"""Comprehensive tests for all 4 tools in kokoro_chat/tools/.

Each tool accepts protocol-based stores. Mock implementations are defined
at the top of this file so that no real database or vector store is needed.
"""
from __future__ import annotations

import pytest

from kokoro_chat.storage.protocol import (
    AttitudeRecord,
    MemorySearchResult,
)
from kokoro_chat.tools.attitude_tool import update_attitude, AttitudeUpdateResult
from kokoro_chat.tools.memo_tool import upsert_memo, UpsertMemoResult
from kokoro_chat.tools.memory_tool import save_memory, SaveMemoryResult
from kokoro_chat.tools.search_tool import search_memories, SearchMemoriesResult


# ---------------------------------------------------------------------------
# Mock stores
# ---------------------------------------------------------------------------

class MockAttitudeStore:
    """In-memory AttitudeStore that tracks calls for assertions."""

    def __init__(
        self,
        initial: AttitudeRecord | None = None,
        *,
        raise_on_get: Exception | None = None,
        raise_on_save: Exception | None = None,
    ) -> None:
        self._record = initial or AttitudeRecord()
        self._raise_on_get = raise_on_get
        self._raise_on_save = raise_on_save
        self.save_calls: list[dict] = []

    async def get(self, user_id: str, character_id: str) -> AttitudeRecord | None:
        if self._raise_on_get:
            raise self._raise_on_get
        return self._record

    async def get_or_create(self, user_id: str, character_id: str) -> AttitudeRecord:
        if self._raise_on_get:
            raise self._raise_on_get
        return self._record

    async def save(
        self,
        user_id: str,
        character_id: str,
        affection: int,
        trust: int,
        mood: str,
        modifiers_json: str = "[]",
    ) -> None:
        if self._raise_on_save:
            raise self._raise_on_save
        self.save_calls.append(
            {
                "user_id": user_id,
                "character_id": character_id,
                "affection": affection,
                "trust": trust,
                "mood": mood,
                "modifiers_json": modifiers_json,
            }
        )


class MockMemoryVectorStore:
    """In-memory MemoryVectorStore that tracks calls for assertions."""

    def __init__(
        self,
        search_results: list[MemorySearchResult] | None = None,
        add_return: str = "mem-001",
        *,
        raise_on_search: Exception | None = None,
        raise_on_add: Exception | None = None,
    ) -> None:
        self._search_results = search_results or []
        self._add_return = add_return
        self._raise_on_search = raise_on_search
        self._raise_on_add = raise_on_add
        self.add_calls: list[dict] = []
        self.search_calls: list[dict] = []

    async def search(
        self,
        query: str,
        character_id: str,
        user_id: str,
        top_k: int = 5,
        room_id: str | None = None,
    ) -> list[MemorySearchResult]:
        if self._raise_on_search:
            raise self._raise_on_search
        self.search_calls.append(
            {
                "query": query,
                "character_id": character_id,
                "user_id": user_id,
                "top_k": top_k,
                "room_id": room_id,
            }
        )
        return self._search_results

    async def add(
        self,
        character_id: str,
        user_id: str,
        text: str,
        memory_type: str = "conversation_summary",
        importance: float = 0.5,
        room_id: str | None = None,
        visibility: str = "room",
    ) -> str:
        if self._raise_on_add:
            raise self._raise_on_add
        self.add_calls.append(
            {
                "character_id": character_id,
                "user_id": user_id,
                "text": text,
                "memory_type": memory_type,
                "importance": importance,
                "room_id": room_id,
                "visibility": visibility,
            }
        )
        return self._add_return


class MockMemoStore:
    """In-memory MemoStore that tracks calls for assertions."""

    def __init__(
        self,
        *,
        raise_on_upsert: Exception | None = None,
    ) -> None:
        self._raise_on_upsert = raise_on_upsert
        self.upsert_calls: list[dict] = []

    async def get(
        self,
        user_id: str,
        character_id: str,
        key: str | None = None,
    ) -> list[dict[str, str]]:
        return []

    async def upsert(
        self,
        user_id: str,
        character_id: str,
        key: str,
        value: str,
    ) -> None:
        if self._raise_on_upsert:
            raise self._raise_on_upsert
        self.upsert_calls.append(
            {
                "user_id": user_id,
                "character_id": character_id,
                "key": key,
                "value": value,
            }
        )


# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------

USER_ID = "user-42"
CHAR_ID = "char-99"


# ===========================================================================
# 1. attitude_tool tests
# ===========================================================================

class TestUpdateAttitude:
    """Tests for update_attitude."""

    async def test_positive_delta(self):
        store = MockAttitudeStore(AttitudeRecord(affection=50, trust=50))
        result = await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=10, trust_delta=5, mood="happy",
        )
        assert result.success is True
        assert result.affection == 60
        assert result.trust == 55
        assert result.mood == "happy"
        assert len(store.save_calls) == 1
        assert store.save_calls[0]["affection"] == 60

    async def test_negative_delta(self):
        store = MockAttitudeStore(AttitudeRecord(affection=30, trust=20))
        result = await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=-15, trust_delta=-10, mood="angry",
        )
        assert result.success is True
        assert result.affection == 15
        assert result.trust == 10
        assert result.mood == "angry"

    @pytest.mark.parametrize(
        "initial_aff, delta_aff, expected_aff",
        [
            (95, 20, 100),   # clamp upper
            (5, -20, 0),     # clamp lower
            (0, -1, 0),      # already at floor
            (100, 1, 100),   # already at ceiling
        ],
        ids=["clamp-upper", "clamp-lower", "floor-stays", "ceiling-stays"],
    )
    async def test_affection_clamping(self, initial_aff, delta_aff, expected_aff):
        store = MockAttitudeStore(AttitudeRecord(affection=initial_aff, trust=50))
        result = await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=delta_aff, trust_delta=0, mood="neutral",
        )
        assert result.success is True
        assert result.affection == expected_aff

    @pytest.mark.parametrize(
        "initial_trust, delta_trust, expected_trust",
        [
            (95, 20, 100),
            (5, -20, 0),
        ],
        ids=["clamp-upper", "clamp-lower"],
    )
    async def test_trust_clamping(self, initial_trust, delta_trust, expected_trust):
        store = MockAttitudeStore(AttitudeRecord(affection=50, trust=initial_trust))
        result = await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=0, trust_delta=delta_trust, mood="neutral",
        )
        assert result.success is True
        assert result.trust == expected_trust

    async def test_callback_invoked(self):
        store = MockAttitudeStore(AttitudeRecord(affection=50, trust=50))
        callback_args: list[tuple] = []

        async def on_updated(uid, cid, snapshot):
            callback_args.append((uid, cid, snapshot))

        result = await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=5, trust_delta=3, mood="content",
            on_updated=on_updated,
        )
        assert result.success is True
        assert len(callback_args) == 1
        uid, cid, snap = callback_args[0]
        assert uid == USER_ID
        assert cid == CHAR_ID
        assert snap == {"affection": 55, "trust": 53, "mood": "content"}

    async def test_callback_none_is_fine(self):
        store = MockAttitudeStore()
        result = await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=1, trust_delta=1, mood="ok",
            on_updated=None,
        )
        assert result.success is True

    async def test_store_exception_on_get(self):
        store = MockAttitudeStore(raise_on_get=RuntimeError("db down"))
        result = await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=1, trust_delta=1, mood="sad",
        )
        assert result.success is False
        assert "db down" in result.error

    async def test_store_exception_on_save(self):
        store = MockAttitudeStore(raise_on_save=RuntimeError("write failed"))
        result = await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=1, trust_delta=1, mood="sad",
        )
        assert result.success is False
        assert "write failed" in result.error

    async def test_modifiers_json_preserved(self):
        store = MockAttitudeStore(
            AttitudeRecord(affection=50, trust=50, modifiers_json='["shy"]')
        )
        await update_attitude(
            store, USER_ID, CHAR_ID,
            affection_delta=0, trust_delta=0, mood="neutral",
        )
        assert store.save_calls[0]["modifiers_json"] == '["shy"]'


# ===========================================================================
# 2. memo_tool tests
# ===========================================================================

class TestUpsertMemo:
    """Tests for upsert_memo."""

    async def test_success(self):
        store = MockMemoStore()
        result = await upsert_memo(store, USER_ID, CHAR_ID, "user_name", "Alice")
        assert result.success is True
        assert result.key == "user_name"
        assert result.value == "Alice"
        assert len(store.upsert_calls) == 1
        assert store.upsert_calls[0]["key"] == "user_name"
        assert store.upsert_calls[0]["value"] == "Alice"

    @pytest.mark.parametrize(
        "key",
        ["", "   ", "\t\n"],
        ids=["empty", "spaces", "whitespace"],
    )
    async def test_empty_key_rejected(self, key):
        store = MockMemoStore()
        result = await upsert_memo(store, USER_ID, CHAR_ID, key, "some value")
        assert result.success is False
        assert "key" in result.error.lower()
        assert len(store.upsert_calls) == 0

    @pytest.mark.parametrize(
        "value",
        ["", "   ", "\t\n"],
        ids=["empty", "spaces", "whitespace"],
    )
    async def test_empty_value_rejected(self, value):
        store = MockMemoStore()
        result = await upsert_memo(store, USER_ID, CHAR_ID, "fav_food", value)
        assert result.success is False
        assert "value" in result.error.lower()
        assert len(store.upsert_calls) == 0

    async def test_strips_whitespace(self):
        store = MockMemoStore()
        result = await upsert_memo(store, USER_ID, CHAR_ID, "  name  ", "  Bob  ")
        assert result.success is True
        assert result.key == "name"
        assert result.value == "Bob"
        assert store.upsert_calls[0]["key"] == "name"
        assert store.upsert_calls[0]["value"] == "Bob"

    async def test_store_exception(self):
        store = MockMemoStore(raise_on_upsert=RuntimeError("db error"))
        result = await upsert_memo(store, USER_ID, CHAR_ID, "key", "val")
        assert result.success is False
        assert "db error" in result.error


# ===========================================================================
# 3. memory_tool tests
# ===========================================================================

class TestSaveMemory:
    """Tests for save_memory."""

    async def test_success_defaults(self):
        store = MockMemoryVectorStore(add_return="mem-xyz")
        result = await save_memory(store, CHAR_ID, USER_ID, "User likes cats")
        assert result.success is True
        assert result.memory_id == "mem-xyz"
        call = store.add_calls[0]
        assert call["text"] == "User likes cats"
        assert call["importance"] == 0.5
        assert call["visibility"] == "room"
        assert call["room_id"] is None
        assert call["memory_type"] == "conversation_summary"

    async def test_empty_summary_rejected(self):
        store = MockMemoryVectorStore()
        result = await save_memory(store, CHAR_ID, USER_ID, "")
        assert result.success is False
        assert "summary" in result.error.lower()
        assert len(store.add_calls) == 0

    @pytest.mark.parametrize(
        "raw_importance, expected",
        [
            (-0.5, 0.0),
            (-100.0, 0.0),
            (1.5, 1.0),
            (999.0, 1.0),
            (0.0, 0.0),
            (1.0, 1.0),
            (0.7, 0.7),
        ],
        ids=[
            "negative-clamped", "very-negative-clamped",
            "above-one-clamped", "very-high-clamped",
            "zero-ok", "one-ok", "normal",
        ],
    )
    async def test_importance_clamping(self, raw_importance, expected):
        store = MockMemoryVectorStore()
        result = await save_memory(
            store, CHAR_ID, USER_ID, "something",
            importance=raw_importance,
        )
        assert result.success is True
        assert store.add_calls[0]["importance"] == pytest.approx(expected)

    @pytest.mark.parametrize(
        "vis_input, expected",
        [
            ("room", "room"),
            ("private", "private"),
            ("public", "room"),      # invalid -> default
            ("", "room"),            # empty -> default
            ("ROOM", "room"),        # wrong case -> default
        ],
        ids=["room", "private", "public-invalid", "empty-invalid", "case-sensitive"],
    )
    async def test_visibility_validation(self, vis_input, expected):
        store = MockMemoryVectorStore()
        result = await save_memory(
            store, CHAR_ID, USER_ID, "note",
            visibility=vis_input,
        )
        assert result.success is True
        assert store.add_calls[0]["visibility"] == expected

    async def test_room_id_passed_through(self):
        store = MockMemoryVectorStore()
        result = await save_memory(
            store, CHAR_ID, USER_ID, "note",
            room_id="room-abc",
        )
        assert result.success is True
        assert store.add_calls[0]["room_id"] == "room-abc"

    async def test_store_exception(self):
        store = MockMemoryVectorStore(raise_on_add=RuntimeError("vector db down"))
        result = await save_memory(store, CHAR_ID, USER_ID, "something")
        assert result.success is False
        assert "vector db down" in result.error


# ===========================================================================
# 4. search_tool tests
# ===========================================================================

class TestSearchMemories:
    """Tests for search_memories."""

    async def test_success_with_results(self):
        hits = [
            MemorySearchResult(id="m1", text="likes cats", relevance=0.95),
            MemorySearchResult(id="m2", text="birthday in May", relevance=0.80),
        ]
        store = MockMemoryVectorStore(search_results=hits)
        result = await search_memories(store, CHAR_ID, USER_ID, "cats")
        assert result.success is True
        assert len(result.memories) == 2
        assert result.memories[0].id == "m1"
        assert result.memories[1].relevance == 0.80

    async def test_success_empty_results(self):
        store = MockMemoryVectorStore(search_results=[])
        result = await search_memories(store, CHAR_ID, USER_ID, "obscure topic")
        assert result.success is True
        assert result.memories == []

    @pytest.mark.parametrize(
        "query",
        ["", "   ", "\t\n"],
        ids=["empty", "spaces", "whitespace"],
    )
    async def test_empty_query_rejected(self, query):
        store = MockMemoryVectorStore()
        result = await search_memories(store, CHAR_ID, USER_ID, query)
        assert result.success is False
        assert "query" in result.error.lower()
        assert len(store.search_calls) == 0

    async def test_limit_passed_as_top_k(self):
        store = MockMemoryVectorStore()
        await search_memories(store, CHAR_ID, USER_ID, "hello", limit=3)
        assert store.search_calls[0]["top_k"] == 3

    async def test_query_stripped_before_search(self):
        store = MockMemoryVectorStore()
        await search_memories(store, CHAR_ID, USER_ID, "  hello world  ")
        assert store.search_calls[0]["query"] == "hello world"

    async def test_store_exception(self):
        store = MockMemoryVectorStore(raise_on_search=RuntimeError("search failed"))
        result = await search_memories(store, CHAR_ID, USER_ID, "test query")
        assert result.success is False
        assert "search failed" in result.error
