"""게이트에 막힌 샷은 **옛 대표가 있어도 완료가 아니다** (2026-09-20 Codex).

`SceneImagePipelineStep.verify_completion` 은 `is_primary` 자산의 **파일
존재**만 본다. 그래서 게이트가 새 생성·승격을 막아도 **재개에서 옛
대표 때문에 거짓 완료**가 난다.

★파일을 지우거나 대표를 내려서 실패를 표현하지 않는다 — **세는 자리**에서
 뺀다. 독립 성공 샷은 그대로 센다.
"""
from __future__ import annotations

import json

import pytest

from app.core.steps.image_steps import SceneImagePipelineStep
from app.modules.pipeline.multiroll_select import (
    GATE_CLEAN,
    GATE_INCOMPLETE,
    GATE_NOT_APPLICABLE,
    GATE_UNRESOLVED,
)

PID, EID = "P", "E"


@pytest.fixture(autouse=True)
def _gate_on(monkeypatch):
    """★이 시험들은 **게이트가 켜진 판**을 잰다.

    적용 경계(`gate_is_on`)가 한 자리로 모이면서, 안 켜면 모든 판정이
    False 가 된다 — 그것이 바로 OFF 계약이다(별도 시험이 본다).
    """
    from app.core import config

    monkeypatch.setattr(config.settings, "still_winner_gate_enabled", True,
                        raising=False)


class _Still:
    def __init__(self, id_, si, shi):
        self.id, self.scene_index, self.shot_index = id_, si, shi


class _Q:
    def __init__(self, rows):
        self._rows = rows

    def filter(self, *a):
        return self

    def all(self):
        return self._rows


class _DB:
    def __init__(self, stills):
        self._stills = stills

    def query(self, model):
        return _Q(self._stills)


def _step(tmp_path, records, stills, *, gate_on=True, monkeypatch=None):
    rd = tmp_path / PID / "images" / EID / "scene" / "recipe"
    rd.mkdir(parents=True, exist_ok=True)
    (rd / "records.json").write_text(json.dumps(records, ensure_ascii=False),
                                     encoding="utf-8")
    st = SceneImagePipelineStep.__new__(SceneImagePipelineStep)
    st.project_id, st.episode_id = PID, EID
    st.db = _DB(stills)
    from app.core import config

    monkeypatch.setattr(config.settings, "projects_dir", str(tmp_path))
    monkeypatch.setattr(config.settings, "still_winner_gate_enabled", gate_on,
                        raising=False)   # autouse fixture 를 덮는다
    return st


def test_an_unresolved_shot_is_not_counted_complete(tmp_path, monkeypatch):
    st = _step(tmp_path,
               {"S1sh1": {"gate": {"outcome": GATE_UNRESOLVED}},
                "S1sh2": {"gate": {"outcome": GATE_CLEAN}}},
               [_Still("a", 1, 1), _Still("b", 1, 2)],
               monkeypatch=monkeypatch)
    assert st._gate_blocked_still_ids(["a", "b"]) == ({"a"}, False, set())


def test_incomplete_is_also_not_complete(tmp_path, monkeypatch):
    """판정을 못 읽은 것도 **완료가 아니다** — 「확인 안 됨」≠「합격」."""
    st = _step(tmp_path, {"S1sh1": {"gate": {"outcome": GATE_INCOMPLETE}}},
               [_Still("a", 1, 1)], monkeypatch=monkeypatch)
    assert st._gate_blocked_still_ids(["a"]) == ({"a"}, False, set())


@pytest.mark.parametrize("outcome", [GATE_CLEAN, GATE_NOT_APPLICABLE])
def test_clean_and_not_applicable_still_count(tmp_path, monkeypatch, outcome):
    st = _step(tmp_path, {"S1sh1": {"gate": {"outcome": outcome}}},
               [_Still("a", 1, 1)], monkeypatch=monkeypatch)
    assert st._gate_blocked_still_ids(["a"]) == (set(), False, set())


def test_the_gate_off_run_is_untouched(tmp_path, monkeypatch):
    """★게이트가 꺼져 있으면 **종전 판정 그대로** — 옛 기록이 완료를
    뒤집지 않는다."""
    st = _step(tmp_path, {"S1sh1": {"gate": {"outcome": GATE_UNRESOLVED}}},
               [_Still("a", 1, 1)], gate_on=False, monkeypatch=monkeypatch)
    assert st._gate_blocked_still_ids(["a"]) == (set(), False, set())


def test_a_missing_records_file_is_unverifiable_when_the_gate_is_on(
        tmp_path, monkeypatch):
    """★**못 읽은 것은 합격이 아니다** (2026-09-20 Codex).

    내 첫 판은 「기록이 없으면 빈 집합」이라 적고 그것을 시험으로
    **잠갔다**. 그러면 파일이 남아 있는 **옛 대표가 다시 완료로**
    집계된다 — 앞 샷 앵커 쪽은 이미 뒤집어 놨는데 세는 쪽만 반대였다.
    """
    st = _step(tmp_path, {}, [_Still("a", 1, 1)], monkeypatch=monkeypatch)
    (tmp_path / PID / "images" / EID / "scene" / "recipe"
     / "records.json").unlink()
    assert st._gate_blocked_still_ids(["a"]) == (set(), True, set())


def test_a_broken_records_file_is_unverifiable_when_the_gate_is_on(
        tmp_path, monkeypatch):
    st = _step(tmp_path, {}, [_Still("a", 1, 1)], monkeypatch=monkeypatch)
    (tmp_path / PID / "images" / EID / "scene" / "recipe"
     / "records.json").write_text("{깨진", encoding="utf-8")
    assert st._gate_blocked_still_ids(["a"]) == (set(), True, set())


@pytest.mark.parametrize("break_it", ["unlink", "corrupt"])
def test_the_gate_off_run_never_becomes_unverifiable(tmp_path, monkeypatch,
                                                     break_it):
    """★꺼진 판은 기록이 없어도 **종전 그대로** — 레버 계약이다."""
    st = _step(tmp_path, {}, [_Still("a", 1, 1)], gate_on=False,
               monkeypatch=monkeypatch)
    rj = (tmp_path / PID / "images" / EID / "scene" / "recipe"
          / "records.json")
    rj.unlink() if break_it == "unlink" else rj.write_text("{깨진")
    assert st._gate_blocked_still_ids(["a"]) == (set(), False, set())


def test_nothing_to_count_is_not_unverifiable(tmp_path, monkeypatch):
    """셀 대상이 없으면 잴 것도 없다 — 없는 실패를 만들지 않는다."""
    st = _step(tmp_path, {}, [], monkeypatch=monkeypatch)
    (tmp_path / PID / "images" / EID / "scene" / "recipe"
     / "records.json").unlink()
    assert st._gate_blocked_still_ids([]) == (set(), False, set())


def test_an_unreadable_state_counts_nothing_as_complete(tmp_path,
                                                        monkeypatch):
    """★검사 불가면 **어느 대표도 완료가 아니다** — 재구매가 아니라 보류.

    파일을 지우거나 대표를 내리지 않는다. 세는 자리에서만 붙잡는다.
    """
    good = tmp_path / "g.png"
    good.write_bytes(b"P")
    st = _counting_step(
        tmp_path, monkeypatch, {},
        [_Still("a", 1, 1)], [_Asset("a", str(good))])
    (tmp_path / PID / "images" / EID / "scene" / "recipe"
     / "records.json").unlink()

    done, held, rows, unver, _unjudged = st._policy_completed_still_ids(
        ["a"])
    assert unver is True
    assert done == set(), "못 읽었는데 옛 대표를 완료로 셌다"
    assert held == {"a"}

    r = st._build_result_counts(["a"], 1)
    assert r["completed_count"] == 0
    assert r["failed_count"] == 1
    assert r["data"]["gate_unverifiable"] is True
    assert r["data"]["gate_held_reason"] == "gate_state_unreadable"
    assert r["data"]["gate_held_still_ids_truncated"] is False
    # 실제 대표 수는 그대로 남는다 — 자산을 건드린 것이 아니다
    assert r["data"]["primary_count"] == 1


def test_child_records_are_ignored(tmp_path, monkeypatch):
    """`::cine` 같은 자식 키는 샷 종착이 아니다."""
    st = _step(tmp_path,
               {"S1sh1::cine": {"gate": {"outcome": GATE_UNRESOLVED}},
                "S1sh1": {"gate": {"outcome": GATE_CLEAN}}},
               [_Still("a", 1, 1)], monkeypatch=monkeypatch)
    assert st._gate_blocked_still_ids(["a"]) == (set(), False, set())


# ── 실행 계수 (Codex ㉢) ───────────────────────────────────────────

class _Asset:
    def __init__(self, still_id, path):
        self.still_id, self.file_path = still_id, path
        self.asset_type, self.is_primary = "scene", 1


class _MixedDB(_DB):
    """SceneStill 질의와 ImageAsset 질의를 갈라 주는 대역."""

    def __init__(self, stills, assets):
        super().__init__(stills)
        self._assets = assets

    def query(self, model):
        name = getattr(model, "__name__", "")
        if name == "ImageAsset":
            return _Q(self._assets)
        return _Q(self._stills)


def _counting_step(tmp_path, monkeypatch, records, stills, assets,
                   *, gate_on=True):
    st = _step(tmp_path, records, stills, gate_on=gate_on,
               monkeypatch=monkeypatch)
    st.db = _MixedDB(stills, assets)
    return st


def test_a_partial_hold_is_not_reported_as_fully_complete(
        tmp_path, monkeypatch):
    """★「partial 인데 completed=239 / failed=0」을 막는다.

    StepRunner 는 exit verify 가 false 면 **상태만** partial 로 바꾸고
    계수를 다시 세지 않는다 — 그래서 계수 자체가 맞아야 한다.
    """
    good = tmp_path / "g.png"
    good.write_bytes(b"P")
    bad = tmp_path / "b.png"
    bad.write_bytes(b"P")
    st = _counting_step(
        tmp_path, monkeypatch,
        {"S1sh1": {"gate": {"outcome": GATE_CLEAN}},
         "S1sh2": {"gate": {"outcome": GATE_UNRESOLVED}}},
        [_Still("a", 1, 1), _Still("b", 1, 2)],
        [_Asset("a", str(good)), _Asset("b", str(bad))])

    r = st._build_result_counts(["a", "b"], 2)
    assert r["completed_count"] == 1, "붙잡힌 샷을 완료로 셌다"
    assert r["failed_count"] == 1
    assert r["applicable_count"] == 2
    assert r["data"]["gate_held_count"] == 1
    assert r["data"]["gate_held_still_ids_preview"] == ["b"]
    # 실제 대표 수는 **따로** 남는다 — 자산을 지운 것이 아니다
    assert r["data"]["primary_count"] == 2


def test_a_held_shot_without_a_primary_is_not_subtracted_twice(
        tmp_path, monkeypatch):
    """★`대표 수 - len(보류)` 로 빼면 **두 번 뺀다** (Codex ㉢).

    보류 샷에 애초에 대표가 없으면, 그 샷은 이미 미완료로 안 세어져
    있다 — 거기서 또 빼면 멀쩡한 샷 하나가 같이 사라진다.
    """
    good = tmp_path / "g.png"
    good.write_bytes(b"P")
    st = _counting_step(
        tmp_path, monkeypatch,
        {"S1sh1": {"gate": {"outcome": GATE_CLEAN}},
         "S1sh2": {"gate": {"outcome": GATE_UNRESOLVED}}},
        [_Still("a", 1, 1), _Still("b", 1, 2)],
        [_Asset("a", str(good))])          # b 는 대표가 아예 없다

    r = st._build_result_counts(["a", "b"], 1)
    assert r["completed_count"] == 1, (
        "보류 샷을 두 번 빼서 멀쩡한 샷까지 미완료가 됐다")
    assert r["failed_count"] == 1


def test_a_primary_row_without_a_file_is_not_complete(tmp_path, monkeypatch):
    """대표 **행**이 있어도 파일이 없으면 완료가 아니다 — verify 와 같다."""
    st = _counting_step(
        tmp_path, monkeypatch,
        {"S1sh1": {"gate": {"outcome": GATE_CLEAN}}},
        [_Still("a", 1, 1)],
        [_Asset("a", str(tmp_path / "없는파일.png"))])
    r = st._build_result_counts(["a"], 1)
    assert r["completed_count"] == 0


def test_the_gate_off_counts_are_untouched(tmp_path, monkeypatch):
    """★꺼진 판은 **종전 그대로** — DB 대표 수를 그대로 쓴다.

    파일 존재 검사조차 새로 끼우지 않는다. 레버가 꺼져 있는데 집계가
    달라지면 그건 게이트가 아니라 몰래 바뀐 계약이다.
    """
    st = _counting_step(
        tmp_path, monkeypatch,
        {"S1sh1": {"gate": {"outcome": GATE_UNRESOLVED}}},
        [_Still("a", 1, 1)],
        [_Asset("a", str(tmp_path / "없는파일.png"))],
        gate_on=False)
    r = st._build_result_counts(["a"], 1)
    assert r["completed_count"] == 1
    assert r["failed_count"] == 0
    assert "gate_held_count" not in r["data"]


def test_the_counter_and_verify_share_one_computation(tmp_path, monkeypatch):
    """★같은 규칙을 두 곳에 적으면 한쪽만 고쳐진다 — 한 함수를 본다."""
    good = tmp_path / "g.png"
    good.write_bytes(b"P")
    st = _counting_step(
        tmp_path, monkeypatch,
        {"S1sh1": {"gate": {"outcome": GATE_CLEAN}},
         "S1sh2": {"gate": {"outcome": GATE_INCOMPLETE}}},
        [_Still("a", 1, 1), _Still("b", 1, 2)],
        [_Asset("a", str(good)), _Asset("b", str(good))])
    done, held, rows, unver, unjudged = st._policy_completed_still_ids(
        ["a", "b"])
    assert done == {"a"} and held == {"b"} and rows == 2
    assert unver is False and unjudged == set()
    assert st._build_result_counts(["a", "b"], 2)["completed_count"] == len(done)


def test_the_counter_actually_subtracts_them():
    """조립부가 아니라 **세는 자리**가 그것을 빼는지 본다."""
    import inspect
    import pathlib

    from app.core.steps import image_steps

    src = pathlib.Path(inspect.getfile(image_steps)).read_text(
        encoding="utf-8")
    i = src.find("def _policy_completed_still_ids")
    assert i > 0, "verify 와 계수가 함께 쓰는 집합 계산이 없다"
    seg = src[i:i + 1800]
    # 파일 삭제·대표 내림으로 실패를 표현하지 않는다
    assert "unlink" not in seg and "is_primary = 0" not in seg
    assert "self._gate_blocked_still_ids" in seg


# ── 「판정 없음」 ≠ 「비대상」 (2026-09-20 Codex BLOCK) ─────────────

def test_a_shot_with_no_verdict_is_not_complete(tmp_path, monkeypatch):
    """★기록은 멀쩡한데 **이 샷의 판정만 없다** — 합격이 아니다.

    `not_applicable` 은 「이 갈래는 대상이 아니다」라고 **적힌** 것이고,
    gate 키가 없는 것은 **아직 안 본 것**이다. 키가 없다는 사실만으로
    「bgfirst 라 비대상이겠지」라고 알 수 없다.
    """
    st = _step(tmp_path,
               {"S1sh1": {"gate": {"outcome": GATE_CLEAN}},
                "S1sh2": {"selected": "a"}},          # 판정 없음
               [_Still("a", 1, 1), _Still("b", 1, 2)],
               monkeypatch=monkeypatch)
    held, unver, unjudged = st._gate_blocked_still_ids(["a", "b"])
    assert held == {"b"} and unjudged == {"b"}
    assert unver is False, "파일은 읽혔다 — 검사 불가가 아니라 미판정이다"


def test_a_shot_missing_from_the_records_is_not_complete(tmp_path,
                                                         monkeypatch):
    """기록에 그 샷 줄이 **아예 없는** 경우도 같다."""
    st = _step(tmp_path, {"S1sh1": {"gate": {"outcome": GATE_CLEAN}}},
               [_Still("a", 1, 1), _Still("b", 1, 2)],
               monkeypatch=monkeypatch)
    held, unver, unjudged = st._gate_blocked_still_ids(["a", "b"])
    assert held == {"b"} and unjudged == {"b"} and unver is False


def test_not_applicable_is_not_unjudged(tmp_path, monkeypatch):
    """★명시 비대상은 **통과한다** — ON 이라고 비대상 갈래까지 세우지 않는다."""
    st = _step(tmp_path, {"S1sh1": {"gate": {"outcome": GATE_NOT_APPLICABLE}}},
               [_Still("a", 1, 1)], monkeypatch=monkeypatch)
    assert st._gate_blocked_still_ids(["a"]) == (set(), False, set())


def test_the_gate_off_run_ignores_missing_verdicts(tmp_path, monkeypatch):
    """꺼진 판은 판정이 하나도 없어도 **종전 그대로**."""
    st = _step(tmp_path, {}, [_Still("a", 1, 1)], gate_on=False,
               monkeypatch=monkeypatch)
    assert st._gate_blocked_still_ids(["a"]) == (set(), False, set())


def test_an_unjudged_shot_with_an_old_primary_is_not_counted(tmp_path,
                                                             monkeypatch):
    """★판정 없는 샷의 **옛 대표**가 완료로 집계되지 않는다 (Codex 최소 반례)."""
    good = tmp_path / "g.png"
    good.write_bytes(b"P")
    st = _counting_step(
        tmp_path, monkeypatch,
        {"S1sh1": {"gate": {"outcome": GATE_CLEAN}}},
        [_Still("a", 1, 1), _Still("b", 1, 2)],
        [_Asset("a", str(good)), _Asset("b", str(good))])
    r = st._build_result_counts(["a", "b"], 2)
    assert r["completed_count"] == 1, "판정 없는 샷을 완료로 셌다"
    assert r["failed_count"] == 1
    assert r["data"]["gate_unjudged_count"] == 1
    assert r["data"]["primary_count"] == 2, "자산을 건드렸다"
