package wang.yaojia.webterm.terminalview

import android.util.Log
import com.termux.terminal.KeyHandler
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalOutput
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import java.util.ArrayDeque
import java.util.concurrent.Executors
import wang.yaojia.webterm.wire.ClientMessage

/**
 * The FORK of Termux's `TerminalSession` (plan §6.1) — a terminal session with **no local subprocess
 * and no JNI**. It owns a [TerminalEmulator] (the VT100/xterm parser + `TerminalBuffer` scrollback ring)
 * that is driven entirely by bytes arriving over the WebSocket, and routes emulator-originated bytes
 * (DA/DSR replies, mouse reports, bracketed-paste wrappers) back out through [engineSend].
 *
 * This is the single seam A21 wires the engine to: `output: Flow<ByteArray>` → [feedRemote];
 * `sendInput`/`resize` ← [writeInput]/[updateSize] via [engineSend]. It depends ONLY on
 * `:wire-protocol` ([ClientMessage]) — the `SessionEvent → ByteArray` decode is A21's, not here
 * (boundary note §3).
 *
 * ### Single-writer confinement (plan §6.2 mustFix)
 * `TerminalEmulator`/`TerminalBuffer` is single-writer, read by the renderer on the UI thread. Every
 * mutation (append, resize, pending flush) is serialized onto ONE confined dispatcher via an ordered
 * command [Channel] — mirroring Termux's background reader thread — so a multi-MB ring replay never
 * blocks the UI thread (ANR) and never races the on-screen draw. Only [onScreenUpdated] is posted to
 * [mainDispatcher]. Ordering is exactly submission order (FIFO channel + one consumer).
 *
 * The emulator is **not** exposed to the stock renderer until the initial [pendingOutput] flush has
 * COMPLETED: the Bind command flushes the queue on the confined thread first, THEN posts a single Main
 * action that publishes `mEmulator` to the view AND fires the first screen update — so the renderer
 * never reads [TerminalBuffer] while the confined thread is still appending at the bind moment (§6.2).
 * One malformed/hostile escape byte cannot freeze the terminal either: [consumeCommands] survives a
 * per-command throw and keeps draining (only cancellation propagates).
 *
 * @param engineSend the outbound sink — every [ClientMessage] produced here (Input/Resize) is handed to
 *   it. A21 bridges it to the engine's ordered send pump (§6.3). Must be safe to call from any thread.
 * @param onTitleChanged raw OSC 0/2 title delegate. Passed through UNsanitized — :terminal-view has no
 *   `:session-core` edge, so :app wires this to `TitleSanitizer` (plan §6.5 / boundary note §3).
 * @param onBell OSC/ctrl-G bell delegate (the view may buzz/flash).
 * @param appendDispatcher the confined single-writer dispatcher (default: a private single-thread
 *   executor). Injected as a `StandardTestDispatcher` in unit tests so the seam drives under virtual time.
 * @param mainDispatcher where [onScreenUpdated] is posted (default `Main.immediate`; a test dispatcher
 *   in unit tests so no real main looper is needed).
 */
public class RemoteTerminalSession(
    private val engineSend: (ClientMessage) -> Unit,
    initialCols: Int = DEFAULT_COLS,
    initialRows: Int = DEFAULT_ROWS,
    transcriptRows: Int = TRANSCRIPT_ROWS,
    private val onTitleChanged: (String) -> Unit = {},
    private val onBell: () -> Unit = {},
    appendDispatcher: CoroutineDispatcher? = null,
    private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
) {
    /** Emulator-originated output sink + title/clipboard/bell delegate. Runs on the confined thread. */
    private val termOutput: TerminalOutput = object : TerminalOutput() {
        override fun write(data: ByteArray?, offset: Int, count: Int) {
            if (data == null || count <= 0) return
            // Emulator reply bytes (DA/DSR/mouse) are ASCII; the engine re-encodes verbatim. Mirrors
            // the web/iOS clients treating input as a string (invariant #9 — no content filtering).
            engineSend(ClientMessage.Input(String(data, offset, count, Charsets.UTF_8)))
        }

        override fun titleChanged(oldTitle: String?, newTitle: String?) {
            onTitleChanged(newTitle ?: "")
        }

        // OSC 52 host-clipboard writes are DECLINED (plan §6.5) — never leak session output to the
        // device clipboard without an explicit user copy.
        override fun onCopyTextToClipboard(text: String?) = Unit
        override fun onPasteTextFromClipboard() = Unit
        override fun onBell(): Unit = this@RemoteTerminalSession.onBell()
        override fun onColorsChanged() = Unit
    }

    /** The VT parser + scrollback buffer. Public so the on-screen view binds it and tests read it. */
    public val emulator: TerminalEmulator =
        TerminalEmulator(termOutput, initialCols, initialRows, transcriptRows, NoOpTerminalSessionClient())

    // ── Confinement plumbing (all mutable state below is touched ONLY on the confined thread) ────────
    private val ownedExecutor = if (appendDispatcher == null) {
        Executors.newSingleThreadExecutor { r -> Thread(r, "webterm-term-append").apply { isDaemon = true } }
    } else {
        null
    }
    private val confinedDispatcher: CoroutineDispatcher =
        appendDispatcher ?: ownedExecutor!!.asCoroutineDispatcher()
    private val scope = CoroutineScope(SupervisorJob() + confinedDispatcher)
    private val commands = Channel<TerminalCommand>(Channel.UNLIMITED)

    /** Queued output that arrived before the view bound; flushed in submission order on bind (§6.2). */
    private val pendingOutput = ArrayDeque<ByteArray>()
    private var bound = false
    private var screenUpdateSink: (() -> Unit)? = null
    private var lastSentDims: TerminalGridSize? = null

    init {
        scope.launch { consumeCommands() }
    }

    // ── Inbound: remote output → emulator (off-main, chunked) ────────────────────────────────────────

    /**
     * Feed remote WS output into the emulator. Never blocks the caller (the command channel is
     * UNLIMITED) and never mutates the emulator on the caller's thread — the bytes are appended on the
     * confined dispatcher. Output arriving before [attachView]/[bind] is queued and replayed in order
     * (§6.2); the `ESC[0m` replay prefix is passed through, never stripped.
     */
    public fun feedRemote(bytes: ByteArray) {
        if (bytes.isEmpty()) return
        commands.trySend(TerminalCommand.Feed(bytes))
    }

    /**
     * Latest-writer-wins resize (§6.4). Resolves the emulator's local buffer reflow AND emits a
     * `Resize` to the server (→ SIGWINCH) — but only when [dims] actually change from [lastSentDims]
     * and are positive. The dedup + `emulator.resize` are serialized on the confined thread so a resize
     * never races an append.
     */
    public fun updateSize(cols: Int, rows: Int) {
        if (cols < TerminalGridMath.MIN_DIMENSION || rows < TerminalGridMath.MIN_DIMENSION) return
        commands.trySend(TerminalCommand.Resize(cols, rows))
    }

    // ── Outbound: typed / key-bar bytes → wire ───────────────────────────────────────────────────────

    /** Typed / key-bar bytes → `engineSend(Input(data))`, verbatim (invariant #9). Any thread. */
    public fun writeInput(data: String) {
        if (data.isEmpty()) return
        engineSend(ClientMessage.Input(data))
    }

    /**
     * The byte sequence for a hardware key, deferring to Termux's [KeyHandler] so DECCKM
     * (`cursorKeysApplication`) still emits `ESC O A` (not `ESC [ A`) for vim/htop (plan §6.3). The
     * caller (view) sends the result via [writeInput]. Returns null when the key maps to nothing.
     */
    public fun keyBytes(keyCode: Int, keyMode: Int): String? =
        KeyHandler.getCode(
            keyCode,
            keyMode,
            emulator.isCursorKeysApplicationMode,
            emulator.isKeypadApplicationMode,
        )

    // ── View binding (pendingOutput flush) ───────────────────────────────────────────────────────────

    /**
     * Bind the on-screen view. The emulator is NOT published to the stock renderer synchronously here
     * (on Main): the confined thread may still be flushing [pendingOutput] into the buffer, and the
     * renderer reads it during draw — publishing now would race that flush (§6.2). Instead the publish
     * is routed through the command channel so it lands on [mainDispatcher] only AFTER the initial flush
     * completes, together with the first screen update. The sink then invalidates the view on every feed.
     */
    public fun attachView(view: RemoteTerminalView) {
        view.session = this
        bind(publishEmulator = { view.bindEmulator(emulator) }) { view.requestScreenUpdate() }
    }

    /** Detach the view (rotation / real background). The emulator + scrollback survive in this holder. */
    public fun detachView() {
        commands.trySend(TerminalCommand.Unbind)
    }

    /**
     * Testable bind seam: register the [onScreenUpdated] sink, flush pending output on the confined
     * thread, THEN publish the emulator ([publishEmulator]) + fire the first screen update on the main
     * dispatcher. Production goes through [attachView] (which supplies [publishEmulator]); unit tests
     * call this directly (no android View needed) and default [publishEmulator] to a no-op.
     */
    internal fun bind(publishEmulator: () -> Unit = {}, onScreenUpdated: () -> Unit) {
        commands.trySend(TerminalCommand.Bind(publishEmulator, onScreenUpdated))
    }

    /** Await-free shutdown: stop the consumer, cancel the scope, release the owned thread. */
    public fun close() {
        commands.close()
        scope.cancel()
        ownedExecutor?.shutdownNow()
    }

    // ── The single confined consumer (one writer to the emulator) ────────────────────────────────────

    private suspend fun consumeCommands() {
        for (command in commands) {
            try {
                when (command) {
                    is TerminalCommand.Feed -> onFeed(command.bytes)
                    is TerminalCommand.Resize -> onResize(command.cols, command.rows)
                    is TerminalCommand.Bind -> onBind(command.publishEmulator, command.onScreenUpdated)
                    TerminalCommand.Unbind -> {
                        bound = false
                        screenUpdateSink = null
                    }
                }
            } catch (e: CancellationException) {
                throw e // never swallow cooperative cancellation — let the scope tear down cleanly
            } catch (t: Throwable) {
                // A malformed / hostile escape byte must NOT kill the single consumer and permanently
                // freeze all further output. Survive the one bad command and keep draining. Log the
                // throwable TYPE only — never its message or the command bytes, which may carry
                // terminal/session content (no secrets leaked).
                Log.w(LOG_TAG, "dropping a terminal command that threw: ${t.javaClass.simpleName}")
            }
        }
    }

    private suspend fun onFeed(bytes: ByteArray) {
        if (!bound) {
            pendingOutput.addLast(bytes)
            return
        }
        appendChunked(bytes)
        postScreenUpdate()
    }

    private suspend fun onBind(publishEmulator: () -> Unit, onScreenUpdated: () -> Unit) {
        screenUpdateSink = onScreenUpdated
        bound = true
        while (pendingOutput.isNotEmpty()) {
            appendChunked(pendingOutput.removeFirst())
        }
        // The flush is now COMPLETE on this confined thread. Only now do we hand the emulator to the
        // stock renderer AND fire the first screen update — both on the main dispatcher, in one action
        // ordered strictly after the flush. The renderer therefore never reads TerminalBuffer while the
        // confined thread is still appending at the bind moment (§6.2 single-writer-vs-UI-read).
        withContext(mainDispatcher) {
            publishEmulator()
            onScreenUpdated()
        }
    }

    private fun onResize(cols: Int, rows: Int) {
        val next = TerminalGridSize(cols, rows)
        if (next == lastSentDims) return
        emulator.resize(cols, rows) // local buffer reflow (confined-thread single writer)
        engineSend(ClientMessage.Resize(cols, rows)) // → server SIGWINCH
        lastSentDims = next
    }

    /**
     * Append [bytes] to the emulator in [APPEND_CHUNK_BYTES] slices, yielding between chunks so a
     * multi-MB replay stays cooperative (cancellable, and never monopolizes the confined thread).
     * UTF-8 continuation state is buffered inside the emulator across `append` calls, so slicing at an
     * arbitrary byte boundary never corrupts a multi-byte character.
     */
    private suspend fun appendChunked(bytes: ByteArray) {
        var offset = 0
        while (offset < bytes.size) {
            val end = minOf(offset + APPEND_CHUNK_BYTES, bytes.size)
            val chunk = if (offset == 0 && end == bytes.size) bytes else bytes.copyOfRange(offset, end)
            emulator.append(chunk, chunk.size)
            offset = end
            if (offset < bytes.size) yield()
        }
    }

    private suspend fun postScreenUpdate() {
        val sink = screenUpdateSink ?: return
        withContext(mainDispatcher) { sink() }
    }

    // ── Test-only inspectors (confined state read after the scheduler is idle) ───────────────────────

    /** The rendered screen + scrollback text, for the S1 seam assertion. */
    internal fun screenTextForTest(): String = emulator.screen.transcriptText

    /** Count of not-yet-flushed pending chunks, for the pendingOutput-ordering assertion. */
    internal fun pendingChunkCountForTest(): Int = pendingOutput.size

    /** The last dims actually sent to the server, for the resize-dedup assertion. */
    internal fun lastSentDimsForTest(): TerminalGridSize? = lastSentDims

    public companion object {
        /** Server hardcodes 80×24 on attach (`src/server.ts`); the real grid follows via `resize`. */
        public const val DEFAULT_COLS: Int = 80
        public const val DEFAULT_ROWS: Int = 24

        /** Local scroll-up history while connected; authoritative history is the server's ring replay. */
        public const val TRANSCRIPT_ROWS: Int = 10_000

        /** Append slice size — bounds one `append` call so a multi-MB replay never blocks. */
        public const val APPEND_CHUNK_BYTES: Int = 4096

        /** Logcat tag for the survive-a-bad-command path (logs the throwable type only, never content). */
        private const val LOG_TAG: String = "RemoteTerminalSession"
    }
}

/** Ordered commands to the single confined consumer — FIFO submission order == emulator write order. */
private sealed interface TerminalCommand {
    class Feed(val bytes: ByteArray) : TerminalCommand
    class Resize(val cols: Int, val rows: Int) : TerminalCommand
    class Bind(val publishEmulator: () -> Unit, val onScreenUpdated: () -> Unit) : TerminalCommand
    data object Unbind : TerminalCommand
}
