"""Ledger event / validator-field trust audits (GHI #193 class).

* ``audit_event_handlers`` — every ledger event emitted has a graph handler
  claiming it, or an explicit ``_NO_GRAPH_IMPACT`` waiver with rationale.
* ``audit_event_schemas`` — every ledger event emitted has a paired
  ``schemas/ledger.json`` entry so ``gz validate --ledger`` does not
  fail-close with ``Unknown event type`` (GHI #374 class).
* ``audit_validator_fields`` — every validator ``info.get('<field>')`` read
  has a corresponding graph or creation-entry write.
"""

from __future__ import annotations

import ast
import json
import re
from pathlib import Path

from gzkit.validate import ValidationError

_NO_GRAPH_IMPACT: dict[str, str] = {
    "brief_reconciled": (
        "Brief reconciliation summary record (ADR-0.0.37, OBPI-06). Consumed by "
        "`gz brief reconcile` operators and reconciliation audits; does not add or "
        "modify artifact graph nodes."
    ),
    "brief_reconcile_drift_detected": (
        "Per-dimension brief drift payload (ADR-0.0.37, OBPI-06). Diagnostic record "
        "for `gz brief reconcile`; informs amendment decisions, not the artifact graph."
    ),
    "brief_reconcile_drift_overridden": (
        "Override receipt for --accept-stale-reconciliation (OBPI-0.0.37-08). "
        "Audit-trail event recording the operator's reason; not an artifact graph node."
    ),
    "project_init": "Bootstrap sentinel; no artifact nodes emit from it.",
    "artifact_edited": "Session activity log; consumed by anchor analysis, not graph.",
    "obpi_lock_claimed": "L3 ephemeral lock file; consumed by gz obpi lock, not graph.",
    "obpi_lock_released": "L3 ephemeral lock file; consumed by gz obpi lock, not graph.",
    "patch-release": (
        "Release-line metadata (hyphenated per patch_release_event at "
        "src/gzkit/ledger_events.py:300). Consumed by gz patch release, "
        "not artifact graph."
    ),
    "audit_generated": "Heavy-lane audit trail; consumed by gz adr audit tooling, not graph.",
    "adr_eval_completed": "Evaluation scorecard; consumed by gz adr evaluate, not graph.",
    "adr-evaluation": (
        "Full per-dimension evaluation scores (ADR-0.0.26-01). "
        "Consumed by eval-feedback-cluster chore and gz validate --evaluation-justify-binding; "
        "not a direct artifact graph node."
    ),
    "lifecycle_transition": (
        "Transition log for state-doctrine audits; consumed by gz state, not graph directly."
    ),
    "artifact_renamed": (
        "Consumed by _build_rename_map during graph construction, not by a per-event handler."
    ),
    "gate_checked": (
        "Consumed by _build_latest_gate_states during graph construction, "
        "not by a per-event handler."
    ),
    "agent_sync_completed": (
        "Mechanical witness for `gz agent sync control-surfaces` runs (GHI #369). "
        "Records that canonical rules + mirrors regenerated; consumed by sync "
        "audits and brief-level REQ proofs, not the artifact graph."
    ),
    "obpi_completion_uncovered_accept": (
        "REQ-coverage waiver record (ADR-0.0.25-02). Consumed by "
        "_check_adr_obpi_coverage_gaps for ADR closeout gap subtraction; "
        "does not add or modify artifact graph nodes."
    ),
    "pipeline_marker_purged": (
        "Runtime cleanup record (GHI #399) — emitted when the pipeline launcher "
        "auto-purges a stale .pipeline-active-* marker whose OBPI is already "
        "attested_completed in the ledger. Audits the cleanup itself; does not "
        "modify the artifact graph (the OBPI's attested_completed state is "
        "already established by the upstream obpi_receipt_emitted event)."
    ),
    "pipeline_launched": (
        "Pipeline-launch authenticity record (GHI #412) — emitted at Stage 1 with "
        "the nonce embedded in the active pipeline marker. Cross-referenced by "
        "the agent-relayed attestation gate to verify the marker was produced "
        "by an operator-initiated 'gz obpi pipeline' run rather than a forged "
        "file. Does not modify the artifact graph."
    ),
    "intrinsic-complexity-attestation": (
        "Attestation record for functions with irreducibly intrinsic cyclomatic "
        "complexity (OBPI-0.0.29-07). Consumed by gz complexity advise advisor "
        "path and gz validate --documents trust audit; does not add artifact graph "
        "nodes (complexity attestations are a quality-governance record, not an "
        "artifact lifecycle event)."
    ),
    "distribution_baseline_regenerated": (
        "Records that `gz validate --distribution --regenerate` rewrote the "
        "baseline manifest (OBPI-0.0.32-15). Layer-2 witness for regeneration runs "
        "symmetric to agent_sync_completed; consumed by distribution drift audits, "
        "not the artifact graph."
    ),
    "composition_rendered": (
        "Constitutional invariant composition render record (ADR-0.0.37, OBPI-0.0.37-03). "
        "Layer-2 witness for successful registry renders; consumed by drift validator "
        "and governance audit tooling, not the artifact graph."
    ),
    "composition_drift_detected": (
        "Constitutional invariant composition drift record (ADR-0.0.37, OBPI-0.0.37-03). "
        "Layer-2 witness for detected drift between rendered registry and committed target; "
        "consumed by drift validator and governance audit tooling, not the artifact graph."
    ),
    "chore_decommission_processed": (
        "Operator-paced chore-processing record (OBPI-0.0.59-04). "
        "Layer-2 witness for each file processed by the decommission-tautological-tests chore; "
        "consumed by chore audit tooling, not the artifact graph."
    ),
    "corpus_entry_appended": (
        "Append-only corpus capture record (ADR-0.0.37, OBPI-0.0.37-19). "
        "Layer-2 witness for a `gz content remember` append to the per-surface corpus store; "
        "consumed by corpus/compose tooling for provenance audit, not the artifact graph."
    ),
}

_VALIDATOR_FIELD_WAIVERS: dict[str, str] = {}

_EVENT_TYPE_HEURISTIC = re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*")
_GRAPH_WRITE_PATTERN = re.compile(r'graph\[[^\]]+\]\["([^"]+)"\]')
_ENTRY_KEY_PATTERN = re.compile(r'\bentry\["([^"]+)"\]')


def audit_event_handlers(project_root: Path) -> list[ValidationError]:
    """Fail on ledger event types that no graph handler claims (GHI #193 class)."""
    ledger_events = project_root / "src" / "gzkit" / "ledger_events.py"
    ledger = project_root / "src" / "gzkit" / "ledger.py"
    if not ledger_events.is_file() or not ledger.is_file():
        return []

    emitted = _collect_emitted_event_types(ledger_events)
    claimed = _collect_claimed_event_types(ledger)

    errors: list[ValidationError] = []
    for unclaimed in sorted(emitted - claimed - _NO_GRAPH_IMPACT.keys()):
        errors.append(
            ValidationError(
                type="event_handlers",
                artifact=f"src/gzkit/ledger_events.py::{unclaimed}",
                message=(
                    f"Ledger event `{unclaimed}` is emitted but no graph handler "
                    "claims it and no waiver exists. Add a handler in "
                    "src/gzkit/ledger.py or add a rationale to "
                    "tests/governance/test_ledger_event_handler_coverage.py::NO_GRAPH_IMPACT."
                ),
            )
        )
    for stale in sorted(_NO_GRAPH_IMPACT.keys() - emitted):
        errors.append(
            ValidationError(
                type="event_handlers",
                artifact=f"NO_GRAPH_IMPACT::{stale}",
                message=(
                    f"Waiver `{stale}` references an event type that no longer "
                    "appears in ledger_events.py. Remove the stale waiver."
                ),
            )
        )
    return errors


def _collect_emitted_event_types(source: Path) -> set[str]:
    tree = ast.parse(source.read_text(encoding="utf-8"))
    emitted: set[str] = set()
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        for keyword in node.keywords:
            if keyword.arg != "event":
                continue
            value = keyword.value
            if isinstance(value, ast.Constant) and isinstance(value.value, str):
                emitted.add(value.value)
    return emitted


def _collect_typed_model_event_types(source: Path) -> set[str]:
    """Collect event-name literals from ``event: Literal["<name>"]`` annotations.

    TASK ledger events (and similar Pydantic-direct emitters) declare their
    event name on the model class, not via a factory call — they need to be
    counted as emitted shapes for schema-coverage purposes.
    """
    tree = ast.parse(source.read_text(encoding="utf-8"))
    typed: set[str] = set()
    for node in ast.walk(tree):
        if not isinstance(node, ast.ClassDef):
            continue
        for item in node.body:
            if not isinstance(item, ast.AnnAssign):
                continue
            target = item.target
            if not (isinstance(target, ast.Name) and target.id == "event"):
                continue
            annotation = item.annotation
            if not (
                isinstance(annotation, ast.Subscript)
                and isinstance(annotation.value, ast.Name)
                and annotation.value.id == "Literal"
            ):
                continue
            literal_arg = annotation.slice
            if isinstance(literal_arg, ast.Constant) and isinstance(literal_arg.value, str):
                typed.add(literal_arg.value)
    return typed


def audit_event_schemas(project_root: Path) -> list[ValidationError]:
    """Fail on emitted ledger event types missing from ``schemas/ledger.json`` (GHI #374 class).

    Walks ``src/gzkit/ledger_events.py`` for factory-call ``event="<name>"`` constants
    and ``src/gzkit/events.py`` for typed-model ``event: Literal["<name>"]``
    annotations. Compares the union against the events declared in
    ``src/gzkit/schemas/ledger.json``. A factory or typed model without a paired
    schema entry causes ``gz validate --ledger`` to emit ``Unknown event type``
    once the event lands on the ledger — the same coupled-surface failure the
    handler audit closes on the graph side.
    """
    ledger_events = project_root / "src" / "gzkit" / "ledger_events.py"
    typed_events = project_root / "src" / "gzkit" / "events.py"
    schema_file = project_root / "src" / "gzkit" / "schemas" / "ledger.json"
    if not ledger_events.is_file() or not typed_events.is_file() or not schema_file.is_file():
        return []

    emitted = _collect_emitted_event_types(ledger_events)
    emitted |= _collect_typed_model_event_types(typed_events)

    schema = json.loads(schema_file.read_text(encoding="utf-8"))
    declared = set(schema.get("events", {}).keys())

    errors: list[ValidationError] = []
    for missing in sorted(emitted - declared):
        errors.append(
            ValidationError(
                type="event_schemas",
                artifact=f"src/gzkit/schemas/ledger.json::{missing}",
                message=(
                    f"Ledger event `{missing}` is emitted but has no entry in "
                    "src/gzkit/schemas/ledger.json. Add a schema entry naming "
                    "required fields and property types so `gz validate --ledger` "
                    "does not fail-close with `Unknown event type`."
                ),
            )
        )
    for stale in sorted(declared - emitted):
        errors.append(
            ValidationError(
                type="event_schemas",
                artifact=f"src/gzkit/schemas/ledger.json::{stale}",
                message=(
                    f"Schema declares event `{stale}` but no factory in "
                    "src/gzkit/ledger_events.py and no typed model in "
                    "src/gzkit/events.py emits it. Remove the stale schema entry."
                ),
            )
        )
    return errors


def _string_constant(node: ast.AST) -> str | None:
    if isinstance(node, ast.Constant) and isinstance(node.value, str):
        return node.value
    return None


def _claimed_from_event_compare(node: ast.AST) -> set[str]:
    """Pick string-literal RHS from ``event.event == "<literal>"`` comparisons."""
    if not (
        isinstance(node, ast.Compare)
        and isinstance(node.left, ast.Attribute)
        and node.left.attr == "event"
        and isinstance(node.left.value, ast.Name)
        and node.left.value.id == "event"
    ):
        return set()
    return {v for c in node.comparators if (v := _string_constant(c)) is not None}


def _claimed_from_collection(node: ast.AST) -> set[str]:
    if not isinstance(node, (ast.Set, ast.List, ast.Tuple)):
        return set()
    return {
        v
        for elt in node.elts
        if (v := _string_constant(elt)) is not None and _EVENT_TYPE_HEURISTIC.fullmatch(v)
    }


def _collect_claimed_event_types(source: Path) -> set[str]:
    tree = ast.parse(source.read_text(encoding="utf-8"))
    claimed: set[str] = set()
    for node in ast.walk(tree):
        claimed.update(_claimed_from_event_compare(node))
        claimed.update(_claimed_from_collection(node))
    return claimed


def audit_validator_fields(project_root: Path) -> list[ValidationError]:
    """Fail on validator ``info.get('<field>')`` reads with no graph writer (GHI #193 class)."""
    validator_src = project_root / "src" / "gzkit" / "commands" / "validate_frontmatter.py"
    ledger_src = project_root / "src" / "gzkit" / "ledger.py"
    if not validator_src.is_file() or not ledger_src.is_file():
        return []

    read_fields = _collect_info_get_fields(validator_src)
    written_fields = _collect_ledger_written_fields(ledger_src)

    errors: list[ValidationError] = []
    for unpopulated in sorted(read_fields - written_fields - _VALIDATOR_FIELD_WAIVERS.keys()):
        errors.append(
            ValidationError(
                type="validator_fields",
                artifact=f"src/gzkit/commands/validate_frontmatter.py::{unpopulated}",
                message=(
                    f"Validator reads graph field `{unpopulated}` but no "
                    "_apply_*_metadata handler or creation-entry initializer "
                    "writes it. Either add population in src/gzkit/ledger.py "
                    "or remove the read. This is GHI #193 class."
                ),
            )
        )
    return errors


def _info_get_field(node: ast.AST) -> str | None:
    """Return the literal field name in ``info.get("<field>")`` calls, else None."""
    if not isinstance(node, ast.Call) or not node.args:
        return None
    func = node.func
    if not isinstance(func, ast.Attribute) or func.attr != "get":
        return None
    caller = func.value
    if not isinstance(caller, ast.Name) or caller.id != "info":
        return None
    return _string_constant(node.args[0])


def _collect_info_get_fields(source: Path) -> set[str]:
    tree = ast.parse(source.read_text(encoding="utf-8"))
    fields: set[str] = set()
    for node in ast.walk(tree):
        field = _info_get_field(node)
        if field is not None:
            fields.add(field)
    return fields


def _collect_ledger_written_fields(source: Path) -> set[str]:
    text = source.read_text(encoding="utf-8")
    written: set[str] = set()
    written.update(_GRAPH_WRITE_PATTERN.findall(text))
    written.update(_ENTRY_KEY_PATTERN.findall(text))
    tree = ast.parse(text)
    for node in ast.walk(tree):
        if not isinstance(node, ast.FunctionDef) or node.name != "_artifact_creation_entry":
            continue
        for sub in ast.walk(node):
            if isinstance(sub, ast.Dict):
                for key in sub.keys:
                    if isinstance(key, ast.Constant) and isinstance(key.value, str):
                        written.add(key.value)
    return written
