package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.ExperimentalCoroutinesApi
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.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
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.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.session.UnreadLedger
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.HostEndpoint
import java.util.UUID

/**
 * [SessionListViewModel] (A20) — the poll→row mapping, unread-dot logic (watermark vs `lastOutputAt`,
 * cleared on open), optimistic swipe-kill (404 = already-gone = success), lossy-list tolerance, and the
 * multi-host switch. Pure JVM under `runTest`; the poll loop launches on an [UnconfinedTestDispatcher] so
 * the first fetch runs inline before the first `delay`. Compose/swipe/thumbnails are device-QA (plan §7).
 */
@OptIn(ExperimentalCoroutinesApi::class)
class SessionListViewModelTest {

    private companion object {
        const val BASE_A = "http://a:3000"
        const val BASE_B = "http://b:3000"
        val ID_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
        val ID_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")

        fun host(id: String, name: String, baseUrl: String, cert: Boolean = false): Host =
            Host.create(id = id, name = name, baseUrl = baseUrl, hasDeviceCert = cert)!!

        fun session(
            id: UUID,
            status: ClaudeStatus = ClaudeStatus.WORKING,
            exited: Boolean = false,
            title: String? = null,
            cols: Int = 161,
            rows: Int = 50,
            lastOutputAt: Long? = null,
        ) = LiveSessionInfo(
            id = id,
            createdAt = 1_700_000_000_000L,
            clientCount = 1,
            status = status,
            exited = exited,
            cwd = null,
            title = title,
            cols = cols,
            rows = rows,
            telemetry = null,
            lastOutputAt = lastOutputAt,
        )
    }

    /** In-memory [HostStore] whose [loadAll] returns a fixed list (mutations unused by the VM's reads). */
    private class FakeHostStore(private val hosts: List<Host>) : HostStore {
        override suspend fun loadAll(): List<Host> = hosts
        override suspend fun upsert(host: Host): List<Host> = hosts
        override suspend fun remove(id: String): List<Host> = hosts
    }

    /** Fake gateway: returns a canned list and records kills; [killError] scripts the DELETE outcome. */
    private class FakeGateway(
        private val list: List<LiveSessionInfo>,
        private val killError: Throwable? = null,
    ) : SessionListGateway {
        val killed = mutableListOf<UUID>()
        override suspend fun liveSessions(): List<LiveSessionInfo> = list
        override suspend fun killSession(id: UUID) {
            killed += id
            killError?.let { throw it }
        }
    }

    // ── poll → row mapping ─────────────────────────────────────────────────────────────────────

    @Test
    fun `poll maps live sessions to rows with status title and dimensions`() =
        runTest(UnconfinedTestDispatcher()) {
            val gateway = FakeGateway(
                listOf(
                    session(ID_1, status = ClaudeStatus.WORKING, title = "web-terminal", cols = 161, rows = 50),
                    session(ID_2, exited = true, title = "  gone ​", cols = 80, rows = 24),
                ),
            )
            val vm = SessionListViewModel(
                hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
                gatewayFactory = { gateway },
            )

            backgroundScope.launch { vm.poll() }

            val rows = vm.uiState.value.rows
            assertEquals(2, rows.size)
            assertEquals(DisplayStatus.Working, rows[0].displayStatus)
            assertEquals("web-terminal", rows[0].title)
            assertEquals("161×50", rows[0].dimensions)
            // exited outranks status; the zero-width char is stripped by TitleSanitizer.
            assertEquals(DisplayStatus.Exited, rows[1].displayStatus)
            assertEquals("gone", rows[1].title)
        }

    // ── unread dot ─────────────────────────────────────────────────────────────────────────────

    @Test
    fun `a session with output newer than its watermark is unread and clears on open`() =
        runTest(UnconfinedTestDispatcher()) {
            val gateway = FakeGateway(listOf(session(ID_1, lastOutputAt = 100L)))
            val vm = SessionListViewModel(
                hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
                gatewayFactory = { gateway },
                watermarkStore = InMemoryUnreadWatermarkStore(), // empty → watermark 0 → unread
            )

            vm.refresh()
            assertTrue(vm.uiState.value.rows.single().isUnread)

            // Opening the session marks it seen at its lastOutputAt → the dot clears immediately.
            vm.markSeen(ID_1)
            assertFalse(vm.uiState.value.rows.single().isUnread)
        }

    @Test
    fun `a null lastOutputAt is never unread`() = runTest(UnconfinedTestDispatcher()) {
        val vm = SessionListViewModel(
            hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
            gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = null))) },
        )

        vm.refresh()

        assertFalse(vm.uiState.value.rows.single().isUnread)
    }

    @Test
    fun `an already-seen session (watermark at or past lastOutputAt) shows no dot`() =
        runTest(UnconfinedTestDispatcher()) {
            val seen = UnreadLedger().record(ID_1.toString(), 100L)
            val vm = SessionListViewModel(
                hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
                gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
                watermarkStore = InMemoryUnreadWatermarkStore(seen),
            )

            vm.refresh()

            assertFalse(vm.uiState.value.rows.single().isUnread)
        }

    // ── swipe-to-kill ──────────────────────────────────────────────────────────────────────────

    @Test
    fun `killSession removes the row optimistically and issues the delete`() =
        runTest(UnconfinedTestDispatcher()) {
            val gateway = FakeGateway(listOf(session(ID_1), session(ID_2)))
            val vm = SessionListViewModel(
                hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
                gatewayFactory = { gateway },
            )
            vm.refresh()
            assertEquals(2, vm.uiState.value.rows.size)

            vm.killSession(ID_1)

            assertEquals(listOf(ID_2), vm.uiState.value.rows.map { it.id })
            assertEquals(listOf(ID_1), gateway.killed)
        }

    @Test
    fun `a 404 on kill is treated as success — the row stays removed`() =
        runTest(UnconfinedTestDispatcher()) {
            val gateway = FakeGateway(listOf(session(ID_1)), killError = ApiClientError.SessionNotFound)
            val vm = SessionListViewModel(
                hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
                gatewayFactory = { gateway },
            )
            vm.refresh()

            vm.killSession(ID_1)

            assertTrue(vm.uiState.value.rows.isEmpty())
        }

    @Test
    fun `a real kill failure restores the row`() = runTest(UnconfinedTestDispatcher()) {
        val gateway = FakeGateway(listOf(session(ID_1), session(ID_2)), killError = ApiClientError.Forbidden)
        val vm = SessionListViewModel(
            hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
            gatewayFactory = { gateway },
        )
        vm.refresh()

        vm.killSession(ID_1)

        // The DELETE was rejected (403) → the session is still alive → the row is restored, in place.
        assertEquals(listOf(ID_1, ID_2), vm.uiState.value.rows.map { it.id })
    }

    // ── lossy list decode tolerance (real ApiClient + fake transport) ────────────────────────────

    @Test
    fun `a malformed entry is dropped end-to-end and the rest become rows`() =
        runTest(UnconfinedTestDispatcher()) {
            val transport = FakeHttpTransport()
            val body = """
                [
                  {"id":"${ID_1}","createdAt":1,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24},
                  {"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24},
                  {"id":"${ID_2}","createdAt":1,"clientCount":1,"status":"reticulating","exited":false,"cols":80,"rows":24}
                ]
            """.trimIndent()
            transport.queueSuccess(url = "$BASE_A/live-sessions", body = body.toByteArray())
            val api = ApiClient(HostEndpoint.fromBaseUrl(BASE_A)!!, transport)
            val vm = SessionListViewModel(
                hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
                gatewayFactory = { ApiClientSessionGateway(api) },
            )

            vm.refresh()

            val rows = vm.uiState.value.rows
            assertEquals(listOf(ID_1, ID_2), rows.map { it.id }) // bad entry dropped, order kept
            assertEquals(DisplayStatus.Unknown, rows[1].displayStatus) // unknown wire status survives
            assertFalse(vm.uiState.value.hasLoadError)
        }

    @Test
    fun `a transport failure flags the error banner without wiping the last-good rows`() =
        runTest(UnconfinedTestDispatcher()) {
            var failNext = false
            val gateway = object : SessionListGateway {
                override suspend fun liveSessions(): List<LiveSessionInfo> {
                    if (failNext) throw ApiClientError.UnexpectedStatus(500)
                    return listOf(session(ID_1))
                }
                override suspend fun killSession(id: UUID) = Unit
            }
            val vm = SessionListViewModel(
                hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
                gatewayFactory = { gateway },
            )
            vm.refresh()
            assertEquals(1, vm.uiState.value.rows.size)

            failNext = true
            vm.refresh()

            assertTrue(vm.uiState.value.hasLoadError)
            assertEquals(1, vm.uiState.value.rows.size) // last-good rows retained
        }

    // ── multi-host switch ──────────────────────────────────────────────────────────────────────

    @Test
    fun `the first host is active by default and selectHost switches the list and the checkmark`() =
        runTest(UnconfinedTestDispatcher()) {
            val gwA = FakeGateway(listOf(session(ID_1)))
            val gwB = FakeGateway(listOf(session(ID_2)))
            val vm = SessionListViewModel(
                hostStore = FakeHostStore(
                    listOf(host("h1", "laptop", BASE_A), host("h2", "desktop", BASE_B, cert = true)),
                ),
                gatewayFactory = { h -> if (h.id == "h1") gwA else gwB },
            )

            vm.refresh()
            assertEquals("h1", vm.uiState.value.activeHostId)
            assertEquals(listOf(ID_1), vm.uiState.value.rows.map { it.id })
            assertTrue(vm.uiState.value.hosts.first { it.id == "h1" }.isActive)
            assertFalse(vm.uiState.value.hosts.first { it.id == "h2" }.isActive)

            vm.selectHost("h2")

            assertEquals("h2", vm.uiState.value.activeHostId)
            assertEquals(listOf(ID_2), vm.uiState.value.rows.map { it.id })
            assertTrue(vm.uiState.value.hosts.first { it.id == "h2" }.isActive)
            assertTrue(vm.uiState.value.hosts.first { it.id == "h2" }.hasDeviceCert)
        }

    @Test
    fun `no paired host yields an empty chooser`() = runTest(UnconfinedTestDispatcher()) {
        val vm = SessionListViewModel(
            hostStore = FakeHostStore(emptyList()),
            gatewayFactory = { error("no host → gateway must never be built") },
        )

        vm.refresh()

        assertNull(vm.uiState.value.activeHostId)
        assertTrue(vm.uiState.value.rows.isEmpty())
        assertTrue(vm.uiState.value.hosts.isEmpty())
    }
}
