"""Wave 1 RED tests for reconcile_owned_prop_namespace_overlap.

Target API (will be implemented by Worker B in Wave 2):

    from app.core.steps._owned_helpers import reconcile_owned_prop_namespace_overlap

    def reconcile_owned_prop_namespace_overlap(
        violations: list[dict],
        owned_object_usage: list[dict],
        t2i_prompt: str,
        visible_entities: list[str],
        prop_term_map: dict[str, frozenset[str]],
    ) -> list[dict]: ...

Until the helper is added, this module fails on ImportError. That is the
intended RED signal for Wave 1.

Reclass rules (all four must hold for a redraw_violation entry to flip to
anchor_reference):

  1. Some P## appears in visible_entities AND in prop_term_map.
  2. The literal substring P## appears in t2i_prompt.
  3. The owned_object (normalized) exactly equals an element of
     prop_term_map[P##]. Single-word tokens must hit a whole word inside
     a prop term; multi-word tokens must match a full prop-term string.
  4. Source linkage: either some owned_object_usage entry with the same
     owned_token has source_phrase containing P##, OR
     v["violating_phrase"] contains both P## and the (un-normalized)
     owned_object.

If multiple P## qualify, the first one in sorted(visible_entities) order
wins. The reason is augmented with exactly one deterministic note (the
helper is idempotent).
"""

from __future__ import annotations

import copy

import pytest

# RED: this import will fail until Worker B (Wave 2) adds the symbol.
from app.core.steps._owned_helpers import (  # noqa: E402
    reconcile_owned_prop_namespace_overlap,
)


# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------


@pytest.fixture
def make_violation():
    """Factory for a v2-schema violation entry."""

    def _make(
        *,
        owned_object: str,
        violating_phrase: str,
        verdict: str = "redraw_violation",
        reason: str = (
            "Token appears inside a render-action clause, constituting a "
            "redraw of this background-owned asset."
        ),
    ) -> dict:
        return {
            "owned_object": owned_object,
            "violating_phrase": violating_phrase,
            "verdict": verdict,
            "reason": reason,
        }

    return _make


@pytest.fixture
def make_usage():
    """Factory for an owned_object_usage entry."""

    def _make(
        *,
        owned_token: str,
        source_phrase: str,
        usage_kind: str = "redraw",
    ) -> dict:
        return {
            "owned_token": owned_token,
            "usage_kind": usage_kind,
            "source_phrase": source_phrase,
        }

    return _make


DETERMINISTIC_NOTE_FMT = (
    "Deterministic prop-owned namespace reconciliation: visible prop {pp} "
    "owns token {tok}; prompt uses {pp} in the source phrase."
)


def _expected_note(pp: str, owned_object: str) -> str:
    return DETERMINISTIC_NOTE_FMT.format(pp=pp, tok=owned_object)


# ---------------------------------------------------------------------------
# G1 — happy path
# ---------------------------------------------------------------------------


def test_g1_happy_path_reclasses_to_anchor_reference(make_violation, make_usage):
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase=(
                "He thrusts P06, a creased paper map damp at the edges, "
                "forward."
            ),
        ),
        # An unrelated violation that should NOT be reclassed.
        make_violation(
            owned_object="lantern",
            violating_phrase="A lantern hangs above the bench.",
        ),
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges forward"
            ),
        ),
        make_usage(
            owned_token="lantern",
            source_phrase="A lantern hangs above the bench.",
        ),
    ]
    t2i_prompt = (
        "He thrusts P06, a creased paper map damp at the edges, forward "
        "while C03 looks on."
    )
    visible_entities = ["C03", "L17", "P06"]
    prop_term_map = {
        "P06": frozenset({"종이 지도", "paper map", "map", "creased paper map"}),
    }

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert len(result) == 2
    # First entry reclassed
    assert result[0]["verdict"] == "anchor_reference"
    assert result[0]["owned_object"] == "map"
    assert result[0]["violating_phrase"] == violations[0]["violating_phrase"]
    # Reason augmented with exactly one deterministic note (separated by 1 space)
    note = _expected_note("P06", "map")
    assert result[0]["reason"].endswith(note)
    assert result[0]["reason"] == violations[0]["reason"] + " " + note
    # Second entry untouched (same dict reference is fine; verdict unchanged)
    assert result[1]["verdict"] == "redraw_violation"
    assert result[1] is violations[1]


# ---------------------------------------------------------------------------
# G2 — visible_entities lacks P06
# ---------------------------------------------------------------------------


def test_g2_visible_entities_missing_prop_returns_input_reference(
    make_violation, make_usage
):
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase=(
                "He thrusts P06, a creased paper map damp at the edges, "
                "forward."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges forward"
            ),
        )
    ]
    t2i_prompt = "He thrusts P06, a creased paper map, forward."
    visible_entities = ["C01", "C03", "L17"]  # no P##
    prop_term_map = {"P06": frozenset({"map", "paper map"})}

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    # Reference equality — short-circuit returns input unchanged.
    assert result is violations
    assert result[0]["verdict"] == "redraw_violation"


# ---------------------------------------------------------------------------
# G3 — prompt lacks P06 literal
# ---------------------------------------------------------------------------


def test_g3_prompt_missing_p_literal_no_reclass(make_violation, make_usage):
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase="A creased paper map sits on the deck.",
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase="A creased paper map sits on the deck.",
        )
    ]
    t2i_prompt = "A creased paper map sits on the deck."
    visible_entities = ["P06"]
    prop_term_map = {"P06": frozenset({"map", "paper map"})}

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result[0]["verdict"] == "redraw_violation"
    # No mutation — the original dict reference is returned unchanged.
    assert result[0] is violations[0]


# ---------------------------------------------------------------------------
# G4 — prop term map has no overlap with the owned token
# ---------------------------------------------------------------------------


def test_g4_owned_token_not_in_prop_term_map_no_reclass(
    make_violation, make_usage
):
    violations = [
        make_violation(
            owned_object="lantern",
            violating_phrase=(
                "He raises P06 high; a lantern flares beside him."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="lantern",
            source_phrase="raises P06 high; a lantern flares beside him",
        )
    ]
    t2i_prompt = "He raises P06 high; a lantern flares beside him."
    visible_entities = ["P06"]
    # P06 owns paper/map terms only — no overlap with "lantern".
    prop_term_map = {"P06": frozenset({"종이 지도", "paper map"})}

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result[0]["verdict"] == "redraw_violation"
    assert result[0] is violations[0]


# ---------------------------------------------------------------------------
# G5 — no source-phrase linkage
# ---------------------------------------------------------------------------


def test_g5_no_source_phrase_linkage_no_reclass(make_violation, make_usage):
    # P06 and "map" both appear in the prompt, but the violating_phrase only
    # contains the latter sentence (no P06) AND no usage entry's source_phrase
    # contains P06 either.
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase="A creased paper map sits on the deck.",
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase="A creased paper map sits on the deck.",
        )
    ]
    t2i_prompt = (
        "He raises P06 high to signal the harbor. A creased paper map sits "
        "on the deck."
    )
    visible_entities = ["P06"]
    prop_term_map = {"P06": frozenset({"map", "paper map"})}

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result[0]["verdict"] == "redraw_violation"
    assert result[0] is violations[0]


# ---------------------------------------------------------------------------
# G6 — multi-word match AND single-word miss when prop terms are multi-word only
# ---------------------------------------------------------------------------


def test_g6a_multi_word_owned_token_matches_full_prop_term(
    make_violation, make_usage
):
    violations = [
        make_violation(
            owned_object="oil lantern",
            violating_phrase=(
                "He lifts P09, an oil lantern dripping with soot, overhead."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="oil lantern",
            source_phrase=(
                "lifts P09, an oil lantern dripping with soot, overhead"
            ),
        )
    ]
    t2i_prompt = (
        "He lifts P09, an oil lantern dripping with soot, overhead."
    )
    visible_entities = ["P09"]
    prop_term_map = {"P09": frozenset({"oil lantern", "brass lantern"})}

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result[0]["verdict"] == "anchor_reference"
    assert result[0]["reason"].endswith(_expected_note("P09", "oil lantern"))


def test_g6b_single_word_token_does_not_split_multi_word_prop_terms(
    make_violation, make_usage
):
    # owned_token "lantern" must NOT match prop_term_map["P09"] = {"oil lantern"}
    # because helper does not split prop terms into sub-words.
    violations = [
        make_violation(
            owned_object="lantern",
            violating_phrase=(
                "He lifts P09, the lantern dripping with soot, overhead."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="lantern",
            source_phrase=(
                "lifts P09, the lantern dripping with soot, overhead"
            ),
        )
    ]
    t2i_prompt = (
        "He lifts P09, the lantern dripping with soot, overhead."
    )
    visible_entities = ["P09"]
    prop_term_map = {"P09": frozenset({"oil lantern"})}  # no bare "lantern"

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result[0]["verdict"] == "redraw_violation"
    assert result[0] is violations[0]


# ---------------------------------------------------------------------------
# G7 — single-word word-boundary
# ---------------------------------------------------------------------------


def test_g7_single_word_token_requires_word_boundary(
    make_violation, make_usage
):
    # owned_token "map" must NOT match prop term "mapped territory" — "map" is
    # not a whitespace-delimited word inside that term.
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase="He gestures at P06, mapped territory spread wide.",
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase="gestures at P06, mapped territory spread wide",
        )
    ]
    t2i_prompt = "He gestures at P06, mapped territory spread wide."
    visible_entities = ["P06"]
    prop_term_map = {"P06": frozenset({"mapped territory"})}

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result[0]["verdict"] == "redraw_violation"
    assert result[0] is violations[0]


# ---------------------------------------------------------------------------
# G8 — idempotency
# ---------------------------------------------------------------------------


def test_g8_idempotent_does_not_re_append_note(make_violation, make_usage):
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase=(
                "He thrusts P06, a creased paper map damp at the edges, forward."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges forward"
            ),
        )
    ]
    t2i_prompt = (
        "He thrusts P06, a creased paper map damp at the edges, forward."
    )
    visible_entities = ["P06"]
    prop_term_map = {"P06": frozenset({"map", "paper map", "creased paper map"})}

    result1 = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )
    # Feed result1 back in — the entry now has verdict=anchor_reference so it
    # must be left untouched.
    result2 = reconcile_owned_prop_namespace_overlap(
        result1, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result1 == result2
    note = _expected_note("P06", "map")
    # Note appears exactly once in the final reason.
    assert result2[0]["reason"].count(note) == 1
    assert result2[0]["verdict"] == "anchor_reference"


# ---------------------------------------------------------------------------
# G9 — input immutability
# ---------------------------------------------------------------------------


def test_g9_inputs_are_not_mutated_reclassed_entries_are_new_objects(
    make_violation, make_usage
):
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase=(
                "He thrusts P06, a creased paper map damp at the edges, forward."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges forward"
            ),
        )
    ]
    t2i_prompt = (
        "He thrusts P06, a creased paper map damp at the edges, forward."
    )
    visible_entities = ["P06"]
    prop_term_map = {"P06": frozenset({"map", "paper map", "creased paper map"})}

    violations_snapshot = copy.deepcopy(violations)
    usage_snapshot = copy.deepcopy(usage)

    # Snapshot the identity of the violation dict so we can verify the helper
    # returned a NEW object for the reclassed entry.
    original_id = id(violations[0])

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    # Inputs unchanged (deep equality).
    assert violations == violations_snapshot
    assert usage == usage_snapshot

    # Reclassed entry must be a NEW dict (id differs from the original).
    assert result[0]["verdict"] == "anchor_reference"
    assert id(result[0]) != original_id
    # Original dict in input list still has its old verdict.
    assert violations[0]["verdict"] == "redraw_violation"


# ---------------------------------------------------------------------------
# G10 — defensive behavior
# ---------------------------------------------------------------------------


def test_g10a_prop_term_map_none_returns_input_reference(
    make_violation, make_usage
):
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase=(
                "He thrusts P06, a creased paper map damp at the edges, forward."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges forward"
            ),
        )
    ]
    t2i_prompt = (
        "He thrusts P06, a creased paper map damp at the edges, forward."
    )
    visible_entities = ["P06"]

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, None
    )

    assert result is violations
    assert result[0]["verdict"] == "redraw_violation"


def test_g10b_prop_term_map_empty_returns_input_reference(
    make_violation, make_usage
):
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase=(
                "He thrusts P06, a creased paper map damp at the edges, forward."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges forward"
            ),
        )
    ]
    t2i_prompt = (
        "He thrusts P06, a creased paper map damp at the edges, forward."
    )
    visible_entities = ["P06"]

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, {}
    )

    assert result is violations


def test_g10c_empty_violations_no_exception():
    result = reconcile_owned_prop_namespace_overlap(
        [], [], "anything", ["P06"], {"P06": frozenset({"map"})}
    )
    assert result == []


def test_g10d_anchor_reference_entry_is_idempotent(make_violation, make_usage):
    # All other gates would match — but the entry already says anchor_reference.
    # Helper must leave it untouched (no note appended, same dict reference).
    pre_anchored = make_violation(
        owned_object="map",
        violating_phrase=(
            "He thrusts P06, a creased paper map damp at the edges, forward."
        ),
        verdict="anchor_reference",
        reason="Anchor-only mention; no redraw cues present.",
    )
    violations = [pre_anchored]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges forward"
            ),
        )
    ]
    t2i_prompt = (
        "He thrusts P06, a creased paper map damp at the edges, forward."
    )
    visible_entities = ["P06"]
    prop_term_map = {"P06": frozenset({"map", "paper map"})}

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result[0] is pre_anchored
    assert result[0]["verdict"] == "anchor_reference"
    assert result[0]["reason"] == "Anchor-only mention; no redraw cues present."


def test_g10e_violation_missing_verdict_key_is_untouched(
    make_violation, make_usage
):
    legacy_v1_entry = {
        "owned_object": "map",
        "violating_phrase": (
            "He thrusts P06, a creased paper map damp at the edges, forward."
        ),
        # No "verdict" key (legacy v1 schema).
        "reason": "Legacy entry without verdict field.",
    }
    violations = [legacy_v1_entry]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges forward"
            ),
        )
    ]
    t2i_prompt = (
        "He thrusts P06, a creased paper map damp at the edges, forward."
    )
    visible_entities = ["P06"]
    prop_term_map = {"P06": frozenset({"map", "paper map"})}

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert result[0] is legacy_v1_entry
    assert "verdict" not in result[0]
    assert result[0]["reason"] == "Legacy entry without verdict field."


# ---------------------------------------------------------------------------
# G11 — Wave 2 fixup (Task 14): producer expands prop terms into full
# phrase + ASCII content tokens. This integration test demonstrates that
# the S28-style scenario (where the canon prop's only ASCII source is a
# longer phrase like "creased paper map" and the owned token is the bare
# noun "map") reclassifies to anchor_reference once the producer feeds
# the helper a token-expanded frozenset. Helper semantics (membership-
# only, no sub-word splitting) stay unchanged — only the producer's set
# composition grew.
# ---------------------------------------------------------------------------


def test_g11_producer_expanded_term_set_covers_s28_scenario(
    make_violation, make_usage
):
    violations = [
        make_violation(
            owned_object="map",
            violating_phrase=(
                "thrusts P06, a creased paper map damp at the edges, forward."
            ),
        )
    ]
    usage = [
        make_usage(
            owned_token="map",
            source_phrase=(
                "thrusts P06, a creased paper map damp at the edges, forward"
            ),
        )
    ]
    t2i_prompt = (
        "C01 thrusts P06, a creased paper map damp at the edges, forward "
        "across the air between them."
    )
    visible_entities = ["C01", "C03", "P06", "L17"]
    # Producer-like expanded set (what _expand_prop_term_variants would
    # produce for an ASCII canon source like "creased paper map").
    prop_term_map = {
        "P06": frozenset({"creased paper map", "creased", "paper", "map"})
    }

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    assert len(result) == 1
    assert result[0]["verdict"] == "anchor_reference"
    reason = result[0]["reason"]
    # Reason ends with the deterministic note containing P06 + the owned
    # token "map".
    expected_note = DETERMINISTIC_NOTE_FMT.format(pp="P06", tok="map")
    assert reason.endswith(expected_note)


# ---------------------------------------------------------------------------
# G12 — W4b gate 4 tightening: source_phrase echo alone must NOT reclass.
# violating_phrase MUST literally contain BOTH P## and owned_object for the
# linkage gate to fire. This guards against the S18_Shot12 false-pass: the
# LLM truly intends a redraw of P07's interior (face melting downward), and
# its owned_object_usage[*].source_phrase happens to echo "P07 photo frame"
# — but violating_phrase itself is "Inside the frame... portrait... melting
# downward" (no P07 literal). Previously gate 4(a) (source_phrase echo)
# would have fired reclass, swallowing the real redraw intent. After W4b,
# only violating_phrase literal substring linkage qualifies.
# ---------------------------------------------------------------------------


def test_g12_violating_phrase_must_contain_pid_when_source_phrase_echoes_pid(
    make_violation, make_usage
):
    violations = [
        make_violation(
            owned_object="photo frame",
            violating_phrase=(
                "Inside the frame, the photographed portrait gradually melts "
                "downward, the face distorting as if liquid."
            ),
        )
    ]
    # owned_object_usage echoes P07 in source_phrase — under the OLD (W2)
    # gate 4(a) this alone would have qualified for reclass. Under W4b the
    # source_phrase echo path is dropped: only violating_phrase substring
    # linkage counts.
    usage = [
        make_usage(
            owned_token="photo frame",
            source_phrase=(
                "P07 photo frame stands on the desk; inside the frame the "
                "portrait begins to melt downward"
            ),
        )
    ]
    t2i_prompt = (
        "C04 stares at P07 photo frame on the desk; inside the frame the "
        "portrait melts downward, face distorting like wet ink."
    )
    visible_entities = ["C04", "P07"]
    prop_term_map = {
        "P07": frozenset({"photo frame", "frame", "photo"}),
    }

    result = reconcile_owned_prop_namespace_overlap(
        violations, usage, t2i_prompt, visible_entities, prop_term_map
    )

    # Real redraw intent must survive — verdict stays redraw_violation.
    assert result[0]["verdict"] == "redraw_violation"
    # No mutation — the helper either returned the input reference or kept
    # the original entry intact.
    assert result[0] is violations[0]
