#!/usr/bin/env python3
"""
GEDRA 0.1 conformance runner.

This runner performs machine validation only:
1. JSON Schema structural validation.
2. JSON-LD -> RDF parsing using local packaged contexts.
3. GEDRA core SHACL validation.
4. Optional HDIP profile SHACL validation.
5. Expected-result comparison for the packaged testcase suite.

A machine PASS is not, by itself, an architectural conformance approval.
"""

from __future__ import annotations

import argparse
import copy
import json
import sys
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple

import yaml
from jsonschema import Draft202012Validator, FormatChecker
from rdflib import Graph, Namespace, RDF, URIRef

try:
    from pyshacl import validate as shacl_validate
except ImportError:
    shacl_validate = None

SH = Namespace("http://www.w3.org/ns/shacl#")
DCTERMS = Namespace("http://purl.org/dc/terms/")
GEDRA = Namespace("https://specs.kivanura.org/gedra#")

EXIT_OK = 0
EXIT_VALIDATION_FAILURE = 1
EXIT_DEPENDENCY_OR_RUNTIME = 2
EXIT_EXPECTATION_MISMATCH = 3


def package_root() -> Path:
    return Path(__file__).resolve().parents[1]


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 _resolve_context_value(value: Any, base_dir: Path) -> Any:
    """Inline packaged relative JSON-LD contexts for deterministic offline parsing."""
    if isinstance(value, str):
        candidate = (base_dir / value).resolve()
        if candidate.exists() and candidate.is_file():
            obj = load_json(candidate)
            return obj.get("@context", obj)
        return value
    if isinstance(value, list):
        return [_resolve_context_value(v, base_dir) for v in value]
    if isinstance(value, dict):
        return value
    return value


def load_jsonld_offline(path: Path) -> Dict[str, Any]:
    obj = load_json(path)
    if "@context" in obj:
        obj["@context"] = _resolve_context_value(obj["@context"], path.parent)
    return obj


def json_schema_result(document: Dict[str, Any], schema: Dict[str, Any]) -> Tuple[str, List[str]]:
    validator = Draft202012Validator(schema, format_checker=FormatChecker())
    errors = sorted(validator.iter_errors(document), key=lambda e: list(e.absolute_path))
    if not errors:
        return "pass", []
    messages = []
    for e in errors:
        loc = "$" + "".join(f"[{json.dumps(x)}]" for x in e.absolute_path)
        messages.append(f"{loc}: {e.message}")
    return "fail", messages


def jsonld_graph(path: Path) -> Graph:
    obj = load_jsonld_offline(path)
    graph = Graph()
    graph.parse(data=json.dumps(obj), format="json-ld", publicID=path.resolve().as_uri())
    return graph


def load_graph(path: Path) -> Graph:
    return Graph().parse(path, format="turtle")


def union_graphs(*graphs: Graph) -> Graph:
    result = Graph()
    for g in graphs:
        for triple in g:
            result.add(triple)
    return result


def report_severities(report_graph: Graph) -> List[URIRef]:
    return list(report_graph.objects(None, SH.resultSeverity))


def report_messages(report_graph: Graph) -> List[str]:
    return [str(x) for x in report_graph.objects(None, SH.resultMessage)]


def report_constraint_ids(report_graph: Graph, shapes_graph: Graph) -> List[str]:
    ids = set()
    for result in report_graph.subjects(RDF.type, SH.ValidationResult):
        shape = report_graph.value(result, SH.sourceShape)
        if shape is not None:
            for ident in shapes_graph.objects(shape, DCTERMS.identifier):
                ids.add(str(ident))
        for msg in report_graph.objects(result, SH.resultMessage):
            text = str(msg)
            for token in text.replace(":", " ").split():
                if token.startswith(("GEDRA-C-", "HDIP-C-")):
                    ids.add(token.rstrip(".,;"))
    return sorted(ids)


def classify_shacl(conforms: bool, report_graph: Graph) -> str:
    severities = set(report_severities(report_graph))
    has_violation = SH.Violation in severities
    has_warning = SH.Warning in severities
    if has_violation or not conforms:
        return "fail"
    if has_warning:
        return "pass-warning"
    return "pass"


def run_shacl(data_graph: Graph, shapes_graph: Graph, ontology_graph: Graph) -> Tuple[str, List[str], List[str], str]:
    if shacl_validate is None:
        raise RuntimeError(
            "pyshacl is not installed. Install testcases/requirements-conformance.txt "
            "before running full SHACL validation."
        )
    conforms, report_graph, report_text = shacl_validate(
        data_graph=data_graph,
        shacl_graph=shapes_graph,
        ont_graph=ontology_graph,
        inference="none",
        advanced=True,
        allow_warnings=True,
        allow_infos=True,
        abort_on_first=False,
        meta_shacl=False,
    )
    status = classify_shacl(bool(conforms), report_graph)
    return status, report_messages(report_graph), report_constraint_ids(report_graph, shapes_graph), str(report_text)


def profile_paths(root: Path, profile: str) -> Tuple[Path, Path]:
    if profile.lower() != "hdip":
        raise ValueError(f"Unsupported packaged profile: {profile}")
    base = root / "profiles" / "hdip" / "0.1"
    return base / "hdip-gedra-profile-0.1.ttl", base / "hdip-gedra-profile-0.1.shacl.ttl"


def infer_profile_from_document(doc: Dict[str, Any]) -> Optional[str]:
    for binding in doc.get("appliesProfile", []) or []:
        if isinstance(binding, dict):
            pid = str(binding.get("id", "")).lower()
            if "hdip" in pid:
                return "hdip"
    return None


def validate_document(
    path: Path,
    root: Path,
    expected: Optional[Dict[str, Any]] = None,
    expected_constraint: Optional[str] = None,
    forced_profile: Optional[str] = None,
    structural_only: bool = False,
) -> Dict[str, Any]:
    schema = load_json(root / "gedra-0.1.schema.json")
    doc = load_json(path)

    result: Dict[str, Any] = {
        "file": str(path),
        "jsonSchema": None,
        "gedraShacl": "not-run",
        "profileShacl": "not-run",
        "constraintIds": [],
        "messages": [],
        "machineValidation": "FAIL",
    }

    js_status, js_messages = json_schema_result(doc, schema)
    result["jsonSchema"] = js_status
    result["messages"].extend(js_messages)

    if js_status == "fail":
        result["machineValidation"] = "FAIL"
        return result

    if structural_only:
        result["machineValidation"] = "PASS"
        return result

    data_graph = jsonld_graph(path)
    core_shapes = load_graph(root / "gedra-0.1.shacl.ttl")
    core_ontology = load_graph(root / "gedra-0.1.ttl")

    core_status, msgs, cids, _ = run_shacl(data_graph, core_shapes, core_ontology)
    result["gedraShacl"] = core_status
    result["messages"].extend(msgs)
    result["constraintIds"].extend(cids)

    profile = forced_profile or infer_profile_from_document(doc)
    if profile:
        profile_ontology_path, profile_shapes_path = profile_paths(root, profile)
        profile_ontology = load_graph(profile_ontology_path)
        profile_shapes = load_graph(profile_shapes_path)
        combined_ontology = union_graphs(core_ontology, profile_ontology)
        profile_status, pmsgs, pcids, _ = run_shacl(data_graph, profile_shapes, combined_ontology)
        result["profileShacl"] = profile_status
        result["messages"].extend(pmsgs)
        result["constraintIds"].extend(pcids)

    result["constraintIds"] = sorted(set(result["constraintIds"]))
    result["machineValidation"] = (
        "PASS"
        if result["jsonSchema"] == "pass"
        and result["gedraShacl"] in ("pass", "pass-warning")
        and result["profileShacl"] in ("pass", "pass-warning", "not-run")
        else "FAIL"
    )
    return result


def expected_match(actual: Dict[str, Any], testcase: Dict[str, Any]) -> Tuple[bool, List[str]]:
    expected = testcase["expected"]
    mismatches = []
    for layer, exp in expected.items():
        act = actual.get(layer, "not-run")
        if act != exp:
            mismatches.append(f"{layer}: actual={act}, expected={exp}")
    cid = testcase.get("expectedConstraint")
    if cid and expected.get("gedraShacl") in ("fail", "pass-warning"):
        if cid not in actual.get("constraintIds", []) and not any(cid in m for m in actual.get("messages", [])):
            mismatches.append(f"expectedConstraint not reported: {cid}")
    if cid and expected.get("profileShacl") == "fail":
        if cid not in actual.get("constraintIds", []) and not any(cid in m for m in actual.get("messages", [])):
            mismatches.append(f"expectedConstraint not reported: {cid}")
    return not mismatches, mismatches


def suite_mode(root: Path, structural_only: bool, json_output: Optional[Path]) -> int:
    manifest = load_json(root / "testcases" / "manifest.json")
    rows = []
    mismatches = []

    for tc in manifest["testcases"]:
        path = root / "testcases" / tc["file"]
        actual = validate_document(
            path,
            root,
            expected=tc.get("expected"),
            expected_constraint=tc.get("expectedConstraint"),
            structural_only=structural_only,
        )
        ok, why = expected_match(actual, tc)

        # In structural-only mode, only compare JSON Schema expectations.
        if structural_only:
            ok = actual["jsonSchema"] == tc["expected"]["jsonSchema"]
            why = [] if ok else [
                f"jsonSchema: actual={actual['jsonSchema']}, expected={tc['expected']['jsonSchema']}"
            ]

        rows.append({"id": tc["id"], "ok": ok, "actual": actual, "mismatches": why})
        if not ok:
            mismatches.append((tc["id"], why))

    print("GEDRA 0.1 machine-validation regression suite")
    print(f"Mode: {'structural-only' if structural_only else 'full'}")
    print(f"Testcases: {len(rows)}")
    for row in rows:
        mark = "PASS" if row["ok"] else "FAIL"
        a = row["actual"]
        print(
            f"{mark:4} {row['id']}: "
            f"JSON={a['jsonSchema']} coreSHACL={a['gedraShacl']} profileSHACL={a['profileShacl']}"
        )
        if not row["ok"]:
            for m in row["mismatches"]:
                print(f"      - {m}")

    if json_output:
        json_output.write_text(json.dumps(rows, indent=2), encoding="utf-8")
        print(f"JSON report: {json_output}")

    if mismatches:
        print(f"Overall machine regression: FAIL ({len(mismatches)} expectation mismatches)")
        return EXIT_EXPECTATION_MISMATCH

    print("Overall machine regression: PASS")
    return EXIT_OK


def document_mode(root: Path, path: Path, profile: Optional[str], structural_only: bool, json_output: Optional[Path]) -> int:
    result = validate_document(path, root, forced_profile=profile, structural_only=structural_only)
    print(f"Document: {path}")
    print(f"JSON Schema: {result['jsonSchema']}")
    print(f"GEDRA SHACL: {result['gedraShacl']}")
    print(f"Profile SHACL: {result['profileShacl']}")
    print(f"Overall machine validation: {result['machineValidation']}")
    if result["constraintIds"]:
        print("Constraint IDs:", ", ".join(result["constraintIds"]))
    for msg in result["messages"]:
        print(f"- {msg}")
    if json_output:
        json_output.write_text(json.dumps(result, indent=2), encoding="utf-8")
        print(f"JSON report: {json_output}")
    return EXIT_OK if result["machineValidation"] == "PASS" else EXIT_VALIDATION_FAILURE


def main() -> int:
    parser = argparse.ArgumentParser(description="GEDRA 0.1 machine-validation runner")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--suite", action="store_true", help="Run packaged testcase suite (default).")
    group.add_argument("--document", type=Path, help="Validate one JSON/JSON-LD document.")
    parser.add_argument("--profile", choices=["hdip"], help="Apply an explicit packaged profile.")
    parser.add_argument("--structural-only", action="store_true", help="Run JSON Schema only; no SHACL dependency required.")
    parser.add_argument("--json-report", type=Path, help="Write machine-readable JSON report.")
    args = parser.parse_args()

    root = package_root()

    if not args.structural_only and shacl_validate is None:
        print(
            "ERROR: pyshacl is required for full validation. "
            "Install testcases/requirements-conformance.txt.",
            file=sys.stderr,
        )
        return EXIT_DEPENDENCY_OR_RUNTIME

    try:
        if args.document:
            return document_mode(root, args.document.resolve(), args.profile, args.structural_only, args.json_report)
        return suite_mode(root, args.structural_only, args.json_report)
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return EXIT_DEPENDENCY_OR_RUNTIME


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