"""Launch a console under test as a real localhost server and wait until it answers.

The harness drives each Magogi console the way an operator does: as a black box
over HTTP. Rather than import the app in-process, :func:`live_server` spawns the
console's own entry command (its ``mgf-*-web`` CLI, from its own virtualenv) as a
subprocess on an ephemeral port, polls until it serves, and yields the base URL
for a browser to open. This keeps the harness' dependencies (Playwright) cleanly
separate from every console's own stack, and exercises the real ASGI server — not
a ``TestClient`` shim.
"""

from __future__ import annotations

import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Iterator, Mapping, Sequence


def free_port() -> int:
    """Reserve then release an ephemeral TCP port, returning its number.

    There is an unavoidable race between releasing the port here and the console
    binding it; in practice it is fine for a single-user local/CI test host, and
    it lets us pass the port to a CLI that has no "bind :0 and tell me" mode.
    """
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.bind(("127.0.0.1", 0))
        return int(sock.getsockname()[1])


@dataclass(frozen=True)
class LiveServer:
    """A running console under test: its base URL and the port it bound."""

    base_url: str
    port: int

    def url(self, path: str = "/") -> str:
        """Absolute URL for ``path`` on the running console."""
        return self.base_url + (path if path.startswith("/") else "/" + path)


class UITestWebError(RuntimeError):
    """Base class for every error :mod:`mgf.uitest_web` raises deliberately.

    Subclasses ``RuntimeError`` so existing ``except RuntimeError`` handlers keep
    working; catch :class:`UITestWebError` to handle only this library's failures.
    """


class ServerStartError(UITestWebError):
    """The console process died, or never answered, before the deadline.

    Carries machine-readable fields alongside the message: ``returncode`` (the
    console's exit code, or ``None`` if it was still running when the deadline
    passed) and ``output`` (the console's drained stdout/stderr) — so a caller can
    branch on them, not only parse the string.
    """

    def __init__(self, message: str, *, returncode: int | None = None, output: str = "") -> None:
        super().__init__(message)
        self.returncode = returncode
        self.output = output


def _probe(url: str) -> int | None:
    """Return the HTTP status if the server answers at all, else ``None``."""
    try:
        with urllib.request.urlopen(url, timeout=2) as resp:  # noqa: S310 (localhost only)
            return int(resp.status)
    except urllib.error.HTTPError as exc:
        return int(exc.code)  # any HTTP response means the server is up
    except (urllib.error.URLError, ConnectionError, OSError):
        return None


@contextmanager
def live_server(
    command: Sequence[str],
    *,
    port: int,
    ready_path: str = "/",
    env: Mapping[str, str] | None = None,
    cwd: str | None = None,
    timeout: float = 30.0,
) -> Iterator[LiveServer]:
    """Spawn ``command`` (which must bind ``port``), wait until it serves, yield it.

    ``command`` is the console's launch argv, already carrying the port — e.g.
    ``[".../mgf-fed-web", "--host", "127.0.0.1", "--port", "8790"]``. We poll
    ``ready_path`` until any HTTP status comes back, then hand the caller a
    :class:`LiveServer`. On exit the process is terminated (then killed if it
    ignores SIGTERM). If the process exits before answering, its captured output
    is surfaced in :class:`ServerStartError` — failures are diagnosable, not silent.

    Server output is streamed to a temp file (not a PIPE) so a chatty console can
    never dead-lock on a full pipe buffer while we are still polling.
    """
    log = tempfile.TemporaryFile(mode="w+")  # noqa: SIM115 (lifecycle spans the yield; closed in finally)
    proc = subprocess.Popen(  # noqa: S603 (caller-supplied console argv)
        list(command),
        env=dict(env) if env is not None else None,
        cwd=cwd,
        stdout=log,
        stderr=subprocess.STDOUT,
        text=True,
    )

    def _drain() -> str:
        log.seek(0)
        return log.read()

    base = f"http://127.0.0.1:{port}"
    ready_url = base + (ready_path if ready_path.startswith("/") else "/" + ready_path)
    deadline = time.monotonic() + timeout
    try:
        while True:
            if proc.poll() is not None:
                out = _drain()
                raise ServerStartError(
                    f"console exited with code {proc.returncode} before serving "
                    f"{ready_url}:\n{out}",
                    returncode=proc.returncode,
                    output=out,
                )
            if _probe(ready_url) is not None:
                break
            if time.monotonic() > deadline:
                proc.terminate()
                out = _drain()
                raise ServerStartError(
                    f"console did not answer {ready_url} within {timeout:.0f}s:\n{out}",
                    returncode=None,
                    output=out,
                )
            time.sleep(0.15)
        yield LiveServer(base, port)
    finally:
        if proc.poll() is None:
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait(timeout=5)
        log.close()
