"""Tests for maxim.embodiment.component_index — semantic discovery layer."""

from __future__ import annotations

import textwrap
import threading

import numpy as np
import pytest

from maxim.embodiment.component_index import (
    ComponentIndex,
    ComponentMatch,
    _build_semantic_signature,
    _cosine_similarity,
)
from maxim.embodiment.component_registry import ComponentRegistry


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def components_dir(tmp_path):
    """Create test component YAMLs with synonyms."""
    weapons = tmp_path / "weapons"
    weapons.mkdir()
    items = tmp_path / "items"
    items.mkdir()
    environments = tmp_path / "environments"
    environments.mkdir()
    creatures = tmp_path / "creatures"
    creatures.mkdir()

    (weapons / "rusty_sword.yaml").write_text(
        textwrap.dedent("""\
        component:
          name: rusty_sword
          tags: [weapon, melee, degradable]
          synonyms: [old sword, dull blade, rusty blade, iron sword, corroded sword]
          category: weapons
        entity:
          name: rusty_sword
          entity_type: weapon
          sensors:
            durability:
              unit: ratio
              range: [0, 1]
              initial: 0.3
            sharpness:
              unit: ratio
              range: [0, 1]
              initial: 0.5
          modulators:
            combat:
              affordances:
                slash:
                  params: {target: str, force: float}
                  description: "Slash at a target with the sword"
                parry:
                  params: {}
                  description: "Parry an incoming attack"
          failure_modes:
            - name: shatter
              trigger: {field: durability, op: "<", value: 0.1, pain: 0.6}
        """)
    )

    (items / "healing_potion.yaml").write_text(
        textwrap.dedent("""\
        component:
          name: healing_potion
          tags: [item, consumable, magical]
          synonyms: [health potion, healing draught, restorative elixir, cure potion]
          category: items
        entity:
          name: healing_potion
          entity_type: item
          sensors:
            doses:
              unit: count
              range: [0, 3]
              initial: 3
          modulators:
            usage:
              affordances:
                drink:
                  params: {}
                  description: "Drink the potion to restore health"
          failure_modes:
            - name: empty
              trigger: {field: doses, op: "<=", value: 0, pain: 0.2}
        """)
    )

    (environments / "rusty_gate.yaml").write_text(
        textwrap.dedent("""\
        component:
          name: rusty_gate
          tags: [environment, obstacle, degradable]
          synonyms: [iron gate, old gate, rusty door, decrepit entrance, old iron door]
          category: environments
        entity:
          name: rusty_gate
          entity_type: environment
          sensors:
            integrity:
              unit: ratio
              range: [0, 1]
              initial: 0.4
          modulators:
            interaction:
              affordances:
                push:
                  params: {force: float}
                  description: "Push the gate open"
                pick_lock:
                  params: {}
                  description: "Pick the lock mechanism"
          failure_modes:
            - name: jammed
              trigger: {field: integrity, op: "<", value: 0.1, pain: 0.3}
        """)
    )

    (creatures / "wolf.yaml").write_text(
        textwrap.dedent("""\
        component:
          name: wolf
          tags: [creature, animal, predator, hostile]
          synonyms: [wild dog, timber wolf, grey wolf, beast, predator]
          category: creatures
        entity:
          name: wolf
          entity_type: creature
          sensors:
            hp:
              unit: points
              range: [0, 12]
              initial: 12
          modulators:
            combat:
              affordances:
                bite:
                  params: {target: str}
                  description: "Bite a target"
          failure_modes:
            - name: death
              trigger: {field: hp, op: "<=", value: 0, pain: 1.0}
        """)
    )

    return tmp_path


@pytest.fixture
def registry(components_dir):
    """Build a ComponentRegistry from test fixtures."""
    return ComponentRegistry(
        search_paths=[components_dir],
        include_defaults=False,
    )


@pytest.fixture
def index(registry):
    """Build a ComponentIndex from the test registry."""
    return ComponentIndex(registry)


# ---------------------------------------------------------------------------
# Test: _build_semantic_signature
# ---------------------------------------------------------------------------


class TestSemanticSignature:
    def test_basic_signature(self):
        spec = {
            "entity": {
                "name": "rusty_sword",
                "entity_type": "weapon",
                "sensors": {"durability": {}, "sharpness": {}},
                "modulators": {
                    "combat": {
                        "affordances": {
                            "slash": {"description": "Slash at a target"},
                            "parry": {"description": "Parry an attack"},
                        }
                    }
                },
                "failure_modes": [{"name": "shatter"}, {"name": "dulled"}],
            }
        }
        sig = _build_semantic_signature(spec)
        assert "rusty_sword" in sig
        assert "weapon" in sig
        assert "durability" in sig
        assert "slash" in sig
        assert "shatter" in sig

    def test_empty_spec(self):
        sig = _build_semantic_signature({})
        assert isinstance(sig, str)

    def test_no_modulators(self):
        spec = {"entity": {"name": "rock", "sensors": {"weight": {}}}}
        sig = _build_semantic_signature(spec)
        assert "rock" in sig
        assert "weight" in sig


# ---------------------------------------------------------------------------
# Test: _cosine_similarity
# ---------------------------------------------------------------------------


class TestCosineSimilarity:
    def test_identical(self):
        v = np.array([1.0, 2.0, 3.0])
        assert _cosine_similarity(v, v) == pytest.approx(1.0)

    def test_orthogonal(self):
        a = np.array([1.0, 0.0, 0.0])
        b = np.array([0.0, 1.0, 0.0])
        assert _cosine_similarity(a, b) == pytest.approx(0.0)

    def test_opposite(self):
        a = np.array([1.0, 0.0])
        b = np.array([-1.0, 0.0])
        assert _cosine_similarity(a, b) == pytest.approx(-1.0)

    def test_zero_vector(self):
        a = np.array([0.0, 0.0])
        b = np.array([1.0, 2.0])
        assert _cosine_similarity(a, b) == 0.0


# ---------------------------------------------------------------------------
# Test: Layer 1 — Alias table
# ---------------------------------------------------------------------------


class TestAliasLayer:
    def test_find_by_exact_synonym(self, index):
        """Synonyms from YAML should be findable via Layer 1."""
        match = index.find("old sword")
        assert match is not None
        assert match.ref == "weapons/rusty_sword"
        assert match.layer == "alias"
        assert match.score == 1.0

    def test_find_by_name_with_spaces(self, index):
        """Component name with underscores replaced by spaces."""
        match = index.find("rusty sword")
        assert match is not None
        assert match.ref == "weapons/rusty_sword"
        assert match.layer == "alias"

    def test_find_by_name_exact(self, index):
        """Component name as-is (with underscores)."""
        match = index.find("rusty_sword")
        assert match is not None
        assert match.ref == "weapons/rusty_sword"

    def test_healing_potion_synonym(self, index):
        """healing_potion should be findable by its synonyms."""
        match = index.find("healing draught")
        assert match is not None
        assert match.ref == "items/healing_potion"
        assert match.layer == "alias"

    def test_case_insensitive(self, index):
        """Alias lookup should be case-insensitive."""
        match = index.find("Old Sword")
        assert match is not None
        assert match.ref == "weapons/rusty_sword"

    def test_old_iron_door_alias(self, index):
        """'old iron door' should match rusty_gate via synonym."""
        match = index.find("old iron door")
        assert match is not None
        assert match.ref == "environments/rusty_gate"
        assert match.layer == "alias"

    def test_no_match(self, index):
        """Truly novel queries should return None."""
        match = index.find("quantum phase rifle")
        # With fallback embeddings this may return None or a low-score match
        # The key test is that it doesn't return a wrong high-confidence match
        if match is not None:
            assert match.layer == "embedding"

    def test_empty_query(self, index):
        assert index.find("") is None
        assert index.find("   ") is None
        assert index.find(None) is None  # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# Test: Layer 2 — Embedding (fallback mode)
# ---------------------------------------------------------------------------


class TestEmbeddingLayer:
    def test_find_similar_returns_list(self, index):
        results = index.find_similar("sword weapon blade", k=3)
        assert isinstance(results, list)
        assert len(results) <= 3
        for m in results:
            assert isinstance(m, ComponentMatch)
            assert m.layer == "embedding"

    def test_find_similar_empty_query(self, index):
        assert index.find_similar("") == []

    def test_fallback_embed_deterministic(self):
        """Bag-of-words fallback should be deterministic."""
        idx = ComponentIndex()
        e1 = idx._fallback_embed("hello world test")
        e2 = idx._fallback_embed("hello world test")
        np.testing.assert_array_equal(e1, e2)

    def test_fallback_embed_different_texts(self):
        """Different texts should produce different fallback embeddings."""
        idx = ComponentIndex()
        e1 = idx._fallback_embed("hello world")
        e2 = idx._fallback_embed("completely different text")
        assert not np.array_equal(e1, e2)


# ---------------------------------------------------------------------------
# Test: add() — incremental indexing
# ---------------------------------------------------------------------------


class TestAdd:
    def test_add_new_component(self, index):
        """Adding a component should make it findable."""
        initial_count = index.component_count
        spec = {
            "entity": {
                "name": "laser_pistol",
                "entity_type": "weapon",
                "sensors": {"charge": {}},
                "modulators": {"combat": {"affordances": {"shoot": {"description": "Fire the pistol"}}}},
            }
        }
        index.add("weapons/laser_pistol", spec, synonyms=["ray gun", "blaster"])
        assert index.component_count == initial_count + 1

        # Should be findable by alias
        match = index.find("ray gun")
        assert match is not None
        assert match.ref == "weapons/laser_pistol"

        match2 = index.find("blaster")
        assert match2 is not None
        assert match2.ref == "weapons/laser_pistol"

    def test_add_updates_existing(self, index):
        """Adding a component with existing ref should update, not duplicate."""
        count_before = index.component_count
        spec = {
            "entity": {
                "name": "rusty_sword",
                "entity_type": "weapon",
                "sensors": {"durability": {}},
            }
        }
        index.add("weapons/rusty_sword", spec, synonyms=["new synonym"])
        assert index.component_count == count_before  # no new entry

        match = index.find("new synonym")
        assert match is not None
        assert match.ref == "weapons/rusty_sword"

    def test_add_with_no_synonyms(self, index):
        """Adding without synonyms should still index the name."""
        spec = {"entity": {"name": "test_item"}}
        index.add("items/test_item", spec)
        match = index.find("test_item")
        assert match is not None
        assert match.ref == "items/test_item"


# ---------------------------------------------------------------------------
# Test: dedup_check
# ---------------------------------------------------------------------------


class TestDedupCheck:
    def test_near_duplicate_detected(self, index):
        """A near-duplicate wolf spec should be caught."""
        near_wolf = {
            "entity": {
                "name": "timber_wolf",
                "entity_type": "creature",
                "sensors": {"hp": {}},
                "modulators": {"combat": {"affordances": {"bite": {"description": "Bite a target"}}}},
                "failure_modes": [{"name": "death"}],
            }
        }
        # With fallback embeddings, use a very low threshold since
        # bag-of-words won't have great semantic similarity
        result = index.dedup_check(near_wolf, threshold=0.3)
        # May or may not find a match depending on fallback quality
        # The important thing is the API works
        if result is not None:
            assert isinstance(result, ComponentMatch)
            assert result.score >= 0.3

    def test_novel_spec_no_duplicate(self, index):
        """A truly novel spec should not be flagged as duplicate."""
        novel = {
            "entity": {
                "name": "quantum_computer",
                "entity_type": "device",
                "sensors": {"qubits": {}, "temperature": {}},
                "modulators": {
                    "computation": {
                        "affordances": {
                            "entangle": {"description": "Entangle qubits"},
                            "measure": {"description": "Measure quantum state"},
                        }
                    }
                },
            }
        }
        # With a high threshold, a truly novel device shouldn't match
        result = index.dedup_check(novel, threshold=0.95)
        assert result is None


# ---------------------------------------------------------------------------
# Test: Thread safety
# ---------------------------------------------------------------------------


class TestThreadSafety:
    def test_concurrent_find_and_add(self, index):
        """Concurrent find() and add() should not crash."""
        errors = []

        def find_loop():
            try:
                for _ in range(50):
                    index.find("old sword")
                    index.find("healing draught")
                    index.find_similar("blade weapon", k=2)
            except Exception as e:
                errors.append(e)

        def add_loop():
            try:
                for i in range(50):
                    spec = {"entity": {"name": f"item_{i}", "sensors": {"x": {}}}}
                    index.add(f"items/item_{i}", spec, synonyms=[f"thing_{i}"])
            except Exception as e:
                errors.append(e)

        threads = [
            threading.Thread(target=find_loop),
            threading.Thread(target=find_loop),
            threading.Thread(target=add_loop),
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10)

        assert errors == [], f"Thread errors: {errors}"

    def test_concurrent_find_similar(self, index):
        """Multiple concurrent find_similar calls should not crash."""
        errors = []

        def search_loop(query):
            try:
                for _ in range(30):
                    index.find_similar(query, k=3)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=search_loop, args=(q,)) for q in ["sword", "potion", "gate", "wolf"]]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10)

        assert errors == []


# ---------------------------------------------------------------------------
# Test: Persistence
# ---------------------------------------------------------------------------


class TestPersistence:
    def test_save_and_load(self, index, tmp_path):
        """Saved index should reload with same embeddings."""
        cache_path = tmp_path / "test_index.npy"
        index.save(cache_path)
        assert cache_path.exists()
        assert cache_path.with_suffix(".json").exists()

        # Create a fresh empty index and load
        new_index = ComponentIndex(cache_path=cache_path)
        loaded = new_index.load(cache_path)
        assert loaded is True
        assert new_index.component_count == index.component_count

    def test_load_nonexistent(self):
        """Loading from nonexistent path should return False."""
        idx = ComponentIndex()
        assert idx.load("/nonexistent/path.npy") is False

    def test_save_empty_index(self, tmp_path):
        """Saving an empty index should not crash."""
        idx = ComponentIndex()
        idx.save(tmp_path / "empty.npy")
        # File should NOT be created for empty index
        assert not (tmp_path / "empty.npy").exists()


# ---------------------------------------------------------------------------
# Test: stats()
# ---------------------------------------------------------------------------


class TestStats:
    def test_stats_populated(self, index):
        stats = index.stats()
        assert stats["component_count"] == 4
        assert stats["alias_count"] > 0
        assert stats["embedding_dim"] > 0
        assert isinstance(stats["using_fallback"], bool)
        assert isinstance(stats["similarity_threshold"], float)

    def test_stats_empty(self):
        idx = ComponentIndex()
        stats = idx.stats()
        assert stats["component_count"] == 0
        assert stats["alias_count"] == 0
        assert stats["embedding_dim"] == 0


# ---------------------------------------------------------------------------
# Test: ComponentRegistry synonyms field
# ---------------------------------------------------------------------------


class TestRegistrySynonyms:
    def test_synonyms_read_from_yaml(self, registry):
        """ComponentInfo should have synonyms populated from YAML."""
        info = registry.get_info("weapons/rusty_sword")
        assert info is not None
        assert "old sword" in info.synonyms
        assert "rusty blade" in info.synonyms

    def test_synonyms_empty_when_missing(self, tmp_path):
        """Components without synonyms field should have empty tuple."""
        weapons = tmp_path / "weapons"
        weapons.mkdir()
        (weapons / "plain_sword.yaml").write_text(
            textwrap.dedent("""\
            component:
              name: plain_sword
              tags: [weapon]
              category: weapons
            entity:
              name: plain_sword
              sensors:
                durability:
                  initial: 1.0
            """)
        )
        reg = ComponentRegistry(search_paths=[tmp_path], include_defaults=False)
        info = reg._index.get("weapons/plain_sword")
        assert info is not None
        assert info.synonyms == ()

    def test_register_manual_with_synonyms(self):
        """Manual register() should pick up synonyms from spec."""
        reg = ComponentRegistry(search_paths=[], include_defaults=False)
        spec = {
            "component": {
                "name": "test_item",
                "category": "items",
                "synonyms": ["alias_a", "alias_b"],
            },
            "entity": {"name": "test_item"},
        }
        reg.register("items/test_item", spec)
        info = reg.get_info("items/test_item")
        assert info is not None
        assert "alias_a" in info.synonyms
        assert "alias_b" in info.synonyms


# ---------------------------------------------------------------------------
# Test: Integration with real seed components (smoke test)
# ---------------------------------------------------------------------------


class TestSeedComponentSmoke:
    """Smoke tests using the actual bundled seed components.

    These tests verify that the synonym backfill works end-to-end
    with real YAML files. They don't require sentence-transformers.
    """

    @pytest.fixture
    def seed_registry(self):
        """Build a registry from the bundled seed components."""
        try:
            from maxim.utils.paths import bundled_data

            components_dir = bundled_data() / "components"
            if not components_dir.is_dir():
                pytest.skip("Bundled components not available")
        except Exception:
            pytest.skip("maxim.utils.paths not available")
        return ComponentRegistry(
            search_paths=[components_dir],
            include_defaults=False,
        )

    @pytest.fixture
    def seed_index(self, seed_registry):
        return ComponentIndex(seed_registry)

    def test_seed_components_have_synonyms(self, seed_registry):
        """At least the majority of seed components should have synonyms."""
        refs = seed_registry.list_refs()
        with_synonyms = 0
        for ref in refs:
            info = seed_registry.get_info(ref)
            if info is not None and info.synonyms:
                with_synonyms += 1
        # Expect at least 50 out of ~62 components have synonyms
        assert with_synonyms >= 50, f"Only {with_synonyms}/{len(refs)} seed components have synonyms"

    def test_alias_lookup_rusty_sword(self, seed_index):
        match = seed_index.find("old sword")
        assert match is not None
        assert match.ref == "weapons/rusty_sword"
        assert match.layer == "alias"

    def test_alias_lookup_healing_potion(self, seed_index):
        match = seed_index.find("healing draught")
        assert match is not None
        assert match.ref == "items/healing_potion"
        assert match.layer == "alias"

    def test_alias_lookup_rusty_gate(self, seed_index):
        """The plan's canonical example: 'old iron door' → rusty_gate."""
        # Check if rusty_gate exists in seed components
        if not seed_index.find("rusty gate"):
            pytest.skip("rusty_gate not in seed components")
        # This relies on synonym "old iron door" being in rusty_gate's YAML
        # If there's no rusty_gate in seeds (it may be a test fixture),
        # skip this test

    def test_alias_lookup_wolf(self, seed_index):
        match = seed_index.find("wild dog")
        assert match is not None
        assert match.ref == "creatures/wolf"
        assert match.layer == "alias"

    def test_find_similar_returns_relevant(self, seed_index):
        """find_similar for 'blade' should return weapons (embedding mode only).

        The bag-of-words fallback produces hash-based embeddings with no
        semantic meaning — word overlap is the only signal.  This test
        only asserts category relevance when sentence-transformers is
        available.
        """
        results = seed_index.find_similar("blade weapon melee", k=5)
        assert len(results) > 0
        if not seed_index._using_fallback:
            # Real embeddings — category relevance should hold
            refs = [r.ref for r in results]
            has_weapon = any("weapons/" in r for r in refs)
            assert has_weapon, f"No weapons in top-5 results: {refs}"

    def test_index_stats(self, seed_index):
        stats = seed_index.stats()
        assert stats["component_count"] >= 50
        assert stats["alias_count"] >= 200  # ~62 components * ~4+ aliases each
