package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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.TitleSanitizer
import wang.yaojia.webterm.session.UnreadLedger
import wang.yaojia.webterm.wire.Tunables
import java.util.UUID
import kotlin.time.Duration

/**
 * # SessionListViewModel (A20) — the session chooser + dashboard + host-menu presenter.
 *
 * Folds `GET /live-sessions` for the ACTIVE host into a single [collectAsStateWithLifecycle-friendly]
 * [uiState] snapshot of [SessionRow]s (status badge · telemetry · thumbnail key · cols×rows · sanitized
 * title · unread dot), and owns the four list actions: STARTED-scoped [poll], [refresh]
 * (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open) and [killSession]
 * (optimistic swipe-to-kill). Ports the iOS session-list surface.
 *
 * ### Untrusted server boundary (plan §4 / §8)
 * The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` →
 * [wang.yaojia.webterm.wire.ClaudeStatus.UNKNOWN]); this VM never crashes on a partial list. Every
 * server string ([SessionRow.title] via [TitleSanitizer], cwd, telemetry) is carried INERT for the row
 * to render as plain `Text` — no linkify/markdown (§8).
 *
 * ### STARTED-scoped poll (plan §6.6)
 * [poll] is a `suspend` loop the screen runs inside `repeatOnLifecycle(STARTED)`: it fetches, waits
 * [pollInterval] ([Tunables.LIST_POLL_INTERVAL]), repeats — and is cancelled the instant the app leaves
 * the foreground (no background polling). One-shot actions ([refresh]/[selectHost]/[markSeen]/
 * [killSession]) are `suspend` too, so the screen launches each in its own scope; a [fetchMutex]
 * serialises the network reads so a manual refresh and a poll tick never interleave-corrupt [uiState].
 *
 * ### Testability
 * A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with
 * no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory],
 * [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all
 * JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7).
 */
public class SessionListViewModel(
    private val hostStore: HostStore,
    private val gatewayFactory: SessionListGatewayFactory,
    private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(),
    private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL,
) {
    private val _uiState = MutableStateFlow(SessionListUiState())

    /** The single snapshot the session-list / dashboard renders from. */
    public val uiState: StateFlow<SessionListUiState> = _uiState.asStateFlow()

    /** Serialises network reads so a poll tick and a manual refresh never race on [uiState]. */
    private val fetchMutex = Mutex()

    /** The paired hosts (with their [Host.endpoint]), by id — the UI only sees the id/name [HostOption]s. */
    private var hostsById: Map<String, Host> = emptyMap()

    /** The active host id; sticky across polls, defaults to the first host until the user switches. */
    private var activeHostId: String? = null

    /** Local unread watermarks (persisted through [watermarkStore]); loaded once, lazily. */
    private var ledger: UnreadLedger = UnreadLedger()
    private var ledgerLoaded = false

    /**
     * STARTED-scoped poll loop (plan §6.6): fetch the active host's list, wait [pollInterval], repeat.
     * Cancellation (app backgrounded) stops it cleanly. Run inside `repeatOnLifecycle(STARTED)`.
     */
    public suspend fun poll() {
        while (true) {
            fetch(markRefreshing = false)
            delay(pollInterval)
        }
    }

    /** Pull-to-refresh: one immediate fetch with the refresh spinner shown. */
    public suspend fun refresh() {
        fetch(markRefreshing = true)
    }

    /**
     * Switch the active host (multi-host menu) and fetch it immediately. An unknown id is ignored (the
     * host may have been removed between render and tap).
     */
    public suspend fun selectHost(hostId: String) {
        if (!hostsById.containsKey(hostId)) return
        activeHostId = hostId
        fetch(markRefreshing = true)
    }

    /**
     * Clear the unread dot for [sessionId] when the user opens it: record the row's current
     * `lastOutputAt` as the seen-watermark (monotonic), persist it, and re-derive the rows so the dot
     * drops immediately. A no-op when the row is unknown or has no output timestamp (nothing to clear).
     */
    public suspend fun markSeen(sessionId: UUID) {
        val row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return
        val at = row.info.lastOutputAt ?: return
        ledger = ledger.record(sessionId.toString(), at)
        runCatching { watermarkStore.save(ledger) }
        _uiState.update { it.copy(rows = it.rows.map { r -> r.withUnread(ledger) }) }
    }

    /**
     * Optimistic swipe-to-kill: remove the row from [uiState] at once, then `DELETE /live-sessions/:id`.
     * 204 and 404 (already gone) are BOTH success — the row stays removed. Any other failure restores the
     * row (the session is still alive) and the next poll reconciles authoritatively.
     */
    public suspend fun killSession(sessionId: UUID) {
        val index = _uiState.value.rows.indexOfFirst { it.id == sessionId }
        if (index < 0) return
        val removed = _uiState.value.rows[index]
        _uiState.update { it.copy(rows = it.rows.filterNot { r -> r.id == sessionId }) }

        val gateway = activeGateway() ?: return
        try {
            gateway.killSession(sessionId)
        } catch (_: ApiClientError.SessionNotFound) {
            // 404 = already gone = success (plan §4.3). Keep it removed.
        } catch (e: CancellationException) {
            throw e
        } catch (_: Throwable) {
            restoreRow(index, removed)
        }
    }

    // ── Internals ────────────────────────────────────────────────────────────────────────────────

    private suspend fun fetch(markRefreshing: Boolean) {
        fetchMutex.withLock {
            ensureLedgerLoaded()
            if (markRefreshing) _uiState.update { it.copy(isRefreshing = true) }

            val hosts = runCatching { hostStore.loadAll() }.getOrDefault(emptyList())
            hostsById = hosts.associateBy { it.id }
            val active = resolveActiveHost(hosts)
            val hostOptions = hosts.map {
                HostOption(id = it.id, name = it.name, isActive = it.id == active?.id, hasDeviceCert = it.hasDeviceCert)
            }

            if (active == null) {
                // No paired host (or none matches) → an empty chooser; the screen offers pairing.
                _uiState.value = SessionListUiState(hosts = hostOptions, activeHostId = null)
                return@withLock
            }

            val result = runCatching { gatewayFactory.create(active).liveSessions() }
            result.fold(
                onSuccess = { list ->
                    _uiState.value = SessionListUiState(
                        hosts = hostOptions,
                        activeHostId = active.id,
                        rows = sessionRowsOf(list, ledger),
                        isRefreshing = false,
                        hasLoadError = false,
                    )
                },
                onFailure = { error ->
                    if (error is CancellationException) throw error
                    // Keep the last-good rows on a transient poll failure — only flag the error banner.
                    _uiState.update {
                        it.copy(
                            hosts = hostOptions,
                            activeHostId = active.id,
                            isRefreshing = false,
                            hasLoadError = true,
                        )
                    }
                },
            )
        }
    }

    /** Keep the current active host if it still exists; otherwise fall back to the first paired host. */
    private fun resolveActiveHost(hosts: List<Host>): Host? {
        val current = activeHostId?.let { id -> hosts.firstOrNull { it.id == id } }
        val resolved = current ?: hosts.firstOrNull()
        activeHostId = resolved?.id
        return resolved
    }

    private fun activeGateway(): SessionListGateway? =
        activeHostId?.let { hostsById[it] }?.let(gatewayFactory::create)

    private fun restoreRow(index: Int, row: SessionRow) {
        _uiState.update { state ->
            if (state.rows.any { it.id == row.id }) return@update state // a poll already re-added it.
            val at = index.coerceIn(0, state.rows.size)
            state.copy(rows = state.rows.toMutableList().apply { add(at, row) })
        }
    }

    private suspend fun ensureLedgerLoaded() {
        if (ledgerLoaded) return
        ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger())
        ledgerLoaded = true
    }
}

/**
 * Map the tolerant-decoded `GET /live-sessions` list to display rows: sanitize the OSC title, resolve
 * the status badge, and light the unread dot from the [ledger]. Pure + deterministic — the JVM-tested
 * core of the poll (order-preserving, one row per entry, never throws on a partial/edge-case list).
 */
public fun sessionRowsOf(sessions: List<LiveSessionInfo>, ledger: UnreadLedger): List<SessionRow> =
    sessions.map { info ->
        SessionRow(
            info = info,
            displayStatus = if (info.exited) DisplayStatus.Exited else DisplayStatus.from(info.status),
            title = TitleSanitizer.sanitize(info.title.orEmpty()),
            isUnread = ledger.isUnread(info.id.toString(), info.lastOutputAt),
        )
    }

/** The session-list / dashboard snapshot. Empty [rows] + null [activeHostId] = the empty (pair-a-host) state. */
public data class SessionListUiState(
    /** Paired hosts for the multi-host switch (checkmark on [HostOption.isActive]). */
    val hosts: List<HostOption> = emptyList(),
    /** The host currently listed, or null when no host is paired. */
    val activeHostId: String? = null,
    /** One row per running/just-exited session on the active host, in server order. */
    val rows: List<SessionRow> = emptyList(),
    /** Pull-to-refresh spinner state. */
    val isRefreshing: Boolean = false,
    /** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */
    val hasLoadError: Boolean = false,
)

/**
 * One session row. [info] is the raw tolerant-decoded record (the thumbnail key `(id, lastOutputAt)`,
 * telemetry, cwd, clientCount, geometry pass straight through to the row Composable); the derived fields
 * are the sanitized/resolved display values the row renders and the tests assert.
 */
public data class SessionRow(
    val info: LiveSessionInfo,
    val displayStatus: DisplayStatus,
    /** Sanitized OSC title ([TitleSanitizer]); "" = no title (the row shows the id/cwd instead). */
    val title: String,
    val isUnread: Boolean,
) {
    /** The session id (thumbnail key + open/kill target). */
    val id: UUID get() = info.id

    /** "161×50" — tabular geometry label for the row (× is U+00D7, not the letter x). */
    val dimensions: String get() = "${info.cols}×${info.rows}"

    /** Re-derive [isUnread] against a new [ledger] (used after [SessionListViewModel.markSeen]). */
    internal fun withUnread(ledger: UnreadLedger): SessionRow =
        copy(isUnread = ledger.isUnread(id.toString(), info.lastOutputAt))
}

/** One host in the multi-host switch menu (id/name only — the endpoint stays inside the VM). */
public data class HostOption(
    val id: String,
    val name: String,
    val isActive: Boolean,
    val hasDeviceCert: Boolean,
)

/**
 * Per-host list gateway — abstracts [ApiClient] so the VM is JVM-testable against a fake. Production is
 * [ApiClientSessionGateway] over the shared mTLS `HttpTransport`; tests queue canned lists.
 */
public interface SessionListGateway {
    /** `GET /live-sessions` — tolerant-decoded list for the active host. */
    public suspend fun liveSessions(): List<LiveSessionInfo>

    /** `DELETE /live-sessions/:id` — 204 success; 404 (already gone) surfaces as [ApiClientError.SessionNotFound]. */
    public suspend fun killSession(id: UUID)
}

/** Mints a [SessionListGateway] for the active [Host] (wiring binds it to [ApiClient] per host endpoint). */
public fun interface SessionListGatewayFactory {
    public fun create(host: Host): SessionListGateway
}

/** Production [SessionListGateway] delegating to a per-host [ApiClient]. */
public class ApiClientSessionGateway(private val api: ApiClient) : SessionListGateway {
    override suspend fun liveSessions(): List<LiveSessionInfo> = api.liveSessions()
    override suspend fun killSession(id: UUID): Unit = api.killSession(id)
}

/**
 * Persistence seam for the unread watermarks (plan §2 — DataStore-backed in production, wired outside
 * A20's lane). [InMemoryUnreadWatermarkStore] is the default so the VM works before that store exists.
 */
public interface UnreadWatermarkStore {
    public suspend fun load(): UnreadLedger
    public suspend fun save(ledger: UnreadLedger)
}

/** In-memory [UnreadWatermarkStore] (default). Watermarks live for the process only until DataStore lands. */
public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore {
    @Volatile private var current: UnreadLedger = initial
    override suspend fun load(): UnreadLedger = current
    override suspend fun save(ledger: UnreadLedger) {
        current = ledger
    }
}
