"""Task 14 Wave 2 — narrow unit test for
``SceneContextLoader._load_entity_canon_prop_term_map``.

Validates the SQL → normalization path that feeds ``ctx.prop_term_map``,
the deterministic input to ``reconcile_owned_prop_namespace_overlap``.

Uses a fake db query interface (not a real SQLAlchemy session) — we only
exercise the loader's row → normalize → frozenset assembly logic.
"""
from __future__ import annotations

from types import SimpleNamespace
from typing import Any, Dict, List, Sequence

import pytest

from app.core.errors import AppError
from app.core.steps.scene_context_loader import SceneContextLoader


class _FakeQuery:
    """Minimal stand-in for SQLAlchemy ``session.query(...)`` chain.

    Records which model columns the loader queried so the test can assert
    the loader pulled the expected fields. ``all()`` returns the rows the
    test fixture seeded for this model.
    """

    def __init__(self, rows_by_model: Dict[str, List[Any]], current_model: str):
        self._rows_by_model = rows_by_model
        self._model = current_model

    def filter(self, *args, **kwargs):
        return self

    def all(self):
        return list(self._rows_by_model.get(self._model, []))


class _FakeDb:
    def __init__(self, prop_rows: List[Any], alias_rows: List[Any]):
        self._rows_by_model = {
            "EntityCanon": prop_rows,
            "EntityAlias": alias_rows,
        }

    def query(self, *cols):
        # Detect target model from the first column's class — every column
        # is a SQLAlchemy InstrumentedAttribute whose ``class_`` gives the
        # mapped class. We use the class __name__ as the dispatch key.
        first = cols[0]
        cls_name = getattr(getattr(first, "class_", None), "__name__", None)
        if cls_name is None:
            # Fallback: pick by column count (5 = EntityCanon, 2 = EntityAlias).
            cls_name = "EntityCanon" if len(cols) >= 3 else "EntityAlias"
        return _FakeQuery(self._rows_by_model, cls_name)


class _FakeRunner:
    project_id = "proj-test"
    episode_id = "ep-test"
    project_config = None

    def __init__(self, prop_rows: List[Any], alias_rows: List[Any]):
        self.db = _FakeDb(prop_rows, alias_rows)

    def _load_prev_checkpoint(self, step_id: str):
        return None


def _canon_row(id_: str, short_id: str, *, name: str = "",
               description: str = "", t2i_prompt: str = ""):
    return SimpleNamespace(
        id=id_, short_id=short_id, name=name,
        description=description, t2i_prompt=t2i_prompt,
    )


def _alias_row(canon_id: str, alias: str):
    return SimpleNamespace(canon_id=canon_id, alias=alias)


def test_prop_term_map_loads_canonical_name_description_t2i_and_aliases():
    prop_rows = [
        _canon_row(
            "prop-uuid-1", "P06",
            name="Paper Map",
            description="A creased paper map damp at the edges.",
            t2i_prompt="creased paper map",
        ),
    ]
    alias_rows = [
        _alias_row("prop-uuid-1", "종이 지도"),
        _alias_row("prop-uuid-1", "map"),
    ]
    loader = SceneContextLoader(_FakeRunner(prop_rows, alias_rows))
    result = loader._load_entity_canon_prop_term_map()

    assert "P06" in result
    terms = result["P06"]
    # All terms are normalized (strip + lowercase + collapse whitespace).
    assert "paper map" in terms
    assert "a creased paper map damp at the edges." in terms
    assert "creased paper map" in terms
    assert "종이 지도" in terms
    assert "map" in terms


def test_prop_term_map_skips_non_p_prefix_short_ids():
    prop_rows = [
        # Defensive: entity_type='prop' rows whose short_id doesn't start
        # with "P" are dropped (data corruption guard).
        _canon_row("u1", "C05", name="map"),
        _canon_row("u2", "P07", name="lantern"),
    ]
    loader = SceneContextLoader(_FakeRunner(prop_rows, []))
    result = loader._load_entity_canon_prop_term_map()

    assert "C05" not in result
    assert result.get("P07") == frozenset({"lantern"})


def test_prop_term_map_empty_when_no_prop_rows():
    loader = SceneContextLoader(_FakeRunner([], []))
    result = loader._load_entity_canon_prop_term_map()
    assert result == {}


def test_prop_term_map_normalizes_whitespace_and_case():
    prop_rows = [
        _canon_row(
            "u1", "P10",
            name="  Brass  Lantern  ",
            description="OIL\tLantern",
            t2i_prompt="",
        ),
    ]
    loader = SceneContextLoader(_FakeRunner(prop_rows, []))
    result = loader._load_entity_canon_prop_term_map()

    terms = result["P10"]
    # Collapsed internal whitespace, lowercased, stripped.
    assert "brass lantern" in terms
    assert "oil lantern" in terms
    # No raw uppercased / tabbed variants.
    assert "Brass Lantern" not in terms
    assert "OIL\tLantern" not in terms


def test_prop_term_map_excludes_empty_or_whitespace_only_sources():
    prop_rows = [
        _canon_row(
            "u1", "P11",
            name="real-name",
            description="   ",
            t2i_prompt="",
        ),
    ]
    alias_rows = [_alias_row("u1", "")]
    loader = SceneContextLoader(_FakeRunner(prop_rows, alias_rows))
    result = loader._load_entity_canon_prop_term_map()

    # Wave 2 fixup (Task 14): producer expands "real-name" into full phrase
    # plus ASCII content tokens (word-boundary across hyphen). The intent of
    # this test — empty/whitespace-only sources contribute nothing — is
    # still preserved (only the single non-empty source produces terms).
    assert result["P11"] == frozenset({"real-name", "real", "name"})


def test_prop_term_map_raises_appstop_on_db_query_failure():
    class _ExplodingDb:
        def query(self, *cols):
            raise RuntimeError("simulated session failure")

    runner = SimpleNamespace(
        project_id="p", episode_id="e", db=_ExplodingDb(), project_config=None,
        _load_prev_checkpoint=lambda step_id: None,
    )
    loader = SceneContextLoader(runner)
    with pytest.raises(AppError) as exc_info:
        loader._load_entity_canon_prop_term_map()
    assert exc_info.value.code == "step.context_loader.prop_canon_load_failed"


# ---------------------------------------------------------------------------
# Wave 2 fixup (Task 14) — `_expand_prop_term_variants` direct unit tests.
#
# Producer owns token expansion; helper stays membership-only. These tests
# pin the pure-function contract documented in plan §7a.
# ---------------------------------------------------------------------------


from app.core.steps._owned_helpers import (  # noqa: E402
    _expand_prop_term_variants,
)


def test_expand_prop_term_variants_ascii_multiword_includes_content_tokens():
    """ASCII multi-word phrase → full phrase + each content token, with no
    sub-word splitting. None of the input words are stopwords."""
    assert _expand_prop_term_variants("creased paper map") == {
        "creased paper map", "creased", "paper", "map",
    }


def test_expand_prop_term_variants_word_boundary_does_not_yield_substring():
    """G6b-style invariant — word-boundary exact, no sub-word split.
    `"mapped territory"` must NOT produce `"map"`."""
    result = _expand_prop_term_variants("mapped territory")
    assert result == {"mapped territory", "mapped", "territory"}
    assert "map" not in result


def test_expand_prop_term_variants_stopwords_excluded():
    """`a`, `the` are stopwords; content tokens (`folded`, `printed`,
    `paper`, `map`, `showing`, `detailed`, `coastlines`) survive."""
    result = _expand_prop_term_variants(
        "a folded printed paper map showing detailed coastlines"
    )
    assert {
        "folded", "printed", "paper", "map",
        "showing", "detailed", "coastlines",
    } <= result
    assert "a" not in result
    assert "the" not in result
    # Full normalized phrase preserved.
    assert "a folded printed paper map showing detailed coastlines" in result


def test_expand_prop_term_variants_korean_preserved_no_token_split():
    """Pure Korean phrase → only the full normalized phrase. We do not
    tokenize Korean (regex matches no `[a-z0-9]+` runs)."""
    assert _expand_prop_term_variants("종이 지도") == {"종이 지도"}


def test_expand_prop_term_variants_mixed_script_yields_ascii_tokens_only():
    """Mixed ASCII + Korean → full normalized phrase plus ASCII content
    tokens only; Hangul glyphs are not split into sub-tokens."""
    result = _expand_prop_term_variants("Korean 지도 paper map")
    assert "korean 지도 paper map" in result
    assert "korean" in result
    assert "paper" in result
    assert "map" in result
    # Korean glyph alone is not added as a separate token.
    assert "지도" not in result


def test_expand_prop_term_variants_two_letter_tokens_survive():
    """No length cutoff — `tv` (2 letters, not a stopword) is kept."""
    result = _expand_prop_term_variants("a TV with HDMI ports")
    assert {"tv", "hdmi", "ports"} <= result
    # Stopwords excluded.
    assert "a" not in result
    assert "with" not in result
    # Full normalized phrase preserved.
    assert "a tv with hdmi ports" in result


def test_expand_prop_term_variants_empty_and_whitespace_only_yield_empty_set():
    assert _expand_prop_term_variants("") == set()
    assert _expand_prop_term_variants("   ") == set()
    assert _expand_prop_term_variants("\t\n  ") == set()


# ---------------------------------------------------------------------------
# Wave 2 fixup (Task 14) — loader integration tests covering the new
# producer expansion end-to-end via the existing fake-DB fixture.
# ---------------------------------------------------------------------------


def test_prop_term_map_p06_s28_scenario_expanded_token_set():
    """S28-style scenario: canon prop P06 has Korean name + Korean
    description + English ASCII t2i_prompt and NO alias rows. The
    expanded term set must include the bare ASCII token `map` (so the
    consumer helper can match the owned token `map` against P06), AND
    all of paper / map / Korean full phrases / English full phrase."""
    prop_rows = [
        _canon_row(
            "p06-uuid", "P06",
            name="인쇄된 종이 지도",
            description="접힌 종이 지도",
            t2i_prompt="a folded printed paper map",
        ),
    ]
    loader = SceneContextLoader(_FakeRunner(prop_rows, []))
    result = loader._load_entity_canon_prop_term_map()

    terms = result["P06"]
    # Bare ASCII content tokens from the English t2i_prompt.
    assert "map" in terms
    assert "paper" in terms
    assert "folded" in terms
    assert "printed" in terms
    # Korean full phrases (no token split).
    assert "인쇄된 종이 지도" in terms
    assert "접힌 종이 지도" in terms
    # Full English phrase (after stopword "a" is dropped from token expansion
    # the full phrase itself is still preserved).
    assert "a folded printed paper map" in terms
    # Stopwords excluded.
    assert "a" not in terms
    assert "the" not in terms


def test_prop_term_map_loader_uses_word_boundary_only_no_substring_match():
    """Loader integration — if a prop's only source is "mapped territory",
    the resulting term set must NOT contain the bare token "map" (only
    "mapped" / "territory" plus the full phrase). This ensures the
    consumer's Gate C remains word-boundary-exact at the loader level."""
    prop_rows = [
        _canon_row(
            "u1", "P20",
            name="mapped territory",
            description="",
            t2i_prompt="",
        ),
    ]
    loader = SceneContextLoader(_FakeRunner(prop_rows, []))
    result = loader._load_entity_canon_prop_term_map()

    terms = result["P20"]
    assert terms == frozenset({"mapped territory", "mapped", "territory"})
    assert "map" not in terms
