"""실내 pose 가이드 attach + context 결정론 테스트 (Wave5, T7·T8).

핵심 불변식만: flag OFF byte-identical / context admit·QC-fail·judge-disabled 분기 /
REF_ROLE 분리. 가이드 품질은 canary 육안.
"""
from app.core.steps.indoor_shared_pose_guide_context import (
    attach_indoor_pose_guide_ref,
    build_indoor_shared_pose_context,
)


def _shot(scene, shot, constraints, framing="medium"):
    return {"scene_index": scene, "shot_index": shot, "shot_type": framing, "camera_direction": "",
            "frame_spatial_contract": {"reason": "blocking", "constraints": constraints}}


def _char(cid, zone, depth):
    return {"target_kind": "character", "target_id": cid, "label": cid.lower(),
            "screen_zone": zone, "depth_plane": depth, "gesture_action": "none",
            "gesture_target_label": ""}


_BGMAP = {"1_1": {"bg_id": "bgA", "image_bytes": b"plate"},
          "1_2": {"bg_id": "bgA", "image_bytes": b"plate"}}


def _grp_shots():
    return {(1, 1): _shot(1, 1, [_char("C01", "left", "foreground")], framing="wide"),
            (1, 2): _shot(1, 2, [_char("C01", "center", "background")], framing="close")}


def _admit_judge(payload, **kw):
    return {"needs_indoor_pose_guide": True, "decision_type": "cross_shot_continuity",
            "confidence": "high",
            "evidence": [{"shot_key": "1_1", "source_field": "fsc", "quote": "two figures"}]}


def _guide_ok(**kw):
    return b"\x89PNG_indoor_guide", {"status": "generated"}


def _guide_qcfail(**kw):
    return None, {"status": "qc_failed", "qc_reason": "photoreal_person"}


# ── T7: attach (flag gate, byte-identical) ──────────────────────────────

def test_attach_flag_off_is_byte_identical(monkeypatch):
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", False, raising=False)
    refs, roles, meta, am = [("x", b"1")], ["x"], [{}], [("x", "1")]
    ctx = {"guide_by_shot": {(1, 1): {"png": b"g", "group_id": "g1", "visible_focus": ""}}}
    changed = attach_indoor_pose_guide_ref(refs, roles, meta, am, scene_index=1, shot_index=1,
                                           indoor_pose_ctx=ctx)
    assert changed is False
    assert refs == [("x", b"1")] and roles == ["x"] and len(meta) == 1 and am == [("x", "1")]


def test_attach_flag_on_appends_precomputed(monkeypatch):
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", True, raising=False)
    refs, roles, meta, am = [], [], [], []
    ctx = {"guide_by_shot": {(1, 1): {"png": b"g", "group_id": "g1", "visible_focus": ""}}}
    changed = attach_indoor_pose_guide_ref(refs, roles, meta, am, scene_index=1, shot_index=1,
                                           indoor_pose_ctx=ctx)
    assert changed is True and roles == ["indoor_pose_guide"] and refs[0][1] == b"g"


def test_attach_flag_on_no_guide_for_shot_is_noop(monkeypatch):
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", True, raising=False)
    refs = []
    changed = attach_indoor_pose_guide_ref(refs, [], [], [], scene_index=9, shot_index=9,
                                           indoor_pose_ctx={"guide_by_shot": {}})
    assert changed is False and refs == []


# ── T8: context precompute (admit / QC fail / judge disabled) ───────────

def test_context_registers_guide_for_admitted_qc_pass(tmp_path):
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_admit_judge, guide_fn=_guide_ok, qc_fn=None, judge_enabled=True)
    assert (1, 1) in ctx["guide_by_shot"] and ctx["guide_by_shot"][(1, 1)]["png"] == b"\x89PNG_indoor_guide"


def test_context_qc_fail_no_guide_but_diagnostic(tmp_path):
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_admit_judge, guide_fn=_guide_qcfail, qc_fn=None, judge_enabled=True)
    assert ctx["guide_by_shot"] == {}
    assert any(d.get("reason") == "photoreal_person" for d in ctx["diagnostics"])


def test_context_judge_disabled_admits_none(tmp_path):
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_admit_judge, guide_fn=_guide_ok, qc_fn=None, judge_enabled=False)
    assert ctx["guide_by_shot"] == {}


def test_context_indoor_gate_excludes_outdoor(tmp_path):
    # indoor_locs 주어지면 게이트 적용 — bgA→L99(outdoor)면 judge 전에 제외.
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_admit_judge, guide_fn=_guide_ok, qc_fn=None, judge_enabled=True,
        bg_loc_by_id={"bgA": "L99"}, indoor_locs={"L01"}, outdoor_locs={"L99"})
    assert ctx["guide_by_shot"] == {}
    assert any(d.get("status") == "gate_excluded" and d.get("reason") == "outdoor_owned"
               for d in ctx["diagnostics"])


def _single_shot_judge(target_keys):
    def _j(payload, **kw):
        return {"needs_indoor_pose_guide": True, "decision_type": "single_shot_complexity",
                "confidence": "high",
                "evidence": [{"shot_key": "1_1", "source_field": "fsc", "quote": "kneeling, hand on surface"}],
                "target_shot_keys": target_keys}
    return _j


def _grp_shots_one_figure():
    # 1_1 = figure 1, 1_2 = figure 0 (establishing/insert).
    return {(1, 1): _shot(1, 1, [_char("C01", "left", "foreground")], framing="close"),
            (1, 2): _shot(1, 2, [], framing="medium")}


def test_context_single_shot_targets_only_figure_shot(tmp_path):
    # single_shot lane: target=1_1(figure1) 만 guide, 1_2(figure0)는 같은 그룹이어도 미생성.
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots_one_figure(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_single_shot_judge(["1_1"]), guide_fn=_guide_ok, qc_fn=None,
        judge_enabled=True)
    assert (1, 1) in ctx["guide_by_shot"]
    assert (1, 2) not in ctx["guide_by_shot"]


def test_context_single_shot_no_target_denies(tmp_path):
    # target_shot_keys 비어있음 → single_shot_no_valid_target.
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots_one_figure(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_single_shot_judge([]), guide_fn=_guide_ok, qc_fn=None,
        judge_enabled=True)
    assert ctx["guide_by_shot"] == {}
    assert any(d.get("status") == "single_shot_no_valid_target" for d in ctx["diagnostics"])


def test_context_single_shot_target_zero_figure_denies(tmp_path):
    # target 이 0-figure shot(1_2) → figure_count<1 이라 제외 → no valid target.
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots_one_figure(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_single_shot_judge(["1_2"]), guide_fn=_guide_ok, qc_fn=None,
        judge_enabled=True)
    assert ctx["guide_by_shot"] == {}
    assert any(d.get("status") == "single_shot_no_valid_target" for d in ctx["diagnostics"])


def test_context_skip_shot_keys_all_done_skips_group(tmp_path):
    # 그룹 전 멤버가 skip(완료) → judge/guide 0, all_members_done_skip 진단.
    calls = []

    def _spy_judge(payload, **kw):
        calls.append("judge")
        return _admit_judge(payload, **kw)

    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_spy_judge, guide_fn=_guide_ok, qc_fn=None,
        judge_enabled=True, skip_shot_keys={(1, 1), (1, 2)})
    assert ctx["guide_by_shot"] == {}
    assert calls == []  # judge 호출 0 (비용 가드)
    assert any(d.get("status") == "all_members_done_skip" for d in ctx["diagnostics"])


def test_context_skip_partial_generates_only_live(tmp_path):
    # 한 멤버만 완료 → 그룹 형성·judge 는 유지, guide 는 미완료 멤버만.
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_admit_judge, guide_fn=_guide_ok, qc_fn=None,
        judge_enabled=True, skip_shot_keys={(1, 1)})
    assert (1, 2) in ctx["guide_by_shot"]
    assert (1, 1) not in ctx["guide_by_shot"]


def test_context_indoor_gate_admits_indoor(tmp_path):
    # bgA→L01(indoor) → 게이트 통과 → admit + guide 등록.
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(1, 1), (1, 2)], bg_id_by_shot={(1, 1): "bgA", (1, 2): "bgA"},
        shot_by_key=_grp_shots(), zoom_member_keys=set(), background_chain_bg_map=_BGMAP,
        cache_dir=tmp_path, judge_fn=_admit_judge, guide_fn=_guide_ok, qc_fn=None, judge_enabled=True,
        bg_loc_by_id={"bgA": "L01"}, indoor_locs={"L01"}, outdoor_locs={"L99"})
    assert (1, 1) in ctx["guide_by_shot"]


# ── Task 4b: single-shot broad lane (context, 2026-07-01 Codex 합의) ──────

_LONE_BGMAP = {"2_1": {"bg_id": "bgB", "image_bytes": b"plate"}}
_LONE_BGMAP_NOPLATE = {"2_1": {"bg_id": "bgB"}}   # image_bytes 없음


def _lone_shots():
    return {(2, 1): _shot(2, 1, [_char("C01", "center", "foreground")], framing="close")}


def _single_target_judge(target_keys):
    def _j(payload, **kw):
        return {"needs_indoor_pose_guide": True, "decision_type": "single_shot_complexity",
                "confidence": "high",
                "evidence": [{"shot_key": "2_1", "source_field": "fsc", "quote": "kneeling, hand on floor"}],
                "target_shot_keys": target_keys}
    return _j


def test_context_single_lane_off_no_lone_candidate(tmp_path):
    # flag OFF(default param) → 단독 샷은 candidate 자체가 없어 judge 호출 0.
    calls = []

    def _spy(payload, **kw):
        calls.append("j")
        return _single_target_judge(["2_1"])(payload, **kw)

    ctx = build_indoor_shared_pose_context(
        selected_keys=[(2, 1)], bg_id_by_shot={(2, 1): "bgB"},
        shot_by_key=_lone_shots(), zoom_member_keys=set(), background_chain_bg_map=_LONE_BGMAP,
        cache_dir=tmp_path, judge_fn=_spy, guide_fn=_guide_ok, qc_fn=None, judge_enabled=True)
    assert ctx["guide_by_shot"] == {}
    assert calls == []          # candidate 0 → judge 미호출


def test_context_single_lane_on_generates_guide_for_lone_shot(tmp_path):
    # flag ON + 단독 selected + bg plate + figure>=1 + judge admit single → guide 생성.
    ctx = build_indoor_shared_pose_context(
        selected_keys=[(2, 1)], bg_id_by_shot={(2, 1): "bgB"},
        shot_by_key=_lone_shots(), zoom_member_keys=set(), background_chain_bg_map=_LONE_BGMAP,
        cache_dir=tmp_path, judge_fn=_single_target_judge(["2_1"]), guide_fn=_guide_ok,
        qc_fn=None, judge_enabled=True, single_shot_lane_enabled=True)
    assert (2, 1) in ctx["guide_by_shot"]
    assert ctx["guide_by_shot"][(2, 1)]["group_id"] == "isp-single-bgB-s2-sh1"


def test_context_single_lane_on_no_bg_plate_skips_guide_fn(tmp_path):
    # single lane 인데 actual bg plate 없음 → guide_fn 호출 0 + single_shot_no_bg_plate.
    calls = []

    def _spy_guide(**kw):
        calls.append("g")
        return _guide_ok(**kw)

    ctx = build_indoor_shared_pose_context(
        selected_keys=[(2, 1)], bg_id_by_shot={(2, 1): "bgB"},
        shot_by_key=_lone_shots(), zoom_member_keys=set(),
        background_chain_bg_map=_LONE_BGMAP_NOPLATE,
        cache_dir=tmp_path, judge_fn=_single_target_judge(["2_1"]), guide_fn=_spy_guide,
        qc_fn=None, judge_enabled=True, single_shot_lane_enabled=True)
    assert ctx["guide_by_shot"] == {}
    assert calls == []          # bg plate 없으면 guide_fn 0
    assert any(d.get("status") == "single_shot_no_bg_plate" for d in ctx["diagnostics"])


def test_context_single_lane_on_judge_deny_no_guide(tmp_path):
    # broad candidate 라도 judge deny → attach 0(fail-closed).
    def _deny(payload, **kw):
        return {"needs_indoor_pose_guide": False, "decision_type": "no_guide",
                "confidence": "low", "evidence": []}

    ctx = build_indoor_shared_pose_context(
        selected_keys=[(2, 1)], bg_id_by_shot={(2, 1): "bgB"},
        shot_by_key=_lone_shots(), zoom_member_keys=set(), background_chain_bg_map=_LONE_BGMAP,
        cache_dir=tmp_path, judge_fn=_deny, guide_fn=_guide_ok, qc_fn=None,
        judge_enabled=True, single_shot_lane_enabled=True)
    assert ctx["guide_by_shot"] == {}
    assert any(d.get("status") == "denied" for d in ctx["diagnostics"])


# ── goal#3: guide→final 구조 lineage (asset_id) ─────────────────────────

def test_attach_threads_asset_id_into_ref_role_metadata(monkeypatch):
    # attach 시 guide ImageAsset UUID 가 ref_role_metadata 에 실려야 coordinator 가
    # final scene input_image_ids 로 복원할 수 있다(라벨파싱 금지·UUID SOT).
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", True, raising=False)
    refs, roles, meta, am = [], [], [], []
    ctx = {"guide_by_shot": {(1, 1): {
        "png": b"g", "group_id": "g1", "visible_focus": "",
        "asset_id": "uuid-guide-123"}}}
    attach_indoor_pose_guide_ref(refs, roles, meta, am, scene_index=1, shot_index=1,
                                 indoor_pose_ctx=ctx)
    assert meta[0]["asset_id"] == "uuid-guide-123"
    assert meta[0]["pipeline_role"] == "indoor_pose_guide"
    assert am[0] == ("indoor_pose_guide", "g1")


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

    def fetchall(self):
        return self._rows


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

    def execute(self, *a, **kw):
        return _FakeResult(self._rows)


def test_resolve_guide_asset_ids_triple_match():
    import json as _json
    from app.core.steps.indoor_shared_pose_guide_context import _resolve_guide_asset_ids
    rows = [("uuid-A", _json.dumps({"group_id": "g1", "bg_key": "bgA", "guide_hash": "h1"}))]
    gbs = {(1, 1): {"group_id": "g1", "bg_key": "bgA", "guide_hash": "h1", "asset_id": None}}
    diags = []
    _resolve_guide_asset_ids(_FakeDB(rows), "p", "e", gbs, diags)
    assert gbs[(1, 1)]["asset_id"] == "uuid-A"
    assert diags == []


def test_resolve_guide_asset_ids_pair_fallback_when_hash_differs():
    # guide_hash 불일치(stale) → (group_id, bg_key) created_at desc fallback.
    import json as _json
    from app.core.steps.indoor_shared_pose_guide_context import _resolve_guide_asset_ids
    rows = [("uuid-latest", _json.dumps({"group_id": "g1", "bg_key": "bgA", "guide_hash": "hOLD"}))]
    gbs = {(1, 1): {"group_id": "g1", "bg_key": "bgA", "guide_hash": "hNEW", "asset_id": None}}
    diags = []
    _resolve_guide_asset_ids(_FakeDB(rows), "p", "e", gbs, diags)
    assert gbs[(1, 1)]["asset_id"] == "uuid-latest"


def test_resolve_guide_asset_ids_ambiguous_pair_unresolved():
    # ★Codex NARROW: 같은 (group,bg) accepted row 2개 + triple 미매칭 → ambiguous,
    # asset_id 미설정(wrong edge 방지) + ambiguous diagnostic.
    import json as _json
    from app.core.steps.indoor_shared_pose_guide_context import _resolve_guide_asset_ids
    rows = [
        ("uuid-1", _json.dumps({"group_id": "g1", "bg_key": "bgA", "guide_hash": "hX"})),
        ("uuid-2", _json.dumps({"group_id": "g1", "bg_key": "bgA", "guide_hash": "hY"})),
    ]
    gbs = {(1, 1): {"group_id": "g1", "bg_key": "bgA", "guide_hash": "hNEW", "asset_id": None}}
    diags = []
    _resolve_guide_asset_ids(_FakeDB(rows), "p", "e", gbs, diags)
    assert gbs[(1, 1)]["asset_id"] is None
    assert any(d.get("status") == "guide_asset_unresolved_ambiguous_pair" for d in diags)


def test_resolve_guide_asset_ids_triple_wins_over_multiple_pairs():
    # triple 정확 매칭은 같은 pair 가 여러 개여도 그 triple asset 을 선택(pair 모호성 무관).
    import json as _json
    from app.core.steps.indoor_shared_pose_guide_context import _resolve_guide_asset_ids
    rows = [
        ("uuid-match", _json.dumps({"group_id": "g1", "bg_key": "bgA", "guide_hash": "h1"})),
        ("uuid-other", _json.dumps({"group_id": "g1", "bg_key": "bgA", "guide_hash": "h2"})),
    ]
    gbs = {(1, 1): {"group_id": "g1", "bg_key": "bgA", "guide_hash": "h1", "asset_id": None}}
    diags = []
    _resolve_guide_asset_ids(_FakeDB(rows), "p", "e", gbs, diags)
    assert gbs[(1, 1)]["asset_id"] == "uuid-match"
    assert diags == []


def test_resolve_guide_asset_ids_miss_leaves_none_and_diagnostic():
    from app.core.steps.indoor_shared_pose_guide_context import _resolve_guide_asset_ids
    rows = []  # accepted row 없음(stale cache)
    gbs = {(1, 1): {"group_id": "g1", "bg_key": "bgA", "guide_hash": "h1", "asset_id": None}}
    diags = []
    _resolve_guide_asset_ids(_FakeDB(rows), "p", "e", gbs, diags)
    assert gbs[(1, 1)]["asset_id"] is None
    assert any(d.get("status") == "guide_asset_unresolved" for d in diags)


# ── ref_contract 방어: indoor_pose_guide role 분리 ──────────────────────

def test_indoor_pose_guide_role_registered_and_separate():
    from app.services.prompt_service import REF_ROLE_VALUES
    assert "indoor_pose_guide" in REF_ROLE_VALUES
    # required ref(character/background/prop) role 과 별개 — 충족 판단에 미참여.
    assert "indoor_pose_guide" not in ("character_ref", "background_chain_ref", "prop_ref")


# ── W-B (2026-07-03): 프레이밍 게이트 — close류 attach skip ───────────────

def test_attach_framing_gate_skips_close_and_insert(monkeypatch):
    """W-B: framing=close/insert → guide 존재해도 attach skip(False, 4-list 불변).
    손목/얼굴 클로즈업에 전신 pose 가이드가 붙던 (e) 패턴의 소비부 게이트."""
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", True, raising=False)
    ctx = {"guide_by_shot": {(1, 1): {"png": b"g", "group_id": "g1", "visible_focus": ""}}}
    for framing in ("close", "insert"):
        refs, roles, meta, am = [("x", b"1")], ["x"], [{}], [("x", "1")]
        changed = attach_indoor_pose_guide_ref(
            refs, roles, meta, am, scene_index=1, shot_index=1,
            indoor_pose_ctx=ctx, framing=framing)
        assert changed is False
        assert refs == [("x", b"1")] and roles == ["x"] and am == [("x", "1")]


def test_attach_framing_gate_passes_wide_medium_none(monkeypatch):
    """W-B: wide/medium/None(구 호출부) 은 게이트 통과 — 기존 attach 동작 유지."""
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", True, raising=False)
    ctx = {"guide_by_shot": {(1, 1): {"png": b"g", "group_id": "g1", "visible_focus": ""}}}
    for framing in ("wide", "medium", None):
        refs, roles, meta, am = [], [], [], []
        changed = attach_indoor_pose_guide_ref(
            refs, roles, meta, am, scene_index=1, shot_index=1,
            indoor_pose_ctx=ctx, framing=framing)
        assert changed is True and roles == ["indoor_pose_guide"]
