"""
Tests for hippo hardening audit fixes (B-005, B-006, B-018–B-022, B-031).
"""
from __future__ import annotations

import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch

from cortex.hippo.compaction.hardening import (
    CompactionConcurrencyGuard,
    DEFAULT_STALE_COMPACTION_TIMEOUT_SECONDS,
    _default_guard,
    chunk_long_text,
    is_empty_message,
    mark_compaction_end,
    mark_compaction_start,
    recover_stale_compaction_flags,
    reset_metrics,
    safe_compact,
    touch_compaction_heartbeat,
)


@pytest.fixture(autouse=True)
def _reset():
    reset_metrics()


# ── B-018: Duplicate get("thinking") → get("reasoning") ──────────────────────


class TestB018ReasoningField:
    """B-018: is_empty_message should check 'reasoning' field, not 'thinking' twice."""

    def test_reasoning_block_detected_as_non_empty(self):
        """A block with only 'reasoning' content should be non-empty."""
        content = [{"type": "reasoning", "reasoning": "deep thought here"}]
        assert is_empty_message(content) is False

    def test_thinking_block_still_detected(self):
        """A block with 'thinking' content should still work."""
        content = [{"type": "thinking", "thinking": "pondering"}]
        assert is_empty_message(content) is False

    def test_text_field_still_works(self):
        """A block with 'text' content should work."""
        content = [{"type": "text", "text": "hello"}]
        assert is_empty_message(content) is False

    def test_empty_reasoning_block_is_empty(self):
        """A reasoning block with no actual text content is still empty."""
        content = [{"type": "reasoning"}]
        assert is_empty_message(content) is True

    def test_reasoning_only_no_text_no_thinking(self):
        """Block with reasoning field but no text/thinking fields is non-empty."""
        content = [{"type": "", "reasoning": "some reasoning"}]
        assert is_empty_message(content) is False


# ── B-020: is_empty_message false negative for non-dict list items ────────────


class TestB020NonDictListItems:
    """B-020: is_empty_message should handle non-dict items in list gracefully."""

    def test_string_item_in_list_is_non_empty(self):
        """A list with a non-empty string should be non-empty."""
        assert is_empty_message(["hello world"]) is False

    def test_int_item_in_list_is_non_empty(self):
        """A list with a truthy int should be non-empty."""
        assert is_empty_message([42]) is False

    def test_empty_string_in_list_is_empty(self):
        """A list with only empty/falsy non-dict items should be empty."""
        assert is_empty_message([""]) is True

    def test_none_in_list_is_empty(self):
        """A list with only None should be empty."""
        assert is_empty_message([None]) is True

    def test_zero_in_list_is_empty(self):
        """A list with only 0 should be empty (falsy)."""
        assert is_empty_message([0]) is True

    def test_mixed_dict_and_string(self):
        """A list with a non-empty string and an empty dict block."""
        content = [{"type": "text", "text": ""}, "some content"]
        assert is_empty_message(content) is False

    def test_no_crash_on_mixed_types(self):
        """Should not raise AttributeError on non-dict items."""
        # This would crash before the fix because of msg.get()
        content = ["hello", 42, None, {"type": "text", "text": ""}]
        result = is_empty_message(content)
        assert result is False  # "hello" and 42 are truthy


# ── B-031: chunk_long_text hard-splits mid-word ───────────────────────────────


class TestB031ChunkWordBoundary:
    """B-031: chunk_long_text should prefer splitting on whitespace."""

    def test_splits_on_whitespace_not_mid_word(self):
        """Chunks should break at word boundaries when possible."""
        # Create text that needs chunking: 100 chars per chunk
        words = "abcdefghij " * 20  # 220 chars total
        chunks = chunk_long_text(words, chunk_size_tokens=25)  # 25*4=100 char limit
        for chunk in chunks:
            # No chunk should start or end mid-word (except possibly the last)
            stripped = chunk.strip()
            if stripped:
                # Should not break in the middle of "abcdefghij"
                assert not stripped.startswith("bcdefghij")
                assert not stripped.startswith("cdefghij")

    def test_falls_back_to_hard_split_for_no_whitespace(self):
        """Long text without any whitespace should still be chunked (hard split)."""
        text = "a" * 500  # No whitespace at all
        chunks = chunk_long_text(text, chunk_size_tokens=25)  # 100 char limit
        assert len(chunks) > 1
        total = sum(len(c) for c in chunks)
        assert total == 500

    def test_prefers_paragraph_then_newline_then_space(self):
        """Should prefer paragraph > newline > space > hard split."""
        # Build text with a paragraph break near the end
        text = "word " * 18 + "\n\n" + "word " * 5  # ~115 chars, para break near 90
        chunks = chunk_long_text(text, chunk_size_tokens=25)  # 100 char limit
        if len(chunks) > 1:
            # First chunk should end before or at the paragraph break
            assert chunks[0].rstrip().endswith("word")


# ── B-005: mark_compaction_end re-raises DB errors ────────────────────────────


class TestB005MarkCompactionEndReraise:
    """B-005: mark_compaction_end should re-raise DB errors."""

    @pytest.mark.asyncio
    async def test_reraises_db_error(self):
        """mark_compaction_end should re-raise exceptions after logging."""
        conn = AsyncMock()
        conn.execute = AsyncMock(side_effect=Exception("DB write failed"))

        with pytest.raises(Exception, match="DB write failed"):
            await mark_compaction_end(conn, conversation_id=42)

    @pytest.mark.asyncio
    async def test_normal_operation_no_raise(self):
        """mark_compaction_end should not raise on success."""
        conn = AsyncMock()
        conn.execute = AsyncMock()
        conn.commit = AsyncMock()

        # Should not raise
        await mark_compaction_end(conn, conversation_id=42)
        conn.execute.assert_called_once()
        conn.commit.assert_called_once()


# ── B-006: Stale recovery timeout configurable + heartbeat ────────────────────


class TestB006StaleRecoveryTimeout:
    """B-006: Stale recovery uses configurable timeout (default 30min) + heartbeat."""

    def test_default_timeout_is_30_minutes(self):
        """Default stale recovery timeout should be 1800s (30 min), not 300s (5 min)."""
        assert DEFAULT_STALE_COMPACTION_TIMEOUT_SECONDS == 1800

    @pytest.mark.asyncio
    async def test_recover_uses_custom_timeout(self):
        """recover_stale_compaction_flags should pass timeout to SQL."""
        conn = AsyncMock()
        cursor = AsyncMock()
        cursor.rowcount = 0
        conn.execute = AsyncMock(return_value=cursor)
        conn.commit = AsyncMock()

        await recover_stale_compaction_flags(conn, stale_after_seconds=7200)

        # Verify the timeout value was passed
        call_args = conn.execute.call_args
        assert call_args[0][1] == (7200,)

    @pytest.mark.asyncio
    async def test_touch_heartbeat_updates_timestamp(self):
        """touch_compaction_heartbeat should update compaction_started_at."""
        conn = AsyncMock()
        conn.execute = AsyncMock()
        conn.commit = AsyncMock()

        await touch_compaction_heartbeat(conn, conversation_id=42)
        conn.execute.assert_called_once()
        sql = conn.execute.call_args[0][0]
        assert "compaction_started_at" in sql
        assert "datetime('now')" in sql

    @pytest.mark.asyncio
    async def test_touch_heartbeat_does_not_raise(self):
        """Heartbeat should be best-effort (no raise on failure)."""
        conn = AsyncMock()
        conn.execute = AsyncMock(side_effect=Exception("DB error"))

        # Should not raise
        await touch_compaction_heartbeat(conn, conversation_id=42)


# ── B-019: safe_compact guard=None uses default guard ─────────────────────────


class TestB019DefaultGuard:
    """B-019: safe_compact should use a default guard when none is provided."""

    def test_default_guard_exists(self):
        """Module-level _default_guard should exist."""
        assert _default_guard is not None
        assert isinstance(_default_guard, CompactionConcurrencyGuard)

    @pytest.mark.asyncio
    async def test_safe_compact_no_guard_still_protected(self):
        """safe_compact with guard=None should still use concurrency protection."""
        call_count = 0

        async def mock_compact():
            nonlocal call_count
            call_count += 1
            return "result"

        result = await safe_compact(
            conversation_id=999,
            compact_fn=mock_compact,
            guard=None,  # No guard provided
            conn=None,
        )
        assert result == "result"
        assert call_count == 1

    @pytest.mark.asyncio
    async def test_safe_compact_no_guard_blocks_concurrent(self):
        """safe_compact with guard=None should block concurrent runs."""
        barrier = asyncio.Event()
        started = asyncio.Event()
        results = []

        async def slow_compact():
            started.set()
            await barrier.wait()
            return "done"

        # First call acquires the default guard
        task1 = asyncio.create_task(
            safe_compact(conversation_id=888, compact_fn=slow_compact, guard=None)
        )
        await started.wait()

        # Second call should be skipped (concurrent)
        result2 = await safe_compact(
            conversation_id=888, compact_fn=slow_compact, guard=None
        )
        assert result2 is None  # Skipped due to concurrency

        barrier.set()
        result1 = await task1
        assert result1 == "done"


# ── B-021/B-022: Guard wired into engine.compact() with DB flags ─────────────


class TestB021B022EngineCompactGuardWiring:
    """B-021/B-022: HippoEngine.compact() should use concurrency guard + DB flags."""

    @pytest.mark.asyncio
    async def test_compact_acquires_guard(self):
        """compact() should acquire the concurrency guard."""
        from cortex.hippo.engine import HippoEngine

        engine = HippoEngine()
        engine._initialized = True
        engine._conversation = MagicMock()
        engine._conversation.conversation_id = 1
        engine.config = MagicMock()
        engine.config.enabled = True
        engine.config.max_tokens = 10000

        # Mock the compaction engine
        ce_result = MagicMock()
        ce_result.action_taken = False
        ce_result.created_summary_ids = []
        ce_result.tokens_before = 100
        ce_result.tokens_after = 50
        ce_result.rounds = 1

        engine._compaction_engine = MagicMock()
        engine._compaction_engine.compact_leaf = AsyncMock(return_value=ce_result)

        # Mock DB connection
        engine._db = MagicMock()
        mock_conn = AsyncMock()
        mock_conn.execute = AsyncMock()
        mock_conn.commit = AsyncMock()
        engine._db.conn = mock_conn

        # Provide a summarize_fn
        summarize_fn = AsyncMock(return_value="summary")

        result = await engine.compact(summarize_fn=summarize_fn)

        # Should have called mark_compaction_start and mark_compaction_end
        calls = mock_conn.execute.call_args_list
        sql_calls = [c[0][0] for c in calls]
        assert any("compaction_in_progress = 1" in sql for sql in sql_calls), \
            "Should call mark_compaction_start"
        assert any("compaction_in_progress = 0" in sql for sql in sql_calls), \
            "Should call mark_compaction_end"

    @pytest.mark.asyncio
    async def test_compact_clears_flag_on_error(self):
        """compact() should clear DB flag even if compaction raises."""
        from cortex.hippo.engine import HippoEngine

        engine = HippoEngine()
        engine._initialized = True
        engine._conversation = MagicMock()
        engine._conversation.conversation_id = 1
        engine.config = MagicMock()
        engine.config.enabled = True
        engine.config.max_tokens = 10000

        engine._compaction_engine = MagicMock()
        engine._compaction_engine.compact_leaf = AsyncMock(
            side_effect=RuntimeError("compaction boom")
        )

        engine._db = MagicMock()
        mock_conn = AsyncMock()
        mock_conn.execute = AsyncMock()
        mock_conn.commit = AsyncMock()
        engine._db.conn = mock_conn

        summarize_fn = AsyncMock(return_value="summary")

        with pytest.raises(RuntimeError, match="compaction boom"):
            await engine.compact(summarize_fn=summarize_fn)

        # Even though compaction failed, mark_compaction_end should have been called
        calls = mock_conn.execute.call_args_list
        sql_calls = [c[0][0] for c in calls]
        assert any("compaction_in_progress = 0" in sql for sql in sql_calls), \
            "Should call mark_compaction_end even on error"
