"""Tests for kokoro_chat.lorebook.engine."""

from __future__ import annotations

import pytest

from kokoro_chat.lorebook.engine import (
    CHARS_PER_TOKEN,
    LorebookEngine,
    LorebookEntry,
)


# ---------------------------------------------------------------------------
# LorebookEntry defaults
# ---------------------------------------------------------------------------

class TestLorebookEntryDefaults:
    def test_default_values(self):
        entry = LorebookEntry()
        assert entry.keys == []
        assert entry.secondary_keys == []
        assert entry.content == ""
        assert entry.enabled is True
        assert entry.constant is False
        assert entry.selective is False
        assert entry.priority == 5
        assert entry.position == "after_char"
        assert entry.insertion_order == 100

    def test_independent_default_lists(self):
        """Each instance should get its own list objects."""
        a = LorebookEntry()
        b = LorebookEntry()
        a.keys.append("x")
        assert b.keys == []


# ---------------------------------------------------------------------------
# _estimate_tokens
# ---------------------------------------------------------------------------

class TestEstimateTokens:
    @pytest.mark.parametrize(
        "text, expected",
        [
            ("", 1),                          # empty -> min 1
            ("hi", 1),                         # 2 chars // 4 = 0 -> clamped to 1
            ("abcd", 1),                       # exactly 4 chars // 4 = 1
            ("a" * 100, 25),                   # 100 // 4 = 25
            ("a" * 13, 3),                     # 13 // 4 = 3  (not evenly divisible)
        ],
        ids=["empty", "short", "exact_4", "long_100", "not_divisible"],
    )
    def test_estimate_tokens(self, text: str, expected: int):
        assert LorebookEngine._estimate_tokens(text) == expected


# ---------------------------------------------------------------------------
# _any_key_matches
# ---------------------------------------------------------------------------

class TestAnyKeyMatches:
    def test_case_insensitive_match(self):
        assert LorebookEngine._any_key_matches(["Dragon"], "a dragon appears") is True

    def test_substring_match(self):
        assert LorebookEngine._any_key_matches(["fire"], "fireplace") is True

    def test_no_match(self):
        assert LorebookEngine._any_key_matches(["ice"], "a dragon appears") is False

    def test_empty_keys(self):
        assert LorebookEngine._any_key_matches([], "anything here") is False

    def test_multiple_keys_one_matches(self):
        assert LorebookEngine._any_key_matches(["ice", "fire"], "on fire") is True

    def test_empty_text(self):
        assert LorebookEngine._any_key_matches(["fire"], "") is False


# ---------------------------------------------------------------------------
# scan - basic triggering
# ---------------------------------------------------------------------------

class TestScanBasicTriggering:
    def test_keyword_triggers_entry(self):
        entry = LorebookEntry(keys=["dragon"], content="Dragons are mighty.")
        engine = LorebookEngine([entry])
        result = engine.scan(["A dragon appeared!"])
        assert result == [entry]

    def test_no_match_returns_empty(self):
        entry = LorebookEntry(keys=["dragon"], content="Dragons are mighty.")
        engine = LorebookEngine([entry])
        result = engine.scan(["The sun is shining."])
        assert result == []

    def test_multiple_entries_only_matched_returned(self):
        e1 = LorebookEntry(keys=["dragon"], content="dragon lore")
        e2 = LorebookEntry(keys=["elf"], content="elf lore")
        engine = LorebookEngine([e1, e2])
        result = engine.scan(["An elf walked by."])
        assert result == [e2]


# ---------------------------------------------------------------------------
# scan - disabled entries
# ---------------------------------------------------------------------------

class TestScanDisabledEntries:
    def test_disabled_entry_skipped(self):
        entry = LorebookEntry(
            keys=["dragon"], content="Dragons!", enabled=False
        )
        engine = LorebookEngine([entry])
        result = engine.scan(["A dragon appeared!"])
        assert result == []

    def test_disabled_constant_skipped(self):
        entry = LorebookEntry(
            content="Always on", constant=True, enabled=False
        )
        engine = LorebookEngine([entry])
        result = engine.scan(["anything"])
        assert result == []


# ---------------------------------------------------------------------------
# scan - constant entries
# ---------------------------------------------------------------------------

class TestScanConstantEntries:
    def test_constant_always_included(self):
        entry = LorebookEntry(
            content="World lore that always appears.", constant=True
        )
        engine = LorebookEngine([entry])
        result = engine.scan(["completely unrelated message"])
        assert result == [entry]

    def test_constant_included_with_empty_messages(self):
        entry = LorebookEntry(content="Always here.", constant=True)
        engine = LorebookEngine([entry])
        result = engine.scan([])
        assert result == [entry]


# ---------------------------------------------------------------------------
# scan - selective mode
# ---------------------------------------------------------------------------

class TestScanSelectiveMode:
    def test_selective_requires_both_keys(self):
        entry = LorebookEntry(
            keys=["dragon"],
            secondary_keys=["fire"],
            content="Fire dragon lore.",
            selective=True,
        )
        engine = LorebookEngine([entry])
        # Both present
        result = engine.scan(["The fire dragon roared!"])
        assert result == [entry]

    def test_selective_primary_only_not_triggered(self):
        entry = LorebookEntry(
            keys=["dragon"],
            secondary_keys=["fire"],
            content="Fire dragon lore.",
            selective=True,
        )
        engine = LorebookEngine([entry])
        result = engine.scan(["A dragon appeared!"])
        assert result == []

    def test_selective_secondary_only_not_triggered(self):
        entry = LorebookEntry(
            keys=["dragon"],
            secondary_keys=["fire"],
            content="Fire dragon lore.",
            selective=True,
        )
        engine = LorebookEngine([entry])
        result = engine.scan(["The fire burns brightly."])
        assert result == []

    def test_selective_with_empty_secondary_keys(self):
        """Empty secondary_keys means _any_key_matches returns False,
        so the entry is never triggered even if primary matches."""
        entry = LorebookEntry(
            keys=["dragon"],
            secondary_keys=[],
            content="Unreachable.",
            selective=True,
        )
        engine = LorebookEngine([entry])
        result = engine.scan(["A dragon appeared!"])
        assert result == []


# ---------------------------------------------------------------------------
# scan - scan_depth
# ---------------------------------------------------------------------------

class TestScanDepth:
    def test_only_last_n_messages_scanned(self):
        entry = LorebookEntry(keys=["dragon"], content="Dragon lore.")
        engine = LorebookEngine([entry], scan_depth=2)
        messages = [
            "A dragon appeared!",  # outside scan window
            "The sun is shining.",
            "Birds are singing.",
        ]
        result = engine.scan(messages)
        assert result == []

    def test_keyword_within_scan_depth(self):
        entry = LorebookEntry(keys=["dragon"], content="Dragon lore.")
        engine = LorebookEngine([entry], scan_depth=2)
        messages = [
            "The sun is shining.",
            "A dragon appeared!",  # within window
            "Birds are singing.",
        ]
        result = engine.scan(messages)
        assert result == [entry]

    def test_scan_depth_larger_than_messages(self):
        entry = LorebookEntry(keys=["dragon"], content="Dragon lore.")
        engine = LorebookEngine([entry], scan_depth=100)
        result = engine.scan(["A dragon appeared!"])
        assert result == [entry]


# ---------------------------------------------------------------------------
# scan - case insensitivity
# ---------------------------------------------------------------------------

class TestScanCaseInsensitivity:
    @pytest.mark.parametrize(
        "message",
        ["A Dragon appeared!", "DRAGON", "dRaGoN rising"],
        ids=["capitalized", "uppercase", "mixed"],
    )
    def test_case_variations_match(self, message: str):
        entry = LorebookEntry(keys=["dragon"], content="Dragon lore.")
        engine = LorebookEngine([entry])
        result = engine.scan([message])
        assert result == [entry]


# ---------------------------------------------------------------------------
# Token budget
# ---------------------------------------------------------------------------

class TestTokenBudget:
    def _make_entry(
        self, content: str, priority: int = 5, insertion_order: int = 100
    ) -> LorebookEntry:
        return LorebookEntry(
            keys=["trigger"],
            content=content,
            priority=priority,
            insertion_order=insertion_order,
        )

    def test_keeps_higher_priority_entries(self):
        """Lower priority number = higher priority = kept first."""
        high = self._make_entry("a" * 40, priority=1, insertion_order=1)   # 10 tokens
        low = self._make_entry("b" * 40, priority=10, insertion_order=2)   # 10 tokens
        engine = LorebookEngine([high, low], token_budget=15)
        result = engine.scan(["trigger"])
        assert high in result
        assert low not in result

    def test_budget_exactly_at_limit(self):
        """All entries fit exactly within the budget."""
        e1 = self._make_entry("a" * 20, priority=1, insertion_order=1)  # 5 tokens
        e2 = self._make_entry("b" * 20, priority=2, insertion_order=2)  # 5 tokens
        engine = LorebookEngine([e1, e2], token_budget=10)
        result = engine.scan(["trigger"])
        assert len(result) == 2

    def test_budget_overflow_drops_progressively(self):
        """When budget is tight, only the highest-priority entries survive."""
        e1 = self._make_entry("a" * 40, priority=1, insertion_order=1)   # 10 tokens
        e2 = self._make_entry("b" * 40, priority=2, insertion_order=2)   # 10 tokens
        e3 = self._make_entry("c" * 40, priority=3, insertion_order=3)   # 10 tokens
        engine = LorebookEngine([e1, e2, e3], token_budget=20)
        result = engine.scan(["trigger"])
        assert len(result) == 2
        assert e1 in result
        assert e2 in result
        assert e3 not in result

    def test_single_entry_exceeds_budget(self):
        """An entry whose tokens alone exceed the budget is dropped."""
        big = self._make_entry("x" * 400, priority=1)  # 100 tokens
        engine = LorebookEngine([big], token_budget=10)
        result = engine.scan(["trigger"])
        assert result == []

    def test_constant_entry_also_subject_to_budget(self):
        """Constant entries consume budget just like regular ones."""
        const = LorebookEntry(
            content="a" * 80, constant=True, priority=5
        )  # 20 tokens
        regular = LorebookEntry(
            keys=["trigger"], content="b" * 80, priority=1
        )  # 20 tokens
        # Budget only 25 -- regular has higher priority (1 < 5), so it wins
        engine = LorebookEngine([const, regular], token_budget=25)
        result = engine.scan(["trigger"])
        assert regular in result
        assert const not in result


# ---------------------------------------------------------------------------
# insertion_order sorting
# ---------------------------------------------------------------------------

class TestInsertionOrderSorting:
    def test_results_sorted_by_insertion_order_ascending(self):
        e1 = LorebookEntry(
            keys=["trigger"], content="first", insertion_order=300
        )
        e2 = LorebookEntry(
            keys=["trigger"], content="second", insertion_order=100
        )
        e3 = LorebookEntry(
            keys=["trigger"], content="third", insertion_order=200
        )
        engine = LorebookEngine([e1, e2, e3])
        result = engine.scan(["trigger"])
        assert [e.insertion_order for e in result] == [100, 200, 300]

    def test_constant_and_triggered_sorted_together(self):
        const = LorebookEntry(
            content="always", constant=True, insertion_order=50
        )
        triggered = LorebookEntry(
            keys=["hello"], content="greeting", insertion_order=10
        )
        engine = LorebookEngine([const, triggered])
        result = engine.scan(["hello world"])
        assert result[0] is triggered
        assert result[1] is const


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------

class TestEdgeCases:
    def test_empty_entries_list(self):
        engine = LorebookEngine([])
        result = engine.scan(["anything"])
        assert result == []

    def test_empty_messages_returns_only_constants(self):
        const = LorebookEntry(content="Always.", constant=True)
        keyword = LorebookEntry(keys=["dragon"], content="Dragon.")
        engine = LorebookEngine([const, keyword])
        result = engine.scan([])
        assert result == [const]

    def test_empty_entries_and_empty_messages(self):
        engine = LorebookEngine([])
        result = engine.scan([])
        assert result == []
