package wang.yaojia.webterm.wiring

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import wang.yaojia.webterm.session.SessionEngine
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.ClientMessage

/**
 * The FROZEN surface AW4 / A21 depend on to drive ONE terminal session (plan §5 A15, §6.2/§6.3). It
 * owns a [SessionEngine]'s lifecycle and is the single seam between the terminal UI (`:terminal-view`,
 * A16) and the engine:
 *  - **UI→engine:** [sendInput] / [resize] / [notifyForegrounded] / [decideGate] map straight onto
 *    `engine.send`/`notifyForegrounded`/`decideGate` (invariant #4 — the only UI→engine calls).
 *  - **engine→UI:** [controlEvents] is the cockpit's control-event stream (banner / gate / telemetry /
 *    digest); [output] is the decoded terminal-byte stream the emulator consumes. The two are DISJOINT
 *    typed sub-streams of [EventBus] (FIX 4) — neither consumer buffers what it does not need.
 *
 * ### Control events are MULTI-SUBSCRIBABLE (FIX 1 — no split across consumers)
 * The reconnect banner AND the [GateViewModel][wang.yaojia.webterm.viewmodels.GateViewModel] (AND later
 * the A28 timeline) each need the FULL control-event stream. A single shared `Channel.receiveAsFlow()`
 * mailbox is a MAILBOX, not a broadcast: two competing `collect()` on it would SPLIT events (each event
 * reaches only one collector). So control events are exposed as a FUNCTION, not a single `val`: EACH
 * call to [controlEvents] mints its OWN per-consumer [EventBus] mailbox (the bus already registers a
 * fresh unbounded channel per call), so every consumer receives every control event, losslessly and
 * without stalling the others (R10). [output], by contrast, stays a SINGLE `val` stream: it has exactly
 * ONE consumer (the emulator feed), and the multi-MB replay must never be fanned out twice.
 *
 * ### START is decoupled from bind (FIX 2 — no pre-subscription event drop)
 * `RetainedSessionHolder.bind()` constructs and returns this controller UN-STARTED, having already
 * registered every consumer's mailbox: [output] registers ONE mailbox at construction, and the holder
 * calls [controlEvents] once per retained consumer (banner + gate) — all EAGERLY, before [start].
 * ALL consumers therefore MUST subscribe (call [controlEvents] / collect [output]) BEFORE [start];
 * [start] then wires the engine→[EventBus] pump AND `engine.start()`. Nothing is published to the bus
 * before [start], and every mailbox already exists, so the `attached` frame + the multi-MB ring-buffer
 * replay can never be dropped, and no event is split.
 *
 * ### Boundary note (plan §3 / §6.2 — decode lives HERE, not in `:terminal-view`)
 * `:terminal-view` depends only on `:wire-protocol` and consumes raw `ByteArray`. The
 * `SessionEvent.Output(String) → ByteArray` (UTF-8) decode therefore happens on the [EventBus]
 * [output][EventBus.outputBytes] sub-stream (off `Main` via `flowOn`), keeping the terminal module free
 * of any `SessionEvent`/`:session-core` edge.
 *
 * A21 writes the full implementation (pendingOutput ordering, banner precedence, new-session-in-cwd,
 * privacy shade). [DefaultTerminalSessionController] is the minimal skeleton that freezes the shape.
 */
public interface TerminalSessionController {
    /**
     * The cockpit's control-event stream: every NON-`Output` event (connection/gate/telemetry/digest/
     * exit). Each call mints its OWN per-consumer [EventBus] mailbox (FIX 1), so multiple consumers
     * (banner + gate + A28 timeline) each receive every control event without splitting — a single
     * shared `val` mailbox would let two `collect()` steal events from each other. The mailbox is
     * registered EAGERLY at call time, so callers MUST call this BEFORE [start] (no pre-subscription
     * drop). Every returned flow is independent, lossless, and re-collectable across a config-change
     * cancel (the mailbox lives for the session, not the collection).
     */
    public fun controlEvents(): Flow<SessionEvent>

    /**
     * The decoded terminal output stream: [SessionEvent.Output] frames only, UTF-8-decoded to bytes on
     * `Dispatchers.Default` (off `Main`), fed verbatim to the emulator (`:terminal-view`). All other
     * event types are routed to [events], never buffered here.
     */
    public val output: Flow<ByteArray>

    /**
     * Begin connecting (attach-first): start the engine→[EventBus] pump AND `engine.start()`. MUST be
     * called AFTER the screen has wired its [events]/[output] collectors (FIX 2b). Idempotent — a
     * second call is a no-op.
     */
    public fun start()

    /** Typed / key-bar bytes → `engine.send(Input(data))`, passed through VERBATIM (invariant #1/#9). */
    public fun sendInput(data: String)

    /** Latest-writer-wins PTY sizing → `engine.send(Resize(cols, rows))` (plan §6.4). */
    public fun resize(cols: Int, rows: Int)

    /**
     * The app/pane returned to the foreground → reclaim full-screen (re-send dims) or connect-now
     * without resetting the back-off ladder (plan §6.4/§6.6). Null dims leave the remembered size.
     */
    public fun notifyForegrounded(cols: Int?, rows: Int?)

    /**
     * Resolve a held gate with the two-line epoch stale-guard: a stale [epoch] is dropped so a slow
     * tap never approves the NEXT gate (`GateTracker.canDecide`, plan §3.2). [message] is the
     * affordance's wire message (`GateState.Affordance.clientMessage`).
     */
    public fun decideGate(epoch: Int, message: ClientMessage)

    /** Detach: close the WS (the server-side PTY keeps running). NEVER a kill (invariant #2). */
    public fun close()
}

/**
 * Minimal skeleton [TerminalSessionController] over a single [SessionEngine] + its PER-SESSION
 * [EventBus] (FIX 1). It establishes the seam for A21 (which wraps it via [TerminalSessionControllerImpl]):
 * registers the SINGLE [output] mailbox at construction and mints a FRESH control mailbox per
 * [controlEvents] call, and on [start] wires the engine→bus pump BEFORE `engine.start()` so the replay
 * is never dropped and no control event is split (FIX 1/FIX 2).
 *
 * [fanOutScope] runs the engine→UI pump; it must live as long as the session (the
 * [RetainedSessionHolder] passes the engine's confinement scope). The pump completes on its own when
 * the engine closes its event channel (detach/exit) or when [fanOutScope] is cancelled.
 */
public class DefaultTerminalSessionController(
    private val engine: SessionEngine,
    private val eventBus: EventBus,
    private val fanOutScope: CoroutineScope,
) : TerminalSessionController {

    // The SINGLE output mailbox is registered EAGERLY at construction (FIX 2a) — exactly one consumer
    // (the emulator feed), so the multi-MB replay is never fanned out twice. Control events are NOT a
    // single `val`: each [controlEvents] call mints its own mailbox (FIX 1) so banner + gate + A28
    // timeline never split the stream. Every mailbox lives for the SESSION (bus) lifetime, not a
    // collection's: a config-change cancel leaves it re-collectable, an uncollected mailbox simply
    // buffers, and all are released by EventBus.close() in RetainedSessionHolder.teardown().
    override val output: Flow<ByteArray> = eventBus.outputBytes()

    // FIX 1: a FRESH per-consumer mailbox on every call (the bus mints one each time). Callers MUST
    // call this BEFORE start() — the mailbox registers eagerly, so nothing published after start drops.
    override fun controlEvents(): Flow<SessionEvent> = eventBus.controlEvents()

    // Guards start() idempotency. The intended caller is the UI thread (the terminal screen), but
    // @Volatile removes that unstated single-thread precondition so a stray off-thread start() cannot
    // observe a stale false and double-start the engine.
    @Volatile
    private var started: Boolean = false
    private var pumpJob: Job? = null

    override fun start() {
        if (started) return
        started = true
        // Connect the engine→bus pump FIRST (mailboxes already registered), THEN start the engine, so
        // the 'attached' frame + multi-MB replay land in the already-registered per-consumer mailboxes.
        pumpJob = eventBus.connect(fanOutScope, engine.events)
        engine.start()
    }

    override fun sendInput(data: String): Unit = engine.send(ClientMessage.Input(data))

    override fun resize(cols: Int, rows: Int): Unit = engine.send(ClientMessage.Resize(cols, rows))

    override fun notifyForegrounded(cols: Int?, rows: Int?): Unit =
        engine.notifyForegrounded(cols, rows)

    override fun decideGate(epoch: Int, message: ClientMessage): Unit =
        engine.decideGate(epoch, message)

    // Fire-and-forget detach; the awaitable teardown path is RetainedSessionHolder.teardown() (FIX 5).
    override fun close() {
        engine.close()
    }
}
