"""끝나지 못한 라운드(provider·예산 — 받은 후보 0)는 재개 때 **다시 돈다**; 2차 신원은 끝난 라운드의 지문을 접는다.

★실측 2026-09-02 밤: 상한에 막혀 빈손으로 끝난 2라운드가 라운드로 세어져 재개가 3라운드부터 시작하려다
아무것도 안 했고, 같은 신원으로 되쓰여 영원히 retryable 이었다(P03 · L02 · L03).
"""
from __future__ import annotations

from app.modules.pipeline import coarse_type_pick as ctp
from app.modules.pipeline import reference_acquisition_rounds as rr
from app.modules.pipeline import grounding_central_acquisition as ca


def _target():
    return {"subject_id": "rs_x", "terms_native": ["가"], "directive_native": "d", "language_lock_native": ""}


def test_a_round_with_no_candidates_is_redone_and_kept_for_audit(tmp_path, monkeypatch):
    searches = []
    def search(*a, **k):
        searches.append(1); return {"results": []}
    resume = {"rounds": [{"round_no": 1, "queries": [{"text": "q1"}], "decision": {"next": ctp.NEXT_NARROW_RETRY}, "downloaded_candidates": [{"index": 1, "path": "p1", "url": "u1"}]},
                         {"round_no": 2, "decision": {"next": ctp.NEXT_RETRYABLE, "why": "지시문 저작 실패: 상한"}, "downloaded_candidates": []}],
              "candidates": [{"index": 1, "path": "p1", "url": "u1"}]}
    got = rr.acquire_one(_target(), workdir=tmp_path, search=search, download=lambda *a, **k: False,
                         judge=lambda c: {}, write_brief=None, rounds=2, resume_from=resume)
    assert len(searches) == 1, "★2라운드를 다시 돌았다"
    assert [r["round_no"] for r in got["rounds"]][:1] == [1] and len(got["rounds"]) == 2
    assert got["incomplete_rounds"][0]["decision"]["why"].startswith("지시문 저작 실패")


def test_a_round_that_downloaded_but_did_not_judge_is_not_redone(tmp_path):
    searches = []
    def search(*a, **k):
        searches.append(1); return {"results": []}
    resume = {"rounds": [{"round_no": 1, "queries": [{"text": "q1"}], "decision": {"next": ctp.NEXT_RETRYABLE, "why": "판정 실패: timeout"},
                          "downloaded_candidates": [{"index": 1, "path": "p1", "url": "u1"}]}],
              "candidates": [{"index": 1, "path": "p1", "url": "u1"}]}
    got = rr.acquire_one(_target(), workdir=tmp_path, search=search, download=lambda *a, **k: False,
                         judge=lambda c: {}, write_brief=None, rounds=2, resume_from=resume)
    assert len(searches) == 1 and got["rounds"][0]["round_no"] == 1 and "incomplete_rounds" not in got


def test_pass_two_identity_moves_with_the_finished_rounds():
    one = {"target": _target(), "ledger_row": {"final_id": "X"}}
    a = ca.identity_of(one, contract_sha="s", brief_inputs={"pass": 2, "done_rounds": [(1, [{"text": "q1"}])]})
    b = ca.identity_of(one, contract_sha="s", brief_inputs={"pass": 2, "done_rounds": [(1, [{"text": "q1"}]), (2, [{"text": "q2"}])]})
    c = ca.identity_of(one, contract_sha="s", brief_inputs={"pass": 2})
    assert len({a, b, c}) == 3


def test_a_capped_second_pass_keeps_the_first_pass_result(monkeypatch, tmp_path):
    """★실측(attempt f01a9858): 2차가 상한에 막힌 대상이 1차 라운드까지 잃고 rounds=0 stub 이 됐다."""
    from app.modules.pipeline import grounding_chunk_journal as cj

    calls = []
    def fake_acquire_one(target, *, rounds, resume_from=None, **kw):
        sid = target["subject_id"]; prev = list((resume_from or {}).get("rounds") or [])
        for r in range(len(prev) + 1, int(rounds) + 1):
            calls.append(f"{sid}{r}"); prev.append({"round_no": r, "queries": [{"text": "q"}], "downloaded_candidates": [{"index": 1}], "decision": {"next": "narrow_retry"}})
        return {"subject_id": sid, "status": "retryable", "chosen": None, "rounds": prev, "candidates": []}
    monkeypatch.setattr(rr, "acquire_one", fake_acquire_one)
    monkeypatch.setattr(ca, "resolve_rounds", lambda r: 2)
    monkeypatch.setattr(ca._aa, "inputs_from_ledger", lambda ledger: {
        "targets": [{"target": {"subject_id": "A", "terms_native": []}, "source_evidence": {}, "ledger_row": {"final_id": "A"}}],
        "skipped": [], "refused": [], "auto_completed_rows": [], "not_applicable_rows": [], "ledger_rows": []})
    journal = cj.ChunkJournal(tmp_path / "j.json", contract={"wiring": ca.CONTRACT_VERSION})
    got = ca.run({"rows": []}, journal=journal, cap=1, workdir=tmp_path, search=lambda *a, **k: {},
                 download=lambda *a, **k: True, judge=lambda c: {}, write_brief=None, rounds=2)
    row = got["rows"][0]
    assert calls == ["A1"], calls
    assert len(row["acquisition"]["rounds"]) == 1, "★1차 라운드가 남아 있다"
    assert row["acquisition"].get("pass2_skipped_why_code") == ca.WHY_CAP_REACHED


def test_legacy_journal_lines_get_a_derived_slot_in_the_view(tmp_path):
    from app.modules.pipeline import grounding_chunk_journal as cj
    import json
    p = tmp_path / "j.json"
    p.write_text(json.dumps({"contract_version": "x", "contract": {"c": 1}, "calls": [
        {"identity": "old1", "status": "ok", "epoch": cj._epoch_of({"c": 1}), "response": {"subject_id": "S", "rounds": [{"round_no": 1}]}},
        {"identity": "old2", "status": "ok", "epoch": cj._epoch_of({"c": 1}), "response": {"subject_id": "S", "rounds": [{"round_no": 1}, {"round_no": 2}]}}]}), encoding="utf-8")
    j = cj.ChunkJournal(p, contract={"c": 1})
    assert j.entries["old1"]["slot"] == "S:p1" and j.entries["old1"]["slot_derived"] is True
    assert j.entries["old2"]["slot"] == "S:p2" and j.bought_slots() == 2
    assert "slot" not in j.events[0], "★로그의 줄은 그대로"
    # 같은 대상의 새 1차 구매는 같은 자리 — 상한을 안 늘린다
    assert cj.buy_or_reuse(j, "new1", cap=2, send=lambda: {"subject_id": "S", "rounds": [{"round_no": 1}]}, slot="S:p1") is not None
    assert j.bought_slots() == 2


def test_an_incomplete_purchase_is_not_reused_and_frees_its_slot(tmp_path):
    """★실측 2026-09-03 새벽: 상한에 막힌 2차(마지막 라운드 빈손)가 ok 로 저장돼 다음 판이 되썼다."""
    from app.modules.pipeline import grounding_chunk_journal as cj
    j = cj.ChunkJournal(tmp_path / "j.json", contract={"a": 1})
    bad = {"subject_id": "A", "status": "retryable", "rounds": [{"round_no": 1, "decision": {"next": "narrow_retry"}, "downloaded_candidates": [{"index": 1}]},
                                                                {"round_no": 2, "decision": {"next": "retryable", "why": "상한"}, "downloaded_candidates": []}]}
    got = cj.buy_or_reuse(j, "p2", cap=1, send=lambda: bad, slot="A:p2", complete=ca.acquisition_is_complete)
    assert got is bad and j.entries["p2"]["status"] == cj.STATUS_INCOMPLETE and j.bought_slots() == 0
    calls = []
    good = {"subject_id": "A", "status": "selected", "rounds": bad["rounds"][:1] + [{"round_no": 2, "decision": {"next": "select"}, "downloaded_candidates": [{"index": 1}]}]}
    assert cj.buy_or_reuse(j, "p2", cap=1, send=lambda: calls.append(1) or good, slot="A:p2", complete=ca.acquisition_is_complete) is good
    assert calls == [1], "★빈손 답은 되쓰지 않고 다시 산다"
    assert j.entries["p2"]["status"] == cj.STATUS_OK
    j2 = cj.ChunkJournal(tmp_path / "j.json", contract={"a": 1})
    assert [e["status"] for e in j2.events if e["identity"] == "p2"] == [cj.STATUS_INCOMPLETE, cj.STATUS_OK]


def test_a_person_can_settle_a_stored_ok_line_as_incomplete(tmp_path):
    from app.modules.pipeline import grounding_chunk_journal as cj
    j = cj.ChunkJournal(tmp_path / "j.json", contract={"a": 1})
    cj.buy_or_reuse(j, "p2", cap=1, send=lambda: {"status": "retryable", "rounds": []}, slot="A:p2")
    j.settle_incomplete("p2", why="마지막 라운드 빈손", by="t")
    assert cj.ChunkJournal(tmp_path / "j.json", contract={"a": 1}).entries["p2"]["status"] == cj.STATUS_INCOMPLETE
    assert cj.buy_or_reuse(j, "p2", cap=1, send=lambda: {"status": "selected", "rounds": []}, slot="A:p2")["status"] == "selected"
