#!/usr/bin/env python3
"""Fail-closed static and deterministic renderer validation for HaPantheon."""
from __future__ import annotations

import hashlib
import json
import os
import re
import shutil
import struct
import subprocess
import sys
from pathlib import Path

EXPECTED_BLOCKS = {
    "page-hero", "gate", "stats", "rooms", "top10", "featured",
    "oracle-band", "newsletter-section", "manifesto", "pillars",
    "timeline", "process", "keeper", "oracle-hero", "oracle-cards",
    "oracle-ask", "faq-list", "contact-layout", "recipe",
}
CORRECTED_DEMOS = {
    "index.blocks.html": {"gate", "stats", "rooms", "top10", "featured", "oracle-band", "newsletter-section"},
    "about.blocks.html": {"page-hero", "manifesto", "stats", "pillars", "timeline", "process", "keeper", "newsletter-section"},
    "oracle.blocks.html": {"oracle-hero", "oracle-cards", "top10", "oracle-ask"},
    "faq.blocks.html": {"page-hero", "faq-list"},
    "contact.blocks.html": {"page-hero", "contact-layout"},
    "recipe-body.blocks.html": {"recipe"},
}
PAGES = ("home", "about", "oracle", "faq", "contact", "recipe")
VIEWPORTS = ("desktop-1440x900", "mobile-390x844")
BLOCKER = (
    "The supplied package contains no pre-WordPress HTML files, original deployment URL, "
    "reference screenshots, database export, or runnable WordPress fixture."
)


def fail(message: str) -> "NoReturn":
    print(f"[validate-theme] ERROR: {message}", file=sys.stderr)
    raise SystemExit(1)


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 load_json(path: Path):
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        fail(f"invalid JSON {path}: {exc}")


def command(name: str) -> str:
    found = shutil.which(name)
    if not found:
        fail(f"required command is unavailable: {name}")
    return found


def png_dimensions(path: Path) -> tuple[int, int]:
    data = path.read_bytes()[:24]
    if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR":
        fail(f"not a valid PNG: {path}")
    return struct.unpack(">II", data[16:24])


def require_file(root: Path, relative: str, minimum: int = 1) -> Path:
    path = root / relative
    if not path.is_file() or path.stat().st_size < minimum:
        fail(f"required non-empty file missing: {relative}")
    return path


def main() -> int:
    if len(sys.argv) != 3:
        fail("usage: validate-theme.py THEME_ROOT SCRATCH_DIR")
    root = Path(sys.argv[1]).resolve()
    scratch = Path(sys.argv[2]).resolve()
    if not root.is_dir() or root.name != "hapantheon":
        fail("THEME_ROOT must be an existing directory named hapantheon")
    if not scratch.is_dir():
        fail("SCRATCH_DIR must already exist")
    try:
        scratch.relative_to(root.parent)
    except ValueError:
        # The external installer deliberately supplies a private extraction sibling.
        pass

    for path in root.rglob("*"):
        if path.is_symlink():
            fail(f"symlink is not allowed in theme package: {path.relative_to(root)}")
        if "__pycache__" in path.parts or path.suffix == ".pyc":
            fail(f"Python cache artifact is not allowed: {path.relative_to(root)}")

    required = [
        "style.css", "functions.php", "index.php", "screenshot.png", "theme.json",
        "SOURCE-PRESERVATION-MANIFEST.json", "inc/source-blocks.php",
        "assets/css/pantheon.css", "assets/css/wordpress.css",
        "assets/css/editor-source-blocks.css", "assets/js/pantheon.js",
        "assets/js/source-blocks.js", "inc/importer.php",
        "verification/source-parity/inventory.json",
        "verification/source-parity/source-reference.json",
        "verification/source-parity/summary.json",
        "verification/source-parity/render-wordpress-fixtures.php",
        "verification/source-parity/run-parity.py",
    ]
    for relative in required:
        require_file(root, relative)

    style = (root / "style.css").read_text(encoding="utf-8")
    for marker in ("Theme Name: HaPantheon", "Version: 2.1.0", "Text Domain: hapantheon"):
        if marker not in style:
            fail(f"style.css metadata missing: {marker}")
    if "Stable tag: 2.1.0" not in (root / "readme.txt").read_text(encoding="utf-8"):
        fail("readme.txt stable tag is not 2.1.0")
    png_dimensions(root / "screenshot.png")

    # Every shipped JSON document must parse.
    for path in root.rglob("*.json"):
        load_json(path)

    manifest = load_json(root / "SOURCE-PRESERVATION-MANIFEST.json")
    inventory = load_json(root / "verification/source-parity/inventory.json")
    summary = load_json(root / "verification/source-parity/summary.json")
    source_reference = load_json(root / "verification/source-parity/source-reference.json")

    if manifest.get("theme", {}).get("version") != "2.1.0":
        fail("source manifest theme version mismatch")
    if len(manifest.get("blocks", [])) != len(EXPECTED_BLOCKS):
        fail("source manifest does not contain all 19 source blocks")
    manifest_blocks = {entry.get("output", "").removeprefix("hapantheon/") for entry in manifest.get("blocks", [])}
    if manifest_blocks != EXPECTED_BLOCKS:
        fail(f"source manifest block set mismatch: {sorted(manifest_blocks ^ EXPECTED_BLOCKS)}")
    if len(manifest.get("templates", [])) < 16:
        fail("source manifest template inventory is incomplete")
    if len(inventory.get("cssFiles", {}).get("assets/css/pantheon.css", {}).get("rules", [])) < 700:
        fail("production CSS selector inventory is unexpectedly incomplete")
    if len(inventory.get("breakpoints", [])) < 20:
        fail("responsive breakpoint inventory is unexpectedly incomplete")
    if not manifest.get("blocker") or BLOCKER not in manifest.get("blocker", ""):
        fail("exact upstream/live parity blocker is missing from manifest")
    if BLOCKER not in source_reference.get("blocker", ""):
        fail("exact blocker is missing from source-reference.json")

    production = manifest.get("sourceReference", {})
    for key in ("attachedProductionCss", "attachedProductionJavascript"):
        entry = production.get(key, {})
        path = require_file(root, entry.get("path", ""))
        if sha256(path) != entry.get("sha256"):
            fail(f"production asset hash mismatch: {entry.get('path')}")
        if entry.get("unchangedFromAttachedSource") is not True:
            fail(f"production asset is not marked source-identical: {entry.get('path')}")
    for asset in inventory.get("assetFiles", []):
        path = require_file(root, asset["path"])
        if sha256(path) != asset.get("sha256"):
            fail(f"asset hash mismatch: {asset['path']}")
        if asset.get("sourceAssetUnchanged") is not True:
            fail(f"attached image/favicon changed: {asset['path']}")

    source_php = (root / "inc/source-blocks.php").read_text(encoding="utf-8")
    cases = set(re.findall(r"case\s+'([a-z0-9-]+)'\s*:", source_php))
    if cases != EXPECTED_BLOCKS:
        fail(f"renderer switch block set mismatch: {sorted(cases ^ EXPECTED_BLOCKS)}")
    if "get_block_wrapper_attributes" not in source_php:
        fail("source block roots are not using WordPress block wrapper attributes")
    if "postSlug" not in source_php or "get_page_by_path" not in source_php:
        fail("featured post slug resolution is missing")

    comment_pattern = re.compile(r"<!--\s+wp:hapantheon/([a-z0-9-]+)\s+(\{.*?\})\s+/-->", re.S)
    for filename, expected in CORRECTED_DEMOS.items():
        text = require_file(root, f"demo/content/{filename}").read_text(encoding="utf-8")
        if re.search(r"<!--\s+wp:(?:group|columns|column|html)\b", text):
            fail(f"generic layout/HTML block found in corrected demo: {filename}")
        matches = comment_pattern.findall(text)
        names = set()
        for name, raw_attributes in matches:
            names.add(name)
            try:
                attributes = json.loads(raw_attributes)
            except Exception as exc:
                fail(f"invalid block attributes in {filename}/{name}: {exc}")
            if not isinstance(attributes, dict):
                fail(f"block attributes are not an object in {filename}/{name}")
        if names != expected:
            fail(f"source block composition mismatch in {filename}: expected {sorted(expected)}, got {sorted(names)}")

    page_template = (root / "templates/page-full-layout.php").read_text(encoding="utf-8")
    if "the_content();" not in page_template or "<article" in page_template:
        fail("full design template does not emit block roots directly")
    importer = (root / "inc/importer.php").read_text(encoding="utf-8")
    if "add_menu_page" not in importer or "__( 'Import', 'hapantheon' )" not in importer:
        fail("top-level Import admin menu contract is missing")
    if "replace_existing" not in importer:
        fail("idempotent replacement control is missing")

    php = command("php")
    node = command("node")
    for path in sorted(root.rglob("*.php")):
        result = subprocess.run([php, "-l", str(path)], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        if result.returncode:
            fail(f"PHP syntax failure in {path.relative_to(root)}: {result.stdout.strip()}")
    for path in sorted((root / "assets/js").glob("*.js")):
        result = subprocess.run([node, "--check", str(path)], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        if result.returncode:
            fail(f"JavaScript syntax failure in {path.relative_to(root)}: {result.stdout.strip()}")
    result = subprocess.run([sys.executable, "-m", "py_compile", str(root / "verification/source-parity/run-parity.py")], env={**os.environ, "PYTHONPYCACHEPREFIX": str(scratch / "pycache")}, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    if result.returncode:
        fail(f"parity runner Python syntax failure: {result.stdout.strip()}")

    if summary.get("contractFixturePass") is not True:
        fail("packaged deterministic parity summary is not passing")
    if summary.get("liveWordPressVerified") is not False or summary.get("independentUpstreamReferenceAvailable") is not False:
        fail("parity summary improperly claims unavailable live/upstream verification")
    if BLOCKER not in summary.get("blocker", ""):
        fail("parity summary omits the exact blocker")
    results = summary.get("results", {})
    for page in PAGES:
        if page not in results:
            fail(f"parity summary missing page: {page}")
        for viewport in VIEWPORTS:
            sample = results[page].get(viewport)
            if not sample or sample.get("pass") is not True:
                fail(f"parity sample is not passing: {page}/{viewport}")
            if sample.get("screenshotDiff", {}).get("differentPixels") != 0:
                fail(f"nonzero screenshot divergence: {page}/{viewport}")
            for side in ("source", "wordpress"):
                require_file(root, f"verification/source-parity/screenshots/{side}/{page}-{viewport}.png", 100)
                png_dimensions(root / f"verification/source-parity/screenshots/{side}/{page}-{viewport}.png")
                require_file(root, f"verification/source-parity/dom/{side}/{page}-{viewport}.json", 100)
                require_file(root, f"verification/source-parity/computed-styles/{side}/{page}-{viewport}.json", 100)
                interaction = load_json(require_file(root, f"verification/source-parity/interactions/{side}/{page}-{viewport}.json", 100))
                if interaction.get("pass") is not True:
                    fail(f"interaction evidence is not passing: {side}/{page}/{viewport}")

    # Re-render actual production PHP callbacks into private scratch and require
    # byte identity with the frozen source-contract fixtures.
    render_out = scratch / "rendered"
    render_out.mkdir(parents=True, exist_ok=True)
    result = subprocess.run(
        [php, str(root / "verification/source-parity/render-wordpress-fixtures.php"), str(render_out)],
        cwd=root,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
    )
    if result.returncode:
        fail(f"deterministic production renderer failed: {result.stdout.strip()}")
    for page in PAGES:
        frozen = require_file(root, f"verification/source-parity/fixtures/source/{page}.html")
        rendered = require_file(render_out, f"{page}.html")
        if frozen.read_bytes() != rendered.read_bytes():
            fail(f"production renderer differs from frozen source contract: {page}")

    print("[validate-theme] PASS: source blocks, assets, manifests, evidence, syntax, importer and deterministic render contract verified")
    print(f"[validate-theme] BLOCKER: {summary['blocker']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
