"""The consumer-facing objects: how to launch a console, and how to drive it.

``ConsoleLaunch`` is the small contract each console provides (from a
``console_launch`` fixture): the command to run it as a black-box server, the
environment it needs, and where to poll for readiness. ``Console`` is the
ergonomic wrapper a spec actually uses — ``console.open("/")`` then
``console.assert_layout_clean()`` / ``.assert_accessible()`` / ``.assert_healthy()``
/ ``.snapshot("overview")`` — so a console's UI spec stays a handful of readable
lines instead of Playwright boilerplate.
"""

from __future__ import annotations

import contextlib
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal

from playwright.sync_api import TimeoutError as PlaywrightTimeoutError

from .checks import (
    DEFAULT_A11Y_IMPACTS,
    HealthWatch,
    LayoutReport,
    audit_layout,
    capture,
    describe_a11y,
    scan_a11y,
)

if TYPE_CHECKING:
    from collections.abc import Mapping, Sequence
    from pathlib import Path

    from playwright.sync_api import Page, Response, ViewportSize

    from .server import LiveServer


@dataclass(frozen=True)
class ConsoleLaunch:
    """How to launch one console under test as a black-box HTTP server.

    A consumer returns this from a ``console_launch`` fixture (which may depend on
    ``tmp_path``, ``console_port``, and any per-test state builder). ``command``
    must bind ``console_port``; ``env`` is merged over the current environment.
    """

    command: Sequence[str]
    env: Mapping[str, str] = field(default_factory=dict)
    ready_path: str = "/"
    cwd: str | None = None
    timeout: float = 30.0
    name: str = "console"


@dataclass(frozen=True)
class Viewport:
    """A named screen size for the responsive matrix."""

    label: str
    width: int
    height: int

    @property
    def size(self) -> ViewportSize:
        """The Playwright ``viewport`` mapping."""
        return {"width": self.width, "height": self.height}


def _slug(text: str) -> str:
    """Filesystem-safe token for gallery filenames."""
    return re.sub(r"[^a-z0-9._-]+", "-", text.strip().lower()).strip("-") or "shot"


class Console:
    """A live console, wrapped for readable specs.

    Holds the Playwright ``page``, the running ``server``, a :class:`HealthWatch`
    that has been attached since before navigation, and the viewport/theme this
    instance is running under (so snapshots are labelled per matrix cell).
    """

    def __init__(
        self,
        page: Page,
        server: LiveServer,
        health: HealthWatch,
        gallery_dir: Path,
        *,
        viewport: Viewport,
        theme: str,
    ) -> None:
        self.page = page
        self.server = server
        self.health = health
        self.viewport = viewport
        self.theme = theme
        self._gallery = gallery_dir
        self.last_response: Response | None = None

    # -- navigation --------------------------------------------------------- #
    def open(
        self,
        path: str = "/",
        *,
        wait_until: Literal["commit", "domcontentloaded", "load", "networkidle"] = "load",
        settle: bool = True,
    ) -> Response | None:
        """Navigate to ``path``; wait for the network to settle; return the response.

        After the ``load`` event we briefly wait for ``networkidle`` (bounded) so
        late sub-resources — a nested CSS ``@import``, a lazily-fetched asset — have
        fired before the health / a11y / layout pillars read the page. Streaming
        pages (SSE) never idle, so the wait is bounded and its timeout swallowed.
        """
        self.last_response = self.page.goto(self.server.url(path), wait_until=wait_until)
        if settle:
            # streaming pages (SSE) never idle — bound the wait and move on.
            with contextlib.suppress(PlaywrightTimeoutError):
                self.page.wait_for_load_state("networkidle", timeout=2000)
        return self.last_response

    def text(self, selector: str = "body") -> str:
        """The text content under ``selector`` (default: the whole body)."""
        return self.page.text_content(selector) or ""

    # -- pillar checks ------------------------------------------------------ #
    def audit_layout(self, *, tolerance: int = 1) -> LayoutReport:
        """Layout-integrity report for the current page (no assertion)."""
        return audit_layout(self.page, tolerance=tolerance)

    def scan_a11y(self, *, impacts: tuple[str, ...] = DEFAULT_A11Y_IMPACTS) -> list[dict[str, Any]]:
        """axe-core violations at ``impacts`` severity for the current page."""
        return scan_a11y(self.page, impacts=impacts)

    # -- gates (assertions) ------------------------------------------------- #
    def assert_status(self, code: int = 200) -> None:
        """Assert the last navigation returned HTTP ``code``."""
        got = self.last_response.status if self.last_response is not None else None
        assert got == code, f"expected HTTP {code}, got {got} for {self.page.url}"

    def assert_layout_clean(self, *, tolerance: int = 1) -> LayoutReport:
        """Assert nothing spills past its box or the viewport; return the report."""
        report = self.audit_layout(tolerance=tolerance)
        assert report.ok, report.describe()
        return report

    def assert_accessible(self, *, impacts: tuple[str, ...] = DEFAULT_A11Y_IMPACTS) -> None:
        """Assert no axe violations at ``impacts`` severity."""
        violations = self.scan_a11y(impacts=impacts)
        assert not violations, describe_a11y(violations)

    def assert_healthy(self) -> None:
        """Assert no console errors, page errors, failed requests, or HTTP errors."""
        assert self.health.ok, self.health.describe()

    def assert_ok(
        self,
        *,
        status: int = 200,
        a11y_impacts: tuple[str, ...] = DEFAULT_A11Y_IMPACTS,
    ) -> None:
        """Run every gate: status, layout, accessibility, and runtime health."""
        self.assert_status(status)
        self.assert_layout_clean()
        self.assert_accessible(impacts=a11y_impacts)
        self.assert_healthy()

    # -- gallery ------------------------------------------------------------ #
    def snapshot(self, name: str) -> Path:
        """Write a screenshot to the gallery, labelled ``name__viewport__theme.png``."""
        filename = f"{_slug(name)}__{self.viewport.label}__{_slug(self.theme)}.png"
        return capture(self.page, self._gallery / filename)
