"""ADR-0040 — structured-logging sink for turn-event audit.

Consumes ``TurnEvent`` records that ADR-0039 makes uniform across
main and stub paths.  Emits one JSON-line per turn with deterministic
field ordering, suitable for log aggregation, replay, and offline
audit pipelines.

Trust boundary (per CLAUDE.md):

* **Metadata-only by default.**  Surface text and input tokens are
  redacted unless the caller explicitly opts in via
  ``include_content=True``.  Audit needs counts, ids, and flags —
  not raw content — and the redact-by-default stance prevents
  accidental PII leakage when sinks point at shared log stores.
* **No implicit wall-clock.**  Timestamps are caller-provided so
  emission is reproducible under replay.  The runtime never reaches
  for ``datetime.now()`` here.
* **Append-only file paths.**  ``JsonlFileSink`` opens the target in
  append mode and never truncates.  Path is fixed at construction;
  the sink does not interpret user-controlled paths at emit time.
* **Idempotent flush.**  Each ``emit()`` flushes immediately so a
  crashed turn loop still has its prior turns durable on disk.

See ``docs/decisions/ADR-0040-telemetry-sink.md``.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Protocol

from core.epistemic_state import (
    coerce_epistemic_state,
    coerce_normative_clearance,
)


_UNKNOWN_DOMAIN_SURFACE = "I don't know — insufficient grounding for that yet."


# ---------- pure serializer ----------


def serialize_turn_event(
    event,
    *,
    safety_pack_id: str = "",
    ethics_pack_id: str = "",
    identity_pack_id: str = "",
    include_content: bool = False,
    timestamp: str | None = None,
) -> dict[str, object]:
    """Produce a JSON-safe audit dict from a ``TurnEvent``.

    Pack ids are passed as kwargs because ``TurnEvent`` does not
    carry them — the runtime knows them.  Fields are typed
    deliberately at the boundary so an upstream change to
    ``TurnEvent`` doesn't silently break the wire format; missing or
    differently-typed values fall back to safe defaults.
    """
    verdicts = getattr(event, "verdicts", None)
    out: dict[str, object] = {
        "turn": int(getattr(event, "turn", 0)),
        "safety_pack_id": str(safety_pack_id),
        "ethics_pack_id": str(ethics_pack_id),
        "identity_pack_id": str(identity_pack_id),
        "epistemic_state": coerce_epistemic_state(
            getattr(event, "epistemic_state", None)
        ).value,
        "normative_clearance": coerce_normative_clearance(
            getattr(event, "normative_clearance", None)
        ).value,
        "normative_detail": str(
            getattr(event, "normative_detail", "") or ""
        ),
        "refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)),
        "hedge_injected": bool(getattr(verdicts, "hedge_injected", False)),
        "versor_condition": float(getattr(event, "versor_condition", 0.0)),
        "vault_hits": int(getattr(event, "vault_hits", 0)),
        "cycle_cost_total": float(getattr(event, "cycle_cost_total", 0.0)),
        "flagged": bool(getattr(event, "flagged", False)),
        "stub_path": getattr(event, "walk_surface", "") == _UNKNOWN_DOMAIN_SURFACE,
        "dialogue_role": str(getattr(event, "dialogue_role", "")),
        # ADR-0072 (R5) — operator-visible register identity per turn.
        # Empty strings on pre-R5 events / UNREGISTERED runtimes / empty
        # marker buckets, so the wire format degrades cleanly.
        "register_id": str(getattr(event, "register_id", "") or ""),
        "register_variant_id": str(getattr(event, "register_variant_id", "") or ""),
        # ADR-0073d (L1.4) — operator-visible anchor-lens identity per
        # turn.  Empty strings on pre-L1.4 events / UNANCHORED runtimes /
        # turns where the lens did not engage.
        "anchor_lens_id": str(getattr(event, "anchor_lens_id", "") or ""),
        "anchor_lens_mode_label": str(getattr(event, "anchor_lens_mode_label", "") or ""),
        # ADR-0075 (C1) — realizer slot-type guard verdict per turn.
        # Empty strings on pre-C1 events; closed enums otherwise.
        "realizer_guard_status": str(getattr(event, "realizer_guard_status", "") or ""),
        "realizer_guard_rule": str(getattr(event, "realizer_guard_rule", "") or ""),
        "composer_graph_atom_status": str(
            getattr(event, "composer_graph_atom_status", "") or ""
        ),
        "composer_atom_set_hash": str(
            getattr(event, "composer_atom_set_hash", "") or ""
        ),
        "graph_atom_set_hash": str(
            getattr(event, "graph_atom_set_hash", "") or ""
        ),
        "composer_graph_atom_overlap_count": int(
            getattr(event, "composer_graph_atom_overlap_count", 0) or 0
        ),
    }
    safety = getattr(event, "safety_verdict", None)
    if safety is not None:
        out["safety_violated"] = sorted(
            getattr(safety, "violated_boundaries", ()) or ()
        )
        out["safety_runtime_checkable_count"] = int(
            getattr(safety, "runtime_checkable_count", 0)
        )
        out["safety_upheld"] = bool(getattr(safety, "upheld", True))
    ethics = getattr(event, "ethics_verdict", None)
    if ethics is not None:
        out["ethics_violated"] = sorted(
            getattr(ethics, "violated_commitments", ()) or ()
        )
        out["ethics_runtime_checkable_count"] = int(
            getattr(ethics, "runtime_checkable_count", 0)
        )
        out["ethics_upheld"] = bool(getattr(ethics, "upheld", True))
    identity_score = getattr(event, "identity_score", None)
    if identity_score is not None:
        out["identity_alignment"] = float(
            getattr(identity_score, "alignment", 0.0)
        )
        out["identity_flagged"] = bool(getattr(identity_score, "flagged", False))
        out["identity_deviation_axes"] = sorted(
            getattr(identity_score, "deviation_axes", ()) or ()
        )
    if include_content:
        out["input_tokens"] = list(getattr(event, "input_tokens", ()))
        out["surface"] = str(getattr(event, "surface", ""))
        out["walk_surface"] = str(getattr(event, "walk_surface", ""))
        out["articulation_surface"] = str(
            getattr(event, "articulation_surface", "")
        )
        # ADR-0077 (R6) — register canonical surface (pre-substantive,
        # pre-decoration).  Content-gated like the other surfaces.
        out["register_canonical_surface"] = str(
            getattr(event, "register_canonical_surface", "") or ""
        )
    if timestamp is not None:
        out["timestamp"] = str(timestamp)
    return out


def format_turn_event_jsonl(event, **kwargs) -> str:
    """Serialize a turn event as one deterministic JSONL line.

    Field order is alphabetical (``sort_keys=True``) so two emissions
    of the same logical event produce byte-identical lines.  No
    trailing newline — the sink owns line termination.
    """
    payload = serialize_turn_event(event, **kwargs)
    return json.dumps(payload, sort_keys=True, separators=(",", ":"))


# ---------- ADR-0059 — correction-event serializer ----------


def serialize_correction_event(
    correction_result,
    *,
    target_turn: int,
    identity_pack_id: str = "",
    safety_pack_id: str = "",
    ethics_pack_id: str = "",
    timestamp: str | None = None,
) -> dict[str, object]:
    """Produce a JSON-safe audit dict from a ``CorrectionResult``.

    Distinct from a turn event: this records the *backward* update to
    a session graph triggered by ``ChatRuntime.correct()``.  The
    forward regen turn that follows still emits its own turn event;
    this event documents the perturbation itself so audit consumers
    can answer "which past turns moved, by how much, and toward what".

    Trust boundary (per CLAUDE.md):

    * **Metadata-only.**  Versor coordinates are NOT emitted — only
      the L2-delta-norm-per-record and a SHA-256 digest of the
      correction versor's float32 bytes (deterministic identifier).
    * **No implicit wall-clock.**  ``timestamp`` is caller-provided.
    * **Deterministic.**  Same ``CorrectionResult`` → byte-identical
      serialized line.  ``records`` are traversed in their tuple
      order (deterministic, matches insertion order from
      ``CorrectionPass.apply``).
    """
    import hashlib
    import math

    records = getattr(correction_result, "records", ()) or ()
    correction_versor = getattr(correction_result, "correction_versor", None)

    # Per-record L2 deltas + the max across records.
    deltas: list[float] = []
    turn_idxs: list[int] = []
    for r in records:
        old_v = getattr(r, "old_versor", None)
        new_v = getattr(r, "new_versor", None)
        if old_v is None or new_v is None:
            continue
        # numpy.ndarray subtraction + norm; pure stdlib fallback would
        # be slower but the runtime already imports numpy on this path.
        import numpy as np
        delta = float(np.linalg.norm(np.asarray(new_v) - np.asarray(old_v)))
        if math.isfinite(delta):
            deltas.append(delta)
        turn_idxs.append(int(getattr(r, "turn_idx", 0)))

    # SHA-256 digest of the correction versor's float32 bytes — gives
    # a stable identifier for the perturbation without leaking
    # coordinates.  Falls back to empty string when missing.
    digest = ""
    if correction_versor is not None:
        import numpy as np
        digest = hashlib.sha256(
            np.asarray(correction_versor, dtype=np.float32).tobytes()
        ).hexdigest()

    out: dict[str, object] = {
        "type": "correction",
        "target_turn": int(target_turn),
        "identity_pack_id": str(identity_pack_id),
        "safety_pack_id": str(safety_pack_id),
        "ethics_pack_id": str(ethics_pack_id),
        "records_count": int(getattr(correction_result, "turns_affected", len(records))),
        "turns_skipped": int(getattr(correction_result, "turns_skipped", 0)),
        "turn_idxs_affected": sorted(turn_idxs),
        "max_delta_norm": max(deltas) if deltas else 0.0,
        "mean_delta_norm": (sum(deltas) / len(deltas)) if deltas else 0.0,
        "correction_versor_digest": digest,
    }
    if timestamp is not None:
        out["timestamp"] = str(timestamp)
    return out


def format_correction_event_jsonl(correction_result, **kwargs) -> str:
    """Serialize a correction event as one deterministic JSONL line.

    The ``"type": "correction"`` field discriminates this line from
    turn events at consume time without changing the sink contract.
    """
    payload = serialize_correction_event(correction_result, **kwargs)
    return json.dumps(payload, sort_keys=True, separators=(",", ":"))


# ---------- ADR-0158 — reboot-event serializer ----------


def serialize_reboot_event(
    *,
    restored_turn_count: int,
    stored_revision: str,
    current_revision: str,
    recognizers_count: int,
    candidates_count: int,
    timestamp: str | None = None,
) -> dict[str, object]:
    """Produce a JSON-safe audit dict for an engine-state reboot recovery.

    Written to the telemetry JSONL when ``ChatRuntime`` loads an existing
    checkpoint at startup (L10 scope §Sub-question 3).  Lets future audit
    reconstruct when this engine instance lost and regained its lifetime.

    ``revision_matched`` is True iff both revisions are known and equal,
    indicating the checkpoint was written by the same code that is running.
    False means the operator should verify the loaded state is still valid
    (the W-023 ``RuntimeWarning`` will already have fired).

    Trust boundary: no surface text or tokens; metadata only.
    """
    revision_matched = bool(
        stored_revision not in ("unknown", "")
        and current_revision not in ("unknown", "")
        and stored_revision == current_revision
    )
    out: dict[str, object] = {
        "type": "reboot",
        "restored_turn_count": int(restored_turn_count),
        "stored_revision": str(stored_revision),
        "current_revision": str(current_revision),
        "revision_matched": revision_matched,
        "recognizers_count": int(recognizers_count),
        "candidates_count": int(candidates_count),
    }
    if timestamp is not None:
        out["timestamp"] = str(timestamp)
    return out


def format_reboot_event_jsonl(
    *,
    restored_turn_count: int,
    stored_revision: str,
    current_revision: str,
    recognizers_count: int,
    candidates_count: int,
    timestamp: str | None = None,
) -> str:
    """Serialize a reboot event as one deterministic JSONL line.

    ``"type": "reboot"`` discriminates this line from turn and correction
    events at consume time without changing the sink contract.
    """
    payload = serialize_reboot_event(
        restored_turn_count=restored_turn_count,
        stored_revision=stored_revision,
        current_revision=current_revision,
        recognizers_count=recognizers_count,
        candidates_count=candidates_count,
        timestamp=timestamp,
    )
    return json.dumps(payload, sort_keys=True, separators=(",", ":"))


# ---------- sink protocol ----------


class TurnEventSink(Protocol):
    """Minimal sink contract.

    Sinks receive one already-serialized JSONL line per turn.  The
    runtime calls ``emit()`` after each ``turn_log.append()`` — see
    ``ChatRuntime._emit_turn_event``.
    """

    def emit(self, line: str) -> None: ...


# ---------- concrete sinks ----------


@dataclass
class JsonlBufferSink:
    """In-memory sink that captures every emitted line.

    Useful for tests, replay diffing, and small-volume audit where
    persistence is the caller's responsibility.
    """

    lines: list[str] = field(default_factory=list)

    def emit(self, line: str) -> None:
        self.lines.append(line)


class JsonlFileSink:
    """Append-only JSONL file sink with eager flush.

    The path is fixed at construction.  Each ``emit()`` flushes
    immediately so a crashed runtime still has its prior turns
    durable on disk.  Supports context-manager usage.
    """

    def __init__(self, path: str | Path) -> None:
        self._path = Path(path)
        self._fh: IO[str] | None = None

    def emit(self, line: str) -> None:
        if self._fh is None:
            self._path.parent.mkdir(parents=True, exist_ok=True)
            self._fh = self._path.open("a", encoding="utf-8")
        self._fh.write(line)
        self._fh.write("\n")
        self._fh.flush()

    def close(self) -> None:
        if self._fh is not None:
            self._fh.close()
            self._fh = None

    def __enter__(self) -> "JsonlFileSink":
        return self

    def __exit__(self, *exc_info) -> None:
        self.close()


# ---------- fan-out ----------


@dataclass
class FanOutSink:
    """Forward every emitted line to N sinks in declaration order.

    ADR-0041.  Composes with any combination of sinks — typically
    ``JsonlFileSink`` (durable) + ``JsonlBufferSink`` (in-memory
    audit), or two file sinks (local + shadow).

    **Error semantics:** fail-fast.  If sink *i* raises, sinks *i+1..*
    are NOT called and the exception propagates to the caller.  This
    is consistent with the single-sink contract: telemetry failures
    surface, never silently drop audit signal.  Callers wanting
    partial-success semantics wrap individual sinks in their own
    error-tolerant shim.
    """

    sinks: tuple = ()  # tuple[TurnEventSink, ...]

    def emit(self, line: str) -> None:
        for sink in self.sinks:
            sink.emit(line)


# ---------- operator-facing summary formatter ----------


def format_verdict_summary(verdicts) -> str:
    """ADR-0041 — one-line human-readable summary of a TurnVerdicts bundle.

    Used by ``core chat --show-verdicts`` to print a per-turn audit
    line to the operator.  Distinct from ``format_turn_event_jsonl``
    (machine-facing): this is dense, terse, and skims the high-signal
    fields.  Empty string when ``verdicts`` is None.

    Format::

        [identity=0.83 safety=ok ethics=ok refusal=- hedge=-]
        [identity=0.42 safety=VIOLATED:preserve_versor_closure ethics=ok refusal=YES hedge=-]
    """
    if verdicts is None:
        return ""
    parts: list[str] = []
    identity = getattr(verdicts, "identity_score", None)
    if identity is not None:
        alignment = float(getattr(identity, "alignment", 0.0))
        parts.append(f"identity={alignment:.2f}")
    else:
        parts.append("identity=-")
    safety = getattr(verdicts, "safety_verdict", None)
    parts.append(_format_verdict_short(
        safety, "safety", id_attr="violated_boundaries",
    ))
    ethics = getattr(verdicts, "ethics_verdict", None)
    parts.append(_format_verdict_short(
        ethics, "ethics", id_attr="violated_commitments",
    ))
    parts.append(
        "refusal=YES" if getattr(verdicts, "refusal_emitted", False)
        else "refusal=-"
    )
    parts.append(
        "hedge=YES" if getattr(verdicts, "hedge_injected", False)
        else "hedge=-"
    )
    return "[" + " ".join(parts) + "]"


def _format_verdict_short(verdict, label: str, *, id_attr: str) -> str:
    if verdict is None:
        return f"{label}=-"
    violated = sorted(getattr(verdict, id_attr, ()) or ())
    if not violated:
        return f"{label}=ok"
    return f"{label}=VIOLATED:{','.join(violated)}"


__all__ = [
    "FanOutSink",
    "JsonlBufferSink",
    "JsonlFileSink",
    "TurnEventSink",
    "format_correction_event_jsonl",
    "format_reboot_event_jsonl",
    "format_turn_event_jsonl",
    "format_verdict_summary",
    "serialize_correction_event",
    "serialize_reboot_event",
    "serialize_turn_event",
]
