"""Tests for PlanService - P0 priority (quota atomicity).

Tests cover:
- Quota check and consume atomicity (TOCTOU fix)
- Plan access and expiration
- Feature availability checks
- Unlimited quota handling
- Custom limit overrides
"""

import asyncio
from datetime import date, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID

import pytest

from identity_plan_kit.auth.domain.entities import User
from identity_plan_kit.config import IdentityPlanKitConfig
from identity_plan_kit.plans.domain.entities import PeriodType, PlanLimit, UserPlan
from identity_plan_kit.plans.domain.exceptions import (
    FeatureNotAvailableError,
    PlanExpiredError,
    QuotaExceededError,
    UserPlanNotFoundError,
)
from identity_plan_kit.plans.dto.usage import UsageInfo
from identity_plan_kit.plans.repositories.usage_repo import QuotaExceededInRepoError
from identity_plan_kit.plans.services.plan_service import PlanService

pytestmark = pytest.mark.anyio


class TestGetUserPlan:
    """Test suite for get_user_plan method."""

    async def test_returns_active_plan(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
    ):
        """Active plan is returned for user."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.get_user_plan(mock_user.id)

        assert result == mock_user_plan
        assert result.plan_code == "free"
        assert not result.is_expired

    async def test_raises_error_when_no_plan(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
    ):
        """UserPlanNotFoundError raised when user has no plan."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=None)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            with pytest.raises(UserPlanNotFoundError):
                await service.get_user_plan(mock_user.id)


class TestCheckFeatureAccess:
    """Test suite for check_feature_access method."""

    async def test_returns_true_for_available_feature(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
        mock_plan_limit: PlanLimit,
    ):
        """Returns True when feature is available in plan."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_plan_limit)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.check_feature_access(mock_user.id, "api_calls")

        assert result is True

    async def test_returns_false_for_unavailable_feature(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
    ):
        """Returns False when feature is not in plan."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=None)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.check_feature_access(mock_user.id, "premium_feature")

        assert result is False

    async def test_returns_false_when_no_plan(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
    ):
        """Returns False when user has no plan."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=None)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.check_feature_access(mock_user.id, "api_calls")

        assert result is False


class TestCheckAndConsumeQuota:
    """Test suite for check_and_consume_quota - CRITICAL for TOCTOU fix validation."""

    async def test_successful_quota_consumption(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
        mock_plan_limit: PlanLimit,
    ):
        """Successful consumption returns updated usage info."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_plan_limit)
        mock_uow.usage.atomic_check_and_consume = AsyncMock(return_value=50)  # New usage

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.check_and_consume_quota(
                mock_user.id, "api_calls", amount=1
            )

        assert isinstance(result, UsageInfo)
        assert result.feature_code == "api_calls"
        assert result.used == 50
        assert result.limit == 100
        assert result.remaining == 50

    async def test_quota_exceeded_raises_error(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
        mock_plan_limit: PlanLimit,
    ):
        """QuotaExceededError raised when limit would be exceeded."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_plan_limit)
        mock_uow.usage.atomic_check_and_consume = AsyncMock(
            side_effect=QuotaExceededInRepoError(current_usage=99, limit=100, requested=2)
        )

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            with pytest.raises(QuotaExceededError) as exc_info:
                await service.check_and_consume_quota(
                    mock_user.id, "api_calls", amount=2
                )

        assert exc_info.value.limit == 100
        assert exc_info.value.used == 99
        assert exc_info.value.feature_code == "api_calls"

    async def test_unlimited_quota_always_succeeds(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
        mock_unlimited_plan_limit: PlanLimit,
    ):
        """Unlimited quota (-1) always allows consumption."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_unlimited_plan_limit)
        mock_uow.usage.atomic_check_and_consume = AsyncMock(return_value=1000000)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.check_and_consume_quota(
                mock_user.id, "api_calls", amount=1000
            )

        assert result.limit == -1
        assert result.remaining == -1  # Unlimited

    async def test_expired_plan_raises_error(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_expired_user_plan: UserPlan,
    ):
        """Expired plan raises PlanExpiredError."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_expired_user_plan)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            with pytest.raises(PlanExpiredError):
                await service.check_and_consume_quota(
                    mock_user.id, "api_calls", amount=1
                )

    async def test_feature_not_in_plan_raises_error(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
    ):
        """Feature not in plan raises FeatureNotAvailableError."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=None)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            with pytest.raises(FeatureNotAvailableError) as exc_info:
                await service.check_and_consume_quota(
                    mock_user.id, "premium_exports", amount=1
                )

        assert exc_info.value.feature_code == "premium_exports"

    async def test_custom_limit_override(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_plan_limit: PlanLimit,
    ):
        """Custom limit overrides plan default limit."""
        # User plan with custom limit
        user_plan_with_custom = UserPlan(
            id=1,
            user_id=mock_user.id,
            plan_id=1,
            plan_code="free",
            started_at=date.today() - timedelta(days=10),
            ends_at=date.today() + timedelta(days=20),
            custom_limits={"api_calls": 500},  # Custom limit
        )

        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=user_plan_with_custom)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_plan_limit)
        mock_uow.usage.atomic_check_and_consume = AsyncMock(return_value=200)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.check_and_consume_quota(
                mock_user.id, "api_calls", amount=1
            )

        # Should use custom limit of 500, not plan default of 100
        assert result.limit == 500
        assert result.remaining == 300  # 500 - 200


class TestQuotaAtomicity:
    """Test suite for atomic quota operations - validates TOCTOU fix."""

    async def test_atomic_check_called_with_correct_params(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
        mock_plan_limit: PlanLimit,
    ):
        """Atomic check receives correct parameters from service."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_plan_limit)
        mock_uow.usage.atomic_check_and_consume = AsyncMock(return_value=10)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            await service.check_and_consume_quota(mock_user.id, "api_calls", amount=5)

        # Verify atomic operation was called with correct params
        mock_uow.usage.atomic_check_and_consume.assert_called_once_with(
            user_plan_id=mock_user_plan.id,
            feature_id=mock_plan_limit.feature_id,
            amount=5,
            limit=100,
            period=PeriodType.DAILY,
        )

    async def test_service_uses_atomic_operation(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
        mock_plan_limit: PlanLimit,
    ):
        """Verifies that the service uses atomic_check_and_consume for TOCTOU protection."""
        # This test verifies the service calls the atomic operation
        # which is critical for preventing race conditions at the DB level

        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_plan_limit)
        mock_uow.usage.atomic_check_and_consume = AsyncMock(return_value=50)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.check_and_consume_quota(
                mock_user.id, "api_calls", amount=1
            )

        # Key assertion: atomic_check_and_consume was called (not separate check + consume)
        mock_uow.usage.atomic_check_and_consume.assert_called_once()
        # And the result reflects the atomic operation's return value
        assert result.used == 50


class TestGetUsageInfo:
    """Test suite for get_usage_info method."""

    async def test_returns_current_usage(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
        mock_plan_limit: PlanLimit,
    ):
        """Returns current usage info without consuming."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_plan_limit)
        mock_uow.usage.get_current_usage = AsyncMock(return_value=42)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.get_usage_info(mock_user.id, "api_calls")

        assert result.used == 42
        assert result.limit == 100
        assert result.remaining == 58
        assert result.period == "daily"

    async def test_unlimited_shows_negative_one_remaining(
        self,
        mock_config: IdentityPlanKitConfig,
        mock_user: User,
        mock_user_plan: UserPlan,
        mock_unlimited_plan_limit: PlanLimit,
    ):
        """Unlimited plan shows -1 for remaining."""
        mock_uow = AsyncMock()
        mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
        mock_uow.__aexit__ = AsyncMock(return_value=None)
        mock_uow.plans.get_user_active_plan = AsyncMock(return_value=mock_user_plan)
        mock_uow.plans.get_plan_limit = AsyncMock(return_value=mock_unlimited_plan_limit)
        mock_uow.usage.get_current_usage = AsyncMock(return_value=999999)

        mock_session_factory = MagicMock()
        service = PlanService(mock_config, mock_session_factory)

        with patch.object(service, "_create_uow", return_value=mock_uow):
            result = await service.get_usage_info(mock_user.id, "api_calls")

        assert result.limit == -1
        assert result.remaining == -1
