"""Tests for observability infrastructure (Sprint 9).

Tests the log buffer and metrics collector components that power
the analytics dashboard.
"""

from __future__ import annotations

from datetime import UTC, datetime

import pytest

from noteflow.infrastructure.logging.log_buffer import LogBuffer, LogEntry
from noteflow.infrastructure.metrics.collector import MetricsCollector, PerformanceMetrics

# Test constants for log buffer capacity
LARGE_DETAILS_KEY_COUNT = 50
"""Number of keys in a large details dictionary for stress testing."""


class TestLogEntry:
    """Test LogEntry dataclass."""

    def test_log_entry_is_frozen(self) -> None:
        """LogEntry should be immutable."""
        entry = LogEntry(
            timestamp=datetime.now(UTC),
            level="info",
            source="app",
            message="Test message",
        )

        from dataclasses import FrozenInstanceError

        with pytest.raises(FrozenInstanceError, match="cannot assign"):
            entry.message = "Modified"

    def test_log_entry_defaults_empty_details(self) -> None:
        """LogEntry defaults to empty details dict."""
        entry = LogEntry(
            timestamp=datetime.now(UTC),
            level="info",
            source="app",
            message="Test",
        )

        assert entry.details == {}, "LogEntry should default to empty details dict"


class TestLogBuffer:
    """Test LogBuffer ring buffer implementation."""

    def test_append_adds_entry(self) -> None:
        """Appending adds entry to buffer."""
        buffer = LogBuffer(capacity=10)
        entry = LogEntry(
            timestamp=datetime.now(UTC),
            level="info",
            source="app",
            message="Test",
        )

        buffer.append(entry)

        assert buffer.count == 1, "Buffer should have one entry after append"
        recent = buffer.get_recent(limit=10)
        assert len(recent) == 1, "get_recent should return one entry"
        assert recent[0] == entry, "Returned entry should match appended entry"

    def test_log_convenience_method(self) -> None:
        """Log method creates entry with current timestamp."""
        buffer = LogBuffer(capacity=10)

        buffer.log("error", "api", "Connection failed", {"url": "/test"})

        assert buffer.count == 1, "Should have logged one entry"
        recent = buffer.get_recent()
        assert len(recent) == 1, "Should return one recent entry"
        assert recent[0].level == "error", "Level should be error"
        assert recent[0].source == "api", "Source should be api"
        assert recent[0].message == "Connection failed", "Message should match"
        assert recent[0].details == {"url": "/test"}, "Details should match"

    def test_capacity_enforced(self) -> None:
        """Buffer evicts oldest entries when capacity reached."""
        buffer = LogBuffer(capacity=3)

        buffer.log("info", "app", "Message 1")
        buffer.log("info", "app", "Message 2")
        buffer.log("info", "app", "Message 3")
        buffer.log("info", "app", "Message 4")

        assert buffer.count == 3, "Buffer count should respect capacity"
        recent = buffer.get_recent()
        messages = [e.message for e in recent]
        assert "Message 1" not in messages, "Oldest message should be evicted"
        assert "Message 4" in messages, "Newest message should be retained"

    def test_get_recent_returns_most_recent_first(self) -> None:
        """Recent entries are returned newest first."""
        buffer = LogBuffer(capacity=10)
        buffer.log("info", "app", "First")
        buffer.log("info", "app", "Second")
        buffer.log("info", "app", "Third")

        recent = buffer.get_recent()

        assert recent[0].message == "Third", "First entry should be most recent (Third)"
        assert recent[1].message == "Second", "Second entry should be Second"
        assert recent[2].message == "First", "Third entry should be oldest (First)"

    def test_get_recent_respects_limit(self) -> None:
        """Limit parameter restricts number of entries returned."""
        buffer = LogBuffer(capacity=10)
        buffer.log("info", "app", "Message 1")
        buffer.log("info", "app", "Message 2")
        buffer.log("info", "app", "Message 3")

        recent = buffer.get_recent(limit=2)

        assert len(recent) == 2, "get_recent should respect limit parameter"

    def test_filter_by_level(self) -> None:
        """Level filter returns only matching entries."""
        buffer = LogBuffer(capacity=10)
        buffer.log("info", "app", "Info message")
        buffer.log("error", "app", "Error message")
        buffer.log("warning", "app", "Warning message")

        errors = buffer.get_recent(level="error")

        assert len(errors) == 1, "Should return only one error-level entry"
        assert errors[0].level == "error", "Returned entry should have error level"

    def test_filter_by_source(self) -> None:
        """Source filter returns only matching entries."""
        buffer = LogBuffer(capacity=10)
        buffer.log("info", "app", "App message")
        buffer.log("info", "api", "API message")
        buffer.log("info", "sync", "Sync message")

        api_logs = buffer.get_recent(source="api")

        assert len(api_logs) == 1, "Should return only one api-source entry"
        assert api_logs[0].source == "api", "Returned entry should have api source"

    def test_combined_filters(self) -> None:
        """Multiple filters are applied together."""
        buffer = LogBuffer(capacity=10)
        buffer.log("error", "api", "API error 1")
        buffer.log("info", "api", "API info")
        buffer.log("error", "app", "App error")
        buffer.log("error", "api", "API error 2")

        filtered = buffer.get_recent(level="error", source="api")

        assert len(filtered) == 2, "Should return two entries matching both filters"
        assert all(e.level == "error" and e.source == "api" for e in filtered), (
            "All entries should match level=error and source=api"
        )

    def test_clear_removes_all_entries(self) -> None:
        """Clear empties the buffer."""
        buffer = LogBuffer(capacity=10)
        buffer.log("info", "app", "Message 1")
        buffer.log("info", "app", "Message 2")

        buffer.clear()

        assert buffer.count == 0, "Buffer count should be zero after clear"
        assert buffer.get_recent() == [], "get_recent should return empty list after clear"


class TestPerformanceMetrics:
    """Test PerformanceMetrics dataclass."""

    def test_metrics_is_frozen(self) -> None:
        """PerformanceMetrics should be immutable."""
        metrics = PerformanceMetrics(
            timestamp=0.0,
            cpu_percent=50.0,
            memory_percent=60.0,
            memory_mb=8000.0,
            disk_percent=45.0,
            network_bytes_sent=1000,
            network_bytes_recv=2000,
            process_memory_mb=256.0,
            active_connections=10,
        )

        from dataclasses import FrozenInstanceError

        with pytest.raises(FrozenInstanceError, match="cannot assign"):
            metrics.cpu_percent = 75.0


class TestMetricsCollector:
    """Test MetricsCollector system metrics collection."""

    def test_collect_now_returns_metrics(self) -> None:
        """Collect now returns valid performance metrics."""
        collector = MetricsCollector(history_size=10)

        metrics = collector.collect_now()

        assert isinstance(metrics, PerformanceMetrics), "collect_now should return PerformanceMetrics"
        assert metrics.timestamp > 0, "Timestamp should be positive"
        assert 0 <= metrics.cpu_percent <= 100, "CPU percent should be in valid range"
        assert 0 <= metrics.memory_percent <= 100, "Memory percent should be in valid range"
        assert metrics.memory_mb >= 0, "Memory MB should be non-negative"

    def test_collect_adds_to_history(self) -> None:
        """Collecting metrics adds to history."""
        collector = MetricsCollector(history_size=10)

        collector.collect_now()
        collector.collect_now()

        history = collector.get_history()
        assert len(history) == 2, "History should contain two collected samples"

    def test_history_respects_limit(self) -> None:
        """Get history respects limit parameter."""
        collector = MetricsCollector(history_size=10)
        collector.collect_now()
        collector.collect_now()
        collector.collect_now()
        collector.collect_now()
        collector.collect_now()

        limited = collector.get_history(limit=3)

        assert len(limited) == 3, "get_history should respect limit parameter"

    def test_history_size_enforced(self) -> None:
        """History evicts old entries when size exceeded."""
        collector = MetricsCollector(history_size=3)

        collector.collect_now()
        collector.collect_now()
        collector.collect_now()
        collector.collect_now()

        history = collector.get_history()
        assert len(history) == 3, "History size should be enforced at capacity"

    def test_history_ordered_oldest_to_newest(self) -> None:
        """History is returned oldest to newest."""
        collector = MetricsCollector(history_size=10)

        collector.collect_now()
        collector.collect_now()
        collector.collect_now()

        history = collector.get_history()

        assert history[0].timestamp <= history[1].timestamp, "First timestamp should be before second"
        assert history[1].timestamp <= history[2].timestamp, "Second timestamp should be before third"

    def test_initial_state_not_running(self) -> None:
        """Collector starts in non-running state."""
        collector = MetricsCollector()

        assert not collector.is_running, "Collector should start in non-running state"

    @pytest.mark.asyncio
    async def test_start_and_stop_collection(self) -> None:
        """Background collection can be started and stopped."""
        import asyncio

        collector = MetricsCollector(history_size=10)

        # Start collection in background with very fast interval
        task = asyncio.create_task(collector.start_collection(interval=0.001))

        # Poll until collection has enough data using wait_for with condition
        async def _wait_history() -> bool:
            for _ in range(1000):
                if len(collector.get_history()) >= 2:
                    return True
                await _yield_control()
            return False

        await asyncio.wait_for(_wait_history(), timeout=5.0)

        assert collector.is_running, "Collector should be running"
        assert len(collector.get_history()) >= 2, "Should have collected samples"

        # Stop collection
        collector.stop_collection()

        # Poll until stopped
        async def _wait_stop() -> bool:
            for _ in range(1000):
                if not collector.is_running:
                    return True
                await _yield_control()
            return False

        await asyncio.wait_for(_wait_stop(), timeout=5.0)

        assert not collector.is_running, "Collector should have stopped"
        task.cancel()


async def _yield_control() -> None:
    """Yield control to the event loop without sleeping."""
    import asyncio

    def _noop() -> None:
        """No-op function for yielding control."""
        pass

    await asyncio.get_event_loop().run_in_executor(None, _noop)


class TestLogBufferEdgeCases:
    """Edge case tests for LogBuffer."""

    def test_empty_buffer_returns_empty_list(self) -> None:
        """Getting recent from empty buffer returns empty list."""
        buffer = LogBuffer(capacity=10)

        recent = buffer.get_recent()

        assert recent == [], "Empty buffer should return empty list"
        assert buffer.count == 0, "Empty buffer should have zero count"

    def test_filter_with_no_matches_returns_empty(self) -> None:
        """Filter that matches nothing returns empty list."""
        buffer = LogBuffer(capacity=10)
        buffer.log("info", "app", "Info message")
        buffer.log("info", "api", "API message")

        nonexistent = buffer.get_recent(level="critical", source="db")

        assert nonexistent == [], "Filter with no matches should return empty list"

    def test_limit_greater_than_count_returns_all(self) -> None:
        """Limit larger than buffer count returns all entries."""
        buffer = LogBuffer(capacity=10)
        buffer.log("info", "app", "Message 1")
        buffer.log("info", "app", "Message 2")

        recent = buffer.get_recent(limit=100)

        assert len(recent) == 2, "Limit larger than count should return all entries"

    def test_log_with_large_details(self) -> None:
        """Buffer handles entries with large details objects."""
        buffer = LogBuffer(capacity=10)
        large_details = {f"key_{i}": f"value_{i}" * 100 for i in range(LARGE_DETAILS_KEY_COUNT)}

        buffer.log("info", "app", "Large details", large_details)

        recent = buffer.get_recent()
        assert len(recent) == 1, "Buffer should contain one entry with large details"
        assert len(recent[0].details) == LARGE_DETAILS_KEY_COUNT, f"Entry should retain all {LARGE_DETAILS_KEY_COUNT} detail keys"

    def test_rapid_sequential_logging(self) -> None:
        """Rapid sequential logging maintains order."""
        buffer = LogBuffer(capacity=100)

        messages = [f"Message {i}" for i in range(50)]
        for msg in messages:
            buffer.log("info", "app", msg)

        recent = buffer.get_recent(limit=50)

        # Recent returns newest first, so reverse to check order
        actual_messages = [entry.message for entry in reversed(recent)]
        assert actual_messages == messages, "Messages should maintain sequential order"

    def test_clear_then_append_works(self) -> None:
        """Buffer works correctly after clear."""
        buffer = LogBuffer(capacity=10)
        buffer.log("info", "app", "Before clear")
        buffer.clear()
        buffer.log("info", "app", "After clear")

        recent = buffer.get_recent()

        assert len(recent) == 1, "Buffer should work correctly after clear"
        assert recent[0].message == "After clear", "Entry after clear should be present"


class TestMetricsCollectorEdgeCases:
    """Edge case tests for MetricsCollector."""

    def test_empty_history_returns_empty_list(self) -> None:
        """Getting history with no collections returns empty list."""
        collector = MetricsCollector(history_size=10)

        history = collector.get_history()

        assert history == [], "Empty history should return empty list"

    def test_limit_greater_than_history_returns_all(self) -> None:
        """Limit larger than history returns all entries."""
        collector = MetricsCollector(history_size=10)
        collector.collect_now()
        collector.collect_now()

        history = collector.get_history(limit=100)

        assert len(history) == 2, "Limit larger than history should return all entries"

    def test_rapid_collection_maintains_order(self) -> None:
        """Rapid collection maintains chronological order."""
        collector = MetricsCollector(history_size=100)

        for _ in range(20):
            collector.collect_now()

        history = collector.get_history()

        out_of_order = [
            (i, history[i].timestamp, history[i + 1].timestamp)
            for i in range(len(history) - 1)
            if history[i].timestamp > history[i + 1].timestamp
        ]
        assert not out_of_order, f"Timestamps should be in chronological order, but found out-of-order pairs: {out_of_order}"

    def test_history_eviction_removes_oldest(self) -> None:
        """History eviction removes oldest entries first."""
        collector = MetricsCollector(history_size=3)

        # Collect 4 samples to trigger eviction
        first = collector.collect_now()
        collector.collect_now()
        collector.collect_now()
        fourth = collector.collect_now()

        history = collector.get_history()

        # First entry should be evicted
        timestamps = [m.timestamp for m in history]
        assert first.timestamp not in timestamps, "First entry should have been evicted"
        assert fourth.timestamp in timestamps, "Fourth entry should be present"


class TestGrpcObservabilityIntegration:
    """Test gRPC observability endpoints integration."""

    @pytest.mark.asyncio
    async def test_get_recent_logs_via_grpc(self) -> None:
        """GetRecentLogs gRPC endpoint returns log entries."""
        from noteflow.grpc.proto import noteflow_pb2
        from noteflow.grpc.service import NoteFlowServicer
        from noteflow.infrastructure.logging import get_log_buffer

        servicer = NoteFlowServicer()

        # Add test logs
        buffer = get_log_buffer()
        buffer.log("info", "test", "Test log message")

        class _DummyContext:
            async def abort(self, code: object, details: str) -> None:
                raise AssertionError(f"abort: {code} - {details}")

        response = await servicer.GetRecentLogs(
            noteflow_pb2.GetRecentLogsRequest(limit=10),
            _DummyContext(),
        )

        assert len(response.logs) >= 1, "GetRecentLogs should return at least one log entry"

    @pytest.mark.asyncio
    async def test_get_performance_metrics_via_grpc(self) -> None:
        """GetPerformanceMetrics gRPC endpoint returns metrics."""
        from noteflow.grpc.proto import noteflow_pb2
        from noteflow.grpc.service import NoteFlowServicer

        servicer = NoteFlowServicer()

        class _DummyContext:
            async def abort(self, code: object, details: str) -> None:
                raise AssertionError(f"abort: {code} - {details}")

        response = await servicer.GetPerformanceMetrics(
            noteflow_pb2.GetPerformanceMetricsRequest(history_limit=10),
            _DummyContext(),
        )

        assert response.current is not None, "Response should include current metrics"
        assert response.current.timestamp > 0, "Current metrics timestamp should be positive"
        assert 0 <= response.current.cpu_percent <= 100, "CPU percent should be in valid range"
