package wang.yaojia.webterm.wiring

import android.graphics.Bitmap
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotSame
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.SessionPreview
import java.util.UUID

/**
 * The [ThumbnailPipeline] policy (plan §6.7), driven at JVM speed with fakes so the cache/concurrency/
 * dedup/failure logic is verified without android.graphics (the pixel raster lives behind the
 * [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd).
 *
 * Proves:
 *  - an unchanged `(sessionId, lastOutputAt)` ⇒ cache hit, NO re-render; a changed `lastOutputAt` ⇒ render;
 *  - the `Semaphore(2)` caps concurrent fetch+render at 2;
 *  - two concurrent same-key requests SHARE one render (one fetch, one raster, one bitmap);
 *  - a fetch failure caches the placeholder and is NOT retried on the next request (no hot-loop storm);
 *  - [PreviewCap] caps the body at 256 KiB, keeping the tail.
 */
@OptIn(ExperimentalCoroutinesApi::class)
class ThumbnailPipelineTest {

    // ── Fakes ─────────────────────────────────────────────────────────────────────────────────

    /** Counts + optionally blocks fetches, tracking peak concurrency, so the semaphore cap is observable. */
    private class FakePreviewSource(
        private val block: CompletableDeferred<Unit>? = null,
        private val failure: Throwable? = null,
    ) : PreviewSource {
        var fetchStarts = 0
        var active = 0
        var maxActive = 0

        override suspend fun fetch(sessionId: UUID): SessionPreview {
            fetchStarts++
            active++
            if (active > maxActive) maxActive = active
            try {
                block?.await()
                failure?.let { throw it }
                return SessionPreview(id = sessionId, cols = 80, rows = 24, data = "hi")
            } finally {
                active--
            }
        }
    }

    /** Hands back a fresh mock bitmap per raster (so identity distinguishes renders) + a fixed placeholder. */
    private class FakeRasterizer : ThumbnailRasterizer {
        var rasterizeCount = 0
        val placeholderBitmap: Bitmap = mockk()

        override fun rasterize(preview: SessionPreview): Bitmap {
            rasterizeCount++
            return mockk()
        }

        override fun placeholder(): Bitmap = placeholderBitmap
    }

    /** Map-backed cache seam — keeps LRU KEYING testable without touching the stubbed android.util.LruCache. */
    private class FakeBitmapCache : BitmapCache {
        private val map = HashMap<ThumbnailKey, Bitmap>()
        override fun get(key: ThumbnailKey): Bitmap? = map[key]
        override fun put(key: ThumbnailKey, bitmap: Bitmap) {
            map[key] = bitmap
        }
    }

    private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo(
        id = id,
        createdAt = 0L,
        clientCount = 0,
        exited = false,
        cols = 80,
        rows = 24,
        lastOutputAt = lastOutputAt,
    )

    // ── Tests ─────────────────────────────────────────────────────────────────────────────────

    @Test
    fun `unchanged lastOutputAt hits cache with no re-render, changed lastOutputAt re-renders`() = runTest {
        val raster = FakeRasterizer()
        val pipeline = ThumbnailPipeline(
            previewSource = FakePreviewSource(),
            rasterizer = raster,
            cache = FakeBitmapCache(),
            dispatcher = StandardTestDispatcher(testScheduler),
        )
        val id = UUID.randomUUID()

        val first = async { pipeline.thumbnail(session(id, lastOutputAt = 100L)) }
        advanceUntilIdle()
        val b1 = first.await()
        assertEquals(1, raster.rasterizeCount)

        // Same key → cache hit, no second render.
        val second = async { pipeline.thumbnail(session(id, lastOutputAt = 100L)) }
        advanceUntilIdle()
        val b2 = second.await()
        assertEquals(1, raster.rasterizeCount)
        assertSame(b1, b2)

        // Changed lastOutputAt → new key → re-render.
        val third = async { pipeline.thumbnail(session(id, lastOutputAt = 200L)) }
        advanceUntilIdle()
        val b3 = third.await()
        assertEquals(2, raster.rasterizeCount)
        assertNotSame(b1, b3)
    }

    @Test
    fun `semaphore caps concurrent fetch and render at two`() = runTest {
        val gate = CompletableDeferred<Unit>()
        val source = FakePreviewSource(block = gate)
        val pipeline = ThumbnailPipeline(
            previewSource = source,
            rasterizer = FakeRasterizer(),
            cache = FakeBitmapCache(),
            dispatcher = StandardTestDispatcher(testScheduler),
        )

        val jobs = (0 until 5).map { launch { pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) } }
        advanceUntilIdle()

        // Only 2 permits → only 2 fetches in flight; the other 3 are parked on the semaphore.
        assertEquals(2, source.active)
        assertEquals(2, source.maxActive)
        assertEquals(2, source.fetchStarts)

        gate.complete(Unit)
        advanceUntilIdle()

        assertEquals(2, source.maxActive) // never exceeded the cap across the whole run
        assertEquals(5, source.fetchStarts)
        jobs.forEach { assertTrue(it.isCompleted) }
    }

    @Test
    fun `two concurrent same-key requests share one render`() = runTest {
        val gate = CompletableDeferred<Unit>()
        val source = FakePreviewSource(block = gate)
        val raster = FakeRasterizer()
        val pipeline = ThumbnailPipeline(
            previewSource = source,
            rasterizer = raster,
            cache = FakeBitmapCache(),
            dispatcher = StandardTestDispatcher(testScheduler),
        )
        val same = session(UUID.randomUUID(), lastOutputAt = 7L)

        val a = async { pipeline.thumbnail(same) }
        val b = async { pipeline.thumbnail(same) }
        advanceUntilIdle()
        assertEquals(1, source.fetchStarts) // deduped: a single in-flight fetch

        gate.complete(Unit)
        advanceUntilIdle()

        assertEquals(1, source.fetchStarts)
        assertEquals(1, raster.rasterizeCount)
        assertSame(a.await(), b.await()) // both callers get the SAME bitmap
    }

    @Test
    fun `fetch failure caches placeholder and is not retried`() = runTest {
        val source = FakePreviewSource(failure = RuntimeException("boom"))
        val raster = FakeRasterizer()
        val pipeline = ThumbnailPipeline(
            previewSource = source,
            rasterizer = raster,
            cache = FakeBitmapCache(),
            dispatcher = StandardTestDispatcher(testScheduler),
        )
        val s = session(UUID.randomUUID(), lastOutputAt = 5L)

        val r1 = async { pipeline.thumbnail(s) }
        advanceUntilIdle()
        assertSame(raster.placeholderBitmap, r1.await())
        assertEquals(1, source.fetchStarts)
        assertEquals(0, raster.rasterizeCount)

        // Same key again → cached placeholder → NO second fetch (no retry storm).
        val r2 = async { pipeline.thumbnail(s) }
        advanceUntilIdle()
        assertSame(raster.placeholderBitmap, r2.await())
        assertEquals(1, source.fetchStarts)
    }

    @Test
    fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() {
        val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL"
        val capped = PreviewCap.cap(SessionPreview(UUID.randomUUID(), 80, 24, big))
        assertTrue(capped.data.toByteArray(Charsets.UTF_8).size <= PreviewCap.MAX_PREVIEW_BYTES)
        assertTrue(capped.data.endsWith("TAIL"))

        val small = SessionPreview(UUID.randomUUID(), 80, 24, "hello")
        assertSame(small, PreviewCap.cap(small))
    }
}
