"""Unit tests for usage window overlap bug fix.

Tests for issue #92 - preventing double-billing and revenue loss
from overlapping or gapped usage windows.
"""

from __future__ import annotations

from datetime import UTC, datetime
from unittest.mock import MagicMock, patch

import pytest

from augint_billing_lib.models import Link
from augint_billing_lib.service import BillingService


class TestWindowOverlapFix:
    """Test suite for usage window overlap bug fix."""

    @pytest.fixture
    def mock_repo(self):
        """Create a mock repository."""
        return MagicMock()

    @pytest.fixture
    def mock_stripe(self):
        """Create a mock Stripe adapter."""
        stripe = MagicMock()
        # Mock report_usage to return a proper response
        stripe.report_usage.return_value = {"id": "ur_test123", "object": "usage_record"}
        return stripe

    @pytest.fixture
    def mock_usage(self):
        """Create a mock usage source."""
        return MagicMock()

    @pytest.fixture
    def service(self, mock_repo, mock_stripe, mock_usage):
        """Create a billing service with mocked dependencies."""
        return BillingService(repo=mock_repo, stripe=mock_stripe, usage=mock_usage)

    @pytest.fixture
    def metered_link(self):
        """Create a metered link for testing."""
        return Link(
            api_key_id="key_123",
            stripe_customer_id="cus_456",
            plan="metered",
            usage_plan_id="METERED",
            metered_subscription_item_id="si_789",
            last_reported_usage_ts=None,
            current_month_reported_units=0,
        )

    def test_no_double_billing_on_overlap(
        self, service, mock_repo, mock_usage, mock_stripe, metered_link
    ):
        """Test that overlapping windows don't cause double billing."""
        # Setup: Link has already reported hour 1 (10:00-11:00)
        metered_link.last_usage_window_end = datetime(2024, 1, 15, 11, 0, tzinfo=UTC)
        metered_link.last_usage_window_units = 1000
        mock_repo.scan_metered.return_value = [metered_link]

        # Mock usage for the new window (should only get 11:00-11:30)
        mock_usage.get_usage.return_value = 500

        # Try to report overlapping window: 10:30-11:30
        # Should only report 11:00-11:30 (the non-overlapping part)
        reports = service.reconcile_usage_window(
            datetime(2024, 1, 15, 10, 30, tzinfo=UTC),  # This should be ignored
            datetime(2024, 1, 15, 11, 30, tzinfo=UTC),
        )

        # Verify usage was queried for the correct non-overlapping window
        mock_usage.get_usage.assert_called_once_with(
            "METERED",
            "key_123",
            datetime(2024, 1, 15, 11, 0, tzinfo=UTC),  # Start from last window end
            datetime(2024, 1, 15, 11, 30, tzinfo=UTC),
        )

        # Verify correct usage was reported to Stripe
        assert len(reports) == 1
        assert reports[0]["window_start"] == "2024-01-15T11:00:00+00:00"
        assert reports[0]["window_end"] == "2024-01-15T11:30:00+00:00"
        assert reports[0]["units"] == 500

    def test_gap_detection_and_recovery(
        self, service, mock_repo, mock_usage, mock_stripe, metered_link
    ):
        """Test that gaps are detected and properly handled."""
        # Setup: Link last reported hour 1 (10:00-11:00)
        metered_link.last_usage_window_end = datetime(2024, 1, 15, 11, 0, tzinfo=UTC)
        mock_repo.scan_metered.return_value = [metered_link]

        # Mock usage for the gap period
        mock_usage.get_usage.return_value = 2000

        # Report hour 3 (12:00-13:00) - should detect gap and report from 11:00
        reports = service.reconcile_usage_window(
            datetime(2024, 1, 15, 12, 0, tzinfo=UTC),  # Requested start (has gap)
            datetime(2024, 1, 15, 13, 0, tzinfo=UTC),
        )

        # Should query from last window end to fill the gap
        mock_usage.get_usage.assert_called_once_with(
            "METERED",
            "key_123",
            datetime(2024, 1, 15, 11, 0, tzinfo=UTC),  # From last window end (fills the gap!)
            datetime(2024, 1, 15, 13, 0, tzinfo=UTC),  # To requested end
        )

        # Verify the report includes the full window from 11:00 to 13:00
        assert len(reports) == 1
        assert reports[0]["window_start"] == "2024-01-15T11:00:00+00:00"
        assert reports[0]["window_end"] == "2024-01-15T13:00:00+00:00"
        assert reports[0]["units"] == 2000

    def test_idempotency_key_prevents_duplicate_billing(
        self, service, mock_repo, mock_usage, mock_stripe, metered_link
    ):
        """Test that idempotency keys prevent duplicate charges."""
        mock_repo.scan_metered.return_value = [metered_link]
        mock_usage.get_usage.return_value = 1500

        # Report the same window twice
        window_start = datetime(2024, 1, 15, 10, 0, tzinfo=UTC)
        window_end = datetime(2024, 1, 15, 11, 0, tzinfo=UTC)

        # First report
        service.reconcile_usage_window(window_start, window_end)

        # Get the idempotency key used
        first_call = mock_stripe.report_usage.call_args_list[0]
        first_call[0][3]

        # Second report of same window (after update)
        metered_link.last_usage_window_end = window_end
        service.reconcile_usage_window(window_start, window_end)

        # Should skip because window already processed
        assert mock_stripe.report_usage.call_count == 1

    def test_first_usage_report_starts_from_since(
        self, service, mock_repo, mock_usage, mock_stripe, metered_link
    ):
        """Test that first usage report uses 'since' parameter."""
        # Link has never reported usage
        metered_link.last_usage_window_end = None
        mock_repo.scan_metered.return_value = [metered_link]
        mock_usage.get_usage.return_value = 1000

        since = datetime(2024, 1, 15, 10, 0, tzinfo=UTC)
        until = datetime(2024, 1, 15, 11, 0, tzinfo=UTC)

        reports = service.reconcile_usage_window(since, until)

        # Should use 'since' for first report
        mock_usage.get_usage.assert_called_once_with("METERED", "key_123", since, until)

        assert len(reports) == 1
        assert reports[0]["window_start"] == since.isoformat()

    def test_state_only_updated_after_successful_reporting(
        self, service, mock_repo, mock_usage, mock_stripe, metered_link
    ):
        """Test that state is only updated after successful Stripe reporting."""
        mock_repo.scan_metered.return_value = [metered_link]
        mock_usage.get_usage.return_value = 1000

        # Make Stripe reporting fail
        mock_stripe.report_usage.side_effect = Exception("Stripe API error")

        with pytest.raises(Exception, match="Stripe API error"):
            service.reconcile_usage_window(
                datetime(2024, 1, 15, 10, 0, tzinfo=UTC), datetime(2024, 1, 15, 11, 0, tzinfo=UTC)
            )

        # Link state should NOT be updated since reporting failed
        mock_repo.save.assert_not_called()

    def test_zero_usage_not_reported(
        self, service, mock_repo, mock_usage, mock_stripe, metered_link
    ):
        """Test that zero usage is not reported to Stripe."""
        mock_repo.scan_metered.return_value = [metered_link]
        mock_usage.get_usage.return_value = 0

        reports = service.reconcile_usage_window(
            datetime(2024, 1, 15, 10, 0, tzinfo=UTC), datetime(2024, 1, 15, 11, 0, tzinfo=UTC)
        )

        # Should not report to Stripe
        mock_stripe.report_usage.assert_not_called()
        assert len(reports) == 0

    def test_window_already_processed_is_skipped(
        self, service, mock_repo, mock_usage, metered_link
    ):
        """Test that already processed windows are skipped."""
        # Link has already processed up to 11:00
        metered_link.last_usage_window_end = datetime(2024, 1, 15, 11, 0, tzinfo=UTC)
        mock_repo.scan_metered.return_value = [metered_link]

        # Try to report an earlier window (10:00-10:30)
        with patch("augint_billing_lib.service.log_event") as mock_log:
            reports = service.reconcile_usage_window(
                datetime(2024, 1, 15, 10, 0, tzinfo=UTC), datetime(2024, 1, 15, 10, 30, tzinfo=UTC)
            )

            # Should log that window was already processed
            skip_calls = [
                call for call in mock_log.call_args_list if "already_processed" in str(call)
            ]
            assert len(skip_calls) > 0

        # Should not query usage
        mock_usage.get_usage.assert_not_called()
        assert len(reports) == 0

    def test_deterministic_idempotency_key_format(
        self, service, mock_repo, mock_usage, mock_stripe, metered_link
    ):
        """Test that idempotency keys are deterministic based on window."""
        mock_repo.scan_metered.return_value = [metered_link]
        mock_usage.get_usage.return_value = 1000

        window_start = datetime(2024, 1, 15, 10, 0, tzinfo=UTC)
        window_end = datetime(2024, 1, 15, 11, 0, tzinfo=UTC)

        service.reconcile_usage_window(window_start, window_end)

        # Check idempotency key format
        call_args = mock_stripe.report_usage.call_args[0]
        idem_key = call_args[3]

        # Should be deterministic: api_key:window_start:window_end
        expected_key = f"key_123:{window_start.isoformat()}:{window_end.isoformat()}"
        assert idem_key == expected_key
