import com.correx.core.context.compression.CompressionStrategy
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.TokenBudget
import com.correx.core.events.types.ContextEntryId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource

class ContextCompressionDeterminismTest {

    private val compressor = DefaultContextCompressor()

    private fun entry(
        id: String,
        layer: ContextLayer,
        tokens: Int,
        content: String = "content-$id",
        sourceId: String = id
    ) = ContextEntry(
        id = ContextEntryId(id),
        layer = layer,
        content = content,
        sourceType = "test",
            sourceId = sourceId,
            tokenEstimate = tokens
        )

    @Test
    fun `same input always produces same output`() {
        val entries = listOf(
            entry("a", ContextLayer.L2, 100),
            entry("b", ContextLayer.L1, 50),
            entry("c", ContextLayer.L0, 30)
        )
        val budget = TokenBudget(limit = 200)
        val result1 = compressor.compress(entries, budget, CompressionStrategy.Conversation())
        val result2 = compressor.compress(entries, budget, CompressionStrategy.Conversation())
        assertEquals(result1, result2)
    }

    @Test
    fun `L2 entries are dropped before L1 when over budget`() {
        val entries = listOf(
            entry("l2-1", ContextLayer.L2, 80),
            entry("l1-1", ContextLayer.L1, 80),
            entry("l0-1", ContextLayer.L0, 80)
        )
        val budget = TokenBudget(limit = 170)
        val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
        assertTrue(result.none { it.layer == ContextLayer.L2 }, "L2 must be dropped first")
        assertTrue(result.any { it.layer == ContextLayer.L1 }, "L1 must be retained")
        assertTrue(result.any { it.layer == ContextLayer.L0 }, "L0 must always be retained")
    }

    @Test
    fun `SteeringNote entries are never dropped regardless of budget`() {
        val entries = listOf(
            entry("s1", ContextLayer.L1, 500),
            entry("s2", ContextLayer.L2, 500)
        )
        val budget = TokenBudget(limit = 10)
        val result = compressor.compress(entries, budget, CompressionStrategy.SteeringNote)
        assertEquals(2, result.size)
    }

    @Test
    fun `EventHistory entries are never dropped`() {
        val entries = listOf(
            entry("e1", ContextLayer.L2, 500),
            entry("e2", ContextLayer.L2, 500)
        )
        val budget = TokenBudget(limit = 10)
        val result = compressor.compress(entries, budget, CompressionStrategy.EventHistory)
        assertEquals(2, result.size)
    }

    @Test
    fun `Artifact strategy keeps latest entry per sourceId`() {
        val entries = listOf(
            entry("v1", ContextLayer.L1, 50, sourceId = "artifact-a"),
            entry("v2", ContextLayer.L1, 50, sourceId = "artifact-a"),
            entry("v3", ContextLayer.L1, 50, sourceId = "artifact-b")
        )
        val budget = TokenBudget(limit = 4000)
        val result = compressor.compress(entries, budget, CompressionStrategy.Artifact)
        assertEquals(2, result.size)
        assertTrue(result.any { it.id == ContextEntryId("v2") }, "Latest artifact-a version must be kept")
        assertTrue(result.any { it.id == ContextEntryId("v3") }, "artifact-b must be kept")
    }

    @Test
    fun `ToolLog strategy deduplicates identical content`() {
        val entries = listOf(
            entry("t1", ContextLayer.L1, 30, content = "same output"),
            entry("t2", ContextLayer.L1, 30, content = "same output"),
            entry("t3", ContextLayer.L1, 30, content = "different output")
        )
        val budget = TokenBudget(limit = 4000)
        val result = compressor.compress(entries, budget, CompressionStrategy.ToolLog)
        assertEquals(2, result.size)
    }

    @Test
    fun `entries fitting within budget are not dropped`() {
        val entries = listOf(
            entry("a", ContextLayer.L1, 30),
            entry("b", ContextLayer.L2, 30)
        )
        val budget = TokenBudget(limit = 200)
        val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
        assertEquals(2, result.size)
    }

    @Test
    fun `Conversation keepLast drops oldest entries beyond the limit`() {
        val entries = (1..5).map { entry("e$it", ContextLayer.L1, 10) }
        val budget = TokenBudget(limit = 4000)
        val result = compressor.compress(entries, budget, CompressionStrategy.Conversation(keepLast = 3))
        assertEquals(3, result.size)
        assertEquals(listOf("e3", "e4", "e5"), result.map { it.id.value })
    }

    @Test
    fun `Conversation L0 is never truncated when L1 overflows budget`() {
        val entries = listOf(
            entry("l0-1", ContextLayer.L0, 60),
            entry("l1-1", ContextLayer.L1, 60),
            entry("l1-2", ContextLayer.L1, 60)
        )
        val budget = TokenBudget(limit = 100)
        val result = compressor.compress(entries, budget, CompressionStrategy.Conversation())
        assertTrue(result.any { it.layer == ContextLayer.L0 }, "L0 must never be dropped")
        assertTrue(result.none { it.id == ContextEntryId("l1-1") }, "Oldest L1 must be dropped first")
    }

    // Parameterized equivalence test: verifies the refactored running-total trimToFit
    // produces the same output as the reference naive implementation for varied input shapes.
    @ParameterizedTest
    @MethodSource("trimToFitEquivalenceCases")
    fun `trimToFit running-total matches reference naive implementation`(
        entries: List<ContextEntry>,
        budget: TokenBudget
    ) {
        val expected = naiveTrimToFit(entries, budget)
        val actual = compressor.compress(entries, budget, CompressionStrategy.Conversation())
        assertEquals(expected, actual, "running-total trimToFit must match naive O(n²) reference")
    }

    // Reference O(n²) implementation kept as spec baseline — intentionally not optimized.
    private fun naiveTrimToFit(entries: List<ContextEntry>, budget: TokenBudget): List<ContextEntry> {
        val dropOrder = listOf(ContextLayer.L2, ContextLayer.L1)
        if (entries.sumOf { it.tokenEstimate } <= budget.limit) return entries
        val mutable = entries.toMutableList()
        for (layer in dropOrder) {
            if (mutable.sumOf { it.tokenEstimate } <= budget.limit) break
            val layerEntries = mutable.filter { it.layer == layer }
            for (e in layerEntries) {
                if (mutable.sumOf { it.tokenEstimate } <= budget.limit) break
                mutable.remove(e)
            }
        }
        return mutable
    }

    companion object {
        @JvmStatic
        fun trimToFitEquivalenceCases(): List<Array<Any>> {
            fun e(id: String, layer: ContextLayer, tokens: Int) =
                ContextEntry(
                    id = ContextEntryId(id),
                    layer = layer,
                    content = "c-$id",
                    sourceType = "test",
                    sourceId = id,
                    tokenEstimate = tokens
                )

            return listOf(
                // All fit — nothing dropped
                arrayOf(
                    listOf(e("a", ContextLayer.L1, 10), e("b", ContextLayer.L2, 10)),
                    TokenBudget(limit = 100)
                ),
                // L2 fully dropped, L1 retained
                arrayOf(
                    listOf(e("x1", ContextLayer.L2, 80), e("x2", ContextLayer.L1, 80), e("x3", ContextLayer.L0, 40)),
                    TokenBudget(limit = 130)
                ),
                // Both L2 and some L1 dropped
                arrayOf(
                    (1..5).map { e("l2-$it", ContextLayer.L2, 30) } +
                        (1..5).map { e("l1-$it", ContextLayer.L1, 30) } +
                        listOf(e("l0-1", ContextLayer.L0, 20)),
                    TokenBudget(limit = 80)
                ),
                // Empty list
                arrayOf(emptyList<ContextEntry>(), TokenBudget(limit = 50)),
                // Single entry exactly at budget
                arrayOf(listOf(e("only", ContextLayer.L2, 50)), TokenBudget(limit = 50))
            )
        }
    }
}
