#!/usr/bin/env python3
"""
verify.py — headless render + health check for a cloned page (rung 0 of the agent loop).

Serves the job folder over real HTTP (never file:// — fonts/CORS break, per CLAUDE.md), renders
it in headless Chromium, scrolls the full page to trigger lazy-load + scroll JS, and reports:
  - broken LOCAL assets (same-origin 404/5xx)  → critical: the clone itself is broken
  - uncaught JS errors / console errors         → critical: behaviour broken
  - failed EXTERNAL requests (api/, trackers)   → warning: expected locally, documented not fixed
  - a full-page screenshot                      → for visual diffing before/after a change

The agent loop uses ok() to decide whether a free-form edit is safe to keep, and screenshot()
+ diff_ratio() to confirm an edit didn't blow up the layout. Nothing leaves the box.
"""

import sys
import json
import socket
import threading
import functools
from pathlib import Path
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer

# Local asset extensions whose same-origin 404 means a genuinely broken clone.
_ASSET_RE = (".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
             ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp4", ".webm", ".ico")

# Same-origin paths whose failure is EXPECTED locally (dynamic backends) — warn, don't fail.
_EXPECTED_PATHS = ("/api/", "/cart.js", "/cart/", "/cdn-cgi/", "/recommendations/")


def _free_port():
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()
    return port


class _Server:
    """A quiet threaded static file server, on an ephemeral localhost port.

    Roots at the job folder's PARENT and serves the job at /<name>/ — mirroring production nginx
    (root /srv/site-clones, jobs at server/<job>/), so clones that use root-absolute paths like
    /breezebox-cooler/css/... resolve exactly as they do live. Serving the job as root would
    404 those and report false breakage."""

    def __init__(self, root):
        port = _free_port()
        handler = functools.partial(_QuietHandler, directory=str(root))
        self.httpd = _QuietServer(("127.0.0.1", port), handler)
        self.base = f"http://127.0.0.1:{port}"
        self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)

    def __enter__(self):
        self.thread.start()
        return self.base

    def __exit__(self, *exc):
        self.httpd.shutdown()
        self.httpd.server_close()


class _QuietServer(ThreadingHTTPServer):
    def handle_error(self, request, client_address):
        # Headless Chromium routinely aborts requests mid-transfer (cancelled media, page
        # teardown) → ConnectionReset/BrokenPipe. Swallow those; they're not clone problems.
        import sys as _sys
        if not isinstance(_sys.exc_info()[1], (ConnectionResetError, BrokenPipeError)):
            super().handle_error(request, client_address)


class _QuietHandler(SimpleHTTPRequestHandler):
    def log_message(self, *a):
        pass


def _scroll_through(page):
    """Scroll top→bottom in steps so lazy-loaded assets and scroll-triggered JS actually fire."""
    try:
        page.evaluate(
            """async () => {
                const sleep = ms => new Promise(r => setTimeout(r, ms));
                const h = document.body.scrollHeight;
                for (let y = 0; y < h; y += window.innerHeight) {
                    window.scrollTo(0, y); await sleep(120);
                }
                window.scrollTo(0, 0); await sleep(120);
            }"""
        )
    except Exception:
        pass


def render(job_dir, screenshot_path=None, viewport=(1280, 800), settle_ms=1500):
    """Render job_dir/index.html headless and collect health signals.

    Returns a dict: {ok, critical[], warnings[], console_errors[], broken_assets[], screenshot}.
    `ok` is True when there are no critical issues. Raises RuntimeError if Playwright is missing.
    """
    try:
        from playwright.sync_api import sync_playwright
    except Exception:
        raise RuntimeError("playwright not installed — run: pip install playwright && playwright install chromium")

    job_dir = Path(job_dir)
    if not (job_dir / "index.html").exists():
        return {"ok": False, "critical": ["index.html missing in job folder"],
                "warnings": [], "console_errors": [], "broken_assets": [], "screenshot": None}

    critical, warnings, console_errors, broken_assets = [], [], [], []
    prefix = f"/{job_dir.name}"  # job is served under this path (mirrors nginx)

    with _Server(job_dir.parent) as base:
        def on_response(resp):
            if resp.status < 400:
                return
            url, status = resp.url, resp.status
            if url.startswith(base):
                path = url[len(base):].split("?", 1)[0]
                rel = path[len(prefix):] if path.startswith(prefix + "/") else path  # strip job prefix
                if any(rel.startswith(p) for p in _EXPECTED_PATHS):
                    warnings.append(f"{status} (expected, dynamic) {path}")
                elif rel.lower().split("#")[0].endswith(_ASSET_RE):
                    broken_assets.append(f"{status} {path}")
                    critical.append(f"broken local asset: {status} {path}")
                else:
                    warnings.append(f"{status} {path}")
            else:
                warnings.append(f"{status} external {url[:120]}")

        def on_console(msg):
            if msg.type != "error":
                return
            text = msg.text[:300]
            # Generic resource-load failures are already captured (with the URL) by on_response.
            if "failed to load resource" in text.lower():
                return
            console_errors.append(text)
            # External/tracker/api console noise is non-critical; same-origin JS errors are.
            if any(k in text.lower() for k in ("/api/", "favicon", "tracking", "analytics", "gtag", "pixel")):
                warnings.append(f"console: {text}")
            else:
                critical.append(f"console error: {text}")

        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
            page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
            page.on("response", on_response)
            page.on("console", on_console)
            page.on("pageerror", lambda e: critical.append(f"uncaught JS: {str(e)[:300]}"))
            try:
                page.goto(f"{base}{prefix}/", wait_until="load", timeout=30000)
            except Exception as e:
                warnings.append(f"load timeout/navigation: {str(e)[:160]}")
            _scroll_through(page)
            page.wait_for_timeout(settle_ms)
            if screenshot_path:
                Path(screenshot_path).parent.mkdir(parents=True, exist_ok=True)
                page.screenshot(path=str(screenshot_path), full_page=True)
            browser.close()

    # De-dup while preserving order.
    seen = set()
    critical = [c for c in critical if not (c in seen or seen.add(c))]
    return {"ok": not critical, "critical": critical, "warnings": warnings[:50],
            "console_errors": console_errors[:50], "broken_assets": broken_assets,
            "screenshot": str(screenshot_path) if screenshot_path else None}


def probe(job_dir, js, viewport=(1280, 800), settle_ms=800):
    """Render the page and run a JS expression against the live DOM, returning its result. This is
    the reliable way to LOCATE elements (hero image, a section by its rendered text) — it queries
    the real rendered page instead of pattern-matching minified HTML source. Returns None on error."""
    from playwright.sync_api import sync_playwright

    job_dir = Path(job_dir)
    prefix = f"/{job_dir.name}"
    with _Server(job_dir.parent) as base:
        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
            page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
            try:
                page.goto(f"{base}{prefix}/", wait_until="load", timeout=30000)
            except Exception:
                pass
            page.wait_for_timeout(settle_ms)
            try:
                result = page.evaluate(js)
            except Exception:
                result = None
            browser.close()
    return result


def hero_image(job_dir):
    """Locate the hero image = the largest <img> at/near the top of the RENDERED page. Returns
    {src, width, height, top} (src exactly as written in the HTML, so it can be matched in source)
    or None. This replaces guessing 'the first <img>' from minified source, which finds the wrong one."""
    return probe(job_dir, """() => {
        const vh = window.innerHeight;
        let best = null, bestArea = 0;
        for (const im of document.querySelectorAll('img')) {
            const r = im.getBoundingClientRect();
            if (r.top > vh * 1.3 || r.bottom < 0) continue;   // must sit at/near the top (the hero)
            const area = r.width * r.height;
            if (area > bestArea) { bestArea = area; best = im; }
        }
        if (!best || bestArea < 5000) return null;            // ignore tiny icons/logos
        const r = best.getBoundingClientRect();
        return {src: best.getAttribute('src'), width: Math.round(r.width),
                height: Math.round(r.height), top: Math.round(r.top)};
    }""")


def locate_section(job_dir, text):
    """Locate the on-page SECTION containing `text`, via the rendered DOM. Returns
    {tag, id, className, width, height, textPreview} for the enclosing block to remove, or None.
    Finds the deepest element whose text contains `text`, then walks up to the enclosing
    full-width block — reliable where guessing a text range in minified source is not."""
    js = f"""(() => {{
        const needle = {json.dumps(text)}.replace(/\\s+/g, ' ').trim().toLowerCase();
        if (!needle) return null;
        let match = null;
        for (const el of document.querySelectorAll('body *')) {{
            const t = (el.innerText || '').replace(/\\s+/g, ' ').trim().toLowerCase();
            if (t.includes(needle)) match = el;   // deepest element fully containing the text
        }}
        if (!match) return null;
        // Universal rule (no site-specific assumptions): a section is a bounded chunk of a page —
        // never the whole page, never a bare text node. Climb to the largest ancestor whose height
        // stays within ~70% of the page; we stop just below whatever element spans the page,
        // whether that's <body>, a main-wrap, or any other container.
        const pageH = document.documentElement.scrollHeight || document.body.scrollHeight || 1;
        let el = match;
        while (el.parentElement && el.parentElement !== document.body &&
               el.parentElement.getBoundingClientRect().height <= pageH * 0.7) {{
            el = el.parentElement;
        }}
        const info = (e) => ({{tag: e.tagName.toLowerCase(), id: e.id || null,
            className: (e.className || '').toString().slice(0, 50),
            heightPct: Math.round(e.getBoundingClientRect().height / pageH * 100),
            text: (e.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 70)}});
        const chain = []; let c = el;   // self + a few ancestors, for transparency / override
        for (let i = 0; i < 4 && c && c !== document.body; i++) {{ chain.push(info(c)); c = c.parentElement; }}
        const r = el.getBoundingClientRect();
        return Object.assign(info(el), {{width: Math.round(r.width), height: Math.round(r.height),
            textPreview: (el.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 90), chain: chain}});
    }})()"""
    return probe(job_dir, js)


def screenshot(job_dir, out_path, **kw):
    """Render and write a full-page screenshot; returns the render() result dict."""
    return render(job_dir, screenshot_path=out_path, **kw)


def gate(job_dir, baseline=None, screenshot_path=None, **kw):
    """Differential health gate for the agent loop: did THIS edit make the page worse?

    Pass `baseline` = a render() result captured BEFORE the edit. Only criticals that are NEW
    relative to the baseline count as regressions — pre-existing quirks (a stray uncaught JS
    error the original clone already threw) don't fail an otherwise-good change. With no baseline
    it behaves like a plain absolute health check. The returned dict adds `regressions` and sets
    `ok` to (no regressions)."""
    after = render(job_dir, screenshot_path=screenshot_path, **kw)
    if baseline is None:
        after["regressions"] = after["critical"]
        return after
    base_set = set(baseline.get("critical", []))
    after["regressions"] = [c for c in after["critical"] if c not in base_set]
    after["ok"] = not after["regressions"]
    return after


def diff_ratio(before_png, after_png):
    """Fraction of pixels that changed between two screenshots (0.0 = identical .. 1.0 = all).

    Used to catch an edit that nuked the layout: a tiny copy change should be a small ratio; a
    blank/broken page jumps high. Images are normalised to the same size before comparing.
    """
    from PIL import Image, ImageChops
    a = Image.open(before_png).convert("RGB")
    b = Image.open(after_png).convert("RGB")
    if a.size != b.size:
        b = b.resize(a.size)
    diff = ImageChops.difference(a, b)
    bbox = diff.getbbox()
    if not bbox:
        return 0.0
    # Mean per-pixel difference, normalised to [0,1].
    hist = diff.convert("L").histogram()
    total = sum(hist)
    changed = sum(hist[16:])  # pixels differing by more than ~6% luma
    return round(changed / total, 4) if total else 0.0


def summarize(result):
    """One-line human summary for Slack/logs."""
    if result["ok"]:
        extra = f" ({len(result['warnings'])} non-critical)" if result["warnings"] else ""
        return f"✅ renders clean{extra}"
    return "❌ " + "; ".join(result["critical"][:4])


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("usage: python3 verify.py <job_dir> [screenshot.png]\n"
              "       python3 verify.py hero <job_dir>   # locate the hero image")
        sys.exit(2)
    if sys.argv[1] == "hero":
        print(json.dumps(hero_image(sys.argv[2]), indent=2))
        sys.exit(0)
    if sys.argv[1] == "locate":
        print(json.dumps(locate_section(sys.argv[2], sys.argv[3]), indent=2))
        sys.exit(0)
    job = sys.argv[1]
    shot = sys.argv[2] if len(sys.argv) > 2 else str(Path(job) / ".verify" / "screenshot.png")
    res = render(job, screenshot_path=shot)
    print(summarize(res))
    print(json.dumps(res, indent=2))
