"""Tests for the within-wing hallway primitive.

Hallways are bridges INSIDE a wing that connect entities (people,
projects, concepts, interests) to each other, materialized from
drawer-level co-occurrence. Two entities are linked by a hallway when
they appear together in enough drawers across the wing.

This file is RED-first. The corresponding implementation lives in
``mempalace/hallways.py`` and is written to make these tests pass.
"""

from unittest.mock import MagicMock, patch


# Mock chromadb at import time so the hallways module can be loaded even
# in environments where chromadb isn't installed. Mirrors the pattern in
# ``tests/test_palace_graph_tunnels.py``.
with patch.dict("sys.modules", {"chromadb": MagicMock()}):
    from mempalace import hallways as hallways_mod


def _use_tmp_hallway_file(monkeypatch, tmp_path):
    """Redirect both the hallway-file resolver and the legacy-file check at the
    tmp_path so existing tests stay in the configured-path branch and don't
    accidentally trip the new legacy-file warning branch in _load_hallways.
    Mirrors the analogous helper in ``tests/test_palace_graph_tunnels.py``.
    """
    hallway_file = tmp_path / "hallways.json"
    monkeypatch.setattr(hallways_mod, "_get_hallway_file", lambda *a, **kw: str(hallway_file))
    monkeypatch.setattr(
        hallways_mod,
        "_legacy_hallway_file",
        lambda: str(tmp_path / "legacy-hallways.json"),
    )
    return hallway_file


def _fake_collection(drawers):
    """Build a MagicMock collection over ``drawers`` that supports the paginated
    fetch (``count()`` + ``get(limit=, offset=)``) that compute_hallways_for_wing
    uses to stay under SQLite's variable limit (#1619)."""
    col = MagicMock()
    metas = [d for d in drawers]
    col.count.return_value = len(metas)

    def _get(limit=None, offset=0, include=None, where=None, ids=None, **kwargs):
        page = metas[offset : offset + limit] if limit is not None else metas
        return {
            "ids": [f"drawer_{i}" for i in range(offset, offset + len(page))],
            "metadatas": page,
        }

    col.get.side_effect = _get
    return col


# ─────────────────────────────────────────────────────────────────────────────
# Storage primitives — _load_hallways / _save_hallways
# ─────────────────────────────────────────────────────────────────────────────


class TestHallwayStorage:
    def test_load_hallways_missing_file_returns_empty_list(self, tmp_path, monkeypatch):
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        assert hallways_mod._load_hallways() == []

    def test_load_hallways_corrupt_file_returns_empty_list(self, tmp_path, monkeypatch):
        hallway_file = _use_tmp_hallway_file(monkeypatch, tmp_path)
        hallway_file.write_text("{not valid json", encoding="utf-8")
        assert hallways_mod._load_hallways() == []

    def test_save_and_load_round_trip(self, tmp_path, monkeypatch):
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        sample = [
            {
                "id": "hallway_wing_aya_aya_lumi_abc12345",
                "wing": "wing_aya",
                "entity_a": "Aya",
                "entity_b": "Lumi",
                "co_occurrence_count": 47,
                "rooms": ["diary", "letters"],
                "label": "Aya ↔ Lumi (co-occur in 47 drawers across 2 rooms)",
            }
        ]
        hallways_mod._save_hallways(sample)
        assert hallways_mod._load_hallways() == sample


# ─────────────────────────────────────────────────────────────────────────────
# compute_hallways_for_wing — entity-pair co-occurrence algorithm
# ─────────────────────────────────────────────────────────────────────────────


class TestComputeHallways:
    def test_explicit_config_scopes_persistence_to_selected_palace(self, tmp_path):
        from mempalace.config import MempalaceConfig

        default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
        selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )

        created = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, config=selected_cfg)

        assert len(created) == 1
        assert hallways_mod.list_hallways(config=selected_cfg) == created
        assert hallways_mod.list_hallways(config=default_cfg) == []

    def test_returns_empty_for_unknown_wing(self, tmp_path, monkeypatch):
        """Wing with no drawers → no hallways, no crash."""
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection([])
        result = hallways_mod.compute_hallways_for_wing("wing_nonexistent", col=col)
        assert result == []

    def test_returns_empty_when_no_drawer_has_two_entities(self, tmp_path, monkeypatch):
        """A drawer must mention >= 2 entities to contribute a pair."""
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya"},  # only one
                {"wing": "wing_aya", "room": "diary", "entities": ""},  # none
            ]
        )
        result = hallways_mod.compute_hallways_for_wing("wing_aya", col=col)
        assert result == []

    def test_creates_hallway_for_entity_pair_when_threshold_met(self, tmp_path, monkeypatch):
        """Two entities co-occurring in >= min_count drawers → one hallway record.

        With min_count=2, Aya↔Lumi appear together in 3 drawers; that's a hallway.
        """
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi;Ever"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )
        result = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        # Find the Aya↔Lumi hallway (other pairs like Aya↔Ever might also be present)
        aya_lumi = [h for h in result if {h["entity_a"], h["entity_b"]} == {"Aya", "Lumi"}]
        assert len(aya_lumi) == 1
        hallway = aya_lumi[0]
        assert hallway["wing"] == "wing_aya"
        assert hallway["co_occurrence_count"] == 3
        assert set(hallway["rooms"]) == {"diary", "letters"}

    def test_connects_person_to_concept(self, tmp_path, monkeypatch):
        """Entities aren't only people — projects/concepts/interests count too.

        The entity tag treats 'consciousness' the same as 'Aya'; both are
        just tokens in the drawer's entities field. So Aya↔consciousness is
        a valid hallway when they co-occur enough.
        """
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;consciousness"},
                {"wing": "wing_aya", "room": "research", "entities": "Aya;consciousness"},
                {"wing": "wing_aya", "room": "ideas", "entities": "Aya;consciousness;Lumi"},
            ]
        )
        result = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        aya_consciousness = [
            h for h in result if {h["entity_a"], h["entity_b"]} == {"Aya", "consciousness"}
        ]
        assert len(aya_consciousness) == 1
        assert aya_consciousness[0]["co_occurrence_count"] == 3
        assert set(aya_consciousness[0]["rooms"]) == {"diary", "research", "ideas"}

    def test_respects_min_count_threshold(self, tmp_path, monkeypatch):
        """min_count=3 filters out pairs that only co-occur twice."""
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )
        result = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=3)
        assert result == []

    def test_creates_deterministic_id_per_entity_pair(self, tmp_path, monkeypatch):
        """Same wing + same entity pair → same hallway id (idempotent re-runs)."""
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )
        first = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        second = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        # Find the Aya↔Lumi record in both runs; ids must match.
        f_id = next(h["id"] for h in first if {h["entity_a"], h["entity_b"]} == {"Aya", "Lumi"})
        s_id = next(h["id"] for h in second if {h["entity_a"], h["entity_b"]} == {"Aya", "Lumi"})
        assert f_id == s_id
        assert f_id.startswith("hallway_")

    def test_entity_pair_is_symmetric(self, tmp_path, monkeypatch):
        """Drawer says 'Aya;Lumi'; another says 'Lumi;Aya' — same hallway."""
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Lumi;Aya"},
            ]
        )
        result = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        aya_lumi = [h for h in result if {h["entity_a"], h["entity_b"]} == {"Aya", "Lumi"}]
        # Symmetry: the two drawers count as 2 co-occurrences, not 0 (no
        # double-bookkeeping despite the swapped order).
        assert len(aya_lumi) == 1
        assert aya_lumi[0]["co_occurrence_count"] == 2

    def test_persists_to_json(self, tmp_path, monkeypatch):
        """After compute, _load_hallways() returns the new records."""
        hallway_file = _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )
        hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        assert hallway_file.exists()
        loaded = hallways_mod._load_hallways()
        assert any({h["entity_a"], h["entity_b"]} == {"Aya", "Lumi"} for h in loaded)

    def test_tracks_rooms_across_co_occurrences(self, tmp_path, monkeypatch):
        """A hallway records the set of rooms where its entities co-occurred."""
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
            ]
        )
        result = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        h = next(h for h in result if {h["entity_a"], h["entity_b"]} == {"Aya", "Lumi"})
        assert set(h["rooms"]) == {"diary", "letters"}
        assert h["co_occurrence_count"] == 3  # 3 drawers, not 3 rooms

    def test_skips_sentinel_drawers(self, tmp_path, monkeypatch):
        """Sentinels exist for file_already_mined() bookkeeping. Skip them."""
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {
                    "wing": "wing_aya",
                    "room": "documents",
                    "entities": "Aya;Lumi",
                    "is_sentinel": True,
                },
                {
                    "wing": "wing_aya",
                    "room": "documents",
                    "entities": "Aya;Lumi",
                    "is_sentinel": True,
                },
            ]
        )
        result = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        assert result == []


# ─────────────────────────────────────────────────────────────────────────────
# Query API — list_hallways, delete_hallway
# ─────────────────────────────────────────────────────────────────────────────


class TestHallwayQuery:
    def test_list_hallways_returns_all_when_no_filter(self, tmp_path, monkeypatch):
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        hallways_mod._save_hallways(
            [
                {"id": "h1", "wing": "wing_aya", "entity_a": "Aya", "entity_b": "Lumi"},
                {"id": "h2", "wing": "wing_lumi", "entity_a": "Lumi", "entity_b": "Ever"},
            ]
        )
        assert len(hallways_mod.list_hallways()) == 2

    def test_list_hallways_filters_by_wing(self, tmp_path, monkeypatch):
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        hallways_mod._save_hallways(
            [
                {"id": "h1", "wing": "wing_aya", "entity_a": "Aya", "entity_b": "Lumi"},
                {"id": "h2", "wing": "wing_lumi", "entity_a": "Lumi", "entity_b": "Ever"},
            ]
        )
        result = hallways_mod.list_hallways(wing="wing_aya")
        assert len(result) == 1
        assert result[0]["id"] == "h1"

    def test_delete_hallway_removes_record(self, tmp_path, monkeypatch):
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        hallways_mod._save_hallways(
            [
                {"id": "h1", "wing": "wing_aya", "entity_a": "Aya", "entity_b": "Lumi"},
                {"id": "h2", "wing": "wing_aya", "entity_a": "Aya", "entity_b": "Ever"},
            ]
        )
        assert hallways_mod.delete_hallway("h1") is True
        remaining = hallways_mod._load_hallways()
        assert len(remaining) == 1
        assert remaining[0]["id"] == "h2"

    def test_delete_hallway_unknown_id_returns_false(self, tmp_path, monkeypatch):
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        hallways_mod._save_hallways([{"id": "h1", "wing": "wing_aya"}])
        assert hallways_mod.delete_hallway("nonexistent") is False

    def test_delete_hallway_uses_selected_palace_config(self, tmp_path):
        from mempalace.config import MempalaceConfig

        default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
        selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
        record = {"id": "h1", "wing": "wing_aya"}
        hallways_mod._save_hallways([record], default_cfg)
        hallways_mod._save_hallways([record], selected_cfg)

        assert hallways_mod.delete_hallway("h1", config=selected_cfg) is True
        assert hallways_mod.list_hallways(config=selected_cfg) == []
        assert hallways_mod.list_hallways(config=default_cfg) == [record]


# ─────────────────────────────────────────────────────────────────────────────
# L7 dynamics integration — hallway records carry strength/stability/etc
# ─────────────────────────────────────────────────────────────────────────────


class TestHallwayDynamicsIntegration:
    """Hallway records produced by ``compute_hallways_for_wing`` must carry
    the L7 dynamics fields (strength, stability, last_activated, access_count)
    so the living-connection math in ``mempalace.dynamics`` can operate on
    them. Plus: recomputing the same wing must PRESERVE accumulated dynamics
    rather than reset them — otherwise every mine wipes the connection
    weights and L7 is undermined."""

    def test_new_hallway_record_carries_all_dynamics_fields(self, tmp_path, monkeypatch):
        from mempalace.dynamics import DEFAULT_STABILITY, DEFAULT_STRENGTH

        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )
        created = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        assert created, "expected at least one hallway record"
        for h in created:
            assert h["strength"] == DEFAULT_STRENGTH, (
                f"new hallway should carry default strength; got {h}"
            )
            assert h["stability"] == DEFAULT_STABILITY, (
                f"new hallway should carry default stability; got {h}"
            )
            assert h["access_count"] == 0, f"new hallway should start at access_count=0; got {h}"
            assert "last_activated" in h, f"new hallway must carry last_activated; got {h}"
            # last_activated should anchor to created_at so decay starts from
            # creation, not from recompute-time.
            assert h["last_activated"] == h["created_at"]

    def test_recompute_preserves_accumulated_strength(self, tmp_path, monkeypatch):
        """If a hallway has been potentiated through use (strength > default),
        a re-run of compute_hallways_for_wing on the same drawer set must
        NOT reset that strength. Otherwise every mine wipes the connection
        weights — undermining the whole L7 dynamics layer."""
        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )
        first_pass = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)
        assert first_pass, "first pass must create at least one hallway"

        # Simulate user activity: manually bump strength + access_count on
        # the persisted records.
        stored = hallways_mod._load_hallways()
        for h in stored:
            h["strength"] = 2.5
            h["access_count"] = 7
            h["stability"] = 1.8
        hallways_mod._save_hallways(stored)

        # Recompute the same wing — should preserve the bumped values.
        hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)

        after = hallways_mod._load_hallways()
        assert after, "after-recompute records must exist"
        for h in after:
            assert h["strength"] == 2.5, (
                f"recompute reset strength — L7 dynamics undermined; got {h}"
            )
            assert h["access_count"] == 7, f"recompute reset access_count; got {h}"
            assert h["stability"] == 1.8, f"recompute reset stability; got {h}"

    def test_recompute_initializes_dynamics_for_brand_new_pairs(self, tmp_path, monkeypatch):
        """When a recompute discovers a NEW entity pair (not in the prior
        wing's hallways), the new record gets default dynamics — not
        inherited from some unrelated previous record."""
        from mempalace.dynamics import DEFAULT_STRENGTH

        _use_tmp_hallway_file(monkeypatch, tmp_path)
        col_a = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )
        hallways_mod.compute_hallways_for_wing("wing_aya", col=col_a, min_count=2)

        # Bump strength on the Aya↔Lumi pair to verify it's not leaked.
        stored = hallways_mod._load_hallways()
        for h in stored:
            h["strength"] = 3.5
        hallways_mod._save_hallways(stored)

        # Now a recompute with a different entity pair (Ever shows up).
        col_b = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Ever"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Ever"},
            ]
        )
        hallways_mod.compute_hallways_for_wing("wing_aya", col=col_b, min_count=2)

        after = hallways_mod._load_hallways()
        aya_ever = [h for h in after if {h["entity_a"], h["entity_b"]} == {"Aya", "Ever"}]
        aya_lumi = [h for h in after if {h["entity_a"], h["entity_b"]} == {"Aya", "Lumi"}]

        assert len(aya_ever) == 1, "Aya↔Ever pair should now exist"
        assert aya_ever[0]["strength"] == DEFAULT_STRENGTH, (
            "new pair should get default strength, not inherit from another pair"
        )
        assert len(aya_lumi) == 1
        assert aya_lumi[0]["strength"] == 3.5, (
            "existing pair's accumulated strength must still be preserved"
        )

    def test_recompute_preserves_dynamics_when_existing_record_has_reversed_entity_order(
        self, tmp_path, monkeypatch
    ):
        """The dynamics-preservation lookup must canonicalize the entity-pair
        key by sorting, matching the symmetric ID generation. Otherwise a
        persisted record with (entity_a='Lumi', entity_b='Aya') would miss
        the lookup when the new computation produces (entity_a='Aya',
        entity_b='Lumi') — silently wiping accumulated dynamics.

        Per PR #1578 review (gemini-code-assist, HIGH priority): existing
        records may not always be stored with sorted entity order
        (manual edits, imports from other sources, legacy schema). The
        lookup must canonicalize the same way ``_hallway_id`` does.
        """
        _use_tmp_hallway_file(monkeypatch, tmp_path)

        # Pre-populate with a record whose entities are stored in REVERSED
        # (non-sorted) order, but bumped to non-default dynamics.
        hallways_mod._save_hallways(
            [
                {
                    "id": hallways_mod._hallway_id("wing_aya", "Aya", "Lumi"),
                    "wing": "wing_aya",
                    "entity_a": "Lumi",  # NOT sorted — Lumi > Aya
                    "entity_b": "Aya",
                    "co_occurrence_count": 5,
                    "rooms": ["diary"],
                    "label": "...",
                    "created_at": "2026-04-01T00:00:00+00:00",
                    "created_by": "auto",
                    "strength": 4.2,
                    "stability": 1.9,
                    "last_activated": "2026-05-01T00:00:00+00:00",
                    "access_count": 33,
                }
            ]
        )

        # Recompute — should match the existing record via sorted-key lookup
        # and preserve its dynamics, not initialize defaults.
        col = _fake_collection(
            [
                {"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
                {"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
            ]
        )
        hallways_mod.compute_hallways_for_wing("wing_aya", col=col, min_count=2)

        after = hallways_mod._load_hallways()
        assert len(after) == 1
        assert after[0]["strength"] == 4.2, (
            "lookup failed to match the reverse-ordered existing record — "
            "strength got reset to default. Lookup key must be canonicalized "
            "by sorting the entity pair (matching _hallway_id's symmetric ID)."
        )
        assert after[0]["access_count"] == 33
        assert after[0]["stability"] == 1.9
