"""
Tests for HippoEngine lifecycle hooks.

Issue: #802, #1162
"""

from __future__ import annotations

import asyncio

import pytest

from unittest.mock import AsyncMock, MagicMock, patch

from cortex.hippo.compaction.engine import CompactionEngine
from cortex.hippo.compaction.types import CompactionResult as CECompactionResult
from cortex.hippo.config import HippoConfig
from cortex.hippo.engine import HippoEngine
from cortex.hippo.models import IngestResult, CompactionDecision


@pytest.mark.asyncio
async def test_engine_initialize():
    """Engine initializes with in-memory DB."""
    eng = HippoEngine(HippoConfig(db_path=":memory:"))
    conv = await eng.initialize(
        db_path=":memory:",
        owner_id="test-owner",
        conversation_id="test-session",
    )
    assert conv.owner_id == "test-owner"
    assert conv.session_id == "test-session"
    assert conv.conversation_id > 0
    await eng.shutdown()


@pytest.mark.asyncio
async def test_engine_initialize_creates_conversation():
    """Initialize creates a new conversation record."""
    eng = HippoEngine(HippoConfig(db_path=":memory:"))
    conv = await eng.initialize(
        db_path=":memory:",
        owner_id="owner1",
        conversation_id="session-abc",
    )
    assert conv.conversation_id is not None
    assert conv.session_id == "session-abc"
    await eng.shutdown()


@pytest.mark.asyncio
async def test_engine_ingest_message(engine: HippoEngine):
    """Ingest a message and verify it's stored."""
    result = await engine.ingest_message("user", "Hello, Hippo!")
    assert isinstance(result, IngestResult)
    assert result.message_id > 0
    assert result.token_count > 0
    assert result.seq == 1


@pytest.mark.asyncio
async def test_engine_ingest_multiple_messages(engine: HippoEngine):
    """Ingest multiple messages with incrementing seq."""
    r1 = await engine.ingest_message("user", "First message")
    r2 = await engine.ingest_message("assistant", "Second message")
    r3 = await engine.ingest_message("user", "Third message")

    assert r1.seq == 1
    assert r2.seq == 2
    assert r3.seq == 3


@pytest.mark.asyncio
async def test_engine_get_context_empty(engine: HippoEngine):
    """Get context with no messages returns empty."""
    result = await engine.get_context()
    assert result.total_tokens == 0
    assert result.messages == []


@pytest.mark.asyncio
async def test_engine_get_context_with_messages(engine: HippoEngine):
    """Get context returns ingested messages."""
    await engine.ingest_message("user", "Hello")
    await engine.ingest_message("assistant", "Hi there!")

    result = await engine.get_context()
    assert len(result.messages) == 2
    assert result.messages[0]["role"] == "user"
    assert result.messages[1]["role"] == "assistant"
    assert result.total_tokens > 0


@pytest.mark.asyncio
async def test_engine_should_compact_below_threshold(engine: HippoEngine):
    """Should not compact when below threshold."""
    await engine.ingest_message("user", "Short message")
    decision = await engine.should_compact()
    assert isinstance(decision, CompactionDecision)
    assert decision.should_compact is False


@pytest.mark.asyncio
async def test_engine_should_compact_above_threshold():
    """Should compact when above threshold."""
    config = HippoConfig(
        db_path=":memory:",
        max_tokens=1000,  # Small budget
        context_threshold=0.1,  # Very low threshold
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    # Ingest enough to exceed threshold (100 tokens)
    for i in range(30):
        await eng.ingest_message("user", f"Message number {i} with some extra content to pad tokens and more words here")

    decision = await eng.should_compact()
    assert decision.should_compact is True
    assert decision.current_tokens > decision.threshold_tokens

    await eng.shutdown()


@pytest.mark.asyncio
async def test_engine_compact_without_summarize_fn_noop(engine: HippoEngine):
    """Compact without summarize_fn returns empty result (no-op)."""
    result = await engine.compact()
    assert result.summaries_created == 0
    assert result.messages_compacted == 0


@pytest.mark.asyncio
async def test_engine_get_stats(engine: HippoEngine):
    """Get stats returns correct counts."""
    await engine.ingest_message("user", "First")
    await engine.ingest_message("assistant", "Second")

    stats = await engine.get_stats()
    assert stats.message_count == 2
    assert stats.summary_count == 0
    assert stats.context_item_count == 2
    assert stats.total_message_tokens > 0


@pytest.mark.asyncio
async def test_engine_not_initialized_raises():
    """Operations before initialize() raise RuntimeError."""
    eng = HippoEngine()

    with pytest.raises(RuntimeError, match="not initialized"):
        await eng.ingest_message("user", "test")

    with pytest.raises(RuntimeError, match="not initialized"):
        await eng.get_context()

    with pytest.raises(RuntimeError, match="not initialized"):
        await eng.should_compact()


@pytest.mark.asyncio
async def test_engine_shutdown_and_reinitialize():
    """Engine can be shut down and reinitialized."""
    eng = HippoEngine(HippoConfig(db_path=":memory:"))
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")
    await eng.ingest_message("user", "before shutdown")
    await eng.shutdown()

    # After shutdown, should raise
    with pytest.raises(RuntimeError):
        await eng.ingest_message("user", "after shutdown")


@pytest.mark.asyncio
async def test_engine_resume_conversation():
    """Same session_id resumes existing conversation."""
    eng = HippoEngine(HippoConfig(db_path=":memory:"))
    conv1 = await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="same-session")
    conv1_id = conv1.conversation_id

    # Re-initialize with same session — should get same conversation
    # (Note: with :memory: this creates a new DB, so we test the logic path)
    await eng.shutdown()

    eng2 = HippoEngine(HippoConfig(db_path=":memory:"))
    conv2 = await eng2.initialize(db_path=":memory:", owner_id="test", conversation_id="same-session")
    # In-memory DBs are independent, so conv2 gets a fresh conversation
    # The important thing is the engine doesn't crash
    assert conv2.session_id == "same-session"
    await eng2.shutdown()


@pytest.mark.asyncio
async def test_engine_multi_tenant_isolation():
    """Different owners get separate conversations."""
    eng1 = HippoEngine(HippoConfig(db_path=":memory:"))
    conv1 = await eng1.initialize(
        db_path=":memory:", owner_id="owner-a", conversation_id="shared-session"
    )
    await eng1.ingest_message("user", "Owner A message")

    eng2 = HippoEngine(HippoConfig(db_path=":memory:"))
    conv2 = await eng2.initialize(
        db_path=":memory:", owner_id="owner-b", conversation_id="shared-session"
    )

    # Owner B should have no messages
    stats = await eng2.get_stats()
    assert stats.message_count == 0

    await eng1.shutdown()
    await eng2.shutdown()


# ---------------------------------------------------------------------------
# W2-2: Per-session operation serialization guard tests (#1153)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_concurrent_ingest_same_conversation_serialized(engine: HippoEngine):
    """Concurrent ingests to the same conversation are serialized — seqs are contiguous."""
    results = await asyncio.gather(
        engine.ingest_message("user", "msg-A"),
        engine.ingest_message("user", "msg-B"),
        engine.ingest_message("user", "msg-C"),
    )
    seqs = sorted(r.seq for r in results)
    assert seqs == [1, 2, 3], f"Expected contiguous seqs [1,2,3], got {seqs}"


@pytest.mark.asyncio
async def test_concurrent_ops_different_conversations_independent():
    """Ops on different conversations don't block each other."""
    eng1 = HippoEngine(HippoConfig(db_path=":memory:"))
    await eng1.initialize(db_path=":memory:", owner_id="test", conversation_id="conv-1")

    eng2 = HippoEngine(HippoConfig(db_path=":memory:"))
    await eng2.initialize(db_path=":memory:", owner_id="test", conversation_id="conv-2")

    # Both should complete without deadlock (timeout would catch it)
    r1, r2 = await asyncio.wait_for(
        asyncio.gather(
            eng1.ingest_message("user", "hello from conv-1"),
            eng2.ingest_message("user", "hello from conv-2"),
        ),
        timeout=5.0,
    )

    assert r1.seq == 1
    assert r2.seq == 1

    await eng1.shutdown()
    await eng2.shutdown()


@pytest.mark.asyncio
async def test_session_lock_cleanup_after_operations(engine: HippoEngine):
    """Lock is retained in _session_locks after operations (safe: no eager deletion).

    Previously the lock was deleted after release, but that was unsafe — asyncio.Lock
    can return locked()=False while waiters are still queued, causing silent failures.
    Locks now persist for the lifetime of the engine, preventing unbounded-growth
    concerns via the engine's own lifecycle management.
    """
    await engine.ingest_message("user", "trigger lock creation")

    # After the operation completes, the lock should still exist (retained safely)
    conv_id = engine._conversation.conversation_id
    assert conv_id in engine._session_locks, (
        f"Lock for conversation {conv_id} should be retained in _session_locks"
    )
    # And it should be unlocked (not held)
    assert not engine._session_locks[conv_id].locked(), (
        "Lock should be released after operation completes"
    )


@pytest.mark.asyncio
async def test_session_lock_exists_during_operation(engine: HippoEngine):
    """Lock exists in _session_locks while an operation is in progress."""
    lock_seen = False
    original_create = engine._conversation_store.create_message

    async def spy_create(*args, **kwargs):
        nonlocal lock_seen
        conv_id = engine._conversation.conversation_id
        lock = engine._session_locks.get(conv_id)
        if lock is not None and lock.locked():
            lock_seen = True
        return await original_create(*args, **kwargs)

    engine._conversation_store.create_message = spy_create

    await engine.ingest_message("user", "check lock during op")
    assert lock_seen, "Lock should be held during operation execution"


@pytest.mark.asyncio
async def test_compact_uses_session_lock(engine: HippoEngine):
    """compact() also goes through the session lock."""
    lock_was_held = False

    conv_id = engine._conversation.conversation_id
    original_with_lock = engine._with_session_lock

    async def tracking_lock(cid, fn):
        nonlocal lock_was_held
        if cid == conv_id:
            lock_was_held = True
        return await original_with_lock(cid, fn)

    engine._with_session_lock = tracking_lock

    # Provide a summarize_fn so compact() doesn't short-circuit as no-op
    engine._summarize_fn = AsyncMock(return_value=None)
    # Mock compact_leaf to avoid needing real summarization
    engine._compaction_engine.compact_leaf = AsyncMock(
        return_value=CECompactionResult(action_taken=False, tokens_before=0, tokens_after=0)
    )

    await engine.compact()
    assert lock_was_held, "compact() should use the session lock"


# ---------------------------------------------------------------------------
# W2-4: afterTurn dual-trigger tests (#1155)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_after_turn_ingests_new_messages(engine: HippoEngine):
    """after_turn ingests only messages after pre_prompt_count."""
    conv_id = engine._conversation.conversation_id
    messages = [
        {"role": "system", "content": "You are helpful."},
        {"role": "user", "content": "Hello"},
        {"role": "assistant", "content": "Hi there!"},
        {"role": "user", "content": "New user msg"},
        {"role": "assistant", "content": "New assistant msg"},
    ]

    await engine.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=3,  # only last 2 are new
    )

    stats = await engine.get_stats()
    assert stats.message_count == 2


@pytest.mark.asyncio
async def test_after_turn_triggers_compaction_when_budget_set():
    """after_turn evaluates compaction via CompactionEngine when token_budget is provided (legacy path)."""
    from cortex.hippo.compaction.types import CompactionDecision as CEDecision

    # Use legacy path (adaptive_scheduling_enabled=False) to test the old trigger
    config = HippoConfig(
        enabled=True,
        db_path=":memory:",
        fresh_tail_count=4,
        max_tokens=10_000,
        context_threshold=0.75,
        context_window_tokens=100,
        leaf_trigger_margin=100,
        adaptive_scheduling_enabled=False,
    )
    engine = HippoEngine(config=config)
    await engine.initialize(db_path=":memory:", owner_id="test-owner", conversation_id="test-session")

    conv_id = engine._conversation.conversation_id
    evaluate_called = False

    async def tracking_evaluate(*args, **kwargs):
        nonlocal evaluate_called
        evaluate_called = True
        return CEDecision(
            should_compact=False,
            reason="under threshold",
            current_tokens=100,
            threshold=5000,
        )

    engine._compaction_engine.evaluate = tracking_evaluate

    # Mock evaluate_leaf_trigger to pass the gate
    async def always_trigger(messages):
        return (True, 9000)

    engine.evaluate_leaf_trigger = always_trigger

    messages = [
        {"role": "user", "content": "Hello"},
        {"role": "assistant", "content": "Hi"},
    ]

    await engine.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
        token_budget=5000,
    )

    assert evaluate_called, "CompactionEngine.evaluate() must be called when token_budget is set"
    await engine.shutdown()


@pytest.mark.asyncio
async def test_after_turn_skips_compaction_without_budget(engine: HippoEngine):
    """after_turn does NOT evaluate compaction when token_budget is None."""
    conv_id = engine._conversation.conversation_id
    evaluate_called = False

    async def tracking_evaluate(*args, **kwargs):
        nonlocal evaluate_called
        evaluate_called = True

    engine._compaction_engine.evaluate = tracking_evaluate

    messages = [{"role": "user", "content": "Hello"}]

    await engine.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
        # no token_budget
    )

    assert not evaluate_called, "CompactionEngine.evaluate() should NOT be called without token_budget"


@pytest.mark.asyncio
async def test_after_turn_handles_ingest_failure_gracefully(engine: HippoEngine):
    """after_turn continues even if one message fails to ingest."""
    conv_id = engine._conversation.conversation_id
    call_count = 0
    original_create = engine._conversation_store.create_message

    async def flaky_create(**kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise RuntimeError("Simulated ingest failure")
        return await original_create(**kwargs)

    engine._conversation_store.create_message = flaky_create

    messages = [
        {"role": "user", "content": "This will fail"},
        {"role": "assistant", "content": "This will succeed"},
    ]

    # Should not raise
    await engine.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
    )

    # Second message should have been ingested despite first failure
    stats = await engine.get_stats()
    assert stats.message_count == 1


# ---------------------------------------------------------------------------
# W2-3: Heartbeat pruning tests (#1154)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_prune_heartbeat_ok_turns():
    """_prune_heartbeat_ok_turns removes heartbeat turn-pairs."""
    engine = HippoEngine(config=HippoConfig(enabled=True, db_path=":memory:"))
    messages = [
        {"role": "user", "content": "What's up?"},
        {"role": "assistant", "content": "Not much!"},
        {"role": "user", "content": "[heartbeat] Are you there?"},
        {"role": "assistant", "content": "HEARTBEAT_OK"},
        {"role": "user", "content": "Real question here"},
        {"role": "assistant", "content": "Real answer"},
    ]

    result = engine._prune_heartbeat_ok_turns(messages)

    assert len(result) == 4
    assert result[0]["content"] == "What's up?"
    assert result[1]["content"] == "Not much!"
    assert result[2]["content"] == "Real question here"
    assert result[3]["content"] == "Real answer"


@pytest.mark.asyncio
async def test_prune_heartbeat_ok_turns_no_match():
    """_prune_heartbeat_ok_turns is a no-op when no heartbeat pairs exist."""
    engine = HippoEngine(config=HippoConfig(enabled=True, db_path=":memory:"))
    messages = [
        {"role": "user", "content": "Hello"},
        {"role": "assistant", "content": "Hi"},
    ]

    result = engine._prune_heartbeat_ok_turns(messages)
    assert len(result) == 2


@pytest.mark.asyncio
async def test_after_turn_prunes_heartbeats_when_is_heartbeat(engine: HippoEngine):
    """after_turn prunes heartbeat pairs when is_heartbeat=True."""
    conv_id = engine._conversation.conversation_id
    messages = [
        {"role": "user", "content": "[heartbeat] check"},
        {"role": "assistant", "content": "HEARTBEAT_OK"},
        {"role": "user", "content": "Real message"},
    ]

    await engine.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
        is_heartbeat=True,
    )

    # Only the real message should be ingested (heartbeat pair pruned)
    stats = await engine.get_stats()
    assert stats.message_count == 1


# ---------------------------------------------------------------------------
# W3-1: Wire compact() to CompactionEngine tests (#1162)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_engine_compact_delegates_to_compaction_engine():
    """compact() delegates to CompactionEngine.compact_leaf (or compact_full_sweep)."""
    mock_summarize = AsyncMock(return_value=None)

    eng = HippoEngine(
        config=HippoConfig(db_path=":memory:", enabled=True),
        summarize_fn=mock_summarize,
    )
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    # Patch the compaction engine's compact_leaf
    ce_result = CECompactionResult(
        action_taken=True,
        tokens_before=500,
        tokens_after=200,
        created_summary_ids=["sum_test_1"],
        rounds=1,
    )
    eng._compaction_engine.compact_leaf = AsyncMock(return_value=ce_result)

    result = await eng.compact(mode="leaf")

    eng._compaction_engine.compact_leaf.assert_called_once()
    assert result.summaries_created == 1
    assert result.tokens_before == 500
    assert result.tokens_after == 200
    assert result.rounds == 1

    await eng.shutdown()


@pytest.mark.asyncio
async def test_engine_compact_full_mode_delegates():
    """compact(mode='full') delegates to CompactionEngine.compact_full_sweep."""
    mock_summarize = AsyncMock(return_value=None)

    eng = HippoEngine(
        config=HippoConfig(db_path=":memory:", enabled=True),
        summarize_fn=mock_summarize,
    )
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    ce_result = CECompactionResult(
        action_taken=True,
        tokens_before=1000,
        tokens_after=400,
        created_summary_ids=["sum_a", "sum_b"],
        condensed=True,
        rounds=2,
    )
    eng._compaction_engine.compact_full_sweep = AsyncMock(return_value=ce_result)

    result = await eng.compact(mode="full")

    eng._compaction_engine.compact_full_sweep.assert_called_once()
    assert result.summaries_created == 2
    assert result.rounds == 2

    await eng.shutdown()


@pytest.mark.asyncio
async def test_engine_compact_without_summarize_fn_returns_noop():
    """compact() without summarize_fn returns empty CompactionResult."""
    eng = HippoEngine(config=HippoConfig(db_path=":memory:"))
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    result = await eng.compact()
    assert result.summaries_created == 0
    assert result.tokens_before == 0

    await eng.shutdown()


@pytest.mark.asyncio
async def test_after_turn_fires_real_compaction():
    """after_turn uses CompactionEngine.evaluate() + compact_leaf() when budget set."""
    from cortex.hippo.compaction.types import CompactionDecision as CEDecision

    mock_summarize = AsyncMock(return_value=None)
    eng = HippoEngine(
        config=HippoConfig(
            db_path=":memory:", max_tokens=10_000, enabled=True,
            adaptive_scheduling_enabled=False,  # test legacy path
        ),
        summarize_fn=mock_summarize,
    )
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")
    conv_id = eng._conversation.conversation_id

    # Mock evaluate_leaf_trigger to pass the gate
    eng.evaluate_leaf_trigger = AsyncMock(return_value=(True, 9000))

    # Make evaluate say compaction is needed
    eng._compaction_engine.evaluate = AsyncMock(
        return_value=CEDecision(
            should_compact=True,
            reason="tokens over threshold",
            current_tokens=8000,
            threshold=7500,
        )
    )
    # Mock compact_leaf so it doesn't actually run
    eng._compaction_engine.compact_leaf = AsyncMock(
        return_value=CECompactionResult(action_taken=True, rounds=1)
    )

    messages = [
        {"role": "user", "content": "Hello"},
        {"role": "assistant", "content": "Hi there"},
    ]

    await eng.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
        token_budget=10_000,
    )

    # Allow the background task to run
    await asyncio.sleep(0.1)

    eng._compaction_engine.evaluate.assert_called_once()
    eng._compaction_engine.compact_leaf.assert_called_once()

    await eng.shutdown()


@pytest.mark.asyncio
async def test_engine_should_compact_delegates_to_compaction_engine():
    """should_compact() still works via the legacy path."""
    eng = HippoEngine(config=HippoConfig(db_path=":memory:", max_tokens=1000, context_threshold=0.1))
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    # Ingest enough messages to exceed threshold
    for i in range(30):
        await eng.ingest_message("user", f"Message {i} with padding words to increase tokens")

    decision = await eng.should_compact()
    assert decision.should_compact is True

    await eng.shutdown()


# ---------------------------------------------------------------------------
# W3-4: bootstrap() tests (#1165)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_bootstrap_fresh(tmp_path):
    """bootstrap() imports messages from a session file into a fresh conversation."""
    import json

    session_file = tmp_path / "session.json"
    session_file.write_text(
        json.dumps(
            [
                {"role": "user", "content": "Hello from file"},
                {"role": "assistant", "content": "Hi there!"},
            ]
        )
    )

    eng = HippoEngine(HippoConfig(db_path=":memory:"))
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="boot-sess")

    result = await eng.bootstrap("boot-sess", str(session_file))

    assert result["bootstrapped"] is True
    assert result["message_count"] == 2

    await eng.shutdown()


@pytest.mark.asyncio
async def test_bootstrap_already_done(tmp_path):
    """bootstrap() is a no-op if conversation is already bootstrapped."""
    import json

    session_file = tmp_path / "session.json"
    session_file.write_text(
        json.dumps([{"role": "user", "content": "Hello"}])
    )

    eng = HippoEngine(HippoConfig(db_path=":memory:"))
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="boot-sess")

    # First bootstrap
    result1 = await eng.bootstrap("boot-sess", str(session_file))
    assert result1["bootstrapped"] is True

    # Second bootstrap — should be no-op
    result2 = await eng.bootstrap("boot-sess", str(session_file))
    assert result2["bootstrapped"] is False
    assert result2["message_count"] == 0

    await eng.shutdown()


# ---------------------------------------------------------------------------
# W3-5: evaluate_leaf_trigger tests (#1166)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_evaluate_leaf_trigger_above():
    """evaluate_leaf_trigger returns True when tokens exceed threshold."""
    config = HippoConfig(
        db_path=":memory:",
        context_window_tokens=1000,
        leaf_trigger_margin=100,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    # Create messages that exceed 900 tokens (threshold = 1000 - 100)
    # Each char ~0.25 tokens, so 4000 chars ≈ 1000 tokens
    big_msg = {"role": "user", "content": "x" * 4000}
    should_fire, token_count = await eng.evaluate_leaf_trigger([big_msg])

    assert should_fire is True
    assert token_count > 900

    await eng.shutdown()


@pytest.mark.asyncio
async def test_evaluate_leaf_trigger_below():
    """evaluate_leaf_trigger returns False when tokens are under threshold."""
    config = HippoConfig(
        db_path=":memory:",
        context_window_tokens=200_000,
        leaf_trigger_margin=20_000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    small_msg = {"role": "user", "content": "Hello"}
    should_fire, token_count = await eng.evaluate_leaf_trigger([small_msg])

    assert should_fire is False
    assert token_count < 180_000

    await eng.shutdown()


# ---------------------------------------------------------------------------
# W3-10: autoCompactionSummary injection in afterTurn (#1171)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_after_turn_injects_compaction_summary():
    """after_turn injects a user-role compaction summary message after successful compaction."""
    from cortex.hippo.compaction.types import CompactionDecision as CEDecision

    eng = HippoEngine(
        config=HippoConfig(
            db_path=":memory:", max_tokens=10_000, auto_compaction_summary=True, enabled=True,
            adaptive_scheduling_enabled=False,  # test legacy path
        ),
        summarize_fn=AsyncMock(return_value=None),
    )
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")
    conv_id = eng._conversation.conversation_id

    # Mock evaluate_leaf_trigger to pass the gate
    eng.evaluate_leaf_trigger = AsyncMock(return_value=(True, 9000))

    # Make evaluate say compaction is needed
    eng._compaction_engine.evaluate = AsyncMock(
        return_value=CEDecision(
            should_compact=True,
            reason="tokens over threshold",
            current_tokens=8000,
            threshold=7500,
        )
    )

    # Mock compact_leaf to return a result with summaries created
    eng._compaction_engine.compact_leaf = AsyncMock(
        return_value=CECompactionResult(
            action_taken=True,
            created_summary_ids=["sum_001", "sum_002"],
            messages_compacted=12,
            rounds=1,
        )
    )

    messages = [
        {"role": "user", "content": "Hello"},
        {"role": "assistant", "content": "Hi there"},
    ]

    await eng.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
        token_budget=10_000,
    )

    # Allow the background task to run
    await asyncio.sleep(0.1)

    # Verify the compaction summary message was injected
    all_msgs = await eng.conversation_store.get_messages(conv_id, "test")
    summary_msgs = [
        m for m in all_msgs
        if "Context was compacted" in m.content
    ]
    assert len(summary_msgs) == 1
    assert "12 messages were summarized into 2 summaries" in summary_msgs[0].content
    assert summary_msgs[0].role == "user"

    await eng.shutdown()


@pytest.mark.asyncio
async def test_after_turn_no_summary_when_disabled():
    """after_turn does NOT inject summary when auto_compaction_summary=False."""
    from cortex.hippo.compaction.types import CompactionDecision as CEDecision

    eng = HippoEngine(
        config=HippoConfig(
            db_path=":memory:", max_tokens=10_000, auto_compaction_summary=False, enabled=True,
            adaptive_scheduling_enabled=False,  # test legacy path
        ),
        summarize_fn=AsyncMock(return_value=None),
    )
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")
    conv_id = eng._conversation.conversation_id

    eng.evaluate_leaf_trigger = AsyncMock(return_value=(True, 9000))
    eng._compaction_engine.evaluate = AsyncMock(
        return_value=CEDecision(
            should_compact=True,
            reason="over threshold",
            current_tokens=8000,
            threshold=7500,
        )
    )
    eng._compaction_engine.compact_leaf = AsyncMock(
        return_value=CECompactionResult(
            action_taken=True,
            created_summary_ids=["sum_001"],
            messages_compacted=5,
            rounds=1,
        )
    )

    messages = [
        {"role": "user", "content": "Hello"},
        {"role": "assistant", "content": "Hi"},
    ]

    await eng.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
        token_budget=10_000,
    )

    await asyncio.sleep(0.1)

    all_msgs = await eng.conversation_store.get_messages(conv_id, "test")
    summary_msgs = [m for m in all_msgs if "Context was compacted" in m.content]
    assert len(summary_msgs) == 0, "No summary should be injected when auto_compaction_summary=False"

    await eng.shutdown()


@pytest.mark.asyncio
async def test_after_turn_no_summary_when_no_compaction():
    """after_turn does NOT inject summary when compaction didn't fire."""
    from cortex.hippo.compaction.types import CompactionDecision as CEDecision

    eng = HippoEngine(
        config=HippoConfig(
            db_path=":memory:", max_tokens=10_000, auto_compaction_summary=True, enabled=True,
            adaptive_scheduling_enabled=False,  # test legacy path
        ),
        summarize_fn=AsyncMock(return_value=None),
    )
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")
    conv_id = eng._conversation.conversation_id

    eng.evaluate_leaf_trigger = AsyncMock(return_value=(True, 9000))
    # Evaluate says no compaction needed
    eng._compaction_engine.evaluate = AsyncMock(
        return_value=CEDecision(
            should_compact=False,
            reason="under threshold",
            current_tokens=3000,
            threshold=7500,
        )
    )

    messages = [
        {"role": "user", "content": "Hello"},
        {"role": "assistant", "content": "Hi"},
    ]

    await eng.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
        token_budget=10_000,
    )

    await asyncio.sleep(0.1)

    all_msgs = await eng.conversation_store.get_messages(conv_id, "test")
    summary_msgs = [m for m in all_msgs if "Context was compacted" in m.content]
    assert len(summary_msgs) == 0, "No summary should be injected when compaction didn't fire"

    await eng.shutdown()


# ═══════════════════════════════════════════════════════════════════════════
# PR Review Fix: background compaction acquires session lock (#1183)
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_background_compaction_acquires_session_lock():
    """Background compaction tasks spawned by after_turn must acquire the
    session lock to prevent races with concurrent operations."""
    config = HippoConfig(
        db_path=":memory:",
        max_tokens=2000,
        enabled=True,
        adaptive_scheduling_enabled=False,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="lock-test")
    conv_id = eng._conversation.conversation_id

    # Track lock acquisitions from background tasks
    bg_lock_calls = []
    original_with_lock = eng._with_session_lock

    async def tracking_lock(cid, fn):
        bg_lock_calls.append(cid)
        return await original_with_lock(cid, fn)

    # Set up so compaction triggers
    eng._summarize_fn = AsyncMock(return_value="Summary text")
    eng._compaction_engine.evaluate = AsyncMock(
        return_value=CompactionDecision(should_compact=True, threshold=100)
    )
    eng._compaction_engine.compact_leaf = AsyncMock(
        return_value=CECompactionResult(
            action_taken=True,
            tokens_before=2000,
            tokens_after=800,
            created_summary_ids=["sum_1"],
        )
    )
    eng.evaluate_leaf_trigger = AsyncMock(return_value=(True, 2000))

    messages = [
        {"role": "user", "content": "Hello " * 50},
        {"role": "assistant", "content": "Response " * 50},
    ]

    # Install tracking lock
    eng._with_session_lock = tracking_lock

    await eng.after_turn(
        conversation_id=conv_id,
        messages=messages,
        pre_prompt_count=0,
        token_budget=2000,
    )

    # Give background task time to run
    await asyncio.sleep(0.3)

    # after_turn itself acquires the lock (1 call), and the background
    # compaction task should also acquire it (2nd call)
    assert len(bg_lock_calls) >= 2, (
        f"Expected at least 2 lock acquisitions (after_turn + bg task), got {len(bg_lock_calls)}"
    )

    await eng.shutdown()
