#!/usr/bin/env python3
"""Draw one page in a real browser at one width and say what is wrong with it.

Sources of truth: `tests/test_page_probe.py`, `docs/QUALITY.md`. Written for the
cross-device and accessibility passes, which need answers a curl cannot give: a
page that is 431 pixels wide inside a 390 pixel phone is a perfect 200 to every
test we have, and the last browser pass found its breaks only because a person
opened the page and looked at it.

    ../.venv-dev/bin/python scripts/page-probe.py http://127.0.0.1:8099/ --width 390
    ... --width 1440 --cookie-file /path/cookie.txt --shot /path/shot.png --ax --tab 12
    ... --reduced-motion            # the same page with prefers-reduced-motion: reduce

It launches headless Chrome on an ephemeral debugging port with a scratch profile,
speaks the DevTools Protocol over a websocket, and prints one JSON object:

    overflow    scrollWidth against innerWidth, and every element that sticks out
                of the viewport (outermost only, with a CSS selector for each);
                `breaks` is the subset a reader cannot reach, because no ancestor
                scrolls sideways to it
    outline     the landmarks and the heading levels in document order
    focus       what N presses of Tab reach, in order, and whether each one draws
                a focus ring a sighted keyboard user can see
    ax          the accessibility tree, with --ax (roles, names, levels)

Reads only. It sends no form, clicks nothing, and the one cookie it sets is the
one handed to it for a copy of the site. A GET must never write; neither must a
browser pass. Chrome is killed and its profile deleted before the script returns.

Exit: 0 the page was drawn and measured, 2 it could not be drawn (Chrome missing,
the page never loaded, the websocket closed).
"""

from __future__ import annotations

import argparse
import asyncio
import base64
import contextlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request

MAIN = pathlib.Path(__file__).resolve().parents[1]
ROOT = MAIN.parent

try:
    import websockets
except ImportError:  # re-exec under the venv check.sh uses
    venv_py = ROOT / ".venv-dev" / "bin" / "python"
    if venv_py.exists() and not os.environ.get("PAGE_PROBE_REEXEC"):
        os.environ["PAGE_PROBE_REEXEC"] = "1"
        os.execv(str(venv_py), [str(venv_py), *sys.argv])
    raise

CHROME = os.environ.get("CHROME_BIN") or shutil.which("google-chrome") or shutil.which("chromium")


class ProbeError(RuntimeError):
    """The page could not be drawn. Never a finding about the page: a finding about us."""


#: A phone is not a narrow desktop: touch, the mobile flag and the device pixel ratio all
#: change what CSS applies, so the probe says which it is rather than only how wide.
PHONE_MAX_WIDTH = 700
DEFAULT_HEIGHTS = {True: 844, False: 900}

# --- the page's own measurements ---------------------------------------------------------------

#: Every measurement runs in the page. Written as one expression per call so a failure names
#: itself instead of poisoning the whole read.
SELECTOR_JS = """
function selectorFor(el) {
  const parts = [];
  for (let node = el; node && node.nodeType === 1 && parts.length < 4; node = node.parentElement) {
    let part = node.tagName.toLowerCase();
    if (node.id) { parts.unshift(part + '#' + node.id); break; }
    const cls = (node.getAttribute('class') || '').trim().split(/\\s+/).filter(Boolean);
    if (cls.length) part += '.' + cls.slice(0, 2).join('.');
    const parent = node.parentElement;
    if (parent) {
      const same = Array.from(parent.children).filter(c => c.tagName === node.tagName);
      if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
    }
    parts.unshift(part);
  }
  return parts.join(' > ');
}
"""

OVERFLOW_JS = SELECTOR_JS + """
(() => {
  const inner = window.innerWidth;
  const doc = document.documentElement;
  const wide = [];
  // The right edge is the only one that breaks a page: a left-to-right document grows a
  // horizontal scrollbar from what sticks out past the right, never from what sits at
  // left: -9999px, which is the visually-hidden idiom (a honeypot field, a skip link).
  const isOut = (el) => {
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) return null;
    const style = getComputedStyle(el);
    if (style.visibility === 'hidden' || style.display === 'none') return null;
    const right = r.left + r.width;
    if (right <= inner + 1) return null;
    return {right: Math.round(right), left: Math.round(r.left), width: Math.round(r.width)};
  };
  const all = Array.from(document.body.querySelectorAll('*'));
  const out = new Map();
  for (const el of all) { const m = isOut(el); if (m) out.set(el, m); }
  // A shelf that scrolls sideways is a design, not a break: the airport page's featured row,
  // the page's own jump links and every .scroll-x table are all wider than a phone on
  // purpose. What matters is whether the reader can reach the far end, so each offender
  // names the ancestor that actually scrolls, and one that has none is the finding.
  const scrollerFor = (el) => {
    for (let node = el.parentElement; node; node = node.parentElement) {
      const style = getComputedStyle(node);
      const scrolls = ['auto', 'scroll', 'overlay'].includes(style.overflowX);
      if (scrolls && node.scrollWidth > node.clientWidth + 1) return node;
      if (node === document.body) break;
    }
    return null;
  };
  const pageScrolls = doc.scrollWidth > inner + 1;
  for (const [el, m] of out) {
    let parent = el.parentElement, covered = false;
    while (parent) { if (out.has(parent)) { covered = true; break; } parent = parent.parentElement; }
    if (covered) continue;                       // the outermost offender is the one to fix
    const style = getComputedStyle(el);
    const scroller = scrollerFor(el);
    wide.push({
      selector: selectorFor(el), tag: el.tagName.toLowerCase(),
      width: m.width, left: m.left, right: m.right,
      overhang: Math.round(m.right - inner),
      overflow_x: style.overflowX, position: style.position,
      scroller: scroller ? selectorFor(scroller) : null,
      verdict: scroller ? 'scrolls' : (pageScrolls ? 'pushes the page' : 'clipped'),
      text: (el.textContent || '').trim().slice(0, 60),
    });
  }
  wide.sort((a, b) => b.overhang - a.overhang);
  // Text a box hides rather than wraps: `overflow: hidden` with more content than room.
  // Three of those are decisions, not breaks, and each is counted apart: an ellipsis, a
  // line clamp, and the visually-hidden idiom (a 1px clipped box whose words exist for a
  // screen reader alone, which is how the header's airport button labels itself on a phone).
  // What is left is a hard cut: words a sighted reader simply never sees.
  const clipped = [], truncated = [];
  for (const el of all) {
    const style = getComputedStyle(el);
    const hiddenX = ['hidden', 'clip'].includes(style.overflowX);
    const hiddenY = ['hidden', 'clip'].includes(style.overflowY);
    if (!hiddenX && !hiddenY) continue;
    const overX = hiddenX && el.scrollWidth > el.clientWidth + 1;
    const overY = hiddenY && el.scrollHeight > el.clientHeight + 1;
    if (!overX && !overY) continue;
    const text = (el.textContent || '').trim();
    if (!text) continue;
    if (el.querySelector('*') && !Array.from(el.childNodes).some(
        n => n.nodeType === 3 && n.textContent.trim())) continue;   // a box of boxes, not text
    if (el.clientWidth <= 1 || el.clientHeight <= 1) continue;      // visually hidden, on purpose
    const clamp = style.webkitLineClamp && style.webkitLineClamp !== 'none';
    const record = {
      selector: selectorFor(el), tag: el.tagName.toLowerCase(),
      axis: overX ? 'x' : 'y',
      shown: overX ? el.clientWidth : el.clientHeight,
      content: overX ? el.scrollWidth : el.scrollHeight,
      how: clamp ? 'line clamp' : (style.textOverflow === 'ellipsis' ? 'ellipsis' : 'hard cut'),
      text: text.replace(/\\s+/g, ' ').slice(0, 60),
    };
    (record.how === 'hard cut' ? clipped : truncated).push(record);
  }
  return {
    inner_width: inner, scroll_width: doc.scrollWidth, client_width: doc.clientWidth,
    body_scroll_width: document.body.scrollWidth,
    horizontal_scroll: pageScrolls,
    wider_than_viewport: wide.slice(0, 25), wider_count: wide.length,
    breaks: wide.filter(w => w.verdict !== 'scrolls').slice(0, 25),
    break_count: wide.filter(w => w.verdict !== 'scrolls').length,
    clipped_text: clipped.slice(0, 25), clipped_count: clipped.length,
    truncated_count: truncated.length,
  };
})()
"""

OUTLINE_JS = SELECTOR_JS + """
(() => {
  const nodes = Array.from(document.querySelectorAll(
    'main, header, footer, nav, aside, h1, h2, h3, h4, h5, h6, [role]'));
  const seen = new Set();
  const items = [];
  for (const el of nodes) {
    if (seen.has(el)) continue;
    seen.add(el);
    const tag = el.tagName.toLowerCase();
    const role = el.getAttribute('role');
    const heading = /^h[1-6]$/.test(tag);
    if (!heading && !role && !['main', 'header', 'footer', 'nav', 'aside'].includes(tag)) continue;
    const style = getComputedStyle(el);
    items.push({
      tag, role: role || null,
      level: heading ? Number(tag[1]) : null,
      label: el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || null,
      text: (el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 70),
      hidden: style.display === 'none' || style.visibility === 'hidden'
              || el.getAttribute('aria-hidden') === 'true',
      selector: selectorFor(el),
    });
  }
  const count = (t) => items.filter(i => i.tag === t && !i.hidden).length;
  const levels = items.filter(i => i.level && !i.hidden).map(i => i.level);
  const skips = [];
  for (let i = 1; i < levels.length; i++) {
    if (levels[i] > levels[i - 1] + 1) skips.push({from: levels[i - 1], to: levels[i]});
  }
  return {
    items,
    counts: {main: count('main'), header: count('header'), footer: count('footer'),
             nav: count('nav'), h1: levels.filter(l => l === 1).length},
    nav_without_label: items.filter(i => i.tag === 'nav' && !i.hidden && !i.label)
                            .map(i => i.selector),
    heading_levels: levels, heading_skips: skips,
    title: document.title,
  };
})()
"""

FOCUS_JS = SELECTOR_JS + """
(() => {
  const el = document.activeElement;
  if (!el || el === document.body) return {selector: 'body', tag: 'body', ring: false, name: ''};
  const style = getComputedStyle(el);
  const ringed = (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0)
    || (style.boxShadow && style.boxShadow !== 'none');
  const r = el.getBoundingClientRect();
  return {
    selector: selectorFor(el), tag: el.tagName.toLowerCase(),
    name: (el.getAttribute('aria-label') || el.textContent || el.value || '')
            .trim().replace(/\\s+/g, ' ').slice(0, 50),
    role: el.getAttribute('role') || null,
    ring: !!ringed, outline: style.outlineStyle + ' ' + style.outlineWidth,
    in_view: r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight + 1
             && r.right <= window.innerWidth + 1,
    off_screen_right: Math.round(Math.max(0, r.right - window.innerWidth)),
  };
})()
"""

SETTLE_JS = "[document.readyState, document.body ? document.body.innerText.length : 0].join(':')"


# --- the browser -------------------------------------------------------------------------------

class Chrome:
    """One headless Chrome with a scratch profile, and the websocket to its first page."""

    def __init__(self, *, timeout: float = 30.0):
        self.timeout = timeout
        self.profile = pathlib.Path(tempfile.mkdtemp(prefix="page-probe-"))
        self.process: subprocess.Popen | None = None
        self.socket = None
        self._next_id = 0

    def start(self) -> str:
        if not CHROME:
            raise ProbeError("no google-chrome or chromium on PATH (CHROME_BIN overrides)")
        self.process = subprocess.Popen(
            [CHROME, "--headless=new", "--no-sandbox", "--disable-gpu", "--no-first-run",
             "--disable-extensions", "--disable-dev-shm-usage", "--hide-scrollbars",
             "--remote-debugging-port=0", f"--user-data-dir={self.profile}", "about:blank"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
        port_file = self.profile / "DevToolsActivePort"
        deadline = time.monotonic() + self.timeout
        while time.monotonic() < deadline:
            if port_file.exists():
                text = port_file.read_text().splitlines()
                if text and text[0].strip().isdigit():
                    return text[0].strip()
            if self.process.poll() is not None:
                raise ProbeError("Chrome exited before it opened a debugging port")
            time.sleep(0.05)
        raise ProbeError("Chrome never wrote DevToolsActivePort")

    def target_url(self, port: str) -> str:
        """The websocket of a fresh page target. `/json/new` needs the PUT the newer protocol
        wants; `/json/list` already holds the about:blank page Chrome opened for us."""
        url = f"http://127.0.0.1:{port}/json/list"
        deadline = time.monotonic() + self.timeout
        while time.monotonic() < deadline:
            try:
                with urllib.request.urlopen(url, timeout=5) as answer:
                    targets = json.loads(answer.read().decode())
                for target in targets:
                    if target.get("type") == "page" and target.get("webSocketDebuggerUrl"):
                        return target["webSocketDebuggerUrl"]
            except (urllib.error.URLError, OSError, json.JSONDecodeError):
                pass
            time.sleep(0.1)
        raise ProbeError("Chrome opened no page target")

    async def send(self, method: str, params: dict | None = None) -> dict:
        self._next_id += 1
        message_id = self._next_id
        await self.socket.send(json.dumps({"id": message_id, "method": method,
                                           "params": params or {}}))
        while True:
            raw = await asyncio.wait_for(self.socket.recv(), timeout=self.timeout)
            message = json.loads(raw)
            if message.get("id") == message_id:
                if "error" in message:
                    raise RuntimeError(f"{method}: {message['error']}")
                return message.get("result", {})
            self.events.append(message)

    async def evaluate(self, expression: str):
        result = await self.send("Runtime.evaluate", {
            "expression": expression, "returnByValue": True, "awaitPromise": True,
        })
        if result.get("exceptionDetails"):
            raise RuntimeError(f"page script failed: {result['exceptionDetails'].get('text')}")
        return result.get("result", {}).get("value")

    async def wait_for(self, method: str, timeout: float) -> bool:
        """True when the event has arrived, either already buffered or before the timeout."""
        if any(e.get("method") == method for e in self.events):
            return True
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            try:
                raw = await asyncio.wait_for(self.socket.recv(),
                                             timeout=max(0.1, deadline - time.monotonic()))
            except asyncio.TimeoutError:
                return False
            message = json.loads(raw)
            self.events.append(message)
            if message.get("method") == method:
                return True
        return False

    def stop(self) -> None:
        if self.process and self.process.poll() is None:
            self.process.terminate()
            try:
                self.process.wait(timeout=10)
            except subprocess.TimeoutExpired:
                self.process.kill()
        shutil.rmtree(self.profile, ignore_errors=True)

    events: list = []


def read_cookies(path: pathlib.Path, url: str) -> list[dict]:
    """`name=value; name=value` as the login wrote it, aimed at this origin only.

    The session cookie is named `__Host-dfp_session`, and that prefix is a rule the browser
    enforces: no `domain`, path `/`, and `secure`. Chrome answered "Sanitizing cookie failed"
    and the whole signed-in half of the pass died, so the prefix decides the attributes here.
    A local copy on 127.0.0.1 counts as a secure origin, which is why the pass works over
    plain HTTP at all.
    """
    parts = urllib.parse.urlsplit(url)
    cookies = []
    for chunk in path.read_text().strip().split(";"):
        name, _, value = chunk.strip().partition("=")
        if not name or not value:
            continue
        prefixed = name.startswith(("__Host-", "__Secure-"))
        cookie = {"name": name, "value": value, "path": "/", "httpOnly": True,
                  "secure": prefixed or parts.scheme == "https", "sameSite": "Lax"}
        if not name.startswith("__Host-"):
            cookie["domain"] = parts.hostname
        cookies.append(cookie)
    return cookies


async def probe(args) -> dict:
    """One page, one width, one browser. Chrome and its scratch profile go away whatever
    happens: a probe that leaves a browser behind would pile them up over a nine-page pass."""
    chrome = Chrome(timeout=args.timeout)
    try:
        return await _probe(args, chrome)
    finally:
        chrome.stop()


async def _probe(args, chrome: Chrome) -> dict:
    phone = args.width < PHONE_MAX_WIDTH
    height = args.height or DEFAULT_HEIGHTS[phone]
    chrome.events = []
    port = chrome.start()
    report: dict = {"url": args.url, "width": args.width, "height": height,
                    "device": "phone" if phone else "desktop",
                    "reduced_motion": bool(args.reduced_motion)}
    async with websockets.connect(chrome.target_url(port), max_size=64 * 1024 * 1024) as socket:
        chrome.socket = socket
        await chrome.send("Page.enable")
        await chrome.send("Runtime.enable")
        await chrome.send("Network.enable")
        await chrome.send("Emulation.setDeviceMetricsOverride", {
            "width": args.width, "height": height, "mobile": phone,
            "deviceScaleFactor": 1, "screenWidth": args.width, "screenHeight": height,
        })
        if args.reduced_motion:
            await chrome.send("Emulation.setEmulatedMedia", {
                "features": [{"name": "prefers-reduced-motion", "value": "reduce"}]})
        if args.cookie_file:
            for cookie in read_cookies(pathlib.Path(args.cookie_file), args.url):
                await chrome.send("Network.setCookie", {**cookie, "url": args.url})

        started = time.monotonic()
        await chrome.send("Page.navigate", {"url": args.url})
        report["loaded"] = await chrome.wait_for("Page.loadEventFired", args.timeout)

        # A React page answers long before it has drawn: wait for the text to stop growing.
        last, stable = None, 0
        deadline = time.monotonic() + args.settle
        while time.monotonic() < deadline:
            await asyncio.sleep(0.25)
            try:
                now = await chrome.evaluate(SETTLE_JS)
            except RuntimeError:
                now = None
            if now is not None and now == last:
                stable += 1
                if stable >= 3:
                    break
            else:
                stable = 0
            last = now
        report["settle_seconds"] = round(time.monotonic() - started, 2)

        report["overflow"] = await chrome.evaluate(OVERFLOW_JS)
        report["outline"] = await chrome.evaluate(OUTLINE_JS)
        report["text_length"] = await chrome.evaluate(
            "document.body ? document.body.innerText.length : 0")

        if args.tab:
            path = []
            for _ in range(args.tab):
                for event_type in ("rawKeyDown", "keyUp"):
                    await chrome.send("Input.dispatchKeyEvent", {
                        "type": event_type, "key": "Tab", "code": "Tab",
                        "windowsVirtualKeyCode": 9, "nativeVirtualKeyCode": 9,
                    })
                await asyncio.sleep(0.05)
                path.append(await chrome.evaluate(FOCUS_JS))
            report["focus"] = path
            report["focus_without_ring"] = [step["selector"] for step in path
                                            if not step.get("ring") and step["tag"] != "body"]

        if args.ax:
            tree = await chrome.send("Accessibility.getFullAXTree", {})
            nodes = []
            for node in tree.get("nodes", []):
                role = (node.get("role") or {}).get("value")
                if role in (None, "none", "generic", "InlineTextBox", "StaticText"):
                    continue
                nodes.append({
                    "role": role,
                    "name": ((node.get("name") or {}).get("value") or "")[:70],
                    "level": next((int(p["value"]["value"]) for p in node.get("properties", [])
                                   if p["name"] == "level"), None),
                    "ignored": node.get("ignored", False),
                })
            report["ax"] = nodes
            report["ax_unnamed"] = [n["role"] for n in nodes
                                    if not n["name"] and not n["ignored"]
                                    and n["role"] in ("button", "link", "textbox", "checkbox",
                                                      "combobox", "image", "navigation")]

        if args.shot:
            shot = await chrome.send("Page.captureScreenshot", {
                "format": "png", "captureBeyondViewport": bool(args.full_page)})
            pathlib.Path(args.shot).write_bytes(base64.b64decode(shot["data"]))
            report["shot"] = args.shot
    return report


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("url")
    parser.add_argument("--width", type=int, default=1440, help="viewport width in CSS pixels")
    parser.add_argument("--height", type=int, default=None)
    parser.add_argument("--cookie-file", default=None,
                        help="a file holding `name=value; name=value` for this origin")
    parser.add_argument("--shot", default=None, help="write a PNG here")
    parser.add_argument("--full-page", action="store_true",
                        help="the whole document in the shot, not the viewport")
    parser.add_argument("--ax", action="store_true", help="include the accessibility tree")
    parser.add_argument("--tab", type=int, default=0, help="press Tab N times and report the path")
    parser.add_argument("--reduced-motion", action="store_true",
                        help="draw with prefers-reduced-motion: reduce")
    parser.add_argument("--settle", type=float, default=8.0,
                        help="seconds to wait for the page to stop changing")
    parser.add_argument("--timeout", type=float, default=30.0)
    args = parser.parse_args(argv)

    try:
        report = asyncio.run(probe(args))
    except (ProbeError, RuntimeError, OSError, asyncio.TimeoutError) as exc:
        print(json.dumps({"url": args.url, "width": args.width,
                          "error": f"{type(exc).__name__}: {exc}"}))
        return 2
    print(json.dumps(report, indent=2))
    return 0


if __name__ == "__main__":
    with contextlib.suppress(KeyboardInterrupt):
        sys.exit(main())
