'use strict';

/*
  The renderer half of the terminal data plane.

  Two things here are load-bearing and easy to get wrong:

  1. Output is acknowledged from term.write's completion callback, never on receipt. The
     acknowledgement returns flow-control credit to the host, so acknowledging early would tell
     the host the screen has caught up when it has not — and the whole point of the credit
     window is that it reflects what has actually been rendered. Acknowledge on receipt and a
     remote running `yes` grows this page's memory until the tab dies.

  2. Output is written as a Uint8Array, not a string. xterm decodes UTF-8 itself and carries
     partial sequences across writes. Decoding here would corrupt any multi-byte character that
     happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
     conditions that are hardest to reproduce.
*/

const SERVER_OUTPUT = 1;
const SERVER_SESSION_OPENED = 2;
const SERVER_SESSION_CLOSED = 3;
const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const SERVER_PASTE = 6;

const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
const CLIENT_RESIZE = 3;

const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;

/*
  The way out of the terminal, for someone using only a keyboard.

  It has to be handled here rather than by the host: once this page's window owns Win32 focus, the
  host's Avalonia window receives no key events at all, so nothing on that side could hear a shortcut.

  Ctrl+Shift+F6 rather than Escape. F6 is the Windows convention for moving to the next pane, but a
  bare F6 is a real terminal key that TUIs bind — as is Escape, which vim alone rules out. Ctrl+Shift
  is the range terminal emulators conventionally keep for themselves and never forward to the remote,
  so qualifying F6 with it keeps the convention without taking a key away from the remote shell.
*/
const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';

const root = document.getElementById('root');
const statusBanner = document.getElementById('status');

/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */
const sessions = new Map();

/** @type {WebSocket | null} */
let socket = null;

function setStatus(text) {
  statusBanner.textContent = text ?? '';
}

/** Builds a frame: opcode, big-endian session id, then payload. */
function frame(opcode, sessionId, payload) {
  const body = payload ?? new Uint8Array(0);
  const buffer = new ArrayBuffer(HEADER_LENGTH + body.length);
  const view = new DataView(buffer);

  view.setUint8(0, opcode);
  view.setUint32(1, sessionId);
  new Uint8Array(buffer, HEADER_LENGTH).set(body);

  return buffer;
}

function send(opcode, sessionId, payload) {
  if (socket && socket.readyState === WebSocket.OPEN) {
    socket.send(frame(opcode, sessionId, payload));
  }
}

function sendAcknowledgement(sessionId, byteCount) {
  const payload = new Uint8Array(4);
  new DataView(payload.buffer).setUint32(0, byteCount);
  send(CLIENT_ACKNOWLEDGE, sessionId, payload);
}

function sendResize(sessionId, term, pane) {
  const payload = new Uint8Array(8);
  const view = new DataView(payload.buffer);

  // Columns before rows, matching the SSH request. Swapping them produces a terminal that is 24
  // columns by 80 rows, which reads as a rendering bug rather than a protocol one.
  view.setUint16(0, term.cols);
  view.setUint16(2, term.rows);
  view.setUint16(4, pane.clientWidth);
  view.setUint16(6, pane.clientHeight);

  send(CLIENT_RESIZE, sessionId, payload);
}

/**
 * Asks the host to take keyboard focus back.
 *
 * Optional by design: the bridge only exists under a real embedded WebView, and this page is also
 * openable in a plain browser for debugging, where there is no host to ask.
 */
function releaseFocusToHost() {
  window.chrome?.webview?.postMessage(RELEASE_FOCUS_MESSAGE);
}

/**
 * Swallows the release-focus shortcut so it never reaches the remote.
 *
 * Returning false stops xterm processing the event, which is what keeps the chord from being encoded
 * and written to the pty.
 */
function handleKey(event) {
  if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && event.key === 'F6') {
    releaseFocusToHost();
    return false;
  }

  return true;
}

function createSession(sessionId) {
  const pane = document.createElement('div');
  pane.className = 'pane';
  pane.dataset.sessionId = String(sessionId);
  root.appendChild(pane);

  const term = new Terminal({
    allowProposedApi: true,
    convertEol: false,
    cursorBlink: true,
    fontSize: 13,
    scrollback: SCROLLBACK_LINES,
    // Matches terminal.css, so the canvas and the page agree on the background.
    theme: { background: '#10131a', foreground: '#d5d8de' },
  });

  const fit = new FitAddon.FitAddon();
  term.loadAddon(fit);
  term.open(pane);

  // WebGL where it is available. Falling back rather than failing matters because a software
  // renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs
  // routinely have no usable GPU context.
  try {
    term.loadAddon(new WebglAddon.WebglAddon());
  } catch (error) {
    console.warn('WebGL renderer unavailable; falling back to canvas.', error);
  }

  term.attachCustomKeyEventHandler(handleKey);

  term.onData((data) => {
    send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
  });

  term.onResize(() => sendResize(sessionId, term, pane));

  const session = { term, fit, pane };
  sessions.set(sessionId, session);

  activate(sessionId);
  resize(session, sessionId);

  return session;
}

function activate(sessionId) {
  for (const [id, session] of sessions) {
    session.pane.dataset.active = String(id === sessionId);
  }

  const active = sessions.get(sessionId);
  if (active) {
    active.term.focus();
  }
}

// Below this, a pane is not being looked at — it is minimised or dragged to nothing. Fitting anyway would
// be actively harmful rather than merely useless: the fit addon floors its proposal at 2 columns by 1 row,
// so a degenerate viewport reflows the *remote* pty to 2x1 through window-change, and the wrapped
// scrollback that produces cannot be recovered when the pane comes back. A guard rather than a fix for one
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
// once splits land.
//
// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
// does not change, no observer fires and this function is never called — measured with a live shell, and
// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
const MINIMUM_FITTABLE_PIXELS = 40;

function resize(session, sessionId) {
  const pane = session.pane;

  if (pane.clientWidth < MINIMUM_FITTABLE_PIXELS || pane.clientHeight < MINIMUM_FITTABLE_PIXELS) {
    return;
  }

  // fit() throws if the pane has no layout yet, which happens on the very first frame.
  try {
    session.fit.fit();
    sendResize(sessionId, session.term, session.pane);
  } catch (error) {
    console.warn('Could not fit the terminal yet.', error);
  }
}

function handleFrame(buffer) {
  if (buffer.byteLength < HEADER_LENGTH) {
    return;
  }

  const view = new DataView(buffer);
  const opcode = view.getUint8(0);
  const sessionId = view.getUint32(1);
  const payload = new Uint8Array(buffer, HEADER_LENGTH);

  switch (opcode) {
    case SERVER_SESSION_OPENED:
      createSession(sessionId);
      setStatus('');
      break;

    case SERVER_OUTPUT: {
      const session = sessions.get(sessionId) ?? createSession(sessionId);

      // The callback is the acknowledgement, and payload.length is the byte count the host
      // reserved credit for. Both must be the raw byte length, not a decoded character count.
      session.term.write(payload, () => sendAcknowledgement(sessionId, payload.length));
      break;
    }

    case SERVER_SESSION_ACTIVATED: {
      const session = sessions.get(sessionId);

      // Ignored for a pane that does not exist. The host sends this when a tab is selected, and a tab
      // whose session ended still has its pane — but one the host knows about and this page has not
      // created yet cannot be shown, and inventing an empty terminal for it would be worse than waiting
      // for the SessionOpened frame that is already on its way.
      if (!session) {
        break;
      }

      activate(sessionId);

      // Refitted on activation, not only on resize. A hidden pane has no layout, so every resize while
      // it was hidden was skipped by the guard in resize() — meaning it comes back holding whatever
      // geometry it had when it was last visible, and the remote pty is still sized to match.
      resize(session, sessionId);
      break;
    }

    case SERVER_SESSION_REMOVED: {
      const session = sessions.get(sessionId);

      if (!session) {
        break;
      }

      /*
        The tab is gone, so the pane goes with it — and this is the only place that is true. A shell that
        ended on its own keeps its pane, because the last thing the remote said is usually why it ended;
        a tab the user closed has nothing left to read.

        term.dispose() is what actually matters. It releases the WebGL context, and a browser hands out
        about sixteen of those: without this, a day of opening and closing terminals ends with panes that
        cannot get a renderer, and nothing outside this page would ever say why.
      */
      session.term.dispose();
      session.pane.remove();
      sessions.delete(sessionId);

      setStatus('');
      break;
    }

    case SERVER_PASTE: {
      const session = sessions.get(sessionId);

      if (!session || payload.length < 1) {
        break;
      }

      const execute = payload[0] !== 0;
      const text = new TextDecoder().decode(payload.subarray(1));

      /*
        term.paste rather than term.input, and that is the whole reason this frame exists rather than
        the host writing the bytes into the pump. paste() wraps the text in bracketed-paste markers
        when the remote has turned that mode on — xterm tracks \e[?2004h from the output stream, which
        is something only this page sees — and a shell that receives a multi-line command inside those
        markers treats every newline as text. Without them it treats each one as "run this", so a
        three-line snippet runs three commands the moment it is inserted.
      */
      session.term.paste(text);

      /*
        And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
        pasted text would be bracketed along with it and arrive at the shell as a literal carriage
        return, so nothing would run — which is the failure that looks like the feature working right
        up until somebody wonders why RUN does not.
      */
      if (execute) {
        session.term.input('\r');
      }

      break;
    }

    case SERVER_SESSION_CLOSED: {
      const session = sessions.get(sessionId);
      const reason = new TextDecoder().decode(payload);

      if (session) {
        // The pane and its scrollback stay. The user was probably reading the last thing the
        // remote said, and that is usually why the session ended.
        session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
        session.term.options.cursorBlink = false;
      }

      setStatus(reason);
      break;
    }

    default:
      // A newer host than this page. Ignored rather than fatal.
      break;
  }
}

function connect() {
  const token = root.dataset.token;
  const url = root.dataset.socket;

  // The token travels as a subprotocol rather than a query parameter, which keeps it out of
  // anything that logs URLs.
  socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
  socket.binaryType = 'arraybuffer';

  socket.addEventListener('open', () => setStatus(''));
  socket.addEventListener('message', (event) => handleFrame(event.data));

  socket.addEventListener('close', () => {
    setStatus('Disconnected from DodoSSH.');
  });

  socket.addEventListener('error', () => {
    setStatus('The terminal connection failed.');
  });
}

// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
// window is being dragged, and a single callback coalesces them for free.
new ResizeObserver(() => {
  for (const [sessionId, session] of sessions) {
    resize(session, sessionId);
  }
}).observe(root);

window.addEventListener('beforeunload', () => socket?.close());

connect();
