"""Unit tests for TurnTimeoutService.

This module tests the turn timeout management functionality including
timer lifecycle (start, cancel, extend), warning detection, and
expiration checking.

All tests use mocked Redis to avoid external dependencies.
"""

import json
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from unittest.mock import AsyncMock

import pytest

from app.services.turn_timeout_service import (
    DEFAULT_KEY_TTL_BUFFER,
    TURN_TIMEOUT_PREFIX,
    PendingWarning,
    TurnTimeoutInfo,
    TurnTimeoutService,
)

# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def mock_redis() -> AsyncMock:
    """Create a mock Redis client with common methods."""
    redis = AsyncMock()
    redis.hset = AsyncMock()
    redis.hgetall = AsyncMock(return_value={})
    redis.delete = AsyncMock(return_value=1)
    redis.expire = AsyncMock()
    redis.exists = AsyncMock(return_value=0)

    # Mock scan_iter to return an empty async iterator by default
    async def empty_scan_iter(match=None):
        return
        yield  # Make this an async generator

    redis.scan_iter = empty_scan_iter
    return redis


@pytest.fixture
def turn_timeout_service(mock_redis: AsyncMock) -> TurnTimeoutService:
    """Create a TurnTimeoutService with injected mock Redis."""

    @asynccontextmanager
    async def mock_redis_factory():
        yield mock_redis

    return TurnTimeoutService(redis_factory=mock_redis_factory)


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


class TestStartTurnTimer:
    """Tests for TurnTimeoutService.start_turn_timer."""

    @pytest.mark.asyncio
    async def test_start_turn_timer_creates_redis_entry(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that starting a timer creates the correct Redis hash entry.

        Verifies that all required fields are stored: player_id, deadline,
        timeout_seconds, warnings_sent, and warning_thresholds.
        """
        await turn_timeout_service.start_turn_timer(
            game_id="game-123",
            player_id="player-1",
            timeout_seconds=180,
            warning_thresholds=[50, 25],
        )

        # Verify Redis hset was called with correct data
        mock_redis.hset.assert_called_once()
        call_args = mock_redis.hset.call_args
        assert call_args[0][0] == f"{TURN_TIMEOUT_PREFIX}game-123"

        mapping = call_args[1]["mapping"]
        assert mapping["player_id"] == "player-1"
        assert mapping["timeout_seconds"] == "180"
        assert mapping["warnings_sent"] == "[]"
        assert json.loads(mapping["warning_thresholds"]) == [50, 25]

        # Verify deadline is approximately correct (within 2 seconds)
        deadline = float(mapping["deadline"])
        expected = datetime.now(UTC).timestamp() + 180
        assert abs(deadline - expected) < 2

    @pytest.mark.asyncio
    async def test_start_turn_timer_returns_info(
        self, turn_timeout_service: TurnTimeoutService
    ) -> None:
        """
        Test that starting a timer returns correct TurnTimeoutInfo.

        The returned info should have all fields populated correctly
        with remaining_seconds equal to timeout_seconds (just started).
        """
        result = await turn_timeout_service.start_turn_timer(
            game_id="game-123",
            player_id="player-1",
            timeout_seconds=180,
        )

        assert isinstance(result, TurnTimeoutInfo)
        assert result.game_id == "game-123"
        assert result.player_id == "player-1"
        assert result.timeout_seconds == 180
        assert result.remaining_seconds == 180
        assert result.warnings_sent == []
        assert not result.is_expired

    @pytest.mark.asyncio
    async def test_start_turn_timer_default_thresholds(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that default warning thresholds [50, 25] are used when not specified.

        This ensures games always have reasonable warning points even if
        the caller doesn't specify custom thresholds.
        """
        await turn_timeout_service.start_turn_timer(
            game_id="game-123",
            player_id="player-1",
            timeout_seconds=180,
        )

        mapping = mock_redis.hset.call_args[1]["mapping"]
        thresholds = json.loads(mapping["warning_thresholds"])
        assert thresholds == [50, 25]

    @pytest.mark.asyncio
    async def test_start_turn_timer_sorts_thresholds_descending(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that warning thresholds are sorted in descending order.

        Warnings should be processed from highest to lowest percentage
        so players get warned at 50% before 25%, regardless of input order.
        """
        await turn_timeout_service.start_turn_timer(
            game_id="game-123",
            player_id="player-1",
            timeout_seconds=180,
            warning_thresholds=[25, 75, 50],  # Unsorted input
        )

        mapping = mock_redis.hset.call_args[1]["mapping"]
        thresholds = json.loads(mapping["warning_thresholds"])
        assert thresholds == [75, 50, 25]  # Should be sorted descending

    @pytest.mark.asyncio
    async def test_start_turn_timer_sets_ttl(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that the Redis key TTL is set beyond the timeout duration.

        The TTL includes a buffer to ensure the key persists long enough
        for the expiration to be detected and handled.
        """
        await turn_timeout_service.start_turn_timer(
            game_id="game-123",
            player_id="player-1",
            timeout_seconds=180,
        )

        mock_redis.expire.assert_called_once()
        call_args = mock_redis.expire.call_args
        assert call_args[0][0] == f"{TURN_TIMEOUT_PREFIX}game-123"
        assert call_args[0][1] == 180 + DEFAULT_KEY_TTL_BUFFER


class TestCancelTimer:
    """Tests for TurnTimeoutService.cancel_timer."""

    @pytest.mark.asyncio
    async def test_cancel_timer_deletes_redis_key(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that canceling a timer deletes the Redis key.

        When a turn ends normally, the timer should be cleaned up
        to prevent false expiration detection.
        """
        result = await turn_timeout_service.cancel_timer("game-123")

        mock_redis.delete.assert_called_once_with(f"{TURN_TIMEOUT_PREFIX}game-123")
        assert result is True

    @pytest.mark.asyncio
    async def test_cancel_timer_returns_false_when_not_found(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that canceling a non-existent timer returns False.

        This allows callers to know if there was actually a timer
        to cancel, useful for debugging and logging.
        """
        mock_redis.delete = AsyncMock(return_value=0)

        result = await turn_timeout_service.cancel_timer("nonexistent-game")

        assert result is False


class TestExtendTimer:
    """Tests for TurnTimeoutService.extend_timer."""

    @pytest.mark.asyncio
    async def test_extend_timer_increases_deadline(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that extending a timer increases the deadline.

        When a player reconnects, they should get additional time
        to complete their turn.
        """
        now = datetime.now(UTC).timestamp()
        original_deadline = now + 60  # 60 seconds remaining

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(original_deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.extend_timer("game-123", extension_seconds=15)

        assert result is not None
        # Deadline should be extended by 15 seconds
        assert result.deadline > original_deadline
        assert result.remaining_seconds >= 60  # At least original remaining

    @pytest.mark.asyncio
    async def test_extend_timer_capped_at_original_timeout(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that timer extension is capped at the original timeout.

        Players shouldn't be able to get more time than the original
        timeout by repeatedly reconnecting.
        """
        now = datetime.now(UTC).timestamp()
        original_deadline = now + 170  # 170 seconds remaining (almost full)

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(original_deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.extend_timer("game-123", extension_seconds=30)

        assert result is not None
        # Should be capped at original timeout (180s from now)
        max_deadline = now + 180
        assert result.deadline <= max_deadline + 1  # Allow 1s tolerance

    @pytest.mark.asyncio
    async def test_extend_timer_returns_none_when_not_found(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that extending a non-existent timer returns None.

        This handles the case where the game ended or the timer
        was already canceled.
        """
        mock_redis.hgetall = AsyncMock(return_value={})

        result = await turn_timeout_service.extend_timer("nonexistent-game", extension_seconds=15)

        assert result is None

    @pytest.mark.asyncio
    async def test_extend_timer_preserves_warnings_sent(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that extending a timer preserves the warnings_sent state.

        If a 50% warning was already sent, extending shouldn't cause
        it to be sent again.
        """
        now = datetime.now(UTC).timestamp()

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(now + 30),  # 30 seconds remaining
                "timeout_seconds": "180",
                "warnings_sent": "[50]",  # 50% warning already sent
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.extend_timer("game-123", extension_seconds=15)

        assert result is not None
        assert 50 in result.warnings_sent


# =============================================================================
# Query Tests
# =============================================================================


class TestGetTimeoutInfo:
    """Tests for TurnTimeoutService.get_timeout_info."""

    @pytest.mark.asyncio
    async def test_get_timeout_info_returns_correct_data(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that get_timeout_info returns all stored data correctly.

        This is the primary method for getting timer state and must
        return accurate information for all fields.
        """
        now = datetime.now(UTC).timestamp()
        deadline = now + 120  # 120 seconds remaining

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[50]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.get_timeout_info("game-123")

        assert result is not None
        assert result.game_id == "game-123"
        assert result.player_id == "player-1"
        assert result.timeout_seconds == 180
        assert result.warnings_sent == [50]
        assert result.warning_thresholds == [50, 25]
        # Allow small variance due to test execution time
        assert 118 <= result.remaining_seconds <= 122

    @pytest.mark.asyncio
    async def test_get_timeout_info_returns_none_when_not_found(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that get_timeout_info returns None for non-existent timers.

        This allows callers to check if a game has an active timer.
        """
        mock_redis.hgetall = AsyncMock(return_value={})

        result = await turn_timeout_service.get_timeout_info("nonexistent-game")

        assert result is None

    @pytest.mark.asyncio
    async def test_get_timeout_info_handles_corrupted_data(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that corrupted Redis data is handled gracefully.

        If a key exists but has missing or invalid fields, we should
        return None rather than crashing.
        """
        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                # Missing deadline and timeout_seconds
            }
        )

        result = await turn_timeout_service.get_timeout_info("game-123")

        assert result is None

    @pytest.mark.asyncio
    async def test_get_timeout_info_expired_timer(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that expired timers are correctly identified.

        The is_expired property should be True when the deadline
        has passed.
        """
        now = datetime.now(UTC).timestamp()
        past_deadline = now - 10  # 10 seconds ago

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(past_deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.get_timeout_info("game-123")

        assert result is not None
        assert result.is_expired
        assert result.remaining_seconds == 0


class TestGetRemainingTime:
    """Tests for TurnTimeoutService.get_remaining_time."""

    @pytest.mark.asyncio
    async def test_get_remaining_time_returns_seconds(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that get_remaining_time returns the correct seconds.

        This convenience method should return just the remaining
        time without requiring full TurnTimeoutInfo parsing.
        """
        now = datetime.now(UTC).timestamp()
        deadline = now + 90

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.get_remaining_time("game-123")

        assert result is not None
        assert 88 <= result <= 92  # Allow small variance

    @pytest.mark.asyncio
    async def test_get_remaining_time_returns_none_when_not_found(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that get_remaining_time returns None for non-existent timers.
        """
        mock_redis.hgetall = AsyncMock(return_value={})

        result = await turn_timeout_service.get_remaining_time("nonexistent-game")

        assert result is None


# =============================================================================
# Warning Tests
# =============================================================================


class TestGetPendingWarning:
    """Tests for TurnTimeoutService.get_pending_warning."""

    @pytest.mark.asyncio
    async def test_get_pending_warning_at_50_percent(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that a warning is returned when time drops below 50%.

        With 180s timeout, 50% = 90s. If remaining is 85s (47%),
        we should get a warning for the 50% threshold.
        """
        now = datetime.now(UTC).timestamp()
        deadline = now + 85  # 85s remaining out of 180s = 47%

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.get_pending_warning("game-123")

        assert result is not None
        assert isinstance(result, PendingWarning)
        assert result.threshold == 50
        assert result.player_id == "player-1"

    @pytest.mark.asyncio
    async def test_get_pending_warning_skips_already_sent(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that warnings already sent are not returned again.

        If 50% warning was sent, and time is now at 20% (below 25%),
        we should get the 25% warning, not the 50% warning again.
        """
        now = datetime.now(UTC).timestamp()
        deadline = now + 36  # 36s remaining out of 180s = 20%

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[50]",  # 50% already sent
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.get_pending_warning("game-123")

        assert result is not None
        assert result.threshold == 25  # Should be 25%, not 50%

    @pytest.mark.asyncio
    async def test_get_pending_warning_none_when_above_threshold(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that no warning is returned when time is above all thresholds.

        If 60% of time remains and thresholds are [50, 25], no warning
        should be pending.
        """
        now = datetime.now(UTC).timestamp()
        deadline = now + 108  # 108s remaining out of 180s = 60%

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.get_pending_warning("game-123")

        assert result is None

    @pytest.mark.asyncio
    async def test_get_pending_warning_none_when_all_sent(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that no warning is returned when all warnings have been sent.

        Even if time is very low, if all warnings were already sent,
        there's nothing pending.
        """
        now = datetime.now(UTC).timestamp()
        deadline = now + 18  # 18s remaining out of 180s = 10%

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[50, 25]",  # All sent
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.get_pending_warning("game-123")

        assert result is None

    @pytest.mark.asyncio
    async def test_get_pending_warning_none_when_expired(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that no warning is returned for expired timers.

        Once a timer expires, warnings are no longer relevant - the
        timeout handler takes over.
        """
        now = datetime.now(UTC).timestamp()
        past_deadline = now - 5  # Expired 5 seconds ago

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(past_deadline),
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.get_pending_warning("game-123")

        assert result is None


class TestMarkWarningSent:
    """Tests for TurnTimeoutService.mark_warning_sent."""

    @pytest.mark.asyncio
    async def test_mark_warning_sent_updates_redis(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that marking a warning updates the warnings_sent list.

        After sending a warning, it should be added to the list
        to prevent duplicate sends.
        """
        now = datetime.now(UTC).timestamp()

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(now + 85),
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.mark_warning_sent("game-123", 50)

        assert result is True
        # Check that hset was called with updated warnings_sent
        call_args = mock_redis.hset.call_args
        assert call_args[0][0] == f"{TURN_TIMEOUT_PREFIX}game-123"
        assert call_args[0][1] == "warnings_sent"
        assert 50 in json.loads(call_args[0][2])

    @pytest.mark.asyncio
    async def test_mark_warning_sent_returns_false_when_not_found(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that marking a warning returns False if timer doesn't exist.
        """
        mock_redis.hgetall = AsyncMock(return_value={})

        result = await turn_timeout_service.mark_warning_sent("nonexistent-game", 50)

        assert result is False

    @pytest.mark.asyncio
    async def test_mark_warning_sent_idempotent(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that marking the same warning twice is idempotent.

        Re-marking shouldn't cause errors or duplicate entries.
        """
        now = datetime.now(UTC).timestamp()

        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(now + 85),
                "timeout_seconds": "180",
                "warnings_sent": "[50]",  # Already marked
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.mark_warning_sent("game-123", 50)

        assert result is True
        # hset should not be called since it's already marked
        mock_redis.hset.assert_not_called()


# =============================================================================
# Expiration Tests
# =============================================================================


class TestCheckExpiredTimers:
    """Tests for TurnTimeoutService.check_expired_timers."""

    @pytest.mark.asyncio
    async def test_check_expired_timers_returns_expired(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that check_expired_timers returns expired timers.

        This is the main polling method that background tasks use
        to detect timeouts.
        """
        now = datetime.now(UTC).timestamp()

        # Mock scan_iter to return one game
        async def mock_scan_iter(match=None):
            yield f"{TURN_TIMEOUT_PREFIX}game-123"

        mock_redis.scan_iter = mock_scan_iter

        # Mock hgetall to return expired timer
        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(now - 10),  # Expired 10 seconds ago
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.check_expired_timers()

        assert len(result) == 1
        assert result[0].game_id == "game-123"
        assert result[0].is_expired

    @pytest.mark.asyncio
    async def test_check_expired_timers_excludes_active(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that check_expired_timers excludes active (non-expired) timers.

        Only expired timers should be returned; active games should
        continue uninterrupted.
        """
        now = datetime.now(UTC).timestamp()

        async def mock_scan_iter(match=None):
            yield f"{TURN_TIMEOUT_PREFIX}game-123"

        mock_redis.scan_iter = mock_scan_iter

        # Mock hgetall to return active timer
        mock_redis.hgetall = AsyncMock(
            return_value={
                "player_id": "player-1",
                "deadline": str(now + 60),  # 60 seconds remaining
                "timeout_seconds": "180",
                "warnings_sent": "[]",
                "warning_thresholds": "[50, 25]",
            }
        )

        result = await turn_timeout_service.check_expired_timers()

        assert len(result) == 0

    @pytest.mark.asyncio
    async def test_check_expired_timers_empty_when_no_timers(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that check_expired_timers returns empty list when no timers exist.
        """

        async def mock_scan_iter(match=None):
            return
            yield  # Empty async generator

        mock_redis.scan_iter = mock_scan_iter

        result = await turn_timeout_service.check_expired_timers()

        assert result == []


# =============================================================================
# TurnTimeoutInfo Tests
# =============================================================================


class TestTurnTimeoutInfo:
    """Tests for TurnTimeoutInfo dataclass properties."""

    def test_is_expired_true_when_remaining_zero(self) -> None:
        """
        Test that is_expired returns True when remaining_seconds is 0.
        """
        info = TurnTimeoutInfo(
            game_id="game-123",
            player_id="player-1",
            deadline=0,
            timeout_seconds=180,
            remaining_seconds=0,
            warnings_sent=[],
            warning_thresholds=[50, 25],
        )

        assert info.is_expired is True

    def test_is_expired_false_when_time_remaining(self) -> None:
        """
        Test that is_expired returns False when time remains.
        """
        info = TurnTimeoutInfo(
            game_id="game-123",
            player_id="player-1",
            deadline=0,
            timeout_seconds=180,
            remaining_seconds=60,
            warnings_sent=[],
            warning_thresholds=[50, 25],
        )

        assert info.is_expired is False

    def test_percent_remaining_calculation(self) -> None:
        """
        Test that percent_remaining calculates correctly.

        90 seconds remaining out of 180 should be 50%.
        """
        info = TurnTimeoutInfo(
            game_id="game-123",
            player_id="player-1",
            deadline=0,
            timeout_seconds=180,
            remaining_seconds=90,
            warnings_sent=[],
            warning_thresholds=[50, 25],
        )

        assert info.percent_remaining == 50.0

    def test_percent_remaining_zero_timeout(self) -> None:
        """
        Test that percent_remaining handles zero timeout gracefully.

        Prevents division by zero errors.
        """
        info = TurnTimeoutInfo(
            game_id="game-123",
            player_id="player-1",
            deadline=0,
            timeout_seconds=0,
            remaining_seconds=0,
            warnings_sent=[],
            warning_thresholds=[50, 25],
        )

        assert info.percent_remaining == 0.0


# =============================================================================
# Utility Method Tests
# =============================================================================


class TestUtilityMethods:
    """Tests for utility methods."""

    @pytest.mark.asyncio
    async def test_has_active_timer_true(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that has_active_timer returns True when timer exists.
        """
        mock_redis.exists = AsyncMock(return_value=1)

        result = await turn_timeout_service.has_active_timer("game-123")

        assert result is True

    @pytest.mark.asyncio
    async def test_has_active_timer_false(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that has_active_timer returns False when no timer exists.
        """
        mock_redis.exists = AsyncMock(return_value=0)

        result = await turn_timeout_service.has_active_timer("nonexistent-game")

        assert result is False

    @pytest.mark.asyncio
    async def test_get_active_timer_count(
        self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
    ) -> None:
        """
        Test that get_active_timer_count returns correct count.

        Useful for monitoring active games with turn timers.
        """

        async def mock_scan_iter(match=None):
            yield f"{TURN_TIMEOUT_PREFIX}game-1"
            yield f"{TURN_TIMEOUT_PREFIX}game-2"
            yield f"{TURN_TIMEOUT_PREFIX}game-3"

        mock_redis.scan_iter = mock_scan_iter

        result = await turn_timeout_service.get_active_timer_count()

        assert result == 3
