"""G4.1 RenderPromptCard hash unit tests (Task 8).

R1-I3: canonical list ordering helper.
R1-I11: `_card_metadata` debug field excluded from hash.
R2-B1: hash payload = envelope minus (`_card_metadata`,
       `render_prompt_card_hash`). schema_version + shot_key + 5 semantic
       fields ALL INCLUDED.
"""
from __future__ import annotations

import pytest

from app.core.steps.render_prompt_card import (
    build_empty_card,
    compute_card_hash,
)


def _full_card_v1():
    c = build_empty_card(scene_index=12, shot_index=4)
    c["render_strategy"] = {
        "mode": "direct", "framing_scale": "medium",
        "camera_direction": "wide", "lighting_mood": "warm",
    }
    c["id_policy"] = {
        "allowed_base_entity_ids": ["C01", "C02"],
        "allowed_outlook_pairs": [{"character_id": "C01", "outlook_id": "O02"}],
        "must_use_composite_character_ids": True,
    }
    c["background_binding"] = {
        "mode": "background_ref_attached", "bg_id": "cb_main_room",
        "owned_objects": ["door", "window"],
    }
    c["continuity_elements_used"] = {
        "fixed_elements": [], "previous_shot_refs": [],
        "forward_zoom_targets": [],
    }
    c["asset_requirements"] = {
        "required_refs": [
            {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
        ],
        "forbidden_refs": [], "readiness_policy": "block_if_missing",
    }
    return c


class TestComputeCardHash:
    def test_returns_16_char_hex(self) -> None:
        h = compute_card_hash(_full_card_v1())
        assert isinstance(h, str)
        assert len(h) == 16
        assert all(c in "0123456789abcdef" for c in h)

    def test_deterministic_same_input(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_key_order_independent_via_sort(self) -> None:
        # sort_keys=True 때문에 dict 입력 순서 무관 — 같은 hash.
        c1 = _full_card_v1()
        c2 = {
            "asset_requirements": c1["asset_requirements"],
            "background_binding": c1["background_binding"],
            "continuity_elements_used": c1["continuity_elements_used"],
            "id_policy": c1["id_policy"],
            "render_strategy": c1["render_strategy"],
            "schema_version": c1["schema_version"],
            "shot_key": c1["shot_key"],
        }
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_owned_change_changes_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["background_binding"]["owned_objects"] = ["door", "window", "TV"]
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_camera_direction_change_changes_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["render_strategy"]["camera_direction"] = "extreme close-up"
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_outlook_pair_change_changes_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["id_policy"]["allowed_outlook_pairs"] = [
            {"character_id": "C01", "outlook_id": "O03"},  # O02 → O03
        ]
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_fixed_element_addition_changes_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["continuity_elements_used"]["fixed_elements"] = [
            {"element_id": "x", "description": "y"},
        ]
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_korean_description_preserved_in_hash(self) -> None:
        # CLAUDE.md 무절단 + ensure_ascii=False — 한국어 description 도 hash 가능.
        c1 = _full_card_v1()
        c1["continuity_elements_used"]["fixed_elements"] = [
            {"element_id": "x", "description": "여자가 옆으로 누워 있다"},
        ]
        h = compute_card_hash(c1)
        assert len(h) == 16

    def test_hash_self_excluded_from_payload(self) -> None:
        # R2-B1 (spec R1-I11 sharpened): canonicalize 가 `render_prompt_card_hash`
        # 를 hash payload 에서 명시 제외. caller 가 hash 를 envelope 에 담아
        # 다시 dump 해도 self-hash 가 stable.
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["render_prompt_card_hash"] = "deadbeef" * 2
        # 두 hash 동일 — render_prompt_card_hash 는 hash 입력에서 빠짐.
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_schema_version_change_changes_hash(self) -> None:
        # R2-B1: schema_version 은 hash payload 에 INCLUDED — 변경 시 hash 변동.
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["schema_version"] = 99
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_shot_key_change_changes_hash(self) -> None:
        # R2-B1: shot_key 도 hash payload 에 INCLUDED — 변경 시 hash 변동.
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["shot_key"] = {"scene_index": 99, "shot_index": 99}
        assert compute_card_hash(c1) != compute_card_hash(c2)


class TestCanonicalizeListOrdering:
    """R1-I3: semantically unordered list permutation invariance."""

    def test_owned_objects_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["background_binding"]["owned_objects"] = ["door", "window"]
        c2["background_binding"]["owned_objects"] = ["window", "door"]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_allowed_base_entity_ids_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["id_policy"]["allowed_base_entity_ids"] = ["C01", "C02"]
        c2["id_policy"]["allowed_base_entity_ids"] = ["C02", "C01"]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_allowed_outlook_pairs_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["id_policy"]["allowed_outlook_pairs"] = [
            {"character_id": "C01", "outlook_id": "O02"},
            {"character_id": "C02", "outlook_id": "O01"},
        ]
        c2["id_policy"]["allowed_outlook_pairs"] = [
            {"character_id": "C02", "outlook_id": "O01"},
            {"character_id": "C01", "outlook_id": "O02"},
        ]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_required_refs_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["asset_requirements"]["required_refs"] = [
            {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
            {"kind": "background", "id": "cb_main_room", "policy": "required"},
        ]
        c2["asset_requirements"]["required_refs"] = [
            {"kind": "background", "id": "cb_main_room", "policy": "required"},
            {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
        ]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_forbidden_refs_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["asset_requirements"]["forbidden_refs"] = [
            {"kind": "background", "reason": "close framing"},
            {"kind": "outlook", "reason": "skipped variant"},
        ]
        c2["asset_requirements"]["forbidden_refs"] = [
            {"kind": "outlook", "reason": "skipped variant"},
            {"kind": "background", "reason": "close framing"},
        ]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_fixed_elements_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["continuity_elements_used"]["fixed_elements"] = [
            {"element_id": "a", "element_type": "scene_state",
             "description": "x"},
            {"element_id": "b", "element_type": "character_state",
             "description": "y"},
        ]
        c2["continuity_elements_used"]["fixed_elements"] = [
            {"element_id": "b", "element_type": "character_state",
             "description": "y"},
            {"element_id": "a", "element_type": "scene_state",
             "description": "x"},
        ]
        assert compute_card_hash(c1) == compute_card_hash(c2)


class TestCardMetadataExcludedFromHash:
    """R1-I11: `_card_metadata` debug field 가 hash 에 영향 X."""

    def test_card_metadata_does_not_affect_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["_card_metadata"] = {
            "build_timestamp": "2026-05-04T12:00:00Z",
            "builder_version": "g4.1.r1",
        }
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_card_metadata_change_does_not_affect_hash(self) -> None:
        c1 = _full_card_v1()
        c1["_card_metadata"] = {"build_timestamp": "A"}
        c2 = _full_card_v1()
        c2["_card_metadata"] = {"build_timestamp": "B"}
        assert compute_card_hash(c1) == compute_card_hash(c2)


# =========================================================================
# Wave 4 R4 I1: assert_card_shape strict semantic field validation.
# =========================================================================
def _full_card_strict():
    """fully shape-valid card — 5 semantic field strict items 모두 채움."""
    from app.core.steps.render_prompt_card import build_render_prompt_card
    return build_render_prompt_card(
        scene_index=12, shot_index=4,
        seg={}, shot_info={"camera_direction": "medium shot"},
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
        perception_mode=None,
        staging={"camera_direction": "medium shot",
                 "framing_scale": "medium",
                 "lighting_mood": "warm dim",
                 # Area C (2026-05-12) — required by build_id_policy.
                 # 빈 list = 비-재현면 shot (applies=False).
                 "key_bg_elements": [],
                 # Area #1 W5 (2026-05-16) — shot_staging v12 top-level
                 # required field. helper SOT graceful empty.
                 "subject_reference_policy": []},
        bg_id="cb_main_room", bg_owned=["door"],
        bg_camera_meta={"camera_position": "wide"},
        bg_guide="g",
        is_close_framing=False, background_mode_on=True,
        fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
    )


class TestAssertCardShapeStrictI1:
    """I1: 5 semantic field 의 list/dict shape + required key strict 검증.

    G3.2 assert_owned_sentinel_shape strict pattern mirror — 위반 시 모두
    AppError(step.contract_violation).
    """

    def test_id_policy_allowed_base_entity_ids_must_be_list(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["id_policy"]["allowed_base_entity_ids"] = "C01"  # not list
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "allowed_base_entity_ids" in ei.value.message

    def test_id_policy_allowed_base_entity_ids_items_must_be_str(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["id_policy"]["allowed_base_entity_ids"] = [123]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "allowed_base_entity_ids" in ei.value.message

    def test_id_policy_outlook_pair_missing_character_id_raises(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["id_policy"]["allowed_outlook_pairs"] = [{"outlook_id": "O02"}]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "character_id" in ei.value.message

    def test_id_policy_outlook_pair_missing_outlook_id_raises(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["id_policy"]["allowed_outlook_pairs"] = [{"character_id": "C01"}]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "outlook_id" in ei.value.message

    def test_background_binding_owned_objects_must_be_str_list(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["background_binding"]["owned_objects"] = [{"name": "door"}]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "owned_objects" in ei.value.message

    def test_continuity_fixed_elements_missing_10th_field_raises(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        # 10 of 10 producer fields required (R1-I2 + Area #4 element_scope). missing one → raise.
        c["continuity_elements_used"]["fixed_elements"] = [{
            "element_id": "x", "element_type": "scene_state",
            "character_name": None, "description": "x",
            "applies_to_shots": [], "element_scope": "full",
            "source_facts": [],
            "visual_inferences": [], "creative_decisions": [],
            # missing: confidence
        }]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "confidence" in ei.value.message

    def test_continuity_fixed_elements_missing_element_scope_raises(self) -> None:
        """Area #4 (Codex iter 1 I3): element_scope required field missing → AppError."""
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["continuity_elements_used"]["fixed_elements"] = [{
            "element_id": "x", "element_type": "scene_state",
            "character_name": None, "description": "x",
            "applies_to_shots": [], "source_facts": [],
            "visual_inferences": [], "creative_decisions": [],
            "confidence": "high",
            # missing: element_scope (Area #4 W3 신규 required)
        }]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "element_scope" in ei.value.message

    def test_continuity_fixed_elements_full_10_field_passes(self) -> None:
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["continuity_elements_used"]["fixed_elements"] = [{
            "element_id": "x", "element_type": "scene_state",
            "character_name": None, "description": "x",
            "applies_to_shots": [], "element_scope": "full",
            "source_facts": [],
            "visual_inferences": [], "creative_decisions": [],
            "confidence": "high",
        }]
        assert_card_shape(c)  # no raise

    def test_continuity_previous_shot_refs_missing_ref_usage_raises(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["continuity_elements_used"]["previous_shot_refs"] = [{
            "scene_index": 12, "shot_index": 3,
        }]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "ref_usage" in ei.value.message

    def test_continuity_forward_zoom_missing_description_raises(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["continuity_elements_used"]["forward_zoom_targets"] = [{
            "scene_index": 12, "shot_index": 5,
        }]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "description" in ei.value.message

    def test_asset_required_ref_missing_policy_raises(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["asset_requirements"]["required_refs"] = [{
            "kind": "character_outlook", "id": "C01O02",
        }]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "policy" in ei.value.message

    def test_asset_forbidden_ref_missing_reason_raises(self) -> None:
        from app.core.errors import AppError
        from app.core.steps.render_prompt_card import assert_card_shape
        c = _full_card_strict()
        c["asset_requirements"]["forbidden_refs"] = [{
            "kind": "background",
        }]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "reason" in ei.value.message


# ── Group C-2: canonicalize frame_spatial_contract.constraints sort ───────
# Area Frame Spatial Contract (2026-05-14) — Task 4 canonicalize path:
# LLM emit 순서 무관 hash 안정성 + null safe.


def test_canonicalize_sorts_fsc_constraints_by_constraint_id():
    """LLM emit 순서 다른 두 card 의 canonical payload 가 동일 (hash 안정)."""
    from app.core.steps.render_prompt_card import canonicalize_render_prompt_card
    c1 = {"constraint_id": "fsc_001", "target_kind": "character", "target_id": "C02",
          "label": "B", "screen_zone": "lower_right", "depth_plane": "foreground",
          "gesture_action": "points_to", "gesture_target_label": "door"}
    c2 = {"constraint_id": "fsc_002", "target_kind": "background", "target_id": "",
          "label": "door", "screen_zone": "upper_center", "depth_plane": "background",
          "gesture_action": "none", "gesture_target_label": ""}
    base_card = {
        "schema_version": "1.0",
        "shot_key": {"scene_index": 1, "shot_index": 1},
        "render_strategy": {
            "spatial_consistency": {},  # builder-static, present
            "frame_spatial_contract": {"reason": "points_to_anchor", "constraints": [c2, c1]},
        },
        "id_policy": {}, "background_binding": {},
        "continuity_elements_used": {}, "asset_requirements": {},
        "render_contracts": [],
    }
    canon = canonicalize_render_prompt_card(base_card)
    sorted_constraints = canon["render_strategy"]["frame_spatial_contract"]["constraints"]
    assert [c["constraint_id"] for c in sorted_constraints] == ["fsc_001", "fsc_002"]


def test_canonicalize_null_fsc_does_not_raise():
    """frame_spatial_contract == None → canonicalize OK (raise X)."""
    from app.core.steps.render_prompt_card import canonicalize_render_prompt_card
    card = {
        "schema_version": "1.0", "shot_key": {"scene_index": 1, "shot_index": 1},
        "render_strategy": {"spatial_consistency": {}, "frame_spatial_contract": None},
        "id_policy": {}, "background_binding": {},
        "continuity_elements_used": {}, "asset_requirements": {},
        "render_contracts": [],
    }
    canon = canonicalize_render_prompt_card(card)
    assert canon["render_strategy"]["frame_spatial_contract"] is None
