"""마커 맵 — 방향 권위(팩 v16) + 3회 실패 시 VLM 이 고르기 (2026-08-25).

두 가지를 재는 시험이다.

① **방향** — 마커 맵이 세 번 다 같은 쪽으로 틀렸다(S2sh1: 시야각 부채꼴이
   피사체 반대편으로 열림). 원인은 모델이 아니라 문안이었다: 그리는 쪽과
   재는 쪽 둘 다 "landmark 서술이 권위, 좌표는 coarse estimate" 라고 했고,
   그 landmark 서술은 "south-east corner" 처럼 방위를 부르는데, 그 방위를
   도면 위에서 확인할 수 있는지는 **그림마다 다르다**(2026-08-25 gpt-image-2
   판에는 나침반이 없었고, 08-26 nb2 판에는 있었다). 코드는 그림을 못 보므로
   어느 쪽으로도 단정하지 않는다 — v16 은 **좌표를 권위로** 뒤집을 뿐이다.
   ★여기서 재는 것은 **나가는 문안**이다 — 헬퍼 반환값이 아니라 실제로
    조립된 프롬프트 문자열. 그리고 **양성 대조**로 v15 를 같이 태운다.
    v15 가 옛 문안 그대로여야 「가름이 실제로 켜졌다」가 증명된다.

② **고르기** — 사용자 지시: "그냥 nb2 만, nb2 가 3번 실패하면 그냥 그 셋
   중에 하나 하라고 VLM 으로". 통과가 없다고 샷을 실패로 떨어뜨리면 스텝이
   partial 로 멈추고 뒤가 통째로 막힌다(실측).

유료 호출은 한 건도 내지 않는다 — 모델을 부르는 자리는 전부 대역이다.
"""
from __future__ import annotations

import ast
import inspect
from pathlib import Path

import pytest


def _geometry():
    """anchor 에 **방위 낱말이 들어 있는** 기하 — 결함이 났던 모양 그대로."""
    return {
        "entity_placements": [
            {"slot": "E1", "subject_en": "running adult figure",
             "x": 0.50, "y": 0.55,
             "anchor_en": "near the south-east corner of the paved area",
             "facing": {"x": 0.50, "y": 0.85},
             "facing_anchor_en": "toward the camera"},
        ],
        "camera": {
            "origin": {"x": 0.50, "y": 0.90},
            "origin_anchor_en": "near the north gate",
            "look_target": {"x": 0.50, "y": 0.35},
            "look_target_anchor_en": "toward the front of the shelter",
            "view_left": {"x": 0.20, "y": 0.30},
            "view_left_anchor_en": "past the west edge of the shelter",
            "view_right": {"x": 0.80, "y": 0.30},
            "view_right_anchor_en": "past the east edge of the shelter",
        },
        "rationale_ko": "SAMPLE",
    }


def _png() -> bytes:
    from PIL import Image
    import io

    buf = io.BytesIO()
    Image.new("RGB", (64, 40), (240, 236, 228)).save(buf, format="PNG")
    return buf.getvalue()


# ── ① 방향: 나가는 문안에서 좌표가 권위인가 ────────────────────


def test_v16_annotate_prompt_makes_coordinates_the_authority():
    """그리는 쪽 문안 — 좌표가 권위, 랜드마크는 참고."""
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_annotate_prompt,
    )

    p16 = build_marker_annotate_prompt(
        geometry=_geometry(), prompt_version="16")
    flat = " ".join(p16.split())

    # 머리말이 권위를 좌표로 못박는다
    assert "THOSE FRACTIONS ARE THE AUTHORITY" in flat
    assert "when a landmark phrase and the fractions disagree, follow " \
           "the fractions" in flat
    # ★나침반이 있다/없다를 **단정하지 않는다** — 코드는 그림을 못 본다.
    #  실측(2026-08-26): nb2 가 그린 도면에는 나침반이 있었고 네 갈래에
    #  방위 라벨까지 붙어 있었다. "이 도면에 나침반이 없다"고 못박아 두면
    #  그림에 실제로 있는 정보를 버리라고 시키는 셈이 된다.
    assert "NO compass" not in flat
    assert "read them only if this plan actually shows a compass" in flat
    # 옛 권위 문장은 사라졌다
    assert "the landmark description wins" not in flat

    # 마커 줄: 좌표가 앞, 랜드마크는 괄호 안 참고
    body = p16[p16.index("MARKERS TO DRAW"):]
    assert "circle at x=0.50, y=0.90" in body
    assert "(landmark, for reference: near the north gate)" in body
    assert "coarse estimate" not in body


def test_v16_wedge_and_facing_lead_with_coordinates():
    """★부채꼴 경계와 피사체 방향 — **틀린 방향이 실제로 나온 자리**.

    종전 표기는 "past the west edge (coarse estimate x=0.20, y=0.30)" 라
    방위 낱말이 앞에 왔다. 이 두 줄이 안 바뀌면 ①의 머리말만 바꾼 셈이다.
    """
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_annotate_prompt,
    )

    body = build_marker_annotate_prompt(
        geometry=_geometry(), prompt_version="16")
    body = body[body.index("MARKERS TO DRAW"):]

    assert "LEFT edge cuts through at x=0.20, y=0.30" in body
    assert "RIGHT edge through at x=0.80, y=0.30" in body
    assert "facing at x=0.50, y=0.85" in body
    # 방위 낱말은 남아 있되 **참고로 표시된 자리에만** 있다
    assert "past the west edge of the shelter" in body
    assert "(landmark, for reference: past the west edge of the " \
           "shelter)" in body


def test_v16_check_lines_and_header_agree_with_the_drawing_side():
    """재는 쪽 — 그리는 쪽과 **같은 말**을 해야 한다.

    실측 정황: 기하가 중앙에 둔 피사체를 검사기가 "남동에 있어야 하는데
    아니다" 로 반려했다. 그리는 쪽만 고치면 재는 쪽이 계속 반려한다.
    """
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_check_user_lines,
        marker_check_user_header,
    )

    head = marker_check_user_header("16")
    assert "the coordinates are the authority" in head
    assert "coarse estimate" not in head

    lines = build_marker_check_user_lines(
        _geometry(), require_anchors=True, require_view=True,
        coord_first=True)
    joined = "\n".join(lines)
    assert "- CAM expected at x=0.50, y=0.90" in joined
    assert "- E1 expected at x=0.50, y=0.55" in joined
    assert "LEFT edge cuts through at x=0.20, y=0.30" in joined
    assert "coarse estimate" not in joined


def test_published_pack_v15_text_is_unchanged():
    """★양성 대조 — 옛 팩은 **바이트 그대로**여야 한다.

    이것이 없으면 「v16 이 좌표를 권위로 말한다」가 **모든 팩이 그렇게
    바뀐 것**과 구별되지 않는다. v6~v15 는 스템에 "landmark 가 권위" 라고
    박아 발행됐으므로, 코드 표기만 바뀌면 스템과 본문이 서로 다른 말을
    하게 된다.
    """
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_annotate_prompt,
        build_marker_check_user_lines,
        marker_check_user_header,
    )

    p15 = build_marker_annotate_prompt(
        geometry=_geometry(), prompt_version="15")
    flat = " ".join(p15.split())
    assert "the landmark description wins" in flat
    assert "THOSE FRACTIONS ARE THE AUTHORITY" not in flat

    body = p15[p15.index("MARKERS TO DRAW"):]
    assert "circle near the north gate (coarse estimate x=0.50, " \
           "y=0.90)" in body
    assert "landmark, for reference" not in body

    assert "coarse estimates" in marker_check_user_header("15")
    old_lines = "\n".join(build_marker_check_user_lines(
        _geometry(), require_anchors=True, require_view=True))
    assert "coarse estimate" in old_lines
    assert "landmark, for reference" not in old_lines


def test_conti_sketch_geometry_text_keeps_the_marked_plan_as_authority():
    """★범위 넘김 방지 — 콘티 스케치 경로는 **한 글자도 안 바뀐다**.

    거기서는 마커 맵 **그림**이 권위이고 좌표는 감사용 병기다
    (staging head: "neither ever overrides the marked plan"). 여기까지
    좌표를 권위로 바꾸면 위에서 본 좌표를 화면 좌우로 읽는 옛 반전
    결함(v11 실측: 맵 좌표 facing 병기 = 인물 방향 반전)을 되살린다.
    """
    from app.modules.pipeline.outdoor_marker_map import (
        build_geometry_text_lines,
    )

    lines = "\n".join(build_geometry_text_lines(_geometry()))
    assert "coarse estimate x=0.50, y=0.90" in lines
    assert "the camera stands near the north gate" in lines
    assert "landmark, for reference" not in lines


def test_v16_pack_stems_carry_the_same_authority_as_the_code():
    """팩 스템과 코드가 **같은 말**을 하는가 — 발행본 실물 대조."""
    from app.modules.pipeline.outdoor_marker_map import (
        load_marker_check_sys,
    )

    sys16 = " ".join(load_marker_check_sys("16").split())
    assert "THE COORDINATES ARE THE AUTHORITY" in sys16
    assert "NO compass" not in sys16
    assert "whether or not this plan shows a compass" in sys16
    assert "violations" in sys16
    # 옛 팩은 그대로
    sys15 = " ".join(load_marker_check_sys("15").split())
    assert "THE COORDINATES ARE THE AUTHORITY" not in sys15


# ── ② 3회 실패 시 VLM 이 고른다 ────────────────────────────────


def _cand(i: int, *, axes=()):
    return {"png": _png(), "model": "nb2", "attempt": i,
            "check": {"markers_ok": False, "details_ko": f"사유{i}",
                      "violations": [{"axis": a, "note_ko": "x"}
                                     for a in axes]}}


def test_pick_is_not_called_when_there_is_only_one_candidate(monkeypatch):
    """물어볼 것이 없는 데 돈을 쓰지 않는다."""
    from app.modules.pipeline import outdoor_marker_map as om

    calls = []
    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured",
        lambda *a, **k: calls.append(1) or {"choice": 1, "why_ko": ""})

    assert om.pick_best_marker_map(
        base_png=_png(), candidates=[_cand(1)], geometry=_geometry(),
        prompt_version="16") == 0
    assert calls == []


def test_pick_returns_the_index_the_vlm_chose(monkeypatch):
    from app.modules.pipeline import outdoor_marker_map as om

    seen = {}

    def fake(name, sys_txt, parts, schema, **kw):
        seen["name"] = name
        seen["parts"] = parts
        seen["schema"] = schema
        return {"choice": 3, "why_ko": "부채꼴이 피사체를 담았다"}

    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured", fake)

    cands = [_cand(1), _cand(2), _cand(3)]
    idx = om.pick_best_marker_map(
        base_png=_png(), candidates=cands, geometry=_geometry(),
        prompt_version="16")

    assert idx == 2
    assert cands[2]["pick_why_ko"] == "부채꼴이 피사체를 담았다"
    assert seen["name"] == "lane_marker_map_pick"
    # 절대 점수를 묻지 않는다 — 「여럿 중 어느 것」 하나뿐
    assert set(seen["schema"]["properties"]) == {"choice", "why_ko"}
    assert seen["schema"]["properties"]["choice"]["maximum"] == 3
    # 원본 1장 + 후보 3장이 실렸다
    assert sum(1 for p in seen["parts"]
               if p.get("type") == "image_url") == 4


def test_pick_payload_carries_each_candidate_reject_reason(monkeypatch):
    """무엇 때문에 걸렸는지를 고르는 쪽에 보여 준다."""
    from app.modules.pipeline import outdoor_marker_map as om

    seen = {}
    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured",
        lambda n, s, parts, sc, **kw: (
            seen.update(parts=parts) or {"choice": 1, "why_ko": ""}))

    om.pick_best_marker_map(
        base_png=_png(),
        candidates=[_cand(1, axes=("camera_wedge",)),
                    _cand(2, axes=("entity_position", "entity_facing")),
                    _cand(3)],
        geometry=_geometry(), prompt_version="16")

    texts = " ".join(p.get("text", "") for p in seen["parts"])
    assert "CANDIDATE 1: (the checker rejected this one for — " \
           "camera_wedge — 사유1)" in texts
    assert "entity_position, entity_facing — 사유2" in texts
    # 축이 비면 사유 문구만 남는다 (빈 괄호를 붙이지 않는다)
    assert "CANDIDATE 3: (the checker rejected this one for — 사유3)" \
        in texts


@pytest.mark.parametrize("out", [
    {"choice": 0, "why_ko": ""},      # 범위 밖 (1-기준인데 0)
    {"choice": 9, "why_ko": ""},      # 후보 수보다 큼
    {"why_ko": ""},                   # choice 결손
])
def test_pick_falls_back_to_the_first_candidate_on_a_bad_answer(
        monkeypatch, out):
    from app.modules.pipeline import outdoor_marker_map as om

    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured",
        lambda *a, **k: out)
    assert om.pick_best_marker_map(
        base_png=_png(), candidates=[_cand(1), _cand(2), _cand(3)],
        geometry=_geometry(), prompt_version="16") == 0


def test_pick_falls_back_when_the_judge_raises(monkeypatch):
    """판정기가 죽었다고 **이미 산 그림을 다 버리면 안 된다**."""
    from app.modules.pipeline import outdoor_marker_map as om

    def boom(*a, **k):
        raise RuntimeError("판정 불가")

    monkeypatch.setattr(
        "app.modules.llm.llm_client.call_structured", boom)
    assert om.pick_best_marker_map(
        base_png=_png(), candidates=[_cand(1), _cand(2)],
        geometry=_geometry(), prompt_version="16") == 0


# ── 스텝이 실제로 그렇게 하는가 (소스 계약) ──────────────────


def _marker_branch_source() -> str:
    from app.core.steps import shot_conti_light_step as m

    return Path(inspect.getsourcefile(m)).read_text(encoding="utf-8")


def test_step_collects_candidates_and_asks_the_vlm_instead_of_failing():
    """★사용자 지시의 핵심 — 3회 실패가 **샷 실패가 아니다**.

    종전에는 여기서 status="failed" 로 떨어뜨렸고 그 한 샷 때문에 스텝이
    partial 로 멈춰 뒤가 통째로 막혔다. 이제는 셋을 모아 VLM 이 고른다.
    ★문자열 검색이 아니라 **AST 로** 본다 — 주석에 적힌 이름이 배선으로
     오독되지 않게.
    """
    tree = ast.parse(_marker_branch_source())
    called = {
        n.func.id for n in ast.walk(tree)
        if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
    }
    assert "pick_best_marker_map" in called
    # 셈으로 순위를 매기던 옛 갈래는 남아 있지 않다
    assert "marker_violation_count" not in called


def test_step_only_fails_the_shot_when_nothing_was_drawn_at_all():
    """한 장도 못 그린 경우에만 실패다 — 그때는 고를 것이 없다."""
    src = _marker_branch_source()
    branch = src[src.index("if not marker_ok:"):]
    branch = branch[:branch.index("sketch_prompt = ")]
    assert 'if not _cands:' in branch
    assert branch.count('status="failed"') == 1
    assert "marker_best_effort=True" in branch
