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

import argparse
import hashlib
import json
import re
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
from rdflib.compare import to_isomorphic

EXIT_OK = 0
EXIT_RUNTIME = 2
EXIT_REGRESSION = 5

GEARM = Namespace("https://specs.kivanura.org/gearm#")
GEARMSH = Namespace("https://specs.kivanura.org/gearm/shacl#")
SH = Namespace("http://www.w3.org/ns/shacl#")


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


def sha256_file(path: Path) -> str:
    return sha256_bytes(path.read_bytes())


def canonical_json_bytes(value: Any) -> bytes:
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def canonical_digest(value: Any) -> str:
    return sha256_bytes(canonical_json_bytes(value))


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 graph_fingerprint(path: Path) -> tuple[int, str]:
    g = Graph().parse(path, format="turtle")
    # rdflib's isomorphic graph digest is independent of Turtle statement order
    # and blank-node labels, making it suitable for a semantic graph baseline.
    return len(g), str(to_isomorphic(g).internal_hash())


def sorted_digest(values: list[str]) -> str:
    return canonical_digest(sorted(values))


def recursive_diff(expected: Any, actual: Any, path: str = "") -> list[dict[str, Any]]:
    diffs: list[dict[str, Any]] = []
    if type(expected) is not type(actual):
        return [{"path": path or "$", "expected": expected, "actual": actual}]
    if isinstance(expected, dict):
        keys = sorted(set(expected) | set(actual))
        for key in keys:
            p = f"{path}.{key}" if path else key
            if key not in expected:
                diffs.append({"path": p, "expected": "<absent>", "actual": actual[key]})
            elif key not in actual:
                diffs.append({"path": p, "expected": expected[key], "actual": "<absent>"})
            else:
                diffs.extend(recursive_diff(expected[key], actual[key], p))
        return diffs
    if isinstance(expected, list):
        if expected != actual:
            diffs.append({"path": path or "$", "expected": expected, "actual": actual})
        return diffs
    if expected != actual:
        diffs.append({"path": path or "$", "expected": expected, "actual": actual})
    return diffs


class SnapshotBuilder:
    def __init__(self, root: Path):
        self.root = root
        self.bps = root.parents[3]
        self.spec = self.bps / "docs/tools/gearm/0.1/gearm-0.1-specification.md"
        self.a1 = yaml_obj(root / "gearm-0.1.concept-registry.yaml")
        self.a2 = yaml_obj(root / "gearm-0.1.relationship-registry.yaml")
        self.a3 = yaml_obj(root / "gearm-0.1.invariant-catalogue.yaml")
        self.a5 = json_obj(root / "gearm-0.1.context.jsonld")
        self.a7 = json_obj(root / "gearm-0.1.schema.json")
        self.a8 = json_obj(root / "gearm-0.1.profile.schema.json")
        self.manifest = json_obj(root / "testcases/manifest.json")

    def _invariant_table(self) -> list[dict[str, str]]:
        text = self.spec.read_text(encoding="utf-8")
        rows = re.findall(r"^\|\s*(GEARM-INV-\d{3})\s*\|\s*(.*?)\s*\|\s*$", text, re.M)
        return [{"id": i, "statement": s} for i, s in rows]

    def _json_files(self, directory: Path) -> dict[str, str]:
        result: dict[str, str] = {}
        for path in sorted(directory.rglob("*.json")) + sorted(directory.rglob("*.jsonld")):
            rel = path.relative_to(self.root).as_posix()
            result[rel] = canonical_digest(json_obj(path))
        return dict(sorted(result.items()))

    def build(self) -> dict[str, Any]:
        concepts = self.a1["concepts"]
        relationships = self.a2["relationships"]
        invariants = self.a3["invariants"]

        a4_count, a4_digest = graph_fingerprint(self.root / "gearm-0.1.ttl")
        a6_graph = Graph().parse(self.root / "gearm-0.1.shacl.ttl", format="turtle")
        a6_count = len(a6_graph)
        a6_digest = str(to_isomorphic(a6_graph).internal_hash())
        shape_count = len(set(a6_graph.subjects(RDF.type, SH.NodeShape)))
        coverage_status = Counter()
        coverage_subjects = set(a6_graph.subjects(GEARMSH.coverageStatus, None))
        for subject in coverage_subjects:
            for status in a6_graph.objects(subject, GEARMSH.coverageStatus):
                coverage_status[str(status).rsplit("#", 1)[-1]] += 1

        context = self.a5["@context"]
        examples_dir = self.root / "examples"
        testcase_dir = self.root / "testcases"
        testcases = self.manifest["testcases"]
        band_counts = Counter()
        for tc in testcases:
            n = int(tc["id"].split("-")[-1])
            if n < 100:
                band_counts["positive"] += 1
            elif n < 200:
                band_counts["structural-invalid"] += 1
            elif n < 300:
                band_counts["semantic-invalid"] += 1
            elif n < 400:
                band_counts["profile-invalid"] += 1
            else:
                band_counts["other"] += 1

        core_files = [
            "gearm-0.1.concept-registry.yaml",
            "gearm-0.1.relationship-registry.yaml",
            "gearm-0.1.invariant-catalogue.yaml",
            "gearm-0.1.ttl",
            "gearm-0.1.context.jsonld",
            "gearm-0.1.shacl.ttl",
            "gearm-0.1.schema.json",
            "gearm-0.1.profile.schema.json",
            "testcases/manifest.json",
            "testcases/run_conformance.py",
            "reconciliation/run_reconciliation.py",
        ]

        return {
            "specification": {"id": "GEARM", "version": "0.1"},
            "A1": {
                "conceptCount": len(concepts),
                "conceptIdDigest": sorted_digest([x["id"] for x in concepts]),
                "qnameDigest": sorted_digest([x["qname"] for x in concepts]),
                "iriDigest": sorted_digest([x["iri"] for x in concepts]),
                "canonicalContentDigest": canonical_digest(self.a1),
            },
            "A2": {
                "relationshipCount": len(relationships),
                "relationshipIdDigest": sorted_digest([x["id"] for x in relationships]),
                "qnameDigest": sorted_digest([x["qname"] for x in relationships]),
                "iriDigest": sorted_digest([x["iri"] for x in relationships]),
                "canonicalContentDigest": canonical_digest(self.a2),
            },
            "A3": {
                "invariantCount": len(invariants),
                "invariantIdDigest": sorted_digest([x["id"] for x in invariants]),
                "statementDigest": canonical_digest(sorted((x["id"], x["statement"]) for x in invariants)),
                "requirementLevelCounts": dict(sorted(Counter(x["requirementLevel"] for x in invariants).items())),
                "validationClassCounts": dict(sorted(Counter(x["validationClass"] for x in invariants).items())),
                "machineConstraintStatusCounts": dict(sorted(Counter(x.get("machineConstraintStatus") for x in invariants).items())),
                "canonicalContentDigest": canonical_digest(self.a3),
            },
            "A4": {
                "tripleCount": a4_count,
                "isomorphicGraphDigest": a4_digest,
            },
            "A5": {
                "contextEntryCount": len(context),
                "hasDefaultVocab": "@vocab" in context,
                "canonicalContentDigest": canonical_digest(self.a5),
            },
            "A6": {
                "tripleCount": a6_count,
                "isomorphicGraphDigest": a6_digest,
                "nodeShapeCount": shape_count,
                "coverageRecordCount": len(coverage_subjects),
                "coverageStatusCounts": dict(sorted(coverage_status.items())),
            },
            "A7": {
                "schemaId": self.a7.get("$id"),
                "schemaDraft": self.a7.get("$schema"),
                "definitionCount": len(self.a7.get("$defs", {})),
                "canonicalContentDigest": canonical_digest(self.a7),
            },
            "A8": {
                "schemaId": self.a8.get("$id"),
                "schemaDraft": self.a8.get("$schema"),
                "definitionCount": len(self.a8.get("$defs", {})),
                "canonicalContentDigest": canonical_digest(self.a8),
            },
            "A9": {
                "exampleCount": len(list(examples_dir.glob("*.json"))) + len(list(examples_dir.glob("*.jsonld"))),
                "exampleDigests": self._json_files(examples_dir),
            },
            "A10_A11": {
                "manifestStage": self.manifest.get("artifactStage"),
                "manifestVersion": self.manifest.get("manifestVersion"),
                "testcaseCount": len(testcases),
                "testcaseIdDigest": sorted_digest([x["id"] for x in testcases]),
                "bandCounts": dict(sorted(band_counts.items())),
                "expectedOutcomeDigest": canonical_digest([(x["id"], x.get("expected", {}), x.get("failure", {})) for x in testcases]),
                "fixtureDigests": self._json_files(testcase_dir),
                "manifestDigest": canonical_digest(self.manifest),
            },
            "A12": {
                "runnerSha256": sha256_file(testcase_dir / "run_conformance.py"),
                "requirementsSha256": sha256_file(testcase_dir / "requirements-conformance.txt"),
            },
            "A13": {
                "runnerSha256": sha256_file(self.root / "reconciliation/run_reconciliation.py"),
                "requirementsSha256": sha256_file(self.root / "reconciliation/requirements-reconciliation.txt"),
                "expectedCheckCount": 51,
                "expectedPassCount": 51,
                "expectedWarningCount": 0,
                "expectedFailCount": 0,
            },
            "normativeSource": {
                "invariantTableCount": len(self._invariant_table()),
                "invariantTableDigest": canonical_digest(self._invariant_table()),
            },
            "rawFileSha256": {name: sha256_file(self.root / name) for name in core_files},
        }


def make_baseline(root: Path) -> dict[str, Any]:
    return {
        "specification": {"id": "GEARM", "version": "0.1"},
        "artifactStage": "GEARM-A14",
        "baselineVersion": "0.1.0-draft",
        "purpose": "Release-facing regression baseline for the established GEARM 0.1 A1-A13 machine-artifact stack.",
        "normativeBoundary": "The regression baseline detects change; it does not decide whether a change is semantically acceptable. Intentional changes require governed review and an explicit baseline replacement. A14 never silently refreshes the canonical baseline.",
        "changePolicy": {
            "automaticBaselineRefresh": False,
            "candidateBaselineMayBeEmitted": True,
            "canonicalBaselineReplacementRequiresReview": True,
            "machinePassDoesNotEstablishCompleteConformance": True,
        },
        "snapshot": SnapshotBuilder(root).build(),
    }


class RegressionRunner:
    def __init__(self, root: Path, baseline_path: Path):
        self.root = root
        self.bps = root.parents[3]
        self.spec = self.bps / "docs/tools/gearm/0.1/gearm-0.1-specification.md"
        self.baseline_path = baseline_path
        self.results: list[dict[str, Any]] = []
        self.live_a13_report: dict[str, Any] | None = None

    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 _compare_group(self, check_id: str, name: str, expected: Any, actual: Any) -> None:
        diffs = recursive_diff(expected, actual)
        self.add(check_id, name, not diffs, f"{name} regression baseline matches", diffs[:25])

    def _run_a13(self) -> None:
        runner = self.root / "reconciliation/run_reconciliation.py"
        with tempfile.TemporaryDirectory() as d:
            report_path = Path(d) / "a13-live.json"
            proc = subprocess.run(
                [sys.executable, str(runner), "--quiet", "--json-report", str(report_path)],
                cwd=self.root,
                capture_output=True,
                text=True,
            )
            if proc.returncode != 0 or not report_path.exists():
                self.add(
                    "A14-150",
                    "A13-live",
                    False,
                    "Live A13 reconciliation executes successfully",
                    {"exit": proc.returncode, "stdout": proc.stdout[-1200:], "stderr": proc.stderr[-1200:]},
                )
                return
            report = json_obj(report_path)
            self.live_a13_report = report
            summary = report.get("summary", {})
            expected = {"checkCount": 51, "passCount": 51, "warningCount": 0, "failCount": 0, "status": "PASS"}
            self.add(
                "A14-150",
                "A13-live",
                summary == expected,
                "Live A13 reconciliation remains 51/51 PASS",
                {"expected": expected, "actual": summary},
            )
            a12_checks = [x for x in report.get("checks", []) if x.get("id") == "A13-111"]
            actual_a12 = a12_checks[0].get("detail", {}).get("summary", {}) if a12_checks else {}
            expected_a12 = {
                "testcases": 46,
                "expectationsMatched": 46,
                "expectationMismatches": 0,
                "machineRegression": "PASS",
            }
            # A12 may report additional summary fields; compare the release-critical subset.
            critical = {k: actual_a12.get(k) for k in expected_a12}
            self.add(
                "A14-151",
                "A12-live",
                critical == expected_a12,
                "Live A12 suite remains 46/46 expectation-matched",
                {"expected": expected_a12, "actual": critical},
            )

    def run(self) -> dict[str, Any]:
        if not self.baseline_path.exists():
            raise FileNotFoundError(f"Regression baseline not found: {self.baseline_path}")
        baseline = json_obj(self.baseline_path)
        self.add(
            "A14-001",
            "baseline",
            baseline.get("artifactStage") == "GEARM-A14" and baseline.get("specification") == {"id": "GEARM", "version": "0.1"},
            "Canonical regression baseline identifies GEARM 0.1 / GEARM-A14",
        )
        current = SnapshotBuilder(self.root).build()
        expected = baseline["snapshot"]
        groups = [
            ("A14-010", "A1", "A1"),
            ("A14-020", "A2", "A2"),
            ("A14-030", "A3", "A3"),
            ("A14-040", "A4", "A4"),
            ("A14-050", "A5", "A5"),
            ("A14-060", "A6", "A6"),
            ("A14-070", "A7", "A7"),
            ("A14-080", "A8", "A8"),
            ("A14-090", "A9", "A9"),
            ("A14-100", "A10/A11", "A10_A11"),
            ("A14-120", "A12", "A12"),
            ("A14-130", "A13", "A13"),
            ("A14-140", "normative-source", "normativeSource"),
            ("A14-145", "raw-file-fingerprints", "rawFileSha256"),
        ]
        for cid, label, key in groups:
            self._compare_group(cid, label, expected[key], current[key])

        required = [
            "regression/baseline.json",
            "regression/run_regression.py",
            "regression/README.md",
            "regression/requirements-regression.txt",
        ]
        missing = [p for p in required if not (self.root / p).exists()]
        self.add("A14-146", "package", not missing, "A14 regression package files are present", missing)

        self._run_a13()

        text = self.spec.read_text(encoding="utf-8")
        docs = ["regression/baseline.json", "regression/run_regression.py", "regression/regression-report.json", "GEARM-A14"]
        missing_docs = [x for x in docs if x not in text]
        self.add("A14-160", "documentation", not missing_docs, "Normative specification references the established A14 regression artifacts", missing_docs)

        passes = sum(x["status"] == "PASS" for x in self.results)
        fails = sum(x["status"] == "FAIL" for x in self.results)
        return {
            "specification": {"id": "GEARM", "version": "0.1"},
            "artifactStage": "GEARM-A14",
            "regressionVersion": "0.1.0-draft",
            "purpose": "Detect unintended change against the governed GEARM 0.1 release-facing regression baseline and verify live A12/A13 behavior.",
            "normativeBoundary": "A14 detects baseline drift and machine-regression changes. A PASS does not replace semantic assessment or establish complete GEARM conformance.",
            "baseline": self.baseline_path.relative_to(self.root).as_posix() if self.baseline_path.is_relative_to(self.root) else str(self.baseline_path),
            "summary": {
                "checkCount": len(self.results),
                "passCount": passes,
                "failCount": fails,
                "status": "PASS" if fails == 0 else "FAIL",
            },
            "checks": self.results,
        }


def main() -> int:
    parser = argparse.ArgumentParser(description="GEARM-A14 release-facing regression suite")
    parser.add_argument("--suite", action="store_true", help="Run the full regression suite (default behavior).")
    parser.add_argument("--baseline", help="Override baseline path. Defaults to regression/baseline.json.")
    parser.add_argument("--json-report", help="Write deterministic JSON regression report.")
    parser.add_argument("--quiet", action="store_true", help="Suppress per-check console output.")
    parser.add_argument(
        "--emit-candidate-baseline",
        metavar="PATH",
        help="Emit a candidate baseline for governed review. This never overwrites the canonical baseline automatically.",
    )
    args = parser.parse_args()

    root = Path(__file__).resolve().parents[1]
    try:
        if args.emit_candidate_baseline:
            candidate = Path(args.emit_candidate_baseline)
            candidate.parent.mkdir(parents=True, exist_ok=True)
            candidate.write_text(json.dumps(make_baseline(root), indent=2, sort_keys=False) + "\n", encoding="utf-8")
            if not args.quiet:
                print(f"GEARM-A14 candidate baseline written to {candidate}")
                print("Canonical baseline was not changed.")
            return EXIT_OK

        baseline_path = Path(args.baseline).resolve() if args.baseline else root / "regression/baseline.json"
        report = RegressionRunner(root, baseline_path).run()
    except Exception as exc:
        print(f"GEARM-A14 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"\nGEARM-A14 regression: {s['status']} — {s['passCount']} pass, {s['failCount']} fail / {s['checkCount']} checks")

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


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