"""Turn timeout management for Mantimon TCG.

This module manages turn time limits using a polling-based approach with
Redis for state storage. It handles:
- Starting and canceling turn timers
- Checking for expired timers
- Sending percentage-based warnings (e.g., 50%, 25% remaining)
- Granting grace periods on reconnection

Key Patterns:
    turn_timeout:{game_id} - Hash with timeout data:
        - player_id: The player whose turn it is
        - deadline: Unix timestamp when the turn expires
        - timeout_seconds: Original timeout duration (for % calculation)
        - warnings_sent: JSON array of thresholds already sent

Design Decisions:
    - Polling approach (not keyspace notifications) for simplicity and
      reliability. A background task polls check_expired_timers() periodically.
    - Warnings are percentage-based (configurable, default 50% and 25%)
      so they scale with different timeout durations.
    - Grace period on reconnect extends the deadline without resetting
      the warning state.

Example:
    from app.services.turn_timeout_service import turn_timeout_service

    # Start a timer when a turn begins
    await turn_timeout_service.start_turn_timer(
        game_id="game-123",
        player_id="player-1",
        timeout_seconds=180,
        warning_thresholds=[50, 25],
    )

    # Check for warnings to send
    warning = await turn_timeout_service.get_pending_warning("game-123")
    if warning:
        # Send warning to player
        await turn_timeout_service.mark_warning_sent("game-123", warning.threshold)

    # On reconnect, grant grace period
    await turn_timeout_service.extend_timer("game-123", extension_seconds=15)

    # Background task polls for expired timers
    expired = await turn_timeout_service.check_expired_timers()
    for game_id in expired:
        # Handle timeout (auto-pass or loss)
"""

import json
import logging
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from app.db.redis import get_redis

if TYPE_CHECKING:
    from redis.asyncio import Redis

logger = logging.getLogger(__name__)

# Type alias for redis factory
RedisFactory = Callable[[], AsyncIterator["Redis"]]

# Redis key patterns
TURN_TIMEOUT_PREFIX = "turn_timeout:"

# Default TTL for timeout keys (cleanup buffer beyond actual timeout)
DEFAULT_KEY_TTL_BUFFER = 300  # 5 minutes beyond deadline


@dataclass
class TurnTimeoutInfo:
    """Information about a turn timeout.

    Attributes:
        game_id: The game ID.
        player_id: The player whose turn is timing out.
        deadline: Unix timestamp when the turn expires.
        timeout_seconds: Original timeout duration.
        remaining_seconds: Seconds remaining until timeout.
        warnings_sent: List of warning thresholds already sent.
        warning_thresholds: Configured warning thresholds.
    """

    game_id: str
    player_id: str
    deadline: float
    timeout_seconds: int
    remaining_seconds: int
    warnings_sent: list[int]
    warning_thresholds: list[int]

    @property
    def is_expired(self) -> bool:
        """Check if the timeout has expired."""
        return self.remaining_seconds <= 0

    @property
    def percent_remaining(self) -> float:
        """Get the percentage of time remaining."""
        if self.timeout_seconds <= 0:
            return 0.0
        return (self.remaining_seconds / self.timeout_seconds) * 100


@dataclass
class PendingWarning:
    """Information about a warning that should be sent.

    Attributes:
        game_id: The game ID.
        player_id: The player to warn.
        threshold: The warning threshold percentage (e.g., 50, 25).
        remaining_seconds: Seconds remaining when warning triggered.
    """

    game_id: str
    player_id: str
    threshold: int
    remaining_seconds: int


class TurnTimeoutService:
    """Service for managing turn timeouts.

    Uses Redis for persistent storage of timeout state. Designed for a
    polling model where a background task periodically checks for expired
    timers and pending warnings.

    Attributes:
        _get_redis: Factory for Redis connections.
    """

    def __init__(
        self,
        redis_factory: RedisFactory | None = None,
    ) -> None:
        """Initialize the TurnTimeoutService.

        Args:
            redis_factory: Optional factory for Redis connections. If not provided,
                uses the default get_redis from app.db.redis. Useful for testing.
        """
        self._get_redis = redis_factory if redis_factory is not None else get_redis

    def _timeout_key(self, game_id: str) -> str:
        """Generate Redis key for a game's timeout data."""
        return f"{TURN_TIMEOUT_PREFIX}{game_id}"

    # =========================================================================
    # Timer Lifecycle
    # =========================================================================

    async def start_turn_timer(
        self,
        game_id: str,
        player_id: str,
        timeout_seconds: int,
        warning_thresholds: list[int] | None = None,
    ) -> TurnTimeoutInfo:
        """Start a turn timer for a game.

        Creates or replaces the timeout data for the game. Resets warnings
        since this is a new turn.

        Args:
            game_id: The game ID.
            player_id: The player whose turn is starting.
            timeout_seconds: Seconds until the turn times out.
            warning_thresholds: Percentage thresholds for warnings (e.g., [50, 25]).
                Defaults to [50, 25] if not provided.

        Returns:
            TurnTimeoutInfo with the new timer state.

        Example:
            info = await service.start_turn_timer("game-123", "player-1", 180)
            print(f"Turn expires at {info.deadline}")
        """
        if warning_thresholds is None:
            warning_thresholds = [50, 25]

        # Sort thresholds descending so we warn at highest % first
        warning_thresholds = sorted(warning_thresholds, reverse=True)

        deadline = datetime.now(UTC).timestamp() + timeout_seconds

        async with self._get_redis() as redis:
            key = self._timeout_key(game_id)
            await redis.hset(
                key,
                mapping={
                    "player_id": player_id,
                    "deadline": str(deadline),
                    "timeout_seconds": str(timeout_seconds),
                    "warnings_sent": json.dumps([]),
                    "warning_thresholds": json.dumps(warning_thresholds),
                },
            )
            # Set TTL slightly beyond deadline for auto-cleanup
            ttl = timeout_seconds + DEFAULT_KEY_TTL_BUFFER
            await redis.expire(key, ttl)

        logger.debug(
            f"Started turn timer: game={game_id}, player={player_id}, "
            f"timeout={timeout_seconds}s, thresholds={warning_thresholds}"
        )

        return TurnTimeoutInfo(
            game_id=game_id,
            player_id=player_id,
            deadline=deadline,
            timeout_seconds=timeout_seconds,
            remaining_seconds=timeout_seconds,
            warnings_sent=[],
            warning_thresholds=warning_thresholds,
        )

    async def cancel_timer(self, game_id: str) -> bool:
        """Cancel a turn timer.

        Removes the timeout data for the game. Use when a turn ends
        normally or the game ends.

        Args:
            game_id: The game ID.

        Returns:
            True if a timer was canceled, False if none existed.

        Example:
            canceled = await service.cancel_timer("game-123")
        """
        async with self._get_redis() as redis:
            key = self._timeout_key(game_id)
            deleted = await redis.delete(key)

        if deleted:
            logger.debug(f"Canceled turn timer: game={game_id}")

        return deleted > 0

    async def extend_timer(
        self,
        game_id: str,
        extension_seconds: int,
    ) -> TurnTimeoutInfo | None:
        """Extend a turn timer (e.g., on reconnection).

        Adds time to the existing deadline without resetting warnings.
        The extension is capped so the total time doesn't exceed the
        original timeout.

        Args:
            game_id: The game ID.
            extension_seconds: Seconds to add to the deadline.

        Returns:
            Updated TurnTimeoutInfo, or None if no timer exists.

        Example:
            # Grant 15 seconds grace on reconnect
            info = await service.extend_timer("game-123", 15)
        """
        info = await self.get_timeout_info(game_id)
        if info is None:
            return None

        # Calculate new deadline, capped at original timeout
        now = datetime.now(UTC).timestamp()
        new_deadline = info.deadline + extension_seconds
        max_deadline = now + info.timeout_seconds

        if new_deadline > max_deadline:
            new_deadline = max_deadline

        async with self._get_redis() as redis:
            key = self._timeout_key(game_id)
            await redis.hset(key, "deadline", str(new_deadline))
            # Refresh TTL
            remaining = int(new_deadline - now) + DEFAULT_KEY_TTL_BUFFER
            if remaining > 0:
                await redis.expire(key, remaining)

        new_remaining = max(0, int(new_deadline - now))
        logger.debug(
            f"Extended turn timer: game={game_id}, "
            f"added={extension_seconds}s, remaining={new_remaining}s"
        )

        return TurnTimeoutInfo(
            game_id=info.game_id,
            player_id=info.player_id,
            deadline=new_deadline,
            timeout_seconds=info.timeout_seconds,
            remaining_seconds=new_remaining,
            warnings_sent=info.warnings_sent,
            warning_thresholds=info.warning_thresholds,
        )

    # =========================================================================
    # Query Methods
    # =========================================================================

    async def get_timeout_info(self, game_id: str) -> TurnTimeoutInfo | None:
        """Get timeout information for a game.

        Args:
            game_id: The game ID.

        Returns:
            TurnTimeoutInfo if a timer exists, None otherwise.

        Example:
            info = await service.get_timeout_info("game-123")
            if info:
                print(f"{info.remaining_seconds}s remaining")
        """
        async with self._get_redis() as redis:
            key = self._timeout_key(game_id)
            data = await redis.hgetall(key)

        if not data:
            return None

        # Validate required fields
        player_id = data.get("player_id")
        deadline_str = data.get("deadline")
        timeout_str = data.get("timeout_seconds")

        if not player_id or not deadline_str or not timeout_str:
            logger.warning(f"Corrupted timeout data for game {game_id}")
            return None

        try:
            deadline = float(deadline_str)
            timeout_seconds = int(timeout_str)
            warnings_sent = json.loads(data.get("warnings_sent", "[]"))
            warning_thresholds = json.loads(data.get("warning_thresholds", "[50, 25]"))
        except (ValueError, json.JSONDecodeError) as e:
            logger.warning(f"Invalid timeout data for game {game_id}: {e}")
            return None

        now = datetime.now(UTC).timestamp()
        remaining = max(0, int(deadline - now))

        return TurnTimeoutInfo(
            game_id=game_id,
            player_id=player_id,
            deadline=deadline,
            timeout_seconds=timeout_seconds,
            remaining_seconds=remaining,
            warnings_sent=warnings_sent,
            warning_thresholds=warning_thresholds,
        )

    async def get_remaining_time(self, game_id: str) -> int | None:
        """Get remaining seconds for a game's turn timer.

        Convenience method that returns just the remaining time.

        Args:
            game_id: The game ID.

        Returns:
            Seconds remaining, or None if no timer exists.

        Example:
            remaining = await service.get_remaining_time("game-123")
            if remaining is not None and remaining < 30:
                # Show low time warning in UI
        """
        info = await self.get_timeout_info(game_id)
        return info.remaining_seconds if info else None

    # =========================================================================
    # Warning Management
    # =========================================================================

    async def get_pending_warning(self, game_id: str) -> PendingWarning | None:
        """Check if a warning should be sent for a game.

        Returns the highest-priority unsent warning if the time remaining
        has dropped below a warning threshold.

        Args:
            game_id: The game ID.

        Returns:
            PendingWarning if one should be sent, None otherwise.

        Example:
            warning = await service.get_pending_warning("game-123")
            if warning:
                await send_warning_to_player(warning.player_id, warning.remaining_seconds)
                await service.mark_warning_sent("game-123", warning.threshold)
        """
        info = await self.get_timeout_info(game_id)
        if info is None or info.is_expired:
            return None

        percent_remaining = info.percent_remaining

        # Check each threshold (sorted descending)
        for threshold in info.warning_thresholds:
            if threshold in info.warnings_sent:
                continue  # Already sent this warning

            if percent_remaining <= threshold:
                return PendingWarning(
                    game_id=game_id,
                    player_id=info.player_id,
                    threshold=threshold,
                    remaining_seconds=info.remaining_seconds,
                )

        return None

    async def mark_warning_sent(self, game_id: str, threshold: int) -> bool:
        """Mark a warning threshold as sent.

        Call this after successfully sending a warning to the player
        to prevent duplicate warnings.

        Args:
            game_id: The game ID.
            threshold: The warning threshold that was sent.

        Returns:
            True if marked successfully, False if timer doesn't exist.

        Example:
            await service.mark_warning_sent("game-123", 50)
        """
        info = await self.get_timeout_info(game_id)
        if info is None:
            return False

        if threshold in info.warnings_sent:
            return True  # Already marked

        warnings_sent = info.warnings_sent + [threshold]

        async with self._get_redis() as redis:
            key = self._timeout_key(game_id)
            await redis.hset(key, "warnings_sent", json.dumps(warnings_sent))

        logger.debug(f"Marked warning sent: game={game_id}, threshold={threshold}%")
        return True

    # =========================================================================
    # Expiration Checking
    # =========================================================================

    async def check_expired_timers(self) -> list[TurnTimeoutInfo]:
        """Check for expired turn timers.

        Scans all active timers and returns those that have expired.
        The caller is responsible for handling the timeouts (auto-pass,
        loss declaration, etc.).

        Returns:
            List of TurnTimeoutInfo for expired timers.

        Example:
            # In background task, poll every 5 seconds
            expired = await service.check_expired_timers()
            for info in expired:
                await handle_turn_timeout(info.game_id, info.player_id)
                await service.cancel_timer(info.game_id)
        """
        expired: list[TurnTimeoutInfo] = []

        async with self._get_redis() as redis:
            # Scan for all timeout keys
            async for key in redis.scan_iter(match=f"{TURN_TIMEOUT_PREFIX}*"):
                game_id = key[len(TURN_TIMEOUT_PREFIX) :]
                info = await self.get_timeout_info(game_id)

                if info is not None and info.is_expired:
                    expired.append(info)
                    logger.debug(f"Found expired timer: game={game_id}")

        return expired

    async def get_all_pending_warnings(self) -> list[PendingWarning]:
        """Get all pending warnings across all games.

        Scans all active timers and returns warnings that should be sent.
        Useful for a background task that handles warnings in batch.

        Returns:
            List of PendingWarning for all games needing warnings.

        Example:
            # In background task
            warnings = await service.get_all_pending_warnings()
            for warning in warnings:
                await send_warning(warning)
                await service.mark_warning_sent(warning.game_id, warning.threshold)
        """
        warnings: list[PendingWarning] = []

        async with self._get_redis() as redis:
            async for key in redis.scan_iter(match=f"{TURN_TIMEOUT_PREFIX}*"):
                game_id = key[len(TURN_TIMEOUT_PREFIX) :]
                warning = await self.get_pending_warning(game_id)
                if warning is not None:
                    warnings.append(warning)

        return warnings

    # =========================================================================
    # Utility Methods
    # =========================================================================

    async def get_active_timer_count(self) -> int:
        """Get the number of active turn timers.

        Useful for monitoring and admin dashboards.

        Returns:
            Number of active timers.
        """
        count = 0
        async with self._get_redis() as redis:
            async for _ in redis.scan_iter(match=f"{TURN_TIMEOUT_PREFIX}*"):
                count += 1
        return count

    async def has_active_timer(self, game_id: str) -> bool:
        """Check if a game has an active turn timer.

        Args:
            game_id: The game ID.

        Returns:
            True if a timer exists (even if expired), False otherwise.
        """
        async with self._get_redis() as redis:
            key = self._timeout_key(game_id)
            return await redis.exists(key) > 0


# Global singleton instance
turn_timeout_service = TurnTimeoutService()
