from __future__ import annotations

import contextlib
import json
import logging
import queue
import threading
import time
from datetime import UTC
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse

from opendeviationbar.sidecar_api import (
    ariadne_lookup,
    catchup_lookup,
    gap_fill_trades,
)

from .config_helpers import _backfill_cfg

logger = logging.getLogger(__name__)

# Mutable container for cached engine metrics, updated by the main sidecar loop.
# The health handler reads this dict — avoids PyO3 borrow conflicts with next_bar().
_health_state: dict[str, object] = {
    "metrics": {},
    "stream_signals": {
        "last_connect": None,  # ISO timestamp of last CONNECT signal
        "last_first_data": None,  # ISO timestamp of last FIRST_DATA signal
        "last_disconnect": None,  # ISO timestamp of last DISCONNECT signal
        "last_ban_detected": None,  # ISO timestamp of last BAN_DETECTED
        "ban_until": None,  # Ban expiry ISO timestamp (if active)
        "connection_count": 0,  # Total connections since sidecar start
        "disconnect_count": 0,  # Total disconnections since sidecar start
    },
}

# Junction telemetry state populated by _sync_flush_rest_bars() for /status endpoint.
_junction_state: dict[str, dict] = {}

# Issue #216/#217: Rate limiting to prevent Binance API budget exhaustion.


class _TokenBucket:
    """Token bucket rate limiter with burst support (#222).

    Inspired by PyrateLimiter's leaky bucket design but deliberately minimal
    (20 lines vs 300+). Supports weighted requests and monotonic clock-jump
    safety. See ~/fork-tools/PyrateLimiter for the full-featured alternative.
    """

    __slots__ = ("_burst", "_last_refill", "_lock", "_name", "_rate", "_tokens")

    def __init__(self, rate: float, burst: int, *, name: str = "unnamed") -> None:
        self._rate = rate
        self._burst = burst
        self._tokens = float(burst)
        self._last_refill = time.monotonic()
        self._lock = threading.Lock()
        self._name = name

    def try_acquire(self, weight: int = 1) -> bool:
        with self._lock:
            now = time.monotonic()
            # Guard against monotonic clock backward jump (macOS sleep/wake)
            elapsed = max(0.0, now - self._last_refill)
            self._tokens = min(
                self._burst,
                self._tokens + elapsed * self._rate,
            )
            self._last_refill = now
            if self._tokens >= weight:
                self._tokens -= weight
                return True
            logger.debug(
                "rate limited [%s]: tokens=%.1f, weight=%d",
                self._name,
                self._tokens,
                weight,
            )
            return False


_gap_fill_bucket = _TokenBucket(rate=1.0, burst=5, name="gap-fill")
# Ariadne uses burst=2 (single lookups, not paginated like gap-fill)
_ariadne_bucket = _TokenBucket(rate=2.0, burst=2, name="ariadne")

_catchup_bucket = _TokenBucket(rate=0.2, burst=1, name="catchup")

# Issue #163: SSE broadcast infrastructure.
# Each connected SSE client gets a queue. Bars are pushed via non-blocking put_nowait().
# Clients that can't keep up get events dropped (backpressure via bounded queue).
_sse_clients: list[queue.Queue[str | None]] = []
_sse_clients_lock = threading.Lock()
_SSE_CLIENT_QUEUE_SIZE = 256  # Max buffered events per client before drop


def _register_sse_client() -> queue.Queue[str | None]:
    """Register a new SSE client and return its event queue."""
    q: queue.Queue[str | None] = queue.Queue(maxsize=_SSE_CLIENT_QUEUE_SIZE)
    with _sse_clients_lock:
        _sse_clients.append(q)
    return q


def _unregister_sse_client(q: queue.Queue[str | None]) -> None:
    """Remove a disconnected SSE client."""
    with _sse_clients_lock, contextlib.suppress(ValueError):
        _sse_clients.remove(q)


def _broadcast_bar_event(symbol: str, threshold: int, bar: dict[str, Any]) -> None:
    """Broadcast a completed bar to all connected SSE clients.

    Non-blocking: clients that can't keep up have events silently dropped.
    """
    event_id = f"{symbol}:{threshold}:{bar.get('close_time_us', 0)}"
    data = json.dumps({"symbol": symbol, "threshold": threshold, **bar})
    message = f"event: bar\nid: {event_id}\ndata: {data}\n\n"

    with _sse_clients_lock:
        for client_queue in _sse_clients:
            with contextlib.suppress(queue.Full):
                client_queue.put_nowait(message)


def _broadcast_forming_event(symbol: str, threshold: int, bar: dict[str, Any]) -> None:
    """Broadcast a forming (incomplete) bar to all connected SSE clients (Issue #214).

    Uses event type 'forming' (distinct from 'bar') so consumers can route by type.
    Stale forming bars (no recent trades) are filtered by the daemon thread before
    calling this function.
    """
    event_id = f"{symbol}:{threshold}:forming:{bar.get('close_time_us', 0)}"
    data = json.dumps(
        {"symbol": symbol, "threshold": threshold, "is_forming": True, **bar}
    )
    message = f"event: forming\nid: {event_id}\ndata: {data}\n\n"

    with _sse_clients_lock:
        for client_queue in _sse_clients:
            with contextlib.suppress(queue.Full):
                client_queue.put_nowait(message)


def _broadcast_checkpoint_event(checkpoints: dict[str, Any]) -> None:
    """Broadcast processor checkpoints to all connected SSE clients (Issue #215).

    Sent periodically (default 5min) and on graceful shutdown for crash resilience.
    """
    data = json.dumps({"checkpoints": checkpoints})
    message = f"event: checkpoint\nid: checkpoint:{int(time.time())}\ndata: {data}\n\n"

    with _sse_clients_lock:
        for client_queue in _sse_clients:
            with contextlib.suppress(queue.Full):
                client_queue.put_nowait(message)


def _broadcast_gap_event(event: dict[str, Any]) -> None:
    """Broadcast a gap detection event to all connected SSE clients (Phase 1c).

    Non-blocking: clients that can't keep up have events silently dropped.
    """
    event_id = f"gap:{event.get('symbol', '')}:{event.get('detected_at_ms', 0)}"
    data = json.dumps(event)
    message = f"event: gap_detected\nid: {event_id}\ndata: {data}\n\n"

    with _sse_clients_lock:
        for client_queue in _sse_clients:
            with contextlib.suppress(queue.Full):
                client_queue.put_nowait(message)


def _start_forming_bar_broadcaster(
    watcher: object,
    valid_pairs: set[tuple[str, int]],
    interval_s: float = 1.0,
    stale_s: float = 60.0,
) -> threading.Event:
    """Start a daemon thread that broadcasts forming bars at regular intervals (Issue #214).

    Reads forming bar snapshots from the engine's watch channels at `interval_s` Hz.
    Skips broadcasting if no SSE clients are connected or if the forming bar's last
    trade timestamp is older than `stale_s` seconds.

    Returns a stop event that can be set to terminate the thread.
    """
    stop_event = threading.Event()

    def _loop() -> None:
        consecutive_errors = 0
        while not stop_event.is_set():
            try:
                # Skip if no SSE clients connected
                with _sse_clients_lock:
                    has_clients = bool(_sse_clients)
                if not has_clients:
                    stop_event.wait(interval_s)
                    continue

                forming_bars = watcher.get_forming_bars()
                now_us = int(time.time() * 1_000_000)
                stale_us = int(stale_s * 1_000_000)

                for fb in forming_bars:
                    sym = fb.pop("_symbol", "UNKNOWN")
                    thr = fb.pop("_threshold", 0)
                    last_trade_us = fb.pop("_last_trade_timestamp_ms", 0)

                    # Skip invalid pairs
                    if (sym, thr) not in valid_pairs:
                        continue

                    # Skip stale forming bars (no recent trades)
                    if last_trade_us > 0 and (now_us - last_trade_us) > stale_us:
                        continue

                    _broadcast_forming_event(sym, thr, fb)

                consecutive_errors = 0

            except Exception:
                consecutive_errors += 1
                if consecutive_errors >= 10:  # noqa: PLR2004
                    logger.exception(
                        "forming bar broadcaster: %d consecutive failures, backing off",
                        consecutive_errors,
                    )
                    stop_event.wait(min(interval_s * consecutive_errors, 30.0))
                else:
                    logger.warning(
                        "forming bar broadcast error (non-fatal)", exc_info=True
                    )

            stop_event.wait(interval_s)

    thread = threading.Thread(target=_loop, daemon=True, name="forming-bar-broadcaster")
    thread.start()
    logger.info(
        "forming bar broadcaster started (interval=%.1fs, stale=%.0fs)",
        interval_s,
        stale_s,
    )
    return stop_event


def _update_stream_signal(signal: str, **kwargs: object) -> None:
    """Update stream_signals state from engine metrics or log parsing."""
    from datetime import datetime

    now = datetime.now(UTC).isoformat()
    signals = _health_state.setdefault("stream_signals", {})

    if signal == "CONNECT":
        signals["last_connect"] = now
        signals["connection_count"] = signals.get("connection_count", 0) + 1
    elif signal == "FIRST_DATA":
        signals["last_first_data"] = now
    elif signal == "DISCONNECT":
        signals["last_disconnect"] = now
        signals["disconnect_count"] = signals.get("disconnect_count", 0) + 1
    elif signal == "BAN_DETECTED":
        signals["last_ban_detected"] = now
        ban_until_ms = kwargs.get("ban_until_ms")
        if ban_until_ms:
            signals["ban_until"] = datetime.fromtimestamp(
                int(ban_until_ms) / 1000, tz=UTC
            ).isoformat()


def _update_health_metrics(metrics: dict[str, int]) -> None:
    """Cache engine metrics for the /health endpoint (called from main loop)."""
    _health_state["metrics"] = metrics


def _start_health_server(port: int, sse_enabled: bool = True) -> HTTPServer | None:
    """Start a lightweight HTTP server for health checks and SSE bar events.

    Endpoints:
        GET /health          — JSON health status (200/503/500)
        GET /events/bars     — SSE stream of completed bars (Issue #163)
            ?symbols=BTCUSDT,ETHUSDT  (optional filter)
            ?thresholds=250,500       (optional filter)

    Returns the server instance (for shutdown) or None if port=0 (disabled).
    """
    if port == 0:
        return None

    class SidecarHandler(BaseHTTPRequestHandler):
        def do_GET(self) -> None:
            parsed = urlparse(self.path)
            if parsed.path == "/health":
                self._handle_health()
            elif parsed.path == "/status":
                self._handle_status()
            elif parsed.path == "/events/bars" and sse_enabled:
                self._handle_sse(parsed.query)
            elif parsed.path.startswith("/ariadne/"):
                self._handle_ariadne(parsed.path, parsed.query)
            elif parsed.path == "/trades/gap-fill":
                self._handle_gap_fill(parsed.query)
            elif parsed.path.startswith("/catchup/"):
                self._handle_catchup(parsed.path, parsed.query)
            else:
                self.send_error(404)

        def _handle_health(self) -> None:
            try:
                from opendeviationbar.health_checks import run_all_checks

                results = run_all_checks()
                bool_checks = {k: v for k, v in results.items() if isinstance(v, bool)}
                all_pass = all(bool_checks.values())

                engine_metrics = _health_state.get("metrics", {})

                resp: dict[str, object] = {
                    "status": "healthy" if all_pass else "unhealthy",
                    "checks": results,
                    "engine": engine_metrics,
                    "stream_signals": _health_state.get("stream_signals", {}),
                    "ws_mode": _health_state.get("ws_mode", "unknown"),
                    "expected_ws_connections": (
                        2
                        if _health_state.get("ws_mode") == "combined"
                        else _health_state.get("ws_symbols_count", 0)
                    ),
                }
                body = json.dumps(resp).encode()
                code = 200 if all_pass else 503
            except (ImportError, OSError, RuntimeError, ValueError):
                body = json.dumps(
                    {"status": "error", "checks": {}},
                ).encode()
                code = 500

            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def _handle_status(self) -> None:
            """Return junction telemetry for heartbeat monitoring."""
            try:
                engine_metrics = _health_state.get("metrics", {})

                # Compute uptime from stored start time
                start_mono = _health_state.get("start_monotonic")
                uptime_s: float | None = None
                if start_mono is not None:
                    uptime_s = round(time.monotonic() - start_mono, 1)

                # Build per-symbol data from junction state
                symbols: dict[str, dict] = {}
                for sym, sym_data in _junction_state.items():
                    symbols[sym] = {
                        "rest_first_tid": sym_data.get("rest_first_tid"),
                        "rest_last_tid": sym_data.get("rest_last_tid"),
                        "ws_first_tid": sym_data.get("ws_first_tid"),
                        "forming_bar": None,
                        "committed_floors": None,
                    }

                resp: dict[str, object] = {
                    "engine": engine_metrics,
                    "symbols": symbols,
                    "uptime_s": uptime_s,
                    "stream_signals": _health_state.get("stream_signals", {}),
                }
                body = json.dumps(resp).encode()
                code = 200
            except (OSError, RuntimeError, ValueError):
                body = json.dumps({"error": "status unavailable"}).encode()
                code = 500

            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def _handle_sse(self, query_string: str) -> None:  # noqa: PLR0915
            """Stream completed and forming bars as Server-Sent Events (Issue #163, #214)."""
            params = parse_qs(query_string)
            from opendeviationbar.symbol_registry import normalize_symbol

            symbol_filter = set()
            threshold_filter = set()
            if "symbols" in params:
                symbol_filter = {
                    normalize_symbol(s)
                    for s in params["symbols"][0].split(",")
                    if s.strip()
                }
            if "thresholds" in params:
                threshold_filter = {
                    int(t.strip())
                    for t in params["thresholds"][0].split(",")
                    if t.strip()
                }

            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("Connection", "keep-alive")
            self.send_header("X-Accel-Buffering", "no")
            self.end_headers()

            client_queue = _register_sse_client()
            heartbeat_interval = 15.0
            last_heartbeat = time.monotonic()

            try:
                while True:
                    try:
                        message = client_queue.get(timeout=1.0)
                    except queue.Empty:
                        message = None

                    if message is not None:
                        # Apply client-side filters
                        if symbol_filter or threshold_filter:
                            try:
                                # Extract symbol/threshold from event data line
                                data_line = ""
                                for line in message.split("\n"):
                                    if line.startswith("data: "):
                                        data_line = line[6:]
                                        break
                                if data_line:
                                    evt = json.loads(data_line)
                                    if (
                                        symbol_filter
                                        and evt.get("symbol") not in symbol_filter
                                    ):
                                        continue
                                    if (
                                        threshold_filter
                                        and evt.get("threshold") not in threshold_filter
                                    ):
                                        continue
                            except (json.JSONDecodeError, KeyError):
                                pass  # Send unfiltered on parse error

                        self.wfile.write(message.encode())
                        self.wfile.flush()

                    # Heartbeat to detect dead connections
                    now = time.monotonic()
                    if now - last_heartbeat >= heartbeat_interval:
                        self.wfile.write(b": heartbeat\n\n")
                        self.wfile.flush()
                        last_heartbeat = now

            except (BrokenPipeError, ConnectionResetError, OSError):
                pass  # Client disconnected
            finally:
                _unregister_sse_client(client_queue)

        def _parse_symbol_threshold(
            self, path: str, endpoint: str
        ) -> tuple[str, int] | None:
            """Parse /{endpoint}/{symbol}/{threshold} path. Returns (symbol, threshold) or None on error."""
            parts = path.strip("/").split("/")
            if len(parts) != 3:  # noqa: PLR2004
                self._send_json(
                    400, {"error": f"expected /{endpoint}/{{symbol}}/{{threshold}}"}
                )
                return None
            from opendeviationbar.symbol_registry import normalize_symbol

            symbol = normalize_symbol(parts[1])
            try:
                threshold = int(parts[2])
            except ValueError:
                self._send_json(400, {"error": "threshold must be an integer"})
                return None
            return (symbol, threshold)

        def _handle_ariadne(self, path: str, query_string: str = "") -> None:
            """GET /ariadne/{symbol}/{threshold} — cascading trade-ID lookup (Issue #217)."""
            if not _ariadne_bucket.try_acquire():
                self._send_json(429, {"error": "rate limited, retry after 0.5s"})
                return

            parsed = self._parse_symbol_threshold(path, "ariadne")
            if parsed is None:
                return
            symbol, threshold = parsed

            params = parse_qs(query_string)
            committed_only = params.get("committed_only", ["false"])[0].lower() in (
                "true",
                "1",
            )

            try:
                # Pass engine reference from module-level _health_state
                engine_ref = _health_state.get("engine")
                result = ariadne_lookup(
                    symbol, threshold, engine=engine_ref, committed_only=committed_only
                )
                self._send_json(200, result)
            except ValueError as e:
                self._send_json(400, {"error": str(e)})
            except Exception:
                logger.exception("ariadne endpoint failed for %s@%d", symbol, threshold)
                self._send_json(500, {"error": "internal server error"})

        def _handle_gap_fill(self, query_string: str) -> None:
            """GET /trades/gap-fill?symbol=X&from_agg_id=N&limit=M (Issue #216)."""
            if not _gap_fill_bucket.try_acquire():
                self._send_json(429, {"error": "rate limited, retry after 1s"})
                return

            params = parse_qs(query_string)
            symbol = params.get("symbol", [None])[0]
            from_agg_id_str = params.get("from_agg_id", [None])[0]
            limit_str = params.get("limit", ["1000"])[0]

            if not symbol:
                self._send_json(400, {"error": "missing 'symbol' parameter"})
                return
            if not from_agg_id_str:
                self._send_json(400, {"error": "missing 'from_agg_id' parameter"})
                return

            try:
                from_agg_id = int(from_agg_id_str)
                limit = max(1, min(int(limit_str), 5000))
            except ValueError:
                self._send_json(
                    400, {"error": "from_agg_id and limit must be integers"}
                )
                return

            try:
                from opendeviationbar.symbol_registry import normalize_symbol

                result = gap_fill_trades(normalize_symbol(symbol), from_agg_id, limit)
                self._send_json(200, result)
            except ValueError as e:
                self._send_json(400, {"error": str(e)})
            except Exception:
                logger.exception("gap-fill endpoint failed for %s", symbol)
                self._send_json(502, {"error": "upstream data fetch failed"})

        def _handle_catchup(self, path: str, query_string: str = "") -> None:
            """GET /catchup/{symbol}/{threshold} — server-side gap-fill (#228)."""
            if not _catchup_bucket.try_acquire():
                self._send_json(429, {"error": "rate limited, retry after 5s"})
                return

            parsed = self._parse_symbol_threshold(path, "catchup")
            if parsed is None:
                return
            symbol, threshold = parsed

            params = parse_qs(query_string)
            try:
                max_trades = max(
                    1,
                    min(
                        int(params.get("max_trades", ["50000"])[0]),
                        _backfill_cfg().paginated_max_trades,
                    ),
                )
            except ValueError:
                self._send_json(400, {"error": "max_trades must be an integer"})
                return

            try:
                engine_ref = _health_state.get("engine")
                result = catchup_lookup(
                    symbol, threshold, max_trades=max_trades, engine=engine_ref
                )
                self._send_json(200, result)
            except ValueError as e:
                self._send_json(400, {"error": str(e)})
            except Exception:
                logger.exception("catchup endpoint failed for %s@%d", symbol, threshold)
                self._send_json(500, {"error": "internal server error"})

        def _send_json(self, code: int, data: object) -> None:
            """Send a JSON response."""
            body = json.dumps(data).encode()
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, format: str, *args: object) -> None:  # noqa: A002
            # Suppress per-request access logs (too noisy for health probes)
            pass

    try:
        from http.server import ThreadingHTTPServer

        server = ThreadingHTTPServer(("0.0.0.0", port), SidecarHandler)
        thread = threading.Thread(target=server.serve_forever, daemon=True)
        thread.start()
        endpoints = (
            "/health, /ariadne/{sym}/{thr}, /trades/gap-fill, /catchup/{sym}/{thr}"
        )
        if sse_enabled:
            endpoints += ", /events/bars (SSE)"
        logger.info("sidecar HTTP server listening on port %d [%s]", port, endpoints)
    except OSError as e:
        logger.warning(
            "failed to start health server on port %d: %s",
            port,
            e,
        )
        return None
    else:
        return server
