"""Durable storage for adapter provenance chains.

The problem this solves: ``client.abom.chain`` is an in-memory list — if nobody
remembers to save it, the evidence dies with the process. A *sink* streams each
Action Provenance record to disk the moment it is appended, so a crash loses at
most the record that was in flight.

Format: one JSON record per line (JSONL), append-only, fsync'd per record. The
file is a pure chain — exactly the records, no header — so anything that can
read a chain (``abom verify --run``, ``verify_chain``) can consume it directly.
"""
from __future__ import annotations

import json
import os
import re
from pathlib import Path
from typing import Any, Protocol

from ..audit import verify_chain

DEFAULT_RUNS_DIR = Path(".abom") / "runs"


class Sink(Protocol):
    """Anything with an ``append(record)`` — file, DB writer, network shipper."""

    def append(self, record: dict) -> None: ...


class JsonlSink:
    """Append-only JSONL writer, one provenance record per line.

    Durability over throughput: each append opens the file, writes one line,
    flushes, and fsyncs. Model/tool calls are seconds apart, so the cost is
    noise next to the call itself — and a crash can never lose a record that
    ``append`` returned from.
    """

    def __init__(self, path: str | Path):
        self.path = Path(path)
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def append(self, record: dict) -> None:
        line = json.dumps(record, separators=(",", ":"), sort_keys=True)
        with open(self.path, "a", encoding="utf-8") as f:
            f.write(line + "\n")
            f.flush()
            os.fsync(f.fileno())


def load_chain(path: str | Path) -> list[dict]:
    """Read a persisted chain back, tolerating exactly one crash artifact.

    A process killed mid-``append`` can leave a truncated final line; that
    trailing partial line is dropped silently (it is the at-most-one in-flight
    record a crash may lose). A malformed line anywhere *else* means the file
    was edited or corrupted, and raises ``ValueError`` — a trust artifact must
    not be silently repaired.
    """
    p = Path(path)
    lines = p.read_text(encoding="utf-8").splitlines()
    records: list[dict] = []
    for i, line in enumerate(lines):
        if not line.strip():
            continue
        try:
            records.append(json.loads(line))
        except json.JSONDecodeError:
            if i == len(lines) - 1:
                break  # trailing partial line: the in-flight record a crash loses
            raise ValueError(f"{p}: corrupt record at line {i + 1}")
    return records


def run_summary(path: str | Path) -> dict:
    """One row of ``abom runs list``: identity, span, size, and chain validity."""
    p = Path(path)
    chain = load_chain(p)
    v = verify_chain(chain)
    first, last = (chain[0], chain[-1]) if chain else ({}, {})
    return {
        "run": p.stem,
        "path": str(p),
        "agent_ref": first.get("run_id", "?"),
        "records": len(chain),
        "started_at": first.get("created_at", ""),
        "last_at": last.get("created_at", ""),
        "valid": v["valid"],
    }


def list_runs(runs_dir: str | Path = DEFAULT_RUNS_DIR) -> list[dict]:
    """Summaries for every persisted run under ``runs_dir``, oldest first."""
    d = Path(runs_dir)
    if not d.is_dir():
        return []
    return [run_summary(p) for p in sorted(d.glob("*.jsonl"))]


def resolve_run(ref: str | Path, runs_dir: str | Path = DEFAULT_RUNS_DIR) -> Path:
    """Turn a run reference — a path, a filename, or a run id — into a file.

    Tries, in order: the literal path; ``<runs_dir>/<ref>``; ``<runs_dir>/<ref>.jsonl``;
    and finally a unique ``*<ref>*.jsonl`` substring match inside ``runs_dir``.
    """
    p = Path(ref)
    if p.is_file():
        return p
    d = Path(runs_dir)
    for candidate in (d / str(ref), d / f"{ref}.jsonl"):
        if candidate.is_file():
            return candidate
    matches = [f for f in d.glob("*.jsonl") if str(ref) in f.stem] if d.is_dir() else []
    if len(matches) == 1:
        return matches[0]
    if len(matches) > 1:
        raise FileNotFoundError(
            f"run '{ref}' is ambiguous in {d}: {', '.join(m.stem for m in matches)}"
        )
    raise FileNotFoundError(f"no run '{ref}' found (looked in {d})")


def safe_slug(text: str) -> str:
    """A filesystem-safe fragment of an agent ref for use in run filenames."""
    return re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-") or "run"


def resolve_sink(
    sink: Any,
    *,
    run_id: str,
    runs_dir: str | Path = DEFAULT_RUNS_DIR,
) -> Sink | None:
    """Normalize ``instrument(sink=…)`` into a Sink (or None).

    * ``None``/``False`` → no persistence (in-memory chain only, the default)
    * ``True`` or ``"auto"`` → JSONL at ``<runs_dir>/<run_id>.jsonl``
    * a path ending in ``.jsonl`` → JSONL at exactly that file
    * any other path → treated as a directory, file named ``<run_id>.jsonl``
    * an object with ``.append`` → used as-is (custom sink)
    """
    if sink is None or sink is False:
        return None
    if sink is True or sink == "auto":
        return JsonlSink(Path(runs_dir) / f"{run_id}.jsonl")
    if isinstance(sink, (str, Path)):
        p = Path(sink)
        if p.suffix == ".jsonl":
            return JsonlSink(p)
        return JsonlSink(p / f"{run_id}.jsonl")
    if hasattr(sink, "append") and callable(sink.append):
        return sink
    raise TypeError(
        f"sink must be None, True, 'auto', a path, or an object with .append; got {type(sink).__name__}"
    )
