package wang.yaojia.webterm.wiring

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.Output
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.GateKind

/**
 * The per-session [EventBus] contract (plan §9 R10, A15 FIXes 1/2/4):
 *  - TWO typed sub-streams — [EventBus.outputBytes] (Output-only, decoded) and [EventBus.controlEvents]
 *    (non-Output) — so no consumer buffers what it ignores (FIX 4);
 *  - EAGER mailbox registration so an event published before collection begins is not dropped (FIX 2a);
 *  - per-consumer unbounded channels so a slow consumer neither stalls a fast one nor drops its own
 *    later event (R10 — the property a single `SharedFlow` cannot give);
 *  - a mailbox that lives for the SESSION, not a collection: the returned flow is re-collectable across
 *    a config-change (rotation) cancel with zero loss, and released only by [EventBus.close] (FIX 1).
 */
@OptIn(ExperimentalCoroutinesApi::class)
class EventBusTest {

    private val gate1: SessionEvent = Gate(GateState(kind = GateKind.TOOL, detail = "bash", epoch = 1))
    private val gate2: SessionEvent = Gate(GateState(kind = GateKind.TOOL, detail = "ls", epoch = 2))
    private val gate3: SessionEvent = Gate(GateState(kind = GateKind.TOOL, detail = "cat", epoch = 3))

    @Test
    fun `outputBytes carries only decoded Output and controlEvents only non-Output`() = runTest {
        // Arrange: decode on the test scheduler so flowOn(decodeDispatcher) runs under virtual time.
        val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
        val output = bus.outputBytes() // EAGER registration at call time
        val control = bus.controlEvents()
        val gotBytes = mutableListOf<ByteArray>()
        val gotControl = mutableListOf<SessionEvent>()

        // Act
        bus.publish(Output("hi"))
        bus.publish(gate1)
        val jb = launch { output.collect { gotBytes += it } }
        val jc = launch { control.collect { gotControl += it } }
        advanceUntilIdle()

        // Assert: the terminal stream sees ONLY the decoded Output bytes; the cockpit stream sees ONLY
        // the gate — neither buffers the other's traffic.
        assertEquals("hi", gotBytes.single().toString(Charsets.UTF_8))
        assertEquals(listOf(gate1), gotControl)

        jb.cancel()
        jc.cancel()
    }

    @Test
    fun `an event published before collection begins is still delivered (eager registration)`() = runTest {
        val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
        val control = bus.controlEvents() // mailbox registered NOW, before any collect
        val received = mutableListOf<SessionEvent>()

        bus.publish(gate1) // published BEFORE the collector runs — must be buffered, not dropped

        val job = launch { control.collect { received += it } }
        runCurrent()

        assertEquals(listOf(gate1), received)
        job.cancel()
    }

    @Test
    fun `slow consumer neither stalls a fast consumer nor drops a buffered event`() = runTest {
        val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
        val fast = bus.controlEvents()
        val slow = bus.controlEvents()
        val fastReceived = mutableListOf<SessionEvent>()
        val slowReceived = mutableListOf<SessionEvent>()
        val release = CompletableDeferred<Unit>()

        val fastJob = launch { fast.collect { fastReceived += it } }
        val slowJob = launch {
            slow.collect { event ->
                slowReceived += event
                if (event === gate1) release.await() // stall AFTER the first event, before the second
            }
        }
        runCurrent()

        bus.publish(gate1)
        bus.publish(gate2)
        runCurrent()

        // (a) NO STALL: the fast consumer received BOTH events even though the slow one is blocked.
        assertEquals(listOf(gate1, gate2), fastReceived)
        assertEquals(listOf(gate1), slowReceived) // slow parked; gate2 buffered in its own mailbox

        release.complete(Unit)
        runCurrent()

        // (b) NO DROP: the slow consumer eventually receives the buffered gate2 — nothing was lost.
        assertEquals(listOf(gate1, gate2), slowReceived)

        fastJob.cancel()
        slowJob.cancel()
    }

    @Test
    fun `the same flow is re-collectable across a config-change cancel with zero loss (rotation)`() = runTest {
        // The controller registers ONE control mailbox once, at construction. A rotation cancels the
        // terminal screen's collector and the recomposed screen re-collects that SAME flow — it must
        // resume on the SAME mailbox (not iterate a dead channel), or the terminal goes blank forever.
        val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
        val control = bus.controlEvents() // registered ONCE — collected, cancelled, then re-collected
        val received = mutableListOf<SessionEvent>()

        // First collection (pre-rotation): drains gate1.
        val first = launch { control.collect { received += it } }
        runCurrent()
        bus.publish(gate1)
        runCurrent()
        assertEquals(listOf(gate1), received)

        // Rotation: the collector is cancelled. The mailbox MUST survive (not close/unregister).
        first.cancel()
        runCurrent()

        // Events published DURING the gap (no live collector) must buffer in the mailbox, not drop.
        bus.publish(gate2)
        runCurrent()

        // Recomposed screen RE-COLLECTS the SAME flow → resumes on the SAME mailbox.
        val second = launch { control.collect { received += it } }
        runCurrent()
        bus.publish(gate3) // and a fresh event after re-collection
        runCurrent()

        // ZERO loss, not a dead stream: the gap event AND the post-rotation event both arrive, in order.
        assertEquals(listOf(gate1, gate2, gate3), received)
        second.cancel()
    }

    @Test
    fun `outputBytes is re-collectable across a rotation with zero loss (the replay path)`() = runTest {
        // The MOTIVATING failure: if the OUTPUT mailbox closed on the terminal collector's rotation
        // cancel, the recomposed terminal would re-collect a dead stream and stay BLANK FOREVER. The
        // decoded byte stream (outputBytes = register().map().flowOn(decodeDispatcher)) must survive
        // collector churn exactly like controlEvents — this locks the property on the output path,
        // whose .map/.flowOn composition a future edit could otherwise silently break.
        val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
        val output = bus.outputBytes() // registered ONCE, at construction
        val got = mutableListOf<String>()

        val first = launch { output.collect { got += it.toString(Charsets.UTF_8) } }
        bus.publish(Output("a"))
        advanceUntilIdle()
        assertEquals(listOf("a"), got)

        // Rotation: cancel the terminal collector; the output mailbox must NOT close/unregister.
        first.cancel()
        advanceUntilIdle()

        // A ring-replay-sized frame lands DURING the gap (no live collector) → buffered, not dropped.
        bus.publish(Output("REPLAY"))
        advanceUntilIdle()

        // Recomposed terminal RE-COLLECTS the SAME flow → resumes on the SAME mailbox.
        val second = launch { output.collect { got += it.toString(Charsets.UTF_8) } }
        bus.publish(Output("c"))
        advanceUntilIdle()

        // ZERO loss on the output path, not a dead stream: the gap replay AND the post-rotation frame.
        assertEquals(listOf("a", "REPLAY", "c"), got)
        second.cancel()
    }

    @Test
    fun `close closes every mailbox so collectors complete and a later publish is a no-op`() = runTest {
        // EventBus.close() is the session-end release (called from RetainedSessionHolder.teardown after
        // the engine close-join). It closes each mailbox — completing any live collector — and clears
        // the registry so a late publish reaches no one.
        val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
        val control = bus.controlEvents()
        val received = mutableListOf<SessionEvent>()
        var completed = false

        val job = launch {
            control.collect { received += it }
            completed = true // reached only when the mailbox is closed and the flow ends normally
        }
        runCurrent()
        bus.publish(gate1)
        runCurrent()
        assertEquals(listOf(gate1), received)
        assertFalse(completed) // still live before close

        bus.close()
        runCurrent()
        assertTrue(completed) // channel closed → receiveAsFlow completed → collector returned
        assertTrue(job.isCompleted)

        bus.publish(gate2) // registry cleared → harmless no-op
        runCurrent()
        assertEquals(listOf(gate1), received) // nothing more delivered
    }
}
