"""Unit tests for webhook delivery metrics.

Sprint GAP-003: Error Handling Mismatches
"""

from __future__ import annotations

import time
from collections.abc import Generator
from concurrent.futures import ThreadPoolExecutor
from dataclasses import FrozenInstanceError
from unittest.mock import patch

import pytest

from noteflow.domain.webhooks import WebhookEventType
from noteflow.infrastructure.webhooks.metrics import (
    METRIC_WINDOW_SECONDS,
    WebhookDeliveryStats,
    WebhookMetrics,
    get_webhook_metrics,
    reset_webhook_metrics,
)

DURATION_SHORT_MS = 100
DURATION_MEDIUM_MS = 150
DURATION_LONG_MS = 200
DURATION_EXTRA_MS = 500
DURATION_THREAD_MS = 50
DURATION_ZERO_MS = 0
ATTEMPTS_SINGLE = 1
ATTEMPTS_DOUBLE = 2
ATTEMPTS_TRIPLE = 3
THREAD_COUNT = 10
DELIVERIES_PER_THREAD = 100
ALL_EVENT_TYPES = tuple(WebhookEventType)


def record_delivery(
    metrics: WebhookMetrics,
    event_type: WebhookEventType,
    success: bool,
    duration_ms: int,
    attempts: int,
) -> None:
    """Record a single delivery for test setup."""
    metrics.record_delivery(
        event_type=event_type,
        success=success,
        duration_ms=duration_ms,
        attempts=attempts,
    )


def record_deliveries(
    metrics: WebhookMetrics,
    event_type: WebhookEventType,
    success: bool,
    duration_ms: int,
    attempts: int,
    count: int,
) -> None:
    """Record multiple deliveries for test setup."""
    for _ in range(count):
        record_delivery(metrics, event_type, success, duration_ms, attempts)


def run_threaded_deliveries(
    metrics: WebhookMetrics,
    event_type: WebhookEventType,
    deliveries_per_thread: int,
    num_threads: int,
) -> None:
    """Run concurrent delivery recording for thread-safety tests."""

    def record_thread_batch() -> None:
        record_deliveries(
            metrics,
            event_type,
            True,
            DURATION_THREAD_MS,
            ATTEMPTS_SINGLE,
            deliveries_per_thread,
        )

    with ThreadPoolExecutor(max_workers=num_threads) as executor:
        futures = tuple(executor.submit(record_thread_batch) for _ in range(num_threads))
        for future in futures:
            future.result()


class TestWebhookDeliveryStats:
    """Tests for WebhookDeliveryStats dataclass."""

    def test_empty_stats_factory(self) -> None:
        """Empty stats should have sensible defaults."""
        stats = WebhookDeliveryStats.empty()

        assert stats.total_deliveries == 0, "total_deliveries should default to 0"
        assert stats.successful_deliveries == 0, "successful_deliveries should default to 0"
        assert stats.failed_deliveries == 0, "failed_deliveries should default to 0"
        assert stats.total_retries == 0, "total_retries should default to 0"
        assert stats.avg_duration_ms == 0.0, "avg_duration_ms should default to 0.0"
        assert stats.success_rate == 1.0, "success_rate should default to 1.0"

    def test_stats_is_frozen(self) -> None:
        """Stats dataclass should be immutable."""
        stats = WebhookDeliveryStats.empty()

        with pytest.raises(FrozenInstanceError, match="cannot assign"):
            stats.total_deliveries = 10

    def test_stats_equality(self) -> None:
        """Two stats with same values should be equal."""
        stats1 = WebhookDeliveryStats(
            total_deliveries=10,
            successful_deliveries=8,
            failed_deliveries=2,
            total_retries=3,
            avg_duration_ms=150.5,
            success_rate=0.8,
        )
        stats2 = WebhookDeliveryStats(
            total_deliveries=10,
            successful_deliveries=8,
            failed_deliveries=2,
            total_retries=3,
            avg_duration_ms=150.5,
            success_rate=0.8,
        )

        assert stats1 == stats2, "Identical stats should compare equal"


class TestWebhookMetrics:
    """Tests for WebhookMetrics class."""

    @pytest.fixture
    def metrics(self) -> WebhookMetrics:
        """Create fresh metrics instance for each test."""
        return WebhookMetrics()

    def test_initial_stats_empty(self, metrics: WebhookMetrics) -> None:
        """New metrics instance should return empty stats."""
        stats = metrics.get_stats()

        assert stats.total_deliveries == 0, "New metrics should have zero deliveries"
        assert stats.success_rate == 1.0, "New metrics should default to 100% success"

    def test_record_successful_delivery(self, metrics: WebhookMetrics) -> None:
        """Recording a successful delivery should update stats."""
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_SHORT_MS,
            ATTEMPTS_SINGLE,
        )

        stats = metrics.get_stats()
        assert stats.total_deliveries == 1, "Should record one delivery"
        assert stats.successful_deliveries == 1, "Successful deliveries should be counted"
        assert stats.failed_deliveries == 0, "No failed deliveries expected"
        assert stats.success_rate == 1.0, "Success rate should be 100%"
        assert stats.avg_duration_ms == float(
            DURATION_SHORT_MS
        ), "Average duration should match recorded duration"

    def test_record_failed_delivery(self, metrics: WebhookMetrics) -> None:
        """Recording a failed delivery should update stats."""
        record_delivery(
            metrics,
            WebhookEventType.SUMMARY_GENERATED,
            False,
            DURATION_EXTRA_MS,
            ATTEMPTS_TRIPLE,
        )

        stats = metrics.get_stats()
        assert stats.total_deliveries == 1, "Should record one delivery"
        assert stats.successful_deliveries == 0, "No successful deliveries expected"
        assert stats.failed_deliveries == 1, "Failed deliveries should be counted"
        assert stats.success_rate == 0.0, "Success rate should be 0% for failure"
        assert stats.total_retries == 2, "Three attempts should count as two retries"

    def test_mixed_deliveries(self, metrics: WebhookMetrics) -> None:
        """Stats should correctly aggregate mixed success/failure."""
        # Record 3 successful, 1 failed
        record_deliveries(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_SHORT_MS,
            ATTEMPTS_SINGLE,
            3,
        )
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            False,
            DURATION_LONG_MS,
            ATTEMPTS_DOUBLE,
        )

        stats = metrics.get_stats()
        assert stats.total_deliveries == 4, "Should record four deliveries total"
        assert stats.successful_deliveries == 3, "Successful count should match inputs"
        assert stats.failed_deliveries == 1, "Failed count should match inputs"
        assert stats.success_rate == 0.75, "Success rate should reflect mixed deliveries"
        assert stats.total_retries == 1, "Retries should reflect failed attempt count"

    def test_average_duration_calculation(self, metrics: WebhookMetrics) -> None:
        """Average duration should be calculated correctly."""
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_SHORT_MS,
            ATTEMPTS_SINGLE,
        )
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_LONG_MS,
            ATTEMPTS_SINGLE,
        )
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_LONG_MS + DURATION_SHORT_MS,
            ATTEMPTS_SINGLE,
        )

        stats = metrics.get_stats()
        expected_average = (
            DURATION_SHORT_MS
            + DURATION_LONG_MS
            + (DURATION_LONG_MS + DURATION_SHORT_MS)
        ) / 3
        assert (
            stats.avg_duration_ms == expected_average
        ), "Average duration should match expected mean"

    def test_zero_duration_excluded_from_average(
        self, metrics: WebhookMetrics
    ) -> None:
        """Zero duration should not affect average calculation."""
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_ZERO_MS,
            ATTEMPTS_SINGLE,
        )
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_LONG_MS,
            ATTEMPTS_SINGLE,
        )

        stats = metrics.get_stats()
        # Only the 200ms delivery should be counted
        assert stats.avg_duration_ms == float(
            DURATION_LONG_MS
        ), "Zero-duration delivery should be excluded from average"

    def test_stats_by_event_type(self, metrics: WebhookMetrics) -> None:
        """Stats should be available broken down by event type."""
        # Record different event types
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_SHORT_MS,
            ATTEMPTS_SINGLE,
        )
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_MEDIUM_MS,
            ATTEMPTS_SINGLE,
        )
        record_delivery(
            metrics,
            WebhookEventType.SUMMARY_GENERATED,
            False,
            DURATION_LONG_MS,
            ATTEMPTS_DOUBLE,
        )

        by_event = metrics.get_stats_by_event()

        assert (
            WebhookEventType.MEETING_COMPLETED.value in by_event
        ), "Meeting completed stats should be present"
        assert (
            WebhookEventType.SUMMARY_GENERATED.value in by_event
        ), "Summary generated stats should be present"

        meeting_stats = by_event[WebhookEventType.MEETING_COMPLETED.value]
        assert (
            meeting_stats.total_deliveries == 2
        ), "Meeting stats should count both deliveries"
        assert (
            meeting_stats.successful_deliveries == 2
        ), "Meeting stats should count successful deliveries"
        assert meeting_stats.success_rate == 1.0, "Meeting stats should be 100% success"

        summary_stats = by_event[WebhookEventType.SUMMARY_GENERATED.value]
        assert summary_stats.total_deliveries == 1, "Summary stats should count one delivery"
        assert summary_stats.failed_deliveries == 1, "Summary stats should count one failure"
        assert summary_stats.success_rate == 0.0, "Summary stats should be 0% success"

    def test_rolling_window_prunes_old_metrics(
        self, metrics: WebhookMetrics
    ) -> None:
        """Old metrics outside the window should be pruned."""
        # Record a metric
        record_delivery(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            True,
            DURATION_SHORT_MS,
            ATTEMPTS_SINGLE,
        )

        # Mock time to be past the window
        future_time = time.time() + METRIC_WINDOW_SECONDS + 1
        with patch("noteflow.infrastructure.webhooks.metrics.time.time") as mock_time:
            mock_time.return_value = future_time
            stats = metrics.get_stats()

        # Old metric should be pruned
        assert stats.total_deliveries == 0, "Old metrics outside window should be pruned"

    def test_thread_safety(self, metrics: WebhookMetrics) -> None:
        """Metrics should be thread-safe for concurrent access."""
        run_threaded_deliveries(
            metrics,
            WebhookEventType.MEETING_COMPLETED,
            DELIVERIES_PER_THREAD,
            THREAD_COUNT,
        )

        stats = metrics.get_stats()
        expected_total = THREAD_COUNT * DELIVERIES_PER_THREAD
        assert (
            stats.total_deliveries == expected_total
        ), f"Expected {expected_total} total deliveries from threaded recording"

    @pytest.mark.parametrize(
        "event_type",
        [pytest.param(event_type, id=event_type.value) for event_type in ALL_EVENT_TYPES],
    )
    def test_all_event_types_supported(
        self, metrics: WebhookMetrics, event_type: WebhookEventType
    ) -> None:
        """All WebhookEventType values should be recordable."""
        record_delivery(
            metrics,
            event_type,
            True,
            DURATION_SHORT_MS,
            ATTEMPTS_SINGLE,
        )

        stats = metrics.get_stats()
        assert stats.total_deliveries == 1, "Each event type should be recordable"


class TestGetWebhookMetrics:
    """Tests for the global singleton accessor."""

    @pytest.fixture(autouse=True)
    def reset_singleton(self) -> Generator[None, None, None]:
        """Reset global metrics singleton for deterministic tests.

        Uses the public reset helper to ensure test isolation for singleton behavior.
        """
        reset_webhook_metrics()
        yield
        reset_webhook_metrics()

    def test_returns_singleton(self) -> None:
        """Should return the same instance on multiple calls."""
        metrics1 = get_webhook_metrics()
        metrics2 = get_webhook_metrics()

        assert metrics1 is metrics2, "get_webhook_metrics should return singleton instance"

    def test_singleton_is_functional(self) -> None:
        """Singleton should be a functional WebhookMetrics instance."""
        metrics = get_webhook_metrics()

        # Should be able to record and retrieve stats
        record_delivery(
            metrics,
            WebhookEventType.RECORDING_STARTED,
            True,
            DURATION_THREAD_MS,
            ATTEMPTS_SINGLE,
        )

        stats = metrics.get_stats()
        assert stats.total_deliveries >= 1, "Singleton should record deliveries successfully"
