#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import json
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path
from typing import Any

import yaml
from rdflib import Graph, Namespace, RDF

EXIT_OK = 0
EXIT_RUNTIME = 2
EXIT_CLOSURE = 6

GEARASH = Namespace("https://specs.kivanura.org/geara/shacl#")
SH = Namespace("http://www.w3.org/ns/shacl#")


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


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def json_obj(path: Path) -> Any:
    return json.loads(path.read_text(encoding="utf-8"))


def yaml_obj(path: Path) -> Any:
    return yaml.safe_load(path.read_text(encoding="utf-8"))


def rel_repo(path: Path, repo: Path) -> str:
    return path.relative_to(repo).as_posix()


def parse_sha256sums(path: Path) -> dict[str, str]:
    out: dict[str, str] = {}
    for n, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        parts = line.split(None, 1)
        if len(parts) != 2 or len(parts[0]) != 64:
            raise ValueError(f"Invalid SHA256SUMS line {n}: {raw}")
        digest, rel = parts
        rel = rel.lstrip("*").strip()
        out[rel] = digest.lower()
    return out


class ReleaseClosureRunner:
    def __init__(self, spec_dir: Path):
        self.spec_dir = spec_dir
        self.repo = spec_dir.parents[3]
        self.spec = self.repo / "docs/tools/geara/0.1/geara-0.1-specification.md"
        self.release = spec_dir / "release"
        self.manifest_path = self.release / "manifest.json"
        self.sums_path = self.release / "SHA256SUMS"
        self.results: list[dict[str, Any]] = []

    def add(self, check_id: str, category: str, ok: bool, summary: str, detail: Any = None) -> None:
        rec: dict[str, Any] = {
            "id": check_id,
            "category": category,
            "status": "PASS" if ok else "FAIL",
            "summary": summary,
        }
        if detail is not None:
            rec["detail"] = detail
        self.results.append(rec)

    def _expected_subject_paths(self) -> list[str]:
        paths = [rel_repo(self.spec, self.repo)]
        for p in sorted(self.spec_dir.rglob("*")):
            if not p.is_file():
                continue
            if self.release in p.parents:
                continue
            if "__pycache__" in p.parts or p.suffix == ".pyc":
                continue
            paths.append(rel_repo(p, self.repo))
        return sorted(paths)

    def _check_manifest_inventory(self, manifest: dict[str, Any]) -> None:
        entries = manifest.get("subjectFiles", [])
        actual_paths = self._expected_subject_paths()
        declared_paths = sorted(x.get("path") for x in entries)
        self.add(
            "A15-020",
            "inventory",
            declared_paths == actual_paths,
            "Release manifest inventories the complete frozen A1-A14 subject plus normative specification",
            {
                "declaredCount": len(declared_paths),
                "actualCount": len(actual_paths),
                "missing": sorted(set(actual_paths) - set(declared_paths)),
                "extra": sorted(set(declared_paths) - set(actual_paths)),
            },
        )

        defects: list[dict[str, Any]] = []
        for entry in entries:
            rel = entry["path"]
            path = self.repo / rel
            if not path.exists():
                defects.append({"path": rel, "issue": "missing"})
                continue
            digest = sha256_file(path)
            size = path.stat().st_size
            if digest != entry.get("sha256"):
                defects.append({"path": rel, "issue": "sha256", "expected": entry.get("sha256"), "actual": digest})
            if size != entry.get("sizeBytes"):
                defects.append({"path": rel, "issue": "sizeBytes", "expected": entry.get("sizeBytes"), "actual": size})
        self.add("A15-021", "inventory", not defects, "Every release-subject file matches its manifest SHA-256 and size", defects[:50])

    def _check_checksums(self, manifest: dict[str, Any]) -> None:
        sums = parse_sha256sums(self.sums_path)
        subject_paths = [x["path"] for x in manifest.get("subjectFiles", [])]
        support = manifest.get("checksumPolicy", {}).get("a15SupportFiles", [])
        expected_paths = sorted(set(subject_paths + support))
        declared_paths = sorted(sums)
        self.add(
            "A15-030",
            "checksums",
            declared_paths == expected_paths,
            "SHA256SUMS contains exactly the declared release subject and A15 support files",
            {
                "declaredCount": len(declared_paths),
                "expectedCount": len(expected_paths),
                "missing": sorted(set(expected_paths) - set(declared_paths)),
                "extra": sorted(set(declared_paths) - set(expected_paths)),
            },
        )
        defects: list[dict[str, Any]] = []
        for rel, expected in sums.items():
            path = self.repo / rel
            if not path.exists():
                defects.append({"path": rel, "issue": "missing"})
                continue
            actual = sha256_file(path)
            if actual != expected:
                defects.append({"path": rel, "expected": expected, "actual": actual})
        self.add("A15-031", "checksums", not defects, "SHA256SUMS validates every listed file", defects[:50])

    def _check_closure_metrics(self, manifest: dict[str, Any]) -> None:
        a1 = yaml_obj(self.spec_dir / "geara-0.1.architecture-registry.yaml")
        a2 = yaml_obj(self.spec_dir / "geara-0.1.relationship-registry.yaml")
        a3 = yaml_obj(self.spec_dir / "geara-0.1.invariant-catalogue.yaml")
        tc = json_obj(self.spec_dir / "testcases/manifest.json")
        examples = json_obj(self.spec_dir / "examples/geara-0.1.examples.json")
        a4 = Graph().parse(self.spec_dir / "geara-0.1.ttl", format="turtle")
        a6 = Graph().parse(self.spec_dir / "geara-0.1.shacl.ttl", format="turtle")
        coverage = Counter(str(x).rsplit("#", 1)[-1] for x in a6.objects(None, GEARASH.coverageStatus))
        node_shapes = set(a6.subjects(RDF.type, SH.NodeShape))
        assessment_required = sum(
            x.get("expected", {}).get("architectureAssessment") in {"required", "still-required"}
            for x in tc.get("testcases", [])
        )
        a13 = json_obj(self.spec_dir / "reconciliation/geara-0.1.reconciliation-report.json")
        a14 = json_obj(self.spec_dir / "regression/geara-0.1.regression-report.json")
        baseline = json_obj(self.spec_dir / "regression/geara-0.1.regression-baseline.json")
        actual = {
            "architectureElementCount": len(a1.get("architectureElements", [])),
            "relationshipCount": len(a2.get("relationships", [])),
            "invariantCount": len(a3.get("invariants", [])),
            "rdfTripleCount": len(a4),
            "shaclTripleCount": len(a6),
            "a6NodeShapeCount": len(node_shapes),
            "a6Coverage": {
                "PartialExecutable": coverage.get("PartialExecutable", 0),
                "HybridAssessmentRequired": coverage.get("HybridAssessmentRequired", 0),
                "AssessmentOnly": coverage.get("AssessmentOnly", 0),
            },
            "exampleCount": examples.get("exampleCount"),
            "testcaseCount": tc.get("testcaseCount"),
            "testcasesRequiringArchitectureAssessment": assessment_required,
            "a13ReconciliationCheckCount": a13.get("summary", {}).get("checks"),
            "a14RegressionCheckCount": a14.get("summary", {}).get("checks"),
            "a14ProtectedFileCount": baseline.get("protectedFileCount"),
            "a14MutationSentinelCount": a14.get("summary", {}).get("mutationSentinelCount"),
        }
        expected = manifest.get("closureMetrics", {})
        self.add(
            "A15-040",
            "architecture-boundary",
            actual == expected,
            "Release manifest accurately records the established architecture and machine-assessment boundary metrics",
            {"expected": expected, "actual": actual},
        )

    def _run_a12_twice(self, manifest: dict[str, Any]) -> None:
        runner = self.spec_dir / "conformance/run-geara-conformance.py"
        reports: list[bytes] = []
        parsed: list[dict[str, Any] | None] = []
        errors: list[str] = []
        exits: list[int] = []
        for _ in range(2):
            with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf:
                out = Path(tf.name)
            proc = subprocess.run(
                [sys.executable, str(runner), "--json-report", str(out)],
                cwd=self.repo,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                check=False,
            )
            exits.append(proc.returncode)
            errors.append(proc.stderr.decode("utf-8", errors="replace"))
            if out.exists():
                data = out.read_bytes()
                reports.append(data)
                try:
                    parsed.append(json.loads(data.decode("utf-8")))
                except Exception:
                    parsed.append(None)
                out.unlink(missing_ok=True)
            else:
                reports.append(b"")
                parsed.append(None)

        expected = manifest.get("requiredGates", {}).get("A12", {})
        first_summary = parsed[0].get("summary", {}) if isinstance(parsed[0], dict) else {}
        actual = {
            "testcasesExecuted": first_summary.get("testcasesExecuted"),
            "testcasesMatched": first_summary.get("testcasesMatched"),
            "testcasesMismatched": first_summary.get("testcasesMismatched"),
            "allExpectedOutcomesMatched": first_summary.get("allExpectedOutcomesMatched"),
            "a6ExecutableShapeCount": first_summary.get("a6ExecutableShapeCount"),
        }
        live_ok = exits[0] == 0 and actual == expected
        self.add(
            "A15-050",
            "A12-live",
            live_ok,
            "Live A12 machine-test gate remains at the release-required 70/70 result",
            {"expected": expected, "actual": actual, "exit": exits[0], "stderr": errors[0][-1500:]},
        )
        deterministic = len(reports) == 2 and reports[0] == reports[1] and reports[0] != b"" and exits == [0, 0]
        self.add(
            "A15-051",
            "A12-live",
            deterministic,
            "Repeated A12 complete-suite JSON reporting remains deterministic",
            {"reportSha256": sha256_bytes(reports[0]) if reports and reports[0] else None, "exits": exits},
        )

    def _check_canonical_a13(self, manifest: dict[str, Any]) -> None:
        report = json_obj(self.spec_dir / "reconciliation/geara-0.1.reconciliation-report.json")
        sm = report.get("summary", {})
        actual = {
            "checks": sm.get("checks"),
            "passed": sm.get("passed"),
            "failed": sm.get("failed"),
            "allChecksPassed": sm.get("allChecksPassed"),
        }
        expected = manifest.get("requiredGates", {}).get("A13", {})
        self.add("A15-052", "A13-canonical", actual == expected, "Canonical A13 reconciliation evidence remains at 84/84 PASS", {"expected": expected, "actual": actual})

    def _check_canonical_a14(self, manifest: dict[str, Any]) -> None:
        report = json_obj(self.spec_dir / "regression/geara-0.1.regression-report.json")
        sm = report.get("summary", {})
        actual = {
            "checks": sm.get("checks"),
            "passed": sm.get("passed"),
            "failed": sm.get("failed"),
            "allChecksPassed": sm.get("allChecksPassed"),
        }
        expected = manifest.get("requiredGates", {}).get("A14", {})
        self.add("A15-053", "A14-canonical", actual == expected, "Canonical A14 regression evidence remains at 33/33 PASS", {"expected": expected, "actual": actual})

    def _check_a14_protected_baseline(self) -> None:
        baseline = json_obj(self.spec_dir / "regression/geara-0.1.regression-baseline.json")
        defects: list[dict[str, Any]] = []
        entries = baseline.get("protectedFiles", [])
        for entry in entries:
            rel = entry.get("path")
            path = self.repo / rel
            if not path.exists():
                defects.append({"path": rel, "issue": "missing"})
                continue
            digest = sha256_file(path)
            size = path.stat().st_size
            if digest != entry.get("sha256"):
                defects.append({"path": rel, "issue": "sha256", "expected": entry.get("sha256"), "actual": digest})
            if size != entry.get("sizeBytes"):
                defects.append({"path": rel, "issue": "sizeBytes", "expected": entry.get("sizeBytes"), "actual": size})
        ok = len(entries) == baseline.get("protectedFileCount") == 102 and not defects
        self.add(
            "A15-054",
            "A14-protected-baseline",
            ok,
            "A14 protected A1-A13 baseline remains byte-intact in the release candidate",
            {"protectedFileCount": len(entries), "defects": defects[:50]},
        )

    def run(self) -> dict[str, Any]:
        manifest = json_obj(self.manifest_path)
        spec_identity = manifest.get("specification", {})
        identity_ok = (
            spec_identity.get("id") == "GEARA"
            and spec_identity.get("version") == "0.1"
            and manifest.get("artifactStage") == "GEARA-A15"
            and manifest.get("releaseIdentifier") == "GEARA-0.1-RC1"
            and manifest.get("releaseStatus") == "Executable Release Candidate"
            and manifest.get("closureVersion") == "0.1.0-rc1"
            and manifest.get("promotionTarget") == "Released"
            and manifest.get("artifactProgram", {}).get("plannedStages") == 15
            and manifest.get("artifactProgram", {}).get("completedStages") == 15
        )
        self.add("A15-001", "identity", identity_ok, "Release manifest identifies GEARA 0.1 RC1 / GEARA-A15 and a complete 15-stage programme")

        required_release_files = [
            "release/README.md",
            "release/RELEASE-NOTES.md",
            "release/manifest.json",
            "release/SHA256SUMS",
            "release/requirements-release.txt",
            "release/run_release_closure.py",
        ]
        missing = [p for p in required_release_files if not (self.spec_dir / p).exists()]
        self.add("A15-010", "package", not missing, "Required A15 closure support files are present", missing)

        self._check_manifest_inventory(manifest)
        self._check_checksums(manifest)
        self._check_closure_metrics(manifest)
        self._run_a12_twice(manifest)
        self._check_canonical_a13(manifest)
        self._check_canonical_a14(manifest)
        self._check_a14_protected_baseline()

        spec_text = self.spec.read_text(encoding="utf-8")
        docs_required = [
            "Normative Draft / Executable Release Candidate",
            "GEARA-A15",
            "release/manifest.json",
            "release/SHA256SUMS",
            "release/run_release_closure.py",
            "GEARA-0.1-RC1",
            "Final `Released` publication remains a separate governed decision",
        ]
        missing_docs = [x for x in docs_required if x not in spec_text]
        self.add("A15-060", "documentation", not missing_docs, "Normative specification declares the A15 release-candidate closure, status boundary, and closure artifacts", missing_docs)

        limitations = manifest.get("knownLimitations", [])
        limit_text = "\n".join(str(x) for x in limitations)
        boundary_ok = all(fragment in limit_text for fragment in ["Architecture Assessment", "PySHACL", "not a final Released publication", "GEARM semantic conformance"])
        self.add("A15-070", "limitations", boundary_ok, "Release manifest explicitly preserves Architecture-Assessment, SHACL-engine, cross-specification, and publication-status limitations")

        policy = manifest.get("promotionPolicy", {})
        policy_ok = (
            policy.get("automaticPromotion") is False
            and policy.get("releaseCandidateClosureIsNotFinalRelease") is True
            and policy.get("finalReleaseRequiresGovernedPublicationDecision") is True
            and policy.get("baselineChangeRequiresGovernedReview") is True
        )
        self.add("A15-080", "promotion", policy_ok, "Release closure cannot silently promote RC1 to Released or silently rewrite its protected baseline")

        passes = sum(x["status"] == "PASS" for x in self.results)
        fails = sum(x["status"] == "FAIL" for x in self.results)
        return {
            "specification": {"id": "GEARA", "version": "0.1"},
            "artifactStage": "GEARA-A15",
            "releaseIdentifier": "GEARA-0.1-RC1",
            "releaseStatus": "Executable Release Candidate",
            "closureVersion": "0.1.0-rc1",
            "closureDate": "2026-10-04",
            "purpose": "Close the GEARA 0.1 executable-artifact programme against a frozen manifest, SHA-256 inventory, explicit architecture/conformance limitations, live A12 behaviour, canonical A13/A14 evidence, and the A14 protected baseline.",
            "normativeBoundary": "A15 closes an executable release candidate. It does not replace Architecture Assessment or Hybrid judgement, establish conformance of an arbitrary architecture, waive deviations, establish external-specification conformance, or promote RC1 to final Released status.",
            "summary": {
                "checkCount": len(self.results),
                "passCount": passes,
                "failCount": fails,
                "status": "PASS" if fails == 0 else "FAIL",
                "candidateClosed": fails == 0,
                "finalReleasePromoted": False,
            },
            "checks": self.results,
        }


def find_spec_dir(start: Path) -> Path:
    for p in [start, *start.parents]:
        candidate = p / "static/specs/geara/0.1"
        spec = p / "docs/tools/geara/0.1/geara-0.1-specification.md"
        if candidate.is_dir() and spec.is_file():
            return candidate
    raise RuntimeError("Could not locate repository root containing static/specs/geara/0.1 and the GEARA specification")


def main() -> int:
    parser = argparse.ArgumentParser(description="GEARA-A15 release-candidate closure gate")
    parser.add_argument("--suite", action="store_true", help="Run the complete release-closure suite (default behavior).")
    parser.add_argument("--json-report", help="Write deterministic JSON closure report.")
    parser.add_argument("--quiet", action="store_true", help="Suppress per-check console output.")
    args = parser.parse_args()

    try:
        spec_dir = find_spec_dir(Path(__file__).resolve())
        report = ReleaseClosureRunner(spec_dir).run()
    except Exception as exc:
        print(f"GEARA-A15 runtime error: {exc}", file=sys.stderr)
        return EXIT_RUNTIME

    if args.json_report:
        out = Path(args.json_report)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(json.dumps(report, indent=2, sort_keys=False) + "\n", encoding="utf-8")

    if not args.quiet:
        for check in report["checks"]:
            print(f"[{check['status']}] {check['id']} {check['summary']}")
        s = report["summary"]
        print(f"\nGEARA-A15 release closure: {s['status']} - {s['passCount']} pass, {s['failCount']} fail / {s['checkCount']} checks")
        if s["status"] == "PASS":
            print("GEARA-0.1-RC1 is closed as an Executable Release Candidate; final release promotion remains a separate governance decision.")

    return EXIT_OK if report["summary"]["failCount"] == 0 else EXIT_CLOSURE


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