#!/usr/bin/env python3
"""Generate deterministic source-contract parity evidence for HaPantheon.

The package has no independent pre-WordPress HTML deployment or runnable
WordPress database. The frozen "source" fixtures are therefore explicitly
bound to the attached production DOM/CSS/JS contract, while the candidate side
is regenerated from the theme's production PHP block callbacks on every run.
This script never claims live original-vs-WordPress verification.
"""

from __future__ import annotations

import base64
import hashlib
import json
import os
import shutil
import subprocess
import sys
import mimetypes
import re
from pathlib import Path
from typing import Any

import numpy as np
from PIL import Image
from playwright.sync_api import Page, sync_playwright

PARITY = Path(__file__).resolve().parent
THEME = PARITY.parents[1]
SOURCE_DIR = PARITY / "fixtures" / "source"
CANDIDATE_DIR = PARITY / "fixtures" / "wordpress"
SCREENSHOT_DIR = PARITY / "screenshots"
DOM_DIR = PARITY / "dom"
STYLE_DIR = PARITY / "computed-styles"
INTERACTION_DIR = PARITY / "interactions"
RENDERER = PARITY / "render-wordpress-fixtures.php"
CHROMIUM = os.environ.get("HAPANTHEON_CHROMIUM", shutil.which("chromium") or shutil.which("chromium-browser") or "")

PAGES = ("home", "about", "oracle", "faq", "contact", "recipe")
VIEWPORTS = {
    "desktop-1440x900": {"width": 1440, "height": 900},
    "mobile-390x844": {"width": 390, "height": 844},
}
COMMON_SELECTORS = (
    "html", "body", ".nav", ".wrap", "main", ".footer", ".eyebrow",
    ".btn-gold", ".btn-teal", ".btn-line", ".rv",
)
PAGE_SELECTORS = {
    "home": (".gate", ".gate-track", ".slice", ".plaque", ".stats", ".rooms", ".rooms-grid", ".temple", ".top10", ".t10", ".featured", ".news"),
    "about": (".page-hero", ".manifesto", ".pillars", ".pillar", ".timeline", ".tl", ".process", ".proc", ".keeper", ".news"),
    "oracle": (".ohero", ".o-cards", ".o-card", ".o-ask", ".top10", ".t10"),
    "faq": (".page-hero", ".faq-tabs", ".faq-wrap", ".faq", ".faq-more"),
    "contact": (".page-hero", ".contact-grid", ".cform", ".seg", ".contact-aside", ".contact-channel"),
    "recipe": (".recipe", ".panel", ".steps", ".rtips", ".rgallery", ".gframe", ".vframe"),
}
STYLE_PROPERTIES = (
    "display", "position", "boxSizing", "width", "height", "minWidth", "maxWidth",
    "marginTop", "marginRight", "marginBottom", "marginLeft",
    "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
    "gridTemplateColumns", "gridAutoFlow", "gap", "alignItems", "justifyContent",
    "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing",
    "color", "backgroundColor", "backgroundImage", "borderTopWidth", "borderTopColor",
    "borderRadius", "boxShadow", "opacity", "overflow", "transform", "visibility",
)
BLOCKER = (
    "The supplied package contains no pre-WordPress HTML files, original deployment URL, "
    "reference screenshots, database export, or runnable WordPress fixture. Independent "
    "original-source versus live-WordPress parity cannot be produced from the supplied inputs."
)


def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def dump_json(path: Path, value: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")




def self_contained_html(source: Path) -> str:
    """Inline packaged CSS, JavaScript, and local images for policy-safe rendering."""
    html = source.read_text(encoding="utf-8")
    for asset in ("pantheon.css", "wordpress.css"):
        marker = f'<link rel="stylesheet" href="../../../../assets/css/{asset}">'
        css = (THEME / "assets" / "css" / asset).read_text(encoding="utf-8")
        html = html.replace(marker, f"<style data-fixture-asset=\"{asset}\">{css}</style>")
    script_marker = '<script src="../../../../assets/js/pantheon.js"></script>'
    javascript = (THEME / "assets" / "js" / "pantheon.js").read_text(encoding="utf-8")
    html = html.replace(script_marker, f"<script data-fixture-asset=\"pantheon.js\">{javascript}</script>")

    def embed(match: re.Match[str]) -> str:
        attr, relative = match.group(1), match.group(2)
        local = (source.parent / relative).resolve()
        try:
            local.relative_to(THEME.resolve())
        except ValueError as exc:
            raise RuntimeError(f"Fixture asset escapes theme root: {relative}") from exc
        if not local.is_file():
            raise RuntimeError(f"Fixture asset is missing: {local}")
        mime = mimetypes.guess_type(local.name)[0] or "application/octet-stream"
        encoded = base64.b64encode(local.read_bytes()).decode("ascii")
        return f'{attr}="data:{mime};base64,{encoded}"'

    html = re.sub(r'(src|poster)="(\.\./\.\./\.\./\.\./assets/[^"?#]+)"', embed, html)
    return html

def regenerate_candidate() -> None:
    CANDIDATE_DIR.mkdir(parents=True, exist_ok=True)
    for path in CANDIDATE_DIR.glob("*.html"):
        path.unlink()
    subprocess.run(["php", str(RENDERER), str(CANDIDATE_DIR)], check=True, cwd=THEME)
    missing = [slug for slug in PAGES if not (CANDIDATE_DIR / f"{slug}.html").is_file()]
    if missing:
        raise RuntimeError(f"Renderer omitted fixtures: {', '.join(missing)}")


def normalized_dom(page: Page) -> dict[str, Any]:
    return page.evaluate(
        """() => {
          function walk(node) {
            if (node.nodeType === Node.TEXT_NODE) {
              const text = (node.nodeValue || '').replace(/\\s+/g, ' ').trim();
              return text ? {type: 'text', text} : null;
            }
            if (node.nodeType !== Node.ELEMENT_NODE) return null;
            const attrs = {};
            Array.from(node.attributes).sort((a,b) => a.name.localeCompare(b.name)).forEach((a) => {
              attrs[a.name] = a.value.replace(/fixture-nonce/g, 'NONCE');
            });
            const children = Array.from(node.childNodes).map(walk).filter(Boolean);
            return {type: 'element', tag: node.tagName.toLowerCase(), attrs, children};
          }
          return walk(document.documentElement);
        }"""
    )


def computed_styles(page: Page, selectors: tuple[str, ...]) -> dict[str, Any]:
    return page.evaluate(
        """({selectors, properties}) => {
          const out = {};
          selectors.forEach((selector) => {
            out[selector] = Array.from(document.querySelectorAll(selector)).slice(0, 8).map((el, index) => {
              const cs = getComputedStyle(el);
              const values = {};
              properties.forEach((property) => { values[property] = cs[property]; });
              return {index, tag: el.tagName.toLowerCase(), id: el.id || '', className: el.className || '', values};
            });
          });
          return out;
        }""",
        {"selectors": list(selectors), "properties": list(STYLE_PROPERTIES)},
    )


def interaction_checks(page: Page, slug: str, viewport_name: str) -> dict[str, Any]:
    result: dict[str, Any] = {"page": slug, "viewport": viewport_name, "checks": {}, "pass": True}

    def record(name: str, passed: bool, details: Any) -> None:
        result["checks"][name] = {"pass": bool(passed), "details": details}
        result["pass"] = result["pass"] and bool(passed)

    # Global theme control and generated accessibility interface.
    if page.locator(".tgl").count():
        page.locator(".tgl").first.click()
        theme = page.locator("html").get_attribute("data-theme")
        record("theme-toggle", theme == "dark", {"themeAfterClick": theme})
        page.locator(".tgl").first.click()
    if page.locator(".a11y-btn").count():
        page.locator(".a11y-btn").click()
        opened = page.locator(".a11y-panel").evaluate("el => el.classList.contains('open')")
        page.locator('.a11y-opt[data-acc="contrast"]').click()
        root_has = page.locator("html").evaluate("el => el.classList.contains('acc-contrast')")
        page.locator(".a11y-reset").click()
        reset = not page.locator("html").evaluate("el => el.classList.contains('acc-contrast')")
        record("accessibility-panel", opened and root_has and reset, {"opened": opened, "optionApplied": root_has, "reset": reset})

    if slug in ("home", "oracle") and page.locator(".t10tab").count() > 1:
        second = page.locator(".t10tab").nth(1)
        panel_id = second.get_attribute("data-panel")
        second.click()
        selected = second.get_attribute("aria-selected")
        active = page.locator(f"#{panel_id}").evaluate("el => el.classList.contains('act')") if panel_id else False
        record("ranked-tabs", selected == "true" and active, {"selected": selected, "panel": panel_id, "active": active})

    if slug == "home" and viewport_name.startswith("mobile"):
        buttons = page.locator(".gate-pag button")
        if buttons.count() > 1:
            buttons.nth(1).click()
            active_index = page.locator(".gate .slice").evaluate_all("els => els.findIndex(el => el.classList.contains('act'))")
            current = buttons.nth(1).get_attribute("aria-current")
            record("mobile-gate-pagination", active_index == 1 and current == "true", {"activeIndex": active_index, "ariaCurrent": current})
        else:
            record("mobile-gate-pagination", False, {"buttonCount": buttons.count()})

    if slug == "faq":
        tabs = page.locator(".faq-tabs .tab")
        if tabs.count() > 1:
            tabs.nth(1).click()
            selected = tabs.nth(1).get_attribute("aria-selected")
            visibility = page.locator(".faq-wrap .faq").evaluate_all(
                "els => els.map(el => ({category: el.dataset.fcat, hidden: el.hidden, open: el.open}))"
            )
            expected_category = tabs.nth(1).get_attribute("data-fcat")
            visible = [item for item in visibility if not item["hidden"]]
            filter_ok = bool(visible) and all(item["category"] == expected_category for item in visible) and visible[0]["open"]
            record("faq-filter", selected == "true" and filter_ok, {"selected": selected, "category": expected_category, "items": visibility})
            if len(visible) > 1:
                page.locator(f'.faq-wrap .faq[data-fcat="{expected_category}"] summary').nth(1).click()
                page.wait_for_timeout(40)
                open_count = page.locator(".faq-wrap .faq[open]").count()
                record("faq-single-open", open_count == 1, {"openCount": open_count})
            else:
                record("faq-single-open", True, {"notApplicable": "filtered category contains one item"})

    if slug == "contact":
        buttons = page.locator(".seg button")
        hidden = page.locator("[data-hapantheon-contact-type]")
        if buttons.count() > 1 and hidden.count():
            expected = buttons.nth(1).get_attribute("data-contact-type") or buttons.nth(1).inner_text().strip()
            buttons.nth(1).click()
            value = hidden.input_value()
            pressed = buttons.nth(1).get_attribute("aria-pressed")
            record("contact-segment", value == expected and pressed == "true", {"expected": expected, "value": value, "ariaPressed": pressed})
        else:
            record("contact-segment", False, {"buttons": buttons.count(), "hiddenInputs": hidden.count()})

    if slug == "recipe":
        frames = page.locator(".gframe")
        if frames.count():
            frames.first.click()
            opened = page.locator(".lightbox").evaluate("el => el.classList.contains('open')")
            caption = page.locator(".lightbox figcaption").inner_text()
            page.keyboard.press("Escape")
            closed = not page.locator(".lightbox").evaluate("el => el.classList.contains('open')")
            record("recipe-lightbox", opened and closed and bool(caption.strip()), {"opened": opened, "closed": closed, "caption": caption})
        else:
            record("recipe-lightbox", False, {"frameCount": 0})
        video = page.locator(".vframe")
        if video.count():
            video.click()
            iframe = page.locator(".vframe iframe")
            src = iframe.get_attribute("src") if iframe.count() else ""
            record("recipe-video", iframe.count() == 1 and "youtube-nocookie.com/embed/" in (src or ""), {"iframeCount": iframe.count(), "src": src})

    return result


def capture_one(browser, side: str, slug: str, viewport_name: str, viewport: dict[str, int]) -> dict[str, Any]:
    source = (SOURCE_DIR if side == "source" else CANDIDATE_DIR) / f"{slug}.html"
    context = browser.new_context(viewport=viewport, reduced_motion="reduce", locale="he-IL")
    context.route("**/*", lambda route: route.continue_() if route.request.url.startswith("data:") or route.request.url.startswith("about:") else route.abort())
    page = context.new_page()
    errors: list[str] = []
    page.on("pageerror", lambda exc: errors.append(str(exc)))
    page.add_init_script("try { localStorage.clear(); } catch (e) {}")
    page.set_content(self_contained_html(source), wait_until="load")
    page.wait_for_timeout(60)
    page.locator("html").evaluate("el => { el.setAttribute('data-theme','light'); el.className = el.className.replace(/acc-[^ ]+/g, '').trim(); }")
    page.locator(".rv").evaluate_all("els => els.forEach(el => el.classList.add('in'))")
    page.wait_for_timeout(30)

    selectors = tuple(dict.fromkeys(COMMON_SELECTORS + PAGE_SELECTORS[slug]))
    dom = normalized_dom(page)
    styles = computed_styles(page, selectors)
    dom_payload = {"side": side, "page": slug, "viewport": viewport_name, "dom": dom}
    style_payload = {"side": side, "page": slug, "viewport": viewport_name, "selectors": styles}
    dump_json(DOM_DIR / side / f"{slug}-{viewport_name}.json", dom_payload)
    dump_json(STYLE_DIR / side / f"{slug}-{viewport_name}.json", style_payload)

    shot = SCREENSHOT_DIR / side / f"{slug}-{viewport_name}.png"
    shot.parent.mkdir(parents=True, exist_ok=True)
    page.screenshot(path=str(shot), full_page=False, animations="disabled")

    interactions = interaction_checks(page, slug, viewport_name)
    interactions["pageErrors"] = errors
    interactions["pass"] = interactions["pass"] and not errors
    dump_json(INTERACTION_DIR / side / f"{slug}-{viewport_name}.json", interactions)
    context.close()

    dom_bytes = json.dumps(dom, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
    style_bytes = json.dumps(styles, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
    return {
        "htmlSha256": sha256(source),
        "domSha256": hashlib.sha256(dom_bytes).hexdigest(),
        "computedStylesSha256": hashlib.sha256(style_bytes).hexdigest(),
        "screenshotPath": str(shot.relative_to(THEME)),
        "screenshotSha256": sha256(shot),
        "interactionPass": interactions["pass"],
        "interactionPath": str((INTERACTION_DIR / side / f"{slug}-{viewport_name}.json").relative_to(THEME)),
        "pageErrors": errors,
    }


def pixel_compare(source_path: Path, candidate_path: Path) -> dict[str, Any]:
    left = np.asarray(Image.open(source_path).convert("RGBA"), dtype=np.int16)
    right = np.asarray(Image.open(candidate_path).convert("RGBA"), dtype=np.int16)
    if left.shape != right.shape:
        return {"pass": False, "sourceShape": list(left.shape), "candidateShape": list(right.shape), "differentPixels": None}
    delta = np.abs(left - right)
    pixel_diff = np.max(delta, axis=2)
    different = int(np.count_nonzero(pixel_diff))
    total = int(pixel_diff.size)
    return {
        "pass": different == 0,
        "sourceShape": list(left.shape),
        "candidateShape": list(right.shape),
        "differentPixels": different,
        "totalPixels": total,
        "differentPercent": (different / total * 100.0) if total else 0.0,
        "maxChannelDelta": int(delta.max()) if delta.size else 0,
        "meanChannelDelta": float(delta.mean()) if delta.size else 0.0,
    }


def main() -> int:
    if not SOURCE_DIR.is_dir() or any(not (SOURCE_DIR / f"{page}.html").is_file() for page in PAGES):
        raise RuntimeError("Frozen source-contract fixtures are missing.")
    if not CHROMIUM:
        raise RuntimeError("Chromium executable is required; set HAPANTHEON_CHROMIUM.")

    regenerate_candidate()
    for directory in (SCREENSHOT_DIR, DOM_DIR, STYLE_DIR, INTERACTION_DIR):
        directory.mkdir(parents=True, exist_ok=True)

    results: dict[str, Any] = {}
    with sync_playwright() as playwright:
        browser = playwright.chromium.launch(executable_path=CHROMIUM, headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
        try:
            for slug in PAGES:
                results[slug] = {}
                for viewport_name, viewport in VIEWPORTS.items():
                    key = viewport_name
                    source_result = capture_one(browser, "source", slug, viewport_name, viewport)
                    candidate_result = capture_one(browser, "wordpress", slug, viewport_name, viewport)
                    shot_source = SCREENSHOT_DIR / "source" / f"{slug}-{viewport_name}.png"
                    shot_candidate = SCREENSHOT_DIR / "wordpress" / f"{slug}-{viewport_name}.png"
                    pixels = pixel_compare(shot_source, shot_candidate)
                    dom_equal = source_result["domSha256"] == candidate_result["domSha256"]
                    styles_equal = source_result["computedStylesSha256"] == candidate_result["computedStylesSha256"]
                    interactions_equal = source_result["interactionPass"] and candidate_result["interactionPass"]
                    results[slug][key] = {
                        "source": source_result,
                        "wordpress": candidate_result,
                        "domEquivalent": dom_equal,
                        "computedStylesEquivalent": styles_equal,
                        "screenshotsEquivalent": pixels["pass"],
                        "screenshotDiff": pixels,
                        "interactionsPass": interactions_equal,
                        "pass": dom_equal and styles_equal and pixels["pass"] and interactions_equal,
                    }
        finally:
            browser.close()

    contract_pass = all(sample["pass"] for page in results.values() for sample in page.values())
    summary = {
        "schemaVersion": 1,
        "theme": "HaPantheon",
        "themeVersion": "2.1.0",
        "generatedAtUtc": "2026-08-12T00:00:00Z",
        "runtimeMode": "deterministic-attached-source-contract",
        "sourceReferenceKind": "frozen DOM contract mechanically derived from the attached production selectors and production PHP render mapping",
        "independentUpstreamReferenceAvailable": False,
        "liveWordPressVerified": False,
        "contractFixturePass": contract_pass,
        "blocker": BLOCKER,
        "stabilization": [
            "prefers-reduced-motion is forced for deterministic rendering",
            "reveal nodes receive the production .in state before same-viewport capture",
            "candidate fixtures are regenerated from production PHP callbacks on every run",
        ],
        "coverage": {
            "pages": list(PAGES),
            "viewports": VIEWPORTS,
            "evidenceTypes": ["paired screenshots", "normalized browser DOM", "computed styles", "interaction results"],
            "scope": "all corrected imported source-block compositions; untouched PHP templates/assets are hash-inventoried separately",
        },
        "results": results,
    }
    dump_json(PARITY / "summary.json", summary)
    print(json.dumps({"contractFixturePass": contract_pass, "samples": sum(len(v) for v in results.values()), "blocker": BLOCKER}, ensure_ascii=False))
    return 0 if contract_pass else 1


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as exc:  # fail closed with a machine-readable failure marker.
        dump_json(PARITY / "summary.json", {
            "schemaVersion": 1,
            "theme": "HaPantheon",
            "themeVersion": "2.1.0",
            "runtimeMode": "deterministic-attached-source-contract",
            "independentUpstreamReferenceAvailable": False,
            "liveWordPressVerified": False,
            "contractFixturePass": False,
            "blocker": BLOCKER,
            "runnerError": str(exc),
        })
        print(f"source-parity runner failed: {exc}", file=sys.stderr)
        raise
