#!/usr/bin/env python3
"""GEARA 0.1 A14 regression runner.

Protects the reconciled A1-A13 machine baseline against unintended drift,
re-runs A12 and A13 behaviour, and executes mutation sentinels proving that the
baseline and semantic fences detect representative regressions.

A14 PASS is regression evidence only. It does not establish complete GEARA
architectural conformance and does not replace Architecture Assessment or
Hybrid judgement required by the normative specification.
"""
from __future__ import annotations

import argparse
import copy
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, RDFS, OWL, URIRef

RUNNER_ID = "GEARA-0.1-A14-REGRESSION-RUNNER"
RUNNER_VERSION = "0.1.0-draft"
GEARA_NS = "https://specs.kivanura.org/geara#"
SH = Namespace("http://www.w3.org/ns/shacl#")
GEARASH = Namespace("https://specs.kivanura.org/geara/shacl#")


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


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


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


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


def stable_hash(data: Any) -> str:
    return sha256_bytes(json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))


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


def run_json_command(cmd: list[str], cwd: Path) -> tuple[int, bytes, dict[str, Any] | None, str]:
    proc = subprocess.run(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
    parsed = None
    err = proc.stderr.decode("utf-8", errors="replace")
    if proc.returncode == 0:
        try:
            parsed = json.loads(proc.stdout.decode("utf-8"))
        except Exception:
            parsed = None
    return proc.returncode, proc.stdout, parsed, err


class Regression:
    def __init__(self, repo_root: Path, baseline_path: Path):
        self.repo_root = repo_root
        self.spec_dir = repo_root / "static/specs/geara/0.1"
        self.doc_path = repo_root / "docs/tools/geara/0.1/geara-0.1-specification.md"
        self.baseline_path = baseline_path
        self.baseline = load_json(baseline_path)
        self.checks: list[dict[str, Any]] = []

        self.a1 = load_yaml(self.spec_dir / "geara-0.1.architecture-registry.yaml")
        self.a2 = load_yaml(self.spec_dir / "geara-0.1.relationship-registry.yaml")
        self.a3 = load_yaml(self.spec_dir / "geara-0.1.invariant-catalogue.yaml")
        self.a5 = load_json(self.spec_dir / "geara-0.1.context.jsonld")
        self.a7 = load_json(self.spec_dir / "geara-0.1.schema.json")
        self.a8 = load_json(self.spec_dir / "geara-0.1.profile.schema.json")
        self.a9 = load_json(self.spec_dir / "examples/geara-0.1.examples.json")
        self.manifest = load_json(self.spec_dir / "testcases/manifest.json")
        self.g4 = Graph().parse(self.spec_dir / "geara-0.1.ttl", format="turtle")
        self.g6 = Graph().parse(self.spec_dir / "geara-0.1.shacl.ttl", format="turtle")
        self.spec_text = self.doc_path.read_text(encoding="utf-8")

    def check(self, cid: str, group: str, title: str, passed: bool, details: Any = None) -> None:
        self.checks.append({
            "id": cid,
            "group": group,
            "title": title,
            "status": "PASS" if passed else "FAIL",
            "details": details,
        })

    def protected_drift(self) -> list[dict[str, Any]]:
        drift = []
        for entry in self.baseline["protectedFiles"]:
            p = self.repo_root / entry["path"]
            if not p.is_file():
                drift.append({"path": entry["path"], "reason": "missing"})
                continue
            actual = sha256(p)
            if actual != entry["sha256"]:
                drift.append({"path": entry["path"], "reason": "sha256", "expected": entry["sha256"], "actual": actual})
        return drift

    def a1_projection(self, data: dict[str, Any] | None = None) -> list[Any]:
        d = data or self.a1
        return [(e["id"], e["localName"], e["qname"], e["iri"], e["elementType"], e["family"]) for e in d["architectureElements"]]

    def a2_projection(self, data: dict[str, Any] | None = None) -> list[Any]:
        d = data or self.a2
        keys = ["id","localName","qname","iri","families","ontologyInheritance","symmetric","transitive","functional","inverseFunctional","automaticOwnershipTransfer","automaticAuthorityTransfer"]
        return [{k:r.get(k) for k in keys} for r in d["relationships"]]

    def a3_projection(self, data: dict[str, Any] | None = None) -> list[Any]:
        d = data or self.a3
        return [(i["id"], i["statement"], i["requirementLevel"], i["validationClass"], i["machineConstraintStatus"]) for i in d["invariants"]]

    def forbidden_a4(self, graph: Graph | None = None) -> dict[str, list[str]]:
        g = graph or self.g4
        guards = self.baseline["semanticGuardrails"]
        bad_predicates = []
        for iri in guards["forbiddenA4Predicates"]:
            if any(True for _ in g.triples((None, URIRef(iri), None))):
                bad_predicates.append(iri)
        bad_types = []
        for iri in guards["forbiddenA4PropertyTypes"]:
            if any(True for _ in g.triples((None, RDF.type, URIRef(iri)))):
                bad_types.append(iri)
        return {"forbiddenPredicates": bad_predicates, "forbiddenPropertyTypes": bad_types}

    def shacl_snapshot(self, graph: Graph | None = None) -> dict[str, Any]:
        g = graph or self.g6
        shapes = sorted(str(s) for s in set(g.subjects(RDF.type, SH.NodeShape)))
        coverage = sorted((str(s), str(o)) for s,o in g.subject_objects(GEARASH.coverageStatus))
        counts = Counter(o.rsplit("#",1)[-1] if "#" in o else o.rsplit("/",1)[-1] for _,o in coverage)
        return {
            "tripleCount": len(g),
            "nodeShapeCount": len(shapes),
            "nodeShapeIriSha256": stable_hash(shapes),
            "coverageResourceCount": len(coverage),
            "coverageCounts": dict(sorted(counts.items())),
        }

    def run(self) -> dict[str, Any]:
        self.check_baseline_integrity()
        self.check_semantic_fences()
        a12_runs = self.check_a12_behaviour()
        a13_evidence = self.check_a13_evidence()
        self.check_mutation_sentinels()
        self.check_spec_integration()

        failed = [c for c in self.checks if c["status"] != "PASS"]
        groups: dict[str, dict[str, int]] = {}
        for c in self.checks:
            g = groups.setdefault(c["group"], {"checks":0,"passed":0,"failed":0})
            g["checks"] += 1
            if c["status"] == "PASS": g["passed"] += 1
            else: g["failed"] += 1

        return {
            "regression": {
                "id": RUNNER_ID,
                "version": RUNNER_VERSION,
                "artifactStage": "GEARA-A14",
                "specification": {"id":"GEARA","version":"0.1"},
                "baseline": self.baseline["baseline"]["id"],
                "scope": "Regression protection for the A13-reconciled GEARA A1-A13 machine baseline, A12 conformance-runner behaviour, A13 reconciliation behaviour, semantic guardrails, and representative drift sentinels.",
                "boundary": "A14 PASS demonstrates preservation of the encoded baseline and expected machine behaviour. It does not establish complete GEARA architectural conformance and does not replace Architecture Assessment or Hybrid judgement.",
                "deterministic": True,
            },
            "summary": {
                "checks": len(self.checks),
                "passed": len(self.checks)-len(failed),
                "failed": len(failed),
                "allChecksPassed": not failed,
                "groups": groups,
                "protectedFileCount": self.baseline["protectedFileCount"],
                "a12TestcaseCount": 70,
                "a13ReconciliationCheckCount": 84,
                "mutationSentinelCount": 12,
            },
            "behaviouralEvidence": {
                "a12RunReportsByteIdentical": a12_runs[0] == a12_runs[1] if len(a12_runs)==2 else False,
                "a13CanonicalEvidencePreserved": a13_evidence,
            },
            "checks": self.checks,
        }

    def check_baseline_integrity(self) -> None:
        g = "A13 baseline byte-integrity"
        b = self.baseline
        self.check("A14-001", g, "Regression baseline identifies GEARA-A14 and protects the A13 machine tree", b.get("baseline",{}).get("artifactStage") == "GEARA-A14" and b.get("protectedFileCount") == 102,
                   {"protectedFileCount": b.get("protectedFileCount")})
        listed = {x["path"] for x in b["protectedFiles"]}
        actual = {
            p.relative_to(self.repo_root).as_posix()
            for p in self.spec_dir.rglob("*")
            if p.is_file()
            and "regression" not in p.relative_to(self.spec_dir).parts
            and "__pycache__" not in p.parts
            and p.suffix != ".pyc"
        }
        self.check("A14-002", g, "Protected A1-A13 file inventory is complete and has no unbaselined files", listed == actual,
                   {"missingFromTree": sorted(listed-actual), "unbaselinedInTree": sorted(actual-listed)})
        drift = self.protected_drift()
        self.check("A14-003", g, "All 102 protected A1-A13 files are byte-identical to the regression baseline", not drift, {"drift": drift})
        size_drift=[]
        for e in b["protectedFiles"]:
            p=self.repo_root/e["path"]
            if p.is_file() and p.stat().st_size != e["sizeBytes"]:
                size_drift.append({"path":e["path"],"expected":e["sizeBytes"],"actual":p.stat().st_size})
        self.check("A14-004", g, "Protected-file sizes match the A13 baseline inventory", not size_drift, {"drift": size_drift})

    def check_semantic_fences(self) -> None:
        g = "Semantic regression fences"
        s = self.baseline["semanticSnapshot"]
        self.check("A14-005", g, "A1 architecture identity projection remains unchanged", len(self.a1["architectureElements"])==s["architectureElementCount"] and stable_hash(self.a1_projection())==s["architectureElementProjectionSha256"],
                   {"count":len(self.a1["architectureElements"])})
        self.check("A14-006", g, "A2 relationship semantic projection remains unchanged", len(self.a2["relationships"])==s["relationshipCount"] and stable_hash(self.a2_projection())==s["relationshipProjectionSha256"],
                   {"count":len(self.a2["relationships"])})
        inheritance=sorted(r["localName"] for r in self.a2["relationships"] if r.get("ontologyInheritance") is True)
        symmetric=sorted(r["localName"] for r in self.a2["relationships"] if r.get("symmetric") is True)
        stronger=[r["id"] for r in self.a2["relationships"] if r.get("transitive") or r.get("functional") or r.get("inverseFunctional") or r.get("automaticOwnershipTransfer") or r.get("automaticAuthorityTransfer")]
        self.check("A14-007", g, "A2 non-inheritance and non-authority-strengthening boundaries remain intact", inheritance==self.baseline["semanticGuardrails"]["ontologyInheritanceRelationships"] and symmetric==self.baseline["semanticGuardrails"]["symmetricRelationships"] and not stronger,
                   {"ontologyInheritance":inheritance,"symmetric":symmetric,"strengtheningViolations":stronger})
        self.check("A14-008", g, "A3 invariant statements and validation metadata remain unchanged", len(self.a3["invariants"])==s["invariantCount"] and stable_hash(self.a3_projection())==s["invariantProjectionSha256"],
                   {"count":len(self.a3["invariants"])})
        f4=self.forbidden_a4()
        self.check("A14-009", g, "A4 conservative RDF/OWL boundary and triple count remain intact", len(self.g4)==s["rdfTripleCount"] and not f4["forbiddenPredicates"] and not f4["forbiddenPropertyTypes"],
                   {"tripleCount":len(self.g4),**f4})
        ctx=self.a5.get("@context",{})
        self.check("A14-010", g, "A5 explicit-context policy remains unchanged and no default @vocab is introduced", len(ctx)==s["jsonLdContextEntryCount"] and stable_hash(ctx)==s["jsonLdContextSha256"] and "@vocab" not in ctx,
                   {"contextEntries":len(ctx),"hasDefaultVocab":"@vocab" in ctx})
        sh=self.shacl_snapshot()
        self.check("A14-011", g, "A6 SHACL Shape and coverage baseline remains unchanged", sh["tripleCount"]==s["shaclTripleCount"] and sh["nodeShapeCount"]==s["shaclNodeShapeCount"] and sh["nodeShapeIriSha256"]==s["shaclNodeShapeIriSha256"] and sh["coverageResourceCount"]==s["shaclCoverageResourceCount"] and sh["coverageCounts"]==s["shaclCoverageCounts"], sh)
        self.check("A14-012", g, "A7 and A8 remain Draft 2020-12 schemas with their canonical identities", self.a7.get("$id")==s["a7SchemaId"] and self.a7.get("$schema")==s["a7SchemaDialect"] and sha256(self.spec_dir/"geara-0.1.schema.json")==s["a7SchemaSha256"] and self.a8.get("$id")==s["a8SchemaId"] and self.a8.get("$schema")==s["a8SchemaDialect"] and sha256(self.spec_dir/"geara-0.1.profile.schema.json")==s["a8SchemaSha256"])
        self.check("A14-013", g, "A9 canonical example index remains unchanged", self.a9.get("exampleCount")==s["exampleCount"] and sha256(self.spec_dir/"examples/geara-0.1.examples.json")==s["exampleIndexSha256"], {"exampleCount":self.a9.get("exampleCount")})
        bands=Counter(tc["band"] for tc in self.manifest.get("testcases",[]))
        self.check("A14-014", g, "A10/A11 seventy-case testcase oracle and band allocation remain unchanged", self.manifest.get("testcaseCount")==s["testcaseCount"] and dict(sorted(bands.items()))==s["testcaseBandCounts"] and sha256(self.spec_dir/"testcases/manifest.json")==s["testcaseManifestSha256"], {"testcaseCount":self.manifest.get("testcaseCount"),"bands":dict(sorted(bands.items()))})

    def check_a12_behaviour(self) -> list[bytes]:
        g = "A12 behavioural regression"
        runner=self.spec_dir/"conformance/run-geara-conformance.py"
        reports=[]
        parsed=[]
        errors=[]
        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_root,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
            errors.append(proc.stderr.decode('utf-8',errors='replace'))
            if out.exists():
                reports.append(out.read_bytes())
                try: parsed.append(load_json(out))
                except Exception: parsed.append(None)
                out.unlink(missing_ok=True)
            else:
                reports.append(b''); parsed.append(None)
        ok=False
        if len(parsed)==2 and all(isinstance(x,dict) for x in parsed):
            sm=parsed[0].get('summary',{})
            ok=(sm.get('testcasesExecuted')==70 and sm.get('testcasesMatched')==70 and sm.get('testcasesMismatched')==0 and sm.get('allExpectedOutcomesMatched') is True)
        self.check("A14-015", g, "A12 complete machine-test oracle still matches 70/70 expected outcomes", ok, {"summary": parsed[0].get('summary') if isinstance(parsed[0],dict) else None,"stderr":errors[0]})
        self.check("A14-016", g, "A12 complete-suite JSON reporting remains deterministic across repeated runs", len(reports)==2 and reports[0]==reports[1] and reports[0]!=b'', {"reportSha256":sha256_bytes(reports[0]) if reports and reports[0] else None})
        return reports

    def check_a13_evidence(self) -> bool:
        g = "A13 reconciled-baseline evidence"
        report_path = self.spec_dir / "reconciliation/geara-0.1.reconciliation-report.json"
        runner_path = self.spec_dir / "reconciliation/run-geara-reconciliation.py"
        report = load_json(report_path)
        sm = report.get("summary", {})
        expected = self.baseline["behaviouralSnapshot"]["a13Expected"]
        summary_ok = (
            sm.get("checks") == expected["checks"]
            and sm.get("passed") == expected["passed"]
            and sm.get("failed") == expected["failed"]
            and sm.get("allChecksPassed") is expected["allChecksPassed"]
        )
        self.check("A14-017", g, "A13 canonical reconciliation evidence remains 84/84 PASS", summary_ok, {"summary": sm})
        report_hash_ok = sha256(report_path) == self.baseline["behaviouralSnapshot"]["a13CanonicalReportSha256"]
        self.check("A14-018", g, "A13 canonical reconciliation report is byte-preserved from the reconciled baseline", report_hash_ok, {"sha256": sha256(report_path)})
        runner_hash_ok = sha256(runner_path) == self.baseline["behaviouralSnapshot"]["a13RunnerSha256"]
        self.check("A14-019", g, "A13 reconciliation runner is byte-preserved from the reconciled baseline", runner_hash_ok, {"sha256": sha256(runner_path)})
        return summary_ok and report_hash_ok and runner_hash_ok

    def check_mutation_sentinels(self) -> None:
        g = "Representative mutation sentinels"
        # Exact-hash sentinels prove each major artifact family is protected.
        by_path={e['path']:e for e in self.baseline['protectedFiles']}
        exact_targets=[
            ("A14-020","A1 registry byte drift", "static/specs/geara/0.1/geara-0.1.architecture-registry.yaml"),
            ("A14-026","A7 schema byte drift", "static/specs/geara/0.1/geara-0.1.schema.json"),
            ("A14-027","A8 Profile schema byte drift", "static/specs/geara/0.1/geara-0.1.profile.schema.json"),
            ("A14-028","A9 example byte drift", "static/specs/geara/0.1/examples/01-direct-analytical-consumption.example.jsonld"),
            ("A14-029","A10/A11 oracle byte drift", "static/specs/geara/0.1/testcases/manifest.json"),
            ("A14-030","A12 runner byte drift", "static/specs/geara/0.1/conformance/run-geara-conformance.py"),
            ("A14-031","A13 runner byte drift", "static/specs/geara/0.1/reconciliation/run-geara-reconciliation.py"),
        ]
        for cid,title,path in exact_targets:
            e=by_path[path]
            mutated=(self.repo_root/path).read_bytes()+b"\n# A14 mutation sentinel\n"
            detected=sha256_bytes(mutated)!=e['sha256']
            self.check(cid,g,title+" is detected by the protected baseline",detected,{"path":path})

        # Semantic-fence sentinels prove high-risk policy regressions remain detectable even conceptually.
        m2=copy.deepcopy(self.a2)
        candidate=next(r for r in m2['relationships'] if r['localName']!='specializes')
        candidate['ontologyInheritance']=True
        inheritance=sorted(r['localName'] for r in m2['relationships'] if r.get('ontologyInheritance') is True)
        self.check("A14-021",g,"A2 accidental ontology-inheritance strengthening is detected",inheritance!=self.baseline['semanticGuardrails']['ontologyInheritanceRelationships'],{"mutatedInheritance":inheritance})

        m3=copy.deepcopy(self.a3)
        m3['invariants'][0]['statement']=m3['invariants'][0]['statement']+' MUTATED'
        self.check("A14-022",g,"A3 normative invariant-statement drift is detected",stable_hash(self.a3_projection(m3))!=self.baseline['semanticSnapshot']['invariantProjectionSha256'])

        m4=Graph()
        for t in self.g4: m4.add(t)
        m4.add((URIRef(GEARA_NS+'dependsOn'),RDF.type,OWL.TransitiveProperty))
        f4=self.forbidden_a4(m4)
        self.check("A14-023",g,"A4 illicit OWL transitive strengthening is detected",str(OWL.TransitiveProperty) in f4['forbiddenPropertyTypes'],f4)

        m5=copy.deepcopy(self.a5)
        m5['@context']['@vocab']=GEARA_NS
        self.check("A14-024",g,"A5 accidental default @vocab introduction is detected",'@vocab' in m5['@context'])

        m6=Graph()
        for t in self.g6: m6.add(t)
        one=URIRef(sorted(str(x) for x in set(m6.subjects(RDF.type,SH.NodeShape)))[0])
        for t in list(m6.triples((one,None,None))): m6.remove(t)
        for t in list(m6.triples((None,None,one))): m6.remove(t)
        sh=self.shacl_snapshot(m6)
        self.check("A14-025",g,"A6 executable Shape removal is detected",sh['nodeShapeCount']!=self.baseline['semanticSnapshot']['shaclNodeShapeCount'] or sh['nodeShapeIriSha256']!=self.baseline['semanticSnapshot']['shaclNodeShapeIriSha256'],sh)

    def check_spec_integration(self) -> None:
        g="Normative specification integration"
        required=[
            "GEARA-A14 — Regression Suite",
            "geara-0.1.regression-baseline.json",
            "run-geara-regression.py",
            "Regression PASS",
        ]
        self.check("A14-032",g,"Human-readable specification records A14 regression artifacts and boundary",all(x in self.spec_text for x in required),{"missing":[x for x in required if x not in self.spec_text]})
        self.check("A14-033",g,"Executable programme records A14 as established and leaves A15 Release Closure as the remaining slice", "GEARA-A14 is established" in self.spec_text and "After GEARA-A14" in self.spec_text and "A15 — Release Closure" in self.spec_text)


def main() -> int:
    parser=argparse.ArgumentParser(description="Run the GEARA 0.1 A14 regression suite")
    parser.add_argument('--repo-root',type=Path,default=None,help='Repository root; auto-detected by default')
    parser.add_argument('--baseline',type=Path,default=None,help='Regression baseline manifest; defaults to regression/geara-0.1.regression-baseline.json')
    parser.add_argument('--json-report',type=Path,default=None,help='Write deterministic JSON report')
    args=parser.parse_args()
    here=Path(__file__).resolve()
    root=args.repo_root.resolve() if args.repo_root else find_repo_root(here.parent)
    baseline=args.baseline.resolve() if args.baseline else root/'static/specs/geara/0.1/regression/geara-0.1.regression-baseline.json'
    try:
        report=Regression(root,baseline).run()
    except Exception as exc:
        print(f"GEARA A14 regression runner error: {exc}",file=sys.stderr)
        return 2
    payload=(json.dumps(report,indent=2,sort_keys=False)+'\n').encode('utf-8')
    if args.json_report:
        args.json_report.parent.mkdir(parents=True,exist_ok=True)
        args.json_report.write_bytes(payload)
    sys.stdout.buffer.write(payload)
    return 0 if report['summary']['allChecksPassed'] else 1

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