#!/usr/bin/env python3
"""GEARA 0.1 A12 automated conformance runner.

Executes the manifest-declared machine validation layers for the GEARA 0.1
A10/A11 testcase corpus and compares actual results with the expected-outcome
oracle in testcases/manifest.json.

This runner validates machine-test expectations only. A successful suite run
MUST NOT be interpreted as complete GEARA architecture conformance because
Architecture-Assessment and Hybrid requirements can remain unevaluated.
"""
from __future__ import annotations

import argparse
import json
import sys
from copy import deepcopy
from pathlib import Path
from typing import Any

try:
    import jsonschema
except ImportError as exc:  # pragma: no cover
    raise SystemExit("Missing required dependency: jsonschema") from exc

try:
    from rdflib import Dataset, Graph, Namespace, RDF
except ImportError as exc:  # pragma: no cover
    raise SystemExit("Missing required dependency: rdflib") from exc

RUNNER_ID = "GEARA-0.1-A12-CONFORMANCE-RUNNER"
RUNNER_VERSION = "0.1.0-draft"
SUPPORTED_VALIDATORS = {
    "A7_JSON_SCHEMA",
    "A8_PROFILE_SCHEMA",
    "A5_JSONLD_EXPANSION",
    "A6_SHACL",
}

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


def load_json(path: Path) -> Any:
    with path.open("r", encoding="utf-8") as f:
        return json.load(f)


def jsonschema_result(instance: Any, schema: Any) -> dict[str, Any]:
    validator = jsonschema.Draft202012Validator(schema)
    errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path))
    if not errors:
        return {"result": "PASS", "errorCount": 0, "errors": []}
    rendered = []
    for e in errors:
        rendered.append({
            "path": "/".join(str(p) for p in e.absolute_path),
            "schemaPath": "/".join(str(p) for p in e.absolute_schema_path),
            "message": e.message,
        })
    return {"result": "FAIL", "errorCount": len(rendered), "errors": rendered}


def _replace_packaged_context(value: Any, packaged_context: Any) -> Any:
    """Replace the packaged GEARA context reference with its local object.

    This keeps JSON-LD expansion offline and deterministic. Local inline contexts
    are preserved and remain layered after the packaged A5 context.
    """
    if isinstance(value, list):
        return [_replace_packaged_context(v, packaged_context) for v in value]
    if isinstance(value, dict):
        out = {}
        for k, v in value.items():
            if k == "@context":
                entries = v if isinstance(v, list) else [v]
                replaced = []
                for item in entries:
                    if isinstance(item, str) and item.endswith("geara-0.1.context.jsonld"):
                        replaced.append(deepcopy(packaged_context))
                    else:
                        replaced.append(_replace_packaged_context(item, packaged_context))
                out[k] = replaced if isinstance(v, list) else replaced[0]
            else:
                out[k] = _replace_packaged_context(v, packaged_context)
        return out
    return value


def expand_jsonld(fixture_path: Path, packaged_context: Any) -> tuple[dict[str, Any], Dataset | None]:
    try:
        document = load_json(fixture_path)
        document = _replace_packaged_context(document, packaged_context)
        graph = Dataset(default_union=True)
        graph.parse(
            data=json.dumps(document, ensure_ascii=False),
            format="json-ld",
            publicID=fixture_path.resolve().as_uri(),
        )
        return {"result": "PASS", "tripleCount": len(graph)}, graph
    except Exception as exc:
        return {"result": "FAIL", "error": f"{type(exc).__name__}: {exc}"}, None


def load_a6_shapes(shacl_path: Path) -> tuple[Graph, list[dict[str, Any]]]:
    graph = Graph()
    graph.parse(shacl_path, format="turtle")
    shapes: list[dict[str, Any]] = []
    for shape in sorted(graph.subjects(RDF.type, SH.NodeShape), key=str):
        targets = list(graph.objects(shape, SH.targetNode))
        constraints = list(graph.objects(shape, SH.sparql))
        if len(targets) != 1 or len(constraints) != 1:
            raise RuntimeError(
                f"Unsupported A6 NodeShape structure for {shape}: "
                "A12 supports exactly one sh:targetNode and one sh:sparql constraint per current A6 shape."
            )
        constraint = constraints[0]
        select = graph.value(constraint, SH.select)
        message = graph.value(constraint, SH.message)
        invariant = graph.value(shape, GEARASH.implementsInvariant)
        if select is None or invariant is None:
            raise RuntimeError(f"Incomplete A6 SPARQL shape metadata for {shape}")
        shapes.append({
            "shape": str(shape),
            "target": targets[0],
            "select": str(select),
            "message": str(message) if message is not None else "",
            "invariantIri": str(invariant),
            "invariant": str(invariant).rsplit("/", 1)[-1],
        })
    return graph, shapes


def a6_shacl_result(data_graph: Dataset, shapes: list[dict[str, Any]]) -> dict[str, Any]:
    """Execute the current A6 SHACL-SPARQL subset with RDFLib.

    GEARA-A6 consists of target-node SPARQL constraints. A12 executes those
    constraints directly because pySHACL is not a mandatory runtime dependency.
    If A6 later introduces unsupported non-SPARQL or multi-target constraint
    forms, load_a6_shapes fails closed rather than silently skipping them.
    """
    violations = []
    for entry in shapes:
        query = entry["select"].replace("$this", "?this")
        try:
            rows = list(data_graph.query(query, initBindings={"this": entry["target"]}))
        except Exception as exc:
            raise RuntimeError(f"Failed to execute A6 shape {entry['shape']}: {exc}") from exc
        if rows:
            violations.append({
                "shape": entry["shape"],
                "invariant": entry["invariant"],
                "target": str(entry["target"]),
                "message": entry["message"],
                "resultCount": len(rows),
            })
    return {
        "result": "FAIL" if violations else "PASS",
        "shapeCount": len(shapes),
        "violationCount": len(violations),
        "violations": violations,
    }


def expected_for_validator(case: dict[str, Any], validator_name: str) -> str:
    expected = case.get("expected", {})
    if validator_name in expected:
        return expected[validator_name]
    if case.get("band") == "positive" and expected.get("machineLayers") == "PASS":
        return "PASS"
    raise RuntimeError(f"No expected result declared for {case['id']} / {validator_name}")


def compare_semantic_violations(case: dict[str, Any], actual: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
    expected = case.get("expected", {})
    expected_shapes = sorted(expected.get("expectedViolationShapes", []))
    expected_invariants = sorted(expected.get("expectedViolationInvariants", []))
    actual_shapes = sorted({v["shape"] for v in actual.get("violations", [])})
    actual_invariants = sorted({v["invariant"] for v in actual.get("violations", [])})
    comparison = {
        "expectedViolationShapes": expected_shapes,
        "actualViolationShapes": actual_shapes,
        "expectedViolationInvariants": expected_invariants,
        "actualViolationInvariants": actual_invariants,
        "shapesExactMatch": expected_shapes == actual_shapes,
        "invariantsExactMatch": expected_invariants == actual_invariants,
    }
    return comparison["shapesExactMatch"] and comparison["invariantsExactMatch"], comparison


def run_case(
    case: dict[str, Any],
    testcases_dir: Path,
    a7_schema: Any,
    a8_schema: Any,
    packaged_context: Any,
    a6_shapes: list[dict[str, Any]],
) -> dict[str, Any]:
    fixture_path = (testcases_dir / case["fixture"]).resolve()
    if not fixture_path.is_file():
        raise FileNotFoundError(f"Fixture not found: {fixture_path}")
    instance = load_json(fixture_path)
    results: dict[str, Any] = {}
    data_graph: Dataset | None = None

    for validator_name in case.get("validators", []):
        if validator_name not in SUPPORTED_VALIDATORS:
            raise RuntimeError(f"Unsupported validator in manifest: {validator_name}")

        if validator_name == "A7_JSON_SCHEMA":
            actual = jsonschema_result(instance, a7_schema)
        elif validator_name == "A8_PROFILE_SCHEMA":
            actual = jsonschema_result(instance, a8_schema)
        elif validator_name == "A5_JSONLD_EXPANSION":
            actual, data_graph = expand_jsonld(fixture_path, packaged_context)
        elif validator_name == "A6_SHACL":
            if data_graph is None:
                expansion, data_graph = expand_jsonld(fixture_path, packaged_context)
                if expansion["result"] != "PASS" or data_graph is None:
                    actual = {
                        "result": "ERROR",
                        "error": "A6_SHACL could not run because JSON-LD expansion failed",
                        "expansion": expansion,
                    }
                else:
                    actual = a6_shacl_result(data_graph, a6_shapes)
            else:
                actual = a6_shacl_result(data_graph, a6_shapes)
        else:  # pragma: no cover
            raise AssertionError(validator_name)

        expected_result = expected_for_validator(case, validator_name)
        actual_result = actual.get("result")
        expectation_match = actual_result == expected_result
        semantic_comparison = None
        if validator_name == "A6_SHACL" and expected_result == "FAIL":
            semantic_match, semantic_comparison = compare_semantic_violations(case, actual)
            expectation_match = expectation_match and semantic_match

        results[validator_name] = {
            "expected": expected_result,
            "actual": actual_result,
            "matchesExpectation": expectation_match,
            "details": actual,
        }
        if semantic_comparison is not None:
            results[validator_name]["violationExpectation"] = semantic_comparison

    return {
        "id": case["id"],
        "title": case.get("title", ""),
        "band": case.get("band"),
        "fixture": case["fixture"],
        "validators": results,
        "matchesExpectedOutcome": all(r["matchesExpectation"] for r in results.values()),
        "architectureAssessment": case.get("expected", {}).get("architectureAssessment"),
        "completeConformance": case.get("expected", {}).get("completeConformance"),
    }


def select_cases(cases: list[dict[str, Any]], ids: list[str], bands: list[str]) -> list[dict[str, Any]]:
    selected = cases
    if ids:
        wanted = set(ids)
        selected = [c for c in selected if c.get("id") in wanted]
        missing = wanted - {c.get("id") for c in selected}
        if missing:
            raise RuntimeError(f"Unknown testcase ID(s): {', '.join(sorted(missing))}")
    if bands:
        wanted_bands = set(bands)
        selected = [c for c in selected if c.get("band") in wanted_bands]
        known = {c.get("band") for c in cases}
        unknown = wanted_bands - known
        if unknown:
            raise RuntimeError(f"Unknown band(s): {', '.join(sorted(unknown))}")
    return selected


def build_summary(results: list[dict[str, Any]], a6_shape_count: int) -> dict[str, Any]:
    layer_counts: dict[str, dict[str, int]] = {}
    for case in results:
        for name, r in case["validators"].items():
            c = layer_counts.setdefault(name, {"executed": 0, "matched": 0, "actualPass": 0, "actualFail": 0})
            c["executed"] += 1
            c["matched"] += int(r["matchesExpectation"])
            c["actualPass"] += int(r["actual"] == "PASS")
            c["actualFail"] += int(r["actual"] == "FAIL")
    matched = sum(1 for r in results if r["matchesExpectedOutcome"])
    bands: dict[str, dict[str, int]] = {}
    for r in results:
        b = r["band"]
        bc = bands.setdefault(b, {"executed": 0, "matched": 0})
        bc["executed"] += 1
        bc["matched"] += int(r["matchesExpectedOutcome"])
    return {
        "testcasesExecuted": len(results),
        "testcasesMatched": matched,
        "testcasesMismatched": len(results) - matched,
        "allExpectedOutcomesMatched": matched == len(results),
        "a6ExecutableShapeCount": a6_shape_count,
        "bands": bands,
        "layers": layer_counts,
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Run GEARA 0.1 manifest-driven machine conformance tests.")
    parser.add_argument("--root", type=Path, help="Path to static/specs/geara/0.1. Defaults to the runner's parent directory.")
    parser.add_argument("--manifest", type=Path, help="Override testcase manifest path.")
    parser.add_argument("--testcase", action="append", default=[], help="Run one testcase ID; repeatable.")
    parser.add_argument("--band", action="append", default=[], help="Run one testcase band; repeatable.")
    parser.add_argument("--json-report", type=Path, help="Write deterministic JSON execution report.")
    parser.add_argument("--quiet", action="store_true", help="Suppress per-test console output.")
    args = parser.parse_args(argv)

    try:
        runner_dir = Path(__file__).resolve().parent
        root = (args.root.resolve() if args.root else runner_dir.parent.resolve())
        testcases_dir = root / "testcases"
        manifest_path = args.manifest.resolve() if args.manifest else testcases_dir / "manifest.json"

        manifest = load_json(manifest_path)
        a7_schema = load_json(root / "geara-0.1.schema.json")
        a8_schema = load_json(root / "geara-0.1.profile.schema.json")
        a5_context_doc = load_json(root / "geara-0.1.context.jsonld")
        packaged_context = a5_context_doc["@context"]
        _, a6_shapes = load_a6_shapes(root / "geara-0.1.shacl.ttl")

        cases = select_cases(manifest.get("testcases", []), args.testcase, args.band)
        results = []
        for case in cases:
            result = run_case(case, testcases_dir, a7_schema, a8_schema, packaged_context, a6_shapes)
            results.append(result)
            if not args.quiet:
                status = "PASS" if result["matchesExpectedOutcome"] else "MISMATCH"
                layer_text = ", ".join(
                    f"{name}:{layer['actual']} (exp {layer['expected']})"
                    for name, layer in result["validators"].items()
                )
                print(f"{result['id']} {status} - {layer_text}")

        summary = build_summary(results, len(a6_shapes))
        report = {
            "runner": {
                "id": RUNNER_ID,
                "version": RUNNER_VERSION,
                "specification": {"id": "GEARA", "version": "0.1"},
                "deterministic": True,
                "boundary": (
                    "Suite PASS means the selected testcase outcomes matched the manifest oracle. "
                    "It does not establish complete GEARA conformance; Architecture Assessment and Hybrid judgement remain applicable."
                ),
            },
            "manifest": {
                "path": str(manifest_path.relative_to(root)) if manifest_path.is_relative_to(root) else str(manifest_path),
                "artifactStage": manifest.get("artifactStage"),
                "artifactVersion": manifest.get("artifactVersion"),
                "declaredTestcaseCount": manifest.get("testcaseCount"),
            },
            "summary": summary,
            "results": results,
        }

        if args.json_report:
            args.json_report.parent.mkdir(parents=True, exist_ok=True)
            with args.json_report.open("w", encoding="utf-8") as f:
                json.dump(report, f, indent=2, ensure_ascii=False, sort_keys=False)
                f.write("\n")

        print(
            f"GEARA A12 summary: {summary['testcasesMatched']}/{summary['testcasesExecuted']} "
            f"testcases matched expected outcomes; A6 shapes={summary['a6ExecutableShapeCount']}."
        )
        print(
            "Conformance boundary: machine-test suite PASS is not complete GEARA architecture conformance; "
            "Architecture Assessment remains required where applicable."
        )
        return 0 if summary["allExpectedOutcomesMatched"] else 1
    except Exception as exc:
        print(f"GEARA A12 runner error: {type(exc).__name__}: {exc}", file=sys.stderr)
        return 2


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