package wang.yaojia.webterm.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.Connection
import wang.yaojia.webterm.session.ConnectionState
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.FailureReason
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.WireConstants
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

/**
 * # ReconnectBanner (A21) — the connection-status / EXIT banner row.
 *
 * The Android port of iOS `ReconnectBanner` + `TerminalViewModel.bannerModel` (plan §1, §5 A21). A
 * single strip pinned above the terminal that reflects the engine's connection lifecycle:
 *  - `connecting` — first dial (spinner);
 *  - `reconnecting(attempt, countdown)` — the back-off ladder (spinner);
 *  - `failed(replayTooLarge)` — a NON-retryable wall: actionable copy, **NO spinner**, "新会话" action;
 *  - `exited(code, reason)` — the shell is gone: `code == -1` is a spawn-failure (reason required),
 *    otherwise a normal exit; both offer "开新会话".
 *
 * ### Precedence (the load-bearing rule — plan §1)
 * **Terminal phases (`failed` / `exited`) OUTRANK the transient ones (`connecting` / `reconnecting`).**
 * Once the session is terminally failed or exited, a stray transient connection event must never
 * overwrite the banner — the reducer [bannerModel] enforces this. This mirrors the iOS `bannerModel`
 * computed property where the exit/failure state is checked before the reconnect state.
 *
 * ### Inert rendering (plan §8)
 * The exit `reason` is an untrusted server string — it is rendered as a plain, inert Compose [Text]
 * with no Markdown / autolink / `AnnotatedString` linkify.
 */

/**
 * What the banner shows, derived purely from the [SessionEvent] stream by [bannerModel]. A sealed model
 * so the precedence rule and the "no-spinner on failure" rule are data, unit-testable without a device.
 */
public sealed interface BannerModel {
    /** Whether a progress spinner is shown — true ONLY for the transient (retrying) phases. */
    public val showsSpinner: Boolean

    /** Whether the banner offers a "new session in cwd" action (`attach(null, cwd)`). */
    public val offersNewSession: Boolean

    /** Connected / idle — nothing to show. */
    public data object Hidden : BannerModel {
        override val showsSpinner: Boolean get() = false
        override val offersNewSession: Boolean get() = false
    }

    /** The first connection is being established. */
    public data object Connecting : BannerModel {
        override val showsSpinner: Boolean get() = true
        override val offersNewSession: Boolean get() = false
    }

    /** The transport dropped; retry [attempt] fires after [countdown] (the [ConnectionState.Reconnecting] ladder). */
    public data class Reconnecting(val attempt: Int, val countdown: Duration) : BannerModel {
        override val showsSpinner: Boolean get() = true
        override val offersNewSession: Boolean get() = false
    }

    /**
     * A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
     * spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
     */
    public data class Failed(val reason: FailureReason) : BannerModel {
        override val showsSpinner: Boolean get() = false
        override val offersNewSession: Boolean get() = true
    }

    /**
     * The shell exited. [isSpawnFailure] (`code == -1`) means the PTY never spawned — [reason] is then
     * required and drives the copy. Either way the session is over; offers "开新会话".
     */
    public data class Exited(val code: Int, val reason: String?) : BannerModel {
        override val showsSpinner: Boolean get() = false
        override val offersNewSession: Boolean get() = true

        /** `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) → the spawn itself failed. */
        public val isSpawnFailure: Boolean get() = code == WireConstants.SPAWN_FAILED_EXIT_CODE
    }
}

/**
 * PURE reducer: fold one [SessionEvent] into the [current] [BannerModel]. Only connection-lifecycle and
 * exit events affect the banner; every other event (`Output`/`Gate`/`Digest`/`Telemetry`/`Adopted`)
 * leaves it unchanged.
 *
 * The precedence rule (plan §1): the terminal phases ([BannerModel.Failed] / [BannerModel.Exited]) are
 * STICKY over the transient phases — a `connecting`/`reconnecting`/`connected`/`closed` event arriving
 * after the session already failed or exited does NOT override the terminal banner. A fresh terminal
 * event always wins (a later exit/failure replaces an earlier one).
 */
public fun bannerModel(current: BannerModel, event: SessionEvent): BannerModel = when (event) {
    is Exited -> BannerModel.Exited(event.code, event.reason) // terminal — always wins
    is Connection -> reduceConnection(current, event.state)
    else -> current
}

private fun reduceConnection(current: BannerModel, state: ConnectionState): BannerModel = when (state) {
    is ConnectionState.Failed -> BannerModel.Failed(state.reason) // terminal — always wins
    ConnectionState.Connecting -> current.orKeepTerminal(BannerModel.Connecting)
    is ConnectionState.Reconnecting -> current.orKeepTerminal(BannerModel.Reconnecting(state.attempt, state.next))
    ConnectionState.Connected -> current.orKeepTerminal(BannerModel.Hidden)
    ConnectionState.Closed -> current.orKeepTerminal(BannerModel.Hidden)
}

/** A terminal banner outranks any transient update; otherwise take the [transient] one. */
private fun BannerModel.orKeepTerminal(transient: BannerModel): BannerModel =
    if (this is BannerModel.Failed || this is BannerModel.Exited) this else transient

// ── UI ───────────────────────────────────────────────────────────────────────

/**
 * The banner row. Renders NOTHING for [BannerModel.Hidden]. [onNewSession] fires the "new session in
 * cwd" action (the exit-banner and replay-too-large affordances both route to it).
 *
 * Compose layout / spinner animation is device-QA (plan §7); the reduced [model] + its copy/affordance
 * mapping is the JVM-tested core.
 */
@Composable
public fun ReconnectBanner(
    model: BannerModel,
    onNewSession: () -> Unit,
    modifier: Modifier = Modifier,
) {
    if (model is BannerModel.Hidden) return

    val container = when (model) {
        is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.errorContainer
        else -> MaterialTheme.colorScheme.surfaceVariant
    }
    val content = when (model) {
        is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.onErrorContainer
        else -> MaterialTheme.colorScheme.onSurfaceVariant
    }

    Surface(color = container, contentColor = content, modifier = modifier.fillMaxWidth()) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
            modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
        ) {
            if (model.showsSpinner) {
                CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
            }
            // Untrusted server strings (exit reason) render as inert Text — no autolink/markdown (§8).
            Text(
                text = bannerCopy(model),
                style = MaterialTheme.typography.bodyMedium,
                modifier = Modifier.weight(1f),
            )
            if (model.offersNewSession) {
                TextButton(onClick = onNewSession) { Text(newSessionLabel(model)) }
            }
        }
    }
}

/** The Chinese status copy for [model]. Kept separate from the reducer so copy never leaks into logic. */
private fun bannerCopy(model: BannerModel): String = when (model) {
    BannerModel.Hidden -> ""
    BannerModel.Connecting -> "连接中…"
    is BannerModel.Reconnecting ->
        "连接断开，重连中（第 ${model.attempt} 次，${model.countdown.inWholeSeconds} 秒后重试）"
    is BannerModel.Failed -> when (model.reason) {
        FailureReason.REPLAY_TOO_LARGE ->
            "回放数据过大，无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
    }
    is BannerModel.Exited ->
        if (model.isSpawnFailure) {
            "会话启动失败：${model.reason ?: "未知原因"}"
        } else {
            "会话已结束（退出码 ${model.code}）"
        }
}

/** "开新会话" for a clean exit, "新会话" for the replay-too-large wall — both call [ReconnectBanner]'s onNewSession. */
private fun newSessionLabel(model: BannerModel): String =
    if (model is BannerModel.Failed) "新会话" else "开新会话"

// ── Preview ───────────────────────────────────────────────────────────────────

@Preview(name = "ReconnectBanner — reconnecting")
@Composable
private fun ReconnectBannerReconnectingPreview() {
    WebTermTheme {
        ReconnectBanner(model = BannerModel.Reconnecting(attempt = 2, countdown = 4.seconds), onNewSession = {})
    }
}

@Preview(name = "ReconnectBanner — exited")
@Composable
private fun ReconnectBannerExitedPreview() {
    WebTermTheme {
        ReconnectBanner(model = BannerModel.Exited(code = 0, reason = null), onNewSession = {})
    }
}
