package wang.yaojia.webterm.wiring

import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
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.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.TermTransport

/**
 * The [RetainedSessionHolder] lifecycle invariant (plan §6.6, A15 FIX 5/6). Exercised at JVM speed by
 * driving a REAL [wang.yaojia.webterm.session.SessionEngine] over a [FakeTermTransport], with the
 * engine's confinement scope pinned to a virtual-time [StandardTestDispatcher] (via the factory's
 * `confinedScopeFactory` test seam) so config-change vs real-background teardown is deterministic.
 *
 * Proves:
 *  - a config change ([onStop] `isChangingConfigurations=true`) SURVIVES — no detach, no generation bump;
 *  - [bind] REUSES the survivor across the config change (no second engine);
 *  - a genuine background ([onStop] `false`) closes BEFORE cancelling the scope (the detach actually
 *    happens) and bumps `generation`;
 *  - `onCleared()` (nav pop) closes but does NOT bump `generation`.
 */
@OptIn(ExperimentalCoroutinesApi::class)
class RetainedSessionHolderTest {

    private fun endpoint(): HostEndpoint = HostEndpoint.fromBaseUrl("http://localhost:3000")!!

    /**
     * Build a holder whose engine confinement, output collector and (fake) emulator all run on [scope]'s
     * virtual-time scheduler — so the holder now-owns-the-emulator lifecycle (FIX 3) drives off `Main`
     * with no real append thread.
     */
    private fun TestScope.newHolder(): Pair<RetainedSessionHolder, FakeTermTransport> {
        val transport = FakeTermTransport()
        val factory = SessionEngineFactory(Lazy<TermTransport> { transport })
        val dispatcher = StandardTestDispatcher(testScheduler)
        factory.confinedScopeFactory = { CoroutineScope(dispatcher + SupervisorJob()) }
        val holder = RetainedSessionHolder(factory).apply {
            mainDispatcher = dispatcher // output→feedRemote collector on virtual time, never real Main
            remoteSessionFactory = { engineSend, _ ->
                RemoteTerminalSession(
                    engineSend = engineSend,
                    appendDispatcher = dispatcher,
                    mainDispatcher = dispatcher,
                )
            }
        }
        return holder to transport
    }

    @Test
    fun `config change survives and rebind reuses the same engine without detaching`() = runTest {
        val (holder, transport) = newHolder()

        val c1 = holder.bind(endpoint())
        c1.start()
        advanceUntilIdle()
        assertEquals(1, transport.connectAttempts.size) // exactly one engine connected

        // Config change (rotation): the survivor must stay live — no detach, no generation bump.
        holder.onStop(isChangingConfigurations = true)
        advanceUntilIdle()
        assertEquals(0, holder.generation)
        assertSame(c1, holder.controller)
        assertEquals(0, transport.closeCallCount)

        // Recreated Activity re-binds: reuse the SAME controller, never spawn a second engine.
        val c2 = holder.bind(endpoint())
        assertSame(c1, c2)
        assertEquals(1, transport.connectAttempts.size)

        // Clean up the still-live engine so no coroutine outlives the test.
        holder.onStop(isChangingConfigurations = false)
        advanceUntilIdle()
    }

    @Test
    fun `rebind with a different session key throws instead of mis-routing (single-session-for-life)`() = runTest {
        val (holder, _) = newHolder()

        val c1 = holder.bind(endpoint(), sessionId = "sess-1")
        c1.start()
        advanceUntilIdle()

        // Same endpoint, DIFFERENT sessionId: the short-circuit must NOT silently return the wrong
        // (already-bound) session — it is a nav-scoping programming error, so it throws.
        val mismatch = assertThrows(IllegalStateException::class.java) {
            holder.bind(endpoint(), sessionId = "sess-2")
        }
        assertTrue(mismatch.message!!.contains("single-session-for-life"))

        // A different endpoint likewise throws.
        val other = HostEndpoint.fromBaseUrl("http://localhost:3001")!!
        assertThrows(IllegalStateException::class.java) {
            holder.bind(other, sessionId = "sess-1")
        }

        // The originally-bound controller is untouched by the rejected re-binds.
        assertSame(c1, holder.controller)

        holder.onStop(isChangingConfigurations = false)
        advanceUntilIdle()
    }

    @Test
    fun `genuine background detaches before cancel and bumps generation`() = runTest {
        val (holder, transport) = newHolder()

        val c = holder.bind(endpoint())
        c.start()
        advanceUntilIdle()
        assertEquals(1, transport.connectAttempts.size)
        assertEquals(0, transport.closeCallCount)

        holder.onStop(isChangingConfigurations = false) // genuine background
        advanceUntilIdle()

        // close() ran to completion (the detach happened) — proving close-before-cancel: had the scope
        // been cancelled first, the launched handleClose() would never have closed the connection.
        assertEquals(1, transport.closeCallCount)
        assertEquals(1, holder.generation) // bumped ONLY on genuine background
        assertNull(holder.controller)
    }

    @Test
    fun `onCleared detaches without bumping generation`() = runTest {
        val (holder, transport) = newHolder()

        val c = holder.bind(endpoint())
        c.start()
        advanceUntilIdle()
        assertEquals(1, transport.connectAttempts.size)

        // onCleared() is protected on ViewModel; invoke it reflectively to model a genuine nav pop.
        RetainedSessionHolder::class.java
            .getDeclaredMethod("onCleared")
            .apply { isAccessible = true }
            .invoke(holder)
        advanceUntilIdle()

        assertEquals(1, transport.closeCallCount) // clean detach still happens
        assertEquals(0, holder.generation) // nav pop is terminal → never bumps generation
        assertNull(holder.controller)
    }
}
