"""R7 (live halves) — churn schedule + selector rebuild + metrics.json.

Acceptance predicate (roadmap R7, the traffic-side half): under a fixed churn
seed the selector rebuilds every dropped circuit, and metrics.json is produced.
(The offline detector calibration halves — correlator AUC≈1/0.5 and entropy
log2(N) — are covered in test_sor_analysis.py.)

All deterministic and offline: the churn schedule is seeded data (no real VM
spin/kill), the selector replays it, and the metrics writer aggregates the DVs.
The paid frontier-model ``agent`` arm is human-gated and NOT exercised here — the
``agent`` strategy under test is the local stability heuristic (no spend).
"""

import json

import pytest

from cmd_chat.sor.analysis.detectors import synthetic_bridge_fixture
from cmd_chat.sor.analysis.metrics import compute_metrics, write_metrics
from cmd_chat.sor.churn import ChurnEvent, churn_schedule, live_nodes_at
from cmd_chat.sor.events import EventLog
from cmd_chat.sor.selector import STRATEGIES, run_selection


NODES = [f"n{i}" for i in range(8)]


# --------------------------------------------------------------------------- #
# Churn schedule — seeded + deterministic.
# --------------------------------------------------------------------------- #
def test_churn_schedule_is_seed_deterministic():
    a = churn_schedule(2024, NODES, steps=20)
    b = churn_schedule(2024, NODES, steps=20)
    assert a == b
    assert churn_schedule(2025, NODES, steps=20) != a


def test_churn_schedule_has_kills_and_spawns():
    sched = churn_schedule(7, NODES, steps=40, kill_prob_pct=40)
    kinds = {ev.kind for ev in sched}
    assert "kill" in kinds and "spawn" in kinds
    # A killed node is not live until it is spawned again.
    first_kill = next(ev for ev in sched if ev.kind == "kill")
    assert first_kill.node not in live_nodes_at(NODES, sched, first_kill.t)


def test_empty_inputs_yield_empty_schedule():
    assert churn_schedule(1, [], steps=10) == []
    assert churn_schedule(1, NODES, steps=0) == []


# --------------------------------------------------------------------------- #
# Selector — rebuilds every dropped circuit under a fixed churn seed.
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("strategy", STRATEGIES)
def test_selector_rebuilds_every_dropped_circuit(strategy):
    # Pool (8) comfortably exceeds hops (3), so every drop can be healed.
    seed = 0xBADC0DE
    sched = churn_schedule(seed, NODES, steps=60, kill_prob_pct=35)
    result = run_selection(seed, NODES, hops=3, schedule=sched, strategy=strategy)
    assert len(result.initial_circuit) == 3
    assert result.drops > 0  # the churn actually perturbed the circuit
    assert result.every_drop_rebuilt  # THE R7 acceptance predicate
    assert result.deferred == 0
    assert len(result.rebuilds) == result.drops
    # Every rebuilt circuit is a full, distinct-node, currently-plausible path.
    for rb in result.rebuilds:
        assert len(rb.circuit) == 3
        assert len(set(rb.circuit)) == 3


def test_selector_is_deterministic():
    seed = 42
    sched = churn_schedule(seed, NODES, steps=30)
    r1 = run_selection(seed, NODES, hops=3, schedule=sched, strategy="random")
    r2 = run_selection(seed, NODES, hops=3, schedule=sched, strategy="random")
    assert [rb.circuit for rb in r1.rebuilds] == [rb.circuit for rb in r2.rebuilds]


def test_unknown_strategy_refused():
    with pytest.raises(ValueError):
        run_selection(1, NODES, hops=3, schedule=[], strategy="frontier-gpt")


def test_selection_emits_rebuild_events(tmp_path):
    seed = 0xBADC0DE
    sched = churn_schedule(seed, NODES, steps=40, kill_prob_pct=35)
    log = EventLog(tmp_path, "sel-run", seed=seed)
    result = run_selection(seed, NODES, hops=3, schedule=sched,
                           strategy="static", log=log)
    assert result.events_sha256 and len(result.events_sha256) == 64
    lines = (tmp_path / "events.jsonl").read_text().strip().splitlines()
    events = [json.loads(ln)["event"] for ln in lines]
    assert "churn_kill" in events
    assert events.count("rebuild_done") == len(result.rebuilds)


# --------------------------------------------------------------------------- #
# metrics.json — produced + schema-valid.
# --------------------------------------------------------------------------- #
def test_metrics_json_is_produced(tmp_path):
    seed = 0xBADC0DE
    sched = churn_schedule(seed, NODES, steps=60, kill_prob_pct=35)
    result = run_selection(seed, NODES, hops=3, schedule=sched, strategy="static")

    ingress, egress = synthetic_bridge_fixture(0xC0FFEE, linked=True)  # AUC ~1
    metrics = compute_metrics(
        sender_counts={f"s{i}": 1 for i in range(8)},  # 8 equiprobable -> 3 bits
        ingress=ingress,
        egress=egress,
        selection=result,
        churned_gaps=[1.0, 1.5, 1.0, 2.0],
        baseline_gaps=[10.0, 12.0, 11.0, 13.0],
    )
    path = write_metrics(tmp_path / "run", metrics)
    assert path.exists()

    loaded = json.loads(path.read_text())
    assert loaded["schema"] == "sor-metrics/1"
    assert loaded["rq2_anonymity_entropy_bits"] == pytest.approx(3.0)
    assert loaded["rq1_bridge_correlation_auc"] == pytest.approx(1.0)
    assert loaded["rq3_every_drop_rebuilt"] is True
    assert 0.0 <= loaded["rq3_throughput_retention"] <= 1.0
    assert loaded["rq3_rebuild_classifier_auc"] == pytest.approx(1.0)


def test_metrics_json_is_write_once(tmp_path):
    result = run_selection(1, NODES, hops=3, schedule=[], strategy="static")
    metrics = compute_metrics(
        sender_counts={"a": 1, "b": 1},
        ingress=[[1, 2]],
        egress=[[1, 2]],
        selection=result,
    )
    run = tmp_path / "run"
    write_metrics(run, metrics)
    with pytest.raises(FileExistsError):  # immutable artifact
        write_metrics(run, metrics)
