import { spawn } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify';
import * as registry from './registry.js';

const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;

export function sanitizeId(raw: string): string | null {
  if (!ID_RE.test(raw)) return null;
  return raw.toLowerCase();
}

// v1.10.8c: per-pane tmux sessions (boolab pattern). Previously booterm used
// one tmux session per chat-session with one window per pane; that meant the
// session-level window-size policy was shared across panes, and
// `attach-session -d` (used to take over from a stale browser) would detach
// every other pane attached to the same session — the "[detached]" bug.
// Now each pane gets its own tmux session named `bc-<paneId>`. The bc- prefix
// namespaces booterm sessions on the shared tmux server.
export function tmuxSessionName(paneId: string): string {
  return `bc-${paneId}`;
}

interface CmdResult {
  stdout: string;
  stderr: string;
  code: number;
}

function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
  return new Promise((resolve) => {
    const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', (chunk: Buffer) => {
      stdout += chunk.toString('utf8');
    });
    child.stderr.on('data', (chunk: Buffer) => {
      stderr += chunk.toString('utf8');
    });
    child.on('error', (err) => {
      resolve({ stdout, stderr: stderr + String(err), code: 1 });
    });
    child.on('close', (code) => {
      resolve({ stdout, stderr, code: code ?? 0 });
    });
  });
}

export async function hasSession(tmuxConfPath: string, sessionName: string): Promise<boolean> {
  const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
  return res.code === 0;
}

// Default fallback size — wider than any real terminal would care about; the
// real client size lands via the WS resize frame within a few ms of attach.
const DEFAULT_COLS = 200;
const DEFAULT_ROWS = 50;

// v1.10.8d: per-pane shell is `ssh -t samkintop@SSH_HOST` (matches boolab's
// pattern). The container has no docker / claude / opencode binaries; SSH'ing
// to the host gives the user their full normal shell environment. Default is
// the host's Tailscale IP (100.114.205.53) — the hostname `ubuntu-homelab`
// only resolves on the host's local /etc/hosts, not from inside containers,
// so SSH'ing to the hostname fails with `Could not resolve hostname` even
// though the host machine is reachable. Boolab uses the same IP.
const SSH_HOST = process.env['BOOTERM_SSH_HOST']?.trim() || '100.114.205.53';
const SSH_USER = process.env['BOOTERM_SSH_USER']?.trim() || 'samkintop';

// POSIX shell single-quote escape: wrap in '…', escape embedded singles by
// closing-the-quote, inserting an escaped quote, and re-opening.
function shellEscape(s: string): string {
  return `'${s.replace(/'/g, `'\\''`)}'`;
}

// Idempotent. Creates the tmux session if it doesn't exist, sized via -x/-y
// from the client's measured xterm dimensions. With `window-size = largest`
// + `aggressive-resize on` in tmux.conf, the attached client's actual size
// wins once it reports in — but seeding at the right size avoids the brief
// window where bash/TUI inherits the default 80x24 from a stale fallback.
export async function ensureSession(
  tmuxConfPath: string,
  sessionName: string,
  projectRoot: string,
  log: FastifyBaseLogger,
  cols?: number,
  rows?: number,
): Promise<void> {
  if (await hasSession(tmuxConfPath, sessionName)) return;
  const sizeCols = cols && cols > 0 ? Math.floor(cols) : DEFAULT_COLS;
  const sizeRows = rows && rows > 0 ? Math.floor(rows) : DEFAULT_ROWS;
  // Bypass tmux.conf's default-command — build the per-pane argv explicitly
  // so we can wrap ssh in the gosu privilege drop. The remote shell sequence
  // (per boolab's invariants in services/tmux_session.py target_cmd_for):
  //   1. ssh's argv must flatten into a single quoted bash -lc <script>
  //   2. -l on the outer bash sources ~/.profile on the remote (PATH etc.)
  //   3. cd to projectRoot, then exec bash -l so the user lands in the repo
  // /opt is bind-mounted host↔container, so projectRoot resolves to the
  // same files on both sides.
  const remoteScript = `cd ${shellEscape(projectRoot)} && exec bash -l`;
  const remoteCmd = `bash -lc ${shellEscape(remoteScript)}`;
  const argv = [
    'new-session', '-d',
    '-s', sessionName,
    '-c', projectRoot,
    '-x', String(sizeCols),
    '-y', String(sizeRows),
    '--',
    // gosu drops privs from the container's root (tmux server runs as root)
    // to samkintop:samkintop. env restores HOME/USER/SHELL so ssh finds the
    // right ~/.ssh/id_ed25519 (key is mode 0600 and ssh refuses keys whose
    // UID doesn't match the running user — both are 1000 here).
    'gosu', 'samkintop:samkintop',
    'env', 'HOME=/home/samkintop', 'USER=samkintop', 'SHELL=/bin/bash',
    'ssh', '-t',
    '-o', 'StrictHostKeyChecking=yes',
    '-o', 'ServerAliveInterval=30',
    '-o', 'ServerAliveCountMax=3',
    `${SSH_USER}@${SSH_HOST}`,
    remoteCmd,
  ];
  log.info(
    { sessionName, projectRoot, cols: sizeCols, rows: sizeRows, sshTarget: `${SSH_USER}@${SSH_HOST}` },
    'creating tmux session (ssh to host)',
  );
  const res = await runTmux(tmuxConfPath, argv);
  if (res.code !== 0) {
    log.error({ res }, 'tmux new-session failed');
    throw new Error(`tmux new-session failed: ${res.stderr}`);
  }
}

export async function killSession(
  tmuxConfPath: string,
  sessionName: string,
): Promise<boolean> {
  const res = await runTmux(tmuxConfPath, ['kill-session', '-t', sessionName]);
  return res.code === 0;
}

// v1.10.8c: capture-pane on WS attach to replay the buffer state to the fresh
// xterm (boolab pattern). `-e` preserves ANSI escape sequences so colours and
// cursor position survive the replay. Returns empty string on failure — the
// client falls back to whatever tmux itself decides to repaint, which is
// non-fatal but visually noisier.
//
// v1.10.8d: strip trailing blank rows. tmux capture-pane emits one `\n` per
// pane row (including all the empty rows below the actual content), so on a
// fresh 35-row pane with just the bash prompt at row 0, the output is
// `<prompt>` followed by 35 `\n` bytes. When xterm.write()s those naively,
// the cursor advances row-by-row until it hits the bottom of the canvas and
// scrolls — pushing the prompt into the scrollback buffer where the user
// can't see it. Stripping the trailing newlines leaves xterm's cursor at the
// natural end of the rendered content (matching tmux's actual cursor
// position for the common single-line-prompt case).
export async function capturePane(
  tmuxConfPath: string,
  sessionName: string,
  lines: number = 2000,
): Promise<string> {
  const res = await runTmux(tmuxConfPath, [
    'capture-pane', '-t', sessionName, '-p', '-e', '-S', `-${lines}`,
  ]);
  if (res.code !== 0) return '';
  return res.stdout.replace(/(?:\r?\n)+$/, '');
}

/**
 * Sweep the registry for expired sessions and kill the underlying tmux sessions.
 * Logs each kill with the expiry reason (idle timeout vs absolute timeout).
 * Returns the list of paneIds that were killed.
 */
export async function sweepExpired(
  tmuxConfPath: string,
  log: FastifyBaseLogger,
): Promise<string[]> {
  const expired = registry.getTimedOutSessions();
  const killed: string[] = [];
  for (const meta of expired) {
    const reason =
      meta.idleExpiresAt &&
      (!meta.absoluteExpiresAt || meta.idleExpiresAt.getTime() <= meta.absoluteExpiresAt.getTime())
        ? 'idle timeout'
        : 'absolute timeout';
    log.info({ paneId: meta.paneId, reason }, 'sweeping expired PTY session');
    meta.timedOut = true;
    const sessionName = tmuxSessionName(meta.paneId);
    try {
      const ok = await killSession(tmuxConfPath, sessionName);
      if (!ok) {
        log.warn({ paneId: meta.paneId, sessionName }, 'killSession returned false during sweep');
      }
    } catch (err) {
      log.warn({ paneId: meta.paneId, err }, 'killSession threw during sweep');
    }
    killed.push(meta.paneId);
  }
  return killed;
}
