package com.fabledsword.minstrel.shared

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

private const val WORK_MS = 1_000L
private const val CHURN_ROUNDS = 5
private const val ART_IN_FLIGHT = 3
private const val SUCCEED_ON_ATTEMPT = 3
private const val QUIET_WINDOWS_TO_OUTLAST = 3

// Time to let a session finish once the screen has stopped changing: the
// retry backoffs, the quiet window and the minimum hold all fit inside it,
// while staying well under maxHoldMs. That gap matters — if a drain ran
// past the ceiling, "the veil lowered" would no longer distinguish
// "it settled" from "it gave up", which is the whole point of these tests.
private const val DRAIN_MS = 3_000L

/**
 * The veil's job is to go up only when content actually changes, and then to
 * stay up until the screen has stopped moving. Each test pins one of the ways
 * the original fixed-delay implementation got that wrong (issue #2327).
 *
 * The controller is built on `backgroundScope` throughout: its consumer
 * loop runs forever, so hanging it off the test's own scope would stop
 * `runTest` from ever completing.
 *
 * Consequence, and the reason every wait below is an explicit
 * `advanceTimeBy`: **`advanceUntilIdle()` is useless here.** It advances
 * only while *foreground* work remains, and everything this controller
 * does lives in `backgroundScope` — so it returns having run nothing, and
 * assertions land on a session that never started (CI run 3163 failed all
 * seven of these with "expected 3, actual 0" and friends). Drive the clock
 * deliberately instead; don't "simplify" these back to advanceUntilIdle.
 */
@OptIn(ExperimentalCoroutinesApi::class)
class UpdateVeilControllerTest {

    private val timings = VeilTimings(
        minHoldMs = 900,
        quietMs = 700,
        maxHoldMs = 12_000,
        attempts = 3,
        retryBackoffMs = 600,
    )

    /** Drives the settle signal by hand: content key, presence, art count. */
    private class FakeScreen(hasContent: Boolean = true) {
        val state = MutableStateFlow(Triple(0, hasContent, 0))

        val signal = state.map { (key, hasContent, art) ->
            VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0)
        }

        /** Content visibly changed — what the veil exists to cover. */
        fun churn() {
            state.value = state.value.copy(first = state.value.first + 1)
        }

        fun artLoading(count: Int) {
            state.value = state.value.copy(third = count)
        }

        fun contentAppears() {
            state.value = state.value.copy(second = true)
        }
    }

    private fun TestScope.controllerOn(
        screen: FakeScreen,
        shouldVeil: suspend () -> Boolean = { true },
        onSessionEnd: (VeilSessionResult) -> Unit = {},
        work: suspend () -> Boolean,
    ) = UpdateVeilController(
        scope = backgroundScope,
        settleSignal = screen.signal,
        shouldVeil = shouldVeil,
        timings = timings,
        onSessionEnd = onSessionEnd,
        work = work,
    )

    /** Records every visibility transition, so an extra raise can't hide. */
    private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> {
        val seen = mutableListOf<Boolean>()
        backgroundScope.launch { controller.visible.collect { seen.add(it) } }
        return seen
    }

    @Test
    fun `veil outlasts content that keeps churning after the pull returns`() = runTest {
        val screen = FakeScreen()
        val controller = controllerOn(screen) { true }

        controller.request()
        runCurrent()
        // The pull's write lands: content changed, so the veil goes up.
        screen.churn()
        runCurrent()
        assertTrue(controller.visible.value, "veil is up while the screen is still moving")

        // Tiles hydrating one after another, each inside the quiet window.
        // The old implementation had already wiped off after a flat 500ms.
        repeat(CHURN_ROUNDS) {
            advanceTimeBy(timings.quietMs / 2)
            screen.churn()
            runCurrent()
            assertTrue(controller.visible.value, "veil must hold across staged churn")
        }

        advanceTimeBy(DRAIN_MS)
        assertFalse(controller.visible.value, "veil lowers once the screen goes quiet")
    }

    @Test
    fun `veil waits for artwork to finish loading`() = runTest {
        val screen = FakeScreen()
        val controller = controllerOn(screen) { true }

        screen.artLoading(ART_IN_FLIGHT)
        controller.request()
        runCurrent()
        screen.churn()
        runCurrent()
        // Well past the quiet window and the floor — but art is still in
        // flight, so lowering now would show the covers popping in.
        advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST)
        assertTrue(controller.visible.value, "veil must wait on in-flight art")

        screen.artLoading(0)
        advanceTimeBy(DRAIN_MS)
        assertFalse(controller.visible.value, "veil lowers once art has landed")
    }

    @Test
    fun `retries quietly, then veils the churn the successful attempt produces`() = runTest {
        val screen = FakeScreen()
        var attempts = 0
        val controller = controllerOn(screen) {
            attempts++
            val succeeded = attempts >= SUCCEED_ON_ATTEMPT // fail twice
            // Only a pull that worked writes anything.
            if (succeeded) screen.churn()
            succeeded
        }
        val seen = recordVisibility(controller)

        controller.request()
        runCurrent()
        // A failed pull changes nothing, so there is nothing to hide yet —
        // the retries happen with no veil at all.
        assertFalse(controller.visible.value, "no veil over a pull that changed nothing")

        advanceTimeBy(DRAIN_MS)
        assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
        assertEquals(
            listOf(false, true, false),
            seen,
            "the veil went up once, over the churn the retry finally produced",
        )
    }

    @Test
    fun `giving up is silent, reports FAILED, and does not block later requests`() = runTest {
        val screen = FakeScreen()
        val results = mutableListOf<VeilSessionResult>()
        var attempts = 0
        var succeed = false
        val controller = controllerOn(screen, onSessionEnd = { results += it }) {
            attempts++
            succeed
        }

        controller.request(userInitiated = true)
        advanceTimeBy(DRAIN_MS)
        assertEquals(timings.attempts, attempts, "exhausts its attempts")
        assertFalse(controller.visible.value, "no veil — a failed pull changed nothing")
        assertEquals(VeilOutcome.FAILED, results.single().outcome)
        assertTrue(results.single().userInitiated, "the user asked, so they're owed an answer")

        // Giving up must not latch anything off — the reconnect-driven
        // recovery still gets to try again later.
        succeed = true
        controller.request()
        advanceTimeBy(DRAIN_MS)
        assertEquals(timings.attempts + 1, attempts, "a later request still runs")
    }

    @Test
    fun `overlapping requests extend one veil instead of racing it`() = runTest {
        val screen = FakeScreen()
        var started = 0
        val controller = controllerOn(screen) {
            started++
            delay(WORK_MS)
            screen.churn()
            true
        }
        val seen = recordVisibility(controller)

        // Reconnect and the rebuild event arriving together is what made the
        // old Boolean flag clear mid-update: whichever pull finished first
        // wiped the veil off while the other was still running.
        controller.request()
        runCurrent()
        controller.request()
        advanceTimeBy(WORK_MS + 1)
        assertTrue(controller.visible.value, "second trigger extends the same veil")

        advanceTimeBy(DRAIN_MS)
        assertEquals(2, started, "the mid-session trigger still did its pull")
        assertEquals(listOf(false, true, false), seen, "one veil session, not two")
    }

    @Test
    fun `a never-settling screen still releases the veil at the ceiling`() = runTest {
        val screen = FakeScreen()
        val controller = controllerOn(screen) {
            screen.churn()
            true
        }

        screen.artLoading(1) // an image that never completes
        controller.request()
        advanceTimeBy(timings.maxHoldMs + 1)
        assertFalse(controller.visible.value, "the hard ceiling must never strand the user")
    }

    @Test
    fun `an unchanged refresh never raises the veil and reports UNCHANGED`() = runTest {
        val screen = FakeScreen()
        val results = mutableListOf<VeilSessionResult>()
        // Succeeds without writing anything — the common case on a launch
        // over a warm cache, where the server returns what's already cached.
        val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
        val seen = recordVisibility(controller)

        controller.request(userInitiated = true)
        advanceTimeBy(DRAIN_MS)

        assertEquals(listOf(false), seen, "a veil over an unchanged screen would hide nothing")
        assertEquals(VeilOutcome.UNCHANGED, results.single().outcome)
        assertTrue(results.single().userInitiated, "so the caller can say 'already up to date'")
    }

    @Test
    fun `cached content painting is not mistaken for a change`() = runTest {
        // Warm cache that hasn't painted yet: the rows arrive a moment after
        // the session starts. Treating that first paint as churn would veil
        // every single launch.
        val screen = FakeScreen(hasContent = false)
        val controller = controllerOn(screen) { true }

        controller.request()
        runCurrent()
        screen.contentAppears()
        advanceTimeBy(DRAIN_MS)

        assertFalse(controller.visible.value, "first paint is not churn")
    }

    @Test
    fun `a background trigger coalescing with the user's does not swallow their answer`() =
        runTest {
            val screen = FakeScreen()
            val results = mutableListOf<VeilSessionResult>()
            val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }

            // Conflation drops the older token, so the "a user asked" bit
            // cannot ride on it — it's tracked separately for exactly this.
            controller.request(userInitiated = true)
            controller.request()
            advanceTimeBy(DRAIN_MS)

            assertTrue(
                results.first().userInitiated,
                "the user's request must not be conflated away",
            )
        }

    @Test
    fun `a cold load runs unveiled and is never reported as already up to date`() = runTest {
        val screen = FakeScreen()
        val results = mutableListOf<VeilSessionResult>()
        var ran = false
        val controller = controllerOn(
            screen,
            shouldVeil = { false }, // empty cache: the skeleton owns this
            onSessionEnd = { results += it },
        ) {
            ran = true
            true
        }

        controller.request(userInitiated = true)
        advanceTimeBy(DRAIN_MS)

        assertTrue(ran, "the refresh still happens")
        assertFalse(controller.visible.value, "but no veil over a skeleton")
        // It went from nothing to something — that IS a change.
        assertEquals(VeilOutcome.CHANGED, results.single().outcome)
    }
}
