"""
Tests for cortex/hippo/compaction.py — Compaction engine.

Issue: #804
"""

from __future__ import annotations

import pytest
import pytest_asyncio

from cortex.hippo.compaction import (
    CompactionEngine,
    estimate_tokens,
    generate_summary_id,
)
from cortex.hippo.config import HippoConfig
from cortex.hippo.engine import HippoEngine
from cortex.hippo.summarize import SummarizeOptions, SummarizeResult


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


@pytest.fixture
def compaction_config() -> HippoConfig:
    return HippoConfig(
        enabled=True,
        db_path=":memory:",
        fresh_tail_count=4,
        max_tokens=10_000,
        context_threshold=0.75,
        leaf_chunk_tokens=500,
        leaf_min_fanout=2,
        condensed_min_fanout=2,
        condensed_min_fanout_hard=2,
        condensed_min_chunk_tokens=50,
        max_rounds=5,
        fallback_max_tokens=128,
        incremental_condensed_max_depth=0,  # Disable for base tests
    )


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


@pytest_asyncio.fixture
async def compaction_engine(engine: HippoEngine):
    return CompactionEngine(
        conversation_store=engine.conversation_store,
        summary_store=engine.summary_store,
        config=engine.config,
    )


async def _mock_summarize(text: str, aggressive: bool, options=None) -> SummarizeResult:
    """Mock summarize that returns a short summary."""
    content = f"Summary of {len(text)} chars. Expand for details about: dropped content"
    return SummarizeResult(
        content=content,
        quality_score=0.8,
        quality_breakdown={"compression": 0.8, "coherence": 0.8, "coverage": 0.8},
        source="content",
        mode_used="aggressive" if aggressive else "normal",
    )


async def _mock_summarize_verbose(text: str, aggressive: bool, options=None) -> SummarizeResult:
    """Mock summarize that returns text longer than input (triggers escalation)."""
    # Return text much longer than input
    long_content = text + " extra verbose content " * 50
    return SummarizeResult(
        content=long_content,
        quality_score=0.3,
        quality_breakdown={"compression": 0.1, "coherence": 0.5, "coverage": 0.3},
        source="content",
        mode_used="aggressive" if aggressive else "normal",
    )


# ═══════════════════════════════════════════════════════════════════════════
# Unit tests: helpers
# ═══════════════════════════════════════════════════════════════════════════


def test_estimate_tokens():
    """Token estimation: ~4 chars per token."""
    assert estimate_tokens("") == 1  # min 1
    assert estimate_tokens("a" * 100) == 25
    assert estimate_tokens("a" * 7) == 2  # ceiling division


def test_generate_summary_id():
    """Summary IDs start with 'sum_' and are 20 chars."""
    sid = generate_summary_id("test content")
    assert sid.startswith("sum_")
    assert len(sid) == 20  # sum_ + 16 hex chars


def test_generate_summary_id_unique():
    """Two calls with same content produce different IDs (time entropy)."""
    id1 = generate_summary_id("same")
    id2 = generate_summary_id("same")
    # They should differ due to time.time_ns() entropy
    # (In extremely fast execution they might collide, so we just check format)
    assert id1.startswith("sum_")
    assert id2.startswith("sum_")


# ═══════════════════════════════════════════════════════════════════════════
# Integration tests: leaf pass
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_leaf_pass_converts_messages_to_summary(engine, compaction_engine):
    """Messages outside fresh tail get compacted to a leaf summary."""
    conv = engine._conversation

    # Ingest 8 messages: 4 will be outside fresh_tail (fresh_tail_count=4)
    for i in range(8):
        await engine.ingest_message("user", f"Message content number {i} with some text to fill tokens")

    result = await compaction_engine.compact_leaf(
        conversation_id=conv.conversation_id,
        token_budget=10_000,
        summarize=_mock_summarize,
        force=True,
    )

    assert result.action_taken is True
    assert len(result.created_summary_ids) == 1
    assert result.created_summary_ids[0].startswith("sum_")

    # Verify context items now have a summary + remaining messages
    items = await engine.summary_store.get_context_items(conv.conversation_id)
    types = [item.item_type for item in items]
    assert "summary" in types
    # Should still have fresh tail messages
    msg_count = types.count("message")
    assert msg_count >= 4  # fresh tail preserved


@pytest.mark.asyncio
async def test_leaf_chunk_selects_oldest_messages(engine, compaction_engine):
    """Chunk selection picks oldest messages, not newest."""
    conv = engine._conversation

    for i in range(8):
        await engine.ingest_message("user", f"Message {i}")

    chunk = await compaction_engine._select_oldest_leaf_chunk(conv.conversation_id)
    assert len(chunk.items) > 0
    # Should be the oldest messages (lowest ordinals)
    ordinals = [item.ordinal for item in chunk.items]
    assert ordinals == sorted(ordinals)
    assert ordinals[0] == 0  # Starts from the beginning


@pytest.mark.asyncio
async def test_fresh_tail_protected(engine, compaction_engine):
    """Messages within fresh_tail_count are never included in leaf chunk."""
    conv = engine._conversation

    for i in range(8):
        await engine.ingest_message("user", f"Message {i}")

    chunk = await compaction_engine._select_oldest_leaf_chunk(conv.conversation_id)

    # fresh_tail_count=4, so ordinals 4-7 should not be in chunk
    all_context = await engine.summary_store.get_context_items(conv.conversation_id)
    max_ordinal = max(item.ordinal for item in all_context)
    fresh_start = max_ordinal - 3  # Last 4 items: max-3, max-2, max-1, max

    for item in chunk.items:
        assert item.ordinal < fresh_start, f"Fresh tail item {item.ordinal} was included in chunk"


@pytest.mark.asyncio
async def test_no_compaction_when_few_messages(engine, compaction_engine):
    """If only fresh_tail_count messages exist, no leaf chunk is selected."""
    conv = engine._conversation

    # Only 3 messages (less than fresh_tail_count=4)
    for i in range(3):
        await engine.ingest_message("user", f"Message {i}")

    chunk = await compaction_engine._select_oldest_leaf_chunk(conv.conversation_id)
    assert len(chunk.items) == 0


@pytest.mark.asyncio
async def test_compact_leaf_no_action_below_threshold(engine, compaction_engine):
    """compact_leaf returns action_taken=False when below threshold."""
    conv = engine._conversation
    for i in range(3):
        await engine.ingest_message("user", f"Message {i}")

    result = await compaction_engine.compact_leaf(
        conversation_id=conv.conversation_id,
        token_budget=100_000,  # Very high budget
        summarize=_mock_summarize,
    )
    assert result.action_taken is False


# ═══════════════════════════════════════════════════════════════════════════
# Integration tests: escalation
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_three_level_escalation_fallback(compaction_engine):
    """When summarize returns content longer than input, use deterministic fallback."""
    result = await compaction_engine._summarize_with_escalation(
        "Short input text.",
        _mock_summarize_verbose,
        SummarizeOptions(),
    )
    assert result.source == "fallback"
    assert "[Truncated" in result.content
    assert "Expand for details about:" in result.content


@pytest.mark.asyncio
async def test_three_level_escalation_normal_path(compaction_engine):
    """When summarize returns short content, no escalation needed."""
    result = await compaction_engine._summarize_with_escalation(
        "Long text content " * 100,
        _mock_summarize,
        SummarizeOptions(),
    )
    # _mock_summarize returns short content, so should succeed at normal level
    assert result.content
    assert estimate_tokens(result.content) < estimate_tokens("Long text content " * 100)


# ═══════════════════════════════════════════════════════════════════════════
# Integration tests: full sweep
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_compact_full_sweep_creates_leaf_summaries(engine, compaction_engine):
    """Full sweep Phase 1 converts messages to leaf summaries."""
    conv = engine._conversation

    for i in range(12):
        await engine.ingest_message("user", f"Message {i} with enough text to hit tokens " * 3)

    result = await compaction_engine.compact_full_sweep(
        conversation_id=conv.conversation_id,
        token_budget=10_000,
        summarize=_mock_summarize,
        force=True,
    )

    assert result.action_taken is True
    assert len(result.created_summary_ids) >= 1
    assert result.rounds >= 1


@pytest.mark.asyncio
async def test_compact_full_sweep_no_action_without_trigger(engine, compaction_engine):
    """Full sweep does nothing when below threshold and no leaf trigger."""
    conv = engine._conversation
    for i in range(3):
        await engine.ingest_message("user", f"Msg {i}")

    result = await compaction_engine.compact_full_sweep(
        conversation_id=conv.conversation_id,
        token_budget=1_000_000,  # Very high budget
        summarize=_mock_summarize,
    )
    assert result.action_taken is False


# ═══════════════════════════════════════════════════════════════════════════
# Integration tests: transaction atomicity
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_replace_context_range_atomic(engine):
    """replace_context_range_with_summary is atomic: DELETE + INSERT in one transaction."""
    conv = engine._conversation

    # Insert 5 messages
    for i in range(5):
        await engine.ingest_message("user", f"Message {i}")

    items_before = await engine.summary_store.get_context_items(conv.conversation_id)
    assert len(items_before) == 5

    # Create a summary to use
    summary = await engine.summary_store.create_summary(
        conversation_id=conv.conversation_id,
        owner_id="test-owner",
        kind="leaf",
        depth=0,
        content="Test summary",
        token_count=10,
    )

    # Replace ordinals 0-2 with summary
    await engine.summary_store.replace_context_range_with_summary(
        conv.conversation_id, 0, 2, summary.summary_id
    )

    items_after = await engine.summary_store.get_context_items(conv.conversation_id)
    # Should have: 1 summary + 2 remaining messages = 3 items
    assert len(items_after) == 3
    assert items_after[0].item_type == "summary"
    assert items_after[0].summary_id == summary.summary_id
    # Ordinals should be contiguous 0, 1, 2
    ordinals = [item.ordinal for item in items_after]
    assert ordinals == [0, 1, 2]


@pytest.mark.asyncio
async def test_replace_context_preserves_remaining_order(engine):
    """After replacing a range, remaining items keep their relative order."""
    conv = engine._conversation

    for i in range(6):
        await engine.ingest_message("user", f"Message {i}")

    summary = await engine.summary_store.create_summary(
        conversation_id=conv.conversation_id,
        owner_id="test-owner",
        kind="leaf",
        depth=0,
        content="Summary",
        token_count=5,
    )

    # Replace middle items (ordinals 2-3)
    await engine.summary_store.replace_context_range_with_summary(
        conv.conversation_id, 2, 3, summary.summary_id
    )

    items = await engine.summary_store.get_context_items(conv.conversation_id)
    # 2 messages before + 1 summary + 2 messages after = 5
    assert len(items) == 5
    types = [item.item_type for item in items]
    assert types == ["message", "message", "summary", "message", "message"]


# ═══════════════════════════════════════════════════════════════════════════
# Integration tests: condensed pass
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_condensed_pass_merges_leaf_summaries(engine, compaction_engine):
    """Multiple leaf summaries get merged into a condensed summary at depth+1."""
    conv = engine._conversation

    # Ingest many messages — each one big enough to form separate chunks
    # leaf_chunk_tokens=500 and each message ~50 tokens, so ~10 per chunk
    for i in range(30):
        await engine.ingest_message("user", f"Message {i} " + ("word " * 50))

    # Run multiple leaf passes to create several leaf summaries
    for _ in range(10):
        result = await compaction_engine.compact_leaf(
            conversation_id=conv.conversation_id,
            token_budget=10_000,
            summarize=_mock_summarize,
            force=True,
        )
        if not result.action_taken:
            break

    # Check we have some leaf summaries in context
    items = await engine.summary_store.get_context_items(conv.conversation_id)
    summary_items = [i for i in items if i.item_type == "summary"]
    assert len(summary_items) >= 2, f"Expected >=2 summaries, got {len(summary_items)}"

    # Now try condensation
    candidate = await compaction_engine._select_shallowest_condensation_candidate(
        conv.conversation_id, hard_trigger=True
    )

    if candidate is not None:
        pass_result = await compaction_engine._condensed_pass(
            conv.conversation_id,
            candidate.chunk.items,
            candidate.target_depth,
            _mock_summarize,
        )
        assert pass_result.summary_id.startswith("sum_")
        assert pass_result.content

        # Verify the condensed summary exists
        summary = await compaction_engine._get_summary_by_context_item(
            type("CI", (), {"summary_id": pass_result.summary_id})()
        )
        assert summary is not None
        assert summary.kind == "condensed"
        assert summary.depth == candidate.target_depth + 1


# ═══════════════════════════════════════════════════════════════════════════
# Evaluate
# ═══════════════════════════════════════════════════════════════════════════


# ═══════════════════════════════════════════════════════════════════════════
# Integration tests: compaction event persistence (W2-5)
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_leaf_pass_persists_compaction_event(engine, compaction_engine):
    """compact_leaf persists a system message recording the leaf compaction event."""
    conv = engine._conversation

    for i in range(8):
        await engine.ingest_message("user", f"Message content number {i} with some text to fill tokens")

    await compaction_engine.compact_leaf(
        conversation_id=conv.conversation_id,
        token_budget=10_000,
        summarize=_mock_summarize,
        force=True,
    )

    # Find the compaction event message
    msgs = await engine.conversation_store.get_messages(
        conv.conversation_id, "test-owner"
    )
    event_msgs = [m for m in msgs if m.role == "system" and "LCM compaction" in m.content]
    assert len(event_msgs) == 1
    assert "leaf pass" in event_msgs[0].content
    assert "->" in event_msgs[0].content
    assert event_msgs[0].token_count == 0  # Events don't count toward budget


@pytest.mark.asyncio
async def test_full_sweep_persists_compaction_events(engine, compaction_engine):
    """compact_full_sweep persists events for both leaf and condensed passes."""
    conv = engine._conversation

    # Ingest many messages to trigger both leaf and condensed passes
    for i in range(30):
        await engine.ingest_message("user", f"Message {i} " + ("word " * 50))

    result = await compaction_engine.compact_full_sweep(
        conversation_id=conv.conversation_id,
        token_budget=10_000,
        summarize=_mock_summarize,
        force=True,
        hard_trigger=True,
    )

    msgs = await engine.conversation_store.get_messages(
        conv.conversation_id, "test-owner"
    )
    event_msgs = [m for m in msgs if m.role == "system" and "LCM compaction" in m.content]
    # Should have at least a leaf event
    assert len(event_msgs) >= 1

    # If condensed pass occurred, should have a condensed event too
    if result.condensed:
        condensed_events = [m for m in event_msgs if "condensed pass" in m.content]
        assert len(condensed_events) >= 1


@pytest.mark.asyncio
async def test_compaction_event_has_valid_metadata(engine, compaction_engine):
    """Compaction event messages contain valid JSON metadata."""
    import json

    conv = engine._conversation

    for i in range(8):
        await engine.ingest_message("user", f"Message content {i} with some text to fill tokens")

    await compaction_engine.compact_leaf(
        conversation_id=conv.conversation_id,
        token_budget=10_000,
        summarize=_mock_summarize,
        force=True,
    )

    # Get the event message — need to query with metadata
    conn = engine.conversation_store._conn
    async with conn.execute(
        """
        SELECT metadata FROM hippo_messages
        WHERE conversation_id = ? AND role = 'system' AND metadata IS NOT NULL
        ORDER BY seq DESC LIMIT 1
        """,
        (conv.conversation_id,),
    ) as cur:
        row = await cur.fetchone()

    assert row is not None
    meta = json.loads(row[0])
    assert meta["type"] == "compaction_event"
    assert meta["pass"] == "leaf"
    assert "level" in meta
    assert "tokens_before" in meta
    assert "tokens_after" in meta
    assert "created_summary_id" in meta


@pytest.mark.asyncio
async def test_compaction_event_persistence_failure_doesnt_break_compaction(
    engine, compaction_engine
):
    """If event persistence fails, compaction still succeeds."""
    conv = engine._conversation

    for i in range(8):
        await engine.ingest_message("user", f"Message content {i} with some text to fill tokens")

    # Sabotage the conversation store to make create_message fail
    original_create = engine.conversation_store.create_message

    async def _broken_create(*args, **kwargs):
        if kwargs.get("role") == "system":
            raise RuntimeError("Simulated persistence failure")
        return await original_create(*args, **kwargs)

    engine.conversation_store.create_message = _broken_create

    # compact_leaf should still succeed
    result = await compaction_engine.compact_leaf(
        conversation_id=conv.conversation_id,
        token_budget=10_000,
        summarize=_mock_summarize,
        force=True,
    )

    assert result.action_taken is True
    assert len(result.created_summary_ids) == 1

    # Restore
    engine.conversation_store.create_message = original_create


# ═══════════════════════════════════════════════════════════════════════════
# Evaluate
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_evaluate_below_threshold(engine, compaction_engine):
    """evaluate returns should_compact=False when below threshold."""
    conv = engine._conversation
    for i in range(3):
        await engine.ingest_message("user", f"Msg {i}")

    decision = await compaction_engine.evaluate(
        conv.conversation_id, token_budget=100_000
    )
    assert decision.should_compact is False


@pytest.mark.asyncio
async def test_evaluate_above_threshold(engine, compaction_engine):
    """evaluate returns should_compact=True when above threshold."""
    conv = engine._conversation
    # Ingest lots of long messages to exceed threshold
    for i in range(50):
        await engine.ingest_message("user", "x" * 400)  # ~100 tokens each

    decision = await compaction_engine.evaluate(
        conv.conversation_id, token_budget=1000  # Low budget
    )
    assert decision.should_compact is True


# ═══════════════════════════════════════════════════════════════════════════
# Incremental condensed passes in compact_leaf (W2-6, #1157)
# ═══════════════════════════════════════════════════════════════════════════


@pytest.fixture
def condensed_config() -> HippoConfig:
    """Config tuned for incremental condensed tests: small chunks, low thresholds."""
    return HippoConfig(
        enabled=True,
        db_path=":memory:",
        fresh_tail_count=2,
        max_tokens=50_000,
        context_threshold=0.75,
        leaf_chunk_tokens=200,
        leaf_min_fanout=2,
        condensed_min_fanout=3,
        condensed_min_fanout_hard=2,
        condensed_min_chunk_tokens=10,
        max_rounds=10,
        fallback_max_tokens=128,
        incremental_condensed_max_depth=1,
    )


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


@pytest_asyncio.fixture
async def condensed_compaction_engine(condensed_engine: HippoEngine):
    return CompactionEngine(
        conversation_store=condensed_engine.conversation_store,
        summary_store=condensed_engine.summary_store,
        config=condensed_engine.config,
    )


@pytest.mark.asyncio
async def test_compact_leaf_triggers_condensed_when_enough_leaves(
    condensed_engine, condensed_compaction_engine
):
    """After enough leaf passes create >=fanout leaf summaries, compact_leaf triggers condensed."""
    conv = condensed_engine._conversation
    cid = conv.conversation_id

    # Ingest all messages up front so that multiple leaf passes create
    # contiguous leaf summaries (no interleaved raw messages breaking the run).
    # fresh_tail_count=2, leaf_chunk_tokens=200, each message ~30 tokens
    # We need enough messages for 3+ leaf chunks plus 2 fresh tail.
    for i in range(20):
        await condensed_engine.ingest_message(
            "user", f"Message {i} with enough text to fill " + "x" * 80
        )

    # Run multiple forced leaf passes — each compacts the next contiguous chunk.
    # Eventually there should be >=3 contiguous leaf summaries, triggering condensed.
    last_result = None
    for _ in range(6):
        result = await condensed_compaction_engine.compact_leaf(
            conversation_id=cid,
            token_budget=50_000,
            summarize=_mock_summarize,
            force=True,
        )
        if not result.action_taken:
            break
        last_result = result
        if result.condensed:
            break

    assert last_result is not None
    assert last_result.condensed is True
    assert last_result.rounds >= 2  # 1 leaf + 1 condensed

    # Verify a condensed summary (depth>0) exists
    summaries = await condensed_engine.summary_store.get_summaries_for_conversation(
        cid, "test-owner"
    )
    depths = [s.depth for s in summaries]
    assert max(depths) >= 1, f"Expected depth>=1 condensed summary, got depths={depths}"
    condensed_summaries = [s for s in summaries if s.kind == "condensed"]
    assert len(condensed_summaries) >= 1


@pytest.mark.asyncio
async def test_compact_leaf_skips_condensed_below_threshold(
    condensed_engine, condensed_compaction_engine
):
    """If fewer than condensed_min_fanout leaf summaries exist, no condensed pass runs."""
    conv = condensed_engine._conversation
    cid = conv.conversation_id

    # Only 1 leaf pass — not enough for condensed (need >=3)
    for i in range(4):
        await condensed_engine.ingest_message(
            "user", f"Message {i} with enough text to fill " + "x" * 80
        )
    result = await condensed_compaction_engine.compact_leaf(
        conversation_id=cid,
        token_budget=50_000,
        summarize=_mock_summarize,
        force=True,
    )

    assert result.action_taken is True
    assert result.condensed is False
    assert result.rounds == 1

    # No condensed summaries should exist
    summaries = await condensed_engine.summary_store.get_summaries_for_conversation(
        cid, "test-owner"
    )
    condensed_summaries = [s for s in summaries if s.kind == "condensed"]
    assert len(condensed_summaries) == 0


@pytest.mark.asyncio
async def test_incremental_condensed_pass_creates_higher_depth_summary(
    condensed_engine, condensed_compaction_engine
):
    """Incremental condensed pass merges depth-0 leaves into a depth-1 summary."""
    conv = condensed_engine._conversation
    cid = conv.conversation_id

    # Create 4 leaf summaries via forced leaf passes
    for batch in range(4):
        for i in range(4):
            await condensed_engine.ingest_message(
                "user", f"Batch {batch} msg {i} " + "y" * 80
            )
        await condensed_compaction_engine.compact_leaf(
            conversation_id=cid,
            token_budget=50_000,
            summarize=_mock_summarize,
            force=True,
        )

    # Verify the condensed summary has depth=1, kind='condensed'
    summaries = await condensed_engine.summary_store.get_summaries_for_conversation(
        cid, "test-owner"
    )
    condensed = [s for s in summaries if s.kind == "condensed"]
    assert len(condensed) >= 1

    highest = max(condensed, key=lambda s: s.depth)
    assert highest.depth == 1
    assert highest.kind == "condensed"
    assert highest.token_count > 0
    assert highest.descendant_count > 0


@pytest.mark.asyncio
async def test_incremental_condensed_disabled_when_max_depth_zero(
    condensed_engine, condensed_compaction_engine
):
    """When incremental_condensed_max_depth=0, no condensed pass runs after leaf."""
    # Override config
    condensed_compaction_engine.config.incremental_condensed_max_depth = 0

    conv = condensed_engine._conversation
    cid = conv.conversation_id

    for batch in range(4):
        for i in range(4):
            await condensed_engine.ingest_message(
                "user", f"Batch {batch} msg {i} " + "z" * 80
            )
        result = await condensed_compaction_engine.compact_leaf(
            conversation_id=cid,
            token_budget=50_000,
            summarize=_mock_summarize,
            force=True,
        )

    # All leaf passes should have condensed=False
    assert result.condensed is False

    summaries = await condensed_engine.summary_store.get_summaries_for_conversation(
        cid, "test-owner"
    )
    condensed = [s for s in summaries if s.kind == "condensed"]
    assert len(condensed) == 0


# ═══════════════════════════════════════════════════════════════════════════
# PR Review Fix: compact_until_under condensed tracking (#1183)
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_compact_until_under_tracks_condensed_from_sweep(engine, compaction_engine):
    """compact_until_under should propagate condensed=True from
    compact_full_sweep results, not just check if any IDs exist."""
    from cortex.hippo.compaction.types import CompactionResult as CResult

    cid = engine._conversation.conversation_id

    # Mock compact_full_sweep to return condensed=False with IDs
    # (leaf-only compaction that created summaries but no condensation)
    call_count = 0

    async def mock_sweep(conversation_id, token_budget, summarize, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            return CResult(
                action_taken=True,
                tokens_before=5000,
                tokens_after=3000,
                created_summary_ids=["sum_leaf"],
                condensed=False,
                rounds=1,
            )
        return CResult(action_taken=False)

    compaction_engine.compact_full_sweep = mock_sweep

    # Mock token count to start above target
    original_get_count = compaction_engine.summary_store.get_context_token_count

    async def mock_count(cid):
        nonlocal call_count
        return 5000 if call_count == 0 else 3000

    compaction_engine.summary_store.get_context_token_count = mock_count

    result = await compaction_engine.compact_until_under(
        conversation_id=cid,
        token_budget=10000,
        summarize=lambda *a, **kw: "summary",
        target_tokens=4000,
        current_tokens=5000,
    )

    # Even though IDs exist, condensed should be False since sweep said so
    assert result.condensed is False, (
        "condensed should reflect actual condensation, not just presence of IDs"
    )
