"""Tests for persistent runs (roadmap 1.1): the sink layer + runs/verify CLI.

Covers the acceptance criteria:
  * round-trip: records stream to disk and verify_chain passes on the file
  * crash recovery: a truncated trailing line loses at most that one record;
    corruption anywhere else refuses to load
  * concurrency: parallel calls produce unique ordered seqs and a valid chain
  * `abom runs list` / `abom runs show` / `abom verify --run` work end-to-end
  * zero behavior change: sink=None (default) writes nothing
"""
from __future__ import annotations

import json
from concurrent.futures import ThreadPoolExecutor

import pytest
from typer.testing import CliRunner

from abom import sign
from abom.adapters import instrument, load_chain
from abom.adapters.sink import list_runs, resolve_run
from abom.audit import verify_chain
from abom.bom import build_composition, verify_abom
from abom.cli import app


# --- fake Anthropic-shaped client (same shape as test_adapters.py) -----------
class _Usage:
    def __init__(self):
        self.input_tokens, self.output_tokens = 10, 5


class _Block:
    def __init__(self, text):
        self.type, self.text = "text", text


class _Response:
    def __init__(self, text):
        self.content = [_Block(text)]
        self.usage = _Usage()


class _Messages:
    def create(self, *, model, messages, **kw):
        return _Response(f"reply from {model}")


class FakeAnthropic:
    __module__ = "anthropic"

    def __init__(self):
        self.messages = _Messages()


MODEL = "claude-3-5-sonnet-20241022"


def _manifest(model=MODEL):
    key = sign.load_or_create_key()
    return build_composition(
        agent={"name": "sink-test-agent", "version": "1.0.0"},
        components=[{"type": "model", "name": model}],
        controls={},
        key=key,
    )


# --- round-trip ---------------------------------------------------------------
def test_round_trip_persist_and_verify(tmp_path):
    client = instrument(FakeAnthropic(), manifest=_manifest(), sink=tmp_path)
    for _ in range(3):
        client.messages.create(model=MODEL, messages=[])

    files = list(tmp_path.glob("*.jsonl"))
    assert len(files) == 1
    persisted = load_chain(files[0])
    assert persisted == client.abom.chain          # disk mirrors memory exactly
    assert len(persisted) == 3
    assert verify_chain(persisted)["valid"]


def test_explicit_jsonl_path(tmp_path):
    target = tmp_path / "my-run.jsonl"
    client = instrument(FakeAnthropic(), manifest=_manifest(), sink=target)
    client.messages.create(model=MODEL, messages=[])
    assert target.is_file()
    assert len(load_chain(target)) == 1


def test_auto_sink_uses_default_runs_dir(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    client = instrument(FakeAnthropic(), manifest=_manifest(), sink="auto")
    client.messages.create(model=MODEL, messages=[])
    files = list((tmp_path / ".abom" / "runs").glob("*.jsonl"))
    assert len(files) == 1
    assert client.abom.run_id in files[0].stem


def test_custom_sink_object_receives_records():
    bucket: list = []
    client = instrument(FakeAnthropic(), manifest=_manifest(), sink=bucket)
    client.messages.create(model=MODEL, messages=[])
    assert len(bucket) == 1 and bucket[0]["seq"] == 0


def test_default_is_memory_only_no_files(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    client = instrument(FakeAnthropic(), manifest=_manifest())   # sink omitted
    client.messages.create(model=MODEL, messages=[])
    assert not (tmp_path / ".abom").exists()                     # zero side effects
    assert len(client.abom.chain) == 1


# --- crash recovery -----------------------------------------------------------
def test_truncated_trailing_line_drops_only_inflight_record(tmp_path):
    client = instrument(FakeAnthropic(), manifest=_manifest(), sink=tmp_path)
    for _ in range(3):
        client.messages.create(model=MODEL, messages=[])
    f = next(tmp_path.glob("*.jsonl"))
    # simulate a crash mid-append: half a JSON record, no newline
    with open(f, "a") as fh:
        fh.write('{"run_id": "sink-test-agent", "seq": 3, "eve')

    recovered = load_chain(f)
    assert len(recovered) == 3                     # the in-flight record is gone…
    assert verify_chain(recovered)["valid"]        # …everything before it verifies


def test_corrupt_middle_line_refuses_to_load(tmp_path):
    client = instrument(FakeAnthropic(), manifest=_manifest(), sink=tmp_path)
    for _ in range(3):
        client.messages.create(model=MODEL, messages=[])
    f = next(tmp_path.glob("*.jsonl"))
    lines = f.read_text().splitlines()
    lines[1] = lines[1][: len(lines[1]) // 2]      # corrupt record #2, not the tail
    f.write_text("\n".join(lines) + "\n")

    with pytest.raises(ValueError, match="corrupt record at line 2"):
        load_chain(f)


# --- concurrency ---------------------------------------------------------------
def test_concurrent_calls_keep_chain_ordered_and_complete(tmp_path):
    client = instrument(FakeAnthropic(), manifest=_manifest(), sink=tmp_path)
    with ThreadPoolExecutor(max_workers=8) as pool:
        list(pool.map(
            lambda _: client.messages.create(model=MODEL, messages=[]), range(40)
        ))

    chain = load_chain(next(tmp_path.glob("*.jsonl")))
    assert len(chain) == 40
    assert sorted(r["seq"] for r in chain) == list(range(40))
    assert verify_chain(sorted(chain, key=lambda r: r["seq"]))["valid"]


# --- verify integration --------------------------------------------------------
def test_verify_abom_accepts_persisted_chain(tmp_path):
    m = _manifest()
    client = instrument(FakeAnthropic(), manifest=m, sink=tmp_path)
    client.messages.create(model=MODEL, messages=[])

    chain = load_chain(next(tmp_path.glob("*.jsonl")))
    ok = verify_abom(m, chain, {"allowed_models": [MODEL]})
    assert ok["ok"] and ok["actions"] == 1

    bad = verify_abom(m, chain, {"allowed_models": ["some-other-model"]})
    assert not bad["ok"]
    assert any(f["rule"] == "model_allowlist" for f in bad["findings"])


# --- CLI: runs list / show / verify --run ---------------------------------------
runner = CliRunner()


@pytest.fixture()
def persisted_run(tmp_path, monkeypatch):
    """A manifest on disk + one persisted run in the default runs dir."""
    monkeypatch.chdir(tmp_path)
    monkeypatch.setenv("ABOM_NO_BANNER", "1")
    m = _manifest()
    (tmp_path / "abom.json").write_text(json.dumps(m))
    client = instrument(FakeAnthropic(), manifest=m, sink="auto")
    for _ in range(2):
        client.messages.create(model=MODEL, messages=[])
    return client.abom.run_id


def test_cli_runs_list(persisted_run):
    result = runner.invoke(app, ["runs", "list"])
    assert result.exit_code == 0
    assert persisted_run in result.output
    assert "sink-test-agent" in result.output


def test_cli_runs_show(persisted_run):
    result = runner.invoke(app, ["runs", "show", persisted_run])
    assert result.exit_code == 0
    assert "chain intact" in result.output
    assert "seq   0" in result.output and "seq   1" in result.output


def test_cli_runs_show_broken_chain_exits_nonzero(persisted_run):
    f = resolve_run(persisted_run)
    lines = f.read_text().splitlines()
    rec = json.loads(lines[1])
    rec["data"]["decision"] = "tampered"           # mutate without re-hashing
    lines[1] = json.dumps(rec, separators=(",", ":"), sort_keys=True)
    f.write_text("\n".join(lines) + "\n")

    result = runner.invoke(app, ["runs", "show", persisted_run])
    assert result.exit_code == 1
    assert "BROKEN" in result.output


def test_cli_verify_with_run(persisted_run, tmp_path):
    policy = tmp_path / "policy.json"
    policy.write_text(json.dumps({"allowed_models": [MODEL]}))

    result = runner.invoke(
        app, ["verify", "abom.json", "--run", persisted_run, "--policy", str(policy)]
    )
    assert result.exit_code == 0
    assert "2 action(s) chain-verified" in result.output

    strict = tmp_path / "strict.json"
    strict.write_text(json.dumps({"allowed_models": ["nothing"]}))
    result = runner.invoke(
        app, ["verify", "abom.json", "--run", persisted_run, "--policy", str(strict)]
    )
    assert result.exit_code == 1
    assert "model_allowlist" in result.output


def test_cli_verify_missing_run_exits_2(persisted_run):
    result = runner.invoke(app, ["verify", "abom.json", "--run", "no-such-run"])
    assert result.exit_code == 2


def test_list_runs_helper(persisted_run):
    rows = list_runs()
    assert len(rows) == 1
    row = rows[0]
    assert row["records"] == 2 and row["valid"] and row["agent_ref"] == "sink-test-agent"
