from __future__ import annotations

import argparse
import json
import os
import re
import secrets
import sys
import threading
import time
import webbrowser
from datetime import datetime, timezone
from decimal import Decimal
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

from dotenv import dotenv_values

from binance_readonly import BinanceReadOnlyClient, BinanceReadOnlyError
from binance_readonly.auth import AuthStore, LoginRateLimiter
from binance_readonly.client import as_decimal, decimal_text
from binance_readonly.notifications import (
    NotificationManager,
    NotificationStore,
    entry_zone_state,
    select_best_opportunity,
    select_watch_opportunities,
)
from binance_readonly.paper import PaperTradeStore
from binance_readonly.performance import (
    daily_loss_state,
    performance_summary,
    progression_state,
    review_closed_trade,
)
from binance_readonly.realtime import RealtimePriceStream
from binance_readonly.research import CoinResearchError, CoinResearchService
from binance_readonly.signals import SignalStore


FROZEN_APP = bool(getattr(sys, "frozen", False))
BUNDLE_DIR = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)).resolve()
APP_DIR = (Path(sys.executable).resolve().parent if FROZEN_APP else BUNDLE_DIR)
RUNTIME_DIR = Path(os.getenv("BSA_RUNTIME_DIR", str(APP_DIR))).resolve()
STATIC_DIR = BUNDLE_DIR / "static"
CONFIG_FILE = RUNTIME_DIR / "config.json"
TRADED_SYMBOLS_FILE = RUNTIME_DIR / "traded_symbols.json"
LATEST_REPORT = RUNTIME_DIR / "latest_analysis.json"
SIGNAL_DB_FILE = RUNTIME_DIR / "signals.db"
AUTH_DB_FILE = RUNTIME_DIR / "auth.db"
NOTIFICATION_SECRET_FILE = RUNTIME_DIR / "notification_secrets.env"
DEFAULT_ENV_FILE = RUNTIME_DIR / ".env"
HOST = os.getenv("BSA_HOST", "127.0.0.1")
PORT = 8765
PROCESS_STARTED_AT = datetime.now(timezone.utc)
PROCESS_STARTED_MONOTONIC = time.monotonic()
CONFIG_LOCK = threading.Lock()
MONITOR_SYMBOL_PATTERN = re.compile(r"^[A-Z0-9]{2,15}USDT$")


def trusted_hostnames() -> set[str]:
    """Return explicit production hosts together with the local development hosts."""
    hosts = {"127.0.0.1", "localhost", "::1"}
    for raw_host in os.getenv("BSA_TRUSTED_HOSTS", "").split(","):
        candidate = raw_host.strip().lower()
        if not candidate or "/" in candidate or "@" in candidate:
            continue
        try:
            hostname = urlparse(f"//{candidate}").hostname
        except ValueError:
            hostname = None
        if hostname:
            hosts.add(hostname)
    # Optional file next to config.json: one trusted hostname per line
    # (e.g. the public Cloudflare Tunnel domain). Lines starting with # and
    # blank lines are ignored.
    hosts_file = RUNTIME_DIR / "trusted_hosts.txt"
    try:
        for line in hosts_file.read_text(encoding="utf-8").splitlines():
            candidate = line.strip().lower()
            if not candidate or candidate.startswith("#") or "/" in candidate or "@" in candidate:
                continue
            try:
                hostname = urlparse(f"//{candidate}").hostname
            except ValueError:
                hostname = None
            if hostname:
                hosts.add(hostname)
    except OSError:
        pass
    return hosts


def _optional_positive_price(value: object) -> str | None:
    if value in (None, ""):
        return None
    try:
        price = Decimal(str(value))
    except Exception as exc:
        raise ValueError("Alert prices must be valid positive numbers.") from exc
    if not price.is_finite() or price <= 0:
        raise ValueError("Alert prices must be valid positive numbers.")
    return format(price.normalize(), "f")


def _normalize_monitor(item: object) -> dict[str, Any] | None:
    if not isinstance(item, dict):
        return None
    symbol = str(item.get("symbol", "")).strip().upper().replace("/", "")
    if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
        return None
    try:
        alert_above = _optional_positive_price(item.get("alert_above"))
        alert_below = _optional_positive_price(item.get("alert_below"))
        purchase_price = _optional_positive_price(item.get("purchase_price"))
        purchase_amount_usdt = _optional_positive_price(item.get("purchase_amount_usdt"))
    except ValueError:
        return None
    if alert_above and alert_below and Decimal(alert_below) >= Decimal(alert_above):
        return None
    monitor_id = str(item.get("id", "")).strip()
    if not re.fullmatch(r"[A-Za-z0-9_-]{8,64}", monitor_id):
        monitor_id = f"manual-{symbol.lower()}"
    return {
        "id": monitor_id,
        "symbol": symbol,
        "pinned": item.get("pinned") is True,
        "alert_above": alert_above,
        "alert_below": alert_below,
        "purchase_price": purchase_price,
        "purchase_amount_usdt": purchase_amount_usdt,
    }


def save_monitor_preference(
    action: str,
    symbol_value: object,
    *,
    alert_above_value: object = None,
    alert_below_value: object = None,
    purchase_price_value: object = None,
    purchase_amount_value: object = None,
    order_value: object = None,
) -> list[dict[str, Any]]:
    symbol = str(symbol_value or "").strip().upper().replace("/", "")
    if action != "reorder" and not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
        raise ValueError("Use a regular USDT Spot symbol such as XRPUSDT.")
    if action not in {"add", "update", "remove", "pin", "move_up", "move_down", "reorder"}:
        raise ValueError("Unsupported monitor action.")

    alert_above = _optional_positive_price(alert_above_value)
    alert_below = _optional_positive_price(alert_below_value)
    purchase_price = _optional_positive_price(purchase_price_value)
    purchase_amount_usdt = _optional_positive_price(purchase_amount_value)
    if alert_above and alert_below and Decimal(alert_below) >= Decimal(alert_above):
        raise ValueError("The upper alert must be greater than the lower alert.")

    with CONFIG_LOCK:
        try:
            raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except (OSError, ValueError) as exc:
            raise ValueError("The local configuration file is unavailable.") from exc
        if not isinstance(raw, dict):
            raise ValueError("The local configuration file is invalid.")

        normalized_monitors = [
            normalized
            for item in raw.get("monitors", [])
            if (normalized := _normalize_monitor(item)) is not None
        ]
        existing = next(
            (item for item in normalized_monitors if item["symbol"] == symbol),
            None,
        )
        watchlist = [str(item).strip().upper() for item in raw.get("watchlist", [])]
        analysis_symbols = [
            str(item).strip().upper() for item in raw.get("analysis_symbols", [])
        ]

        if action == "add":
            replacement = {
                "id": existing["id"] if existing else secrets.token_urlsafe(12),
                "symbol": symbol,
                "pinned": bool((existing or {}).get("pinned")),
                "alert_above": alert_above,
                "alert_below": alert_below,
                "purchase_price": (existing or {}).get("purchase_price") or purchase_price,
                "purchase_amount_usdt": (existing or {}).get(
                    "purchase_amount_usdt"
                )
                or purchase_amount_usdt,
            }
            if existing:
                monitors = [
                    replacement if item["symbol"] == symbol else item
                    for item in normalized_monitors
                ]
            else:
                monitors = [*normalized_monitors, replacement]
            if symbol not in watchlist:
                watchlist.append(symbol)
        elif action == "update":
            if existing is None:
                raise ValueError("The monitored symbol was not found.")
            monitors = [
                {
                    **item,
                    "alert_above": alert_above,
                    "alert_below": alert_below,
                }
                if item["symbol"] == symbol else item
                for item in normalized_monitors
            ]
        elif action == "remove":
            monitors = [item for item in normalized_monitors if item["symbol"] != symbol]
            watchlist = [item for item in watchlist if item != symbol]
        elif action == "pin":
            if existing is None:
                raise ValueError("The monitored symbol was not found.")
            target = {**existing, "pinned": not bool(existing.get("pinned"))}
            others = [item for item in normalized_monitors if item["symbol"] != symbol]
            pinned = [item for item in others if item.get("pinned")]
            unpinned = [item for item in others if not item.get("pinned")]
            monitors = [target, *pinned, *unpinned] if target["pinned"] else [*pinned, target, *unpinned]
        elif action == "reorder":
            # Drag-and-drop reorder: the client sends the full desired order
            # of monitor symbols; pinned rows keep their leading group while
            # the requested order is applied inside each group.
            requested: list[str] = []
            if isinstance(order_value, list):
                for item in order_value:
                    candidate = str(item).strip().upper().replace("/", "")
                    if (
                        MONITOR_SYMBOL_PATTERN.fullmatch(candidate)
                        and candidate not in requested
                    ):
                        requested.append(candidate)
            rank = {name: index for index, name in enumerate(requested)}
            monitors = sorted(
                normalized_monitors,
                key=lambda item: (
                    0 if item.get("pinned") else 1,
                    rank.get(item["symbol"], len(rank)),
                ),
            )
        else:
            if existing is None:
                raise ValueError("The monitored symbol was not found.")
            monitors = list(normalized_monitors)
            group_positions = [
                index
                for index, item in enumerate(monitors)
                if bool(item.get("pinned")) == bool(existing.get("pinned"))
            ]
            current_group_index = next(
                index
                for index, position in enumerate(group_positions)
                if monitors[position]["symbol"] == symbol
            )
            neighbor_group_index = current_group_index + (-1 if action == "move_up" else 1)
            if 0 <= neighbor_group_index < len(group_positions):
                current_position = group_positions[current_group_index]
                neighbor_position = group_positions[neighbor_group_index]
                monitors[current_position], monitors[neighbor_position] = (
                    monitors[neighbor_position],
                    monitors[current_position],
                )

        raw["monitors"] = monitors
        raw["watchlist"] = watchlist
        raw["analysis_symbols"] = analysis_symbols
        temporary = CONFIG_FILE.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        temporary.replace(CONFIG_FILE)
    return monitors


def _normalize_coin_decision(item: object) -> dict[str, Any] | None:
    if not isinstance(item, dict):
        return None
    symbol = str(item.get("symbol", "")).strip().upper().replace("/", "")
    status = str(item.get("status", "")).strip().upper()
    if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol) or status not in {"ACCEPTED", "AVOID"}:
        return None
    return {
        "symbol": symbol,
        "status": status,
        "name": str(item.get("name", ""))[:120],
        "score": max(0, min(100, int(item.get("score", 0) or 0))),
        "updated_at": str(item.get("updated_at", "")),
    }


def save_coin_decision(
    action: str,
    symbol_value: object,
    *,
    name: object = "",
    score: object = 0,
) -> list[dict[str, Any]]:
    symbol = str(symbol_value or "").strip().upper().replace("/", "")
    if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
        raise ValueError("Use a regular USDT Spot symbol such as XRPUSDT.")
    if action not in {"accept", "avoid", "remove"}:
        raise ValueError("Unsupported research decision.")
    try:
        safe_score = max(0, min(100, int(score or 0)))
    except (TypeError, ValueError) as exc:
        raise ValueError("The project score is invalid.") from exc
    with CONFIG_LOCK:
        try:
            raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except (OSError, ValueError) as exc:
            raise ValueError("The local configuration file is unavailable.") from exc
        if not isinstance(raw, dict):
            raise ValueError("The local configuration file is invalid.")
        decisions = [
            normalized
            for item in raw.get("coin_decisions", [])
            if (normalized := _normalize_coin_decision(item)) is not None
            and normalized["symbol"] != symbol
        ]
        if action != "remove":
            decisions.insert(
                0,
                {
                    "symbol": symbol,
                    "status": "ACCEPTED" if action == "accept" else "AVOID",
                    "name": str(name or "")[:120],
                    "score": safe_score,
                    "updated_at": datetime.now(timezone.utc).isoformat(),
                },
            )
        raw["coin_decisions"] = decisions
        temporary = CONFIG_FILE.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        temporary.replace(CONFIG_FILE)
    return decisions


def _load_traded_symbols() -> list[str]:
    """Persistent archive of symbols that ever had real trades on this account."""
    try:
        raw = json.loads(TRADED_SYMBOLS_FILE.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return []
    symbols: list[str] = []
    if isinstance(raw, list):
        for item in raw:
            candidate = str(item).strip().upper().replace("/", "")
            if MONITOR_SYMBOL_PATTERN.fullmatch(candidate) and candidate not in symbols:
                symbols.append(candidate)
    return symbols


def _save_traded_symbols(symbols: list[str]) -> None:
    cleaned: list[str] = []
    for item in symbols:
        candidate = str(item).strip().upper().replace("/", "")
        if MONITOR_SYMBOL_PATTERN.fullmatch(candidate) and candidate not in cleaned:
            cleaned.append(candidate)
    cleaned.sort()
    with CONFIG_LOCK:
        temporary = TRADED_SYMBOLS_FILE.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(cleaned, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
        temporary.replace(TRADED_SYMBOLS_FILE)


def save_favorite(action: str, symbol_value: object) -> list[str]:
    symbol = str(symbol_value or "").strip().upper().replace("/", "")
    if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
        raise ValueError("Use a regular USDT Spot symbol such as XRPUSDT.")
    if action not in {"add", "remove"}:
        raise ValueError("Unsupported favorite action.")
    with CONFIG_LOCK:
        try:
            raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except (OSError, ValueError) as exc:
            raise ValueError("The local configuration file is unavailable.") from exc
        if not isinstance(raw, dict):
            raise ValueError("The local configuration file is invalid.")
        favorites: list[str] = []
        if isinstance(raw.get("favorites", []), list):
            for item in raw.get("favorites", []):
                candidate = str(item).strip().upper().replace("/", "")
                if MONITOR_SYMBOL_PATTERN.fullmatch(candidate) and candidate not in favorites:
                    favorites.append(candidate)
        favorites = [item for item in favorites if item != symbol]
        if action == "add":
            favorites.insert(0, symbol)
        raw["favorites"] = favorites
        temporary = CONFIG_FILE.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        temporary.replace(CONFIG_FILE)
    return favorites


# Assets that are cash-like and never become favorite "coins".
STABLE_ASSETS = {
    "USDT", "USD", "USDC", "FDUSD", "TUSD", "USDP", "DAI",
    "BUSD", "EUR", "TRY", "BRL", "ARS", "GBP", "AEUR", "EURI",
}


def import_activity_favorites(held_assets: dict[str, float] | None = None) -> tuple[list[str], list[str]]:
    """Merge every symbol the account ever traded plus every asset it
    currently holds into the local favorites list.

    Binance exposes no API for the app's starred-pairs preference, so the
    closest faithful equivalent is the owner's real activity: traded symbols
    (archived locally) and currently held balances. Existing favorites keep
    their order; newly imported symbols are appended. Returns the full
    favorites list plus the symbols that were actually added.
    """
    candidates: list[str] = list(_load_traded_symbols())
    if held_assets:
        for asset, amount in held_assets.items():
            clean = str(asset).strip().upper()
            try:
                held = float(amount)
            except (TypeError, ValueError):
                held = 0.0
            if clean in STABLE_ASSETS or held <= 0 or not clean.isalnum():
                continue
            candidate = f"{clean}USDT"
            if MONITOR_SYMBOL_PATTERN.fullmatch(candidate) and candidate not in candidates:
                candidates.append(candidate)
    with CONFIG_LOCK:
        try:
            raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except (OSError, ValueError) as exc:
            raise ValueError("The local configuration file is unavailable.") from exc
        if not isinstance(raw, dict):
            raise ValueError("The local configuration file is invalid.")
        favorites: list[str] = []
        if isinstance(raw.get("favorites", []), list):
            for item in raw.get("favorites", []):
                candidate = str(item).strip().upper().replace("/", "")
                if MONITOR_SYMBOL_PATTERN.fullmatch(candidate) and candidate not in favorites:
                    favorites.append(candidate)
        added = [item for item in candidates if item not in favorites]
        favorites.extend(added)
        raw["favorites"] = favorites
        temporary = CONFIG_FILE.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        temporary.replace(CONFIG_FILE)
    return favorites, added


def reorder_favorites(order_value: object) -> list[str]:
    """Rewrite the favorites list in the owner's chosen order (drag & drop).

    Symbols missing from the requested order are kept at the end so nothing
    is ever lost by an incomplete payload.
    """
    requested: list[str] = []
    if isinstance(order_value, list):
        for item in order_value:
            candidate = str(item).strip().upper().replace("/", "")
            if MONITOR_SYMBOL_PATTERN.fullmatch(candidate) and candidate not in requested:
                requested.append(candidate)
    with CONFIG_LOCK:
        try:
            raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except (OSError, ValueError) as exc:
            raise ValueError("The local configuration file is unavailable.") from exc
        if not isinstance(raw, dict):
            raise ValueError("The local configuration file is invalid.")
        favorites: list[str] = []
        if isinstance(raw.get("favorites", []), list):
            for item in raw.get("favorites", []):
                candidate = str(item).strip().upper().replace("/", "")
                if MONITOR_SYMBOL_PATTERN.fullmatch(candidate) and candidate not in favorites:
                    favorites.append(candidate)
        ordered = [s for s in requested if s in favorites]
        ordered.extend(s for s in favorites if s not in ordered)
        raw["favorites"] = ordered
        temporary = CONFIG_FILE.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        temporary.replace(CONFIG_FILE)
    return ordered


def save_favorite_alert(
    symbol_value: object, above_value: object, below_value: object
) -> dict[str, Any]:
    """Set (or update) the upper/lower price alert for a favorite coin."""
    symbol = str(symbol_value or "").strip().upper().replace("/", "")
    if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
        raise ValueError("Choose a valid coin symbol like BTCUSDT.")
    above = _optional_positive_price(above_value)
    below = _optional_positive_price(below_value)
    if above is None and below is None:
        raise ValueError("Set at least one alert price (upper or lower).")
    if above and below and Decimal(below) >= Decimal(above):
        raise ValueError("The upper alert must be greater than the lower alert.")
    with CONFIG_LOCK:
        try:
            raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except (OSError, ValueError) as exc:
            raise ValueError("The local configuration file is unavailable.") from exc
        if not isinstance(raw, dict):
            raise ValueError("The local configuration file is invalid.")
        alerts = raw.get("favorite_alerts")
        if not isinstance(alerts, dict):
            alerts = {}
        alerts[symbol] = {"above": above, "below": below}
        raw["favorite_alerts"] = alerts
        temporary = CONFIG_FILE.with_suffix(".tmp")
        temporary.write_text(
            json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        temporary.replace(CONFIG_FILE)
    return {"symbol": symbol, "above": above, "below": below}


def clear_favorite_alert(symbol_value: object) -> dict[str, Any]:
    """Remove the price alert of a favorite coin (kept as a favorite)."""
    symbol = str(symbol_value or "").strip().upper().replace("/", "")
    if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
        raise ValueError("Choose a valid coin symbol like BTCUSDT.")
    removed = False
    with CONFIG_LOCK:
        try:
            raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
        except (OSError, ValueError) as exc:
            raise ValueError("The local configuration file is unavailable.") from exc
        if not isinstance(raw, dict):
            raise ValueError("The local configuration file is invalid.")
        alerts = raw.get("favorite_alerts")
        if isinstance(alerts, dict) and symbol in alerts:
            alerts.pop(symbol)
            raw["favorite_alerts"] = alerts
            temporary = CONFIG_FILE.with_suffix(".tmp")
            temporary.write_text(
                json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
            )
            temporary.replace(CONFIG_FILE)
            removed = True
    return {"symbol": symbol, "removed": removed}


def _alert_chime() -> None:
    """Short system chime when a favorite price alert fires (Windows only)."""
    if os.name != "nt":
        return
    try:
        import winsound

        winsound.PlaySound(
            "SystemExclamation", winsound.SND_ALIAS | winsound.SND_ASYNC
        )
    except Exception:
        pass


def _process_favorite_alerts(
    market: list[dict[str, Any]], config: dict[str, Any], now: datetime
) -> dict[str, int]:
    """Evaluate favorite price alerts; chime only on a real delivery.

    A threshold stays "reached" on every later tick, so chiming on
    ``triggered`` would repeat the sound endlessly — chime only when at
    least one notification was actually delivered in this batch.
    """
    try:
        summary = NOTIFICATION_MANAGER.process_favorite_alerts(
            market, config.get("favorite_alerts", {}), config, now=now
        )
    except Exception:
        return {"sent": 0, "failed": 0, "skipped": 0, "triggered": 0}
    if summary.get("sent"):
        _alert_chime()
    return summary


def runtime_snapshot() -> dict[str, Any]:
    return {
        "started_at": PROCESS_STARTED_AT.isoformat(),
        "uptime_seconds": max(0, int(time.monotonic() - PROCESS_STARTED_MONOTONIC)),
        "automatic_restart_supported": True,
    }


def load_config() -> dict[str, Any]:
    defaults: dict[str, Any] = {
        "refresh_seconds": 120,
        "watchlist": ["BTCUSDT", "ETHUSDT", "BNBUSDT", "ADAUSDT", "XPLUSDT", "PENGUUSDT"],
        "analysis_symbols": ["ADAUSDT", "XPLUSDT", "PENGUUSDT"],
        "analysis_timeframes": ["1h", "4h", "1d"],
        "risk_percent": 0.5,
        "max_allocation_percent": 20,
        "auto_scan_enabled": True,
        "auto_scan_count": 5,
        "min_quote_volume_usdt": 5000000,
        "min_signal_score": 0.65,
        "min_confidence_percent": 79,
        "min_volume_ratio": 1.2,
        "min_market_breadth_percent": 50,
        "max_daily_move_percent": 12,
        "max_stop_risk_percent": 7,
        "min_reward_risk": 2,
        "signal_ttl_minutes": 30,
        "paper_trading_enabled": True,
        "paper_notional_usdt": 100,
        "paper_fee_bps": 10,
        "paper_slippage_bps": 5,
        "paper_move_stop_to_break_even": True,
        "secure_session_cookie": False,
        "notifications_enabled": True,
        "windows_notifications_enabled": True,
        "telegram_notifications_enabled": False,
        "notification_min_score": 0.70,
        "notification_min_confidence_percent": 80,
        "notification_max_stop_risk_percent": 7,
        "notification_min_reward_risk": 2,
        "entry_zone_alerts_enabled": True,
        "entry_zone_near_percent": 0.5,
        "beginner_mode": True,
        "beginner_min_market_cap_usdt": 100000000,
        "beginner_min_age_days": 180,
        "paper_daily_loss_limit_percent": 2,
        "paper_trades_before_live": 20,
        "favorites": [],
        "favorite_alerts": {},
        "monitors": [],
        "coin_decisions": [],
    }
    try:
        loaded = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return defaults
    if not isinstance(loaded, dict):
        return defaults
    refresh = loaded.get("refresh_seconds", defaults["refresh_seconds"])
    watchlist = loaded.get("watchlist", defaults["watchlist"])
    analysis_symbols = loaded.get("analysis_symbols", defaults["analysis_symbols"])
    analysis_timeframes = loaded.get("analysis_timeframes", defaults["analysis_timeframes"])
    risk_percent = loaded.get("risk_percent", defaults["risk_percent"])
    max_allocation = loaded.get("max_allocation_percent", defaults["max_allocation_percent"])
    auto_scan_enabled = loaded.get("auto_scan_enabled", defaults["auto_scan_enabled"])
    auto_scan_count = loaded.get("auto_scan_count", defaults["auto_scan_count"])
    min_quote_volume = loaded.get("min_quote_volume_usdt", defaults["min_quote_volume_usdt"])
    min_signal_score = loaded.get("min_signal_score", defaults["min_signal_score"])
    min_confidence = loaded.get("min_confidence_percent", defaults["min_confidence_percent"])
    min_volume_ratio = loaded.get("min_volume_ratio", defaults["min_volume_ratio"])
    min_market_breadth = loaded.get(
        "min_market_breadth_percent", defaults["min_market_breadth_percent"]
    )
    max_daily_move = loaded.get("max_daily_move_percent", defaults["max_daily_move_percent"])
    max_stop_risk = loaded.get("max_stop_risk_percent", defaults["max_stop_risk_percent"])
    min_reward_risk = loaded.get("min_reward_risk", defaults["min_reward_risk"])
    signal_ttl_minutes = loaded.get("signal_ttl_minutes", defaults["signal_ttl_minutes"])
    paper_enabled = loaded.get("paper_trading_enabled", defaults["paper_trading_enabled"])
    paper_notional = loaded.get("paper_notional_usdt", defaults["paper_notional_usdt"])
    paper_fee_bps = loaded.get("paper_fee_bps", defaults["paper_fee_bps"])
    paper_slippage_bps = loaded.get("paper_slippage_bps", defaults["paper_slippage_bps"])
    paper_break_even = loaded.get(
        "paper_move_stop_to_break_even", defaults["paper_move_stop_to_break_even"]
    )
    secure_session_cookie = loaded.get(
        "secure_session_cookie", defaults["secure_session_cookie"]
    )
    notifications_enabled = loaded.get(
        "notifications_enabled", defaults["notifications_enabled"]
    )
    windows_notifications = loaded.get(
        "windows_notifications_enabled", defaults["windows_notifications_enabled"]
    )
    telegram_notifications = loaded.get(
        "telegram_notifications_enabled", defaults["telegram_notifications_enabled"]
    )
    notification_min_score = loaded.get(
        "notification_min_score", defaults["notification_min_score"]
    )
    notification_min_confidence = loaded.get(
        "notification_min_confidence_percent",
        defaults["notification_min_confidence_percent"],
    )
    notification_max_stop_risk = loaded.get(
        "notification_max_stop_risk_percent",
        defaults["notification_max_stop_risk_percent"],
    )
    notification_min_reward_risk = loaded.get(
        "notification_min_reward_risk", defaults["notification_min_reward_risk"]
    )
    entry_zone_alerts_enabled = loaded.get(
        "entry_zone_alerts_enabled", defaults["entry_zone_alerts_enabled"]
    )
    entry_zone_near_percent = loaded.get(
        "entry_zone_near_percent", defaults["entry_zone_near_percent"]
    )
    beginner_mode = loaded.get("beginner_mode", defaults["beginner_mode"])
    beginner_min_market_cap = loaded.get(
        "beginner_min_market_cap_usdt", defaults["beginner_min_market_cap_usdt"]
    )
    beginner_min_age_days = loaded.get(
        "beginner_min_age_days", defaults["beginner_min_age_days"]
    )
    paper_daily_loss_limit = loaded.get(
        "paper_daily_loss_limit_percent", defaults["paper_daily_loss_limit_percent"]
    )
    paper_trades_before_live = loaded.get(
        "paper_trades_before_live", defaults["paper_trades_before_live"]
    )
    monitors = [
        normalized
        for item in loaded.get("monitors", [])
        if (normalized := _normalize_monitor(item)) is not None
    ] if isinstance(loaded.get("monitors", []), list) else []
    favorites: list[str] = []
    if isinstance(loaded.get("favorites", []), list):
        for item in loaded.get("favorites", []):
            symbol = str(item).strip().upper().replace("/", "")
            if MONITOR_SYMBOL_PATTERN.fullmatch(symbol) and symbol not in favorites:
                favorites.append(symbol)
    coin_decisions = [
        normalized
        for item in loaded.get("coin_decisions", [])
        if (normalized := _normalize_coin_decision(item)) is not None
    ] if isinstance(loaded.get("coin_decisions", []), list) else []
    favorite_alerts: dict[str, dict[str, Any]] = {}
    loaded_alerts = loaded.get("favorite_alerts", {})
    if isinstance(loaded_alerts, dict):
        for key, value in loaded_alerts.items():
            symbol = str(key).strip().upper().replace("/", "")
            if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol) or not isinstance(value, dict):
                continue
            try:
                above = _optional_positive_price(value.get("above"))
                below = _optional_positive_price(value.get("below"))
            except ValueError:
                continue
            if above is None and below is None:
                continue
            if above and below and Decimal(below) >= Decimal(above):
                continue
            favorite_alerts[symbol] = {"above": above, "below": below}
    if not isinstance(refresh, int) or refresh < 10:
        refresh = defaults["refresh_seconds"]
    if not isinstance(watchlist, list):
        watchlist = defaults["watchlist"]
    if not isinstance(analysis_symbols, list):
        analysis_symbols = defaults["analysis_symbols"]
    if not isinstance(analysis_timeframes, list):
        analysis_timeframes = defaults["analysis_timeframes"]
    if not isinstance(risk_percent, (int, float)) or not 0.1 <= risk_percent <= 2:
        risk_percent = defaults["risk_percent"]
    if not isinstance(max_allocation, (int, float)) or not 5 <= max_allocation <= 50:
        max_allocation = defaults["max_allocation_percent"]
    if not isinstance(auto_scan_enabled, bool):
        auto_scan_enabled = defaults["auto_scan_enabled"]
    if not isinstance(auto_scan_count, int) or not 1 <= auto_scan_count <= 100:
        auto_scan_count = defaults["auto_scan_count"]
    if not isinstance(min_quote_volume, (int, float)) or min_quote_volume < 1000000:
        min_quote_volume = defaults["min_quote_volume_usdt"]
    if not isinstance(min_signal_score, (int, float)) or not 0.5 <= min_signal_score <= 1:
        min_signal_score = defaults["min_signal_score"]
    if not isinstance(min_confidence, (int, float)) or not 50 <= min_confidence <= 95:
        min_confidence = defaults["min_confidence_percent"]
    if not isinstance(min_volume_ratio, (int, float)) or not 1 <= min_volume_ratio <= 3:
        min_volume_ratio = defaults["min_volume_ratio"]
    if not isinstance(min_market_breadth, (int, float)) or not 40 <= min_market_breadth <= 80:
        min_market_breadth = defaults["min_market_breadth_percent"]
    if not isinstance(max_daily_move, (int, float)) or not 5 <= max_daily_move <= 20:
        max_daily_move = defaults["max_daily_move_percent"]
    if not isinstance(max_stop_risk, (int, float)) or not 1 <= max_stop_risk <= 10:
        max_stop_risk = defaults["max_stop_risk_percent"]
    if not isinstance(min_reward_risk, (int, float)) or not 1.5 <= min_reward_risk <= 4:
        min_reward_risk = defaults["min_reward_risk"]
    if not isinstance(signal_ttl_minutes, int) or not 2 <= signal_ttl_minutes <= 240:
        signal_ttl_minutes = defaults["signal_ttl_minutes"]
    if not isinstance(paper_enabled, bool):
        paper_enabled = defaults["paper_trading_enabled"]
    if not isinstance(paper_notional, (int, float)) or not 10 <= paper_notional <= 100000:
        paper_notional = defaults["paper_notional_usdt"]
    if not isinstance(paper_fee_bps, (int, float)) or not 0 <= paper_fee_bps <= 100:
        paper_fee_bps = defaults["paper_fee_bps"]
    if not isinstance(paper_slippage_bps, (int, float)) or not 0 <= paper_slippage_bps <= 200:
        paper_slippage_bps = defaults["paper_slippage_bps"]
    if not isinstance(paper_break_even, bool):
        paper_break_even = defaults["paper_move_stop_to_break_even"]
    if not isinstance(secure_session_cookie, bool):
        secure_session_cookie = defaults["secure_session_cookie"]
    if not isinstance(notifications_enabled, bool):
        notifications_enabled = defaults["notifications_enabled"]
    if not isinstance(windows_notifications, bool):
        windows_notifications = defaults["windows_notifications_enabled"]
    if not isinstance(telegram_notifications, bool):
        telegram_notifications = defaults["telegram_notifications_enabled"]
    if not isinstance(notification_min_score, (int, float)) or not 0.65 <= notification_min_score <= 1:
        notification_min_score = defaults["notification_min_score"]
    if not isinstance(notification_min_confidence, (int, float)) or not 79 <= notification_min_confidence <= 99:
        notification_min_confidence = defaults["notification_min_confidence_percent"]
    if not isinstance(notification_max_stop_risk, (int, float)) or not 1 <= notification_max_stop_risk <= 7:
        notification_max_stop_risk = defaults["notification_max_stop_risk_percent"]
    if not isinstance(notification_min_reward_risk, (int, float)) or not 1.5 <= notification_min_reward_risk <= 4:
        notification_min_reward_risk = defaults["notification_min_reward_risk"]
    if not isinstance(entry_zone_alerts_enabled, bool):
        entry_zone_alerts_enabled = defaults["entry_zone_alerts_enabled"]
    if not isinstance(entry_zone_near_percent, (int, float)) or not 0.1 <= entry_zone_near_percent <= 3:
        entry_zone_near_percent = defaults["entry_zone_near_percent"]
    if not isinstance(beginner_mode, bool):
        beginner_mode = defaults["beginner_mode"]
    if not isinstance(beginner_min_market_cap, (int, float)) or not 1000000 <= beginner_min_market_cap <= 10000000000:
        beginner_min_market_cap = defaults["beginner_min_market_cap_usdt"]
    if not isinstance(beginner_min_age_days, int) or not 0 <= beginner_min_age_days <= 730:
        beginner_min_age_days = defaults["beginner_min_age_days"]
    if not isinstance(paper_daily_loss_limit, (int, float)) or not 0.5 <= paper_daily_loss_limit <= 10:
        paper_daily_loss_limit = defaults["paper_daily_loss_limit_percent"]
    if not isinstance(paper_trades_before_live, int) or not 1 <= paper_trades_before_live <= 100:
        paper_trades_before_live = defaults["paper_trades_before_live"]
    return {
        "refresh_seconds": refresh,
        "watchlist": [str(item) for item in watchlist],
        "analysis_symbols": [str(item) for item in analysis_symbols],
        "analysis_timeframes": [str(item) for item in analysis_timeframes],
        "risk_percent": risk_percent,
        "max_allocation_percent": max_allocation,
        "auto_scan_enabled": auto_scan_enabled,
        "auto_scan_count": auto_scan_count,
        "min_quote_volume_usdt": min_quote_volume,
        "min_signal_score": min_signal_score,
        "min_confidence_percent": min_confidence,
        "min_volume_ratio": min_volume_ratio,
        "min_market_breadth_percent": min_market_breadth,
        "max_daily_move_percent": max_daily_move,
        "max_stop_risk_percent": max_stop_risk,
        "min_reward_risk": min_reward_risk,
        "signal_ttl_minutes": signal_ttl_minutes,
        "paper_trading_enabled": paper_enabled,
        "paper_notional_usdt": paper_notional,
        "paper_fee_bps": paper_fee_bps,
        "paper_slippage_bps": paper_slippage_bps,
        "paper_move_stop_to_break_even": paper_break_even,
        "secure_session_cookie": secure_session_cookie,
        "notifications_enabled": notifications_enabled,
        "windows_notifications_enabled": windows_notifications,
        "telegram_notifications_enabled": telegram_notifications,
        "notification_min_score": notification_min_score,
        "notification_min_confidence_percent": notification_min_confidence,
        "notification_max_stop_risk_percent": notification_max_stop_risk,
        "notification_min_reward_risk": notification_min_reward_risk,
        "entry_zone_alerts_enabled": entry_zone_alerts_enabled,
        "entry_zone_near_percent": entry_zone_near_percent,
        "beginner_mode": beginner_mode,
        "beginner_min_market_cap_usdt": beginner_min_market_cap,
        "beginner_min_age_days": beginner_min_age_days,
        "paper_daily_loss_limit_percent": paper_daily_loss_limit,
        "paper_trades_before_live": paper_trades_before_live,
        "favorites": favorites,
        "favorite_alerts": favorite_alerts,
        "monitors": monitors,
        "coin_decisions": coin_decisions,
    }


def load_client() -> BinanceReadOnlyClient:
    configured = os.getenv("BINANCE_ENV_FILE")
    env_file = Path(configured) if configured else DEFAULT_ENV_FILE
    if not env_file.is_file():
        local_env = APP_DIR / ".env"
        env_file = local_env if local_env.is_file() else env_file
    if not env_file.is_file():
        raise BinanceReadOnlyError(f"The .env file was not found at {env_file}.")

    values = dotenv_values(env_file)
    api_key = (values.get("BINANCE_API_KEY") or "").strip()
    api_secret = (values.get("BINANCE_API_SECRET") or "").strip()
    return BinanceReadOnlyClient(
        api_key, api_secret, proxy=_load_binance_proxy()
    )


def _binance_env_file() -> Path | None:
    configured = os.getenv("BINANCE_ENV_FILE")
    env_file = Path(configured) if configured else DEFAULT_ENV_FILE
    if not env_file.is_file():
        local_env = APP_DIR / ".env"
        env_file = local_env if local_env.is_file() else env_file
    return env_file if env_file.is_file() else None


def _load_binance_proxy() -> str | None:
    """Optional BINANCE_PROXY from the same .env that holds the API keys."""
    env_file = _binance_env_file()
    if env_file is None:
        return None
    try:
        values = dotenv_values(env_file)
    except Exception:
        return None
    return (values.get("BINANCE_PROXY") or "").strip() or None


def build_monitor_positions(
    client: BinanceReadOnlyClient,
    monitors: list[dict[str, Any]],
    balances: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
    """Return stable, read-only holding data for the monitored symbols."""
    balances_by_asset = {
        str(item.get("asset", "")).strip().upper(): item
        for item in balances
        if isinstance(item, dict)
    }
    positions: dict[str, dict[str, Any]] = {}
    for monitor in monitors:
        symbol = str(monitor.get("symbol", "")).strip().upper()
        if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
            continue
        balance = balances_by_asset.get(symbol[:-4], {})
        quantity = as_decimal(balance.get("total"))
        position: dict[str, Any] = {
            "current_quantity": decimal_text(quantity),
            "average_cost": None,
            "cost_basis_complete": False,
        }
        if quantity <= 0:
            positions[symbol] = position
            continue
        current_price = as_decimal(monitor.get("current_price"))
        if current_price <= 0:
            position["error"] = True
            positions[symbol] = position
            continue
        try:
            cycle = client.trade_cycle_summary(
                symbol,
                client.recent_trades(symbol),
                current_price=current_price,
                current_quantity=quantity,
            )
            summary = cycle.get("position", {})
            if isinstance(summary, dict):
                position.update(
                    {
                        "current_quantity": summary.get(
                            "current_quantity", position["current_quantity"]
                        ),
                        "average_cost": summary.get("average_cost"),
                        "cost_basis_complete": summary.get(
                            "cost_basis_complete", False
                        ) is True,
                    }
                )
        except (BinanceReadOnlyError, ValueError, OSError):
            position["error"] = True
        positions[symbol] = position
    return positions


class DashboardCache:
    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._payload: dict[str, Any] | None = None
        self._created_at = 0.0
        self._my_trades: list[dict[str, Any]] = []
        self._my_trades_at = 0.0
        self._my_trades_error: str | None = None

    def _load_my_trades(self, config: dict[str, Any]) -> None:
        """Refresh actual buy/sell round trips at most once per ten minutes."""
        now = time.time()
        if now - self._my_trades_at < 600:
            return
        self._my_trades_at = now
        symbols: list[str] = []
        for symbol in (
            list(config.get("watchlist", []))
            + [str(item.get("symbol", "")) for item in config.get("monitors", [])]
            + list(config.get("favorites", []))
        ):
            clean = str(symbol).strip().upper()
            if MONITOR_SYMBOL_PATTERN.fullmatch(clean) and clean not in symbols:
                symbols.append(clean)
        rows: list[dict[str, Any]] = []
        try:
            client = load_client()
            permissions = client.permission_status()
            client.assert_read_only(permissions)
            for archived in _load_traded_symbols():
                if archived not in symbols:
                    symbols.append(archived)
            # Auto-include every asset the account currently holds so the
            # trade log always covers real open positions, even for symbols
            # that are not on the watchlist, monitors, or favorites.
            stable_assets = {
                "USDT", "USD", "USDC", "FDUSD", "TUSD", "USDP", "DAI",
                "BUSD", "EUR", "TRY", "BRL", "ARS", "GBP", "AEUR", "EURI",
            }
            held_assets: dict[str, float] | None = None
            try:
                balances = client.account_balances()
                held_assets = {}
                for item in balances:
                    asset = str(item.get("asset", "")).strip().upper()
                    try:
                        held_assets[asset] = float(item.get("free", 0) or 0) + float(
                            item.get("locked", 0) or 0
                        )
                    except (TypeError, ValueError):
                        held_assets[asset] = 0.0
            except Exception:
                balances = []
            for item in balances:
                asset = str(item.get("asset", "")).strip().upper()
                if asset in stable_assets or not asset.isalnum():
                    continue
                try:
                    held = float(item.get("free", 0) or 0) + float(
                        item.get("locked", 0) or 0
                    )
                except (TypeError, ValueError):
                    continue
                if held <= 0:
                    continue
                candidate = f"{asset}USDT"
                if (
                    MONITOR_SYMBOL_PATTERN.fullmatch(candidate)
                    and candidate not in symbols
                ):
                    symbols.append(candidate)
            for symbol in symbols:
                base_asset = symbol[:-4]
                if base_asset in stable_assets:
                    continue
                try:
                    trades = client.recent_trades(symbol, 1000)
                except Exception:
                    continue
                if not trades:
                    continue
                block = BinanceReadOnlyClient.trade_round_trips(symbol, trades)
                try:
                    block["current_price"] = str(client.ticker_price(symbol))
                except Exception:
                    block["current_price"] = None
                # The balance API is the source of truth for whether a
                # position exists: FIFO residuals smaller than the spendable
                # balance are commission-rounding dust (or migration debris)
                # and must not surface as fake open positions.
                if held_assets is not None:
                    held_qty = held_assets.get(symbol[:-4], 0.0)
                    if block.get("open_position") is not None and held_qty <= 0:
                        block["open_position"] = None
                        block["dust_reconciled"] = True
                rows.append(block)
            discovered = [row["symbol"] for row in rows if row.get("symbol")]
            archived = _load_traded_symbols()
            new_symbols = [s for s in discovered if s not in archived]
            if new_symbols:
                try:
                    _save_traded_symbols(archived + new_symbols)
                except OSError:
                    pass
            self._my_trades = rows
            self._my_trades_error = None
        except Exception as exc:
            self._my_trades_error = str(exc)

    def get(self, force: bool = False) -> dict[str, Any]:
        config = load_config()
        max_age = config["refresh_seconds"]
        with self._lock:
            if not force and self._payload is not None and time.time() - self._created_at < max_age:
                return self._payload
            client = load_client()
            price_monitor_symbols = {
                str(item.get("symbol", "")) for item in config["monitors"]
            }
            decision_analysis_symbols = [
                symbol
                for symbol in config["analysis_symbols"]
                if symbol not in price_monitor_symbols
            ]
            payload = client.dashboard(
                config["watchlist"],
                analysis_symbols=decision_analysis_symbols,
                analysis_timeframes=config["analysis_timeframes"],
                risk_percent=Decimal(str(config["risk_percent"])),
                max_allocation_percent=Decimal(str(config["max_allocation_percent"])),
                auto_scan_enabled=config["auto_scan_enabled"],
                auto_scan_count=config["auto_scan_count"],
                min_quote_volume_usdt=Decimal(str(config["min_quote_volume_usdt"])),
                min_signal_score=Decimal(str(config["min_signal_score"])),
                min_confidence_percent=Decimal(str(config["min_confidence_percent"])),
                min_volume_ratio=Decimal(str(config["min_volume_ratio"])),
                min_market_breadth_percent=Decimal(
                    str(config["min_market_breadth_percent"])
                ),
                max_daily_move_percent=Decimal(str(config["max_daily_move_percent"])),
                max_stop_risk_percent=Decimal(str(config["max_stop_risk_percent"])),
                min_reward_risk=Decimal(str(config["min_reward_risk"])),
            )
            payload["refresh_seconds"] = config["refresh_seconds"]
            payload["coin_decisions"] = config["coin_decisions"]
            live_snapshot = REALTIME_STREAM.snapshot()
            live_prices = live_snapshot.get("prices", {})
            payload["monitors"] = self._enrich_monitors(
                config["monitors"],
                payload.get("market", []),
                live_prices,
            )
            payload["monitor_positions"] = build_monitor_positions(
                client,
                payload["monitors"],
                payload.get("balances", []),
            )
            payload["realtime"] = live_snapshot
            try:
                signal_now = datetime.fromisoformat(str(payload.get("updated_at")))
            except ValueError:
                signal_now = datetime.now(timezone.utc)
            gatekeeper = make_beginner_gatekeeper(config, payload.get("market", []))
            if gatekeeper is not None:
                # Pre-warm public profile lookups outside the store lock.
                for item in payload.get("analysis", []):
                    if item.get("decision") == "BUY_SETUP":
                        gatekeeper.verdict(
                            str(item.get("symbol", "")), config, gatekeeper.tickers
                        )
            signal_state = SIGNAL_STORE.process(
                payload.get("analysis", []),
                now=signal_now,
                ttl_minutes=config["signal_ttl_minutes"],
                gatekeeper=gatekeeper,
            )
            if gatekeeper is not None:
                for blocked_item in signal_state.get("blocked_signals", []):
                    verdict = gatekeeper.verdict(
                        str(blocked_item.get("symbol", "")), config, gatekeeper.tickers
                    )
                    blocked_item["detail"] = verdict.get("reason", "")
                    blocked_item["profile"] = verdict.get("profile")
            signal_for_symbol = signal_state.get("signal_for_symbol", {})
            active_by_symbol = {
                str(item.get("symbol", "")): item
                for item in signal_state.get("active_signals", [])
            }
            new_signal_ids = {
                str(event.get("signal_id", ""))
                for event in signal_state.get("events", [])
                if event.get("event") == "NEW_STRONG_SIGNAL"
            }
            for item in payload.get("analysis", []):
                symbol = str(item.get("symbol", ""))
                signal_id = signal_for_symbol.get(symbol)
                item["notification_eligible"] = False
                if signal_id:
                    active_signal = active_by_symbol.get(symbol, {})
                    item["signal"] = {
                        "id": signal_id,
                        "status": "ACTIVE",
                        "expires_at": active_signal.get("expires_at"),
                    }
                    item["notification_eligible"] = signal_id in new_signal_ids
            payload["signal_state"] = signal_state
            payload["beginner_mode"] = config["beginner_mode"]
            entry_near_percent = Decimal(str(config["entry_zone_near_percent"]))
            payload["entry_zone_alerts"] = [
                {
                    "symbol": item.get("symbol"),
                    "state": zone_state,
                    "current_price": item.get("current_price"),
                    "entry_low": (item.get("levels") or {}).get("entry_low"),
                    "entry_high": (item.get("levels") or {}).get("entry_high"),
                }
                for item in payload.get("analysis", [])
                if (zone_state := entry_zone_state(item, near_percent=entry_near_percent))
                is not None
            ]
            try:
                NOTIFICATION_MANAGER.scan_fresh()
                notification_state = NOTIFICATION_MANAGER.process(
                    payload.get("analysis", []),
                    signal_state,
                    config,
                    now=signal_now,
                )
                price_alert_state = NOTIFICATION_MANAGER.process_price_alerts(
                    payload.get("market", []),
                    config["monitors"],
                    config,
                    now=signal_now,
                )
                for key in ("sent", "failed", "skipped"):
                    notification_state[key] += price_alert_state[key]
                notification_state["price_alerts_triggered"] = price_alert_state[
                    "triggered"
                ]
                favorite_alert_state = _process_favorite_alerts(
                    payload.get("market", []), config, signal_now
                )
                for key in ("sent", "failed", "skipped"):
                    notification_state[key] += favorite_alert_state[key]
                notification_state["favorite_alerts_triggered"] = favorite_alert_state[
                    "triggered"
                ]
                entry_zone_state_counts = NOTIFICATION_MANAGER.process_entry_zone_alerts(
                    payload.get("analysis", []),
                    config,
                    now=signal_now,
                )
                for key in ("sent", "failed", "skipped"):
                    notification_state[key] += entry_zone_state_counts[key]
                notification_state["entry_zone_inside"] = entry_zone_state_counts[
                    "inside"
                ]
                notification_state["entry_zone_near"] = entry_zone_state_counts["near"]
                notification_state.update(NOTIFICATION_MANAGER.public_status(config))
            except Exception:
                notification_state = {
                    "sent": 0,
                    "failed": 1,
                    "skipped": 0,
                    "qualified": 0,
                    "windows_enabled": False,
                    "telegram_requested": False,
                    "telegram_configured": False,
                    "price_alerts_triggered": 0,
                    "favorite_alerts_triggered": 0,
                }
            payload["notification_state"] = notification_state
            recommendation_thresholds = {
                "min_score": Decimal(str(config["notification_min_score"])),
                "min_confidence": Decimal(
                    str(config["notification_min_confidence_percent"])
                ),
                "max_stop_risk": Decimal(
                    str(config["notification_max_stop_risk_percent"])
                ),
                "min_reward_risk": Decimal(
                    str(config["notification_min_reward_risk"])
                ),
            }
            strict_best = select_best_opportunity(
                payload.get("analysis", []),
                **recommendation_thresholds,
            )
            recommendation_source = strict_best
            try:
                current_recommendation_id = NOTIFICATION_STORE.get_meta(
                    "current_recommendation_signal_id"
                )
            except Exception:
                current_recommendation_id = None
            if current_recommendation_id:
                current_source = next(
                    (
                        item
                        for item in payload.get("analysis", [])
                        if isinstance(item.get("signal"), dict)
                        and item["signal"].get("id") == current_recommendation_id
                    ),
                    None,
                )
                if current_source is not None and select_best_opportunity(
                    [current_source], **recommendation_thresholds
                ) is not None:
                    recommendation_source = current_source
            if recommendation_source is None:
                recommendation = {"status": "NONE"}
            else:
                recommendation = {
                    "status": "RECOMMENDED",
                    "symbol": recommendation_source.get("symbol"),
                    "current_price": recommendation_source.get("current_price"),
                    "combined_score": recommendation_source.get("combined_score"),
                    "confidence_percent": recommendation_source.get(
                        "confidence_percent"
                    ),
                    "levels": recommendation_source.get("levels"),
                    "risk": recommendation_source.get("risk"),
                    "signal": recommendation_source.get("signal"),
                }
            payload["recommendation"] = recommendation
            payload["opportunity_options"] = select_watch_opportunities(
                payload.get("analysis", []),
                exclude_symbol=(
                    str(recommendation_source.get("symbol"))
                    if recommendation_source is not None
                    else None
                ),
                limit=3,
                min_readiness=65,
                **recommendation_thresholds,
            )
            paper_state = PAPER_STORE.process(
                payload.get("analysis", []),
                signal_state,
                now=signal_now,
                enabled=config["paper_trading_enabled"],
                notional_usdt=Decimal(str(config["paper_notional_usdt"])),
                fee_bps=Decimal(str(config["paper_fee_bps"])),
                slippage_bps=Decimal(str(config["paper_slippage_bps"])),
                move_stop_to_break_even=config["paper_move_stop_to_break_even"],
            )
            payload["paper_state"] = paper_state
            payload["favorites"] = list(config["favorites"])
            payload["favorite_alerts"] = dict(config.get("favorite_alerts", {}))
            try:
                payload["favorite_alerts_history"] = NOTIFICATION_STORE.recent_deliveries(
                    "PRICEFAV:", 50
                )
            except Exception:
                payload["favorite_alerts_history"] = []
            self._load_my_trades(config)
            payload["my_trades"] = {
                "updated_at": datetime.fromtimestamp(
                    self._my_trades_at, tz=timezone.utc
                ).isoformat(),
                "symbols": self._my_trades,
                "error": self._my_trades_error,
            }
            try:
                daily_summary_state = NOTIFICATION_MANAGER.process_daily_portfolio_summary(
                    self._my_trades,
                    config,
                    now=datetime.now(timezone.utc),
                )
                notification_state["daily_positions"] = daily_summary_state[
                    "positions"
                ]
            except Exception:
                notification_state["daily_positions"] = 0
            loss_state = daily_loss_state(
                paper_state.get("recent_closed_trades", []),
                paper_state.get("open_trades", []),
                limit_percent=Decimal(str(config["paper_daily_loss_limit_percent"])),
                notional_usdt=Decimal(str(config["paper_notional_usdt"])),
                today=signal_now,
            )
            if loss_state["halted"]:
                halt_result = NOTIFICATION_MANAGER.send_once(
                    f"DAILYHALT:{loss_state['date']}",
                    "توقف التداول الورقي اليوم",
                    (
                        f"خسارتك اليوم {loss_state['loss_percent']}% وصلت حدك "
                        f"{loss_state['limit_percent']}%. قاعدة المبتدئ: توقف الآن وارجع غدًا — "
                        "السوق ليس مكان الانتقام من الخسارة. لم يُرسل أي أمر."
                    ),
                    config,
                    now=signal_now,
                )
                for key in ("sent", "failed", "skipped"):
                    notification_state[key] += halt_result[key]
            payload["guardrails"] = {
                "daily_loss": loss_state,
                "beginner_mode": config["beginner_mode"],
                "beginner_min_market_cap_usdt": config["beginner_min_market_cap_usdt"],
                "beginner_min_age_days": config["beginner_min_age_days"],
            }
            payload["progression"] = progression_state(
                paper_state.get("summary", {}).get("closed_count", 0),
                required_trades=config["paper_trades_before_live"],
            )
            recent_signals = SIGNAL_STORE.recent(500)
            recent_reviews = [
                {
                    **review_closed_trade(trade),
                    "symbol": trade.get("symbol"),
                    "closed_at": trade.get("closed_at"),
                }
                for trade in paper_state.get("recent_closed_trades", [])[:10]
            ]
            payload["performance"] = performance_summary(
                paper_state.get("recent_closed_trades", []),
                recent_signals,
                now=signal_now,
            )
            payload["performance"]["recent_reviews"] = recent_reviews
            report = {
                "updated_at": payload.get("updated_at"),
                "mode": payload.get("mode"),
                "permissions": payload.get("permissions"),
                "summary": payload.get("summary"),
                "orders": payload.get("orders"),
                "analysis": payload.get("analysis"),
                "analysis_policy": payload.get("analysis_policy"),
                "signal_state": signal_state,
                "notification_state": notification_state,
                "recommendation": recommendation,
                "paper_state": paper_state,
                "monitors": payload["monitors"],
                "guardrails": payload["guardrails"],
                "progression": payload["progression"],
                "performance": payload["performance"],
                "entry_zone_alerts": payload["entry_zone_alerts"],
            }
            try:
                temporary_report = LATEST_REPORT.with_suffix(".tmp")
                temporary_report.write_text(
                    json.dumps(report, ensure_ascii=False, indent=2),
                    encoding="utf-8",
                )
                temporary_report.replace(LATEST_REPORT)
                payload["local_report_saved"] = True
            except OSError:
                payload["local_report_saved"] = False
            self._payload = payload
            self._created_at = time.time()
            return payload

    @staticmethod
    def _enrich_monitors(
        monitors: list[dict[str, Any]],
        market_items: list[dict[str, Any]],
        live_prices: dict[str, dict[str, str]],
    ) -> list[dict[str, Any]]:
        market_by_symbol = {
            str(item.get("symbol", "")): item
            for item in market_items
        }
        return [
            {
                **item,
                "current_price": (
                    live_prices.get(str(item.get("symbol", "")), {}).get("price")
                    or market_by_symbol.get(str(item.get("symbol", "")), {}).get("price")
                ),
                "high_price": market_by_symbol.get(
                    str(item.get("symbol", "")), {}
                ).get("high_price"),
                "low_price": market_by_symbol.get(
                    str(item.get("symbol", "")), {}
                ).get("low_price"),
                "realtime": str(item.get("symbol", "")) in live_prices,
            }
            for item in monitors
        ]

    def patch_after_config_change(self) -> list[dict[str, Any]] | None:
        """Apply a monitors/favorites config change to the cached payload
        without the expensive full dashboard rebuild (klines + market scan),
        so add/edit/reorder/drag actions feel instant. Returns the enriched
        monitors list, or None when no payload is cached yet.
        """
        config = load_config()
        with self._lock:
            if self._payload is None:
                return None
            live_snapshot = REALTIME_STREAM.snapshot()
            live_prices = live_snapshot.get("prices", {})
            self._payload["monitors"] = self._enrich_monitors(
                config["monitors"],
                self._payload.get("market", []),
                live_prices,
            )
            self._payload["favorites"] = list(config["favorites"])
            self._payload["favorite_alerts"] = dict(config.get("favorite_alerts", {}))
            self._payload["realtime"] = live_snapshot
            return list(self._payload["monitors"])

    def invalidate(self) -> None:
        with self._lock:
            self._payload = None
            self._created_at = 0.0


SIGNAL_STORE = SignalStore(SIGNAL_DB_FILE)
PAPER_STORE = PaperTradeStore(SIGNAL_DB_FILE)
NOTIFICATION_STORE = NotificationStore(SIGNAL_DB_FILE)
NOTIFICATION_MANAGER = NotificationManager(NOTIFICATION_STORE, NOTIFICATION_SECRET_FILE)
AUTH_STORE = AuthStore(AUTH_DB_FILE)
LOGIN_LIMITER = LoginRateLimiter()
CACHE = DashboardCache()
RESEARCH_SERVICE = CoinResearchService()


_BEGINNER_PROFILE_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
_BEGINNER_PROFILE_CACHE_SECONDS = 3600


def _beginner_verdict(symbol: str, config: dict[str, Any], tickers: dict[str, Any]) -> dict[str, Any]:
    """Cached beginner-safety verdict for a USDT symbol.

    Blocks very small, very young, or AVOID-verdict projects from strong
    signals. When public research is unavailable the symbol is allowed but
    flagged as unknown so the dashboard can show the uncertainty.
    """
    now = time.time()
    cached = _BEGINNER_PROFILE_CACHE.get(symbol)
    if cached and now - cached[0] < _BEGINNER_PROFILE_CACHE_SECONDS:
        return cached[1]
    profile_summary: dict[str, Any] | None = None
    allowed = True
    reason = ""
    try:
        profile = RESEARCH_SERVICE.profile(symbol, tickers.get(symbol, {}))
        profile_summary = {
            "verdict": profile.get("verdict"),
            "market_cap_usd": profile.get("market_cap_usd"),
            "age_days": profile.get("age_days"),
            "score": profile.get("score"),
        }
        reasons: list[str] = []
        market_cap = as_decimal(profile.get("market_cap_usd"))
        min_cap = Decimal(str(config["beginner_min_market_cap_usdt"]))
        age_days = profile.get("age_days")
        min_age = int(config["beginner_min_age_days"])
        if market_cap > 0 and market_cap < min_cap:
            reasons.append("SMALL_MARKET_CAP")
        if age_days is not None and int(age_days) < min_age:
            reasons.append("VERY_YOUNG_PROJECT")
        if profile.get("verdict") == "AVOID":
            reasons.append("PROJECT_VERDICT_AVOID")
        if reasons:
            allowed = False
            reason = ",".join(reasons)
    except Exception:
        reason = "PROFILE_UNKNOWN"
    result = {"allowed": allowed, "reason": reason, "profile": profile_summary}
    _BEGINNER_PROFILE_CACHE[symbol] = (now, result)
    return result


def make_beginner_gatekeeper(
    config: dict[str, Any], market_items: list[dict[str, Any]]
) -> Any:
    """Build a ``(symbol) -> (allowed, reason)`` gatekeeper, or None when off."""
    if config.get("beginner_mode", True) is not True:
        return None
    tickers = {
        str(item.get("symbol", "")).strip().upper(): item
        for item in market_items
        if isinstance(item, dict)
    }

    def gatekeeper(symbol: str) -> tuple[bool, str]:
        verdict = _beginner_verdict(symbol, config, tickers)
        return verdict["allowed"], verdict["reason"]

    gatekeeper.verdict = _beginner_verdict  # type: ignore[attr-defined]
    gatekeeper.tickers = tickers  # type: ignore[attr-defined]
    return gatekeeper


def realtime_symbols() -> list[str]:
    symbols = {"BTCUSDT", "ETHUSDT"}
    config = load_config()
    symbols.update(str(item.get("symbol", "")) for item in config["monitors"])
    symbols.update(str(item) for item in config["favorites"])
    return sorted(symbols)


def realtime_price_observed(symbol: str, price: str, observed_at: datetime) -> None:
    config = load_config()
    matching = [item for item in config["monitors"] if item.get("symbol") == symbol]
    if matching:
        try:
            NOTIFICATION_MANAGER.process_price_alerts(
                [{"symbol": symbol, "price": price}],
                matching,
                config,
                now=observed_at,
            )
        except Exception:
            pass
    if symbol in config.get("favorite_alerts", {}):
        _process_favorite_alerts(
            [{"symbol": symbol, "price": price}], config, observed_at
        )


REALTIME_STREAM = RealtimePriceStream(realtime_symbols, realtime_price_observed)
try:
    REALTIME_STREAM.set_proxy(_load_binance_proxy())
except Exception:
    pass


class BackgroundScanner:
    def __init__(self, cache: DashboardCache) -> None:
        self._cache = cache
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self._lock = threading.Lock()
        self._running = False
        self._last_attempt_at: str | None = None
        self._last_success_at: str | None = None
        self._last_error: str | None = None

    def start(self) -> None:
        if self._thread is not None and self._thread.is_alive():
            return
        self._stop.clear()
        self._thread = threading.Thread(
            target=self._run,
            name="binance-read-only-scanner",
            daemon=True,
        )
        self._thread.start()

    def stop(self) -> None:
        self._stop.set()
        if self._thread is not None:
            self._thread.join(timeout=5)

    def scan_once(self) -> None:
        attempt = datetime.now(timezone.utc).isoformat()
        with self._lock:
            self._running = True
            self._last_attempt_at = attempt
        try:
            self._cache.get(force=True)
        except BinanceReadOnlyError as exc:
            try:
                NOTIFICATION_MANAGER.scan_failed(load_config())
            except Exception:
                pass
            with self._lock:
                self._last_error = str(exc)
        except Exception as exc:
            try:
                NOTIFICATION_MANAGER.scan_failed(load_config())
            except Exception:
                pass
            with self._lock:
                self._last_error = f"Unexpected background scan error ({type(exc).__name__})."
        else:
            with self._lock:
                self._last_success_at = datetime.now(timezone.utc).isoformat()
                self._last_error = None
        finally:
            with self._lock:
                self._running = False

    def _run(self) -> None:
        while not self._stop.is_set():
            started = time.monotonic()
            self.scan_once()
            interval = load_config()["refresh_seconds"]
            remaining = max(1.0, float(interval) - (time.monotonic() - started))
            self._stop.wait(remaining)

    def snapshot(self) -> dict[str, Any]:
        with self._lock:
            return {
                "running": self._running,
                "thread_alive": self._thread is not None and self._thread.is_alive(),
                "last_attempt_at": self._last_attempt_at,
                "last_success_at": self._last_success_at,
                "last_error": self._last_error,
                "interval_seconds": load_config()["refresh_seconds"],
            }


SCANNER = BackgroundScanner(CACHE)


class DashboardHandler(BaseHTTPRequestHandler):
    server_version = "BinanceReadOnlyDashboard/4.0"
    session_cookie_name = "bsa_session"

    def log_message(self, format: str, *args: object) -> None:
        return

    def _security_headers(self) -> None:
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("X-Frame-Options", "DENY")
        self.send_header("Referrer-Policy", "no-referrer")
        self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
        self.send_header(
            "Content-Security-Policy",
            "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; "
            "img-src 'self' data:; frame-ancestors 'none'",
        )

    def _send_bytes(
        self,
        body: bytes,
        content_type: str,
        status: int = 200,
        extra_headers: dict[str, str] | None = None,
    ) -> None:
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        for name, value in (extra_headers or {}).items():
            self.send_header(name, value)
        self._security_headers()
        self.end_headers()
        self.wfile.write(body)

    def _send_json(
        self,
        payload: dict[str, Any],
        status: int = 200,
        extra_headers: dict[str, str] | None = None,
    ) -> None:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self._send_bytes(body, "application/json; charset=utf-8", status, extra_headers)

    def _cookie_token(self) -> str | None:
        raw_cookie = self.headers.get("Cookie", "")
        if not raw_cookie or len(raw_cookie) > 4096:
            return None
        cookie = SimpleCookie()
        try:
            cookie.load(raw_cookie)
        except Exception:
            return None
        morsel = cookie.get(self.session_cookie_name)
        return morsel.value if morsel is not None else None

    def _session(self) -> tuple[str | None, dict[str, str] | None]:
        token = self._cookie_token()
        return token, AUTH_STORE.get_session(token)

    def _require_auth(self) -> tuple[str, dict[str, str]] | None:
        token, session = self._session()
        if token is None or session is None:
            self._send_json({"ok": False, "error": "Authentication required."}, 401)
            return None
        return token, session

    def _read_json(self) -> dict[str, Any] | None:
        try:
            length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            return None
        if not 1 <= length <= 4096:
            return None
        try:
            payload = json.loads(self.rfile.read(length).decode("utf-8"))
        except (UnicodeDecodeError, ValueError):
            return None
        return payload if isinstance(payload, dict) else None

    def _discard_small_body(self) -> None:
        try:
            length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            return
        if 0 < length <= 4096:
            self.rfile.read(length)

    def _origin_is_safe(self) -> bool:
        origin = self.headers.get("Origin")
        if not origin:
            return True
        parsed = urlparse(origin)
        return parsed.scheme in {"http", "https"} and parsed.netloc == self.headers.get("Host")

    def _host_is_safe(self) -> bool:
        host_header = self.headers.get("Host", "")
        try:
            hostname = urlparse(f"//{host_header}").hostname
        except ValueError:
            return False
        return hostname is not None and hostname.lower() in trusted_hostnames()

    def _session_cookie(self, token: str, max_age: int) -> str:
        parts = [
            f"{self.session_cookie_name}={token}",
            "Path=/",
            "HttpOnly",
            "SameSite=Strict",
            f"Max-Age={max_age}",
        ]
        if load_config()["secure_session_cookie"]:
            parts.append("Secure")
        return "; ".join(parts)

    def _handle_login(self) -> None:
        if not self._origin_is_safe():
            self._send_json({"ok": False, "error": "Request origin was rejected."}, 403)
            return
        payload = self._read_json()
        if payload is None:
            self._send_json({"ok": False, "error": "Invalid login request."}, 400)
            return
        username = str(payload.get("username", "")).strip()
        password = str(payload.get("password", ""))
        client = self.client_address[0]
        retry_after = LOGIN_LIMITER.retry_after(client, username)
        if retry_after:
            self._send_json(
                {"ok": False, "error": "Too many login attempts. Try again later."},
                429,
                {"Retry-After": str(retry_after)},
            )
            return
        if not AUTH_STORE.verify_password(username, password):
            lockout = LOGIN_LIMITER.failure(client, username)
            headers = {"Retry-After": str(lockout)} if lockout else None
            self._send_json({"ok": False, "error": "Invalid username or password."}, 401, headers)
            return
        LOGIN_LIMITER.success(client, username)
        session = AUTH_STORE.create_session(username)
        self._send_json(
            {
                "ok": True,
                "authenticated": True,
                "username": username,
                "csrf_token": session["csrf_token"],
                "expires_at": session["expires_at"],
            },
            extra_headers={
                "Set-Cookie": self._session_cookie(
                    session["token"], AUTH_STORE.session_hours * 60 * 60
                )
            },
        )

    def _handle_register(self) -> None:
        if not self._origin_is_safe():
            self._send_json({"ok": False, "error": "Request origin was rejected."}, 403)
            return
        payload = self._read_json()
        if payload is None:
            self._send_json({"ok": False, "error": "Invalid registration request."}, 400)
            return
        username = str(payload.get("username", "")).strip()
        password = str(payload.get("password", ""))
        try:
            AUTH_STORE.create_user(username, password)
        except ValueError as exc:
            self._send_json({"ok": False, "error": str(exc)}, 400)
            return
        session = AUTH_STORE.create_session(username)
        self._send_json({"ok": True, "authenticated": True, "username": username, "csrf_token": session["csrf_token"]}, 201, {"Set-Cookie": self._session_cookie(session["token"], AUTH_STORE.session_hours * 60 * 60)})

    def _handle_logout(self) -> None:
        if not self._origin_is_safe():
            self._send_json({"ok": False, "error": "Request origin was rejected."}, 403)
            return
        authenticated = self._require_auth()
        if authenticated is None:
            return
        token, session = authenticated
        if not AUTH_STORE.valid_csrf(session, self.headers.get("X-CSRF-Token")):
            self._send_json({"ok": False, "error": "CSRF validation failed."}, 403)
            return
        AUTH_STORE.revoke_session(token)
        self._send_json(
            {"ok": True, "authenticated": False},
            extra_headers={"Set-Cookie": self._session_cookie("", 0)},
        )

    def _handle_change_password(self) -> None:
        if not self._origin_is_safe():
            self._send_json({"ok": False, "error": "Request origin was rejected."}, 403)
            return
        authenticated = self._require_auth()
        if authenticated is None:
            return
        token, session = authenticated
        if not AUTH_STORE.valid_csrf(session, self.headers.get("X-CSRF-Token")):
            self._send_json({"ok": False, "error": "CSRF validation failed."}, 403)
            return
        payload = self._read_json()
        if payload is None:
            self._send_json({"ok": False, "error": "Invalid password request."}, 400)
            return
        username = str(session.get("username", ""))
        current_password = str(payload.get("current_password", ""))
        new_password = str(payload.get("new_password", ""))
        if not AUTH_STORE.verify_password(username, current_password):
            self._send_json({"ok": False, "error": "Current password is incorrect."}, 400)
            return
        try:
            AUTH_STORE.set_password(username, new_password)
        except ValueError as exc:
            self._send_json({"ok": False, "error": str(exc)}, 400)
            return
        self._send_json(
            {"ok": True, "message": "Password changed. Please sign in again."},
            extra_headers={"Set-Cookie": self._session_cookie("", 0)},
        )

    def _handle_monitor_preference(self) -> None:
        if not self._origin_is_safe():
            self._send_json({"ok": False, "error": "Request origin was rejected."}, 403)
            return
        authenticated = self._require_auth()
        if authenticated is None:
            return
        _token, session = authenticated
        if not AUTH_STORE.valid_csrf(session, self.headers.get("X-CSRF-Token")):
            self._send_json({"ok": False, "error": "CSRF validation failed."}, 403)
            return
        payload = self._read_json()
        if payload is None:
            self._send_json({"ok": False, "error": "Invalid monitor request."}, 400)
            return
        try:
            monitors = save_monitor_preference(
                str(payload.get("action", "add")),
                payload.get("symbol"),
                alert_above_value=payload.get("alert_above"),
                alert_below_value=payload.get("alert_below"),
                purchase_price_value=payload.get("purchase_price"),
                purchase_amount_value=payload.get("purchase_amount_usdt"),
                order_value=payload.get("order"),
            )
        except (OSError, ValueError) as exc:
            self._send_json({"ok": False, "error": str(exc)}, 400)
            return
        patched_monitors = CACHE.patch_after_config_change()
        if patched_monitors is not None:
            monitors = patched_monitors
        else:
            CACHE.invalidate()
        self._send_json({"ok": True, "monitors": monitors})

    def _send_static(self, filename: str, content_type: str) -> None:
        path = STATIC_DIR / filename
        try:
            body = path.read_bytes()
        except OSError:
            self._send_json({"ok": False, "error": "Static file not found."}, 404)
            return
        self._send_bytes(body, content_type)

    def _handle_execution_cycle(self, parsed_url: Any) -> None:
        symbol = str(parse_qs(parsed_url.query).get("symbol", [""])[0]).strip().upper()
        if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
            self._send_json({"ok": False, "error": "Invalid USDT Spot symbol."}, 400)
            return
        try:
            client = load_client()
            permissions = client.permission_status()
            client.assert_read_only(permissions)
            trades = client.recent_trades(symbol)
            balances = client.account_balances()
            base_asset = symbol[:-4]
            current_quantity = Decimal("0")
            for item in balances:
                if str(item.get("asset", "")).strip().upper() == base_asset:
                    current_quantity = as_decimal(item.get("free")) + as_decimal(
                        item.get("locked")
                    )
                    break
            current_price = client.ticker_price(symbol)
            summary = client.trade_cycle_summary(
                symbol,
                trades,
                current_price=current_price,
                current_quantity=current_quantity,
            )
        except BinanceReadOnlyError as exc:
            self._send_json({"ok": False, "error": str(exc)}, 502)
            return
        except Exception:
            self._send_json(
                {"ok": False, "error": "Trade history could not be read. No order was sent."},
                500,
            )
            return
        self._send_json({"ok": True, "cycle": summary})

    def _handle_analyze_once(self, parsed_url: Any) -> None:
        symbol = str(parse_qs(parsed_url.query).get("symbol", [""])[0]).strip().upper()
        if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
            self._send_json({"ok": False, "error": "Invalid USDT Spot symbol."}, 400)
            return
        try:
            config = load_config()
            client = load_client()
            payload = client.dashboard(
                [symbol],
                analysis_symbols=[symbol],
                analysis_timeframes=config["analysis_timeframes"],
                risk_percent=Decimal(str(config["risk_percent"])),
                max_allocation_percent=Decimal(str(config["max_allocation_percent"])),
                auto_scan_enabled=False,
                min_quote_volume_usdt=Decimal(str(config["min_quote_volume_usdt"])),
                min_signal_score=Decimal(str(config["min_signal_score"])),
                min_confidence_percent=Decimal(str(config["min_confidence_percent"])),
                min_volume_ratio=Decimal(str(config["min_volume_ratio"])),
                min_market_breadth_percent=Decimal(
                    str(config["min_market_breadth_percent"])
                ),
                max_daily_move_percent=Decimal(str(config["max_daily_move_percent"])),
                max_stop_risk_percent=Decimal(str(config["max_stop_risk_percent"])),
                min_reward_risk=Decimal(str(config["min_reward_risk"])),
                include_account_candidates=False,
            )
            analysis = next(
                (
                    item for item in payload.get("analysis", [])
                    if str(item.get("symbol", "")) == symbol
                ),
                None,
            )
            if analysis is None:
                raise BinanceReadOnlyError("The requested symbol could not be analyzed.")
        except BinanceReadOnlyError as exc:
            self._send_json({"ok": False, "error": str(exc)}, 502)
            return
        except Exception:
            self._send_json(
                {"ok": False, "error": "One-time analysis failed. No order was sent."},
                500,
            )
            return
        self._send_json(
            {
                "ok": True,
                "analysis": analysis,
                "updated_at": payload.get("updated_at"),
                "saved_for_monitoring": False,
            }
        )

    def _handle_price_history(self, parsed_url: Any) -> None:
        symbol = str(parse_qs(parsed_url.query).get("symbol", [""])[0]).strip().upper()
        if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
            self._send_json({"ok": False, "error": "Invalid USDT Spot symbol."}, 400)
            return
        try:
            rows = load_client().klines(symbol, "1h", 48)
            closes = [str(row[4]) for row in rows if isinstance(row, list) and len(row) > 4]
            self._send_json({"ok": True, "symbol": symbol, "closes": closes})
        except BinanceReadOnlyError as exc:
            self._send_json({"ok": False, "error": str(exc)}, 502)
            return

    def _handle_coin_profile(self, parsed_url: Any) -> None:
        symbol = str(parse_qs(parsed_url.query).get("symbol", [""])[0]).strip().upper()
        if not MONITOR_SYMBOL_PATTERN.fullmatch(symbol):
            self._send_json({"ok": False, "error": "Invalid USDT Spot symbol."}, 400)
            return
        try:
            client = load_client()
            permissions = client.permission_status()
            client.assert_read_only(permissions)
            ticker = next(
                (
                    item
                    for item in client.ticker_24hr()
                    if str(item.get("symbol", "")).strip().upper() == symbol
                ),
                None,
            )
            if ticker is None:
                raise CoinResearchError("The symbol is not available on Binance Spot.")
            profile = RESEARCH_SERVICE.profile(symbol, ticker)
        except CoinResearchError as exc:
            self._send_json({"ok": False, "error": str(exc)}, 502)
            return
        except BinanceReadOnlyError as exc:
            self._send_json({"ok": False, "error": str(exc)}, 502)
            return
        except Exception:
            self._send_json(
                {"ok": False, "error": "Coin research failed. No order was sent."},
                500,
            )
            return
        self._send_json({"ok": True, "profile": profile})

    def _handle_coin_decision(self) -> None:
        if not self._origin_is_safe():
            self._send_json({"ok": False, "error": "Request origin was rejected."}, 403)
            return
        authenticated = self._require_auth()
        if authenticated is None:
            return
        _token, session = authenticated
        if not AUTH_STORE.valid_csrf(session, self.headers.get("X-CSRF-Token")):
            self._send_json({"ok": False, "error": "CSRF validation failed."}, 403)
            return
        payload = self._read_json()
        if payload is None:
            self._send_json({"ok": False, "error": "Invalid research decision."}, 400)
            return
        try:
            decisions = save_coin_decision(
                str(payload.get("action", "")),
                payload.get("symbol"),
                name=payload.get("name"),
                score=payload.get("score"),
            )
        except (OSError, ValueError) as exc:
            self._send_json({"ok": False, "error": str(exc)}, 400)
            return
        CACHE.invalidate()
        self._send_json({"ok": True, "coin_decisions": decisions})

    def _handle_favorites(self) -> None:
        if not self._origin_is_safe():
            self._send_json({"ok": False, "error": "Request origin was rejected."}, 403)
            return
        authenticated = self._require_auth()
        if authenticated is None:
            return
        _token, session = authenticated
        if not AUTH_STORE.valid_csrf(session, self.headers.get("X-CSRF-Token")):
            self._send_json({"ok": False, "error": "CSRF validation failed."}, 403)
            return
        payload = self._read_json()
        if payload is None:
            self._send_json({"ok": False, "error": "Invalid favorite request."}, 400)
            return
        try:
            action = str(payload.get("action", "")).strip().lower()
            if action == "import":
                held_assets: dict[str, float] | None = None
                try:
                    balances = load_client().account_balances()
                    held_assets = {}
                    for item in balances:
                        asset = str(item.get("asset", "")).strip().upper()
                        try:
                            held_assets[asset] = float(item.get("free", 0) or 0) + float(
                                item.get("locked", 0) or 0
                            )
                        except (TypeError, ValueError):
                            held_assets[asset] = 0.0
                except Exception:
                    held_assets = None
                favorites, added = import_activity_favorites(held_assets)
                CACHE.patch_after_config_change()
                self._send_json(
                    {"ok": True, "favorites": favorites, "imported": added}
                )
                return
            if action == "reorder":
                favorites = reorder_favorites(payload.get("order"))
                CACHE.patch_after_config_change()
                self._send_json({"ok": True, "favorites": favorites})
                return
            if action == "set_alert":
                alert = save_favorite_alert(
                    payload.get("symbol"),
                    payload.get("above"),
                    payload.get("below"),
                )
                CACHE.patch_after_config_change()
                self._send_json(
                    {
                        "ok": True,
                        "alert": alert,
                        "favorite_alerts": load_config()["favorite_alerts"],
                    }
                )
                return
            if action == "clear_alert":
                result = clear_favorite_alert(payload.get("symbol"))
                CACHE.patch_after_config_change()
                self._send_json(
                    {
                        "ok": True,
                        "removed": result["removed"],
                        "favorite_alerts": load_config()["favorite_alerts"],
                    }
                )
                return
            favorites = save_favorite(
                "add" if payload.get("favorite") is True else "remove",
                payload.get("symbol"),
            )
        except (OSError, ValueError) as exc:
            self._send_json({"ok": False, "error": str(exc)}, 400)
            return
        CACHE.patch_after_config_change()
        self._send_json({"ok": True, "favorites": favorites})

    def do_GET(self) -> None:  # noqa: N802 - required by BaseHTTPRequestHandler
        if not self._host_is_safe():
            self._send_json({"ok": False, "error": "Host header was rejected."}, 421)
            return
        parsed_url = urlparse(self.path)
        path = parsed_url.path
        if path == "/":
            self._send_static("index.html", "text/html; charset=utf-8")
            return
        if path == "/app.js":
            self._send_static("app.js", "application/javascript; charset=utf-8")
            return
        if path == "/style.css":
            self._send_static("style.css", "text/css; charset=utf-8")
            return
        if path == "/api/auth/status":
            _token, session = self._session()
            self._send_json(
                {
                    "ok": True,
                    "configured": AUTH_STORE.configured(),
                    "authenticated": session is not None,
                    "username": session.get("username") if session else None,
                    "expires_at": session.get("expires_at") if session else None,
                    "csrf_token": session.get("csrf_hash") if session else None,
                }
            )
            return
        if path.startswith("/api/") and self._require_auth() is None:
            return
        if path == "/api/health":
            self._send_json(
                {
                    "ok": True,
                    "mode": "READ_ONLY",
                    "scanner": SCANNER.snapshot(),
                    "realtime": REALTIME_STREAM.snapshot(),
                    "runtime": runtime_snapshot(),
                }
            )
            return
        if path == "/api/live-prices":
            self._send_json({"ok": True, "realtime": REALTIME_STREAM.snapshot()})
            return
        if path == "/api/execution-cycle":
            self._handle_execution_cycle(parsed_url)
            return
        if path == "/api/analyze-once":
            self._handle_analyze_once(parsed_url)
            return
        if path == "/api/price-history":
            self._handle_price_history(parsed_url)
            return
        if path == "/api/coin-profile":
            self._handle_coin_profile(parsed_url)
            return
        if path == "/api/signals":
            try:
                signals = SIGNAL_STORE.recent(limit=100)
            except Exception:
                self._send_json({"ok": False, "error": "Signal registry is unavailable."}, 500)
                return
            self._send_json({"ok": True, "signals": signals})
            return
        if path == "/api/paper":
            try:
                trades = PAPER_STORE.recent(limit=100)
            except Exception:
                self._send_json({"ok": False, "error": "Paper trade registry is unavailable."}, 500)
                return
            self._send_json({"ok": True, "trades": trades})
            return
        if path == "/api/dashboard":
            try:
                payload = CACHE.get(force=False)
            except BinanceReadOnlyError as exc:
                self._send_json({"ok": False, "error": str(exc)}, 502)
                return
            except Exception:
                self._send_json(
                    {"ok": False, "error": "Unexpected local error. No order was sent."},
                    500,
                )
                return
            self._send_json({"ok": True, "data": payload})
            return
        if path == "/api/refresh":
            try:
                payload = CACHE.get(force=True)
            except BinanceReadOnlyError as exc:
                self._send_json({"ok": False, "error": str(exc)}, 502)
                return
            except Exception:
                self._send_json(
                    {"ok": False, "error": "Unexpected local error. No order was sent."},
                    500,
                )
                return
            self._send_json({"ok": True, "data": payload})
            return
        self._send_json({"ok": False, "error": "Not found."}, 404)

    def do_POST(self) -> None:  # noqa: N802 - only local auth and monitor preferences can change
        if not self._host_is_safe():
            self._send_json({"ok": False, "error": "Host header was rejected."}, 421)
            return
        path = urlparse(self.path).path
        if path == "/api/auth/login":
            self._handle_login()
            return
        if path == "/api/auth/register":
            self._handle_register()
            return
        if path == "/api/auth/logout":
            self._handle_logout()
            return
        if path == "/api/auth/change-password":
            self._handle_change_password()
            return
        if path == "/api/monitors":
            self._handle_monitor_preference()
            return
        if path == "/api/coin-decisions":
            self._handle_coin_decision()
            return
        if path == "/api/favorites":
            self._handle_favorites()
            return
        self._discard_small_body()
        self._send_json({"ok": False, "error": "Read-only service. Write operations are disabled."}, 405)

    def _reject_write(self) -> None:
        self._discard_small_body()
        self._send_json({"ok": False, "error": "Read-only service. Write operations are disabled."}, 405)

    def do_PUT(self) -> None:  # noqa: N802
        self._reject_write()

    def do_PATCH(self) -> None:  # noqa: N802
        self._reject_write()

    def do_DELETE(self) -> None:  # noqa: N802
        self._reject_write()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Strictly read-only Binance Spot dashboard")
    parser.add_argument("--no-browser", action="store_true", help="Do not open a browser automatically")
    parser.add_argument("--port", type=int, default=PORT, help="Local dashboard port")
    return parser.parse_args()


def create_dashboard_server(port: int = PORT) -> ThreadingHTTPServer:
    """Create the local-only dashboard server without opening a terminal or browser."""
    if not 1024 <= port <= 65535:
        raise SystemExit("Port must be between 1024 and 65535.")
    if not RUNTIME_DIR.is_dir():
        raise SystemExit(f"Runtime directory does not exist: {RUNTIME_DIR}")
    if not AUTH_STORE.configured():
        raise SystemExit(
            "Private login is not configured. Run set_admin_password.py before starting the dashboard."
        )
    try:
        return ThreadingHTTPServer((HOST, port), DashboardHandler)
    except OSError:
        raise SystemExit(
            f"Local port {port} is already in use. Close the previous dashboard window and retry."
        ) from None


def start_dashboard_in_background(port: int = PORT) -> ThreadingHTTPServer:
    """Start one local dashboard instance for the Windows desktop shell."""
    server = create_dashboard_server(port)
    SCANNER.start()
    REALTIME_STREAM.start()
    threading.Thread(target=server.serve_forever, name="DashboardServer", daemon=True).start()
    return server


def stop_dashboard(server: ThreadingHTTPServer) -> None:
    server.shutdown()
    server.server_close()
    REALTIME_STREAM.stop()
    SCANNER.stop()


def main() -> None:
    args = parse_args()
    server = create_dashboard_server(args.port)
    url = f"http://{HOST}:{args.port}"
    print("Binance Spot Read-Only Dashboard")
    print(f"Local address: {url}")
    print("Mode: read-only. The application contains no order execution path.")
    print("Private login: enabled with server-side sessions.")
    print("Spot/Margin trading and withdrawal permissions must remain disabled.")
    print(f"Background scan interval: {load_config()['refresh_seconds']} seconds.")
    print("Close this window or press Ctrl+C to stop the dashboard.")
    SCANNER.start()
    REALTIME_STREAM.start()
    if not args.no_browser:
        threading.Timer(1.0, lambda: webbrowser.open(url)).start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        stop_dashboard(server)


if __name__ == "__main__":
    main()
