package wang.yaojia.webterm.wiring

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.session.Output
import wang.yaojia.webterm.session.SessionEvent
import java.util.concurrent.CopyOnWriteArrayList

/**
 * The engine→UI event fan-out (A15 wiring freeze; plan §0 invariant #4, §9 R10). The Android analogue
 * of the iOS **per-subscriber `AsyncStream`**: the [SessionEngine][wang.yaojia.webterm.session.SessionEngine]
 * publishes a SINGLE `SessionEvent` stream (its `events` is a `receiveAsFlow` — one consumer only),
 * and this bus fans it out to the two UI consumers so the terminal and the cockpit each observe it
 * independently and losslessly.
 *
 * ### PER-SESSION, never a `@Singleton` (FIX 1 — cross-talk fix)
 * A [SessionEvent] carries NO session id, so an app-wide singleton bus shared by every
 * `RetainedSessionHolder` would let one session's `Output`/`Gate` reach another session's UI. This bus
 * is therefore constructed INSIDE the session unit (one bus per engine, alongside the engine's confined
 * scope in `RetainedSessionHolder.bind()`) and dies with the session. There is NO Hilt binding for it.
 *
 * ### Two TYPED sub-streams, not one generic subscribe (FIX 4 — no cross-buffering)
 * The bus exposes exactly two consumer streams:
 *  - [outputBytes] — `Output(String)` frames only, UTF-8-decoded to `ByteArray` on [decodeDispatcher]
 *    (`Dispatchers.Default`) so the multi-MB ring-buffer replay decode never runs on `Main` (§6.6).
 *    Consumed ONLY by the terminal emulator.
 *  - [controlEvents] — every NON-`Output` event (connection/gate/telemetry/digest/exit). Consumed
 *    ONLY by the cockpit (banner / gate / telemetry chips / away digest).
 * Because [publish] offers each event only to the consumers that WANT it, the terminal never buffers a
 * gate it ignores and the cockpit never buffers the multi-MB `Output` replay it ignores.
 *
 * ### Per-consumer `Channel`s and NOT a single `SharedFlow` (R10 — the load-bearing decision)
 * Each consumer gets its OWN unbounded [Channel] (`Channel.UNLIMITED`). Fan-out is a non-suspending
 * [trySend] to each channel:
 *  - **never stalls another consumer** — `trySend` on an unbounded channel never suspends, so one slow
 *    collector cannot back-pressure the pump or the fast collector;
 *  - **never drops** — an unbounded buffer holds a slow consumer's backlog (including a `Gate`) until
 *    it drains, so nothing is lost.
 * A single `SharedFlow` cannot provide this: `onBufferOverflow=SUSPEND` head-of-line-blocks OUTPUT for
 * every consumer; `DROP_*` could silently lose a `Gate` (a correctness bug).
 *
 * ### Eager registration (FIX 2a — no pre-subscription drop)
 * [outputBytes]/[controlEvents] register their per-consumer mailbox EAGERLY at call time (not deferred
 * to first collection). The `RetainedSessionHolder` constructs the controller — which calls both — and
 * returns it UN-STARTED; only later does the terminal screen call `controller.start()` (FIX 2b), which
 * starts the engine→bus pump AND `engine.start()`. Since both mailboxes exist before ANYTHING is
 * published, the `attached` frame and the multi-MB replay land in an already-registered mailbox and are
 * never dropped.
 *
 * ### Mailbox lives for the SESSION, not the collector (FIX 1 — config-change re-collection)
 * A mailbox is tied to the BUS (session) lifetime, NOT to any single collection. There are exactly TWO
 * fixed per-session consumers (output + control) registered once at controller construction — there is
 * no dynamic subscribe/unsubscribe — so the returned flows are RE-COLLECTABLE: when a config change
 * (rotation/fold) cancels the terminal screen's collector, the mailbox is NOT closed; the recomposed
 * screen re-collects the SAME flow and resumes draining the events buffered during the gap AND every new
 * one, with ZERO loss. (Had the flow closed its mailbox on cancellation, the re-collect would iterate a
 * dead channel and the terminal would be blank forever after one rotation.) The mailboxes are released
 * exactly when the session ends, by [close] (called from `RetainedSessionHolder`'s teardown after the
 * engine close-join). An uncollected stream simply keeps its mailbox buffering for the session lifetime.
 *
 * ### Purity (invariant #4 / testability)
 * PURE Kotlin — coroutines only, no Android import — so the fan-out property is unit-testable under
 * `runTest` ([decodeDispatcher] is injectable so the decode runs on virtual time in tests). The engine's
 * confinement (`limitedParallelism(1)`) and the UI's `Main.immediate` never touch each other's state;
 * this bus is the only engine→UI seam, and [publish] is a plain non-suspending hand-off, safe to call
 * from the engine's confined dispatcher.
 */
public class EventBus(
    private val decodeDispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
    /** One consumer mailbox plus the predicate selecting the events it wants (FIX 4 typing). */
    private class Consumer(
        val mailbox: Channel<SessionEvent>,
        val wants: (SessionEvent) -> Boolean,
    )

    /**
     * Live consumer mailboxes. Copy-on-write so [publish] (called from the engine's confined
     * dispatcher) iterates a stable snapshot while registration/cancellation add/remove concurrently.
     */
    private val consumers = CopyOnWriteArrayList<Consumer>()

    /**
     * Register a [wants]-filtered mailbox EAGERLY (FIX 2a) and return a RE-COLLECTABLE stream that
     * drains it (FIX 1). The mailbox is added to [consumers] the instant this is called and lives for
     * the BUS (session) lifetime — cancelling a collection does NOT close or unregister it, so a
     * config-change re-collect resumes on the SAME mailbox and loses nothing. `receiveAsFlow` (not
     * `consumeAsFlow`) is what makes the flow collectable more than once. Mailboxes are released only by
     * [close] when the session ends.
     */
    private fun register(wants: (SessionEvent) -> Boolean): Flow<SessionEvent> {
        val consumer = Consumer(Channel(Channel.UNLIMITED), wants)
        consumers.add(consumer) // EAGER: mailbox exists before start() → nothing published can be dropped
        return consumer.mailbox.receiveAsFlow()
    }

    /**
     * The terminal's stream: `Output` frames only, UTF-8-decoded to bytes on [decodeDispatcher] so the
     * multi-MB replay decode is off `Main` (§6.6). Fed verbatim to the emulator (`:terminal-view`).
     */
    public fun outputBytes(): Flow<ByteArray> =
        register { it is Output }
            .map { (it as Output).data.toByteArray(Charsets.UTF_8) }
            .flowOn(decodeDispatcher)

    /** The cockpit's stream: every NON-`Output` event (connection/gate/telemetry/digest/exit). */
    public fun controlEvents(): Flow<SessionEvent> = register { it !is Output }

    /**
     * Fan [event] out to every registered consumer that WANTS it (FIX 4). Non-suspending and lossless:
     * [trySend] on an unbounded channel never suspends (no head-of-line stall) and never fails while the
     * channel is open (no drop). A `trySend` to an already-closing mailbox (racing cancellation) fails
     * harmlessly and is ignored — that consumer is going away anyway.
     */
    public fun publish(event: SessionEvent) {
        consumers.forEach { if (it.wants(event)) it.mailbox.trySend(event) }
    }

    /**
     * Launch the engine→UI pump: collect the engine's single-consumer [source] stream and [publish]
     * each event to the fan-out. Runs until [source] completes (the engine closed its channel) or
     * [scope] is cancelled. Returns the pump [Job] so the caller can tie it to the session lifetime.
     * Called from `controller.start()` AFTER the mailboxes are registered, so no event is missed.
     */
    public fun connect(scope: CoroutineScope, source: Flow<SessionEvent>): Job =
        scope.launch { source.collect { publish(it) } }

    /**
     * End-of-session release (FIX 1): close every per-consumer mailbox and clear the registry. Closing a
     * mailbox ends its `receiveAsFlow` iteration normally (any live collector completes), and a later
     * [publish] to a closed mailbox `trySend`s harmlessly. Called from `RetainedSessionHolder`'s teardown
     * AFTER the engine close-join, so the mailboxes are freed exactly when the session dies — never on a
     * mere config-change collector cancellation (which must leave the mailbox re-collectable).
     */
    public fun close() {
        consumers.forEach { it.mailbox.close() }
        consumers.clear()
    }
}
