/*
  The terminal panel.

  Loaded only on a chat that can actually open a shell -- see the head and
  scripts blocks in chat/index.html -- because xterm is nearly three times the
  size of everything else vendored here. The Terminal object itself is built on
  the first *open* rather than on load, so even here nothing is parsed for
  somebody who never presses the button.

  Three things about xterm that are easy to get wrong, and cost an afternoon
  each:

  * `fit()` measures `offsetWidth`, which is 0 inside a `[hidden]` ancestor, so
    fitting while closed silently does nothing and leaves an 80-column terminal
    in a 34rem panel. Everything below is arranged so a fit only ever happens
    after the panel is visible.
  * A window `resize` event does not fire when the sidebar is toggled or a panel
    opens beside this one, which is by far the commonest way the panel changes
    size. Hence the ResizeObserver.
  * xterm does not read CSS variables. The theme is built from the computed
    style at open time and rebuilt when the theme changes, or switching to
    `shire` leaves a black rectangle in a light interface.
*/
(function () {
  "use strict";

  var panel = null;
  var term = null;
  var fit = null;
  var socket = null;
  var screen = null;
  var messageEl = null;
  var observer = null;
  var closedOnPurpose = false;
  /* Whether this shell tells us where commands begin and end -- "live",
     "loading" or "none". Everything the three buttons do keys off it. */
  var integration = "loading";
  /* "off" | "copy" | "send". Three states rather than a boolean, because the
     old one did the wrong one of them: it appended into the composer, on top of
     whatever was being typed there. A select rather than a cycling button --
     a button cannot say which of three states it is in. */
  var autoMode = "off";
  var lastCommand = null;

  function say(text, isError) {
    if (!messageEl) return;
    messageEl.textContent = text;
    messageEl.classList.toggle("terminal__message--error", !!isError);
  }

  /* --- Theme -------------------------------------------------------------- */
  /* The sixteen ANSI slots, in the order xterm names them. A shell chooses
     these itself -- `ls --color`, a git diff, htop's meters -- so without them
     the panel rendered a foreign palette inside the application, and switching
     to Shire left dark-theme colours on parchment. */
  var ANSI = [
    "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
    "brightBlack", "brightRed", "brightGreen", "brightYellow",
    "brightBlue", "brightMagenta", "brightCyan", "brightWhite"
  ];

  function readTheme() {
    var style = getComputedStyle(document.documentElement);
    function token(name, fallback) {
      return (style.getPropertyValue(name) || "").trim() || fallback;
    }
    var theme = {
      background: token("--code-bg", "#0C0F13"),
      foreground: token("--ink", "#E4E8EC"),
      cursor: token("--accent", "#8FB3CC"),
      cursorAccent: token("--code-bg", "#0C0F13"),
      selectionBackground: token("--accent-soft", "rgba(143, 179, 204, 0.3)")
    };
    /* camelCase to --kebab-case: brightBlack -> --ansi-bright-black. A slot
       with no token is left off the object entirely rather than set to
       undefined, which xterm treats as a colour and renders as black. */
    ANSI.forEach(function (name) {
      var value = token(
        "--ansi-" + name.replace(/[A-Z]/g, function (c) { return "-" + c.toLowerCase(); }),
        ""
      );
      if (value) theme[name] = value;
    });
    return theme;
  }

  /* --- Sizing ------------------------------------------------------------- */
  function visible() {
    return panel && !panel.hasAttribute("hidden") && panel.offsetWidth > 0;
  }

  function refit() {
    if (!term || !fit || !visible()) return;
    try {
      fit.fit();
    } catch (error) {
      return;
    }
    send({ t: "resize", cols: term.cols, rows: term.rows });
  }

  /* --- The socket --------------------------------------------------------- */
  function send(payload) {
    if (socket && socket.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify(payload));
    }
  }

  function connect() {
    if (socket) return;
    /* No target yet. The same guard `repointTerminal` has, and it belongs here
       too: with no connection chosen `dataset.url` is "", so the URL below
       becomes `ws://host?cols=80&rows=24` -- a handshake against the app root,
       which fails into onerror and blames the proxy for something the reader
       simply has not chosen yet. */
    if (!panel.dataset.url) {
      say("Choose a connection above, and this panel will open a shell on it.");
      return;
    }
    closedOnPurpose = false;

    var base = location.protocol === "https:" ? "wss://" : "ws://";
    var url =
      base + location.host + panel.dataset.url +
      "?cols=" + (term.cols || 80) + "&rows=" + (term.rows || 24);

    say("Connecting…");
    /* Held in a local as well as in `socket`, because every handler below has
       to know *which* connection it belongs to.

       `close()` queues its event rather than firing it, and both reconnect
       paths -- the `behind` frame and `repointTerminal` -- close the old
       socket and immediately call this function. So the old socket's `close`
       arrives *after* a new one has been assigned, and a handler that touched
       the module-level `socket` was reaching past its own connection into the
       live one. It set it to null: output kept arriving, because `onmessage`
       is bound to the object, while `send`, `term.onData` and the paste
       handler all gate on `socket && readyState === OPEN` -- so every
       keystroke was dropped and no resize was ever sent again, on a panel that
       looked perfectly healthy. And `closedOnPurpose` had already been cleared
       for the new connection, so it said "Disconnected. Close and reopen to
       reconnect." about a shell that had just reconnected. */
    var opened = new WebSocket(url);
    socket = opened;
    opened.binaryType = "arraybuffer";

    opened.onmessage = function (event) {
      if (socket !== opened) return;
      if (typeof event.data === "string") return control(event.data);
      /* Written straight through as bytes. xterm's decoder is stateful across
         calls, so a multi-byte character split across two frames still lands
         correctly -- which is exactly why the server never decodes either. */
      term.write(new Uint8Array(event.data));
    };

    opened.onclose = function () {
      if (socket !== opened) return;
      socket = null;
      if (!closedOnPurpose) say("Disconnected. Close and reopen to reconnect.");
    };

    opened.onerror = function () {
      if (socket !== opened) return;
      /* A failed handshake gives the page nothing: no status, no reason. So
         this is a guess, and it names the likeliest cause rather than
         pretending to know. */
      say("Could not connect. If this instance is behind a proxy, it may not " +
          "be passing WebSocket upgrades through.", true);
    };
  }

  function control(raw) {
    var payload;
    try {
      payload = JSON.parse(raw);
    } catch (error) {
      return;
    }

    if (payload.t === "ready") {
      say(payload.shared
        ? "Connected. This shell is also open in another tab, and they share a size."
        : "Connected.");
      if (payload.dir) {
        var where = panel.querySelector("[data-terminal-where]");
        if (where) where.textContent = payload.dir;
      }
      integration = payload.integration || "none";
      showLast(payload.last);
      applyIntegration();
      /* The server may have opened the shell at a size chosen by whoever got
         here first, so ask for ours now that there is something to ask. */
      refit();
      term.focus();
      return;
    }

    if (payload.t === "command") {
      /* A command finished. Tens of bytes, not the output: a 64KB text frame
         would compete with PTY bytes on the one path that has to stay quick,
         and the buttons fetch what they need when they are pressed. */
      if (integration !== "live") { integration = "live"; applyIntegration(); }
      showLast(payload.command);
      if (autoMode !== "off") {
        capture(true).then(function (text) {
          if (!text) return;
          if (autoMode === "copy") return intoComposer(text, true);
          sendStraightToChat(text);
        });
      }
      return;
    }

    if (payload.t === "behind") {
      /* This window stopped reading and was disconnected so the others kept
         up. Reconnecting costs nothing: the scrollback is the state. */
      say(payload.message || "Reconnecting…");
      closedOnPurpose = true;
      if (socket) socket.close();
      socket = null;
      term.reset();
      connect();
      return;
    }

    if (payload.t === "closed" || payload.t === "error") {
      say(payload.message || "This terminal closed.", payload.t === "error");
      closedOnPurpose = true;
      /* Deliberately no reconnect. A new shell has lost the working directory,
         the environment and the half-typed command, and quietly substituting
         one is worse than saying the connection went. */
    }
  }

  /* --- Building it -------------------------------------------------------- */
  function build() {
    if (term) return true;
    if (typeof Terminal === "undefined" || typeof FitAddon === "undefined") {
      say("The terminal could not be loaded.", true);
      return false;
    }

    var style = getComputedStyle(document.documentElement);
    term = new Terminal({
      allowProposedApi: true,
      convertEol: false,
      cursorBlink: true,
      fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
      /* xterm wants a number, so the token has to be a plain pixel value and
         is parsed back out here. Reading it rather than repeating 13 is what
         keeps it adjustable in one place with everything else. */
      fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 13,
      scrollback: 5000,
      theme: readTheme()
    });
    /* The module namespace is the UMD global, so the class is a property of
       it. `new FitAddon()` is the mistake that reads correctly. */
    fit = new FitAddon.FitAddon();
    term.loadAddon(fit);
    term.open(screen);

    term.onData(function (data) {
      if (socket && socket.readyState === WebSocket.OPEN) {
        socket.send(new TextEncoder().encode(data));
      }
    });

    /* Ctrl+C is interrupt here, which is correct and will still surprise
       somebody. Copy and paste are the shifted pair, as in every terminal. */
    term.attachCustomKeyEventHandler(function (event) {
      if (!event.ctrlKey || !event.shiftKey || event.type !== "keydown") return true;
      var key = event.key.toLowerCase();
      if (key === "c") {
        var selection = term.getSelection();
        if (selection) navigator.clipboard.writeText(selection);
        return false;
      }
      if (key === "v") {
        navigator.clipboard.readText().then(function (text) {
          if (socket && socket.readyState === WebSocket.OPEN) {
            socket.send(new TextEncoder().encode(text));
          }
        });
        return false;
      }
      return true;
    });

    /* The panel changes size when the sidebar is toggled or the window is
       resized, and only the second of those fires a `resize` event. */
    if (window.ResizeObserver) {
      observer = new ResizeObserver(function () {
        refit();
      });
      observer.observe(panel);
    }
    return true;
  }

  function open() {
    if (!build()) return;
    /* Next frame: the panel has just had `hidden` removed and has no measured
       width yet, so fitting now would be the silent no-op this file exists to
       avoid. */
    requestAnimationFrame(function () {
      refit();
      connect();
      term.focus();
    });
  }

  /* --- Handing a command to the chat --------------------------------------
     Three buttons over one idea: the last command, its output, and where it
     ran. The server renders the block, so the text a model eventually reads
     exists in exactly one place -- and the screen buffer could not produce it
     anyway, holding as it does what is *on screen*, hard-wrapped at the
     terminal's width with no way to tell a wrap from a newline. */

  function applyIntegration() {
    if (!panel) return;
    var auto = panel.querySelector("[data-terminal-auto]");
    if (!auto) return;
    var usable = integration === "live";
    auto.disabled = !usable;
    auto.title = usable
      ? "What to do with each command you run"
      : "This shell did not load LLeMbas's command markers, so there is no way " +
        "to tell where one command's output ends.";
    if (!usable && autoMode !== "off") setAuto("off");
  }

  function showLast(command) {
    lastCommand = command || null;
    var slot = panel && panel.querySelector("[data-terminal-last]");
    if (!slot) return;
    // textContent, always: this is a command line off somebody's machine.
    slot.textContent = lastCommand ? lastCommand.summary : "";
  }

  var AUTO_SAID = {
    off: "Commands are no longer attached automatically.",
    copy: "Every command you run will be put into the message box.",
    send: "Every command you run will be sent as a message on its own."
  };

  function setAuto(mode) {
    autoMode = AUTO_SAID[mode] ? mode : "off";
    var select = panel && panel.querySelector("[data-terminal-auto]");
    if (select && select.value !== autoMode) select.value = autoMode;
    say(AUTO_SAID[autoMode]);
  }

  /* Sent, not typed. The composer is left entirely alone -- somebody may be
     half-way through a sentence in it, and overwriting that is the complaint
     this replaces. The thread receives whatever the server decides the message
     is: a streaming pair, or a single queued bubble if a reply is already being
     written. Nothing here needs to know which. */
  function sendStraightToChat(text) {
    var url = panel.dataset.url.replace(/\/terminal\/ws$/, "/messages");
    if (!window.htmx) return;
    window.htmx.ajax("POST", url, {
      target: "#thread",
      swap: "beforeend",
      values: { content: text }
    });
  }

  /* A selection always wins, in every state. People rely on it, and it is the
     only way to send part of something. */
  function selected() {
    var text = term ? term.getSelection() : "";
    return text && text.trim() ? text : "";
  }

  function scraped() {
    var lines = [];
    var buffer = term.buffer.active;
    var last = buffer.baseY + buffer.cursorY;
    for (var y = Math.max(0, last - 40); y <= last; y++) {
      var line = buffer.getLine(y);
      if (line) lines.push(line.translateToString(true));
    }
    return lines.join("\n").replace(/\n+$/, "");
  }

  /* Fetches the rendered block, or falls back to the screen. `quiet` is the
     auto path, which must not narrate every command it collects. */
  function capture(quiet) {
    var chosen = selected();
    if (chosen && !quiet) return Promise.resolve("```\n" + chosen + "\n```\n");

    if (integration !== "live") {
      if (quiet) return Promise.resolve("");
      var text = scraped();
      if (!text.trim()) {
        say("Nothing to send: select some output first.");
        return Promise.resolve("");
      }
      /* Said plainly rather than dressed up. Without markers this is the last
         forty rows as they appeared, wraps and all, and pretending otherwise
         would put a precise-looking block in front of a model that is not. */
      say("Copied the last of the screen, as it appeared. This shell does not " +
          "mark where commands begin.");
      return Promise.resolve("```\n" + text + "\n```\n");
    }

    return fetch(panel.dataset.url.replace(/\/ws$/, "/last"), { credentials: "same-origin" })
      .then(function (response) { return response.json(); })
      .then(function (body) {
        if (!body.ok) {
          if (!quiet) say(body.message || "Nothing to send yet.");
          return "";
        }
        return body.text + "\n";
      })
      .catch(function () {
        if (!quiet) say("Could not read the last command.", true);
        return "";
      });
  }

  function intoComposer(text, quiet) {
    if (!text) return;
    var input = document.querySelector("[data-composer-input]");
    if (!input) return;
    input.value = input.value ? input.value.replace(/\s*$/, "\n\n") + text : text;
    if (window.lembas && window.lembas.autosize) window.lembas.autosize(input);
    if (window.lembas && window.lembas.paintComposer) window.lembas.paintComposer();
    /* Auto-send never steals focus: it fires while somebody is typing in the
       terminal, and yanking the caret out of a shell mid-command is the sort
       of thing that gets a feature switched off for good. */
    if (!quiet) input.focus();
  }

  function sendToChat() {
    if (!term) return;
    capture(false).then(function (text) {
      if (!text) return;
      intoComposer(text, false);
      /* Into the composer, never sent. What a machine printed is exactly the
         sort of text somebody should read before a model does, and the box is
         where that happens. */
      if (integration === "live") say("Put into the message box. It is not sent yet.");
    });
  }

  function copyToClipboard() {
    if (!term) return;
    capture(false).then(function (text) {
      if (!text) return;
      if (window.lembas && window.lembas.copyText) {
        window.lembas.copyText(text);
        say("Copied.");
      }
    });
  }

  /* --- Wiring ------------------------------------------------------------- */
  function start() {
    panel = document.querySelector("[data-terminal]");
    if (!panel) return;
    screen = panel.querySelector("[data-terminal-screen]");
    messageEl = panel.querySelector("[data-terminal-message]");

    panel.addEventListener("lembas:toggle", function (event) {
      if (event.detail && event.detail.open) open();
      /* Closing leaves the Terminal object and the socket alone. `write()` is
         internally queued, so disposing mid-output drops it, and keeping the
         object is what makes reopening instant. The session on the far side
         outlives this panel by design. */
    });

    panel.addEventListener("click", function (event) {
      if (event.target.closest("[data-terminal-send]")) {
        event.preventDefault();
        return sendToChat();
      }
      if (event.target.closest("[data-terminal-copy]")) {
        event.preventDefault();
        return copyToClipboard();
      }
    });

    panel.addEventListener("change", function (event) {
      var select = event.target.closest("[data-terminal-auto]");
      if (select) setAuto(select.value);
    });

    /* xterm holds colours as values, not as variables, so a theme change has
       to be pushed into it. */
    document.addEventListener("lembas:theme", function () {
      if (term) term.options.theme = readTheme();
    });

    /* The panel now belongs to a different shell: the new-chat screen changed
       connection or directory, so `dataset.url` has been rewritten and the
       socket is pointed at the wrong machine. Closing is deliberate -- hence
       the flag, which is what stops "Disconnected" being reported for something
       nobody lost -- and the screen is cleared because this really is a
       different shell, unlike a reconnect to the same one.

       Reconnecting is left to the panel being opened, so a target changed while
       the terminal is shut costs nothing. */
    window.lembas = window.lembas || {};
    window.lembas.repointTerminal = function () {
      if (socket) {
        closedOnPurpose = true;
        socket.close();
        socket = null;
      }
      if (term) term.reset();
      if (panel && !panel.hidden && panel.dataset.url) connect();
    };
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", start);
  } else {
    start();
  }
})();
