import { join } from 'node:path';
import * as pty from 'node-pty';
import { homedir } from 'node:os';

// The shell store. Everything about running a terminal lives here: what shell, where it opens, how much
// scrollback is kept, who is watching. The platform holds none of it — it authenticates a browser and
// relays bytes to this process, and that is the whole of its involvement.

// What a re-attaching client gets back. 50KB was about one long agent turn, so reconnecting mid-task
// showed you the tail and nothing else. Per session, so ten live shells is ~5MB — cheap next to node-pty.
const BUFFER_MAX = 512 * 1024;

// HOME_DIR mirrors `data-path.ts:getOwnerHomeDir` — on a host where the owner's real login home differs
// from this process's HOME, the shell should open in the former, like every other host-executing surface.
const HOME_DIR = homedir();
const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] };

// A terminal is always a plain host shell: the owner is the only account and it is their own machine
// (`platform/CLAUDE.md` — do not add a jail without being asked).

/**
 * @typedef {{
 *   term: import('node-pty').IPty, buffer: string, cols: number, rows: number,
 *   createdAt: number, lastActivityAt: number, title: string, pid: number|undefined,
 *   clients: Set<{ send: (msg: object) => void }>,
 * }} Session
 */

/** @type {Map<string, Session>} */
const sessions = new Map();

/**
 * Where a shell opens. `base` is the requesting account's home; without one this is the owner's HOME_DIR, as
 * it always was.
 *
 * An absolute `cwd` is honoured as given, for the owner and for a member alike. That is not a hole and not an
 * oversight: the member's shell runs as their uid, so the kernel decides what they can see once they get
 * there. A path check here would be theatre — `cd /` is one keystroke away in any shell.
 */
const resolveCwd = (cwd, base) => {
  const home = base || HOME_DIR;
  if (!cwd || cwd === '~') return home;
  if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
  if (cwd.startsWith('/')) return cwd;
  // Relative paths have no meaning here — this process's cwd is the repo, not the user's folder.
  return home;
};

/**
 * Sequences that make a terminal ANSWER, stripped before anything is stored.
 *
 * The scrollback is replayed verbatim to a re-attaching client. Anything in it that asks the terminal a
 * question gets asked AGAIN on every reconnect — and xterm answers, correctly, by writing the reply to its
 * input. That input is a keystroke as far as the pty is concerned, so a reconnect injects text into the
 * shell that nobody typed: `^[[?62;c` and friends landing on the command line, or being eaten by whatever
 * TUI is running. It is the "terminal goes weird after reconnecting" symptom, and it is not the shell's
 * fault.
 *
 * Stripping on the way IN rather than on the way out: the buffer is the thing that gets replayed, and a
 * live client has already answered these once, at the moment they were legitimately asked.
 *
 * What is removed is only ever a QUESTION. Colour, cursor movement, screen clears — everything that draws —
 * is untouched, so a replay still reproduces the screen exactly.
 */
// Each pattern is the QUERY form only. Where a control shares its final byte with a command that DRAWS,
// the numeric parameter is enumerated rather than wildcarded — `CSI 18 t` asks the window size, but
// `CSI 22 t` pushes the title, and stripping the second would silently change what a replay renders.
const QUERY_SEQUENCES = [
  /\x1b\[\??[56]n/g, // DSR — cursor position (6n), status (5n), and the DEC `?` variants
  /\x1b\[[0-9;?>=]*c/g, // DA1/DA2/DA3 — device attributes. `c` is only ever a query.
  /\x1b\[\?[0-9;]*\$p/g, // DECRQM — mode query
  /\x1b\[(?:1[1345689]|2[01])(?:;[0-9]+)*t/g, // XTWINOPS reports only — NOT 22/23 (title push/pop)
  /\x1b\[>[0-9;]*q/g, // XTVERSION
  /\x1bP\+q[0-9a-fA-F;]*(?:\x1b\\|\x07)/g, // DCS XTGETTCAP — terminfo capability query
  /\x1b\](?:10|11|12|4;[0-9]+);\?(?:\x07|\x1b\\)/g, // OSC colour queries (fg/bg/cursor/palette)
];

/** Exported for the test: this is the one function here whose mistakes are invisible until a replay. */
export const stripQueries = (data) => QUERY_SEQUENCES.reduce((out, re) => out.replace(re, ''), data);

const appendBuffer = (session, data) => {
  session.buffer += stripQueries(data);
  if (session.buffer.length > BUFFER_MAX) {
    // Cut on a line boundary, not a byte offset. A blind slice can land inside an escape sequence, and the
    // replay then opens with the tail of a colour or cursor-move code — which xterm renders as garbage, or
    // worse, applies as a real instruction. Fall back to the raw cut when a single line is enormous.
    const cut = session.buffer.length - BUFFER_MAX;
    const nl = session.buffer.indexOf('\n', cut);
    session.buffer = nl !== -1 && nl - cut < 4096 ? session.buffer.slice(nl + 1) : session.buffer.slice(cut);
  }
};

const broadcast = (session, msg) => {
  for (const client of session.clients) {
    try {
      client.send(msg);
    } catch {
      // client went away between the check and the write
    }
  }
};

/**
 * The argv that opens a shell as `osUser`, or as this process when there is none.
 *
 * Mirrors `runAs` in src/servers/os-user.ts, and cannot import it: this sidecar runs under node (node-pty
 * binds a native addon against node's ABI) while that file is Bun/TypeScript. If you change one, change both
 * — the flags are load-bearing and the reasoning for each is documented there.
 *
 * node-pty does support `uid`/`gid` options, unlike Bun.spawn. They are deliberately NOT used: they set the
 * ids without applying the account's supplementary groups or resetting the environment, so the shell would
 * keep the OWNER's groups and the platform's entire env — including everything Bun loaded from `.env`.
 *
 * `--reset-env` keeps TERM (per setpriv(1)) and sets HOME/SHELL/USER/LOGNAME/PATH from the target's passwd
 * entry. COLORTERM does not survive it, so it is re-stated through `env` — it is how programs decide they may
 * emit 24-bit colour.
 */
function shellArgv(osUser) {
  if (!osUser) return { command: SHELL.command, args: SHELL.args };
  return {
    command: 'sudo',
    args: [
      '-n',
      'setpriv',
      `--reuid=${osUser}`,
      `--regid=${osUser}`,
      '--init-groups',
      '--reset-env',
      '--',
      // THEIR login shell, from their passwd entry — not this sidecar's `$SHELL`, which is whatever PM2 was
      // started with. `--reset-env` has already set SHELL from passwd, so the indirection through `sh -c` is
      // what reads it; there is no shell in an argv to expand a variable otherwise.
      //
      // `-i` rather than `-l`: interactive is what makes zsh read ~/.zshrc, which is the file the shell
      // template writes. COLORTERM does not survive --reset-env and is restated here — it is how programs
      // decide they may emit 24-bit colour.
      'sh',
      '-c',
      'COLORTERM=truecolor exec "$SHELL" -i',
    ],
  };
}

/**
 * Attach a client to a session, spawning the shell if this is the first time we've seen the id.
 *
 * `osUser` and `home` come from the PLATFORM, which resolves them from the authenticated account — never from
 * anything the browser sent. This socket binds loopback and only the platform's bridge reaches it, which is
 * the same trust model as the `X-Officer-User` header on the HTTP sidecars.
 */
export function attach(sessionId, client, { cwd, cols, rows, osUser, home } = {}) {
  let session = sessions.get(sessionId);

  if (session) {
    // A session belongs to whoever started it. Re-attaching is only allowed for the same account, or this is
    // how one member resumes another's shell by guessing a session id — and the id is in a query string.
    if ((session.osUser ?? null) !== (osUser ?? null)) {
      client.send({ type: 'output', data: '\r\n[Terminal error] that session belongs to another account\r\n' });
      client.send({ type: 'exit', exitCode: 1 });
      return null;
    }
    session.clients.add(client);
    // Scrollback goes out as `replay`, not as ordinary output: the client may already be showing some of
    // it, so it resets and rebuilds from this rather than appending a second copy.
    if (session.buffer.length > 0) client.send({ type: 'replay', data: session.buffer });
    if (cols > 0 && rows > 0 && (cols !== session.cols || rows !== session.rows)) resize(sessionId, cols, rows);
    return session;
  }

  const spawnCols = cols > 0 ? cols : 80;
  const spawnRows = rows > 0 ? rows : 24;

  let term;
  try {
    const { command, args } = shellArgv(osUser);
    term = pty.spawn(command, args, {
      name: 'xterm-256color',
      cols: spawnCols,
      rows: spawnRows,
      // A member's shell starts in THEIR home, not the owner's. `resolveCwd` resolves `~` and relative paths
      // against HOME_DIR, which is the owner's — so the base has to be passed in for anyone else.
      cwd: resolveCwd(cwd, home),
      // For a member this env reaches `sudo` and `setpriv`, and `--reset-env` clears it before the shell:
      // see shellArgv. For the owner it is the shell's environment as before.
      env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Failed to start terminal';
    client.send({ type: 'output', data: `\r\n[Terminal error] ${message}\r\n` });
    client.send({ type: 'exit', exitCode: 1 });
    return null;
  }

  const now = Date.now();
  session = {
    term,
    buffer: '',
    cols: spawnCols,
    rows: spawnRows,
    createdAt: now,
    lastActivityAt: now,
    title: '',
    pid: term.pid,
    /** Whose shell this is, or null for the owner. Read by `list` and `kill` so one account cannot see or
     *  end another's — the gap TODO.md called the pty sidecar's identity blindness. */
    osUser: osUser ?? null,
    clients: new Set([client]),
  };
  sessions.set(sessionId, session);

  term.onData((output) => {
    appendBuffer(session, output);
    session.lastActivityAt = Date.now();
    // The title the shell sets for itself (OSC 0/2 — usually the running command). It is what turns "some
    // uuid" into "the one running claude" in the session list.
    const titleMatch = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/.exec(output);
    if (titleMatch) session.title = titleMatch[1];
    broadcast(session, { type: 'output', data: output });
  });

  term.onExit(({ exitCode, signal }) => {
    console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
    broadcast(session, { type: 'exit', exitCode, signal });
    sessions.delete(sessionId);
  });

  return session;
}

/** A client went away. The shell stays: it is re-attachable, and killing it is an explicit act. */
export function detach(sessionId, client) {
  sessions.get(sessionId)?.clients.delete(client);
}

export function write(sessionId, data) {
  sessions.get(sessionId)?.term.write(data ?? '');
}

export function resize(sessionId, cols, rows) {
  const session = sessions.get(sessionId);
  if (!session || !(cols > 0) || !(rows > 0)) return;
  session.cols = cols;
  session.rows = rows;
  try {
    session.term.resize(cols, rows);
  } catch {
    // the shell died between the lookup and the resize
  }
}

/**
 * End a session. `osUser` scopes it: pass an account name and only that account's sessions can be killed;
 * pass nothing and it is the owner, who may kill any.
 *
 * Undefined and null mean different things here, which is why the check is explicit. `undefined` is "the
 * caller did not scope this" (the owner). `null` is "the caller is the owner's own shell", which is a real
 * value a member must not match.
 */
export function kill(sessionId, osUser) {
  const session = sessions.get(sessionId);
  if (!session) return false;
  if (osUser !== undefined && (session.osUser ?? null) !== osUser) return false;
  try {
    session.term.kill();
  } catch {
    // already gone
  }
  sessions.delete(sessionId);
  return true;
}

/** Sessions, scoped the same way as `kill`: an account name sees only its own, nothing sees everything. */
export function list(osUser) {
  return [...sessions.entries()]
    .filter(([, s]) => osUser === undefined || (s.osUser ?? null) === osUser)
    .map(([sessionId, s]) => ({
      sessionId,
      cols: s.cols,
      rows: s.rows,
      createdAt: s.createdAt,
      lastActivityAt: s.lastActivityAt,
      title: s.title || undefined,
      pid: s.pid,
      clients: s.clients.size,
      osUser: s.osUser ?? undefined,
    }));
}

export function killAll() {
  for (const session of sessions.values()) {
    try {
      session.term.kill();
    } catch {
      // ignore
    }
  }
  sessions.clear();
}
