from __future__ import annotations

import asyncio
import http
import json
import socket
import sys
import threading
import webbrowser
from collections import deque
from pathlib import Path
from typing import Any, Dict, List, Optional, Set

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
HISTORY_BUFFER_SIZE = 200
PORT_SCAN_RANGE = 100

_DASHBOARD_HTML = Path(__file__).parent / "dashboard.html"


def _find_free_port(host: str, start: int) -> Optional[int]:
    """Scan for an open TCP port starting at *start*.

    Returns:
        First available port number, or None if all in range are occupied.
    """
    for port in range(start, start + PORT_SCAN_RANGE):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            if s.connect_ex((host, port)) != 0:
                return port
    return None


class DashboardServer:
    """Embedded server that serves the dashboard HTML over HTTP and streams
    log records to connected browsers over WebSocket — both on the same port.

    A plain browser GET to http://host:port returns the dashboard page.
    The page's JS then connects back as a WebSocket on the same address.

    Thread-safe: broadcast() may be called from any thread.
    """

    def __init__(self, config: Any) -> None:
        self._config = config
        self._host: str = config.host
        self._port: int = config.port
        self._auto_open: bool = config.auto_open

        self._loop: Optional[asyncio.AbstractEventLoop] = None
        self._thread: Optional[threading.Thread] = None
        self._clients: Set[Any] = set()          # accessed only on _loop thread
        self._history: deque[Dict[str, Any]] = deque(maxlen=HISTORY_BUFFER_SIZE)
        self._history_lock = threading.Lock()
        self._started = threading.Event()
        self._shutdown_event: Optional[asyncio.Event] = None
        self._stopped = False

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def start(self) -> None:
        """Bind and start serving in a daemon thread."""
        free_port = _find_free_port(self._host, self._port)
        if free_port is None:
            sys.stderr.write(
                f"[pulselog] Could not find a free port starting at {self._port}. "
                "Dashboard disabled.\n"
            )
            self._stopped = True
            self._started.set()
            return

        self._port = free_port
        self._thread = threading.Thread(
            target=self._run_loop,
            daemon=True,
            name="pulselog-server",
        )
        self._thread.start()
        self._started.wait(timeout=5.0)

    def broadcast(self, batch: list) -> None:
        """Send a batch of LogRecord objects to all connected clients.

        Thread-safe. No-op when server is stopped or no clients connected.
        History is always updated regardless.

        Args:
            batch: List of LogRecord instances from the worker drain.
        """
        self._update_history(batch)

        if self._stopped or not batch or self._loop is None:
            return
        if not self._loop.is_running():
            return
        if not self._clients:
            return

        records_json = [r.to_dict() for r in batch]
        asyncio.run_coroutine_threadsafe(
            self._async_broadcast(records_json), self._loop
        )

    @property
    def port(self) -> int:
        """The actual bound port (may differ from config.port after auto-increment)."""
        return self._port

    @property
    def client_count(self) -> int:
        """Number of currently connected WebSocket clients."""
        return len(self._clients)

    def shutdown(self) -> None:
        """Stop the server gracefully.  Idempotent."""
        if self._stopped:
            return
        self._stopped = True
        if self._loop is not None and self._loop.is_running():
            if self._shutdown_event is not None:
                self._loop.call_soon_threadsafe(self._shutdown_event.set)
            else:
                self._loop.call_soon_threadsafe(self._loop.stop)
        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=2.0)

    # ------------------------------------------------------------------
    # Private — asyncio side (runs only on _loop thread)
    # ------------------------------------------------------------------

    def _run_loop(self) -> None:
        """Daemon thread target — owns the asyncio event loop."""
        self._loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._loop)
        # Suppress benign shutdown noise from websockets HTTP cleanup
        self._loop.set_exception_handler(lambda loop, ctx: None)
        try:
            self._loop.run_until_complete(self._serve())
        except Exception as exc:
            sys.stderr.write(f"[pulselog] Dashboard server error: {exc}\n")
        finally:
            try:
                pending = asyncio.all_tasks(self._loop)
                if pending:
                    self._loop.run_until_complete(
                        asyncio.gather(*pending, return_exceptions=True)
                    )
            except Exception:
                pass
            self._loop.close()

    async def _process_request(
        self, path: str, headers: Any
    ) -> Optional[tuple]:
        """Intercept plain HTTP requests and serve the dashboard HTML.

        When the browser navigates to http://host:port it sends a normal HTTP
        GET (no Upgrade header).  We return the HTML page here; the JS inside
        then opens a WebSocket back to the same address.

        Returning None tells websockets to proceed with the WS handshake.
        """
        upgrade = headers.get("Upgrade", "").lower()
        if upgrade == "websocket":
            return None  # let websockets handle the upgrade

        # Serve dashboard HTML for any non-WS request
        try:
            html = _DASHBOARD_HTML.read_bytes()
            # Inject the actual port so the JS knows where to connect
            html = html.replace(b"{{PORT}}", str(self._port).encode())
        except Exception as exc:
            sys.stderr.write(f"[pulselog] Could not read dashboard.html: {exc}\n")
            html = b"<h1>pulselog dashboard unavailable</h1>"

        return (
            http.HTTPStatus.OK,
            [
                ("Content-Type", "text/html; charset=utf-8"),
                ("Content-Length", str(len(html))),
                ("Cache-Control", "no-cache"),
            ],
            html,
        )

    async def _serve(self) -> None:
        """Bind the server and serve HTTP + WebSocket until shutdown() is called."""
        try:
            import websockets.legacy.server as ws_server  # type: ignore[import]
        except ImportError:
            try:
                import websockets.server as ws_server  # type: ignore[import]
            except ImportError:
                sys.stderr.write("[pulselog] websockets package not installed — dashboard disabled.\n")
                self._started.set()
                return

        self._shutdown_event = asyncio.Event()

        try:
            async with ws_server.serve(
                self._handle_client,
                self._host,
                self._port,
                process_request=self._process_request,
            ):
                dashboard_url = f"http://{self._host}:{self._port}"
                print(f"📊 pulselog dashboard → {dashboard_url}")
                self._started.set()

                if self._auto_open:
                    await asyncio.sleep(0.3)
                    webbrowser.open(dashboard_url)

                await self._shutdown_event.wait()

        except Exception as exc:
            sys.stderr.write(f"[pulselog] Failed to bind dashboard: {exc}\n")
            self._started.set()

    async def _handle_client(self, websocket: Any) -> None:
        """Handle a single WebSocket connection lifecycle."""
        self._clients.add(websocket)
        try:
            # Replay history on connect so late-joiners see past logs
            with self._history_lock:
                history = list(self._history)
            if history:
                await websocket.send(
                    json.dumps({"type": "history", "records": history})
                )
            async for _ in websocket:
                pass  # we only push; ignore any client messages
        except Exception:
            pass
        finally:
            self._clients.discard(websocket)

    async def _async_broadcast(self, records: List[Dict[str, Any]]) -> None:
        """Push a records batch to every connected client (asyncio-thread only)."""
        if not self._clients:
            return
        payload = json.dumps({"type": "logs", "records": records})
        dead: Set[Any] = set()
        for ws in list(self._clients):
            try:
                await ws.send(payload)
            except Exception:
                dead.add(ws)
        self._clients -= dead

    def _update_history(self, batch: list) -> None:
        """Append serialised records to the in-memory ring buffer."""
        with self._history_lock:
            for r in batch:
                self._history.append(r.to_dict())
