"""The pillar checks: layout integrity, accessibility, runtime health, screenshots.

Each console composes these against a Playwright ``Page`` after navigation. They
are deliberately framework-agnostic — they take a ``Page`` and return findings —
so the same vocabulary serves every Magogi web console (and, in spirit, the
future Qt sibling).

The headline is :func:`audit_layout`: it reads the browser's *real* geometry to
catch content that spills past its box — text written outside a border, an
element pushed beyond the viewport — deterministically, with no image baseline to
bless and no font-rendering false alarms.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from playwright.sync_api import Page

# --------------------------------------------------------------------------- #
# layout integrity                                                            #
# --------------------------------------------------------------------------- #

# Runs in the page. Walks every visible element and flags two things:
#   1. viewport-overflow-x — the element's box extends past the right edge of the
#      viewport (the classic "something is too wide and the page scrolls sideways").
#   2. text-overflow — a text leaf whose content is larger than its own box while
#      overflow is visible, i.e. the text is drawn outside its border.
# position:fixed elements are skipped (legitimately viewport-anchored). Tolerance
# absorbs sub-pixel rounding.
_LAYOUT_JS = r"""
({tolerance, vtolerance}) => {
  const vw = window.innerWidth;
  const violations = [];
  const nodes = document.body ? document.body.querySelectorAll('*') : [];
  for (const el of nodes) {
    const cs = getComputedStyle(el);
    if (cs.display === 'none' || cs.visibility === 'hidden' || cs.position === 'fixed') continue;
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) continue;
    const cls = el.getAttribute('class') || '';
    const tag = el.tagName.toLowerCase();
    // Flag only if the overflow ESCAPES its containers: an element that reaches
    // past the viewport but lives inside an overflow-x:auto/scroll/hidden ancestor
    // (e.g. a wide table in a .table-wrap scroller) is a valid pattern, not a bug.
    if (r.right > vw + tolerance) {
      let contained = false;
      let anc = el.parentElement;
      while (anc && anc !== document.documentElement) {
        const ax = getComputedStyle(anc).overflowX;
        if (ax === 'auto' || ax === 'scroll' || ax === 'hidden') { contained = true; break; }
        anc = anc.parentElement;
      }
      if (!contained) {
        violations.push({ kind: 'viewport-overflow-x', tag, cls,
          right: Math.round(r.right), viewport: vw });
      }
    }
    const isLeaf = el.children.length === 0 && (el.textContent || '').trim().length > 0;
    if (isLeaf && cs.overflowX === 'visible' && cs.overflowY === 'visible') {
      if (el.scrollWidth > el.clientWidth + tolerance
          || el.scrollHeight > el.clientHeight + vtolerance) {
        violations.push({ kind: 'text-overflow', tag, cls,
          text: (el.textContent || '').trim().slice(0, 60),
          scrollWidth: el.scrollWidth, clientWidth: el.clientWidth,
          scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
      }
    }
  }
  return {
    viewportWidth: vw,
    documentScrollWidth: document.documentElement.scrollWidth,
    horizontalScroll: document.documentElement.scrollWidth > vw + tolerance,
    violations,
  };
}
"""


@dataclass(frozen=True)
class LayoutReport:
    """The result of a layout-integrity audit at one viewport width."""

    viewport_width: int
    document_scroll_width: int
    horizontal_scroll: bool
    violations: list[dict[str, Any]]

    @property
    def ok(self) -> bool:
        """True when nothing spills past its box and the page has no sideways scroll."""
        return not self.horizontal_scroll and not self.violations

    def describe(self) -> str:
        """A human-readable summary suitable for an assertion message."""
        if self.ok:
            return (
                f"layout ok (viewport={self.viewport_width}px, "
                f"document={self.document_scroll_width}px)"
            )
        lines = [
            f"layout FAILED at viewport={self.viewport_width}px "
            f"(document scrollWidth={self.document_scroll_width}px, "
            f"horizontal_scroll={self.horizontal_scroll}):"
        ]
        for v in self.violations:
            if v["kind"] == "viewport-overflow-x":
                lines.append(
                    f"  - <{v['tag']} class={v['cls']!r}> reaches x={v['right']}px, "
                    f"past the {v['viewport']}px viewport"
                )
            elif v["kind"] == "text-overflow":
                lines.append(
                    f"  - <{v['tag']} class={v['cls']!r}> text {v['text']!r} overflows its box "
                    f"(content {v['scrollWidth']}x{v['scrollHeight']} vs box "
                    f"{v['clientWidth']}x{v['clientHeight']})"
                )
        return "\n".join(lines)


def audit_layout(page: Page, *, tolerance: int = 1, vtolerance: int = 4) -> LayoutReport:
    """Read the page's real geometry and report anything that spills past its box.

    ``tolerance`` (horizontal) stays tight — text past the side border is the
    headline signal. ``vtolerance`` (vertical) is looser to absorb line-box /
    font-metric rounding: a heading whose glyphs sit ~2px taller than its box is
    not a real "off the line" defect, and flagging it would only erode trust.
    """
    data = page.evaluate(_LAYOUT_JS, {"tolerance": tolerance, "vtolerance": vtolerance})
    return LayoutReport(
        viewport_width=int(data["viewportWidth"]),
        document_scroll_width=int(data["documentScrollWidth"]),
        horizontal_scroll=bool(data["horizontalScroll"]),
        violations=list(data["violations"]),
    )


# --------------------------------------------------------------------------- #
# accessibility (axe-core)                                                    #
# --------------------------------------------------------------------------- #

#: axe impact levels that fail a gate by default (minor/moderate are reported only).
DEFAULT_A11Y_IMPACTS: tuple[str, ...] = ("serious", "critical")


def scan_a11y(
    page: Page, *, impacts: tuple[str, ...] = DEFAULT_A11Y_IMPACTS
) -> list[dict[str, Any]]:
    """Run axe-core against the loaded page; return violations at ``impacts`` level.

    Returns the raw axe violation objects (each carries ``id``, ``impact``,
    ``help``, and the offending ``nodes``) so callers can both count and explain
    them. An empty list means the page passed at the requested severity.
    """
    from axe_playwright_python.sync_playwright import Axe

    results = Axe().run(page)
    payload = getattr(results, "response", None)
    if not isinstance(payload, dict):
        payload = getattr(results, "results", {}) or {}
    all_violations = payload.get("violations", []) if isinstance(payload, dict) else []
    return [v for v in all_violations if v.get("impact") in impacts]


def describe_a11y(violations: list[dict[str, Any]]) -> str:
    """A human-readable summary of axe violations for an assertion message."""
    if not violations:
        return "no accessibility violations at the requested severity"
    lines = [f"{len(violations)} accessibility violation(s):"]
    for v in violations:
        targets = [n.get("target") for n in v.get("nodes", [])[:3]]
        lines.append(f"  - [{v.get('impact')}] {v.get('id')}: {v.get('help')} at {targets}")
    return "\n".join(lines)


# --------------------------------------------------------------------------- #
# runtime health (console / network)                                          #
# --------------------------------------------------------------------------- #


@dataclass
class HealthWatch:
    """Accumulates browser-console errors, page errors, and HTTP/network failures."""

    console_errors: list[str] = field(default_factory=list)
    page_errors: list[str] = field(default_factory=list)
    failed_requests: list[str] = field(default_factory=list)
    #: HTTP responses with status >= 400, as ``{"url": ..., "status": ...}`` — this is
    #: what turns a bare "console 404" into an actionable "which resource 404'd".
    error_responses: list[dict[str, Any]] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        """True when the page produced no error-level console/network/HTTP noise."""
        return not (
            self.console_errors or self.page_errors or self.failed_requests or self.error_responses
        )

    def describe(self) -> str:
        """A human-readable summary suitable for an assertion message."""
        if self.ok:
            return "runtime health ok (no console/page errors, failed requests, or HTTP errors)"
        return (
            f"runtime health issues: {len(self.console_errors)} console error(s) "
            f"{self.console_errors}; {len(self.page_errors)} page error(s) "
            f"{self.page_errors}; {len(self.failed_requests)} failed request(s) "
            f"{self.failed_requests}; {len(self.error_responses)} HTTP error(s) "
            f"{self.error_responses}"
        )


def watch_health(page: Page) -> HealthWatch:
    """Attach console/error/network listeners to ``page`` BEFORE navigation.

    Returns a live :class:`HealthWatch` that fills as the page loads and runs, so
    the caller can assert ``watch.ok`` after driving the page.
    """
    watch = HealthWatch()
    page.on(
        "console",
        lambda msg: watch.console_errors.append(msg.text) if msg.type == "error" else None,
    )
    page.on("pageerror", lambda err: watch.page_errors.append(str(err)))
    page.on("requestfailed", lambda req: watch.failed_requests.append(req.url))
    page.on(
        "response",
        lambda resp: (
            watch.error_responses.append({"url": resp.url, "status": resp.status})
            if resp.status >= 400
            else None
        ),
    )
    return watch


# --------------------------------------------------------------------------- #
# screenshots                                                                 #
# --------------------------------------------------------------------------- #


def capture(page: Page, path: str | Path, *, full_page: bool = True) -> Path:
    """Write a screenshot of ``page`` to ``path`` (parents created), return the path."""
    out = Path(path)
    out.parent.mkdir(parents=True, exist_ok=True)
    page.screenshot(path=str(out), full_page=full_page)
    return out
