#!/usr/bin/env python3 """Headless QA for all site pages: light/dark render + overflow at 390/768/1280. CDP per discovery #82: --force-prefers-color-scheme is silently ignored, so Emulation.setEmulatedMedia is the only reliable scheme flip. Metrics are set BEFORE navigate so layout never happens at another width; uncontained inside
 is filtered when hunting real page-level overflow.

Doc pages also get a sticky-ToC assertion: in the two-column docs layout
(wide viewport) the ToC must be position:sticky and sit beside the prose
column; in the single-column layout (narrow viewport) it must be static.

Works over the browser endpoint (Target.createTarget + flatten sessions),
which sidesteps the flaky /json/new page-target websocket on this Chromium.
"""
import json
import subprocess
import sys
import time
import urllib.request

import websocket

PORT = 9333
PAGES = [
    ("home", "index.html"),
    ("developer", "developer/index.html"),
    ("invercargill", "developer/invercargill/index.html"),
    ("invercargill-json", "developer/invercargill-json/index.html"),
    ("inversion", "developer/inversion/index.html"),
    ("invercargill-sql", "developer/invercargill-sql/index.html"),
    ("invercargill-sql-inversion", "developer/invercargill-sql-inversion/index.html"),
    ("astralis", "developer/astralis/index.html"),
    ("statum", "developer/statum/index.html"),
    ("spry", "developer/spry/index.html"),
    ("locksmith", "developer/locksmith/index.html"),
    ("vallama", "developer/vallama/index.html"),
    ("usm", "developer/usm/index.html"),
]
WIDTHS = [390, 768, 1280]
SCHEMES = ["light", "dark"]

def http_put(url):
    req = urllib.request.Request(url, method="PUT", data=b"")
    return json.load(urllib.request.urlopen(req))

class Cdp:
    def __init__(self, ws):
        self.ws = ws
        self.mid = 0

    def call(self, method, params=None, timeout=30):
        self.mid += 1
        mid = self.mid
        self.ws.send(json.dumps({"id": mid, "method": method,
                                 "params": params or {}}))
        deadline = time.time() + timeout
        while time.time() < deadline:
            try:
                msg = json.loads(self.ws.recv())
            except websocket.WebSocketTimeoutException:
                continue
            if msg.get("id") == mid:
                if "error" in msg:
                    raise RuntimeError(f"{method}: {msg['error']}")
                return msg.get("result", {})
        raise TimeoutError(method)

    def eval(self, expr):
        r = self.call("Runtime.evaluate",
                      {"expression": expr, "returnByValue": True})
        return r.get("result", {}).get("value")

def main():
    proc = subprocess.Popen([
        "chromium-browser", "--headless=new", "--disable-gpu", "--no-sandbox",
        "--remote-allow-origins=*", f"--remote-debugging-port={PORT}",
        "about:blank",
    ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    try:
        ver = None
        for _ in range(50):
            try:
                ver = json.load(urllib.request.urlopen(
                    f"http://127.0.0.1:{PORT}/json/version"))
                break
            except Exception:
                time.sleep(0.2)
        assert ver, "chromium did not start"
        ws = websocket.create_connection(
            ver["webSocketDebuggerUrl"],
            origin=f"http://127.0.0.1:{PORT}", timeout=5)
        ws.settimeout(0.5)
        cdp = Cdp(ws)

        failures = []
        bg_by_page = {}
        for name, path in PAGES:
            url = "file://" + sys.argv[1].rstrip("/") + "/" + path
            tid = cdp.call("Target.createTarget", {"url": "about:blank"})["targetId"]
            sess = cdp.call("Target.attachToTarget",
                            {"targetId": tid, "flatten": True})["sessionId"]

            def send(method, params=None):
                cdp.mid += 1
                mid = cdp.mid
                ws.send(json.dumps({"id": mid, "method": method,
                                    "params": params or {},
                                    "sessionId": sess}))
                deadline = time.time() + 30
                while time.time() < deadline:
                    try:
                        msg = json.loads(ws.recv())
                    except websocket.WebSocketTimeoutException:
                        continue
                    if msg.get("id") == mid:
                        if "error" in msg:
                            raise RuntimeError(f"{method}: {msg['error']}")
                        return msg.get("result", {})
                raise TimeoutError(method)

            send("Page.enable")
            send("Emulation.setDeviceMetricsOverride",
                 {"width": 390, "height": 900, "deviceScaleFactor": 1,
                  "mobile": True})
            send("Page.navigate", {"url": url})
            time.sleep(1.5)  # fonts + layout settle

            for scheme in SCHEMES:
                send("Emulation.setEmulatedMedia",
                     {"features": [{"name": "prefers-color-scheme",
                                    "value": scheme}]})
                for w in WIDTHS:
                    send("Emulation.setDeviceMetricsOverride",
                         {"width": w, "height": 900, "deviceScaleFactor": 1,
                          "mobile": w < 500})
                    time.sleep(0.3)
                    data = json.loads(send("Runtime.evaluate", {
                        "expression": """(() => {
                            const de = document.documentElement;
                            const over = [];
                            const w = de.clientWidth;
                            for (const el of document.querySelectorAll('body *')) {
                                if (el.closest('pre')) continue;
                                const b = el.getBoundingClientRect();
                                if (b.width && (b.right > w + 1 || b.left < -1)) {
                                    over.push(el.tagName + '.' + (el.className||''));
                                }
                            }
                            const toc = document.querySelector('.docs-toc');
                            let tocInfo = null;
                            if (toc) {
                                const cs = getComputedStyle(toc);
                                const layout = getComputedStyle(
                                    document.querySelector('.docs-layout'));
                                const prose = document.querySelector('.docs-prose');
                                tocInfo = {
                                    pos: cs.position,
                                    twoCol: layout.gridTemplateColumns.split(' ').length === 2,
                                    besideProse: prose ? toc.getBoundingClientRect().left
                                        >= prose.getBoundingClientRect().right - 1 : null,
                                };
                            }
                            return JSON.stringify({
                                scrollW: de.scrollWidth, clientW: w,
                                bg: getComputedStyle(document.body).backgroundColor,
                                over: over.slice(0, 5),
                                imgsBroken: [...document.images]
                                    .filter(i => !i.complete || i.naturalWidth === 0)
                                    .map(i => i.getAttribute('src')),
                                toc: tocInfo,
                            });
                        })()""", "returnByValue": True})
                        ["result"]["value"])
                    label = f"{name}/{scheme}/{w}"
                    problems = []
                    if data["scrollW"] > data["clientW"] + 1:
                        problems.append(
                            f"scroll {data['scrollW']}>{data['clientW']}")
                    if data["over"]:
                        problems.append("overflow: " + ",".join(data["over"]))
                    if data["imgsBroken"]:
                        problems.append(
                            "broken imgs: " + ",".join(data["imgsBroken"]))
                    toc = data.get("toc")
                    if toc:
                        # sticky ToC expectation: two-column layout at wide
                        # widths -> sticky + right of prose; one-column at
                        # narrow widths -> static
                        two_col_layout = toc["twoCol"]
                        if two_col_layout and w >= 1000:
                            if toc["pos"] != "sticky":
                                problems.append(
                                    f"toc pos={toc['pos']} in 2-col @ {w}")
                            if toc["besideProse"] is False:
                                problems.append(
                                    "toc not beside prose column")
                        if not two_col_layout and toc["pos"] != "static":
                            problems.append(
                                f"toc pos={toc['pos']} in 1-col @ {w}")
                    if problems:
                        failures.append(label + ": " + "; ".join(problems))
                        print(f"FAIL {label}: {'; '.join(problems)}",
                              file=sys.stderr)
                    else:
                        tocst = ""
                        if data.get("toc"):
                            tocst = (f" toc={data['toc']['pos']}"
                                     f"{'/2col' if data['toc']['twoCol'] else '/1col'}")
                        print(f"ok   {label} bg={data['bg']} "
                              f"{data['scrollW']}/{data['clientW']}{tocst}")
                    bg_by_page.setdefault((name, w), {})[scheme] = data["bg"]

            cdp.call("Target.closeTarget", {"targetId": tid})

        # scheme sanity: bg must differ between light and dark
        for (name, w), bgs in sorted(bg_by_page.items()):
            if bgs.get("light") == bgs.get("dark"):
                failures.append(f"{name}@{w}: light/dark bg identical "
                                f"({bgs.get('light')}) — scheme flip failed")

        if failures:
            print(f"\n{len(failures)} failure(s)", file=sys.stderr)
            for f in failures:
                print(" -", f, file=sys.stderr)
            return 1
        print("\nALL OK (light/dark differ, no overflow, no broken images)")
        return 0
    finally:
        proc.terminate()

if __name__ == "__main__":
    sys.exit(main())