"""FINDING 5 (e2e-bughunt-v1 W3) — owned_object_usage deterministic skeleton/merge.

금월도 E2E bug-hunting session 1 에서 scene_detail 29/30 shot 이
`owned_object_usage cardinality 0 != normalized owned_list 12~23` 으로 실패.
근본 원인 = C2 의 1:1 exact-cardinality echo contract — close-framing 은 owned
block 을 프롬프트에서 숨기면서 echo 를 요구 (불가능 contract), non-close 는
gemini-pro 가 12~23 개 echo 를 체계적으로 무시.

`merge_owned_object_usage` = deterministic skeleton/merge 재설계 — code 가
cardinality skeleton 을 소유, LLM 은 redraw/anchor 한 owned 만 부분 declare,
누락은 absent default 로 채움, close-framing 은 all-absent synthesize.

no live LLM + NO VLM — 순수 helper 함수 검증만 사용.
"""
from __future__ import annotations

import ast
import pathlib

import pytest

from app.core.errors import AppError
from app.core.steps._owned_helpers import (
    assert_close_framing_absent_echo,
    build_owned_sentinel,
    merge_owned_object_usage,
    validate_owned_object_usage_coverage,
)

_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
_VLM_FORBIDDEN = {"vlm", "vision_lm", "vision_language", "lvm_judge", "lvm_validator"}


def _entry(token: str, kind: str = "anchor", phrase: str = "ref phrase") -> dict:
    return {
        "owned_token": token,
        "usage_kind": kind,
        "source_phrase": "" if kind == "absent" else phrase,
    }


# ── G1 — close-framing → deterministic all-absent synthesis ──
def test_g1_close_framing_synthesizes_all_absent():
    owned = ["chair", "desk", "lamp"]
    merged = merge_owned_object_usage(
        [], owned, is_close_framing=True, where="g1",
    )
    assert merged == [
        {"owned_token": "chair", "usage_kind": "absent", "source_phrase": ""},
        {"owned_token": "desk", "usage_kind": "absent", "source_phrase": ""},
        {"owned_token": "lamp", "usage_kind": "absent", "source_phrase": ""},
    ]
    # close-framing invariant 자명 충족.
    assert_close_framing_absent_echo(merged, where="g1")


def test_g1_close_framing_ignores_nonempty_llm_input():
    # close-framing prompt 은 owned block 을 LLM 에 노출하지 않으므로 LLM echo
    # 는 무의미 — deterministic all-absent 가 우선 (shot fail 아님).
    owned = ["chair", "desk"]
    llm = [
        {"owned_token": "chair", "usage_kind": "redraw",
         "source_phrase": "draw a new chair"},
    ]
    merged = merge_owned_object_usage(
        llm, owned, is_close_framing=True, where="g1",
    )
    assert all(e["usage_kind"] == "absent" for e in merged)
    assert all(e["source_phrase"] == "" for e in merged)
    assert len(merged) == 2


def test_g1_close_framing_empty_owned():
    merged = merge_owned_object_usage(
        [], [], is_close_framing=True, where="g1",
    )
    assert merged == []


# ── G2 — non-close + full valid LLM coverage → preserved verbatim ──
def test_g2_non_close_full_valid_coverage_preserved():
    owned = ["chair", "desk", "lamp"]
    llm = [
        {"owned_token": "chair", "usage_kind": "anchor",
         "source_phrase": "beside the chair"},
        {"owned_token": "desk", "usage_kind": "redraw",
         "source_phrase": "draw a desk by the window"},
        {"owned_token": "lamp", "usage_kind": "absent", "source_phrase": ""},
    ]
    merged = merge_owned_object_usage(
        llm, owned, is_close_framing=False, where="g2",
    )
    by = {e["owned_token"]: e for e in merged}
    assert by["chair"]["usage_kind"] == "anchor"
    assert by["chair"]["source_phrase"] == "beside the chair"
    assert by["desk"]["usage_kind"] == "redraw"
    assert by["desk"]["source_phrase"] == "draw a desk by the window"
    assert by["lamp"]["usage_kind"] == "absent"
    assert len(merged) == 3


# ── G3 — non-close + partial/missing LLM coverage → absent default fill ──
def test_g3_non_close_partial_llm_fills_absent_default():
    owned = ["chair", "desk", "lamp", "rug"]
    # LLM 이 chair 만 declare — 나머지 3 개는 code skeleton default.
    llm = [
        {"owned_token": "chair", "usage_kind": "redraw",
         "source_phrase": "draw a new chair"},
    ]
    merged = merge_owned_object_usage(
        llm, owned, is_close_framing=False, where="g3",
    )
    assert len(merged) == 4
    by = {e["owned_token"]: e for e in merged}
    assert by["chair"] == {
        "owned_token": "chair", "usage_kind": "redraw",
        "source_phrase": "draw a new chair",
    }
    for t in ("desk", "lamp", "rug"):
        assert by[t] == {
            "owned_token": t, "usage_kind": "absent", "source_phrase": "",
        }


def test_g3_non_close_empty_llm_fills_all_absent():
    owned = ["chair", "desk"]
    merged = merge_owned_object_usage(
        [], owned, is_close_framing=False, where="g3",
    )
    assert merged == [
        {"owned_token": "chair", "usage_kind": "absent", "source_phrase": ""},
        {"owned_token": "desk", "usage_kind": "absent", "source_phrase": ""},
    ]


def test_g3_non_close_empty_owned_empty_merged():
    merged = merge_owned_object_usage(
        [], [], is_close_framing=False, where="g3",
    )
    assert merged == []


# ── G4 — unknown token: soft-drop + diagnostics (hard fail 아님, 계약 변경분) ──
def test_g4_unknown_token_dropped_with_diagnostics():
    """owned 목록 밖 token 은 drop + diagnostics.rejected_owned_tokens 기록.

    현행 계약(merge docstring): unknown label 은 hard fail 이 아니라 entry
    drop → 그 자리는 skeleton absent default. 옛 raise 기대는 stale
    (2026-07-02 브랜치 부채 정리 — 코드 무변경, 테스트만 현행 계약으로 갱신).
    """
    diag: dict = {}
    merged = merge_owned_object_usage(
        [_entry("sofa", "redraw", "a sofa")],
        ["chair", "desk"], is_close_framing=False, where="g4",
        diagnostics=diag,
    )
    assert [(e["owned_token"], e["usage_kind"]) for e in merged] == [
        ("chair", "absent"), ("desk", "absent"),
    ]
    rejected = diag.get("rejected_owned_tokens") or []
    assert len(rejected) == 1 and rejected[0]["owned_token"] == "sofa"


def test_g4_duplicate_token_raises():
    with pytest.raises(AppError) as exc:
        merge_owned_object_usage(
            [_entry("chair", "redraw", "a chair"),
             _entry("chair", "anchor", "the chair")],
            ["chair", "desk"], is_close_framing=False, where="g4",
        )
    assert exc.value.code == "step.contract_violation"
    assert "more than once" in exc.value.message


def test_g4_non_list_raises():
    with pytest.raises(AppError) as exc:
        merge_owned_object_usage(
            "not a list", ["chair"], is_close_framing=False, where="g4",
        )
    assert exc.value.code == "step.contract_violation"
    assert "must be list" in exc.value.message


def test_g4_non_dict_entry_raises():
    with pytest.raises(AppError) as exc:
        merge_owned_object_usage(
            ["plain string"], ["chair"], is_close_framing=False, where="g4",
        )
    assert exc.value.code == "step.contract_violation"
    assert "must be dict" in exc.value.message


def test_g4_missing_key_raises():
    with pytest.raises(AppError) as exc:
        merge_owned_object_usage(
            [{"owned_token": "chair", "usage_kind": "redraw"}],  # source_phrase 누락
            ["chair"], is_close_framing=False, where="g4",
        )
    assert exc.value.code == "step.contract_violation"
    assert "source_phrase" in exc.value.message


def test_g4_non_str_field_raises():
    with pytest.raises(AppError) as exc:
        merge_owned_object_usage(
            [{"owned_token": "chair", "usage_kind": "redraw", "source_phrase": 123}],
            ["chair"], is_close_framing=False, where="g4",
        )
    assert exc.value.code == "step.contract_violation"


def test_g4_bad_usage_kind_enum_raises():
    with pytest.raises(AppError) as exc:
        merge_owned_object_usage(
            [{"owned_token": "chair", "usage_kind": "morph", "source_phrase": "x"}],
            ["chair"], is_close_framing=False, where="g4",
        )
    assert exc.value.code == "step.contract_violation"
    assert "usage_kind" in exc.value.message


def test_g4_non_absent_empty_phrase_raises():
    # redraw/anchor 인데 source_phrase 빈 문자열 → 모순 (근거 없음).
    with pytest.raises(AppError) as exc:
        merge_owned_object_usage(
            [{"owned_token": "chair", "usage_kind": "anchor", "source_phrase": ""}],
            ["chair"], is_close_framing=False, where="g4",
        )
    assert exc.value.code == "step.contract_violation"
    assert "non-absent needs evidence" in exc.value.message


def test_g4_absent_nonempty_phrase_raises():
    # absent 인데 source_phrase 비어있지 않음 → 모순.
    with pytest.raises(AppError) as exc:
        merge_owned_object_usage(
            [{"owned_token": "chair", "usage_kind": "absent",
              "source_phrase": "beside the chair"}],
            ["chair"], is_close_framing=False, where="g4",
        )
    assert exc.value.code == "step.contract_violation"
    assert "contradiction" in exc.value.message


# ── G5 — 금월도 cardinality-0 E2E bug regression ──
def test_g5_cardinality_zero_regression_non_close():
    # 금월도 E2E bug: gemini-pro 가 non-close shot 에서 owned_object_usage=[]
    # 출력 → 옛 1:1 echo contract 가 "cardinality 0 != 12" 로 29/30 shot fail.
    # merge 가 deterministic all-absent skeleton 으로 채워 coverage 통과.
    owned = [f"obj{i:02d}" for i in range(12)]
    merged = merge_owned_object_usage(
        [], owned, is_close_framing=False, where="g5",
    )
    assert len(merged) == 12
    # 직후 coverage validator 통과 — 옛 contract 였으면 AppError.
    validate_owned_object_usage_coverage(merged, owned, where="g5")


def test_g5_cardinality_zero_regression_close_framing():
    # close-framing 불가능 contract: prompt 가 owned block 을 숨겨 LLM 이 echo
    # 불가 → 옛 code 가 cardinality 0 로 fail. merge 가 all-absent synthesize.
    owned = [f"obj{i:02d}" for i in range(23)]
    merged = merge_owned_object_usage(
        [], owned, is_close_framing=True, where="g5",
    )
    assert len(merged) == 23
    validate_owned_object_usage_coverage(merged, owned, where="g5")
    assert_close_framing_absent_echo(merged, where="g5")


# ── G6 — merge output → build_owned_sentinel coverage 통과 (integration) ──
def test_g6_merge_then_build_sentinel_close_framing():
    owned = ["chair", "desk"]
    merged = merge_owned_object_usage(
        [], owned, is_close_framing=True, where="g6",
    )
    sentinel = build_owned_sentinel(
        owned=owned, camera_direction="extreme close-up",
        t2i_prompt="focus on a hand", is_close_framing=True,
        violations=[], owned_object_usage=merged,
    )
    assert sentinel["validator"] == "scene_detail_owned_objects.v1.close_skip"
    assert len(sentinel["owned_usage_hash"]) == 16


def test_g6_merge_then_build_sentinel_non_close_partial():
    owned = ["chair", "desk", "lamp"]
    llm = [
        {"owned_token": "lamp", "usage_kind": "anchor",
         "source_phrase": "lit by the lamp"},
    ]
    merged = merge_owned_object_usage(
        llm, owned, is_close_framing=False, where="g6",
    )
    sentinel = build_owned_sentinel(
        owned=owned, camera_direction="wide shot",
        t2i_prompt="a room lit by the lamp", is_close_framing=False,
        violations=[], owned_object_usage=merged,
    )
    assert sentinel["validator"] == "scene_detail_owned_objects.v1"
    assert len(sentinel["owned_usage_hash"]) == 16


# ── G7 — output order = normalized_owned_list order + NO VLM ──
def test_g7_output_order_matches_owned_list_order():
    # 출력 순서 = normalized_owned_list 순서 (test 안정성 / hash 결정론).
    owned = ["alpha", "bravo", "charlie", "delta"]
    llm = [
        {"owned_token": "charlie", "usage_kind": "redraw",
         "source_phrase": "draw charlie"},
        {"owned_token": "alpha", "usage_kind": "anchor",
         "source_phrase": "near alpha"},
    ]
    merged = merge_owned_object_usage(
        llm, owned, is_close_framing=False, where="g7",
    )
    assert [e["owned_token"] for e in merged] == [
        "alpha", "bravo", "charlie", "delta",
    ]


def test_g7_no_vlm_owned_helpers_module():
    # NO VLM (feedback_no_vlm_dependency) — merge helper 가 사는 모듈에
    # vision-model import/call leak 없음 (AST scan).
    tree = ast.parse(
        (_REPO_ROOT / "backend/app/core/steps/_owned_helpers.py").read_text()
    )
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            for alias in node.names:
                low = (alias.name or "").lower()
                for kw in _VLM_FORBIDDEN:
                    assert kw not in low, f"VLM import leak: {alias.name}"
            if isinstance(node, ast.ImportFrom):
                mod = (node.module or "").lower()
                for kw in _VLM_FORBIDDEN:
                    assert kw not in mod, f"VLM module leak: {node.module}"
        if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
            for kw in _VLM_FORBIDDEN:
                assert kw not in node.func.id.lower(), (
                    f"VLM call leak: {node.func.id}"
                )
