package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.session.AwayDigest
import wang.yaojia.webterm.session.Digest
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.session.Telemetry
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.StatusTelemetry
import wang.yaojia.webterm.wire.TimelineEvent
import wang.yaojia.webterm.wiring.TerminalSessionController

/**
 * [GateViewModel] (A22) — the two-line epoch stale-guard, arrival-only haptics, telemetry hold, and
 * digest suppression. Pure JVM under `runTest`; an [UnconfinedTestDispatcher] runs the event
 * collector eagerly so an [FakeController.emit] is processed inline before the assertion. A hand-fake
 * [TerminalSessionController] both drives the control stream and records every `decideGate` so
 * "exactly one, right mode" is assertable. Compose sheets/cards/haptic firing are device-QA (§7).
 */
@OptIn(ExperimentalCoroutinesApi::class)
class GateViewModelTest {

    /**
     * Fake control-stream source + decision recorder. [controlEvents] is backed by an UNBOUNDED channel
     * (mirrors the real per-consumer [EventBus] mailbox) so an [emit] before the collector subscribes is
     * buffered, not dropped, and the collect loop yields per element (arrivals interleave correctly). The
     * VM captures ONE mailbox at construction (FIX 1), so returning `channel.receiveAsFlow()` per call is
     * faithful — this fake models a single VM's mailbox.
     */
    private class FakeController : TerminalSessionController {
        private val channel = Channel<SessionEvent>(Channel.UNLIMITED)
        val decisions = mutableListOf<Pair<Int, ClientMessage>>()

        override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
        override val output: Flow<ByteArray> = emptyFlow()
        override fun start() = Unit
        override fun sendInput(data: String) = Unit
        override fun resize(cols: Int, rows: Int) = Unit
        override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
        override fun decideGate(epoch: Int, message: ClientMessage) {
            decisions += epoch to message
        }

        override fun close() = Unit

        fun emit(event: SessionEvent) {
            channel.trySend(event) // UNLIMITED → never fails, never suspends.
        }
    }

    private fun toolGate(epoch: Int) = Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = epoch))
    private fun planGate(epoch: Int) = Gate(GateState(kind = GateKind.PLAN, detail = "plan", epoch = epoch))

    // ── Two-line stale-guard ─────────────────────────────────────────────────────

    @Test
    fun `a decision for a stale epoch is dropped`() = runTest(UnconfinedTestDispatcher()) {
        val fake = FakeController()
        val vm = GateViewModel(fake)
        vm.bind(backgroundScope)

        // The live gate has already advanced to epoch 2 (the old gate resolved, a new one rose).
        fake.emit(toolGate(2))

        // A slow tap against the RESOLVED epoch-1 gate must approve NOTHING (防误批新 gate).
        vm.decide(GateDecision.APPROVE, epoch = 1)

        assertTrue(fake.decisions.isEmpty())
    }

    @Test
    fun `a decision with no gate held is dropped`() = runTest(UnconfinedTestDispatcher()) {
        val fake = FakeController()
        val vm = GateViewModel(fake)
        vm.bind(backgroundScope)

        // Gate rose then fell (falling edge → no gate held).
        fake.emit(toolGate(1))
        fake.emit(Gate(null))

        vm.decide(GateDecision.APPROVE, epoch = 1)

        assertTrue(fake.decisions.isEmpty())
    }

    @Test
    fun `a current-epoch tool approve sends exactly one plain approve`() = runTest(UnconfinedTestDispatcher()) {
        val fake = FakeController()
        val vm = GateViewModel(fake)
        vm.bind(backgroundScope)
        fake.emit(toolGate(2))

        vm.decide(GateDecision.APPROVE, epoch = 2)

        assertEquals(listOf(2 to ClientMessage.Approve(mode = null)), fake.decisions)
    }

    @Test
    fun `a current-epoch tool reject sends exactly one reject`() = runTest(UnconfinedTestDispatcher()) {
        val fake = FakeController()
        val vm = GateViewModel(fake)
        vm.bind(backgroundScope)
        fake.emit(toolGate(4))

        vm.decide(GateDecision.REJECT, epoch = 4)

        assertEquals(listOf<Pair<Int, ClientMessage>>(4 to ClientMessage.Reject), fake.decisions)
    }

    @Test
    fun `acceptEdits on a tool gate is dropped (not an affordance of that kind)`() =
        runTest(UnconfinedTestDispatcher()) {
            val fake = FakeController()
            val vm = GateViewModel(fake)
            vm.bind(backgroundScope)
            fake.emit(toolGate(1))

            vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1)

            assertTrue(fake.decisions.isEmpty())
        }

    @Test
    fun `plan decisions resolve to the top-level approve mode key`() = runTest(UnconfinedTestDispatcher()) {
        val fake = FakeController()
        val vm = GateViewModel(fake)
        vm.bind(backgroundScope)
        fake.emit(planGate(3))

        vm.decide(GateDecision.APPROVE, epoch = 3)      // default review
        vm.decide(GateDecision.ACCEPT_EDITS, epoch = 3) // auto-accept
        vm.decide(GateDecision.REJECT, epoch = 3)       // keep planning

        assertEquals(
            listOf(
                3 to ClientMessage.Approve(mode = ApproveMode.DEFAULT),
                3 to ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS),
                3 to ClientMessage.Reject,
            ),
            fake.decisions,
        )
    }

    // ── Haptic arrival (rising-edge only) ────────────────────────────────────────

    @Test
    fun `gateArrivals fires once per new gate — never on refresh or falling edge`() =
        runTest(UnconfinedTestDispatcher()) {
            val fake = FakeController()
            val vm = GateViewModel(fake)
            val arrivals = mutableListOf<Unit>()
            backgroundScope.launch { vm.gateArrivals.collect { arrivals += it } }
            vm.bind(backgroundScope)

            fake.emit(toolGate(1)) // rising edge → arrival #1
            fake.emit(toolGate(1)) // same epoch refresh → NO arrival
            fake.emit(Gate(null))  // falling edge → NO arrival
            fake.emit(toolGate(2)) // new gate → arrival #2

            assertEquals(2, arrivals.size)
        }

    // ── Telemetry hold + digest suppression ──────────────────────────────────────

    @Test
    fun `latest telemetry is held in ui state`() = runTest(UnconfinedTestDispatcher()) {
        val fake = FakeController()
        val vm = GateViewModel(fake)
        vm.bind(backgroundScope)
        val telemetry = StatusTelemetry(model = "opus", at = 1_000L)

        fake.emit(Telemetry(telemetry))

        assertEquals(telemetry, vm.uiState.value.telemetry)
    }

    @Test
    fun `a non-empty away digest is shown and an all-zero digest is suppressed`() =
        runTest(UnconfinedTestDispatcher()) {
            val fake = FakeController()
            val vm = GateViewModel(fake)
            vm.bind(backgroundScope)
            val nonEmpty = AwayDigest(
                toolRuns = 2,
                waitingCount = 1,
                sawDone = false,
                sawStuck = false,
                recent = listOf(TimelineEvent(at = 5L, eventClass = "tool", label = "ran Bash")),
            )

            fake.emit(Digest(nonEmpty))
            assertEquals(nonEmpty, vm.uiState.value.digest)

            // A later reconnect with nothing to report suppresses the banner (holds null, not EMPTY).
            fake.emit(Digest(AwayDigest.EMPTY))
            assertNull(vm.uiState.value.digest)
        }

    @Test
    fun `an exit clears a held gate so no stale card lingers`() = runTest(UnconfinedTestDispatcher()) {
        val fake = FakeController()
        val vm = GateViewModel(fake)
        vm.bind(backgroundScope)
        fake.emit(toolGate(1))
        assertEquals(1, vm.uiState.value.gate?.epoch)

        fake.emit(Exited(code = 0))

        assertNull(vm.uiState.value.gate)
    }
}
