"""Tests for GameStateManager service.

This module tests the Redis-primary, Postgres-backup game state management:
- Cache operations (save/load/delete from Redis)
- Database operations (persist/load/delete from Postgres)
- High-level operations (load with fallback, recovery)

Prerequisites:
    docker compose up -d  # Postgres on 5433, Redis on 6380
"""

import json
from typing import Any
from uuid import uuid4

import pytest
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.enums import TurnPhase
from app.core.models.game_state import GameState
from app.db.models import GameType
from app.db.redis import RedisHelper
from app.services.game_state_manager import (
    GAME_KEY_PREFIX,
    GameStateManager,
)
from tests.factories import UserFactory
from tests.services.conftest import create_test_game_state

# =============================================================================
# Mock Redis Helper for Unit Tests
# =============================================================================


class MockRedisHelper(RedisHelper):
    """In-memory mock Redis for unit testing without real Redis."""

    def __init__(self) -> None:
        self.store: dict[str, str] = {}
        self.ttls: dict[str, int | None] = {}

    async def get_json(self, key: str) -> dict[str, Any] | None:
        """Get JSON from mock store."""
        value = self.store.get(key)
        if value is None:
            return None
        return json.loads(value)

    async def set_json(
        self,
        key: str,
        value: dict[str, Any],
        expire_seconds: int | None = None,
    ) -> None:
        """Store JSON in mock store."""
        self.store[key] = json.dumps(value, default=str)
        self.ttls[key] = expire_seconds

    async def delete(self, key: str) -> bool:
        """Delete from mock store."""
        if key in self.store:
            del self.store[key]
            self.ttls.pop(key, None)
            return True
        return False

    async def exists(self, key: str) -> bool:
        """Check if key exists in mock store."""
        return key in self.store


# =============================================================================
# Cache Operation Tests
# =============================================================================


class TestCacheOperations:
    """Tests for Redis cache operations."""

    @pytest.mark.asyncio
    async def test_save_to_cache(self, redis_client: Any) -> None:
        """Test saving game state to Redis cache.

        Verifies that GameState is serialized to JSON and stored with
        the correct key format.
        """
        # Create a mock helper that uses our test redis client
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)
        game = create_test_game_state()

        await manager.save_to_cache(game)

        # Verify key exists
        key = f"{GAME_KEY_PREFIX}{game.game_id}"
        assert key in helper.store

        # Verify data is valid JSON that can be deserialized
        data = json.loads(helper.store[key])
        assert data["game_id"] == game.game_id
        assert data["turn_number"] == game.turn_number

    @pytest.mark.asyncio
    async def test_load_from_cache_hit(self) -> None:
        """Test loading game state from cache when it exists.

        Verifies cache hit returns the correct GameState.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)
        game = create_test_game_state(turn_number=5)

        # Save first
        await manager.save_to_cache(game)

        # Load and verify
        loaded = await manager.load_from_cache(game.game_id)
        assert loaded is not None
        assert loaded.game_id == game.game_id
        assert loaded.turn_number == 5

    @pytest.mark.asyncio
    async def test_load_from_cache_miss(self) -> None:
        """Test loading game state from cache when it doesn't exist.

        Verifies cache miss returns None.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        loaded = await manager.load_from_cache("nonexistent-game")
        assert loaded is None

    @pytest.mark.asyncio
    async def test_delete_from_cache(self) -> None:
        """Test deleting game state from cache.

        Verifies game is removed and subsequent loads return None.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)
        game = create_test_game_state()

        await manager.save_to_cache(game)
        assert await manager.cache_exists(game.game_id)

        deleted = await manager.delete_from_cache(game.game_id)
        assert deleted is True
        assert not await manager.cache_exists(game.game_id)

    @pytest.mark.asyncio
    async def test_delete_from_cache_not_found(self) -> None:
        """Test deleting game that doesn't exist in cache.

        Verifies returns False when game not found.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        deleted = await manager.delete_from_cache("nonexistent-game")
        assert deleted is False

    @pytest.mark.asyncio
    async def test_cache_exists(self) -> None:
        """Test checking if game exists in cache.

        Verifies exists returns True for cached games, False otherwise.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)
        game = create_test_game_state()

        assert not await manager.cache_exists(game.game_id)

        await manager.save_to_cache(game)
        assert await manager.cache_exists(game.game_id)


# =============================================================================
# Database Operation Tests
# =============================================================================


class TestDatabaseOperations:
    """Tests for Postgres database operations."""

    @pytest.mark.asyncio
    async def test_persist_to_db_creates_new(self, db_session: AsyncSession) -> None:
        """Test persisting game state creates new ActiveGame record.

        Verifies a new game is properly stored in Postgres.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        # Create user for FK
        user = await UserFactory.create(db_session)
        game = create_test_game_state()

        # Persist to database
        active_game = await manager.persist_to_db(
            game,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            npc_id="test_npc",
            session=db_session,
        )

        assert active_game.id == game.game_id
        assert active_game.game_type == GameType.CAMPAIGN
        assert active_game.player1_id == user.id
        assert active_game.npc_id == "test_npc"
        assert active_game.turn_number == game.turn_number

    @pytest.mark.asyncio
    async def test_persist_to_db_updates_existing(self, db_session: AsyncSession) -> None:
        """Test persisting game state updates existing ActiveGame record.

        Verifies an existing game is updated with new state.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user = await UserFactory.create(db_session)
        game = create_test_game_state(turn_number=1)

        # Create initial record
        await manager.persist_to_db(
            game,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )

        # Update game state
        game = GameState.model_validate(
            {**game.model_dump(), "turn_number": 10, "phase": TurnPhase.ATTACK.value}
        )

        # Persist again
        active_game = await manager.persist_to_db(
            game,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )

        # Verify update
        assert active_game.turn_number == 10

    @pytest.mark.asyncio
    async def test_load_from_db_found(self, db_session: AsyncSession) -> None:
        """Test loading game state from database when it exists.

        Verifies game state is correctly deserialized from Postgres.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user = await UserFactory.create(db_session)
        game = create_test_game_state(turn_number=7)

        # Persist first
        await manager.persist_to_db(
            game,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )

        # Load and verify
        loaded = await manager.load_from_db(game.game_id, session=db_session)
        assert loaded is not None
        assert loaded.game_id == game.game_id
        assert loaded.turn_number == 7

    @pytest.mark.asyncio
    async def test_load_from_db_not_found(self, db_session: AsyncSession) -> None:
        """Test loading game state from database when it doesn't exist.

        Verifies returns None for missing games.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        loaded = await manager.load_from_db(str(uuid4()), session=db_session)
        assert loaded is None

    @pytest.mark.asyncio
    async def test_delete_from_db(self, db_session: AsyncSession) -> None:
        """Test deleting game from database.

        Verifies game is removed from ActiveGame table.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user = await UserFactory.create(db_session)
        game = create_test_game_state()

        # Create game
        await manager.persist_to_db(
            game,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )

        # Delete it
        deleted = await manager.delete_from_db(game.game_id, session=db_session)
        assert deleted is True

        # Verify gone
        loaded = await manager.load_from_db(game.game_id, session=db_session)
        assert loaded is None

    @pytest.mark.asyncio
    async def test_delete_from_db_not_found(self, db_session: AsyncSession) -> None:
        """Test deleting game that doesn't exist in database.

        Verifies returns False for missing games.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        deleted = await manager.delete_from_db(str(uuid4()), session=db_session)
        assert deleted is False


# =============================================================================
# High-Level Operation Tests
# =============================================================================


class TestHighLevelOperations:
    """Tests for high-level game state operations."""

    @pytest.mark.asyncio
    async def test_load_state_cache_hit(self) -> None:
        """Test load_state returns cached data when available.

        Verifies the fast path uses Redis when game is cached.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)
        game = create_test_game_state()

        # Cache the game
        await manager.save_to_cache(game)

        # Load should use cache (fast path)
        loaded = await manager.load_state(game.game_id)
        assert loaded is not None
        assert loaded.game_id == game.game_id

    @pytest.mark.asyncio
    async def test_load_state_cache_miss_db_hit(self, db_session: AsyncSession) -> None:
        """Test load_state falls back to database on cache miss.

        Verifies database fallback and cache repopulation.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user = await UserFactory.create(db_session)
        game = create_test_game_state()

        # Only in database, not in cache
        await manager.persist_to_db(
            game,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )

        # Load should fallback to DB (pass session for test)
        loaded = await manager.load_state(game.game_id, session=db_session)
        assert loaded is not None
        assert loaded.game_id == game.game_id

        # Should now be in cache
        assert await manager.cache_exists(game.game_id)

    @pytest.mark.asyncio
    async def test_load_state_not_found(self, db_session: AsyncSession) -> None:
        """Test load_state returns None when game doesn't exist anywhere.

        Verifies proper handling of missing games.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        loaded = await manager.load_state(str(uuid4()), session=db_session)
        assert loaded is None

    @pytest.mark.asyncio
    async def test_delete_game(self, db_session: AsyncSession) -> None:
        """Test delete_game removes from both cache and database.

        Verifies complete cleanup of game state.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user = await UserFactory.create(db_session)
        game = create_test_game_state()

        # Create in both cache and DB
        await manager.save_to_cache(game)
        await manager.persist_to_db(
            game,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )

        # Delete completely (pass session)
        deleted = await manager.delete_game(game.game_id, session=db_session)
        assert deleted is True

        # Verify gone from both
        assert not await manager.cache_exists(game.game_id)
        loaded = await manager.load_from_db(game.game_id, session=db_session)
        assert loaded is None

    @pytest.mark.asyncio
    async def test_recover_active_games(self, db_session: AsyncSession) -> None:
        """Test recovering active games from database to cache on startup.

        Verifies all games are loaded from Postgres and cached in Redis.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user = await UserFactory.create(db_session)

        # Create multiple games in DB only
        game1 = create_test_game_state()
        game2 = create_test_game_state()

        await manager.persist_to_db(
            game1,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )
        await manager.persist_to_db(
            game2,
            game_type=GameType.FREEPLAY,
            player1_id=user.id,
            session=db_session,
        )

        # Clear cache
        helper.store.clear()

        # Recover (pass session)
        result = await manager.recover_active_games(session=db_session)
        assert result.recovered == 2
        assert result.total == 2
        assert len(result.failed) == 0

        # Both should be in cache now
        assert await manager.cache_exists(game1.game_id)
        assert await manager.cache_exists(game2.game_id)

    @pytest.mark.asyncio
    async def test_get_player_active_games(self, db_session: AsyncSession) -> None:
        """Test getting all active games for a player.

        Verifies correct games are returned for a specific player.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user1 = await UserFactory.create(db_session)
        user2 = await UserFactory.create(db_session)

        # Game where user1 is player1
        game1 = create_test_game_state()
        await manager.persist_to_db(
            game1,
            game_type=GameType.CAMPAIGN,
            player1_id=user1.id,
            session=db_session,
        )

        # Game where user1 is player2
        game2 = create_test_game_state()
        await manager.persist_to_db(
            game2,
            game_type=GameType.FREEPLAY,
            player1_id=user2.id,
            player2_id=user1.id,
            session=db_session,
        )

        # Game for different user
        game3 = create_test_game_state()
        await manager.persist_to_db(
            game3,
            game_type=GameType.CAMPAIGN,
            player1_id=user2.id,
            session=db_session,
        )

        # Get user1's games
        games = await manager.get_player_active_games(user1.id, session=db_session)
        game_ids = {str(g.id) for g in games}

        assert game1.game_id in game_ids
        assert game2.game_id in game_ids
        assert game3.game_id not in game_ids


# =============================================================================
# Integration Tests with Real Redis
# =============================================================================


class TestRealRedisIntegration:
    """Integration tests using real Redis (from testcontainers)."""

    @pytest.mark.asyncio
    async def test_save_and_load_with_real_redis(self, redis_url: str) -> None:
        """Test full save/load cycle with real Redis client.

        Verifies the complete flow works with actual Redis.
        Creates Redis client inside test to avoid event loop issues.
        """
        import redis.asyncio as aioredis

        # Create client inside test (same event loop)
        client = aioredis.from_url(redis_url, decode_responses=True)

        try:
            # Clear test database
            await client.flushdb()

            # Create a helper that uses the test client
            class TestRedisHelper(RedisHelper):
                def __init__(self, redis_client: Any) -> None:
                    self._client = redis_client

                async def get_json(self, key: str) -> dict | None:
                    value = await self._client.get(key)
                    if value is None:
                        return None
                    return json.loads(value)

                async def set_json(
                    self, key: str, value: dict, expire_seconds: int | None = None
                ) -> None:
                    json_str = json.dumps(value, default=str)
                    if expire_seconds:
                        await self._client.setex(key, expire_seconds, json_str)
                    else:
                        await self._client.set(key, json_str)

                async def delete(self, key: str) -> bool:
                    result = await self._client.delete(key)
                    return result > 0

                async def exists(self, key: str) -> bool:
                    result = await self._client.exists(key)
                    return result > 0

            helper = TestRedisHelper(client)
            manager = GameStateManager(redis=helper)

            game = create_test_game_state(turn_number=42)

            # Save to real Redis
            await manager.save_to_cache(game)

            # Load from real Redis
            loaded = await manager.load_from_cache(game.game_id)

            assert loaded is not None
            assert loaded.game_id == game.game_id
            assert loaded.turn_number == 42

            # Clean up
            await manager.delete_from_cache(game.game_id)
            assert not await manager.cache_exists(game.game_id)

        finally:
            await client.flushdb()
            await client.aclose()


# =============================================================================
# Additional Coverage Tests
# =============================================================================


class TestAdditionalCoverage:
    """Tests to fill coverage gaps identified in review."""

    @pytest.mark.asyncio
    async def test_get_active_game_count(self, db_session: AsyncSession) -> None:
        """Test counting active games in database.

        Verifies the monitoring/admin dashboard helper works correctly.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user = await UserFactory.create(db_session)

        # Initially zero
        count = await manager.get_active_game_count(session=db_session)
        assert count == 0

        # Create some games
        game1 = create_test_game_state()
        game2 = create_test_game_state()
        await manager.persist_to_db(
            game1,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )
        await manager.persist_to_db(
            game2,
            game_type=GameType.FREEPLAY,
            player1_id=user.id,
            session=db_session,
        )

        # Now should be 2
        count = await manager.get_active_game_count(session=db_session)
        assert count == 2

    @pytest.mark.asyncio
    async def test_recover_active_games_with_invalid_state(self, db_session: AsyncSession) -> None:
        """Test recovery handles corrupted game state gracefully.

        Verifies the exception handler in recover_active_games() logs errors
        but continues recovering other games.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        user = await UserFactory.create(db_session)

        # Create a valid game
        valid_game = create_test_game_state()
        await manager.persist_to_db(
            valid_game,
            game_type=GameType.CAMPAIGN,
            player1_id=user.id,
            session=db_session,
        )

        # Manually insert a game with invalid/corrupted state
        from sqlalchemy import text

        invalid_game_id = str(uuid4())
        await db_session.execute(
            text("""
                INSERT INTO active_games
                (id, game_type, player1_id, rules_config, game_state, turn_number,
                 started_at, last_action_at, created_at, updated_at)
                VALUES
                (:id, 'CAMPAIGN', :player_id, '{}', '{"invalid": "not a valid game state"}',
                 1, NOW(), NOW(), NOW(), NOW())
            """),
            {"id": invalid_game_id, "player_id": str(user.id)},
        )
        await db_session.flush()

        # Clear cache
        helper.store.clear()

        # Recovery should handle the error and still recover the valid game
        result = await manager.recover_active_games(session=db_session)

        # Only the valid game should be recovered (invalid one fails validation)
        assert result.recovered == 1
        assert result.total == 2
        assert len(result.failed) == 1
        # Verify the failed game ID is reported
        failed_ids = [gid for gid, _ in result.failed]
        assert invalid_game_id in failed_ids
        assert await manager.cache_exists(valid_game.game_id)
        assert not await manager.cache_exists(invalid_game_id)

    @pytest.mark.asyncio
    async def test_delete_game_not_found_anywhere(self, db_session: AsyncSession) -> None:
        """Test delete_game when game doesn't exist in cache or DB.

        Verifies the warning log path and returns False.
        """
        helper = MockRedisHelper()
        manager = GameStateManager(redis=helper)

        # Try to delete a game that doesn't exist anywhere
        nonexistent_id = str(uuid4())
        deleted = await manager.delete_game(nonexistent_id, session=db_session)

        # Should return False (nothing was deleted)
        assert deleted is False
