"""
Tests for ContextAssembler — context assembly with cornerstone injection,
plus multipart reconstruction & role remapping (W2-8, #1159).

Issue: #803, #1159
"""

from __future__ import annotations

import json

import pytest

from cortex.hippo.assembler import (
    ContextAssembler,
    MessagePartRecord,
    SummarySignal,
    _block_from_part,
    _get_original_role,
    _get_part_metadata,
    _parse_json,
    _reconstruct_message,
    _to_runtime_role,
    build_system_prompt_addition,
)
from cortex.hippo.engine import HippoEngine
from cortex.hippo.config import HippoConfig
from cortex.hippo.models import ContextItemType, MessageRecord


@pytest.mark.asyncio
async def test_assembler_empty_conversation(engine: HippoEngine):
    """Assembler returns empty result for empty conversation."""
    result = await engine.get_context()
    assert result.total_tokens == 0
    assert result.messages == []
    assert result.items == []


@pytest.mark.asyncio
async def test_assembler_messages_in_order(engine: HippoEngine):
    """Messages are assembled in chronological order."""
    await engine.ingest_message("user", "First")
    await engine.ingest_message("assistant", "Second")
    await engine.ingest_message("user", "Third")

    result = await engine.get_context()
    assert len(result.messages) == 3
    assert result.messages[0]["content"] == "First"
    # Assistant string content is normalized to block array (Gap 2 fix)
    assert result.messages[1]["content"] == [{"type": "text", "text": "Second"}]
    assert result.messages[2]["content"] == "Third"


@pytest.mark.asyncio
async def test_assembler_fresh_tail_protection():
    """Fresh tail messages are always included, even at budget limit."""
    config = HippoConfig(
        db_path=":memory:",
        fresh_tail_count=2,
        max_tokens=50_000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

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

    result = await eng.get_context()
    # All should be included (within budget)
    assert len(result.messages) == 5

    await eng.shutdown()


@pytest.mark.asyncio
async def test_assembler_token_budget_respected():
    """Assembler doesn't exceed token budget."""
    config = HippoConfig(
        db_path=":memory:",
        fresh_tail_count=2,
        max_tokens=1000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    # Ingest many messages that exceed a small budget
    for i in range(20):
        await eng.ingest_message("user", f"This is a longer message number {i} with extra words to pad the token count significantly")

    result = await eng.get_context(max_tokens=50)
    # Should have at least the fresh tail (2 messages)
    assert len(result.messages) >= 2
    # But not all 20
    assert len(result.messages) < 20

    await eng.shutdown()


@pytest.mark.asyncio
async def test_assembler_context_item_types(engine: HippoEngine):
    """Context items have correct types."""
    await engine.ingest_message("user", "Hello")
    await engine.ingest_message("assistant", "Hi!")

    result = await engine.get_context()
    assert all(item.kind == ContextItemType.MESSAGE for item in result.items)


@pytest.mark.asyncio
async def test_assembler_cornerstone_injection_disabled(engine: HippoEngine):
    """Without cornerstone_provider, no cornerstones are injected."""
    await engine.ingest_message("user", "Hello")
    result = await engine.get_context()
    assert result.cornerstone_tokens == 0
    assert all(item.kind != ContextItemType.CORNERSTONE for item in result.items)


@pytest.mark.asyncio
async def test_assembler_token_stats(engine: HippoEngine):
    """Token stats are correctly reported."""
    await engine.ingest_message("user", "Hello world")
    await engine.ingest_message("assistant", "Hi there!")

    result = await engine.get_context()
    assert result.total_tokens > 0
    assert result.message_tokens > 0
    assert result.cornerstone_tokens == 0
    assert result.summary_tokens == 0
    assert result.total_tokens == result.message_tokens


@pytest.mark.asyncio
async def test_assembler_summary_xml_wrapping():
    """Summary XML wrapping produces correct format matching lossless-claw."""
    from cortex.hippo.models import SummaryRecord
    summary = SummaryRecord(
        summary_id="sum_abc123",
        conversation_id=1,
        owner_id="test-owner",
        kind="leaf",
        depth=2,
        content="Summary content",
        token_count=10,
        earliest_at="2026-03-19T00:00:00Z",
        latest_at="2026-03-19T01:00:00Z",
        descendant_count=3,
    )
    xml = ContextAssembler._wrap_summary_xml(summary)
    assert '<summary id="sum_abc123"' in xml
    assert 'kind="leaf"' in xml
    assert 'depth="2"' in xml
    assert 'descendant_count="3"' in xml
    assert 'earliest_at=' in xml
    assert "Summary content" in xml
    assert "</summary>" in xml


@pytest.mark.asyncio
async def test_assembler_with_summaries(engine: HippoEngine):
    """Summaries in context items are included in assembly."""
    # Create a summary directly via store
    summary = await engine.summary_store.create_summary(
        conversation_id=engine._conversation.conversation_id,
        owner_id="test-owner",
        kind="leaf",
        depth=0,
        content="This is a summary of earlier messages",
        token_count=10,
    )

    # Add summary to context items
    await engine.summary_store.append_context_item(
        conversation_id=engine._conversation.conversation_id,
        item_type="summary",
        summary_id=summary.summary_id,
    )

    # Add a message after the summary
    await engine.ingest_message("user", "New message after summary")

    result = await engine.get_context()
    # Should have both summary and message
    kinds = [item.kind for item in result.items]
    assert ContextItemType.SUMMARY in kinds
    assert ContextItemType.MESSAGE in kinds
    assert result.summary_tokens > 0


# ═══════════════════════════════════════════════════════════════════════════
# build_system_prompt_addition tests (W2-7, #1158)
# ═══════════════════════════════════════════════════════════════════════════


def test_build_system_prompt_returns_none_for_empty_signals():
    """No signals → None (no system prompt addition)."""
    assert build_system_prompt_addition([]) is None


def test_build_system_prompt_basic():
    """Shallow summaries → core recall + simple precision reminder (not heavily compacted)."""
    signals = [
        SummarySignal(kind="leaf", depth=0, descendant_count=3),
        SummarySignal(kind="leaf", depth=1, descendant_count=5),
    ]
    result = build_system_prompt_addition(signals)
    assert result is not None
    # Core recall section always present
    assert "## LCM Recall" in result
    assert "lcm_grep" in result
    assert "lcm_describe" in result
    assert "lcm_expand_query" in result
    # Simple precision reminder (not heavily compacted path)
    assert "**For precision/evidence questions**" in result
    assert "Do not guess from condensed summaries" in result
    # Should NOT have the expanded uncertainty checklist
    assert "Uncertainty checklist" not in result
    assert "Deeply compacted context" not in result


def test_build_system_prompt_heavily_compacted_by_depth():
    """Deep summaries (maxDepth >= 2) → expanded uncertainty checklist."""
    signals = [
        SummarySignal(kind="condensed", depth=2, descendant_count=10),
    ]
    result = build_system_prompt_addition(signals)
    assert result is not None
    # Heavily compacted path
    assert "Deeply compacted context" in result
    assert "Uncertainty checklist" in result
    assert "Do not guess" in result
    assert "expand first" in result.lower()
    # Should NOT have the simple reminder
    assert "**For precision/evidence questions**" not in result


def test_build_system_prompt_heavily_compacted_by_condensed_count():
    """Multiple condensed summaries (>=2) → heavily compacted path."""
    signals = [
        SummarySignal(kind="condensed", depth=1, descendant_count=5),
        SummarySignal(kind="condensed", depth=1, descendant_count=8),
    ]
    result = build_system_prompt_addition(signals)
    assert result is not None
    assert "Deeply compacted context" in result
    assert "Uncertainty checklist" in result


@pytest.mark.asyncio
async def test_assemble_includes_system_prompt_addition(engine: HippoEngine):
    """When summaries are in context, system_prompt_addition is populated."""
    # Create a summary directly via store
    summary = await engine.summary_store.create_summary(
        conversation_id=engine._conversation.conversation_id,
        owner_id="test-owner",
        kind="leaf",
        depth=0,
        content="Summary of earlier messages about deployment",
        token_count=10,
    )
    await engine.summary_store.append_context_item(
        conversation_id=engine._conversation.conversation_id,
        item_type="summary",
        summary_id=summary.summary_id,
    )
    await engine.ingest_message("user", "New message")

    result = await engine.get_context()
    assert result.system_prompt_addition is not None
    assert "## LCM Recall" in result.system_prompt_addition


# ═══════════════════════════════════════════════════════════════════════════
# Multipart Reconstruction + Role Remapping tests (W2-8, #1159)
# ═══════════════════════════════════════════════════════════════════════════


def _make_part(**kwargs) -> MessagePartRecord:
    """Helper to create a MessagePartRecord with sensible defaults."""
    defaults = {
        "part_id": 1,
        "message_id": 100,
        "ordinal": 0,
        "part_type": "text",
        "text_content": None,
        "tool_call_id": None,
        "tool_name": None,
        "tool_input": None,
        "tool_output": None,
        "metadata": None,
    }
    defaults.update(kwargs)
    return MessagePartRecord(**defaults)


def _make_msg(**kwargs) -> MessageRecord:
    """Helper to create a MessageRecord with sensible defaults."""
    defaults = {
        "message_id": 100,
        "conversation_id": 1,
        "owner_id": "test",
        "seq": 1,
        "role": "user",
        "content": "",
        "token_count": 10,
        "created_at": "",
    }
    defaults.update(kwargs)
    return MessageRecord(**defaults)


# ── _parse_json ───────────────────────────────────────────────────────────


def test_parse_json_valid():
    assert _parse_json('{"a": 1}') == {"a": 1}


def test_parse_json_none():
    assert _parse_json(None) is None


def test_parse_json_empty():
    assert _parse_json("") is None
    assert _parse_json("   ") is None


def test_parse_json_invalid():
    assert _parse_json("not json") is None


# ── _get_part_metadata ────────────────────────────────────────────────────


def test_get_part_metadata_full():
    part = _make_part(metadata=json.dumps({
        "originalRole": "assistant",
        "rawType": "tool_use",
        "raw": {"type": "tool_use", "id": "call_1"},
    }))
    meta = _get_part_metadata(part)
    assert meta["originalRole"] == "assistant"
    assert meta["rawType"] == "tool_use"
    assert meta["raw"] == {"type": "tool_use", "id": "call_1"}


def test_get_part_metadata_empty():
    part = _make_part(metadata=None)
    assert _get_part_metadata(part) == {}


def test_get_part_metadata_partial():
    part = _make_part(metadata=json.dumps({"rawType": "thinking"}))
    meta = _get_part_metadata(part)
    assert "originalRole" not in meta
    assert meta["rawType"] == "thinking"


# ── _to_runtime_role ──────────────────────────────────────────────────────


def test_to_runtime_role_from_original_role():
    """originalRole in metadata takes priority over db_role."""
    parts = [_make_part(metadata=json.dumps({"originalRole": "toolResult"}))]
    assert _to_runtime_role("user", parts) == "toolResult"

    parts = [_make_part(metadata=json.dumps({"originalRole": "assistant"}))]
    assert _to_runtime_role("user", parts) == "assistant"

    parts = [_make_part(metadata=json.dumps({"originalRole": "user"}))]
    assert _to_runtime_role("assistant", parts) == "user"

    # system → user at runtime
    parts = [_make_part(metadata=json.dumps({"originalRole": "system"}))]
    assert _to_runtime_role("assistant", parts) == "user"


def test_to_runtime_role_fallback_to_db_role():
    """Without originalRole metadata, fall back to DB role."""
    empty_parts = [_make_part()]

    assert _to_runtime_role("tool", empty_parts) == "toolResult"
    assert _to_runtime_role("assistant", empty_parts) == "assistant"
    assert _to_runtime_role("user", empty_parts) == "user"
    assert _to_runtime_role("system", empty_parts) == "user"


def test_to_runtime_role_empty_parts():
    """Empty parts list → pure DB role fallback."""
    assert _to_runtime_role("assistant", []) == "assistant"
    assert _to_runtime_role("tool", []) == "toolResult"
    assert _to_runtime_role("user", []) == "user"


# ── _block_from_part — text ───────────────────────────────────────────────


def test_block_from_part_text():
    """Text part → text block."""
    part = _make_part(part_type="text", text_content="Hello world")
    block = _block_from_part(part)
    assert block == {"type": "text", "text": "Hello world"}


def test_block_from_part_text_empty():
    """Text part with no content → empty text block."""
    part = _make_part(part_type="text", text_content=None)
    block = _block_from_part(part)
    assert block == {"type": "text", "text": ""}


# ── _block_from_part — tool call ──────────────────────────────────────────


def test_block_from_part_tool_call():
    """Tool part without toolResult role → tool call block."""
    part = _make_part(
        part_type="tool",
        tool_call_id="call_abc",
        tool_name="get_weather",
        tool_input='{"city": "Bangkok"}',
    )
    block = _block_from_part(part)
    assert block["type"] == "toolCall"
    assert block["id"] == "call_abc"
    assert block["name"] == "get_weather"
    assert block["input"] == {"city": "Bangkok"}


def test_block_from_part_tool_call_function_call_type():
    """rawType=function_call → function_call format with call_id."""
    part = _make_part(
        part_type="tool",
        tool_call_id="call_xyz",
        tool_name="search",
        tool_input='"hello"',
        metadata=json.dumps({"rawType": "function_call"}),
    )
    block = _block_from_part(part)
    assert block["type"] == "function_call"
    assert block["call_id"] == "call_xyz"
    assert block["name"] == "search"
    assert block["arguments"] == "hello"


# ── _block_from_part — tool result ────────────────────────────────────────


def test_block_from_part_tool_result():
    """Tool part with toolResult originalRole → tool result block."""
    part = _make_part(
        part_type="tool",
        tool_call_id="call_abc",
        tool_name="get_weather",
        tool_output='"Sunny, 32°C"',
        metadata=json.dumps({"originalRole": "toolResult"}),
    )
    block = _block_from_part(part)
    assert block["type"] == "tool_result"
    assert block["output"] == "Sunny, 32°C"
    assert block["tool_use_id"] == "call_abc"
    # `name` field must NOT be present on tool_result blocks (lossless-claw parity)
    assert "name" not in block


def test_block_from_part_tool_result_function_call_output():
    """rawType=function_call_output → function_call_output format."""
    part = _make_part(
        part_type="tool",
        tool_call_id="call_99",
        tool_output='"done"',
        metadata=json.dumps({"rawType": "function_call_output"}),
    )
    block = _block_from_part(part)
    assert block["type"] == "function_call_output"
    assert block["call_id"] == "call_99"
    assert block["output"] == "done"


# ── _block_from_part — raw metadata ──────────────────────────────────────


def test_block_from_part_with_raw_metadata():
    """Part with raw metadata → passthrough the raw block directly."""
    raw_block = {"type": "tool_use", "id": "tu_1", "name": "code_exec", "input": {"code": "1+1"}}
    part = _make_part(
        part_type="tool",
        metadata=json.dumps({"raw": raw_block}),
    )
    block = _block_from_part(part)
    assert block == raw_block


def test_block_from_part_with_raw_metadata_reasoning():
    """Raw thinking block passthrough."""
    raw_block = {"type": "thinking", "thinking": "Let me think..."}
    part = _make_part(
        part_type="reasoning",
        metadata=json.dumps({"raw": raw_block}),
    )
    block = _block_from_part(part)
    assert block == raw_block


# ── _block_from_part — reasoning ──────────────────────────────────────────


def test_block_from_part_reasoning():
    """Reasoning part → reasoning block."""
    part = _make_part(part_type="reasoning", text_content="Step by step...")
    block = _block_from_part(part)
    assert block == {"type": "reasoning", "text": "Step by step..."}


def test_block_from_part_reasoning_thinking_type():
    """rawType=thinking → thinking block format."""
    part = _make_part(
        part_type="reasoning",
        text_content="Hmm...",
        metadata=json.dumps({"rawType": "thinking"}),
    )
    block = _block_from_part(part)
    assert block == {"type": "thinking", "thinking": "Hmm..."}


# ── _reconstruct_message ─────────────────────────────────────────────────


def test_reconstruct_message_multipart():
    """Full multipart message with text + tool call + tool result."""
    msg = _make_msg(role="assistant")
    parts = [
        _make_part(ordinal=0, part_type="text", text_content="Let me check the weather."),
        _make_part(
            ordinal=1,
            part_type="tool",
            tool_call_id="call_w",
            tool_name="get_weather",
            tool_input='{"city": "Bangkok"}',
        ),
    ]
    result = _reconstruct_message(msg, parts)
    assert result["role"] == "assistant"
    assert isinstance(result["content"], list)
    assert len(result["content"]) == 2
    assert result["content"][0] == {"type": "text", "text": "Let me check the weather."}
    assert result["content"][1]["type"] == "toolCall"
    assert result["content"][1]["name"] == "get_weather"


def test_reconstruct_message_no_parts():
    """Message with no parts → simple text message."""
    msg = _make_msg(role="user", content="Hello!")
    result = _reconstruct_message(msg, [])
    assert result == {"role": "user", "content": "Hello!"}


def test_reconstruct_message_single_text_user_unwraps():
    """Single text block for user role → unwraps to plain string."""
    msg = _make_msg(role="user")
    parts = [_make_part(part_type="text", text_content="Just a question")]
    result = _reconstruct_message(msg, parts)
    assert result["role"] == "user"
    assert result["content"] == "Just a question"
    assert isinstance(result["content"], str)


def test_reconstruct_message_tool_result_role():
    """Message with toolResult originalRole → toolResult role + metadata."""
    msg = _make_msg(role="tool")
    parts = [
        _make_part(
            part_type="tool",
            tool_call_id="call_t",
            tool_name="search",
            tool_output='"found it"',
            metadata=json.dumps({"originalRole": "toolResult"}),
        ),
    ]
    result = _reconstruct_message(msg, parts)
    assert result["role"] == "toolResult"
    assert result["toolCallId"] == "call_t"
    assert result["toolName"] == "search"


def test_reconstruct_message_role_remapping_from_metadata():
    """originalRole in metadata overrides DB role for reconstruction."""
    msg = _make_msg(role="user")
    parts = [
        _make_part(
            part_type="text",
            text_content="I am the assistant",
            metadata=json.dumps({"originalRole": "assistant"}),
        ),
    ]
    result = _reconstruct_message(msg, parts)
    assert result["role"] == "assistant"


# ═══════════════════════════════════════════════════════════════════════════
# W3-2: Assembly fallback on failure tests (#1163)
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_assemble_fallback_on_error(engine: HippoEngine):
    """When assembly internals raise, fallback returns raw messages."""
    # Ingest some messages first
    await engine.ingest_message("user", "Hello world")
    await engine.ingest_message("assistant", "Hi there!")

    # Patch _assemble_inner to raise
    original_inner = engine.assembler._assemble_inner

    async def broken_inner(*args, **kwargs):
        raise RuntimeError("Simulated assembly failure")

    engine.assembler._assemble_inner = broken_inner

    # Should NOT raise — should return fallback
    result = await engine.get_context()
    assert len(result.messages) >= 1
    assert result.total_tokens > 0
    # Messages should contain our ingested content
    contents = [m["content"] for m in result.messages]
    assert "Hello world" in contents


@pytest.mark.asyncio
async def test_assemble_fallback_on_empty(engine: HippoEngine):
    """Empty conversation returns empty AssembleResult, not an error."""
    result = await engine.get_context()
    assert result.total_tokens == 0
    assert result.messages == []
    assert result.items == []


@pytest.mark.asyncio
async def test_assemble_fallback_when_store_also_fails(engine: HippoEngine):
    """When both assembly and fallback store fail, returns empty result."""
    await engine.ingest_message("user", "Hello")

    # Patch _assemble_inner to raise
    async def broken_inner(*args, **kwargs):
        raise RuntimeError("Inner failed")

    engine.assembler._assemble_inner = broken_inner

    # Also patch get_messages to fail
    original_get = engine.assembler._conversation_store.get_messages

    async def broken_get(*args, **kwargs):
        raise RuntimeError("Store also failed")

    engine.assembler._conversation_store.get_messages = broken_get

    # Should still not raise — returns empty
    result = await engine.get_context()
    assert result.total_tokens == 0
    assert result.messages == []


# ═══════════════════════════════════════════════════════════════════════════
# W3-6: Budget eviction keeps newest (#1167)
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_budget_eviction_keeps_newest():
    """When summaries exceed budget, oldest are evicted and newest kept."""
    config = HippoConfig(
        db_path=":memory:",
        fresh_tail_count=1,
        max_tokens=50_000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sess")

    # Create 3 summaries of 100 tokens each (in chronological order)
    for i in range(3):
        content = f"Summary {i}: " + "x" * 400  # ~100 tokens
        summary = await eng.summary_store.create_summary(
            conversation_id=eng._conversation.conversation_id,
            owner_id="test",
            kind="leaf",
            depth=0,
            content=content,
            token_count=100,
        )
        await eng.summary_store.append_context_item(
            conversation_id=eng._conversation.conversation_id,
            item_type="summary",
            summary_id=summary.summary_id,
        )

    # Add a message for the tail
    await eng.ingest_message("user", "Latest message")

    # Budget = 160 tokens — enough for 1 summary (100) + tail msg (~4 tokens)
    # but NOT all 3 summaries (300 tokens)
    result = await eng.get_context(max_tokens=160)

    # Should keep the NEWEST summary (Summary 2), drop oldest
    summary_items = [i for i in result.items if i.kind == ContextItemType.SUMMARY]
    assert len(summary_items) >= 1
    # The kept summary should be the last one (newest)
    assert "Summary 2" in summary_items[-1].content

    await eng.shutdown()


# ═══════════════════════════════════════════════════════════════════════════
# W3-7: N+1 query fix — batch fetch (#1168)
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_message_items_batch_fetched(engine: HippoEngine):
    """Message context items are resolved via batch fetch, not N+1 queries."""
    from unittest.mock import AsyncMock, patch

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

    # Spy on get_messages_by_ids to verify it's called (batch path)
    original_batch = engine.conversation_store.get_messages_by_ids
    call_count = 0

    async def tracking_batch(ids, **kwargs):
        nonlocal call_count
        call_count += 1
        return await original_batch(ids, **kwargs)

    engine._conversation_store.get_messages_by_ids = tracking_batch

    # Also spy on get_messages to verify the old N+1 path is NOT used
    old_get_called = False
    original_get = engine.conversation_store.get_messages

    async def tracking_get(*a, **kw):
        nonlocal old_get_called
        old_get_called = True
        return await original_get(*a, **kw)

    engine._conversation_store.get_messages = tracking_get

    result = await engine.get_context()

    assert len(result.messages) == 5
    assert call_count >= 1, "get_messages_by_ids should be called at least once (batch)"
    assert not old_get_called, "get_messages (N+1 path) should NOT be called during assembly"


# ═══════════════════════════════════════════════════════════════════════════
# PR Review Fix: fitted_signals alignment with evicted summaries (#1183)
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_fitted_signals_aligned_with_newest_summaries():
    """After budget eviction, fitted_signals must correspond to the kept
    (newest) summaries, not the oldest ones that were dropped."""
    config = HippoConfig(
        db_path=":memory:",
        fresh_tail_count=1,
        max_tokens=50_000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="sig-test")

    # Create 3 summaries with distinct signals
    for i in range(3):
        content = f"Summary {i}: " + "x" * 400
        summary = await eng.summary_store.create_summary(
            conversation_id=eng._conversation.conversation_id,
            owner_id="test",
            kind="leaf",
            depth=0,
            content=content,
            token_count=100,
        )
        await eng.summary_store.append_context_item(
            conversation_id=eng._conversation.conversation_id,
            item_type="summary",
            summary_id=summary.summary_id,
        )

    await eng.ingest_message("user", "Latest message")

    # Budget = 160 — only room for ~1 summary (100 tokens) + tail
    result = await eng.get_context(max_tokens=160)

    # The system prompt addition (if present) should reference the newest
    # summary's signal, not the oldest. We verify the kept summary is newest.
    summary_items = [i for i in result.items if i.kind == ContextItemType.SUMMARY]
    assert len(summary_items) >= 1
    assert "Summary 2" in summary_items[-1].content

    await eng.shutdown()


@pytest.mark.asyncio
async def test_eviction_continues_past_large_summaries():
    """Eviction loop should continue past summaries that don't fit,
    allowing smaller summaries to fill remaining budget."""
    config = HippoConfig(
        db_path=":memory:",
        fresh_tail_count=1,
        max_tokens=50_000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="cont-test")

    # Create summaries: [small=50, large=200, small=50] (chronological)
    for i, tokens in enumerate([(50, "Small-0"), (200, "Large-1"), (50, "Small-2")]):
        tc, label = tokens
        content = f"{label}: " + "x" * (tc * 4)
        summary = await eng.summary_store.create_summary(
            conversation_id=eng._conversation.conversation_id,
            owner_id="test",
            kind="leaf",
            depth=0,
            content=content,
            token_count=tc,
        )
        await eng.summary_store.append_context_item(
            conversation_id=eng._conversation.conversation_id,
            item_type="summary",
            summary_id=summary.summary_id,
        )

    await eng.ingest_message("user", "tail")

    # Budget = 110 — enough for both small summaries (50+50=100) but not large
    result = await eng.get_context(max_tokens=110)
    summary_items = [i for i in result.items if i.kind == ContextItemType.SUMMARY]

    # With continue (not break), both small summaries should fit
    contents = " ".join(i.content for i in summary_items)
    assert "Small-2" in contents, "Newest small summary should be kept"

    await eng.shutdown()


# ═══════════════════════════════════════════════════════════════════════════
# R-035: Ordinal-sorted unified resolution (#ordinal-interleaving)
# Summaries and messages must interleave chronologically, not summaries-first.
# ═══════════════════════════════════════════════════════════════════════════


@pytest.mark.asyncio
async def test_ordinal_interleaving_summary_between_messages():
    """Summary inserted between two messages must appear between them in output."""
    config = HippoConfig(
        db_path=":memory:",
        fresh_tail_count=10,
        max_tokens=50_000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="interleave-test")

    # Ingest: msg_A → summary_S → msg_B
    # Expected output order: msg_A, summary_S, msg_B
    await eng.ingest_message("user", "Message A")

    summary = await eng.summary_store.create_summary(
        conversation_id=eng._conversation.conversation_id,
        owner_id="test",
        kind="leaf",
        depth=0,
        content="Summary of earlier context",
        token_count=20,
    )
    await eng.summary_store.append_context_item(
        conversation_id=eng._conversation.conversation_id,
        item_type="summary",
        summary_id=summary.summary_id,
    )

    await eng.ingest_message("user", "Message B")

    result = await eng.get_context()

    # Must have 3 items in final output
    assert len(result.items) == 3

    # Verify interleaved order: MESSAGE → SUMMARY → MESSAGE
    assert result.items[0].kind == ContextItemType.MESSAGE
    assert result.items[1].kind == ContextItemType.SUMMARY
    assert result.items[2].kind == ContextItemType.MESSAGE

    # Verify content order
    assert "Message A" in result.items[0].content
    assert "Summary of earlier context" in result.items[1].content
    assert "Message B" in result.items[2].content

    await eng.shutdown()


@pytest.mark.asyncio
async def test_ordinal_interleaving_preserved_under_budget_pressure():
    """When budget is tight, ordinal order is preserved in the kept items."""
    config = HippoConfig(
        db_path=":memory:",
        fresh_tail_count=2,
        max_tokens=50_000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="budget-interleave")

    # Sequence: msg_old → summary → msg_recent_1 → msg_recent_2 (fresh tail)
    await eng.ingest_message("user", "Old message")

    summary = await eng.summary_store.create_summary(
        conversation_id=eng._conversation.conversation_id,
        owner_id="test",
        kind="leaf",
        depth=0,
        content="Summary between old and new",
        token_count=20,
    )
    await eng.summary_store.append_context_item(
        conversation_id=eng._conversation.conversation_id,
        item_type="summary",
        summary_id=summary.summary_id,
    )

    await eng.ingest_message("user", "Recent message 1")
    await eng.ingest_message("user", "Recent message 2")

    # Large budget — all 4 items fit
    result = await eng.get_context(max_tokens=50_000)
    assert len(result.items) == 4
    kinds = [i.kind for i in result.items]
    assert kinds == [
        ContextItemType.MESSAGE,   # Old message
        ContextItemType.SUMMARY,   # Summary
        ContextItemType.MESSAGE,   # Recent 1
        ContextItemType.MESSAGE,   # Recent 2
    ]

    await eng.shutdown()


@pytest.mark.asyncio
async def test_unified_fresh_tail_includes_summary_if_in_tail():
    """Fresh tail is over all item types — a summary in the tail position is protected."""
    config = HippoConfig(
        db_path=":memory:",
        fresh_tail_count=2,
        max_tokens=50_000,
    )
    eng = HippoEngine(config)
    await eng.initialize(db_path=":memory:", owner_id="test", conversation_id="tail-summary")

    await eng.ingest_message("user", "Old message 1")
    await eng.ingest_message("user", "Old message 2")

    # Summary is one of the last 2 items — should be in fresh tail
    summary = await eng.summary_store.create_summary(
        conversation_id=eng._conversation.conversation_id,
        owner_id="test",
        kind="leaf",
        depth=0,
        content="Recent summary",
        token_count=20,
    )
    await eng.summary_store.append_context_item(
        conversation_id=eng._conversation.conversation_id,
        item_type="summary",
        summary_id=summary.summary_id,
    )

    await eng.ingest_message("user", "Recent message")

    # Budget too tight for old messages but should keep tail (summary + recent msg)
    result = await eng.get_context(max_tokens=40)

    # Should include at least the fresh tail items (summary + recent_message)
    kinds = {i.kind for i in result.items}
    assert ContextItemType.SUMMARY in kinds
    contents = [i.content for i in result.items]
    assert any("Recent message" in c for c in contents)

    await eng.shutdown()
