import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.EntryRole
import com.correx.core.context.model.TokenBudget
import com.correx.core.events.events.L3RetrievedHit
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.talkie.DefaultTalkieContextBuilder
import com.correx.core.talkie.model.TalkieConfig
import com.correx.core.talkie.model.WorkflowSummary
import com.correx.core.talkie.model.RouterL2Entry
import com.correx.core.talkie.model.TalkieState
import com.correx.core.talkie.model.TalkieTurn
import com.correx.core.talkie.model.StageOutcomeKind
import com.correx.core.talkie.model.TurnRole
import com.correx.core.talkie.model.WorkflowStatus
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class TalkieContextBuilderTest {

    private val config = TalkieConfig(conversationKeepLast = 3, tokenBudget = TokenBudget(limit = 200))
    private val builder = DefaultTalkieContextBuilder(config)

    private fun buildPack(state: TalkieState, budget: TokenBudget): ContextPack = runBlocking {
        builder.build(state, budget)
    }

    private val sessionId = SessionId("test-session")
    private val stageId = StageId("stage-1")
    private val clock = Clock.System

    // --------------------------------------------------------------------------
    // Budget enforcement
    // --------------------------------------------------------------------------

    @Test
    fun `build fits within budget when all entries are small`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "hi", clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        assertEquals(10000, pack.budgetLimit)
        assertTrue(pack.budgetUsed <= pack.budgetLimit)
    }

    @Test
    fun `build drops entries when budget is exceeded`() {
        // Build a state where conversation + L2 entries together exceed the tight budget
        val longContent = "x".repeat(800) // ~400 tokens each
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, longContent, clock.now()),
                TalkieTurn(TurnRole.ROUTER, longContent, clock.now()),
                TalkieTurn(TurnRole.USER, longContent, clock.now()),
            ),
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 100))
        // L0 entries (system prompt + workflow status) always fit; L1/L2 should be dropped
        assertTrue(pack.compressionMetadata.entriesDropped > 0)
    }

    @Test
    fun `build drops entries oldest-first for L2 memory when budget forces partial drop`() {
        // Each L2 entry: "Stage sN (SUCCESS): " + 200-char summary ≈ 55 tokens each.
        // Use a config with conversationKeepLast=0 so only L0 + L2 compete for budget.
        val configNoConv = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
        val builderNoConv = DefaultTalkieContextBuilder(configNoConv)
        val summary = "x".repeat(200) // ~50 tokens; with prefix "Stage sN (SUCCESS): " ~55 tokens total
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T00:00:00Z")),
                RouterL2Entry(StageId("s2"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-02T00:00:00Z")),
                RouterL2Entry(StageId("s3"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-03T00:00:00Z")),
            ),
        )
        // Measure the protected frame (L0) and per-entry cost, then size the budget to fit
        // exactly two of three L2 entries — robust to system-prompt length changes.
        val measured = runBlocking { builderNoConv.build(state, TokenBudget(limit = 10000)) }
        val l0Cost = measured.layers[ContextLayer.L0].orEmpty().sumOf { it.tokenEstimate }
        val entryCost = measured.layers[ContextLayer.L2].orEmpty().first().tokenEstimate
        val pack = runBlocking {
            builderNoConv.build(state, TokenBudget(limit = l0Cost + entryCost * 2 + entryCost / 2))
        }
        val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
        val retainedIds = l2Entries.map { it.sourceId }.toSet()
        // Newest two survive; oldest is dropped
        assertEquals(2, l2Entries.size, "Only 2 of 3 L2 entries should fit")
        assertFalse(retainedIds.contains("s1"), "Oldest L2 entry (s1) should be dropped first")
        assertTrue(retainedIds.contains("s2"), "Second-newest L2 entry (s2) should be retained")
        assertTrue(retainedIds.contains("s3"), "Newest L2 entry (s3) should be retained")
        assertEquals(1, pack.compressionMetadata.entriesDropped)
    }

    @Test
    fun `build drops conversation turns oldest-first when conversationKeepLast exceeded`() {
        val configWide = TalkieConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
        val builderWide = DefaultTalkieContextBuilder(configWide)
        val longContent = "x".repeat(400)
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, longContent, Instant.parse("2026-01-01T00:00:00Z")),
                TalkieTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-02T00:00:00Z")),
                TalkieTurn(TurnRole.USER, longContent, Instant.parse("2026-01-03T00:00:00Z")),
                TalkieTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-04T00:00:00Z")),
            ),
        )
        // Budget allows L0 + 2 long conversation turns; oldest 2 turns are dropped.
        // Bumped 500 → 600 → 800 as the triage system prompt grew (now carries the
        // workflow-proposal directive); conversationKeepLast=2 still caps L1 at 2.
        val pack = runBlocking { builderWide.build(state, TokenBudget(limit = 800)) }
        val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
        // Only the last 2 turns (oldest-first drop) should remain
        assertEquals(2, l1Entries.size)
    }

    // --------------------------------------------------------------------------
    // L0 immutability
    // --------------------------------------------------------------------------

    @Test
    fun `L0 system prompt is always present`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.IDLE,
            currentStageId = null,
        )
        val pack = buildPack(state, TokenBudget(limit = 0))
        val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
        assertTrue(l0Entries.any { it.sourceType == "systemPrompt" })
    }

    @Test
    fun `L0 workflow status is always present`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.FAILED,
            currentStageId = StageId("failed-stage"),
        )
        val pack = buildPack(state, TokenBudget(limit = 0))
        val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
        assertTrue(l0Entries.any { it.sourceType == "workflowStatus" })
        val workflowEntry = l0Entries.find { it.sourceType == "workflowStatus" }
        assertNotNull(workflowEntry)
        assertTrue(workflowEntry!!.content.contains("failed"))
    }

    @Test
    fun `L0 entries survive zero budget`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.COMPLETED,
            currentStageId = null,
            conversationHistory = emptyList(),
            l2Memory = emptyList(),
        )
        val pack = buildPack(state, TokenBudget(limit = 0))
        val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
        assertEquals(2, l0Entries.size) // systemPrompt + workflowStatus
    }

    @Test
    fun `L0 entries survive budget that cannot cover L1 or L2`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "x".repeat(500), clock.now()),
            ),
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 50))
        val l0Entries = pack.layers[ContextLayer.L0] ?: emptyList()
        val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
        assertEquals(2, l0Entries.size)
        // The current (last) user turn is always protected and present even at budget=50
        assertEquals(1, (pack.layers[ContextLayer.L1] ?: emptyList()).size)
        assertEquals(0, l2Entries.size)
        assertTrue(pack.compressionMetadata.entriesDropped > 0)
    }

    // --------------------------------------------------------------------------
    // L2 oldest-first eviction
    // --------------------------------------------------------------------------

    @Test
    fun `L2 entries are evicted oldest-first and retained in chronological order`() {
        // Each L2 entry: "Stage sN (SUCCESS): " + 100-char summary ≈ 30 tokens each.
        // L0 ~30 tokens; budget 90 leaves ~60. Two entries (~30 each = ~60) fit; oldest dropped.
        val summary = "x".repeat(100) // ~25 tokens; with prefix ~30 tokens total
        val configTight = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
        val builderTight = DefaultTalkieContextBuilder(configTight)
        val baseTime = Instant.parse("2026-01-01T00:00:00Z")
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), summary, StageOutcomeKind.SUCCESS, baseTime),
                RouterL2Entry(StageId("s2"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T01:00:00Z")),
                RouterL2Entry(StageId("s3"), summary, StageOutcomeKind.SUCCESS, Instant.parse("2026-01-01T02:00:00Z")),
            ),
        )
        // Size the budget from the measured protected frame + per-entry cost to fit exactly two
        // of three entries; the third (oldest, s1) is dropped. Robust to system-prompt length.
        val measured = runBlocking { builderTight.build(state, TokenBudget(limit = 10000)) }
        val l0Cost = measured.layers[ContextLayer.L0].orEmpty().sumOf { it.tokenEstimate }
        val entryCost = measured.layers[ContextLayer.L2].orEmpty().first().tokenEstimate
        val pack = runBlocking {
            builderTight.build(state, TokenBudget(limit = l0Cost + entryCost * 2 + entryCost / 2))
        }
        val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
        val retainedIds = l2Entries.map { it.sourceId }
        // Oldest entry (s1) should be dropped; s2 and s3 retained in chronological order
        assertFalse(retainedIds.contains("s1"), "Oldest L2 entry (s1) should be dropped first")
        assertTrue(retainedIds.contains("s2"), "Second L2 entry (s2) should be retained")
        assertTrue(retainedIds.contains("s3"), "Newest L2 entry (s3) should be retained")
        // Chronological ordering preserved
        if (retainedIds.size == 2) {
            assertEquals("s2", retainedIds[0], "s2 should appear before s3 in chronological order")
            assertEquals("s3", retainedIds[1], "s3 should appear after s2 in chronological order")
        }
    }

    @Test
    fun `L2 entries fit within budget are all retained`() {
        val config = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
        val builder = DefaultTalkieContextBuilder(config)
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
                RouterL2Entry(StageId("s2"), "summary", StageOutcomeKind.FAILURE, clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
        assertEquals(2, l2Entries.size)
        assertEquals(0, pack.compressionMetadata.entriesDropped)
    }

    // --------------------------------------------------------------------------
    // Layer classification
    // --------------------------------------------------------------------------

    @Test
    fun `system prompt is classified as L0`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.IDLE,
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l0Entries = pack.layers[ContextLayer.L0]
        assertNotNull(l0Entries)
        assertTrue(l0Entries!!.any { it.sourceType == "systemPrompt" && it.layer == ContextLayer.L0 })
    }

    @Test
    fun `workflow status is classified as L0`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l0Entries = pack.layers[ContextLayer.L0]
        assertTrue(l0Entries!!.any { it.sourceType == "workflowStatus" && it.layer == ContextLayer.L0 })
    }

    @Test
    fun `conversation turns are classified as L1`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "hello", clock.now()),
                TalkieTurn(TurnRole.ROUTER, "hi", clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l1Entries = pack.layers[ContextLayer.L1]
        assertNotNull(l1Entries)
        assertEquals(2, l1Entries!!.size)
        assertTrue(l1Entries.all { it.layer == ContextLayer.L1 })
        assertTrue(l1Entries.all { it.sourceType == "conversation" })
    }

    @Test
    fun `l2 memory entries are classified as L2`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l2Entries = pack.layers[ContextLayer.L2]
        assertNotNull(l2Entries)
        assertEquals(1, l2Entries!!.size)
        assertTrue(l2Entries.all { it.layer == ContextLayer.L2 })
        assertTrue(l2Entries.all { it.sourceType == "stageSummary" })
    }

    @Test
    fun `no entries other than L0 L1 L2 appear in pack`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "hello", clock.now()),
            ),
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val presentLayers = pack.layers.keys
        assertTrue(presentLayers.containsAll(listOf(ContextLayer.L0, ContextLayer.L1, ContextLayer.L2)))
        assertFalse(presentLayers.contains(ContextLayer.L3))
        assertFalse(presentLayers.contains(ContextLayer.L4))
    }

    // --------------------------------------------------------------------------
    // Conversation history capping (conversationKeepLast)
    // --------------------------------------------------------------------------

    @Test
    fun `conversationHistory respects conversationKeepLast from config`() {
        val config = TalkieConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
        val builder = DefaultTalkieContextBuilder(config)
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "one", Instant.parse("2026-01-01T00:00:00Z")),
                TalkieTurn(TurnRole.ROUTER, "two", Instant.parse("2026-01-02T00:00:00Z")),
                TalkieTurn(TurnRole.USER, "three", Instant.parse("2026-01-03T00:00:00Z")),
                TalkieTurn(TurnRole.ROUTER, "four", Instant.parse("2026-01-04T00:00:00Z")),
                TalkieTurn(TurnRole.USER, "five", Instant.parse("2026-01-05T00:00:00Z")),
            ),
        )
        val pack = runBlocking { builder.build(state, TokenBudget(limit = 10000)) }
        val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
        // conversationKeepLast=2, so only the last 2 turns should appear
        assertEquals(2, l1Entries.size)
    }

    @Test
    fun `conversationHistory takeLast preserves ordering`() {
        val config = TalkieConfig(conversationKeepLast = 3, tokenBudget = TokenBudget(limit = 10000))
        val builder = DefaultTalkieContextBuilder(config)
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "first", Instant.parse("2026-01-01T00:00:00Z")),
                TalkieTurn(TurnRole.ROUTER, "second", Instant.parse("2026-01-02T00:00:00Z")),
                TalkieTurn(TurnRole.USER, "third", Instant.parse("2026-01-03T00:00:00Z")),
                TalkieTurn(TurnRole.ROUTER, "fourth", Instant.parse("2026-01-04T00:00:00Z")),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
        assertEquals(3, l1Entries.size)
        // The last 3: second, third, fourth
        assertTrue(l1Entries.any { it.content.contains("second") })
        assertTrue(l1Entries.any { it.content.contains("third") })
        assertTrue(l1Entries.any { it.content.contains("fourth") })
    }

    // --------------------------------------------------------------------------
    // Empty state
    // --------------------------------------------------------------------------

    @Test
    fun `build with empty state produces L0 only`() {
        val state = TalkieState()
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l0Entries = pack.layers[ContextLayer.L0]
        assertNotNull(l0Entries)
        assertEquals(2, l0Entries!!.size)
        assertTrue(pack.layers[ContextLayer.L1].isNullOrEmpty())
        assertTrue(pack.layers[ContextLayer.L2].isNullOrEmpty())
    }

    @Test
    fun `pack contains correct metadata on empty state`() {
        val state = TalkieState()
        val pack = buildPack(state, TokenBudget(limit = 5000))
        assertEquals(5000, pack.budgetLimit)
        assertEquals(0, pack.compressionMetadata.entriesDropped)
        assertEquals(listOf("L0Immutable", "Conversation"), pack.compressionMetadata.appliedStrategies)
        assertTrue(pack.compressionMetadata.truncatedLayers.isEmpty())
    }

    @Test
    fun `pack has correct context pack id and session info`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
        )
        val pack = buildPack(state, TokenBudget(limit = 1000))
        assertEquals("test-session-router-pack", pack.id.value)
        assertEquals(sessionId, pack.sessionId)
        assertEquals(stageId, pack.stageId)
    }

    // --------------------------------------------------------------------------
    // Content formatting
    // --------------------------------------------------------------------------

    @Test
    fun `system prompt has expected content`() {
        val state = TalkieState()
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val systemEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "systemPrompt" }
        assertNotNull(systemEntry)
        val content = systemEntry!!.content
        assertTrue(content.contains("correx"), "system prompt names the product")
        assertTrue(
            content.contains("workflow", ignoreCase = true),
            "system prompt grounds the router in workflow state",
        )
    }

    @Test
    fun `workflow status entry contains status and stage`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.COMPLETED,
            currentStageId = null,
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val workflowEntry = pack.layers[ContextLayer.L0]?.find { it.sourceType == "workflowStatus" }
        assertNotNull(workflowEntry)
        assertTrue(workflowEntry!!.content.contains("Status: Completed"))
        assertTrue(workflowEntry.content.contains("Stage: none"))
    }

    @Test
    fun `conversation turn entry is formatted with role prefix`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "user message", clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l1Entries = pack.layers[ContextLayer.L1] ?: emptyList()
        assertEquals(1, l1Entries.size)
        assertEquals("user message", l1Entries[0].content)
        assertEquals(EntryRole.USER, l1Entries[0].role)
    }

    @Test
    fun `l2 entry is formatted with stage outcome and summary`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            l2Memory = listOf(
                RouterL2Entry(StageId("stage-x"), "completed with 3 items", StageOutcomeKind.SUCCESS, clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l2Entries = pack.layers[ContextLayer.L2] ?: emptyList()
        assertEquals(1, l2Entries.size)
        val entry = l2Entries[0]
        assertEquals("stage-x", entry.sourceId)
        assertTrue(entry.content.contains("Stage stage-x"))
        assertTrue(entry.content.contains("SUCCESS"))
        assertTrue(entry.content.contains("completed with 3 items"))
    }

    // --------------------------------------------------------------------------
    // Token budget accounting
    // --------------------------------------------------------------------------

    @Test
    fun `budgetUsed equals sum of tokenEstimate of all retained entries`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "hello world", clock.now()),
            ),
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val allEntries = pack.layers.values.flatten()
        val computedSum = allEntries.sumOf { it.tokenEstimate }
        assertEquals(computedSum, pack.budgetUsed)
    }

    @Test
    fun `compressionMetadata entriesDropped reflects dropped count`() {
        val config = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10))
        val builder = DefaultTalkieContextBuilder(config)
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "x".repeat(200), clock.now()),
            ),
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
                RouterL2Entry(StageId("s2"), "x".repeat(500), StageOutcomeKind.SUCCESS, clock.now()),
            ),
        )
        val pack = runBlocking { builder.build(state, TokenBudget(limit = 65)) }
        // L0 consumes ~30 tokens, leaving 35 — both L2 entries (each ~130 tokens) dropped
        // conversationKeepLast=0 but the single user turn is the protected current turn; it fits and is not dropped
        assertEquals(2, pack.compressionMetadata.entriesDropped)
    }

    // --------------------------------------------------------------------------
    // Integration: full lifecycle
    // --------------------------------------------------------------------------

    @Test
    fun `build with full state including all layers`() {
        val config = TalkieConfig(conversationKeepLast = 2, tokenBudget = TokenBudget(limit = 10000))
        val builder = DefaultTalkieContextBuilder(config)
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "first turn", Instant.parse("2026-01-01T00:00:00Z")),
                TalkieTurn(TurnRole.ROUTER, "first reply", Instant.parse("2026-01-02T00:00:00Z")),
                TalkieTurn(TurnRole.USER, "second turn", Instant.parse("2026-01-03T00:00:00Z")),
                TalkieTurn(TurnRole.ROUTER, "second reply", Instant.parse("2026-01-04T00:00:00Z")),
            ),
            l2Memory = listOf(
                RouterL2Entry(
                    StageId("s1"),
                    "stage one done",
                    StageOutcomeKind.SUCCESS,
                    Instant.parse("2026-01-01T10:00:00Z"),
                ),
                RouterL2Entry(
                    StageId("s2"),
                    "stage two failed",
                    StageOutcomeKind.FAILURE,
                    Instant.parse("2026-01-02T10:00:00Z"),
                ),
            ),
        )
        val pack = runBlocking { builder.build(state, TokenBudget(limit = 10000)) }

        // L0: system prompt + workflow status
        val l0 = pack.layers[ContextLayer.L0]!!
        assertEquals(2, l0.size)
        assertEquals("systemPrompt", l0[0].sourceType)
        assertEquals("workflowStatus", l0[1].sourceType)

        // L1: last 2 conversation turns
        val l1 = pack.layers[ContextLayer.L1]!!
        assertEquals(2, l1.size)
        assertEquals("conversation", l1[0].sourceType)

        // L2: both stage summaries fit
        val l2 = pack.layers[ContextLayer.L2]!!
        assertEquals(2, l2.size)
        assertEquals("stageSummary", l2[0].sourceType)

        // Budget and metadata
        assertTrue(pack.budgetUsed > 0)
        assertTrue(pack.budgetUsed <= pack.budgetLimit)
        assertEquals(0, pack.compressionMetadata.entriesDropped)
    }

    @Test
    fun `build with null sessionId produces unknown pack id`() {
        val state = TalkieState(
            workflowStatus = WorkflowStatus.IDLE,
        )
        val pack = buildPack(state, TokenBudget(limit = 1000))
        assertTrue(pack.id.value.contains("unknown"))
    }

    // --------------------------------------------------------------------------
    // L3 memory injection (slice 2b)
    // --------------------------------------------------------------------------

    @Test
    fun `L3 hits injected into pack ordered by score descending`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            lastRetrievedMemory = listOf(
                L3RetrievedHit(
                    entryId = "entry-low",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "turn-low",
                    text = "low relevance memory",
                    score = 0.3f,
                ),
                L3RetrievedHit(
                    entryId = "entry-high",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "turn-high",
                    text = "high relevance memory",
                    score = 0.9f,
                ),
                L3RetrievedHit(
                    entryId = "entry-mid",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "turn-mid",
                    text = "mid relevance memory",
                    score = 0.6f,
                ),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l3Entries = pack.layers[ContextLayer.L3]
        assertNotNull(l3Entries)
        assertEquals(3, l3Entries!!.size)
        // Verify ordering: highest score first
        assertEquals("entry-high", l3Entries[0].sourceId)
        assertEquals("entry-mid", l3Entries[1].sourceId)
        assertEquals("entry-low", l3Entries[2].sourceId)
    }

    @Test
    fun `L3 entries carry recalled memory label in content`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            lastRetrievedMemory = listOf(
                L3RetrievedHit(
                    entryId = "e1",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "t1",
                    text = "some past knowledge",
                    score = 0.8f,
                ),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val l3Entry = pack.layers[ContextLayer.L3]?.first()
        assertNotNull(l3Entry)
        assertTrue(l3Entry!!.content.contains("[recalled memory]"))
        assertTrue(l3Entry.content.contains("some past knowledge"))
        assertEquals(EntryRole.SYSTEM, l3Entry.role)
        assertEquals(ContextLayer.L3, l3Entry.layer)
        assertEquals("recalledMemory", l3Entry.sourceType)
    }

    @Test
    fun `L3 hits dropped when budget exhausted and counted in entriesDropped`() {
        // Budget tight enough for L0 but not L3 entries with long text
        val longText = "x".repeat(800) // ~200 tokens
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            lastRetrievedMemory = listOf(
                L3RetrievedHit(
                    entryId = "big-entry",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "t1",
                    text = longText,
                    score = 0.9f,
                ),
            ),
        )
        // Budget 50: L0 frame ~30 tokens leaves ~20; L3 entry ~200 tokens -> dropped
        val pack = buildPack(state, TokenBudget(limit = 50))
        assertTrue(pack.layers[ContextLayer.L3].isNullOrEmpty())
        assertEquals(1, pack.compressionMetadata.entriesDropped)
        assertTrue(pack.compressionMetadata.truncatedLayers.contains(ContextLayer.L3))
    }

    // --------------------------------------------------------------------------
    // Budget priority: protect system + current user turn, drop oldest first
    // --------------------------------------------------------------------------

    @Test
    fun `system prompt and current user turn survive tight budget`() {
        val longContent = "x".repeat(800) // ~200 tokens each
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, longContent, Instant.parse("2026-01-01T00:00:00Z"), turnId = "old-turn"),
                TalkieTurn(TurnRole.ROUTER, longContent, Instant.parse("2026-01-02T00:00:00Z"), turnId = "mid-turn"),
                TalkieTurn(TurnRole.USER, "current user input", Instant.parse("2026-01-03T00:00:00Z"), turnId = "current-turn"),
            ),
        )
        // Budget 60: L0 frame ~30 tokens -> remaining ~30; current user turn ~4 tokens -> protected.
        // Older turns (each ~200 tokens) cannot fit -> dropped.
        val pack = buildPack(state, TokenBudget(limit = 60))
        val l0 = pack.layers[ContextLayer.L0]
        assertNotNull(l0)
        assertEquals(2, l0!!.size) // system prompt + workflow status always present

        val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
        // Current user turn ("current user input") must be present
        assertTrue(l1.any { it.content == "current user input" })
        // Older turns dropped due to budget
        assertFalse(l1.any { it.content == longContent })
        assertTrue(pack.compressionMetadata.entriesDropped > 0)
    }

    @Test
    fun `oldest conversation turn is dropped before newest when budget is tight`() {
        val config3 = TalkieConfig(conversationKeepLast = 3, tokenBudget = TokenBudget(limit = 10000))
        val builder3 = DefaultTalkieContextBuilder(config3)
        val longContent = "x".repeat(800) // ~200 tokens each
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "oldest turn $longContent", Instant.parse("2026-01-01T00:00:00Z"), turnId = "t1"),
                TalkieTurn(TurnRole.ROUTER, "middle turn $longContent", Instant.parse("2026-01-02T00:00:00Z"), turnId = "t2"),
                TalkieTurn(TurnRole.USER, "newest turn", Instant.parse("2026-01-03T00:00:00Z"), turnId = "t3"),
            ),
        )
        // Budget 100: L0 frame ~30 tokens -> remaining ~70; newest turn ~3 tokens -> protected.
        // Middle turn (~200+) doesn't fit -> dropped. Oldest turn also dropped.
        val pack = runBlocking { builder3.build(state, TokenBudget(limit = 100)) }
        val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
        // Newest turn must survive
        assertTrue(l1.any { it.content == "newest turn" })
        // Oldest turn should be dropped (too large)
        assertFalse(l1.any { it.content.contains("oldest turn") && it.content.contains(longContent) })
        assertTrue(pack.compressionMetadata.entriesDropped > 0)
        assertTrue(pack.compressionMetadata.truncatedLayers.contains(ContextLayer.L1))
    }

    @Test
    fun `L3 and L2 dropped before recent conversation when budget is tight`() {
        val longContent = "x".repeat(200) // ~50 tokens each
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "current user message", clock.now(), turnId = "curr"),
            ),
            lastRetrievedMemory = listOf(
                L3RetrievedHit(
                    entryId = "l3-entry",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "l3-turn",
                    text = longContent,
                    score = 0.9f,
                ),
            ),
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), longContent, StageOutcomeKind.SUCCESS, clock.now()),
            ),
        )
        // Budget 80: L0 frame ~30 -> remaining ~50; current user turn ~5 -> protected, remaining ~45
        // L3 entry ~50 tokens -> just barely doesn't fit (50 > 45) -> dropped
        // L2 entry ~50 tokens -> dropped
        val pack = buildPack(state, TokenBudget(limit = 80))
        val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
        // Current user message must survive
        assertTrue(l1.any { it.content == "current user message" })
        // L3 and L2 must be absent
        assertTrue(pack.layers[ContextLayer.L3].isNullOrEmpty())
        assertTrue(pack.layers[ContextLayer.L2].isNullOrEmpty())
        assertTrue(pack.compressionMetadata.entriesDropped >= 2)
    }

    // --------------------------------------------------------------------------
    // A2: availableWorkflows and projectProfileText L0 injection
    // --------------------------------------------------------------------------

    @Test
    fun `available workflows entry is injected into L0 when non-empty`() {
        val workflows = listOf(WorkflowSummary("healthcheck", "Runs the health check pipeline", listOf("collect", "report")))
        val pack = runBlocking { builder.build(TalkieState(), TokenBudget(limit = 10000), availableWorkflows = workflows) }
        val entry = (pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "availableWorkflows" }
        assertNotNull(entry)
        assertTrue(entry!!.content.contains("## Available workflows"))
        assertTrue(entry.content.contains("healthcheck: Runs the health check pipeline (stages: collect → report)"))
    }

    @Test
    fun `available workflows entry is omitted when list is empty`() {
        val pack = runBlocking { builder.build(TalkieState(), TokenBudget(limit = 10000)) }
        assertNull((pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "availableWorkflows" })
    }

    @Test
    fun `project profile text is injected into L0 when non-null`() {
        val text = "## Project profile\nA Kotlin service.\n### Conventions\n- Use runCatching"
        val pack = runBlocking { builder.build(TalkieState(), TokenBudget(limit = 10000), projectProfileText = text) }
        val entry = (pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "projectProfile" }
        assertEquals(text, entry!!.content)
    }

    @Test
    fun `project profile text is omitted when null or blank`() {
        val pack = runBlocking { builder.build(TalkieState(), TokenBudget(limit = 10000), projectProfileText = "  ") }
        assertNull((pack.layers[ContextLayer.L0] ?: emptyList()).find { it.sourceType == "projectProfile" })
    }

    @Test
    fun `system prompt contains triage directive not bare refusal`() {
        val pack = buildPack(TalkieState(), TokenBudget(limit = 10000))
        val content = pack.layers[ContextLayer.L0]?.find { it.sourceType == "systemPrompt" }!!.content
        assertFalse(content.contains("if something is not in context, say you do not have it"))
        assertTrue(content.contains("Available workflows", ignoreCase = true))
        assertTrue(content.contains("project profile", ignoreCase = true))
    }

    @Test
    fun `truncatedLayers populated with affected layers when drops occur`() {
        val longText = "x".repeat(800)
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, longText, clock.now(), turnId = "old"),
                TalkieTurn(TurnRole.USER, "current", clock.now(), turnId = "curr"),
            ),
            lastRetrievedMemory = listOf(
                L3RetrievedHit(
                    entryId = "l3e",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "lt",
                    text = longText,
                    score = 0.8f,
                ),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 60))
        val truncated = pack.compressionMetadata.truncatedLayers
        // At least L1 and L3 should be truncated
        assertTrue(truncated.isNotEmpty())
    }

    // --------------------------------------------------------------------------
    // N2/B2: protected-frame-overflow — budget smaller than system + current user turn
    // --------------------------------------------------------------------------

    @Test
    fun `budget overflow - system prompt and current user turn present even when they exceed budgetLimit`() {
        // Budget so tight that the protected frame alone (system prompt + workflow status) exceeds it.
        // The current user turn must still be included; budgetUsed must report the true total;
        // appliedStrategies must contain "BudgetExceeded".
        val longUserTurn = "x".repeat(200) // ~50 tokens
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, longUserTurn, clock.now(), turnId = "current"),
            ),
        )
        // Budget 1: system prompt + workflow status alone exceed this.
        val pack = buildPack(state, TokenBudget(limit = 1))

        // System prompt must be present
        val l0 = pack.layers[ContextLayer.L0] ?: emptyList()
        assertTrue(l0.any { it.sourceType == "systemPrompt" }, "System prompt must always be present")
        assertTrue(l0.any { it.sourceType == "workflowStatus" }, "Workflow status must always be present")

        // Current user turn must be present
        val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
        assertTrue(l1.any { it.content == longUserTurn }, "Current user turn must be present even when over budget")

        // budgetUsed must be the true total (exceeds budgetLimit)
        val allEntries = pack.layers.values.flatten()
        val trueTotal = allEntries.sumOf { it.tokenEstimate }
        assertEquals(trueTotal, pack.budgetUsed, "budgetUsed must report the true token total, not clamped")
        assertTrue(pack.budgetUsed > pack.budgetLimit, "budgetUsed must exceed budgetLimit in overflow scenario")

        // BudgetExceeded must be signalled in appliedStrategies
        assertTrue(
            pack.compressionMetadata.appliedStrategies.contains("BudgetExceeded"),
            "appliedStrategies must contain 'BudgetExceeded' when frame exceeds budget",
        )
    }

    // --------------------------------------------------------------------------
    // S1: conversationKeepLast = 0 must still include the current user turn
    // --------------------------------------------------------------------------

    @Test
    fun `conversationKeepLast zero still includes the current user turn`() {
        val configZero = TalkieConfig(conversationKeepLast = 0, tokenBudget = TokenBudget(limit = 10000))
        val builderZero = DefaultTalkieContextBuilder(configZero)
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "old turn 1", Instant.parse("2026-01-01T00:00:00Z"), turnId = "t1"),
                TalkieTurn(TurnRole.ROUTER, "old reply", Instant.parse("2026-01-02T00:00:00Z"), turnId = "t2"),
                TalkieTurn(TurnRole.USER, "current user input", Instant.parse("2026-01-03T00:00:00Z"), turnId = "t3"),
            ),
        )
        val pack = runBlocking { builderZero.build(state, TokenBudget(limit = 10000)) }
        val l1 = pack.layers[ContextLayer.L1] ?: emptyList()
        // conversationKeepLast=0 caps the window to 0 turns, but the protected current turn must appear
        assertTrue(l1.any { it.content == "current user input" }, "Current user turn must be present even with conversationKeepLast=0")
        // The older turns (capped out) must NOT be present
        assertFalse(l1.any { it.content == "old turn 1" }, "Older turns must not appear with conversationKeepLast=0")
        assertFalse(l1.any { it.content == "old reply" }, "Older turns must not appear with conversationKeepLast=0")
    }

    // --------------------------------------------------------------------------
    // S4: appliedStrategies includes L3Recall when L3 memory is injected
    // --------------------------------------------------------------------------

    @Test
    fun `appliedStrategies includes L3Recall when lastRetrievedMemory is non-empty`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            lastRetrievedMemory = listOf(
                L3RetrievedHit(
                    entryId = "e1",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "t1",
                    text = "recalled fact",
                    score = 0.7f,
                ),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        assertTrue(
            pack.compressionMetadata.appliedStrategies.contains("L3Recall"),
            "appliedStrategies must include 'L3Recall' when L3 memory is injected",
        )
    }

    @Test
    fun `appliedStrategies does not include L3Recall when lastRetrievedMemory is empty`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        assertFalse(
            pack.compressionMetadata.appliedStrategies.contains("L3Recall"),
            "appliedStrategies must not include 'L3Recall' when no L3 memory is present",
        )
    }

    // --------------------------------------------------------------------------
    // N3: ContextPack.layers is sorted by ContextLayer ordinal
    // --------------------------------------------------------------------------

    @Test
    fun `layers map is sorted by ContextLayer ordinal`() {
        val state = TalkieState(
            sessionId = sessionId,
            workflowStatus = WorkflowStatus.RUNNING,
            currentStageId = stageId,
            conversationHistory = listOf(
                TalkieTurn(TurnRole.USER, "hello", clock.now()),
            ),
            l2Memory = listOf(
                RouterL2Entry(StageId("s1"), "summary", StageOutcomeKind.SUCCESS, clock.now()),
            ),
            lastRetrievedMemory = listOf(
                L3RetrievedHit(
                    entryId = "e1",
                    sourceSessionId = SessionId("other"),
                    sourceTurnId = "t1",
                    text = "recalled",
                    score = 0.5f,
                ),
            ),
        )
        val pack = buildPack(state, TokenBudget(limit = 10000))
        val layerKeys = pack.layers.keys.toList()
        val ordinals = layerKeys.map { it.ordinal }
        assertEquals(ordinals.sorted(), ordinals, "ContextPack.layers must be sorted by ContextLayer ordinal")
    }
}
