"""
Tests for Wave 3B hardening: #1233, #1234, #1235.

- #1233: CompactionMetrics structured logging
- #1234: Assembly token budget overflow protection
- #1235: Stats endpoint completeness
"""

from __future__ import annotations

import json
import logging
from unittest.mock import MagicMock

import pytest
import pytest_asyncio

from cortex.hippo.assembler import ContextAssembler
from cortex.hippo.compaction.types import CompactionMetrics
from cortex.hippo.config import HippoConfig
from cortex.hippo.engine import HippoEngine
from cortex.hippo.models import ConversationStats


# ═══════════════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════════════


@pytest.fixture
def hippo_config() -> HippoConfig:
    return HippoConfig(
        enabled=True,
        db_path=":memory:",
        fresh_tail_count=2,
        max_tokens=1000,
        context_threshold=0.1,  # Very low threshold so compaction triggers easily
        context_window_tokens=100,
        leaf_trigger_margin=100,
    )


@pytest_asyncio.fixture
async def engine(hippo_config: HippoConfig) -> HippoEngine:
    eng = HippoEngine(config=hippo_config)
    await eng.initialize(
        db_path=":memory:",
        owner_id="test-owner",
        conversation_id="test-session",
    )
    yield eng
    await eng.shutdown()


async def _dummy_summarize(text: str, aggressive: bool, options=None):
    """Minimal summarize_fn that returns a short summary."""
    from cortex.hippo.summarize import SummarizeResult
    return SummarizeResult(
        content=f"Summary of {len(text)} chars",
        quality_score=0.9,
        quality_breakdown={"coverage": 0.9},
        source="content",
        mode_used="normal",
        input_tokens=len(text) // 4,
        output_tokens=10,
    )


# ═══════════════════════════════════════════════════════════════════════════
# #1233 — CompactionMetrics
# ═══════════════════════════════════════════════════════════════════════════


class TestCompactionMetrics:
    """Verify CompactionMetrics dataclass and structured logging."""

    def test_metrics_dataclass_fields(self):
        m = CompactionMetrics(
            messages_processed=5,
            summaries_created=2,
            tokens_saved=100,
            rounds_executed=1,
            duration_ms=42.5,
            mode="FULL",
        )
        assert m.messages_processed == 5
        assert m.summaries_created == 2
        assert m.tokens_saved == 100
        assert m.rounds_executed == 1
        assert m.duration_ms == 42.5
        assert m.mode == "FULL"

    def test_metrics_to_dict(self):
        m = CompactionMetrics(
            messages_processed=3,
            summaries_created=1,
            tokens_saved=50,
            rounds_executed=1,
            duration_ms=12.345,
            mode="LEAF",
        )
        d = m.to_dict()
        assert d["messages_processed"] == 3
        assert d["summaries_created"] == 1
        assert d["tokens_saved"] == 50
        assert d["rounds_executed"] == 1
        assert d["duration_ms"] == 12.35  # rounded
        assert d["mode"] == "LEAF"

    def test_metrics_to_dict_json_serializable(self):
        m = CompactionMetrics(mode="FORCE")
        s = json.dumps(m.to_dict())
        parsed = json.loads(s)
        assert parsed["mode"] == "FORCE"

    @pytest.mark.asyncio
    async def test_compact_leaf_emits_metrics(self, engine: HippoEngine):
        """compact() in leaf mode should populate metrics on the compaction engine."""
        # Ingest enough messages to trigger compaction
        for i in range(10):
            await engine.ingest_message("user", f"Message {i} " * 20)
            await engine.ingest_message("assistant", f"Reply {i} " * 20)

        result = await engine.compact(
            mode="leaf",
            summarize_fn=_dummy_summarize,
        )

        # The compaction engine should have last_compaction_metrics
        ce = engine._compaction_engine
        assert ce is not None
        assert ce._last_compaction_metrics is not None
        metrics = ce._last_compaction_metrics
        assert metrics.mode == "LEAF"
        assert metrics.summaries_created >= 1
        assert metrics.duration_ms > 0

    @pytest.mark.asyncio
    async def test_compact_full_emits_metrics(self, engine: HippoEngine):
        """compact() in full mode should populate metrics with mode=FULL."""
        for i in range(10):
            await engine.ingest_message("user", f"Message {i} " * 20)
            await engine.ingest_message("assistant", f"Reply {i} " * 20)

        result = await engine.compact(
            mode="full",
            summarize_fn=_dummy_summarize,
        )

        ce = engine._compaction_engine
        assert ce is not None
        assert ce._last_compaction_metrics is not None
        metrics = ce._last_compaction_metrics
        assert metrics.mode in ("FULL", "LIGHT")
        assert metrics.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_metrics_callback_hook(self, engine: HippoEngine):
        """on_compaction_metrics callback should receive metrics."""
        received = []
        engine._compaction_engine._on_compaction_metrics = lambda m: received.append(m)

        for i in range(10):
            await engine.ingest_message("user", f"Message {i} " * 20)
            await engine.ingest_message("assistant", f"Reply {i} " * 20)

        await engine.compact(mode="leaf", summarize_fn=_dummy_summarize)

        assert len(received) >= 1
        assert isinstance(received[0], CompactionMetrics)
        assert received[0].mode == "LEAF"

    @pytest.mark.asyncio
    async def test_metrics_structured_log_output(self, engine: HippoEngine, caplog):
        """Structured JSON log should be emitted at INFO level."""
        for i in range(10):
            await engine.ingest_message("user", f"Message {i} " * 20)
            await engine.ingest_message("assistant", f"Reply {i} " * 20)

        with caplog.at_level(logging.INFO, logger="cortex.hippo.compaction.engine"):
            await engine.compact(mode="leaf", summarize_fn=_dummy_summarize)

        metric_logs = [r for r in caplog.records if "compaction_metrics" in r.message]
        assert len(metric_logs) >= 1
        # The message should contain valid JSON after the prefix
        log_msg = metric_logs[0].message
        json_str = log_msg.split("compaction_metrics: ", 1)[1]
        parsed = json.loads(json_str)
        assert "mode" in parsed
        assert "summaries_created" in parsed


# ═══════════════════════════════════════════════════════════════════════════
# #1234 — Assembly token budget overflow protection
# ═══════════════════════════════════════════════════════════════════════════


class TestAssemblyOverflow:
    """Verify summaries exceeding budget are dropped, not truncated."""

    @pytest.mark.asyncio
    async def test_large_summary_dropped_gracefully(self, engine: HippoEngine):
        """A summary larger than remaining budget should be skipped entirely."""
        conv = engine._conversation
        owner_id = engine._owner_id

        # Ingest a few messages
        for i in range(4):
            await engine.ingest_message("user", f"msg {i}")

        # Create a summary with very large token count directly
        ss = engine.summary_store
        import os
        large_summary_id = "sum_" + os.urandom(8).hex()
        # Insert a summary with huge token count
        await ss._conn.execute(
            """INSERT INTO hippo_summaries
               (summary_id, conversation_id, owner_id, kind, depth, content, token_count)
               VALUES (?, ?, ?, ?, ?, ?, ?)""",
            (large_summary_id, conv.conversation_id, owner_id, "leaf", 0,
             "Large summary content " * 100, 9999),
        )
        # Also add a small summary
        small_summary_id = "sum_" + os.urandom(8).hex()
        await ss._conn.execute(
            """INSERT INTO hippo_summaries
               (summary_id, conversation_id, owner_id, kind, depth, content, token_count)
               VALUES (?, ?, ?, ?, ?, ?, ?)""",
            (small_summary_id, conv.conversation_id, owner_id, "leaf", 0,
             "Small summary", 10),
        )
        await ss._conn.commit()

        # Add both to context items
        await ss.append_context_item(conv.conversation_id, "summary", summary_id=large_summary_id)
        await ss.append_context_item(conv.conversation_id, "summary", summary_id=small_summary_id)

        # Assemble with a tight budget — the large summary should be dropped
        result = await engine.get_context(max_tokens=200)

        # The large summary (9999 tokens) should NOT be in the result
        large_in_result = any(
            large_summary_id in (item.source_id or "")
            for item in result.items
        )
        assert not large_in_result, "Large summary should have been dropped"

        # The small summary should still be present
        small_in_result = any(
            small_summary_id in (item.source_id or "")
            for item in result.items
        )
        assert small_in_result, "Small summary should still fit"

        # Total tokens should not exceed budget
        assert result.total_tokens <= 200

    @pytest.mark.asyncio
    async def test_overflow_warning_logged(self, engine: HippoEngine, caplog):
        """When summaries are dropped, a warning should be logged."""
        conv = engine._conversation
        owner_id = engine._owner_id
        ss = engine.summary_store

        import os
        # Create oversized summary
        big_id = "sum_" + os.urandom(8).hex()
        await ss._conn.execute(
            """INSERT INTO hippo_summaries
               (summary_id, conversation_id, owner_id, kind, depth, content, token_count)
               VALUES (?, ?, ?, ?, ?, ?, ?)""",
            (big_id, conv.conversation_id, owner_id, "leaf", 0, "big", 50000),
        )
        await ss._conn.commit()
        await ss.append_context_item(conv.conversation_id, "summary", summary_id=big_id)

        with caplog.at_level(logging.WARNING, logger="cortex.hippo.assembler"):
            await engine.get_context(max_tokens=100)

        overflow_logs = [r for r in caplog.records if "overflow" in r.message.lower()]
        assert len(overflow_logs) >= 1, "Expected overflow warning log"


# ═══════════════════════════════════════════════════════════════════════════
# #1235 — Stats endpoint completeness
# ═══════════════════════════════════════════════════════════════════════════


class TestStatsCompleteness:
    """Verify ConversationStats has all required fields."""

    @pytest.mark.asyncio
    async def test_stats_has_all_fields(self, engine: HippoEngine):
        """get_stats() should return all expected fields."""
        # Ingest some data
        for i in range(3):
            await engine.ingest_message("user", f"hello {i}")
            await engine.ingest_message("assistant", f"hi {i}")

        stats = await engine.get_stats()
        assert isinstance(stats, ConversationStats)

        # Original fields
        assert stats.message_count >= 6
        assert stats.conversation_id > 0
        assert stats.total_message_tokens >= 0
        assert stats.total_summary_tokens >= 0
        assert stats.context_item_count >= 0

        # #1235 extended fields
        assert hasattr(stats, "last_compaction_at")
        assert hasattr(stats, "cache_hit_rate")
        assert hasattr(stats, "circuit_breaker_state")
        assert hasattr(stats, "summary_depth_distribution")

        # Circuit breaker should be ok by default
        assert stats.circuit_breaker_state == "ok"

        # Cache hit rate should be a float
        assert isinstance(stats.cache_hit_rate, float)

        # Depth distribution should be a dict
        assert isinstance(stats.summary_depth_distribution, dict)

    @pytest.mark.asyncio
    async def test_stats_after_compaction(self, engine: HippoEngine):
        """After compaction, stats should reflect summaries and compaction time."""
        for i in range(10):
            await engine.ingest_message("user", f"Message {i} " * 20)
            await engine.ingest_message("assistant", f"Reply {i} " * 20)

        # Run compaction
        await engine.compact(mode="leaf", summarize_fn=_dummy_summarize)

        stats = await engine.get_stats()

        # Should have at least 1 summary now
        assert stats.summary_count >= 1

        # last_compaction_at should be set
        assert stats.last_compaction_at is not None

        # Depth distribution should have at least depth 0
        assert 0 in stats.summary_depth_distribution or stats.summary_count == 0

    @pytest.mark.asyncio
    async def test_stats_depth_distribution_accuracy(self, engine: HippoEngine):
        """Depth distribution should accurately reflect summary depths."""
        for i in range(10):
            await engine.ingest_message("user", f"Message {i} " * 20)
            await engine.ingest_message("assistant", f"Reply {i} " * 20)

        await engine.compact(mode="leaf", summarize_fn=_dummy_summarize)

        stats = await engine.get_stats()
        # Sum of distribution values should equal summary_count
        dist_total = sum(stats.summary_depth_distribution.values())
        assert dist_total == stats.summary_count

    @pytest.mark.asyncio
    async def test_stats_serializable(self, engine: HippoEngine):
        """Stats should be fully JSON-serializable."""
        for i in range(3):
            await engine.ingest_message("user", f"test {i}")

        stats = await engine.get_stats()
        # model_dump should work and be JSON-serializable
        d = stats.model_dump()
        s = json.dumps(d)
        parsed = json.loads(s)
        assert parsed["circuit_breaker_state"] == "ok"
        assert isinstance(parsed["summary_depth_distribution"], dict)
