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

// 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 = process.env.HOME_DIR ?? process.env.HOME ?? process.cwd();
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();

const resolveCwd = (cwd) => {
  if (!cwd || cwd === '~') return HOME_DIR;
  if (cwd.startsWith('~/')) return join(HOME_DIR, 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_DIR;
};

const appendBuffer = (session, data) => {
  session.buffer += 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
    }
  }
};

/** Attach a client to a session, spawning the shell if this is the first time we've seen the id. */
export function attach(sessionId, client, { cwd, cols, rows } = {}) {
  let session = sessions.get(sessionId);

  if (session) {
    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 {
    term = pty.spawn(SHELL.command, SHELL.args, {
      name: 'xterm-256color',
      cols: spawnCols,
      rows: spawnRows,
      cwd: resolveCwd(cwd),
      // COLORTERM is how programs decide they may emit 24-bit colour — TERM only advertises 256.
      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,
    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
  }
}

export function kill(sessionId) {
  const session = sessions.get(sessionId);
  if (!session) return false;
  try {
    session.term.kill();
  } catch {
    // already gone
  }
  sessions.delete(sessionId);
  return true;
}

export function list() {
  return [...sessions.entries()].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,
  }));
}

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