"""도면 확인이 **빈 응답**이면 둘째 관찰자에게 한 번 더 보인다 (2026-09-19).

S48sh5 가 세 판 내리 같은 자리(`confined_fp_readback`)에서 빈 응답으로
죽었다. 그 확인은 「도면을 읽어 장면 설명을 만든다」는 관문이라 끌 수 없다.

잠그는 것 (Codex 설계 합의):
- 빈 응답일 때만 — 같은 도면 bytes · 같은 글 · 같은 스키마로 **한 번**
- 둘째도 실패하면 그대로 실패 (통과로 안 친다)
- 빈 응답이 아닌 실패(멈춤·예산·일반 오류)는 넘기지 않는다
- 첫 관찰이 성공하면 예전과 같은 호출 하나 · 같은 반환
"""
from __future__ import annotations

import pytest

from app.modules.llm.llm_client import EmptyLLMResponse

# 대역 모양은 실제 계약(`build_fp_readback_schema` 의 required)과 같게 —
# Codex 리뷰: 빈 `reads` 는 production 스키마를 못 지난다.
_OK = {"reads": {"controls": "SAMPLE", "mirrors": "SAMPLE",
                 "camera": "SAMPLE", "occupants": "SAMPLE"},
       "mismatches": [], "scene_description_en": "SAMPLE"}


def _drive(monkeypatch, tmp_path, behaviours):
    from app.modules.llm import llm_client
    from app.modules.pipeline import confined_fp

    fp = tmp_path / "fp.png"
    fp.write_bytes(b"\x89PNG\r\n\x1a\n" + b"SAMPLE")
    calls = []

    def fake_call_structured(step, system_prompt, user_prompt,
                             response_schema, project_config=None,
                             schema_name="response", opik_metadata=None,
                             **kw):
        calls.append({
            "step": step, "model": project_config[step]["model"],
            "sys": system_prompt, "parts": user_prompt,
            "schema": response_schema, "schema_name": schema_name,
            "kw": kw,
        })
        b = behaviours[len(calls) - 1]
        if isinstance(b, BaseException):
            raise b
        if kw.get("usage_sink") is not None:
            kw["usage_sink"]["physical_model"] = "SAMPLE-physical"
        return dict(b)

    monkeypatch.setattr(llm_client, "call_structured", fake_call_structured)
    try:
        out = confined_fp._readback(
            "confined_fp_readback_S1sh1", fp, "SAMPLE CONTEXT", None, None)
    except BaseException as exc:  # noqa: BLE001
        return calls, exc
    return calls, out


def _empty():
    return EmptyLLMResponse(
        "LLM returned empty response for step=x, model=gemini-pro, "
        "finish_reason=None")


def test_the_fake_answer_passes_the_production_schema():
    import jsonschema

    from app.modules.pipeline.confined_fp import build_fp_readback_schema

    jsonschema.validate(_OK, build_fp_readback_schema())


def test_first_success_is_one_call_and_same_return(monkeypatch, tmp_path):
    calls, out = _drive(monkeypatch, tmp_path, [_OK])
    assert out == _OK                       # 기록 칸이 새로 붙지 않는다
    assert len(calls) == 1
    assert calls[0]["model"] == "gemini-pro"
    assert calls[0]["kw"] == {"enable_fallback": False}


def test_empty_goes_to_second_observer_once_with_same_inputs(
        monkeypatch, tmp_path):
    from app.modules.pipeline.multiroll_gemini import SELECT_JUDGE_MODEL_2

    calls, out = _drive(monkeypatch, tmp_path, [_empty(), _OK])
    assert len(calls) == 2
    first, second = calls
    assert second["model"] == SELECT_JUDGE_MODEL_2
    # 같은 도면 bytes · 같은 글 · 같은 스키마 — 글을 부드럽게 하지 않는다
    assert second["parts"] == first["parts"]
    assert second["sys"] == first["sys"]
    assert second["schema"] == first["schema"]
    assert second["schema_name"] == first["schema_name"]
    # 「한 번 더」가 Router 재시도로 네 번이 되지 않게
    assert second["kw"]["num_retries"] == 0
    assert second["kw"]["enable_fallback"] is False
    fb = out["readback_fallback"]
    assert fb["first_model"] == "gemini-pro"
    assert fb["model"] == SELECT_JUDGE_MODEL_2
    assert fb["physical_model"] == "SAMPLE-physical"
    assert "empty response" in fb["first_error"]
    assert out["scene_description_en"] == "SAMPLE"


def test_both_empty_still_fails(monkeypatch, tmp_path):
    calls, out = _drive(monkeypatch, tmp_path, [_empty(), _empty()])
    assert len(calls) == 2
    assert isinstance(out, EmptyLLMResponse)


@pytest.mark.parametrize("exc", [
    RuntimeError("SAMPLE budget exhausted"),
    TimeoutError("SAMPLE deadline"),
    KeyboardInterrupt(),
])
def test_other_failures_are_not_handed_over(monkeypatch, tmp_path, exc):
    calls, out = _drive(monkeypatch, tmp_path, [exc, _OK])
    assert len(calls) == 1
    assert out is exc


def test_initial_fallback_survives_the_fix_readback(monkeypatch, tmp_path):
    """처음 확인이 둘째 관찰자로 갔고, 어긋남 때문에 수정 뒤 다시 확인하면 —
    마지막 기록만 남아 「대체 없음」으로 읽히면 안 된다 (Codex 리뷰)."""
    from app.modules.llm import llm_client
    from app.modules.pipeline import confined_fp

    drawn = []
    monkeypatch.setattr(
        confined_fp, "_gpt_fp_image",
        lambda prompt, out_path, ref=None, capture_role="": (
            drawn.append(capture_role),
            out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"FP")))
    mismatch = {**_OK, "mismatches": ["SAMPLE mismatch"]}
    answers = [_empty(), mismatch, _OK]      # 처음: 빈 응답→대체 · 수정 뒤: 주
    calls = []

    def fake_call_structured(step, *a, project_config=None, **kw):
        calls.append((step, project_config[step]["model"]))
        b = answers[len(calls) - 1]
        if isinstance(b, BaseException):
            raise b
        return dict(b)

    monkeypatch.setattr(llm_client, "call_structured", fake_call_structured)
    base = tmp_path / "base.png"
    base.write_bytes(b"\x89PNG\r\n\x1a\n" + b"BASE")
    _, rb = confined_fp.produce_shot_fp(
        "S1sh1", base,
        {"shot_text": "SAMPLE", "people": "SAMPLE", "camera": "SAMPLE"},
        tmp_path / "S1sh1_fp.png")
    assert drawn == ["confined_fp_mark", "confined_fp_fix"]
    assert rb["fixed"] is True
    # 수정 뒤 확인은 주 관찰자로 됐다 — 그 기록엔 대체가 없다
    assert "readback_fallback" not in rb
    # 그래도 처음 확인이 대체로 갔다는 사실은 남는다
    assert rb["readback_fallback_initial"]["first_model"] == "gemini-pro"
