"""
Tests for Hippo compaction edge-case hardening.

Covers:
- Empty message handling (skip gracefully, log warning)
- Very long message chunking (>100K tokens)
- Concurrent compaction guard (in-process asyncio)
- Partial failure recovery (DB flag cleared after crash)
- Metrics counters

Issue: hippo-hardening — edge cases for empty/long/concurrent compaction
"""

from __future__ import annotations

import asyncio
import logging
from unittest.mock import AsyncMock, patch

import pytest
import pytest_asyncio

from cortex.hippo.compaction.hardening import (
    LONG_MESSAGE_CHUNK_TOKEN_THRESHOLD,
    LONG_MESSAGE_CHUNK_SIZE,
    CompactionConcurrencyGuard,
    CompactionEdgeMetrics,
    check_and_skip_empty,
    chunk_long_text,
    estimate_content_tokens,
    get_metrics,
    is_empty_message,
    mark_compaction_end,
    mark_compaction_start,
    maybe_chunk_long_content,
    recover_stale_compaction_flags,
    reset_metrics,
    safe_compact,
)
from cortex.hippo.config import HippoConfig
from cortex.hippo.engine import HippoEngine
from cortex.hippo.models import IngestResult


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


@pytest.fixture(autouse=True)
def _reset_metrics():
    """Reset global metrics counters before each test."""
    reset_metrics()
    yield
    reset_metrics()


@pytest_asyncio.fixture
async def engine():
    """In-memory HippoEngine for integration tests."""
    config = HippoConfig(
        enabled=True,
        db_path=":memory:",
        fresh_tail_count=2,
        max_tokens=10_000,
        context_threshold=0.75,
    )
    eng = HippoEngine(config=config)
    await eng.initialize(
        db_path=":memory:",
        owner_id="test-owner",
        conversation_id="test-hardening",
    )
    yield eng
    await eng.shutdown()


# ═══════════════════════════════════════════════════════════════════════════
# 1. is_empty_message
# ═══════════════════════════════════════════════════════════════════════════


class TestIsEmptyMessage:
    def test_none_is_empty(self):
        assert is_empty_message(None) is True

    def test_empty_string(self):
        assert is_empty_message("") is True

    def test_whitespace_only_string(self):
        assert is_empty_message("   \n\t  ") is True

    def test_non_empty_string(self):
        assert is_empty_message("Hello") is False

    def test_empty_list(self):
        assert is_empty_message([]) is True

    def test_list_with_empty_text_blocks(self):
        blocks = [{"type": "text", "text": ""}, {"type": "text", "text": "  "}]
        assert is_empty_message(blocks) is True

    def test_list_with_content(self):
        blocks = [{"type": "text", "text": "Hello"}]
        assert is_empty_message(blocks) is False

    def test_list_with_tool_use_block(self):
        # Tool use is non-empty even without text
        blocks = [{"type": "tool_use", "id": "t1", "name": "my_tool", "input": {}}]
        assert is_empty_message(blocks) is False

    def test_dict_is_not_empty(self):
        assert is_empty_message({"key": "val"}) is False

    def test_list_with_thinking_empty(self):
        blocks = [{"type": "thinking", "thinking": ""}]
        assert is_empty_message(blocks) is True

    def test_list_with_thinking_content(self):
        blocks = [{"type": "thinking", "thinking": "some thoughts"}]
        assert is_empty_message(blocks) is False


# ═══════════════════════════════════════════════════════════════════════════
# 2. check_and_skip_empty (with metrics)
# ═══════════════════════════════════════════════════════════════════════════


class TestCheckAndSkipEmpty:
    def test_empty_increments_counter(self, caplog):
        with caplog.at_level(logging.WARNING, logger="cortex.hippo.compaction.hardening"):
            result = check_and_skip_empty("user", "")
        assert result is True
        assert get_metrics().skipped_empty == 1
        assert "skipping empty message" in caplog.text

    def test_non_empty_does_not_increment(self):
        result = check_and_skip_empty("user", "Hello")
        assert result is False
        assert get_metrics().skipped_empty == 0

    def test_multiple_empties_accumulate(self):
        check_and_skip_empty("user", "")
        check_and_skip_empty("assistant", None)
        check_and_skip_empty("user", "   ")
        assert get_metrics().skipped_empty == 3


# ═══════════════════════════════════════════════════════════════════════════
# 3. chunk_long_text
# ═══════════════════════════════════════════════════════════════════════════


class TestChunkLongText:
    def test_short_text_not_chunked(self):
        text = "Short text"
        chunks = chunk_long_text(text, chunk_size_tokens=1000)
        assert chunks == [text]

    def test_long_text_is_split(self):
        # ~200K chars ≈ 50K tokens → should split with default 50K token chunks
        long_text = "word " * 40_000  # 200K chars
        chunks = chunk_long_text(long_text, chunk_size_tokens=10_000)
        assert len(chunks) > 1

    def test_chunks_reconstruct_original(self):
        long_text = ("paragraph content.\n\n" * 5_000)
        chunks = chunk_long_text(long_text, chunk_size_tokens=5_000)
        reconstructed = " ".join(c.strip() for c in chunks)
        # All content should be present (allow for whitespace stripping)
        for chunk in chunks:
            assert len(chunk) > 0

    def test_splits_on_paragraph_boundary(self):
        # Create text with clear paragraph boundaries
        para = "x " * 5_000 + "\n\n"  # 10K chars + paragraph break
        text = para * 3  # 30K chars total
        chunks = chunk_long_text(text, chunk_size_tokens=5_000)
        # Each chunk should end at a paragraph boundary (no partial para split)
        for chunk in chunks[:-1]:
            # Chunks should be reasonable size
            assert len(chunk) > 0

    def test_hard_split_when_no_newlines(self):
        # Solid text with no newlines — must hard-split at char boundary
        text = "A" * 200_000
        chunks = chunk_long_text(text, chunk_size_tokens=10_000)
        assert len(chunks) > 1
        total_chars = sum(len(c) for c in chunks)
        assert total_chars == len(text)


# ═══════════════════════════════════════════════════════════════════════════
# 4. maybe_chunk_long_content (with metrics)
# ═══════════════════════════════════════════════════════════════════════════


class TestMaybeChunkLongContent:
    def test_short_content_not_chunked(self):
        was_chunked, pieces = maybe_chunk_long_content("user", "Short", threshold=1000)
        assert was_chunked is False
        assert pieces == ["Short"]
        assert get_metrics().chunked_long == 0

    def test_long_string_is_chunked(self, caplog):
        big = "word " * 25_000  # 125K chars ≈ 31K tokens
        with caplog.at_level(logging.WARNING, logger="cortex.hippo.compaction.hardening"):
            was_chunked, pieces = maybe_chunk_long_content(
                "user", big, threshold=10_000, chunk_size=5_000
            )
        assert was_chunked is True
        assert len(pieces) > 1
        assert get_metrics().chunked_long == 1
        assert "long message detected" in caplog.text

    def test_long_structured_content_is_chunked(self):
        # Structured content (list of blocks) — serialize first, then split
        big_block = [{"type": "text", "text": "word " * 25_000}]
        was_chunked, pieces = maybe_chunk_long_content(
            "user", big_block, threshold=10_000, chunk_size=5_000
        )
        assert was_chunked is True
        assert all(isinstance(p, str) for p in pieces)

    def test_none_content_not_chunked(self):
        was_chunked, pieces = maybe_chunk_long_content("user", None, threshold=100)
        assert was_chunked is False


# ═══════════════════════════════════════════════════════════════════════════
# 5. CompactionConcurrencyGuard
# ═══════════════════════════════════════════════════════════════════════════


class TestCompactionConcurrencyGuard:
    @pytest.mark.asyncio
    async def test_first_acquire_succeeds(self):
        guard = CompactionConcurrencyGuard()
        async with guard.acquire(1) as acquired:
            assert acquired is True

    @pytest.mark.asyncio
    async def test_second_concurrent_acquire_skips(self):
        guard = CompactionConcurrencyGuard()
        results = []

        async def slow_compact():
            async with guard.acquire(1) as acquired:
                results.append(("first", acquired))
                if acquired:
                    await asyncio.sleep(0.05)  # hold the lock

        async def fast_check():
            await asyncio.sleep(0.01)  # wait for first to acquire
            async with guard.acquire(1) as acquired:
                results.append(("second", acquired))

        await asyncio.gather(slow_compact(), fast_check())

        assert ("first", True) in results
        assert ("second", False) in results

    @pytest.mark.asyncio
    async def test_after_release_next_can_acquire(self):
        guard = CompactionConcurrencyGuard()
        # First run
        async with guard.acquire(1) as acquired:
            assert acquired is True
        # Second run (after first released)
        async with guard.acquire(1) as acquired:
            assert acquired is True

    @pytest.mark.asyncio
    async def test_different_conversations_independent(self):
        guard = CompactionConcurrencyGuard()
        results = []

        async def compact_conv(conv_id: int):
            async with guard.acquire(conv_id) as acquired:
                results.append((conv_id, acquired))
                await asyncio.sleep(0.05)

        await asyncio.gather(compact_conv(1), compact_conv(2))
        # Both should have acquired their respective locks
        assert (1, True) in results
        assert (2, True) in results

    @pytest.mark.asyncio
    async def test_skip_increments_metric(self):
        guard = CompactionConcurrencyGuard()

        async def hold():
            async with guard.acquire(1) as acquired:
                if acquired:
                    await asyncio.sleep(0.1)

        async def try_skip():
            await asyncio.sleep(0.01)
            async with guard.acquire(1):
                pass

        await asyncio.gather(hold(), try_skip())
        assert get_metrics().concurrent_skips >= 1

    def test_is_in_progress(self):
        guard = CompactionConcurrencyGuard()
        assert guard.is_in_progress(42) is False


# ═══════════════════════════════════════════════════════════════════════════
# 6. safe_compact wrapper
# ═══════════════════════════════════════════════════════════════════════════


class TestSafeCompact:
    @pytest.mark.asyncio
    async def test_calls_compact_fn(self):
        called = []

        async def fake_compact():
            called.append(True)
            return "done"

        result = await safe_compact(1, fake_compact)
        assert result == "done"
        assert called == [True]

    @pytest.mark.asyncio
    async def test_increments_failed_on_exception(self):
        async def failing_compact():
            raise RuntimeError("boom")

        with pytest.raises(RuntimeError, match="boom"):
            await safe_compact(1, failing_compact)
        assert get_metrics().failed_compactions == 1

    @pytest.mark.asyncio
    async def test_with_guard_skips_concurrent(self):
        guard = CompactionConcurrencyGuard()
        calls = []

        async def slow_compact():
            calls.append("start")
            await asyncio.sleep(0.05)
            calls.append("end")

        async def concurrent_compact():
            await asyncio.sleep(0.01)
            await safe_compact(1, slow_compact, guard=guard)

        await asyncio.gather(
            safe_compact(1, slow_compact, guard=guard),
            concurrent_compact(),
        )
        # Only one "start" should appear (second was skipped)
        assert calls.count("start") == 1


# ═══════════════════════════════════════════════════════════════════════════
# 7. DB-level flag helpers (with in-memory aiosqlite)
# ═══════════════════════════════════════════════════════════════════════════


class TestDbFlagHelpers:
    @pytest.mark.asyncio
    async def test_mark_and_clear_compaction_flag(self):
        """mark_compaction_start / end work on a minimal table."""
        import aiosqlite

        async with aiosqlite.connect(":memory:") as conn:
            await conn.execute("""
                CREATE TABLE hippo_conversations (
                    conversation_id         INTEGER PRIMARY KEY,
                    compaction_in_progress  INTEGER NOT NULL DEFAULT 0,
                    compaction_started_at   TEXT
                )
            """)
            await conn.execute(
                "INSERT INTO hippo_conversations (conversation_id) VALUES (1)"
            )
            await conn.commit()

            # Set flag
            await mark_compaction_start(conn, 1)
            async with conn.execute(
                "SELECT compaction_in_progress FROM hippo_conversations WHERE conversation_id = 1"
            ) as cur:
                row = await cur.fetchone()
            assert row[0] == 1

            # Clear flag
            await mark_compaction_end(conn, 1)
            async with conn.execute(
                "SELECT compaction_in_progress FROM hippo_conversations WHERE conversation_id = 1"
            ) as cur:
                row = await cur.fetchone()
            assert row[0] == 0

    @pytest.mark.asyncio
    async def test_recover_stale_flags(self):
        """recover_stale_compaction_flags clears old in-progress rows."""
        import aiosqlite

        async with aiosqlite.connect(":memory:") as conn:
            await conn.execute("""
                CREATE TABLE hippo_conversations (
                    conversation_id         INTEGER PRIMARY KEY,
                    compaction_in_progress  INTEGER NOT NULL DEFAULT 0,
                    compaction_started_at   TEXT
                )
            """)
            # Insert a stale row (started 10 minutes ago)
            await conn.execute(
                """
                INSERT INTO hippo_conversations
                    (conversation_id, compaction_in_progress, compaction_started_at)
                VALUES (1, 1, datetime('now', '-10 minutes'))
                """
            )
            # Insert a fresh row (started 1 second ago)
            await conn.execute(
                """
                INSERT INTO hippo_conversations
                    (conversation_id, compaction_in_progress, compaction_started_at)
                VALUES (2, 1, datetime('now', '-1 seconds'))
                """
            )
            await conn.commit()

            # Recover with 5-minute threshold
            cleared = await recover_stale_compaction_flags(conn, stale_after_seconds=300)
            assert cleared == 1  # Only the 10-min-old one

            # Verify
            async with conn.execute(
                "SELECT conversation_id, compaction_in_progress FROM hippo_conversations ORDER BY conversation_id"
            ) as cur:
                rows = await cur.fetchall()
            assert rows[0][1] == 0  # conv 1 cleared
            assert rows[1][1] == 1  # conv 2 still set

    @pytest.mark.asyncio
    async def test_flags_tolerate_missing_column(self):
        """Flag helpers handle tables without the columns gracefully."""
        import aiosqlite

        async with aiosqlite.connect(":memory:") as conn:
            await conn.execute("""
                CREATE TABLE hippo_conversations (
                    conversation_id INTEGER PRIMARY KEY
                )
            """)
            await conn.execute(
                "INSERT INTO hippo_conversations (conversation_id) VALUES (1)"
            )
            await conn.commit()

            # Should not raise — just log debug
            await mark_compaction_start(conn, 1)
            await mark_compaction_end(conn, 1)
            cleared = await recover_stale_compaction_flags(conn)
            assert cleared == 0


# ═══════════════════════════════════════════════════════════════════════════
# 8. Engine integration: empty message handling
# ═══════════════════════════════════════════════════════════════════════════


class TestEngineEmptyMessageHandling:
    @pytest.mark.asyncio
    async def test_ingest_empty_string_returns_sentinel(self, engine: HippoEngine):
        result = await engine.ingest_message("user", "")
        assert isinstance(result, IngestResult)
        assert result.message_id == 0
        assert result.token_count == 0
        assert get_metrics().skipped_empty == 1

    @pytest.mark.asyncio
    async def test_ingest_none_returns_sentinel(self, engine: HippoEngine):
        result = await engine.ingest_message("user", None)
        assert result.message_id == 0
        assert get_metrics().skipped_empty == 1

    @pytest.mark.asyncio
    async def test_ingest_whitespace_returns_sentinel(self, engine: HippoEngine):
        result = await engine.ingest_message("assistant", "   \n  ")
        assert result.message_id == 0
        assert get_metrics().skipped_empty == 1

    @pytest.mark.asyncio
    async def test_ingest_normal_message_still_works(self, engine: HippoEngine):
        result = await engine.ingest_message("user", "Hello Hippo!")
        assert result.message_id > 0
        assert result.token_count > 0
        assert get_metrics().skipped_empty == 0

    @pytest.mark.asyncio
    async def test_empty_message_not_counted_in_context(self, engine: HippoEngine):
        """Skipped empty messages should not appear in assembled context."""
        await engine.ingest_message("user", "Real message")
        await engine.ingest_message("assistant", "")       # skipped
        await engine.ingest_message("user", "Another real")

        ctx = await engine.get_context()
        # Only 2 real messages should be in context
        assert len(ctx.messages) == 2


# ═══════════════════════════════════════════════════════════════════════════
# 9. Engine integration: long message chunking
# ═══════════════════════════════════════════════════════════════════════════


class TestEngineLongMessageChunking:
    @pytest.mark.asyncio
    async def test_very_long_message_is_chunked(self, engine: HippoEngine):
        """A message exceeding LONG_MESSAGE_CHUNK_TOKEN_THRESHOLD is split."""
        # ~500K chars ≈ 125K tokens → above 100K threshold
        very_long = "word " * 100_000  # 500K chars
        result = await engine.ingest_message("user", very_long)
        # Should have been chunked — message_id will be from the last chunk
        assert result.message_id > 0
        assert get_metrics().chunked_long == 1

    @pytest.mark.asyncio
    async def test_long_message_all_chunks_in_context(self):
        """All chunks of a long message appear in context (with a large budget)."""
        config = HippoConfig(
            enabled=True,
            db_path=":memory:",
            fresh_tail_count=10,
            max_tokens=200_000,  # Large enough to hold all chunks
            context_threshold=0.9,
        )
        eng = HippoEngine(config=config)
        await eng.initialize(
            db_path=":memory:",
            owner_id="test-owner",
            conversation_id="test-long-chunks",
        )
        try:
            # ~440K chars ≈ 110K tokens → above 100K threshold, forces chunking
            long_msg = "para " * 40_000 + "\n\n" + "para " * 48_000
            await eng.ingest_message("user", long_msg)
            ctx = await eng.get_context()
            # Multiple messages should be in context (one per chunk)
            assert len(ctx.messages) >= 2
        finally:
            await eng.shutdown()

    @pytest.mark.asyncio
    async def test_normal_length_message_not_chunked(self, engine: HippoEngine):
        """A normal-length message is NOT chunked."""
        msg = "A reasonable message " * 100
        await engine.ingest_message("user", msg)
        assert get_metrics().chunked_long == 0


# ═══════════════════════════════════════════════════════════════════════════
# 10. Metrics
# ═══════════════════════════════════════════════════════════════════════════


class TestCompactionEdgeMetrics:
    def test_initial_metrics_are_zero(self):
        m = get_metrics()
        assert m.skipped_empty == 0
        assert m.chunked_long == 0
        assert m.failed_compactions == 0
        assert m.concurrent_skips == 0

    def test_as_dict(self):
        m = CompactionEdgeMetrics(
            skipped_empty=1, chunked_long=2, failed_compactions=3, concurrent_skips=4
        )
        d = m.as_dict()
        assert d == {
            "skipped_empty": 1,
            "chunked_long": 2,
            "failed_compactions": 3,
            "concurrent_skips": 4,
        }

    def test_reset_clears_all(self):
        # Trigger some counts
        check_and_skip_empty("user", "")
        assert get_metrics().skipped_empty == 1
        reset_metrics()
        assert get_metrics().skipped_empty == 0

    @pytest.mark.asyncio
    async def test_failed_compact_metric(self):
        async def boom():
            raise ValueError("test failure")

        with pytest.raises(ValueError):
            await safe_compact(1, boom)
        assert get_metrics().failed_compactions == 1
