"use strict";

/**
 * AtdtTerm — the caller terminal (SPEC §7): ANSI/VT parser over a
 * Cp437Render.ScreenBuffer, JSON transport, hotkey/line input modes, baud
 * throttle, BEL, ceremony. AnsiParser is DOM-free (Node-tested).
 */
var AtdtTerm = (function () {
    var R = (typeof Cp437Render !== "undefined") ? Cp437Render : require("./cp437render.js");

    /** ANSI SGR 30-37 → VGA color index (inverse of the server's map). */
    var SGR_TO_VGA = { 0: 0, 1: 4, 2: 2, 3: 6, 4: 1, 5: 5, 6: 3, 7: 7 };

    /** The §7.3 state machine. opts: {ice: bool, onBell: fn}. @constructor */
    function AnsiParser(buffer, opts) {
        this.buf = buffer;
        this.ice = !!(opts && opts.ice);
        this.onBell = (opts && opts.onBell) || function () {};
        this.x = 0; this.y = 0;                    // 0-based cursor
        this.fg = 7; this.bg = 0;                  // base fg 0-7 (bold adds 8)
        this.bold = false; this.blink = false; this.reverse = false;
        this.savedX = 0; this.savedY = 0;
        this.top = 0; this.bot = buffer.rows - 1;  // DECSTBM scroll region (1.6.0)
        this.wrapPending = false;                  // deferred wrap: armed at col 80
        this.state = "text";                       // text | esc | csi
        this.params = "";
    }

    AnsiParser.prototype.effAttrs = function () {
        var fg = this.fg + (this.bold ? 8 : 0);
        var bg = this.bg + (this.ice && this.blink ? 8 : 0);
        var blink = this.ice ? false : this.blink;
        if (this.reverse) {
            var t = fg; fg = bg; bg = t;
        }
        return { fg: fg & 0x0F, bg: bg & 0x0F, blink: blink };
    };

    AnsiParser.prototype.feed = function (text) {
        for (var i = 0; i < text.length; i++) {
            this.feedChar(text[i]);
        }
    };

    AnsiParser.prototype.feedChar = function (ch) {
        var code = ch.charCodeAt(0);
        if (this.state === "esc") {
            this.state = (ch === "[") ? "csi" : "text"; // non-CSI escapes: ignored (§7.3)
            this.params = "";
            return;
        }
        if (this.state === "csi") {
            if ((ch >= "0" && ch <= "9") || ch === ";") {
                this.params += ch;
            } else if (code >= 0x40 && code <= 0x7E) {
                this.csi(ch);
                this.state = "text";
            } else if (code < 0x20) {
                this.state = "text"; // malformed; drop
            }
            // other intermediates: consume silently
            return;
        }
        switch (code) {
            case 0x1B: this.state = "esc"; return;
            case 0x0D: this.x = 0; this.wrapPending = false; return;                       // \r
            case 0x0A: this.lineFeed(); return;                                            // \n
            case 0x08: this.x = Math.max(0, this.x - 1); this.wrapPending = false; return; // \b
            case 0x09: this.x = Math.min(79, ((this.x >> 3) + 1) << 3); this.wrapPending = false; return; // \t
            case 0x07: this.onBell(); return;                                              // BEL
            case 0x0C: this.clearScreen(); return;                                         // FF
            case 0x00: return;
        }
        if (code < 0x20) { return; } // other controls: ignore
        this.putChar(code);
    };

    AnsiParser.prototype.putChar = function (code) {
        // Deferred wrap (§7.3 + art safety): writing column 80 arms the wrap;
        // the NEXT printable performs it. Flowing text wraps identically, but
        // full-screen art touching cell (80,25) no longer scrolls itself.
        if (this.wrapPending) {
            this.wrapPending = false;
            this.x = 0;
            this.lineFeed();
        }
        var a = this.effAttrs();
        this.buf.set(this.x, this.y, code, a.fg, a.bg, a.blink);
        this.x++;
        if (this.x >= this.buf.cols) {
            this.x = this.buf.cols - 1;
            this.wrapPending = true;
        }
    };

    AnsiParser.prototype.lineFeed = function () {
        this.wrapPending = false;
        var a;
        if (this.y === this.bot) {
            // At the region's floor: the region scrolls, full-screen or not.
            a = this.effAttrs();
            this.buf.scrollRegion(this.top, this.bot, 1, a.fg, this.ice ? a.bg : this.bg);
        } else if (this.y >= this.buf.rows - 1) {
            this.y = this.buf.rows - 1; // below a narrowed region: stay put
        } else {
            this.y++;
        }
    };

    AnsiParser.prototype.clearScreen = function () {
        var a = this.effAttrs();
        this.buf.clear(a.fg, a.bg);
        this.x = 0;
        this.y = 0;
        this.top = 0;
        this.bot = this.buf.rows - 1;
        this.wrapPending = false;
    };

    AnsiParser.prototype.csi = function (final) {
        var p = this.params.split(";").map(function (s) {
            return s === "" ? null : parseInt(s, 10);
        });
        var n = (p[0] === null || p[0] === undefined) ? 1 : p[0];
        if ("ABCDHfu".indexOf(final) !== -1) { this.wrapPending = false; }
        switch (final) {
            case "A": this.y = Math.max(0, this.y - Math.max(1, n)); break;
            case "B": this.y = Math.min(this.buf.rows - 1, this.y + Math.max(1, n)); break;
            case "C": this.x = Math.min(this.buf.cols - 1, this.x + Math.max(1, n)); break;
            case "D": this.x = Math.max(0, this.x - Math.max(1, n)); break;
            case "H":
            case "f": {
                var row = (p[0] === null || p[0] === undefined) ? 1 : p[0];
                var col = (p[1] === null || p[1] === undefined) ? 1 : p[1];
                this.y = Math.min(this.buf.rows - 1, Math.max(0, row - 1));
                this.x = Math.min(this.buf.cols - 1, Math.max(0, col - 1));
                break;
            }
            case "J": this.clearScreen(); break;   // 0/1 treated as 2 (§7.3)
            case "K": {                            // erase cursor → EOL
                var a = this.effAttrs();
                this.buf.blankSpan(this.x, this.buf.cols - 1, this.y, a.fg, a.bg);
                break;
            }
            case "m": this.sgr(p); break;
            case "s": this.savedX = this.x; this.savedY = this.y; break;
            case "u": this.x = this.savedX; this.y = this.savedY; break;
            case "r": {                            // DECSTBM (1.6.0): chat scrolls its window
                var rTop = (p[0] === null || p[0] === undefined) ? 1 : p[0];
                var rBot = (p[1] === null || p[1] === undefined) ? this.buf.rows : p[1];
                rTop = Math.max(1, Math.min(this.buf.rows, rTop));
                rBot = Math.max(rTop, Math.min(this.buf.rows, rBot));
                this.top = rTop - 1;
                this.bot = rBot - 1;
                this.x = 0; this.y = 0;            // the VT100 homes on set
                this.wrapPending = false;
                break;
            }
            case "S": {                            // scroll region up n
                var aS = this.effAttrs();
                this.buf.scrollRegion(this.top, this.bot, Math.max(1, n), aS.fg, this.ice ? aS.bg : this.bg);
                break;
            }
            case "T": {                            // scroll region down n
                var aT = this.effAttrs();
                this.buf.scrollRegion(this.top, this.bot, -Math.max(1, n), aT.fg, this.ice ? aT.bg : this.bg);
                break;
            }
            case "M": {                            // DL: delete n lines at the cursor (1.7.4)
                // Newly honored: the door stream filter used to eat
                // ESC[M as an ANSI-music opener, so the parser never
                // met a delete-line. Rows below the cursor pull up
                // inside the scroll region; the freed floor blanks.
                if (this.y >= this.top && this.y <= this.bot) {
                    var aM = this.effAttrs();
                    this.buf.scrollRegion(this.y, this.bot, Math.max(1, n), aM.fg, this.ice ? aM.bg : this.bg);
                }
                break;
            }
            case "L": {                            // IL: insert n blank lines at the cursor (1.7.4)
                if (this.y >= this.top && this.y <= this.bot) {
                    var aL = this.effAttrs();
                    this.buf.scrollRegion(this.y, this.bot, -Math.max(1, n), aL.fg, this.ice ? aL.bg : this.bg);
                }
                break;
            }
            default: break;                        // unrecognized: ignore (§7.3)
        }
    };

    AnsiParser.prototype.sgr = function (params) {
        if (params.length === 1 && (params[0] === null || params[0] === undefined)) {
            params = [0];
        }
        for (var i = 0; i < params.length; i++) {
            var v = params[i] === null ? 0 : params[i];
            if (v === 0) {
                this.fg = 7; this.bg = 0;
                this.bold = false; this.blink = false; this.reverse = false;
            } else if (v === 1) { this.bold = true; }
            else if (v === 5) { this.blink = true; }
            else if (v === 7) { this.reverse = true; }
            else if (v === 22) { this.bold = false; }
            else if (v === 25) { this.blink = false; }
            else if (v >= 30 && v <= 37) { this.fg = SGR_TO_VGA[v - 30]; }
            else if (v >= 40 && v <= 47) { this.bg = SGR_TO_VGA[v - 40]; }
            // anything else: ignore
        }
    };

    /** JSON transport (§7.4): serialized requests, seq + token tracking. @constructor */
    function Transport(url) {
        this.url = url;
        this.token = null;
        this.seq = 0;
        this.chain = Promise.resolve();
    }

    Transport.prototype.post = function (body) {
        var self = this;
        this.chain = this.chain.then(function () {
            if (body.a !== "connect" && body.a !== "preview") {
                if (!self.token) { return null; }
                body.token = self.token;
                body.seq = ++self.seq;
            }
            return fetch(self.url, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify(body)
            }).then(function (r) { return r.json(); }).catch(function () { return null; });
        });
        return this.chain;
    };

    /** The terminal proper: buffer/parser/painter + modes + throttle. @constructor */
    function Terminal(cfg) {
        var self = this;
        this.cfg = cfg;
        this.buf = new R.ScreenBuffer();
        this.parser = new AnsiParser(this.buf, { ice: false, onBell: function () { self.beep(); } });
        this.painter = new R.CanvasPainter(cfg.canvas, this.buf);
        this.transport = new Transport(cfg.apiUrl);
        this.mode = "none";          // none | hotkey | line | editor
        this.line = null; this.editor = null;
        // Board default before login (1.6.0); the user's own pref lands
        // with the login response and takes over from there.
        this.prefs = { beep: false, baud: (typeof cfg.baud === "number") ? cfg.baud : 9600 };
        this.revealQueue = ""; this.revealTimer = null;
        this.pendingMode = null;     // input arms after the reveal (1.6.0)
        this.pendingDeath = false;   // goodbye reveals at line speed first
        this.pollMs = 4000; this.pollTimer = null;
        this.dead = false; this.audio = null;
        this.doorHold = false;       // a DOS door owns the line (1.7.0): no polling
        this.cursorOn = true;        // hotkey frames may switch the cursor+echo off (1.7.4, doors)
        this.chatHw = -1;            // §9.6 chat watermark; server initializes
        this.sel = null;             // drag selection for select-to-copy (1.1.0)

        setInterval(function () {
            self.painter.setBlinkPhase(!self.painter.blinkVisible);
            if (self.sel) { self.drawSel(); } // the blink repaint must not eat the highlight
        }, 533);
    }

    Terminal.prototype.start = function () {
        var self = this;
        R.loadFont(this.cfg.fontUrl, function (family) {
            self.painter.setFont(family);
            if (self.cfg.preview) {
                self.startPreview();
            } else if (self.cfg.ceremony) {
                self.ceremony(function () { self.connect(); });
            } else {
                self.connect();
            }
        });
    };

    /**
     * Read-only preview for the console's menu designer (§11.5): one shot
     * of a menu's frames, painted instantly — no session, no input, no poll.
     */
    Terminal.prototype.startPreview = function () {
        var self = this;
        this.dead = true; // no input handling, ever
        this.transport.post({ a: "preview", menu: this.cfg.preview }).then(function (res) {
            var frames = (res && res.frames) || [];
            frames.forEach(function (f) {
                if (f.t === "ansi") { self.parser.feed(f.d); }
            });
            self.painter.repaint();
        });
    };

    /**
     * Sysop broadcast (owner round 3): a strip above the terminal, shown
     * once per broadcast id, gone when its clock runs out. Lives in the
     * page, not the canvas, so no screen content is disturbed.
     */
    Terminal.prototype.showBanner = function (b) {
        if (!b || !b.text || b.id === this.bannerId) { return; }
        this.bannerId = b.id;
        var el = document.getElementById("banner");
        if (!el) {
            el = document.createElement("div");
            el.id = "banner";
            document.body.insertBefore(el, document.body.firstChild);
        }
        el.textContent = "*** " + b.text + " ***";
        el.style.display = "block";
        this.beep();
        clearTimeout(this.bannerTimer);
        var secs = Math.max(5, Math.min(600, b.secs || 120));
        this.bannerTimer = setTimeout(function () { el.style.display = "none"; }, secs * 1000);
    };

    /** Pure theater (§7.6): local modem dance before the first API call. */
    Terminal.prototype.ceremony = function (done) {
        var self = this;
        var script = "ATDT " + this.cfg.dial + "\r\n";
        var i = 0;
        var typeOut = function () {
            if (i < script.length) {
                self.parser.feed(script[i++]);
                self.painter.repaint();
                setTimeout(typeOut, 45);
            } else {
                setTimeout(function () {
                    // No throttle answers as CONNECT 14400 (1.6.0): past
                    // 9600 everything was effectively instant anyway.
                    var b = Number(self.prefs.baud);
                    self.parser.feed("CONNECT " + (b === 0 ? 14400 : (b || 9600)) + "\r\n\r\n");
                    self.painter.repaint();
                    setTimeout(done, 350);
                }, 650);
            }
        };
        typeOut();
    };

    Terminal.prototype.connect = function () {
        var self = this;
        this.transport.post({ a: "connect" }).then(function (res) { self.handle(res); });
        var tick = function () {
            if (!self.dead && !self.doorHold) {
                // chat_hw is the delivery watermark (§9.6): without it the
                // server assumes a fresh client and delivers nothing — chat
                // lines and sysop break-ins ride on this field.
                self.transport.post({ a: "poll", chat_hw: self.chatHw }).then(function (res) { self.handle(res, true); });
            }
        };
        this.pollTimer = setInterval(tick, this.pollMs);
        // Waking the tab polls NOW (1.5.5): browsers throttle background-tab
        // timers hard, so a sysop break-in could sit undelivered for a
        // minute-plus and then look like it needed a keypress to arrive.
        if (typeof document !== "undefined" && document.addEventListener) {
            document.addEventListener("visibilitychange", function () {
                if (!document.hidden) { tick(); }
            });
        }
    };

    /** Apply one API response envelope. */
    Terminal.prototype.handle = function (res, fromPoll) {
        if (!res) { return; }
        if (typeof res.chat_hw === "number") { this.chatHw = res.chat_hw; }
        if (res.banner) { this.showBanner(res.banner); }
        if (res.session) {
            if (res.session.token) { this.transport.token = res.session.token; }
            if (typeof res.session.seq === "number") { this.transport.seq = res.session.seq; }
            if (res.session.poll_ms) { this.pollMs = res.session.poll_ms; }
            if (typeof res.session.ice === "boolean") { this.parser.ice = res.session.ice; }
            if (res.session.prefs) {
                if (typeof res.session.prefs.beep === "boolean") { this.prefs.beep = res.session.prefs.beep; }
                if (typeof res.session.prefs.baud !== "undefined") { this.prefs.baud = res.session.prefs.baud; }
            }
        }
        var frames = res.frames || [];
        for (var i = 0; i < frames.length; i++) {
            var f = frames[i];
            if (f.t === "ansi") {
                this.showAnsi(String(f.d), fromPoll);
            } else if (f.t === "mode") {
                if ((f.m === "line" || f.m === "editor") && this.revealBusy()) {
                    // 1.6.0: arming a typed input used to smash the reveal
                    // queue "so local echo has a current screen" -- which
                    // made every line-mode screen (the message boards
                    // above all) paint instantly, whatever the baud. The
                    // mode now waits its turn; the reveal finishes at line
                    // speed and THEN the field arms. Any keypress still
                    // fast-forwards, as ever.
                    this.pendingMode = f;
                } else {
                    this.setMode(f);
                }
            } else if (f.t === "bell") {
                this.beep();
            } else if (f.t === "dosdoor") {
                this.runDosDoor(String(f.slug || ""));
            } else if (f.t === "disconnect") {
                this.mode = "none";
                this.line = null;
                this.pendingMode = null;
                if (this.pollTimer) { clearInterval(this.pollTimer); }
                if (this.revealBusy()) {
                    // The goodbye deserves its baud too (1.6.0): the line
                    // goes dead only after the last byte reveals.
                    this.pendingDeath = true;
                } else {
                    this.dead = true;
                }
            }
        }
        this.syncCursor();
        this.painter.repaint();
    };

    /**
     * A DOSDOOR frame (1.7.0): pull in the runner and the emulator, then
     * hand it the line. Both scripts load only the first time a caller
     * actually enters a DOS door — nobody else pays for the 2 MB machine.
     * If loading fails, nothing happens: the fallback line the server
     * printed is already on screen and any key repaints the menu.
     */
    Terminal.prototype.runDosDoor = function (slug) {
        var self = this;
        if (!slug || this.doorHold || typeof document === "undefined") { return; }
        var need = [];
        if (typeof AtdtDosDoor === "undefined") { need.push("assets/dosdoor.js"); }
        if (typeof V86 === "undefined") { need.push("dosdoors/client/libv86.js"); }
        var load = function (i) {
            if (i >= need.length) {
                self.drainReveal();
                self.mode = "none";     // the door speaks serial, not frames
                AtdtDosDoor.run({
                    slug: slug,
                    term: self,
                    base: "dosdoors/",
                    onExit: function () { self.mode = "hotkey"; }
                });
                return;
            }
            var s = document.createElement("script");
            s.src = need[i];
            s.onload = function () { load(i + 1); };
            s.onerror = function () { /* fallback text is already up */ };
            document.head.appendChild(s);
        };
        load(0);
    };

    /** Baud throttle (§7.6): reveal at ~baud/10 chars/sec; poll frames bypass. */
    Terminal.prototype.showAnsi = function (text, bypass) {
        var baud = Number(this.prefs.baud) || 0;
        if (bypass || !baud || baud === 0 || this.dead) {
            // A poll frame (chat line, sysop BREAK-IN) can land while an
            // earlier screen is still revealing at baud speed. Finish that
            // reveal NOW, in order, or its tail keeps painting over the
            // fresh screen -- the break-in used to sit half-buried under
            // menu leftovers until somebody typed (1.5.5).
            this.drainReveal();
            this.parser.feed(text);
            return;
        }
        this.revealQueue += text;
        if (!this.revealTimer) {
            var self = this;
            var perTick = Math.max(1, Math.round(baud / 10 / 20)); // 50ms ticks
            this.revealTimer = setInterval(function () {
                // The parser is incremental, so cutting mid-sequence is safe.
                var end = Math.min(perTick, self.revealQueue.length);
                self.parser.feed(self.revealQueue.slice(0, end));
                self.revealQueue = self.revealQueue.slice(end);
                // The cursor RIDES the reveal (1.7.4 follow-up): synced
                // only at frame time, it sat parked at the OLD screen's
                // prompt -- the bottom -- while a long list drew above
                // it. A real terminal's cursor is the print head; at low
                // baud you watch it draw the screen.
                self.syncCursor();
                self.painter.repaint();
                if (self.revealQueue.length === 0) {
                    clearInterval(self.revealTimer);
                    self.revealTimer = null;
                    self.revealDone();
                }
            }, 50);
        }
    };

    Terminal.prototype.revealBusy = function () {
        return this.revealQueue.length > 0 || !!this.revealTimer;
    };

    /** The reveal finished (naturally or drained): arm whatever waited. */
    Terminal.prototype.revealDone = function () {
        if (this.pendingMode) {
            var f = this.pendingMode;
            this.pendingMode = null;
            this.setMode(f);
            this.painter.repaint();
        }
        if (this.pendingDeath) {
            this.pendingDeath = false;
            this.dead = true;
        }
        // The reveal moved the parser cell by cell; park the hardware
        // cursor where the output actually ended (1.7.4).
        this.syncCursor();
        this.painter.repaint();
    };

    Terminal.prototype.drainReveal = function () {
        if (this.revealQueue.length > 0) {
            this.parser.feed(this.revealQueue);
            this.revealQueue = "";
        }
        if (this.revealTimer) {
            clearInterval(this.revealTimer);
            this.revealTimer = null;
        }
        this.revealDone();
    };

    Terminal.prototype.setMode = function (f) {
        if (this.doorHold) {
            // A DOS door owns the line (1.7.0). The DOSDOOR reply carries a
            // hotkey frame for clients that ignored the dosdoor frame; here
            // it must not re-arm terminal input, or keys pressed while the
            // machine boots would reach the menu and repaint it over the
            // door. The runner restores the mode when the door ends.
            return;
        }
        if (f.m === "editor") {
            // Full-screen local editor (§7.5) — delegated to AtdtMsgEd.
            var self = this;
            var M = (typeof AtdtMsgEd !== "undefined") ? AtdtMsgEd : require("./msged.js");
            this.mode = "editor";
            this.line = null;
            this.editor = new M.Editor(f, this.buf, function (lines) {
                self.mode = "hotkey";
                self.editor = null;
                self.transport.post({ a: "editor", lines: lines }).then(function (res) { self.handle(res); });
            });
            return;
        }
        if (f.m === "line") {
            this.mode = "line";
            // Pinned to the grid (1.5.5): a frame aiming past the edge
            // would paint its echo and cursor nowhere — dead-looking keys.
            var lx = Math.min(Math.max((f.x || 1) - 1, 0), this.buf.cols - 1);
            var ly = Math.min(Math.max((f.y || 1) - 1, 0), this.buf.rows - 1);
            this.line = {
                x: lx,
                y: ly,
                max: Math.max(1, Math.min(f.max || 20, this.buf.cols - lx)),
                mask: f.mask || "",
                attr: (typeof f.attr === "number") ? f.attr : 15,
                value: String(f.initial || ""),
                cur: String(f.initial || "").length,   // caret index (1.6.0)
                // Named keys the SERVER wants even while a line is being
                // typed (chat history paging). Default keeps the classic
                // pair; screens opt into more via the mode frame.
                fwd: Array.isArray(f.fwd) ? f.fwd : ["PGUP", "PGDN"]
            };
            this.paintLine();
        } else {
            this.mode = "hotkey";
            this.line = null;
            // Doors switch the hardware cursor and the key echo off
            // (cur:0): they paint their own prompt marker and repaint
            // only what they choose (1.7.4).
            this.cursorOn = !(f && f.cur === 0);
            this.syncCursor();
        }
    };

    /**
     * The hardware cursor (1.7.4, owner): on hotkey screens the painter
     * shows a blinking bar wherever the board's output left the cursor,
     * the way a real terminal sat after "Command:". Line mode paints
     * its own caret, the editor its own, and a DOS door owns the whole
     * screen -- theirs stays out of the way.
     */
    Terminal.prototype.syncCursor = function () {
        if (!this.painter) { return; }
        this.painter.cursor = (this.mode === "hotkey" && this.cursorOn
                && !this.dead && !this.doorHold && this.parser)
            ? { x: this.parser.x, y: this.parser.y }
            : null;
    };

    /**
     * Local echo for line mode (§7.4). The caret (1.6.0) can sit
     * anywhere in the value: mid-text it blinks the character under it
     * on an inverse cell; at the end it is the classic underscore.
     */
    Terminal.prototype.paintLine = function () {
        var L = this.line;
        if (!L) { return; }
        if (L.cur === undefined || L.cur === null) { L.cur = L.value.length; }
        L.cur = Math.max(0, Math.min(L.value.length, L.cur));
        for (var i = 0; i < L.max; i++) {
            var ch = 0x20;
            var fg = L.attr;
            var bg = 0;
            var blink = false;
            if (i < L.value.length) {
                ch = (L.mask ? L.mask : L.value[i]).charCodeAt(0);
            }
            if (i === L.cur) {
                if (i < L.value.length) {
                    fg = 0; bg = 7; // inverse cell marks the caret mid-text
                } else {
                    ch = 0x5F; // the classic underscore at the end
                }
                blink = true;
            }
            this.buf.set(L.x + i, L.y, ch, fg, bg, blink);
        }
        this.painter.repaint();
    };

    Terminal.prototype.keyInput = function (name, printable) {
        var self = this;
        this.drainReveal(); // any keypress smashes the throttle queue (§7.6)
        if (this.dead || this.mode === "none") { return; }
        if (this.mode === "editor") {
            if (this.editor) {
                this.editor.key(name, printable !== null ? printable : name);
                this.painter.repaint();
            }
            return;
        }
        if (this.mode === "hotkey") {
            // 1.7.4 (owner): a single-key command echoes for the beat it
            // takes the board to answer, the way a real terminal felt.
            // The next frames repaint over it; the guard against the
            // last column keeps a stray echo from ever scrolling.
            if (printable !== null && printable.length === 1
                && printable.charCodeAt(0) >= 0x20 && this.cursorOn
                && this.parser && this.parser.x < this.buf.cols - 1) {
                this.parser.feed(printable);
                this.syncCursor();   // the cursor rides just past the echo
                if (this.painter) { this.painter.repaint(); }
            }
            this.transport.post({ a: "key", key: name }).then(function (res) { self.handle(res); });
            return;
        }
        var L = this.line;
        if (!L) { return; }
        if (L.cur === undefined || L.cur === null) { L.cur = L.value.length; }
        if (name === "ENTER") {
            this.mode = "hotkey";
            this.buf.blankSpan(L.x + L.value.length, L.x + L.max, L.y, 7, 0);
            this.transport.post({ a: "line", line: L.value }).then(function (res) { self.handle(res); });
        } else if (name === "ESC") {
            this.mode = "hotkey";
            this.buf.blankSpan(L.x, L.x + L.max, L.y, 7, 0);
            this.transport.post({ a: "line", line: null }).then(function (res) { self.handle(res); });
        } else if (name === "BACKSPACE") {
            if (L.cur > 0) {
                L.value = L.value.slice(0, L.cur - 1) + L.value.slice(L.cur);
                L.cur--;
                this.paintLine();
            }
        } else if (name === "DELETE") {
            if (L.cur < L.value.length) {
                L.value = L.value.slice(0, L.cur) + L.value.slice(L.cur + 1);
                this.paintLine();
            }
        } else if (name === "LEFT") {
            if (L.cur > 0) { L.cur--; this.paintLine(); }
        } else if (name === "RIGHT") {
            if (L.cur < L.value.length) { L.cur++; this.paintLine(); }
        } else if (name === "HOME") {
            L.cur = 0; this.paintLine();
        } else if (name === "END") {
            L.cur = L.value.length; this.paintLine();
        } else if (name.length > 1 && (L.fwd || ["PGUP", "PGDN"]).indexOf(name) !== -1) {
            // Server-declared pass-through (chat history paging §10.6): the
            // reply repaints the scroll region only, so the half-typed line
            // here survives untouched.
            this.transport.post({ a: "key", key: name }).then(function (res) { self.handle(res); });
        } else if (printable && printable.length === 1 && L.value.length < L.max) {
            var code = printable.charCodeAt(0);
            if (code >= 0x20 && code !== 0x7F) {
                L.value = L.value.slice(0, L.cur) + printable + L.value.slice(L.cur);
                L.cur++;
                this.paintLine();
            }
        }
    };

    /**
     * Clipboard paste (1.1.0): typed into the line editor or the message
     * editor as if keyed, one character at a time. Newlines become ENTER
     * in the full-screen editor and spaces on a single line; nothing is
     * pasted at a menu prompt.
     */
    Terminal.prototype.paste = function (text) {
        if (this.dead || !text) { return; }
        text = String(text).replace(/\r\n?/g, "\n");
        if (this.mode === "editor") {
            if (!this.editor) { return; }
            for (var i = 0; i < text.length; i++) {
                var ch = text[i];
                if (ch === "\n") { this.editor.key("ENTER", "ENTER"); }
                else if (ch === "\t") { this.editor.key("TAB", "TAB"); }
                else if (ch >= " ") { this.editor.key(ch, ch); }
            }
            this.painter.repaint();
            return;
        }
        var L = this.line;
        if (this.mode !== "line" || !L) { return; }
        if (L.cur === undefined || L.cur === null) { L.cur = L.value.length; }
        var flat = text.replace(/[\n\t]+/g, " ");
        for (var j = 0; j < flat.length && L.value.length < L.max; j++) {
            var code = flat.charCodeAt(j);
            if (code >= 0x20 && code !== 0x7F) {
                L.value = L.value.slice(0, L.cur) + flat[j] + L.value.slice(L.cur);
                L.cur++;
            }
        }
        this.paintLine();
    };

    /**
     * Select-to-copy (1.1.0), the PuTTY way: drag a rectangle on the
     * canvas and it lands on the clipboard at mouseup — rows trimmed of
     * trailing blanks, joined with newlines. The screen is a canvas, so
     * this reads straight from the cell buffer.
     */
    Terminal.prototype.copySelection = function () {
        var s = this.sel;
        if (!s) { return; }
        var x0 = Math.min(s.x0, s.x1), x1 = Math.max(s.x0, s.x1);
        var y0 = Math.min(s.y0, s.y1), y1 = Math.max(s.y0, s.y1);
        var out = [];
        for (var y = y0; y <= y1; y++) {
            var row = "";
            for (var x = x0; x <= x1; x++) {
                var cell = this.buf.get(x, y);
                row += String.fromCodePoint(cell && cell.ch ? cell.ch : 0x20);
            }
            out.push(row.replace(/\s+$/, ""));
        }
        var text = out.join("\n");
        if (!text.replace(/\s/g, "")) { return; }
        if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(text).catch(function () { /* denied: nothing to do */ });
        } else {
            var ta = document.createElement("textarea");
            ta.value = text;
            ta.style.position = "fixed";
            ta.style.left = "-9999px";
            document.body.appendChild(ta);
            ta.select();
            try { document.execCommand("copy"); } catch (e) { /* ignore */ }
            ta.remove();
        }
    };

    /** The translucent highlight over the current drag selection. */
    Terminal.prototype.drawSel = function () {
        if (!this.sel) { return; }
        var g = this.cfg.canvas.getContext("2d");
        var x = Math.min(this.sel.x0, this.sel.x1);
        var y = Math.min(this.sel.y0, this.sel.y1);
        var w = Math.abs(this.sel.x1 - this.sel.x0) + 1;
        var h = Math.abs(this.sel.y1 - this.sel.y0) + 1;
        g.save();
        g.globalAlpha = 0.35;
        g.fillStyle = "#7fb2ff";
        g.fillRect(x * 8, y * 16, w * 8, h * 16);
        g.restore();
    };

    Terminal.prototype.beep = function () {
        if (!this.prefs.beep) { return; }
        try {
            if (!this.audio) {
                this.audio = new (window.AudioContext || window.webkitAudioContext)();
            }
            var osc = this.audio.createOscillator();
            var gain = this.audio.createGain();
            osc.type = "square";
            osc.frequency.value = 880;
            gain.gain.value = 0.05;
            osc.connect(gain);
            gain.connect(this.audio.destination);
            osc.start();
            osc.stop(this.audio.currentTime + 0.12);
        } catch (e) { /* no audio, no beep */ }
    };

    /** Map a KeyboardEvent to the §7.4 key name (or null to let it pass). */
    function keyName(e) {
        var k = e.key;
        if (e.ctrlKey && !e.altKey && !e.metaKey && k.length === 1) {
            var up = k.toUpperCase();
            if (up === "V" || up === "C") { return null; } // clipboard belongs to the browser (1.1.0)
            if (up >= "A" && up <= "Z") { return "CTRL+" + up; }
        }
        if (e.ctrlKey || e.altKey || e.metaKey) { return null; }
        if (k.length === 1) { return k; }
        var map = {
            Enter: "ENTER", Backspace: "BACKSPACE", Tab: "TAB", Escape: "ESC",
            ArrowUp: "UP", ArrowDown: "DOWN", ArrowLeft: "LEFT", ArrowRight: "RIGHT",
            Home: "HOME", End: "END", PageUp: "PGUP", PageDown: "PGDN", Delete: "DELETE",
            Insert: "INS"
        };
        if (map[k]) { return map[k]; }
        if (/^F([1-9]|10)$/.test(k)) { return k; }
        return null;
    }

    /**
     * Integer scale-to-fit (§7.1). Preview iframes (§11.5) may be smaller
     * than the canvas — there fractional downscale beats a cropped corner.
     */
    function fitCanvas(canvas, allowShrink) {
        var kx = window.innerWidth / canvas.width;
        var ky = (window.innerHeight - (allowShrink ? 0 : 40)) / canvas.height;
        var k = Math.min(kx, ky);
        k = allowShrink ? Math.min(k, 4) : Math.max(1, Math.floor(k));
        canvas.style.transform = "scale(" + k + ")";
    }

    /** Wire the page together. Reads config from #atdt-config JSON. */
    function boot(doc) {
        var cfgEl = doc.getElementById("atdt-config");
        var cfg = cfgEl ? JSON.parse(cfgEl.textContent) : {};
        var canvas = doc.getElementById("term");
        var input = doc.getElementById("kbd");
        var term = new Terminal({
            canvas: canvas,
            apiUrl: cfg.api || "api.php",
            fontUrl: cfg.font || "assets/fonts/Web437_IBM_VGA_8x16.woff",
            ceremony: !!cfg.ceremony,
            dial: cfg.dial || "555-0001",
            baud: (typeof cfg.baud === "number") ? cfg.baud : 9600,
            preview: cfg.preview || null
        });

        fitCanvas(canvas, !!cfg.preview);
        window.addEventListener("resize", function () { fitCanvas(canvas, !!cfg.preview); });

        doc.addEventListener("keydown", function (e) {
            // NO CARRIER (1.6.0): once the call ends, the keyboard
            // belongs to the browser again - swallowing Ctrl-R here
            // left refresh doing nothing after logoff.
            if (term.dead) { return; }
            var name = keyName(e);
            if (name === null) { return; }
            e.preventDefault();
            term.keyInput(name, e.key.length === 1 ? e.key : null);
        });
        doc.addEventListener("pointerdown", function () {
            if (input) { input.focus({ preventScroll: true }); }
        });
        // Clipboard (1.1.0): paste types into whatever accepts typing;
        // select-drag on the screen copies, the classic terminal way.
        doc.addEventListener("paste", function (e) {
            var t = e.clipboardData ? e.clipboardData.getData("text/plain") : "";
            e.preventDefault();
            term.paste(t);
        });
        var cellAt = function (e) {
            var r = canvas.getBoundingClientRect();
            var cx = Math.floor((e.clientX - r.left) * (canvas.width / r.width) / 8);
            var cy = Math.floor((e.clientY - r.top) * (canvas.height / r.height) / 16);
            return {
                x: Math.max(0, Math.min(79, cx)),
                y: Math.max(0, Math.min(24, cy))
            };
        };
        canvas.addEventListener("pointerdown", function (e) {
            if (e.button !== 0) { return; }
            var c = cellAt(e);
            term.sel = { x0: c.x, y0: c.y, x1: c.x, y1: c.y, drag: false };
        });
        canvas.addEventListener("pointermove", function (e) {
            if (!term.sel) { return; }
            var c = cellAt(e);
            if (c.x !== term.sel.x1 || c.y !== term.sel.y1) {
                term.sel.x1 = c.x;
                term.sel.y1 = c.y;
                term.sel.drag = true;
            }
            term.painter.repaint();
            term.drawSel();
        });
        doc.addEventListener("pointerup", function () {
            if (!term.sel) { return; }
            if (term.sel.drag) { term.copySelection(); }
            term.sel = null;
            term.painter.repaint();
        });
        doc.querySelectorAll("[data-key]").forEach(function (btn) {
            btn.addEventListener("click", function (e) {
                e.preventDefault();
                var name = btn.getAttribute("data-key");
                term.keyInput(name, name.length === 1 ? name : null);
            });
        });
        // Full screen with the keyboard locked (1.7.0): a browser keeps
        // Ctrl+W and Ctrl+T for itself on a normal page - closing the
        // tab out from under a door whose game binds them (Usurper
        // does). Inside JS-initiated full screen the Keyboard Lock API
        // hands every chord to the page, and holding Esc is the one way
        // out, which the browser explains on screen by itself. Where the
        // API is missing (Firefox, plain HTTP) full screen still works,
        // just without the reserved chords.
        var full = doc.getElementById("fullkeys");
        if (full) {
            full.addEventListener("click", function (e) {
                e.preventDefault();
                if (doc.fullscreenElement) {
                    doc.exitFullscreen().catch(function () { /* already out */ });
                    return;
                }
                var el = doc.documentElement;
                (el.requestFullscreen ? el.requestFullscreen() : Promise.reject())
                    .then(function () {
                        if (navigator.keyboard && navigator.keyboard.lock) {
                            return navigator.keyboard.lock();
                        }
                    })
                    .catch(function () { /* full screen refused; nothing to undo */ });
            });
            doc.addEventListener("fullscreenchange", function () {
                if (!doc.fullscreenElement && navigator.keyboard && navigator.keyboard.unlock) {
                    navigator.keyboard.unlock();
                }
                fitCanvas(canvas, !!cfg.preview);
            });
        }
        if (input) {
            // Mobile soft keyboards deliver text via input events, not keydown.
            input.addEventListener("input", function () {
                var v = input.value;
                input.value = "";
                for (var i = 0; i < v.length; i++) {
                    term.keyInput(v[i], v[i]);
                }
            });
            input.focus({ preventScroll: true });
        }
        term.start();
        return term;
    }

    return {
        AnsiParser: AnsiParser,
        Transport: Transport,
        Terminal: Terminal,
        keyName: keyName,
        boot: boot
    };
})();

if (typeof module !== "undefined" && module.exports) {
    module.exports = AtdtTerm;
}
