#!/usr/bin/env python3
"""Final code-first user-documentation closure gate for Nexamas UI.

This gate combines the six audit phases into one repeatable check. It validates
project/source alignment, the assembly-derived public API baseline, documentation
coverage, reconciled mismatch ledgers, public examples/recipe hosts, durable
Windows proof, recipe status, local links/anchors, and source-specific validators.
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import json
import re
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import unquote

BASELINE_PATH = "eng/tests/Nexamas.UI.DocumentationClosureBaseline.json"


def read_json(path: Path) -> dict:
    return json.loads(path.read_text(encoding="utf-8-sig"))


def run_validator(root: Path, script: Path, args: list[str] | None = None) -> dict:
    command = [sys.executable, str(script), *(args or [])]
    try:
        proc = subprocess.run(
            command,
            cwd=root,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            timeout=240,
        )
        output = proc.stdout.strip()
        lines = output.splitlines()
        if len(lines) > 80:
            output = "\n".join(["... output truncated ...", *lines[-80:]])
        return {
            "script": script.relative_to(root).as_posix(),
            "exitCode": proc.returncode,
            "status": "PASS" if proc.returncode == 0 else "FAIL",
            "output": output,
        }
    except subprocess.TimeoutExpired as exc:
        return {
            "script": script.relative_to(root).as_posix(),
            "exitCode": -1,
            "status": "FAIL",
            "output": f"Timed out after {exc.timeout} seconds",
        }


def heading_anchors(path: Path) -> set[str]:
    anchors: set[str] = set()
    duplicate_count: dict[str, int] = {}
    for raw in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
        match = re.match(r"^#{1,6}\s+(.+?)\s*#*\s*$", raw)
        if not match:
            continue
        text = re.sub(r"<[^>]+>", "", match.group(1))
        text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
        text = text.replace("`", "").strip().lower()
        text = re.sub(r"[^\w\- ]", "", text, flags=re.UNICODE)
        slug = re.sub(r"\s+", "-", text)
        count = duplicate_count.get(slug, 0)
        duplicate_count[slug] = count + 1
        anchors.add(slug if count == 0 else f"{slug}-{count}")
    return anchors


def scan_links(root: Path) -> tuple[int, int, list[str]]:
    files = sorted((root / "docs").rglob("*.md")) + sorted((root / "release-docs").rglob("*.md"))
    checked = 0
    anchors_checked = 0
    errors: list[str] = []
    anchor_cache: dict[Path, set[str]] = {}
    link_re = re.compile(r"(?<!!)\[[^\]]*\]\(([^)]+)\)")
    for source in files:
        text = source.read_text(encoding="utf-8-sig", errors="replace")
        for raw in link_re.findall(text):
            raw = raw.strip()
            if not raw or raw.startswith(("http://", "https://", "mailto:")):
                continue
            if raw.startswith("<") and raw.endswith(">"):
                raw = raw[1:-1]
            target_text, _, fragment = raw.partition("#")
            target_text = unquote(target_text.split()[0]) if target_text else ""
            target = source if not target_text else (source.parent / target_text).resolve()
            checked += 1
            if not target.exists():
                errors.append(f"{source.relative_to(root).as_posix()} -> {raw}: missing target")
                continue
            if fragment and target.suffix.lower() == ".md":
                anchors_checked += 1
                anchors = anchor_cache.setdefault(target, heading_anchors(target))
                if unquote(fragment).strip().lower() not in anchors:
                    errors.append(f"{source.relative_to(root).as_posix()} -> {raw}: missing anchor")
    return checked, anchors_checked, errors


def project_alignment(root: Path, expected_source_files: int) -> dict:
    project = root / "Nexamas.UI.vbproj"
    tree = ET.parse(project)
    ns = {"m": "http://schemas.microsoft.com/developer/msbuild/2003"}
    includes = []
    for node in tree.findall(".//m:Compile", ns):
        value = node.attrib.get("Include")
        if value:
            includes.append(value.replace("\\", "/"))
    missing = [value for value in includes if not (root / value).is_file()]
    source_roots = [root / "MASSystem", root / "Component", root / "My Project"]
    disk = []
    for source_root in source_roots:
        if source_root.exists():
            disk.extend(path.relative_to(root).as_posix() for path in source_root.rglob("*.vb"))
    include_set = set(includes)
    extra = sorted(set(disk) - include_set)
    duplicate = sorted({value for value in includes if includes.count(value) > 1})
    return {
        "compileIncludes": len(includes),
        "sourceFiles": len(disk),
        "missingCompileIncludes": missing,
        "sourceFilesNotCompiled": extra,
        "duplicateCompileIncludes": duplicate,
        "status": "PASS" if len(includes) == expected_source_files and len(disk) == expected_source_files and not missing and not extra and not duplicate else "FAIL",
    }


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--root", default=str(Path(__file__).resolve().parents[2]))
    parser.add_argument("--write", action="store_true")
    parser.add_argument("--skip-validator-runs", action="store_true")
    args = parser.parse_args()
    root = Path(args.root).resolve()
    failures: list[str] = []
    baseline = read_json(root / BASELINE_PATH)
    expected_api_hash = baseline["publicApiHash"]
    expected_types = int(baseline["consumerVisibleTypeCount"])
    expected_members = int(baseline["consumerVisibleMemberCount"])
    expected_source_files = int(baseline["compiledSourceFileCount"])
    expected_recipes = int(baseline["governedRecipeCount"])
    expected_batches = int(baseline["recipeBatchCount"])

    required = [
        BASELINE_PATH,
        "docs/_inventory/assembly-api/public-api-assembly.json",
        "docs/_inventory/public-api-documentation-coverage.csv",
        "docs/_inventory/phase3-documentation-mismatch-ledger.csv",
        "docs/_inventory/phase4-code-to-documentation-gap-ledger.csv",
        baseline["durableWindowsProof"],
        baseline["durableSnapshotProof"],
        "docs/recipes/build-verification-ledger.md",
        "tools/docs/requirements.txt",
        "tools/docs/Export-NexamasUIPublicApiMetadata.py",
        "tools/docs/Verify-NexamasUIPublicApiDrift.py",
        "tools/docs/Invoke-NexamasUIPublicApiDrift.ps1",
        "tools/docs/Invoke-NexamasUIDocumentationClosure.ps1",
        "eng/tests/Run-NexamasUIDocumentationSnippetProof.ps1",
        "eng/tests/Run-NexamasUIRenderSnapshotSmoke.ps1",
    ]
    missing_required = [item for item in required if not (root / item).is_file()]
    failures.extend(f"Missing required closure asset: {item}" for item in missing_required)

    alignment = project_alignment(root, expected_source_files)
    if alignment["status"] != "PASS":
        failures.append("Project/source alignment is not exact.")

    api = read_json(root / "docs/_inventory/assembly-api/public-api-assembly.json")
    api_checks = {
        "publicApiHash": api.get("publicApiHash"),
        "externallyVisibleTypeCount": api.get("externallyVisibleTypeCount"),
        "consumerVisibleDeclaredMemberCount": api.get("consumerVisibleDeclaredMemberCount"),
        "typesArrayCount": len(api.get("types", [])),
    }
    if api_checks["publicApiHash"] != expected_api_hash:
        failures.append(f"Public API hash drift: {api_checks['publicApiHash']} != {expected_api_hash}")
    if api_checks["externallyVisibleTypeCount"] != expected_types or api_checks["typesArrayCount"] != expected_types:
        failures.append(f"Consumer-visible public type count is not {expected_types}.")
    if api_checks["consumerVisibleDeclaredMemberCount"] != expected_members:
        failures.append(f"Consumer-visible member count is not {expected_members}.")

    with (root / "docs/_inventory/public-api-documentation-coverage.csv").open(encoding="utf-8-sig", newline="") as handle:
        coverage = list(csv.DictReader(handle))
    coverage_failures = [row["fullName"] for row in coverage if row.get("status") != "PASS"]
    if len(coverage) != expected_types:
        failures.append(f"Coverage matrix has {len(coverage)} rows instead of {expected_types}.")
    if coverage_failures:
        failures.append(f"Coverage matrix contains {len(coverage_failures)} non-PASS types.")

    with (root / "docs/_inventory/phase3-documentation-mismatch-ledger.csv").open(encoding="utf-8-sig", newline="") as handle:
        mismatch_rows = list(csv.DictReader(handle))
    unresolved_mismatches = [row.get("id", "?") for row in mismatch_rows if row.get("status") not in {"Fixed", "Verified", "PASS"}]
    if unresolved_mismatches:
        failures.append("Phase 3 mismatch ledger has unresolved rows: " + ", ".join(unresolved_mismatches))

    with (root / "docs/_inventory/phase4-code-to-documentation-gap-ledger.csv").open(encoding="utf-8-sig", newline="") as handle:
        gap_rows = list(csv.DictReader(handle))
    unresolved_gaps = [row.get("type", "?") for row in gap_rows if "PASS" not in row.get("phase4State", "")]
    if unresolved_gaps:
        failures.append(f"Phase 4 gap ledger has {len(unresolved_gaps)} unresolved types.")

    full_proof = read_json(root / baseline["durableWindowsProof"])
    snapshot_proof = read_json(root / baseline["durableSnapshotProof"])
    windows_ok = all([
        full_proof.get("status") == "PASS",
        full_proof.get("totalBatches") == expected_batches,
        full_proof.get("totalRecipes") == expected_recipes,
        full_proof.get("buildPassed") == expected_batches,
        full_proof.get("runtimeSmokePassed") == expected_batches,
        full_proof.get("failedBatches") == 0,
    ])
    snapshot_ok = snapshot_proof.get("status") == "PASS" and snapshot_proof.get("exitCode") == 0
    if not windows_ok:
        failures.append("Durable Windows documentation recipe proof is missing or invalid.")
    if not snapshot_ok:
        failures.append("Durable Render Verification snapshot proof is missing or invalid.")

    ledger = (root / "docs/recipes/build-verification-ledger.md").read_text(encoding="utf-8-sig")
    recipe_rows = re.findall(r"^\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|", ledger, re.M)
    governed = [(recipe, status.strip()) for recipe, status in recipe_rows if recipe != "Card.Basic"]
    non_build = [recipe for recipe, status in governed if status != "Build-verified"]
    if len(governed) != expected_recipes:
        failures.append(f"Recipe ledger has {len(governed)} governed rows instead of {expected_recipes}.")
    if non_build:
        failures.append("Recipes not Build-verified: " + ", ".join(non_build))

    stale_patterns = {
        "STATIC PASS / WINDOWS CLOSURE REQUIRED": "stale Phase 5 closure status",
        "Phase 5 still must compile": "stale pending Phase 5 statement",
        "Batch 4 Windows host prepared; build/runtime PASS still required": "stale Batch 4 pending statement",
        "Batch 5 Windows host prepared; build/runtime and optional snapshot PASS still required": "stale Batch 5 pending statement",
    }
    stale_hits: list[str] = []
    for path in [root / "docs/_inventory/documentation-status.md", root / "docs/_inventory/phase5-documentation-build-runtime-audit.md", root / "docs/recipes/build-verification-ledger.md"]:
        text = path.read_text(encoding="utf-8-sig")
        for pattern, label in stale_patterns.items():
            if pattern in text:
                stale_hits.append(f"{path.relative_to(root).as_posix()}: {label}")
    failures.extend(stale_hits)

    links_checked, anchors_checked, link_errors = scan_links(root)
    failures.extend(link_errors)

    validator_results: list[dict] = []
    if not args.skip_validator_runs:
        primary_validators = [
            (root / "tools/docs/Audit-NexamasUIPublicDocumentationCoverage.py", []),
            (root / "tools/docs/Audit-NexamasUIDocumentationSnippets.py", ["--write"]),
            (root / "tools/docs/Verify-NexamasUIDocumentationRecipeHosts.py", ["--write"]),
            (root / "tools/docs/Verify-NexamasUIRecipeVisualHardening.py", ["--write"]),
            (root / "tools/docs/Verify-PublicCoverageSupportingContracts.py", []),
        ]
        source_validators = sorted((root / "tools/docs").glob("Verify-*DocumentationSource.py"))
        seen = {path.resolve() for path, _ in primary_validators}
        for path, validator_args in primary_validators:
            result = run_validator(root, path, validator_args)
            validator_results.append(result)
            if result["status"] != "PASS":
                failures.append(f"Validator failed: {result['script']}")
        parallel_validators = [path for path in source_validators if path.resolve() not in seen]
        with ThreadPoolExecutor(max_workers=6) as executor:
            future_map = {executor.submit(run_validator, root, path, []): path for path in parallel_validators}
            for future in as_completed(future_map):
                result = future.result()
                validator_results.append(result)
                if result["status"] != "PASS":
                    failures.append(f"Validator failed: {result['script']}")
        validator_results.sort(key=lambda item: item["script"])

    report = {
        "schema": "nexamas-ui-user-documentation-closure/v1",
        "status": "PASS" if not failures else "FAIL",
        "baseline": baseline,
        "publicApi": api_checks,
        "projectAlignment": alignment,
        "documentationCoverage": {
            "types": len(coverage),
            "nonPassTypes": len(coverage_failures),
            "phase3UnresolvedMismatches": len(unresolved_mismatches),
            "phase4UnresolvedGaps": len(unresolved_gaps),
        },
        "recipeProof": {
            "governedRecipes": len(governed),
            "buildVerifiedRecipes": len(governed) - len(non_build),
            "windowsFullProof": "PASS" if windows_ok else "FAIL",
            "snapshotProof": "PASS" if snapshot_ok else "FAIL",
        },
        "links": {
            "localLinksChecked": links_checked,
            "anchorsChecked": anchors_checked,
            "errors": len(link_errors),
        },
        "validators": {
            "run": len(validator_results),
            "passed": sum(item["status"] == "PASS" for item in validator_results),
            "failed": sum(item["status"] != "PASS" for item in validator_results),
            "results": validator_results,
        },
        "failures": failures,
    }

    if args.write:
        out_json = root / "docs/_inventory/phase6-user-documentation-closure-summary.json"
        out_json.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
        md = [
            "# Phase 6 — Final User Documentation Closure",
            "",
            f"**Status:** `{report['status']}`",
            "",
            "## Closure result",
            "",
            "| Gate | Result |",
            "|---|---:|",
            f"| Project/source alignment | {alignment['status']} — {alignment['compileIncludes']} compile entries / {alignment['sourceFiles']} source files |",
            f"| Public API baseline | {'PASS' if not any('Public API' in f or 'type count' in f or 'member count' in f for f in failures) else 'FAIL'} — {api_checks['externallyVisibleTypeCount']} types / {api_checks['consumerVisibleDeclaredMemberCount']} members |",
            f"| Public API documentation coverage | {'PASS' if not coverage_failures else 'FAIL'} — {len(coverage)} / {expected_types} types |",
            f"| Documentation-to-code mismatches | {'PASS' if not unresolved_mismatches else 'FAIL'} — {len(unresolved_mismatches)} unresolved |",
            f"| Code-to-documentation gaps | {'PASS' if not unresolved_gaps else 'FAIL'} — {len(unresolved_gaps)} unresolved |",
            f"| Recipe build/runtime proof | {'PASS' if windows_ok else 'FAIL'} — {len(governed) - len(non_build)} / {expected_recipes} Build-verified |",
            f"| Render snapshot proof | {'PASS' if snapshot_ok else 'FAIL'} |",
            f"| Local links and anchors | {'PASS' if not link_errors else 'FAIL'} — {links_checked} links / {anchors_checked} anchors / {len(link_errors)} errors |",
            f"| Python documentation validators | {report['validators']['passed']} PASS / {report['validators']['failed']} FAIL |",
            "",
            "## Frozen truth baseline",
            "",
            f"- Public API hash: `{expected_api_hash}`",
            f"- Consumer-visible CLR types: `{expected_types}`",
            f"- Consumer-visible members: `{expected_members}`",
            f"- Compiled VB source files: `{expected_source_files}`",
            f"- Governed recipes: `{expected_recipes}`",
            "",
            "## Remaining failures",
            "",
        ]
        if failures:
            md.extend(f"- {failure}" for failure in failures)
        else:
            md.append("None. The six-stage user-documentation truth audit is closed.")
        md += [
            "",
            "## Change rule",
            "",
            "Any change to public API, project compile entries, package-visible usage, governed recipes, or user-facing claims must rerun this gate, the freshly built assembly drift gate (`tools/docs/Invoke-NexamasUIPublicApiDrift.ps1`), and the Windows documentation snippet proof before release.",
        ]
        (root / "docs/_inventory/phase6-user-documentation-closure.md").write_text("\n".join(md) + "\n", encoding="utf-8")

    print(json.dumps({
        "status": report["status"],
        "types": len(coverage),
        "members": api_checks["consumerVisibleDeclaredMemberCount"],
        "recipes": len(governed),
        "linksChecked": links_checked,
        "validatorsPassed": report["validators"]["passed"],
        "failures": len(failures),
    }, indent=2))
    return 0 if not failures else 1


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