"""G4.2 background-binding lift — Rule A/C/E prose → card constraints.

본 테스트는 G4.2 (2026-05-04) 의 spec/plan 에 정의된 lift 동작을 25 unit
test 로 검증한다. v17 system.md 에서 제거된 3 prose section (Rule A camera /
Rule C owned / Rule E close-skip) 이 `build_background_binding()` 의
machine-readable constraint 문자열로 single source 가 되었는지, 그리고
관측용 `_card_metadata` envelope-sibling 이 hash 와 분리된 채 lift_status +
rule_source 를 운반하는지 확인한다.

25 test coverage:
  - sub-case A/B/C/D of `background_ref_attached` (owned × camera matrix)
  - 6 ground-truth substrings + 4 Rule A invalidation forms 의
    `skipped_close_framing` lift
  - `not_applicable` (bg_id=None + bg_on=True) 의 reference-absent guard
  - `_card_metadata.lift_status` (4 mode 별) + `rule_source`
  - `_card_metadata` 변형 시 hash 불변성 (R1-B1 + plan-R1-I8)
  - card hash 가 owned / camera / constraints 변경 시 drift 검출
  - `_card_metadata` 추가/제거 양방향 hash 불변성 (Codex Important 2)

Reference: docs/superpowers/specs/2026-05-04-g4.2-background-binding-lift-design.md
"""
from __future__ import annotations

import copy
import json
from typing import Any, Dict, Optional

import pytest

from app.core.steps.render_prompt_card import (
    BG_MODE_NOT_APPLICABLE,
    BG_MODE_OFF,
    BG_MODE_REF_ATTACHED,
    BG_MODE_SKIPPED_CLOSE,
    build_background_binding,
    build_render_prompt_card,
    canonicalize_render_prompt_card,
    compute_card_hash,
)


# ──────────────────────────────────────────────────────────────────────────
# Fixture (G4.1 함정 1 carry — _DEFAULT_SENTINEL pattern REQUIRED).
# `bg_owned or []` 류 silent absorb 패턴 절대 금지 — production drift
# cascade 의 origin (Wave subagent hardcoded empty trap).
# ──────────────────────────────────────────────────────────────────────────

_DEFAULT_SENTINEL = object()


def _make_full_card(
    *,
    bg_id: Optional[str] = "cb_test_001",
    bg_owned=_DEFAULT_SENTINEL,
    bg_camera_meta=_DEFAULT_SENTINEL,
    is_close_framing: bool = False,
    background_mode_on: bool = True,
) -> Dict[str, Any]:
    """build_render_prompt_card() 전체 call — None / [] / default 명시 구분.

    Sentinel 의도 (G4.1 함정 1 carry — Wave subagent hardcoded empty trap):
      - bg_owned=None → builder 가 AppError raise (None 은 producer missing 의미)
      - bg_owned=[] → 빈 list (sub-case D 분기 진입 가능)
      - bg_owned=_DEFAULT_SENTINEL (default) → fixture 가 ['door', 'window'] 채움
    `bg_owned or []` 패턴 절대 금지 — production drift cascade 의 origin.
    """
    if bg_owned is _DEFAULT_SENTINEL:
        bg_owned = ["door", "window"]
    if bg_camera_meta is _DEFAULT_SENTINEL:
        bg_camera_meta = {
            "camera_position": "south",
            "camera_height": "eye-level",
            "lens_hint": "35mm",
            "framing_notes": "wide",
        }
    return build_render_prompt_card(
        scene_index=1,
        shot_index=1,
        seg={"index": 1, "text": "scene segment text content here"},
        shot_info={
            "shot_index": 1,
            "camera_direction": (
                "extreme close-up" if is_close_framing else "medium shot"
            ),
            "primary_subject": "the subject at the table",
        },
        visible_entities=["C01"],
        outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
        perception_mode=None,
        staging={
            "camera_direction": (
                "extreme close-up" if is_close_framing else "medium shot"
            ),
            # framing_scale enum SOT v1 (2026-05-15): helper read fail-fast 정합.
            "framing_scale": "close" if is_close_framing else "medium",
            "lighting_mood": "warm",
            # Area C (2026-05-12) — required by build_id_policy. Empty list
            # = 비-재현면 shot (applies=False).
            "key_bg_elements": [],
            # Area #1 W5 (2026-05-16) — shot_staging v12 top-level required
            # field. helper SOT graceful empty (default policy
            # id_and_outlook_required 가 visible_entities 에 적용).
            "subject_reference_policy": [],
        },
        bg_id=bg_id,
        bg_owned=bg_owned,
        bg_camera_meta=bg_camera_meta,
        bg_guide="general view",
        is_close_framing=is_close_framing,
        background_mode_on=background_mode_on,
        fixed_elements=[],
        previous_shot_refs=[],
        forward_zoom_targets=[],
    )


def _join_constraints(card: Dict[str, Any]) -> str:
    """concat all background_binding.constraints into a single text blob —
    substring assertions against the joined corpus."""
    return "\n".join(card["background_binding"].get("constraints") or [])


# ──────────────────────────────────────────────────────────────────────────
# Class 1: TestBackgroundBindingConstraints — constraint string content.
# spec §6.1 / §6.2 — Rule A/C/E lift verifications.
# ──────────────────────────────────────────────────────────────────────────


class TestBackgroundBindingConstraints:
    """sub-case A/B/C/D + close-framing 6 GT + 4 Rule A forms + bg_off."""

    def test_bg_ref_attached_constraints_cover_rule_a(self) -> None:
        """spec §6.1 — sub-case A card carries Rule A camera reference
        constraint (camera_position / camera_height / framing)."""
        card = _make_full_card()  # owned + camera (sub-case A)
        text = _join_constraints(card)
        # Rule A: camera_reference framing match.
        assert "camera_reference" in text
        assert "camera_position" in text
        assert "camera_height" in text
        assert "framing" in text

    def test_bg_ref_attached_constraints_cover_rule_c(self) -> None:
        """spec §6.1 — sub-case A card carries Rule C owned-object
        preservation: 'do not create new' + sidecar declaration phrasing
        (Area #5 W3 — legacy 'anchor it explicitly: the door from the reference'
        wording replaced with sidecar reference-kind declaration)."""
        card = _make_full_card()  # owned + camera (sub-case A)
        text = _join_constraints(card)
        assert "do not create new" in text
        assert "reference_phrase_kinds sidecar" in text

    def test_skipped_close_framing_constraints_no_rule_e_closed_list(
        self,
    ) -> None:
        """Area #5 W3 (2026-05-18) — close-framing card NO longer enumerates
        Rule E closed-list forbidden-wording substrings (legacy 6 patterns
        폐기). principle-only constraints — phantom guard 는 sidecar SOT 로
        이동 (ref_contract_validator step 6)."""
        card = _make_full_card(is_close_framing=True)
        text = _join_constraints(card)
        # Legacy Rule E 6 substrings — all removed.
        for needle in (
            "the existing X",
            "from the reference",
            "use the X from the reference",
            "preserving the same room perspective",
            "maintaining the reference's framing",
            "do not generate a new X",
        ):
            assert needle not in text, (
                f"close-framing card unexpectedly enumerates Rule E "
                f"closed-list substring {needle!r} — Area #5 W3 wording "
                f"cleanup violated; constraints=\n{text}"
            )

    def test_skipped_close_framing_constraints_no_rule_a_closed_list(
        self,
    ) -> None:
        """Area #5 W3 (2026-05-18) — close-framing card NO longer enumerates
        Rule A closed-list invalidation forms (legacy 4 patterns 폐기).
        principle-only constraints — phantom guard 는 sidecar SOT 로 이동."""
        card = _make_full_card(is_close_framing=True)
        text = _join_constraints(card)
        for needle in (
            "matching the reference camera",
            "deviation from reference",
            "same … angle as the reference",
            "match the reference framing",
        ):
            assert needle not in text, (
                f"close-framing card unexpectedly enumerates Rule A "
                f"closed-list form {needle!r} — Area #5 W3 wording cleanup "
                f"violated; constraints=\n{text}"
            )

    def test_skipped_close_framing_no_owned_objects(self) -> None:
        """spec §6.2 — close-framing card has no owned_objects (G3.2 R5-B4
        carry: close framing skips owned preservation)."""
        card = _make_full_card(is_close_framing=True)
        assert card["background_binding"]["owned_objects"] == []

    def test_skipped_close_framing_no_camera_reference(self) -> None:
        """spec §6.2 — close-framing card has no camera_reference (close
        framing has no reference framing to refer to)."""
        card = _make_full_card(is_close_framing=True)
        assert card["background_binding"]["camera_reference"] is None

    def test_background_mode_off_no_constraints_reference(self) -> None:
        """spec §6.3 — bg_mode=off card has no positive reference /
        owned_objects / camera_reference promise language in any constraint.

        Note: the bg_off constraint legitimately negates references
        ('no chain_bg reference is attached'), so substring 'reference'
        in isolation is a false positive — this test asserts that no
        *positive* reference-binding language appears (camera_reference /
        owned_objects keys, 'from the reference' phrasing, etc.)."""
        card = _make_full_card(background_mode_on=False, bg_id=None,
                               bg_owned=[], bg_camera_meta=None)
        text = _join_constraints(card)
        # bg_off must not promise references / owned listings / camera
        # framing matching — none of the substrings should appear.
        for forbidden in (
            "camera_reference", "owned_objects",
            "from the reference", "match the reference",
        ):
            assert forbidden not in text, (
                f"bg_off card unexpectedly mentions {forbidden!r} — "
                f"constraints=\n{text}"
            )

    def test_background_ref_attached_owned_only_no_camera_constraints(
        self,
    ) -> None:
        """spec §6.1 — sub-case B: owned ['door','window'] + no camera_meta.
        Exactly 2 constraints (do-not-create-new + sidecar declaration);
        no camera-related wording.

        Area #5 W3 (2026-05-18) — legacy 'anchor it explicitly: the door
        from the reference' wording 폐기, sidecar reference-kind 선언으로 교체.
        """
        card = _make_full_card(
            bg_owned=["door", "window"], bg_camera_meta=None,
        )
        constraints = card["background_binding"]["constraints"]
        assert len(constraints) == 2, (
            f"sub-case B expected 2 constraints, got {len(constraints)}: "
            f"{constraints!r}"
        )
        text = "\n".join(constraints)
        assert "do not create new" in text
        assert "reference_phrase_kinds sidecar" in text
        # camera-related phrasing must not appear.
        for forbidden in (
            "camera_position", "camera_height", "match camera",
            "camera_reference",
        ):
            assert forbidden not in text, (
                f"sub-case B unexpectedly mentions camera language "
                f"{forbidden!r} — constraints=\n{text}"
            )

    def test_background_ref_attached_camera_only_no_owned_constraints(
        self,
    ) -> None:
        """spec §6.1 — sub-case C: empty owned + camera_meta given.
        Exactly 2 constraints (camera_reference framing + deviation);
        no 'owned_objects lists' substring."""
        card = _make_full_card(
            bg_owned=[],
            bg_camera_meta={
                "camera_position": "south",
                "camera_height": "eye-level",
                "lens_hint": "35mm",
                "framing_notes": "wide",
            },
        )
        constraints = card["background_binding"]["constraints"]
        assert len(constraints) == 2, (
            f"sub-case C expected 2 constraints, got {len(constraints)}: "
            f"{constraints!r}"
        )
        text = "\n".join(constraints)
        assert "camera_reference" in text
        assert "deviating" in text or "deviation" in text
        # owned-list language must not appear.
        assert "owned_objects lists" not in text, (
            f"sub-case C unexpectedly mentions 'owned_objects lists' — "
            f"constraints=\n{text}"
        )

    def test_background_ref_attached_neither_owned_nor_camera_fallback(
        self,
    ) -> None:
        """spec §6.1 R1-I1 — sub-case D: empty owned + None camera_meta.
        Exactly 1 fallback constraint mentioning the diagnostic phrase
        'carries no owned-object list and no camera metadata'."""
        card = _make_full_card(bg_owned=[], bg_camera_meta=None)
        constraints = card["background_binding"]["constraints"]
        assert len(constraints) == 1, (
            f"sub-case D expected 1 fallback constraint, got "
            f"{len(constraints)}: {constraints!r}"
        )
        assert (
            "carries no owned-object list and no camera metadata"
            in constraints[0]
        ), (
            f"sub-case D fallback constraint missing diagnostic phrase: "
            f"{constraints[0]!r}"
        )

    def test_owned_objects_english_canonical_only(self) -> None:
        """spec §6.1 — sub-case A: card.background_binding.owned_objects
        preserves the input list 1:1 (canonicalize sorts inside the hash
        payload only — the card itself preserves caller order via
        `list(bg_owned)`)."""
        card = _make_full_card()  # default bg_owned=["door","window"]
        assert card["background_binding"]["owned_objects"] == [
            "door", "window",
        ]

    def test_non_close_owned_list_in_card_only(self) -> None:
        """spec §6.1 — non-close card owned_objects is non-empty AND the
        canonicalized JSON-serialized card contains the owned object names
        (sanity: sort doesn't drop entries)."""
        card = _make_full_card()  # non-close, owned=['door','window']
        assert card["background_binding"]["owned_objects"]
        canonical = canonicalize_render_prompt_card(card)
        blob = json.dumps(canonical, ensure_ascii=False, sort_keys=True)
        assert "door" in blob
        assert "window" in blob

    def test_close_framing_card_mode_no_forbidden_wording_enumeration(self) -> None:
        """Area #5 W3 (2026-05-18) — close-framing card NO longer enumerates
        'from the reference' as forbidden wording. principle-only constraint
        (background reference intentionally omitted at composition time).
        phantom guard 는 sidecar SOT 로 이동 (ref_contract_validator step 6)."""
        card = _make_full_card(is_close_framing=True)
        text = _join_constraints(card)
        assert "from the reference" not in text, (
            f"close-framing card unexpectedly contains 'from the reference' "
            f"forbidden-wording enumeration — Area #5 W3 wording cleanup "
            f"violated; constraints=\n{text}"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 2: TestNotApplicable — bg_id=None + bg_on=True (no chain_bg).
# spec §6.4 / R1-I7.
# ──────────────────────────────────────────────────────────────────────────


class TestNotApplicable:
    """not_applicable mode: bg_on=True yet bg_id=None — no chain_bg bound.
    Constraints must NOT promise reference language; lift_status must show
    rule_e_lifted=False (close-skip prose not lifted for this mode).
    """

    def test_not_applicable_no_bg_id(self) -> None:
        """spec §6.4 — bg_id=None + bg_on=True yields mode='not_applicable'."""
        card = _make_full_card(bg_id=None, bg_owned=[], bg_camera_meta=None)
        assert (
            card["background_binding"]["mode"] == BG_MODE_NOT_APPLICABLE
        ), f"got mode={card['background_binding']['mode']!r}"

    def test_not_applicable_constraints_no_reference_language(self) -> None:
        """spec §6.4 R1-I7 — not_applicable card constraint mentions
        'no chain_bg is bound' guard wording.

        Area #5 W3 (2026-05-18) — legacy "no reference image to refer to"
        closed-list 폐기. principle-only constraint ("describe the location
        freely without referencing a background image")."""
        card = _make_full_card(bg_id=None, bg_owned=[], bg_camera_meta=None)
        text = _join_constraints(card)
        assert "no chain_bg is bound" in text, (
            f"not_applicable card missing R1-I7 guard wording — "
            f"constraints=\n{text}"
        )
        # Area #5 W3: legacy closed-list literal 폐기 검증
        assert "no reference image to refer to" not in text, (
            f"not_applicable card unexpectedly retains legacy closed-list — "
            f"Area #5 W3 wording cleanup violated; constraints=\n{text}"
        )

    def test_not_applicable_card_metadata_lift_status(self) -> None:
        """spec §6.4 R1-I7 — not_applicable card has lift_status with
        rule_a/c lifted=True but rule_e_lifted=False (close-skip prose
        is NOT lifted for non-close modes).

        G4.3 added 4 keys (rule_h_lifted / 3 id_policy_*); subset match
        decouples G4.2 tests from G4.3 evolution.
        """
        card = _make_full_card(bg_id=None, bg_owned=[], bg_camera_meta=None)
        # G4.3 added 4 keys; subset match decouples G4.2 tests from G4.3 evolution
        expected_g4_2 = {
            "rule_a_lifted": True,
            "rule_c_lifted": True,
            "rule_e_lifted": False,
        }
        actual = card["_card_metadata"]["lift_status"]
        assert expected_g4_2.items() <= actual.items(), (
            f"G4.2 lift_status subset not present — got {actual!r}"
        )


# ──────────────────────────────────────────────────────────────────────────
# Class 3: TestCardMetadata — envelope-sibling lift_status + rule_source.
# spec §6.5 R1-B1 / plan-R1-I8 / R2-I5.
# ──────────────────────────────────────────────────────────────────────────


class TestCardMetadata:
    """`_card_metadata` envelope-sibling carries lift_status + rule_source.
    Free-form (R2-I5): no schema enforcement on `_card_metadata` itself.
    Hash-excluded (R1-B1 + plan-R1-I8): mutating `_card_metadata` does NOT
    alter compute_card_hash() output.
    """

    def test_lift_status_in_card_metadata(self) -> None:
        """spec §6.5 — for each of the 4 modes (off / skipped_close /
        not_applicable / ref_attached) the card has the correct lift_status
        boolean dict. Per builder logic:
          - off / skipped_close → all three lifted=True
          - not_applicable / ref_attached → rule_e_lifted=False (close-skip
            prose is NOT applicable to non-close modes)

        G4.3 added 4 keys (rule_h_lifted / 3 id_policy_*); subset match
        decouples G4.2 tests from G4.3 evolution.
        """
        # off
        card_off = _make_full_card(
            background_mode_on=False, bg_id=None,
            bg_owned=[], bg_camera_meta=None,
        )
        expected_off = {
            "rule_a_lifted": True,
            "rule_c_lifted": True,
            "rule_e_lifted": True,
        }
        actual_off = card_off["_card_metadata"]["lift_status"]
        assert expected_off.items() <= actual_off.items(), (
            f"off mode G4.2 subset missing — got {actual_off!r}"
        )
        # skipped_close
        card_close = _make_full_card(is_close_framing=True)
        expected_close = {
            "rule_a_lifted": True,
            "rule_c_lifted": True,
            "rule_e_lifted": True,
        }
        actual_close = card_close["_card_metadata"]["lift_status"]
        assert expected_close.items() <= actual_close.items(), (
            f"skipped_close mode G4.2 subset missing — got {actual_close!r}"
        )
        # not_applicable
        card_na = _make_full_card(
            bg_id=None, bg_owned=[], bg_camera_meta=None,
        )
        expected_na = {
            "rule_a_lifted": True,
            "rule_c_lifted": True,
            "rule_e_lifted": False,
        }
        actual_na = card_na["_card_metadata"]["lift_status"]
        assert expected_na.items() <= actual_na.items(), (
            f"not_applicable mode G4.2 subset missing — got {actual_na!r}"
        )
        # ref_attached
        card_ref = _make_full_card()
        expected_ref = {
            "rule_a_lifted": True,
            "rule_c_lifted": True,
            "rule_e_lifted": False,
        }
        actual_ref = card_ref["_card_metadata"]["lift_status"]
        assert expected_ref.items() <= actual_ref.items(), (
            f"ref_attached mode G4.2 subset missing — got {actual_ref!r}"
        )

    def test_rule_source_in_card_metadata(self) -> None:
        """spec §6.5 — any card has rule_source mapping the 3 contractual
        rules to their A/C/E identifiers.

        G4.3 added 4 keys (demographic_rule / composite_id_rule /
        close_framing_face_rule / reproduction_surface_rule); subset
        match decouples G4.2 tests from G4.3 evolution.
        """
        card = _make_full_card()
        expected_g4_2 = {
            "camera_rule": "A",
            "owned_rule": "C",
            "close_skip_rule": "E",
        }
        actual = card["_card_metadata"]["rule_source"]
        assert expected_g4_2.items() <= actual.items(), (
            f"G4.2 rule_source subset not present — got {actual!r}"
        )

    def test_card_metadata_excluded_from_hash(self) -> None:
        """spec §6.5 R1-B1 + plan-R1-I8 — two cards differing only in
        `_card_metadata` produce the same hash; canonicalize() drops
        `_card_metadata` from its output payload."""
        card_a = _make_full_card()
        card_b = copy.deepcopy(card_a)
        # Mutate _card_metadata only.
        card_b["_card_metadata"]["lift_status"] = {
            "rule_a_lifted": False,
            "rule_c_lifted": False,
            "rule_e_lifted": False,
        }
        card_b["_card_metadata"]["custom_extra_key"] = "anything"
        assert compute_card_hash(card_a) == compute_card_hash(card_b)
        # canonicalize() output must not contain _card_metadata.
        canonical = canonicalize_render_prompt_card(card_a)
        assert "_card_metadata" not in canonical

    def test_card_hash_stable_across_lift_status_change(self) -> None:
        """spec §6.5 R1-B1 carry — deep-copy card and mutate lift_status to
        an entirely different dict; hash must remain identical (envelope
        siblings excluded from hash payload)."""
        card_a = _make_full_card()
        h_a = compute_card_hash(card_a)
        card_b = copy.deepcopy(card_a)
        card_b["_card_metadata"]["lift_status"] = {
            "rule_a_lifted": False,
            "rule_c_lifted": False,
            "rule_e_lifted": True,
        }
        assert compute_card_hash(card_b) == h_a


# ──────────────────────────────────────────────────────────────────────────
# Class 4: TestCardHash — hash drift / stability.
# spec §6.5 + §10 Q4 + R1-I3 + R1-I11 + R2-B1.
# ──────────────────────────────────────────────────────────────────────────


class TestCardHash:
    """card hash deterministic + drifts on owned/camera change + stable
    on `_card_metadata` mutation. Repeat call determinism check."""

    def test_card_hash_drifts_on_owned_change(self) -> None:
        """spec §6.5 — owned list change drifts hash (G3.2 sentinel-style
        binding at shot level)."""
        card_a = _make_full_card(bg_owned=["door", "window"])
        card_b = _make_full_card(bg_owned=["door", "table"])
        assert compute_card_hash(card_a) != compute_card_hash(card_b)

    def test_card_hash_drifts_on_camera_change(self) -> None:
        """spec §6.5 — camera_meta change drifts hash."""
        card_a = _make_full_card(bg_camera_meta={
            "camera_position": "south", "camera_height": "eye-level",
            "lens_hint": "35mm", "framing_notes": "wide",
        })
        card_b = _make_full_card(bg_camera_meta={
            "camera_position": "north", "camera_height": "eye-level",
            "lens_hint": "35mm", "framing_notes": "wide",
        })
        assert compute_card_hash(card_a) != compute_card_hash(card_b)

    def test_card_hash_stable_on_owned_objects_reorder(self) -> None:
        """spec §6.5 / canonicalize §4.4 — owned_objects 는 hash 입력 sort
        대상 리스트. caller 가 같은 set 의 owned 를 다른 순서로 넘겨도 hash
        결과는 동일해야 한다 (canonicalize_render_prompt_card 의 sort 불변식
        검증). 본 invariant 가 깨지면 동일 shot 의 두 빌드가 false 'drift' 로
        분류돼 force escalate path 가 잘못 작동.
        """
        card_a = _make_full_card(bg_owned=["door", "window"])
        card_b = _make_full_card(bg_owned=["window", "door"])
        # 두 card 가 owned_objects list order 만 다른 동일 set — hash 동일.
        assert compute_card_hash(card_a) == compute_card_hash(card_b)
        # canonicalize 가 실제로 sort 했는지 추가 검증 — payload 간 동등성.
        canonical_a = canonicalize_render_prompt_card(card_a)
        canonical_b = canonicalize_render_prompt_card(card_b)
        assert (
            canonical_a["background_binding"]["owned_objects"]
            == canonical_b["background_binding"]["owned_objects"]
        )

    def test_card_hash_drifts_on_constraints_change(self) -> None:
        """Codex Important 2 fix — `background_binding.constraints` 는
        canonicalize payload 에 포함되므로 어떤 한 문자열이라도 바뀌면 hash
        가 drift 한다. lift 의 핵심 invariant: constraints 가 single source
        of truth 이므로 그 변경은 inject content 변경 = card identity 변경.
        본 테스트가 깨지면 v16↔v17 lift 의 lift_status flip detection 이
        작동 안 함."""
        card_a = _make_full_card()
        card_b = copy.deepcopy(card_a)
        constraints_a = card_a["background_binding"]["constraints"]
        # sub-case A 의 sub-A constraints 는 4 entries — 첫번째 string 을
        # 임의로 변경 (의미상 다른 reminder).
        assert isinstance(constraints_a, list) and len(constraints_a) >= 1
        card_b["background_binding"]["constraints"][0] = (
            "MUTATED — different reminder string for hash-drift detection"
        )
        # canonicalize 단계에서 constraints 가 그대로 흘러가야 hash 가 drift.
        assert compute_card_hash(card_a) != compute_card_hash(card_b)

    def test_card_hash_stable_when_card_metadata_added_or_removed(self) -> None:
        """Codex Important 2 fix — `_card_metadata` 가 envelope-sibling 으로
        canonicalize() 출력에서 drop 되어 hash payload 에 미포함됨을
        반대 방향(추가/제거)에서 한번 더 검증. 기존 mutate-in-place 테스트
        (test_card_metadata_excluded_from_hash) 와 보완 — 키 부재 자체도
        hash invariant. 본 invariant 가 깨지면 R1-B1 plan-R1-I8 contract
        깨짐."""
        card_a = _make_full_card()
        # _card_metadata 가 fixture 에서 envelope sibling 으로 채워져 있음.
        assert "_card_metadata" in card_a
        card_b = copy.deepcopy(card_a)
        del card_b["_card_metadata"]
        assert "_card_metadata" not in card_b
        # 키 부재 자체도 hash invariant — canonicalize 는 _card_metadata 를
        # 항상 drop 하므로 있으나 없으나 hash 동일.
        assert compute_card_hash(card_a) == compute_card_hash(card_b)
