"""LiveRecorder — the warden's journal into the project's ``fed-resources/`` tree.

Two write surfaces, never conflated (spec §4.3 vs §4.4):

``on_event``  — maps each broker event to a **live_event_test** NDJSON line and
appends it to ``fed-resources/live/runs/<run_id>.ndjson`` (one JSON object per
line, monotonic ``seq``), and maintains the ``fed-resources/live/CURRENT`` pointer
(set on ``test_start``, cleared on ``test_end``). Live logs are **ephemeral
telemetry** — never gated, never provenance — so a line that can't be made
schema-clean is dropped rather than crashing the warden; the authority view stays
truthful even if the tail breaks.

``on_complete`` — writes the durable **test_run_record** to
``fed-resources/generated/runs/<run_id>.json`` (canonical, schema-clean): the
verifiable "what happened" — result, effective config + digest, and the full lease
audit (including any double-launch the lease absorbed, recovered from its
``watchers``). Coalesced/queued requests never execute, so they get no record of
their own; they appear as ``coalesced`` rows in the holder's audit.

The broker stays ignorant of ``fed-resources/`` — it just calls these two hooks.
"""

from __future__ import annotations

import json
import threading
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mgf.fedres import validate_payload
from mgf.fedres.canonical import dumpb

from .leases import age_seconds, now_iso
from .outcomes import record_status

if TYPE_CHECKING:
    from .leases import Lease, RunRequest

#: live_event_test grammar (the schema is ``additionalProperties: false``). The
#: recorder stamps ``v``/``run_id``/``seq``/``ts``/``kind``; every other field is
#: copied from the broker event **iff** it is one of these — after the renames.
_GRAMMAR_FIELDS: frozenset[str] = frozenset(
    {
        "test_id", "origin", "requested_config", "effective_config", "key", "holder",
        "ttl_s", "stream", "msg", "label", "progress", "name", "result", "digest",
        "duration_s", "attached_to", "position", "since", "reason", "note",
    }
)  # fmt: skip
#: mtest's ``assert`` event speaks its own dialect; rename it into the grammar
#: (``outcome`` → ``result``, ``summary`` → ``msg``); ``code``/``duration_ms`` drop.
_RENAME: dict[str, str] = {"outcome": "result", "summary": "msg"}
_STAMPED: frozenset[str] = frozenset({"v", "run_id", "seq", "ts", "kind"})
_ORIGINS: frozenset[str] = frozenset({"web", "cli", "agent", "ci"})


def _project_id(root: Path, fallback: str) -> str:
    """Best-effort project id from ``fed-resources/source/project.toml`` (else fallback)."""
    source = root / "fed-resources" / "source" / "project.toml"
    if source.is_file():
        try:
            data = tomllib.loads(source.read_text(encoding="utf-8"))
            return str(data.get("project", {}).get("id", fallback))
        except (tomllib.TOMLDecodeError, OSError):
            return fallback
    return fallback


class LiveRecorder:
    """Persist a warden's runs to ``fed-resources/`` (live NDJSON + run-records)."""

    def __init__(self, project_root: str | Path, project: str | None = None) -> None:
        self.root = Path(project_root)
        self.project = project or _project_id(self.root, self.root.resolve().name)
        self._live = self.root / "fed-resources" / "live"
        self._runs = self._live / "runs"
        self._current = self._live / "CURRENT"
        self._records = self.root / "fed-resources" / "generated" / "runs"
        self._seq: dict[str, int] = {}
        self._lock = threading.Lock()

    # ----------------------------------------------------------- live NDJSON --
    def event_to_line(self, event: dict[str, Any], seq: int) -> dict[str, Any]:
        """Map a broker event to a live_event_test line (grammar-only fields)."""
        line: dict[str, Any] = {
            "v": "0.1",
            "run_id": event["run_id"],
            "seq": seq,
            "ts": now_iso(),
            "kind": event["kind"],
        }
        for raw_key, value in event.items():
            if raw_key in _STAMPED or value is None:
                continue
            key = _RENAME.get(raw_key, raw_key)
            if key in _GRAMMAR_FIELDS:
                line[key] = value
        return line

    def on_event(self, event: dict[str, Any]) -> None:
        run_id = event.get("run_id")
        kind = event.get("kind")
        if not isinstance(run_id, str) or not isinstance(kind, str):
            return
        with self._lock:
            seq = self._seq.get(run_id, 0) + 1
            line = self.event_to_line(event, seq)
            if not validate_payload(line):  # [] == clean; only then advance seq + write
                self._seq[run_id] = seq
                self._runs.mkdir(parents=True, exist_ok=True)
                with (self._runs / f"{run_id}.ndjson").open("a", encoding="utf-8") as handle:
                    handle.write(json.dumps(line, ensure_ascii=False) + "\n")
            if kind == "test_start":
                self._live.mkdir(parents=True, exist_ok=True)
                self._current.write_text(run_id + "\n", encoding="utf-8")
            elif kind == "test_end":
                self._current.unlink(missing_ok=True)

    # -------------------------------------------------------- the run-record --
    def completion_record(
        self, request: RunRequest, lease: Lease | None, result: str
    ) -> dict[str, Any]:
        """Build the durable test_run_record (effective config + full lease audit)."""
        started_at = lease.acquired_at if lease else now_iso()
        released_at = now_iso()
        duration_s = max(0.0, age_seconds(started_at, released_at)) if lease else 0.0
        watchers = list(lease.watchers) if lease else []
        audit: list[dict[str, Any]] = [{"ts": started_at, "event": "lease_acquired"}]
        # Every coalesced double-launch the lease absorbed is visible proof here.
        for other_run in watchers:
            audit.append({"ts": released_at, "event": "coalesced", "other_run": other_run})
        # The record's `result` is clamped to the fedres 0.1 test_status_value enum
        # (no hung/crashed/quarantined there yet — mgf-fedres's enum to widen). When
        # the projection loses fidelity, the VERBATIM warden result is preserved on
        # the audit trail (schema-legal), so History renders the true outcome.
        recorded = record_status(result)
        if recorded != result:
            audit.append({"ts": released_at, "event": "outcome", "note": result})
        audit.append({"ts": released_at, "event": "lease_released"})
        return {
            "schema_version": "0.1",
            "kind": "test_run_record",
            "run_id": request.run_id,
            "project": self.project,
            "test_id": request.test_id,
            "result": recorded,
            "started_at": started_at,
            "duration_s": duration_s,
            "origin": request.origin if request.origin in _ORIGINS else "cli",
            "who": request.who,
            "effective_config": dict(request.config),
            "config_digest": request.config_digest,
            "lease": {
                "acquired_at": started_at,
                "released_at": released_at,
                "heartbeats": max(1, int(duration_s // 10) + 1),
                "reclaimed": False,
            },
            "audit": audit,
        }

    def on_complete(self, request: RunRequest, lease: Lease | None, result: str) -> None:
        record = self.completion_record(request, lease, result)
        errors = validate_payload(record)
        if errors:  # durable history must never be silently malformed — surface loudly
            raise ValueError(f"run-record for {request.run_id} is not schema-clean: {errors}")
        self._records.mkdir(parents=True, exist_ok=True)
        (self._records / f"{request.run_id}.json").write_bytes(dumpb(record))
        # TODO(per-project fedres): folding this run-record into the project's fedres
        # manifest (integrity-hashed, reproduce-exempt per D-RUN-4) is the project
        # generator's concern, not the warden's — writing the file is enough for the
        # History view to read it. Intentionally not done here.
