package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.session.AwayDigest
import wang.yaojia.webterm.session.Digest
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.session.Telemetry
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.StatusTelemetry
import wang.yaojia.webterm.wiring.TerminalSessionController

/**
 * # GateViewModel (A22) — the cockpit's remote approve/reject + telemetry + away-digest presenter.
 *
 * Folds its OWN [TerminalSessionController.controlEvents] mailbox (gate / telemetry / digest)
 * into a single [collectAsStateWithLifecycle-friendly][uiState] snapshot, and resolves the user's
 * decision back onto [TerminalSessionController.decideGate] behind the **two-line epoch stale-guard**
 * (plan §3.2). Ports the iOS gate-cockpit surface 1:1.
 *
 * ### Two-line stale-guard (security-load-bearing, plan §1/§3.2)
 * A held gate carries an [GateState.epoch] that increments only on a `pending` false→true rising
 * edge (`GateTracker`). Every decision is tapped against the epoch of the gate that was ON SCREEN.
 * [decide] drops a decision whose `epoch` no longer matches the current live gate — so a slow tap
 * against a resolved gate can NEVER approve the NEXT one (防误批新 gate). This is **line 1**; the
 * engine re-checks `GateTracker.canDecide` on its confinement dispatcher as **line 2**
 * ([TerminalSessionController.decideGate]). Defense in depth: the UI drop is immediate, the engine
 * drop is authoritative.
 *
 * ### Haptic on ARRIVAL only
 * [gateArrivals] emits exactly once per NEW gate (a rising edge — a new [GateState.epoch]), never on
 * a same-epoch refresh and never on a falling edge. A Composable collects it and fires a haptic
 * gated by `LocalReduceMotion` (the OS reduce-motion setting) — the gate itself is composition-only,
 * so this VM stays pure/JVM-testable and the actual haptic is device-QA.
 *
 * ### Testability
 * A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time
 * with no `Dispatchers.Main` / Robolectric. It is OWNED by the config-surviving
 * [RetainedSessionHolder][wang.yaojia.webterm.wiring.RetainedSessionHolder] (one per session), which
 * constructs it and calls [bind] on the engine's confined scope BEFORE `controller.start()`; so its
 * gate/telemetry state survives a rotation instead of being re-derived from an empty fresh mailbox.
 * [decide] is synchronous.
 *
 * ### Its OWN control mailbox (FIX 1 — no split with the reconnect banner)
 * [controlEvents] is captured ONCE at construction — a dedicated [TerminalSessionController.controlEvents]
 * mailbox that is INDEPENDENT of the banner's (and the A28 timeline's). Every consumer sees every
 * control event; none steals from another. Capturing at construction registers the mailbox eagerly,
 * before the holder calls `controller.start()`, so the first gate is never dropped.
 */
public class GateViewModel(
    private val controller: TerminalSessionController,
) {
    /** This VM's OWN control-event mailbox, registered eagerly at construction (before `start()`). */
    private val controlEvents: Flow<SessionEvent> = controller.controlEvents()

    private val _uiState = MutableStateFlow(GateUiState())

    /** The single snapshot the gate/cockpit surfaces render from. */
    public val uiState: StateFlow<GateUiState> = _uiState.asStateFlow()

    // replay=0: a haptic is a fire-once effect, not state. DROP_OLDEST so an emit never suspends the
    // event collector even when no haptic collector is attached (e.g. reduce-motion is on).
    private val _gateArrivals = MutableSharedFlow<Unit>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )

    /** One `Unit` per NEW gate (rising edge). Composable → haptic, gated by `LocalReduceMotion`. */
    public val gateArrivals: SharedFlow<Unit> = _gateArrivals.asSharedFlow()

    // The epoch we last fired an arrival for; guards "arrival ONCE per new gate" (not per refresh).
    private var lastArrivalEpoch: Int? = null

    private var bound: Boolean = false

    /**
     * Start folding this VM's [controlEvents] mailbox into [uiState] / [gateArrivals] in [scope] (the
     * holder passes the engine's confined scope, so the fold is cancelled with the SESSION, surviving a
     * config change). Idempotent — a second call is a no-op. The collector completes when the control
     * stream closes (session teardown) or when [scope] is cancelled.
     */
    public fun bind(scope: CoroutineScope) {
        if (bound) return
        bound = true
        scope.launch {
            controlEvents.collect(::onEvent)
        }
    }

    private fun onEvent(event: SessionEvent) {
        when (event) {
            is Gate -> onGate(event.gate)
            is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
            is Digest -> onDigest(event.digest)
            // The session ended: a held gate is no longer decidable — clear it so no stale card lingers.
            is Exited -> _uiState.value = _uiState.value.copy(gate = null)
            else -> Unit // Output / Connection / Adopted are not cockpit concerns.
        }
    }

    private fun onGate(gate: GateState?) {
        _uiState.value = _uiState.value.copy(gate = gate)
        if (gate == null) return // Falling edge: gate lifted; keep lastArrivalEpoch so it can't refire.
        // Rising edge = a NEW epoch (epochs are monotonic; a refresh keeps the same epoch).
        if (gate.epoch != lastArrivalEpoch) {
            lastArrivalEpoch = gate.epoch
            _gateArrivals.tryEmit(Unit)
        }
    }

    private fun onDigest(digest: AwayDigest) {
        // All-zero digest is suppressed (the UI's "don't render" signal) — hold null, not EMPTY.
        _uiState.value = _uiState.value.copy(digest = if (digest.isEmpty) null else digest)
    }

    /**
     * Resolve a user decision behind the two-line stale-guard (line 1). [epoch] is the epoch of the
     * gate that was ON SCREEN when the button was tapped (captured by the banner/sheet). Drops the
     * decision — sending nothing — when no gate is held, the epoch is stale, or the decision is not
     * an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message.
     */
    public fun decide(decision: GateDecision, epoch: Int) {
        val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → drop.
        if (held.epoch != epoch) return          // canDecide line 2: stale epoch → drop.
        val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop.
        controller.decideGate(epoch, message)
    }
}

/** The three logical gate decisions the surfaces expose (tool = approve/reject; plan = 3-way). */
public enum class GateDecision {
    /** Tool: plain approve (`mode` absent). Plan: approve + review (`mode:"default"`). */
    APPROVE,

    /** Plan only: approve + auto-accept edits (`mode:"acceptEdits"`). */
    ACCEPT_EDITS,

    /** Tool: reject. Plan: keep planning (both wire to `reject`). */
    REJECT,
}

/** The gate/cockpit snapshot rendered by the surfaces. All fields null = nothing to show. */
public data class GateUiState(
    /** The held permission gate, or `null` when none (falling edge / never risen). */
    val gate: GateState? = null,
    /** Latest statusLine telemetry, or `null` before the first `telemetry` frame. */
    val telemetry: StatusTelemetry? = null,
    /** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */
    val digest: AwayDigest? = null,
)

/**
 * Map a [GateDecision] to the exact wire [ClientMessage] for the current [kind], mirroring
 * [GateState.Affordance.clientMessage] (public/tabs.ts:345-347). Returns `null` when the decision is
 * not an affordance of [kind] (e.g. [GateDecision.ACCEPT_EDITS] on a [GateKind.TOOL] gate) — the
 * caller drops it. Pure — independently unit-tested.
 */
internal fun resolveGateMessage(decision: GateDecision, kind: GateKind): ClientMessage? =
    when (kind) {
        GateKind.TOOL -> when (decision) {
            GateDecision.APPROVE -> GateState.Affordance.APPROVE.clientMessage
            GateDecision.REJECT -> GateState.Affordance.REJECT.clientMessage
            GateDecision.ACCEPT_EDITS -> null // tool gates have no acceptEdits affordance.
        }

        GateKind.PLAN -> when (decision) {
            GateDecision.APPROVE -> GateState.Affordance.APPROVE_REVIEW.clientMessage
            GateDecision.ACCEPT_EDITS -> GateState.Affordance.APPROVE_AUTO.clientMessage
            GateDecision.REJECT -> GateState.Affordance.KEEP_PLANNING.clientMessage
        }
    }
