"""
tests/test_metrics.py — Tests for MetricsCollector (#368).

All tests are self-contained (no external dependencies, no DB, no LLM).
They exercise the full public API of MetricsCollector:

    - Counter increment and read
    - Histogram recording and percentile calculation
    - Gauge set and read
    - Thread safety (concurrent access)
    - Reset clears all data
    - Summary returns all metrics
    - Prometheus export format
    - Labels support
    - Singleton pattern
    - Disabled mode (zero-overhead)
"""
from __future__ import annotations

import asyncio

import pytest
import pytest_asyncio

from cortex.core.metrics import MetricsCollector, _histogram_stats, _percentile


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture(autouse=True)
def _reset_singletons():
    """Clear singleton registry before each test."""
    MetricsCollector.reset_instances()
    yield
    MetricsCollector.reset_instances()


@pytest.fixture
def collector() -> MetricsCollector:
    return MetricsCollector(owner_id="test", enabled=True)


@pytest.fixture
def disabled_collector() -> MetricsCollector:
    return MetricsCollector(owner_id="test-disabled", enabled=False)


# ---------------------------------------------------------------------------
# Counter tests
# ---------------------------------------------------------------------------

class TestCounters:
    @pytest.mark.asyncio
    async def test_increment_default(self, collector: MetricsCollector):
        await collector.increment("memories_stored")
        summary = await collector.get_summary()
        assert summary["counters"]["memories_stored"] == 1.0

    @pytest.mark.asyncio
    async def test_increment_custom_amount(self, collector: MetricsCollector):
        await collector.increment("memories_stored", amount=5.0)
        summary = await collector.get_summary()
        assert summary["counters"]["memories_stored"] == 5.0

    @pytest.mark.asyncio
    async def test_increment_multiple_times(self, collector: MetricsCollector):
        for _ in range(10):
            await collector.increment("memories_stored")
        summary = await collector.get_summary()
        assert summary["counters"]["memories_stored"] == 10.0

    @pytest.mark.asyncio
    async def test_increment_with_labels(self, collector: MetricsCollector):
        await collector.increment("memories_stored", labels={"scope": "session"})
        await collector.increment("memories_stored", labels={"scope": "session"})
        await collector.increment("memories_stored", labels={"scope": "longterm"})
        summary = await collector.get_summary()
        counter_data = summary["counters"]["memories_stored"]
        assert isinstance(counter_data, dict)
        assert counter_data["by_label"]['scope="session"'] == 2.0
        assert counter_data["by_label"]['scope="longterm"'] == 1.0

    @pytest.mark.asyncio
    async def test_increment_creates_new_counter(self, collector: MetricsCollector):
        await collector.increment("custom_counter")
        summary = await collector.get_summary()
        assert summary["counters"]["custom_counter"] == 1.0

    @pytest.mark.asyncio
    async def test_default_counters_exist(self, collector: MetricsCollector):
        summary = await collector.get_summary()
        for name in ["memories_stored", "memories_retrieved", "extractions_run",
                      "reconciliations_run", "promotions_run", "boot_sequences",
                      "re_retrievals", "sleep_cycles", "dream_cycles"]:
            assert name in summary["counters"]


# ---------------------------------------------------------------------------
# Histogram tests
# ---------------------------------------------------------------------------

class TestHistograms:
    @pytest.mark.asyncio
    async def test_record_histogram(self, collector: MetricsCollector):
        await collector.record("retrieval_latency_ms", 42.0)
        await collector.record("retrieval_latency_ms", 100.0)
        summary = await collector.get_summary()
        hist = summary["histograms"]["retrieval_latency_ms"]
        assert hist["count"] == 2
        assert hist["min"] == 42.0
        assert hist["max"] == 100.0

    @pytest.mark.asyncio
    async def test_percentile_calculation(self, collector: MetricsCollector):
        # Record 100 values: 1, 2, 3, ..., 100
        for i in range(1, 101):
            await collector.record("retrieval_latency_ms", float(i))
        summary = await collector.get_summary()
        hist = summary["histograms"]["retrieval_latency_ms"]
        assert hist["p50"] == 50.0
        assert hist["p95"] == 95.0
        assert hist["p99"] == 99.0

    @pytest.mark.asyncio
    async def test_histogram_with_labels(self, collector: MetricsCollector):
        await collector.record("retrieval_latency_ms", 10.0, labels={"strategy": "hybrid"})
        await collector.record("retrieval_latency_ms", 20.0, labels={"strategy": "semantic"})
        summary = await collector.get_summary()
        hist = summary["histograms"]["retrieval_latency_ms"]
        assert "by_label" in hist

    @pytest.mark.asyncio
    async def test_histogram_creates_new(self, collector: MetricsCollector):
        await collector.record("custom_latency_ms", 55.0)
        summary = await collector.get_summary()
        assert "custom_latency_ms" in summary["histograms"]

    @pytest.mark.asyncio
    async def test_default_histograms_exist(self, collector: MetricsCollector):
        summary = await collector.get_summary()
        for name in ["retrieval_latency_ms", "extraction_latency_ms",
                      "boot_latency_ms", "compilation_latency_ms"]:
            assert name in summary["histograms"]

    def test_histogram_stats_empty(self):
        assert _histogram_stats([]) == {}

    def test_percentile_single_value(self):
        assert _percentile([42.0], 0.95) == 42.0

    def test_percentile_empty(self):
        assert _percentile([], 0.95) == 0.0


# ---------------------------------------------------------------------------
# Gauge tests
# ---------------------------------------------------------------------------

class TestGauges:
    @pytest.mark.asyncio
    async def test_record_gauge(self, collector: MetricsCollector):
        """record() on a registered gauge sets its value."""
        await collector.record("active_claims", 42.0)
        summary = await collector.get_summary()
        assert summary["gauges"]["active_claims"] == 42.0

    @pytest.mark.asyncio
    async def test_set_gauge_explicit(self, collector: MetricsCollector):
        await collector.set_gauge("active_claims", 99.0)
        summary = await collector.get_summary()
        assert summary["gauges"]["active_claims"] == 99.0

    @pytest.mark.asyncio
    async def test_gauge_overwrites(self, collector: MetricsCollector):
        await collector.set_gauge("active_claims", 10.0)
        await collector.set_gauge("active_claims", 20.0)
        summary = await collector.get_summary()
        assert summary["gauges"]["active_claims"] == 20.0

    @pytest.mark.asyncio
    async def test_gauge_with_labels(self, collector: MetricsCollector):
        await collector.set_gauge("active_claims", 5.0, labels={"scope": "session"})
        summary = await collector.get_summary()
        gauge_data = summary["gauges"]["active_claims"]
        assert isinstance(gauge_data, dict)
        assert gauge_data["by_label"]['scope="session"'] == 5.0

    @pytest.mark.asyncio
    async def test_set_gauge_creates_new(self, collector: MetricsCollector):
        await collector.set_gauge("custom_gauge", 77.0)
        summary = await collector.get_summary()
        assert summary["gauges"]["custom_gauge"] == 77.0

    @pytest.mark.asyncio
    async def test_default_gauges_exist_after_set(self, collector: MetricsCollector):
        # Default gauges exist internally but only appear in summary after being set
        for name in ["active_claims", "working_memory_size", "context_budget_usage_pct"]:
            await collector.set_gauge(name, 0.0)
        summary = await collector.get_summary()
        for name in ["active_claims", "working_memory_size", "context_budget_usage_pct"]:
            assert name in summary["gauges"]

    @pytest.mark.asyncio
    async def test_unset_gauges_excluded_from_summary(self, collector: MetricsCollector):
        # Gauges that have never been set should not appear in summary (cold-start grace)
        summary = await collector.get_summary()
        assert len(summary["gauges"]) == 0


# ---------------------------------------------------------------------------
# Reset
# ---------------------------------------------------------------------------

class TestReset:
    @pytest.mark.asyncio
    async def test_reset_clears_all(self, collector: MetricsCollector):
        await collector.increment("memories_stored", amount=10)
        await collector.record("retrieval_latency_ms", 100.0)
        await collector.set_gauge("active_claims", 50.0)

        await collector.reset()

        summary = await collector.get_summary()
        assert summary["counters"]["memories_stored"] == 0.0
        assert summary["histograms"]["retrieval_latency_ms"] == {}
        assert summary["gauges"]["active_claims"] == 0.0

    @pytest.mark.asyncio
    async def test_reset_clears_labels(self, collector: MetricsCollector):
        await collector.increment("memories_stored", labels={"scope": "test"})
        await collector.reset()
        summary = await collector.get_summary()
        assert summary["counters"]["memories_stored"] == 0.0


# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------

class TestSummary:
    @pytest.mark.asyncio
    async def test_summary_structure(self, collector: MetricsCollector):
        summary = await collector.get_summary()
        assert "owner_id" in summary
        assert "counters" in summary
        assert "histograms" in summary
        assert "gauges" in summary
        assert "uptime_seconds" in summary
        assert summary["owner_id"] == "test"


# ---------------------------------------------------------------------------
# Prometheus export
# ---------------------------------------------------------------------------

class TestPrometheusExport:
    @pytest.mark.asyncio
    async def test_prometheus_format(self, collector: MetricsCollector):
        await collector.increment("memories_stored", amount=5)
        await collector.record("retrieval_latency_ms", 42.0)
        await collector.set_gauge("active_claims", 10.0)

        output = collector.export_prometheus()
        assert "# TYPE cortex_memories_stored counter" in output
        assert "cortex_memories_stored 5.0" in output
        assert "# TYPE cortex_retrieval_latency_ms summary" in output
        assert "# TYPE cortex_active_claims gauge" in output
        assert "cortex_active_claims 10.0" in output

    def test_prometheus_disabled(self, disabled_collector: MetricsCollector):
        output = disabled_collector.export_prometheus()
        assert output == ""


# ---------------------------------------------------------------------------
# Thread safety (concurrent access)
# ---------------------------------------------------------------------------

class TestConcurrency:
    @pytest.mark.asyncio
    async def test_concurrent_increments(self, collector: MetricsCollector):
        """Many concurrent increments should produce the exact total."""
        async def inc():
            for _ in range(100):
                await collector.increment("memories_stored")

        await asyncio.gather(*[inc() for _ in range(10)])

        summary = await collector.get_summary()
        assert summary["counters"]["memories_stored"] == 1000.0

    @pytest.mark.asyncio
    async def test_concurrent_records(self, collector: MetricsCollector):
        """Concurrent histogram records should all be captured."""
        async def rec(start: int):
            for i in range(100):
                await collector.record("retrieval_latency_ms", float(start + i))

        await asyncio.gather(*[rec(i * 100) for i in range(5)])

        summary = await collector.get_summary()
        assert summary["histograms"]["retrieval_latency_ms"]["count"] == 500


# ---------------------------------------------------------------------------
# Singleton pattern
# ---------------------------------------------------------------------------

class TestSingleton:
    def test_same_owner_returns_same_instance(self):
        a = MetricsCollector.get("owner-x")
        b = MetricsCollector.get("owner-x")
        assert a is b

    def test_different_owner_returns_different_instance(self):
        a = MetricsCollector.get("owner-a")
        b = MetricsCollector.get("owner-b")
        assert a is not b


# ---------------------------------------------------------------------------
# Disabled mode
# ---------------------------------------------------------------------------

class TestDisabledMode:
    @pytest.mark.asyncio
    async def test_disabled_increment_noop(self, disabled_collector: MetricsCollector):
        await disabled_collector.increment("memories_stored", amount=100)
        summary = await disabled_collector.get_summary()
        # Still returns a summary, but counters are at zero
        assert summary["counters"]["memories_stored"] == 0.0

    @pytest.mark.asyncio
    async def test_disabled_record_noop(self, disabled_collector: MetricsCollector):
        await disabled_collector.record("retrieval_latency_ms", 999.0)
        summary = await disabled_collector.get_summary()
        assert summary["histograms"]["retrieval_latency_ms"] == {}

    @pytest.mark.asyncio
    async def test_disabled_set_gauge_noop(self, disabled_collector: MetricsCollector):
        await disabled_collector.set_gauge("active_claims", 999.0)
        summary = await disabled_collector.get_summary()
        # When disabled, set_gauge is a no-op — gauge never marked as set
        assert "active_claims" not in summary["gauges"]
