"""Tests for kokoro_chat.engine.attitude_manager — AttitudeManager."""
from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest

from kokoro_chat.attitude.state import AttitudeState
from kokoro_chat.engine.attitude_manager import AttitudeManager
from kokoro_chat.storage.protocol import AttitudeRecord


# ---------------------------------------------------------------------------
# Mock implementations
# ---------------------------------------------------------------------------

class MockAttitudeStore:
    """In-memory AttitudeStore for testing."""

    def __init__(self, record: AttitudeRecord | None = None) -> None:
        self._record = record or AttitudeRecord(
            affection=50, trust=50, mood="neutral", modifiers_json="[]",
        )
        self.get_or_create = AsyncMock(return_value=self._record)
        self.save = AsyncMock()

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


class MockCacheStore:
    """In-memory CacheStore for testing."""

    def __init__(self, cached: dict[str, Any] | None = None) -> None:
        self._cached = cached
        self.get_attitude = AsyncMock(return_value=cached)
        self.set_attitude = AsyncMock()

    # Satisfy remaining CacheStore protocol methods (not used by AttitudeManager)
    async def get_recent_messages(self, user_id, character_id, limit=20):
        return None

    async def set_recent_messages(self, user_id, character_id, messages):
        pass

    async def append_message(self, user_id, character_id, role, content):
        pass

    async def get_rolling_summary(self, user_id, character_id):
        return None

    async def set_rolling_summary(self, user_id, character_id, summary, turn_count):
        pass


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def make_manager(
    record: AttitudeRecord | None = None,
    cached: dict[str, Any] | None = None,
) -> tuple[AttitudeManager, MockAttitudeStore, MockCacheStore]:
    store = MockAttitudeStore(record)
    cache = MockCacheStore(cached)
    return AttitudeManager(store, cache), store, cache


# ---------------------------------------------------------------------------
# load — cache hit
# ---------------------------------------------------------------------------

class TestLoadCacheHit:

    @pytest.mark.asyncio
    async def test_returns_cached_values(self) -> None:
        cached = {"affection": 70, "trust": 60, "mood": "happy", "modifiers_json": "[]"}
        mgr, store, cache = make_manager(cached=cached)

        att = await mgr.load("char1", "user1")

        assert att.affection == 70
        assert att.trust == 60
        assert att.mood == "happy"
        assert att.attitude_modifiers == []

    @pytest.mark.asyncio
    async def test_does_not_call_store(self) -> None:
        cached = {"affection": 70, "trust": 60, "mood": "happy", "modifiers_json": "[]"}
        mgr, store, cache = make_manager(cached=cached)

        await mgr.load("char1", "user1")

        store.get_or_create.assert_not_called()

    @pytest.mark.asyncio
    async def test_cached_modifiers_parsed(self) -> None:
        modifiers = [{"trigger": "test", "effect": "warm", "remaining": 3}]
        cached = {
            "affection": 50,
            "trust": 50,
            "mood": "neutral",
            "modifiers_json": json.dumps(modifiers),
        }
        mgr, _, _ = make_manager(cached=cached)

        att = await mgr.load("char1", "user1")

        assert att.attitude_modifiers == modifiers

    @pytest.mark.asyncio
    async def test_invalid_modifiers_json_falls_back_to_empty(self) -> None:
        cached = {
            "affection": 50,
            "trust": 50,
            "mood": "neutral",
            "modifiers_json": "not-valid-json",
        }
        mgr, _, _ = make_manager(cached=cached)

        att = await mgr.load("char1", "user1")

        assert att.attitude_modifiers == []

    @pytest.mark.asyncio
    async def test_missing_modifiers_key_defaults_to_empty(self) -> None:
        cached = {"affection": 55, "trust": 45, "mood": "content"}
        mgr, _, _ = make_manager(cached=cached)

        att = await mgr.load("char1", "user1")

        assert att.attitude_modifiers == []

    @pytest.mark.asyncio
    async def test_missing_fields_use_defaults(self) -> None:
        cached: dict = {}
        mgr, _, _ = make_manager(cached=cached)

        att = await mgr.load("char1", "user1")

        assert att.affection == 50
        assert att.trust == 50
        assert att.mood == "neutral"


# ---------------------------------------------------------------------------
# load — cache miss → store
# ---------------------------------------------------------------------------

class TestLoadCacheMiss:

    @pytest.mark.asyncio
    async def test_calls_store_get_or_create(self) -> None:
        record = AttitudeRecord(affection=65, trust=55, mood="content", modifiers_json="[]")
        mgr, store, _ = make_manager(record=record)

        await mgr.load("char1", "user1")

        store.get_or_create.assert_called_once_with("user1", "char1")

    @pytest.mark.asyncio
    async def test_returns_store_values(self) -> None:
        record = AttitudeRecord(affection=65, trust=55, mood="content", modifiers_json="[]")
        mgr, _, _ = make_manager(record=record)

        att = await mgr.load("char1", "user1")

        assert att.affection == 65
        assert att.trust == 55
        assert att.mood == "content"

    @pytest.mark.asyncio
    async def test_caches_result_after_store_load(self) -> None:
        record = AttitudeRecord(affection=65, trust=55, mood="content", modifiers_json="[]")
        mgr, _, cache = make_manager(record=record)

        await mgr.load("char1", "user1")

        cache.set_attitude.assert_called_once()
        call_args = cache.set_attitude.call_args
        assert call_args[0][0] == "user1"
        assert call_args[0][1] == "char1"
        data = call_args[0][2]
        assert data["affection"] == 65
        assert data["trust"] == 55
        assert data["mood"] == "content"

    @pytest.mark.asyncio
    async def test_invalid_modifiers_json_in_record_falls_back(self) -> None:
        record = AttitudeRecord(
            affection=50, trust=50, mood="neutral", modifiers_json="BROKEN",
        )
        mgr, _, _ = make_manager(record=record)

        att = await mgr.load("char1", "user1")

        assert att.attitude_modifiers == []

    @pytest.mark.asyncio
    async def test_none_modifiers_json_in_record_falls_back(self) -> None:
        record = AttitudeRecord(
            affection=50, trust=50, mood="neutral", modifiers_json=None,  # type: ignore[arg-type]
        )
        mgr, _, _ = make_manager(record=record)

        att = await mgr.load("char1", "user1")

        assert att.attitude_modifiers == []


# ---------------------------------------------------------------------------
# load — time decay
# ---------------------------------------------------------------------------

class TestLoadTimeDecay:

    @pytest.mark.asyncio
    async def test_decay_triggers_store_save(self) -> None:
        """When time decay runs and changes values, they are persisted to store."""
        record = AttitudeRecord(affection=80, trust=70, mood="happy", modifiers_json="[]")
        mgr, store, _ = make_manager(record=record)

        att = await mgr.load("char1", "user1")
        # Manually simulate what happens when apply_time_decay returns True
        # by patching the method to return True and checking store.save is called.
        # For a direct integration test, we need a real old timestamp.
        # Reset and redo with an old last_interaction_at via monkeypatching.
        # We'll test indirectly: no decay without last_interaction_at.
        store.save.assert_not_called()  # No last_interaction_at → no decay

    @pytest.mark.asyncio
    async def test_no_decay_without_last_interaction(self) -> None:
        record = AttitudeRecord(affection=80, trust=70, mood="happy", modifiers_json="[]")
        mgr, store, _ = make_manager(record=record)

        await mgr.load("char1", "user1")

        store.save.assert_not_called()

    @pytest.mark.asyncio
    async def test_decay_saves_decayed_values_to_store(self, monkeypatch) -> None:
        """If apply_time_decay returns True, store.save is called with updated values."""
        record = AttitudeRecord(affection=80, trust=70, mood="happy", modifiers_json="[]")
        mgr, store, _ = make_manager(record=record)

        # Patch apply_time_decay to simulate decay occurring
        def fake_decay(self_att):
            self_att.affection = 75
            self_att.trust = 68
            return True

        monkeypatch.setattr(AttitudeState, "apply_time_decay", fake_decay)

        att = await mgr.load("char1", "user1")

        store.save.assert_called_once()
        call_args = store.save.call_args
        assert call_args[0][0] == "user1"
        assert call_args[0][1] == "char1"
        assert call_args[1]["affection"] == 75
        assert call_args[1]["trust"] == 68

    @pytest.mark.asyncio
    async def test_no_decay_does_not_call_store_save(self, monkeypatch) -> None:
        record = AttitudeRecord(affection=50, trust=50, mood="neutral", modifiers_json="[]")
        mgr, store, _ = make_manager(record=record)

        monkeypatch.setattr(AttitudeState, "apply_time_decay", lambda self: False)

        await mgr.load("char1", "user1")

        store.save.assert_not_called()


# ---------------------------------------------------------------------------
# save
# ---------------------------------------------------------------------------

class TestSave:

    @pytest.mark.asyncio
    async def test_saves_to_store(self) -> None:
        mgr, store, _ = make_manager()
        att = AttitudeState("char1", "user1")
        att.affection = 75
        att.trust = 65
        att.mood = "happy"
        att.attitude_modifiers = []

        await mgr.save(att)

        store.save.assert_called_once_with(
            "user1", "char1",
            affection=75,
            trust=65,
            mood="happy",
            modifiers_json="[]",
        )

    @pytest.mark.asyncio
    async def test_updates_cache(self) -> None:
        mgr, _, cache = make_manager()
        att = AttitudeState("char1", "user1")
        att.affection = 75
        att.trust = 65
        att.mood = "happy"
        att.attitude_modifiers = []

        await mgr.save(att)

        cache.set_attitude.assert_called_once()
        call_args = cache.set_attitude.call_args
        assert call_args[0][0] == "user1"
        assert call_args[0][1] == "char1"
        data = call_args[0][2]
        assert data["affection"] == 75
        assert data["trust"] == 65
        assert data["mood"] == "happy"
        assert data["modifiers_json"] == "[]"

    @pytest.mark.asyncio
    async def test_modifiers_serialized_correctly(self) -> None:
        mgr, store, cache = make_manager()
        att = AttitudeState("char1", "user1")
        att.affection = 50
        att.trust = 50
        att.mood = "neutral"
        att.attitude_modifiers = [
            {"trigger": "gift", "effect": "grateful", "remaining": 2},
        ]

        await mgr.save(att)

        expected_json = json.dumps(att.attitude_modifiers, ensure_ascii=False)

        store_call = store.save.call_args
        assert store_call[1]["modifiers_json"] == expected_json

        cache_call = cache.set_attitude.call_args
        assert cache_call[0][2]["modifiers_json"] == expected_json

    @pytest.mark.asyncio
    async def test_save_calls_both_store_and_cache(self) -> None:
        mgr, store, cache = make_manager()
        att = AttitudeState("char2", "user2")

        await mgr.save(att)

        store.save.assert_called_once()
        cache.set_attitude.assert_called_once()
