"""frame_spatial_contract helper module unit tests (Group A)."""
from __future__ import annotations

import pytest

from app.core import frame_spatial_contract as fsc
from app.core.errors import AppError


# ── Constants tests ────────────────────────────────────────────────────────


def test_constants_9_zones_3_depths_max_3():
    """ZONE_PHRASES 9 entry / DEPTH_PHRASES 3 entry / _FSC_MAX_CONSTRAINTS = 3."""
    assert len(fsc.ZONE_PHRASES) == 9
    assert len(fsc.DEPTH_PHRASES) == 3
    assert fsc._FSC_MAX_CONSTRAINTS == 3
    assert fsc._FSC_ID_PREFIX == "fsc_"
    assert all(isinstance(v, list) and len(v) >= 1 for v in fsc.ZONE_PHRASES.values())


# ── find_frame_spatial_contract_violations tests ──────────────────────────

VALID_CONSTRAINT = {
    "target_kind": "character",
    "target_id": "C02",
    "label": "B",
    "screen_zone": "lower_right",
    "depth_plane": "foreground",
    "gesture_action": "points_to",
    "gesture_target_label": "entrance door",
}


def _shot_with_contract(contract):
    return {"scene_index": 1, "shot_index": 1, "frame_spatial_contract": contract}


def test_find_violations_null_contract_returns_empty():
    """null contract → violation 없음 (gating opt-in)."""
    shots = [_shot_with_contract(None)]
    assert fsc.find_frame_spatial_contract_violations(shots) == []


def test_find_violations_valid_1_to_3_constraints_returns_empty():
    """valid 1~3 constraints → violation 없음."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [VALID_CONSTRAINT, {**VALID_CONSTRAINT, "target_id": "C03"}],
    }
    shots = [_shot_with_contract(contract)]
    assert fsc.find_frame_spatial_contract_violations(shots) == []


def test_find_violations_more_than_3_constraints():
    """> 3 constraints → violation."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [VALID_CONSTRAINT] * 4,
    }
    shots = [_shot_with_contract(contract)]
    violations = fsc.find_frame_spatial_contract_violations(shots)
    assert len(violations) == 1
    assert "constraints_max_3" in violations[0]["reason"]


def test_find_violations_invalid_enum_or_empty_label_or_wrong_target_id():
    """invalid enum / empty label / wrong target_id shape → violation."""
    contract_invalid_zone = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "screen_zone": "INVALID_ZONE"}],
    }
    contract_empty_label = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "label": ""}],
    }
    contract_wrong_target = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "target_kind": "character", "target_id": "P02"}],
    }
    for contract in [contract_invalid_zone, contract_empty_label, contract_wrong_target]:
        shots = [_shot_with_contract(contract)]
        assert len(fsc.find_frame_spatial_contract_violations(shots)) >= 1


def test_find_violations_gesture_action_not_none_requires_label():
    """gesture_action != 'none' + gesture_target_label = '' → violation."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "gesture_action": "points_to", "gesture_target_label": ""}],
    }
    shots = [_shot_with_contract(contract)]
    violations = fsc.find_frame_spatial_contract_violations(shots)
    assert len(violations) >= 1
    assert any("gesture_target_label" in v["reason"] for v in violations)


def test_find_violations_gesture_action_none_requires_empty_label():
    """gesture_action == 'none' + gesture_target_label != '' → violation."""
    contract = {
        "reason": "required_background_position",
        "constraints": [{**VALID_CONSTRAINT, "gesture_action": "none", "gesture_target_label": "door"}],
    }
    shots = [_shot_with_contract(contract)]
    violations = fsc.find_frame_spatial_contract_violations(shots)
    assert len(violations) >= 1
    assert any("gesture_target_label" in v["reason"] for v in violations)


# ── validate_and_prepare tests ────────────────────────────────────────────


def test_validate_and_prepare_null_returns_none():
    """null contract → None 반환."""
    assert fsc.validate_and_prepare(None, []) is None


def test_validate_and_prepare_visible_entities_none_raises():
    """visible_entities is None → raise (No Silent Fallback gate / build_id_policy convention)."""
    contract = {"reason": "points_to_anchor", "constraints": [VALID_CONSTRAINT]}
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, None)
    assert "fsc_invalid" in exc_info.value.code
    assert "visible_entities is None" in exc_info.value.message


def test_validate_and_prepare_assigns_deterministic_ids():
    """sort key 7-tuple 로 정렬 후 fsc_001/fsc_002 부여."""
    contract = {
        "reason": "shared_space_relation",
        "constraints": [
            {**VALID_CONSTRAINT, "target_id": "C03", "label": "C"},  # 두 번째 sort
            {**VALID_CONSTRAINT, "target_id": "C02", "label": "B"},  # 첫 번째 sort
        ],
    }
    visible = ["C02", "C03"]  # SID list (render_prompt_card.py:1020 일관)
    prepared = fsc.validate_and_prepare(contract, visible)
    assert prepared is not None
    assert prepared["constraints"][0]["target_id"] == "C02"
    assert prepared["constraints"][0]["constraint_id"] == "fsc_001"
    assert prepared["constraints"][1]["target_id"] == "C03"
    assert prepared["constraints"][1]["constraint_id"] == "fsc_002"


def test_validate_and_prepare_character_missing_in_visible_raises():
    """target_kind=character + target_id 가 visible_entities 에 없으면 raise."""
    contract = {"reason": "points_to_anchor", "constraints": [VALID_CONSTRAINT]}
    visible = ["C03"]  # C02 missing
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "fsc_cross_check_failed" in exc_info.value.code


def test_validate_and_prepare_prop_missing_in_visible_raises():
    """target_kind=prop + target_id 가 visible_entities 에 없으면 raise."""
    contract = {
        "reason": "required_background_position",
        "constraints": [{**VALID_CONSTRAINT, "target_kind": "prop", "target_id": "P05",
                         "gesture_action": "none", "gesture_target_label": ""}],
    }
    visible = ["P03"]
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "fsc_cross_check_failed" in exc_info.value.code


def test_validate_and_prepare_background_label_only_check():
    """target_kind=background → target_id empty + label non-empty 만 검사 (semantic match 없음)."""
    contract = {
        "reason": "required_background_position",
        "constraints": [{"target_kind": "background", "target_id": "", "label": "entrance door",
                         "screen_zone": "upper_center", "depth_plane": "background",
                         "gesture_action": "none", "gesture_target_label": ""}],
    }
    visible: list = []
    prepared = fsc.validate_and_prepare(contract, visible)
    assert prepared is not None
    assert prepared["constraints"][0]["constraint_id"] == "fsc_001"


def test_validate_and_prepare_duplicate_sort_key_raises():
    """같은 sort 7-tuple 의 두 constraint → duplicate_constraint raise."""
    duplicate = dict(VALID_CONSTRAINT)
    contract = {
        "reason": "points_to_anchor",
        "constraints": [duplicate, dict(duplicate)],
    }
    visible = ["C02"]
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "duplicate_constraint" in str(exc_info.value)


def test_validate_and_prepare_invalid_screen_zone_raises():
    """consumer-side defensive: invalid screen_zone enum → fsc_invalid raise."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "screen_zone": "INVALID_ZONE"}],
    }
    visible = ["C02"]
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "fsc_invalid" in exc_info.value.code
    assert "screen_zone" in exc_info.value.message


def test_validate_and_prepare_gesture_target_label_mismatch_raises():
    """consumer-side defensive: gesture_action != 'none' + gesture_target_label '' → fsc_invalid."""
    contract = {
        "reason": "points_to_anchor",
        "constraints": [{**VALID_CONSTRAINT, "gesture_action": "points_to",
                         "gesture_target_label": ""}],
    }
    visible = ["C02"]
    with pytest.raises(AppError) as exc_info:
        fsc.validate_and_prepare(contract, visible)
    assert "fsc_invalid" in exc_info.value.code
    assert "gesture_target_label" in exc_info.value.message


# ── validate_echoes tests ─────────────────────────────────────────────────


def _card_with_contract(constraints):
    return {
        "render_strategy": {
            "frame_spatial_contract": {
                "reason": "points_to_anchor",
                "constraints": [{**c, "constraint_id": f"fsc_{i+1:03d}"}
                                for i, c in enumerate(constraints)],
            }
        }
    }


def _card_null():
    return {"render_strategy": {"frame_spatial_contract": None}}


def test_validate_echoes_null_contract_empty_echoes_pass():
    """null contract 면 각 variation echo = [] → violation 없음."""
    card = _card_null()
    result = {"t2i_variations": [
        {"variant_label": "var_1", "applied_frame_spatial_constraint_ids": []},
        {"variant_label": "var_2", "applied_frame_spatial_constraint_ids": []},
    ]}
    assert fsc.validate_echoes(card, result) == []


def test_validate_echoes_null_contract_nonempty_echo_violation():
    """null contract 인데 echo 가 비어있지 않음 → violation."""
    card = _card_null()
    result = {"t2i_variations": [
        {"variant_label": "var_1", "applied_frame_spatial_constraint_ids": ["fsc_001"]},
    ]}
    violations = fsc.validate_echoes(card, result)
    assert len(violations) == 1
    assert violations[0]["extra"] == ["fsc_001"]


def test_validate_echoes_match_pass():
    """contract 있고 모든 variation echo set == injected set → violation 없음."""
    card = _card_with_contract([VALID_CONSTRAINT, {**VALID_CONSTRAINT, "target_id": "C03"}])
    result = {"t2i_variations": [
        {"variant_label": "var_1", "applied_frame_spatial_constraint_ids": ["fsc_001", "fsc_002"]},
        {"variant_label": "var_2", "applied_frame_spatial_constraint_ids": ["fsc_002", "fsc_001"]},
    ]}
    assert fsc.validate_echoes(card, result) == []


def test_validate_echoes_missing_id_violation():
    """variation echo 에 injected id 누락 → violation list."""
    card = _card_with_contract([VALID_CONSTRAINT, {**VALID_CONSTRAINT, "target_id": "C03"}])
    result = {"t2i_variations": [
        {"variant_label": "var_1", "applied_frame_spatial_constraint_ids": ["fsc_001"]},
    ]}
    violations = fsc.validate_echoes(card, result)
    assert len(violations) == 1
    assert violations[0]["missing"] == ["fsc_002"]
    assert violations[0]["variant_label"] == "var_1"


# ── phrase_diagnostic + _format_retry_hint tests ──────────────────────────


def test_phrase_diagnostic_warns_only_no_raise():
    """phrase diagnostic 은 warning data 반환만, raise 안 함."""
    constraint = {**VALID_CONSTRAINT, "constraint_id": "fsc_001"}
    # t2i_prompt 에 label / zone / depth phrase 모두 누락
    diag = fsc.phrase_diagnostic("a generic prompt with no spatial keywords", [constraint])
    assert isinstance(diag, list)
    assert len(diag) == 1
    # 모두 missing
    assert diag[0]["label_missing"] is True
    assert diag[0]["zone_missing"] is True
    assert diag[0]["depth_missing"] is True


def test_phrase_diagnostic_all_phrases_present_no_warning():
    """label / zone / depth phrase 모두 매칭되면 빈 diagnostic."""
    constraint = {**VALID_CONSTRAINT, "constraint_id": "fsc_001"}
    prompt = "B stands in the lower-right foreground area"
    diag = fsc.phrase_diagnostic(prompt, [constraint])
    assert diag == []


def test_format_retry_hint_includes_invalid_value():
    """retry hint 가 enum violation 의 invalid value 를 LLM 에 노출."""
    violations = [
        {"scene_index": 1, "shot_index": 2, "constraint_idx": 0,
         "reason": "invalid_screen_zone", "value": "INVALID_ZONE"},
    ]
    hint = fsc._format_retry_hint(violations)
    assert "S1 Shot2" in hint
    assert "constraint[0]" in hint
    assert "invalid_screen_zone" in hint
    assert "INVALID_ZONE" in hint  # 핵심 — LLM 이 무엇을 고쳐야 하는지 알아야 함
