#!/usr/bin/env node
/**
 * forge-server — Start the Forge web platform.
 *
 * Usage:
 *   forge-server                          Start in foreground (production)
 *   forge-server --dev                    Start in foreground (development)
 *   forge-server --background             Start in background
 *   forge-server --stop                   Stop background server
 *   forge-server --restart                Stop + start (safe for remote)
 *   forge-server --rebuild                Force rebuild
 *   forge-server --port 4000              Custom web port (default: 8403)
 *   forge-server --terminal-port 4001     Custom terminal port (default: 8404)
 *   forge-server --dir ~/.forge-test      Custom data directory (default: ~/.forge)
 *   forge-server --reset-terminal         Kill terminal server before start (loses tmux sessions)
 *
 * Examples:
 *   forge-server --background --port 4000 --terminal-port 4001 --dir ~/.forge-staging
 *   forge-server --restart
 */

import { execSync, spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');

/** Build Next.js — install devDependencies first if missing */
function buildNext() {
  // Check if devDependencies are installed (e.g. @tailwindcss/postcss)
  if (!existsSync(join(ROOT, 'node_modules', '@tailwindcss', 'postcss'))) {
    console.log('[forge] Installing dependencies...');
    // --legacy-peer-deps: next-auth@5-beta + its nested @auth/core declare
    // contradictory peerOptional nodemailer requirements (v7 vs v6) — both
    // optional, but npm 10+ treats this as a hard ERESOLVE error. Same
    // policy as the repo's .npmrc, baked in here so it works even in the
    // installed npm-package copy (.npmrc isn't published).
    execSync('npm install --include=dev --legacy-peer-deps', { cwd: ROOT, stdio: 'inherit' });
  }
  execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
}

/**
 * Build if missing OR if the installed npm package version is newer than
 * what produced the cached .next/. Without this version check, `forge server
 * restart` (and any code path that doesn't go through the explicit rebuild
 * block) would happily serve a stale .next built against the previous
 * version — HTML referencing chunk hashes that no longer exist, → CSS/JS
 * 500s in the browser. Local devs don't hit this because they always
 * `forge server rebuild`; downstream users hitting `npm i -g + restart` do.
 */
function ensureBuilt() {
  const buildIdPath = join(ROOT, '.next', 'BUILD_ID');
  const versionFile = join(ROOT, '.next', '.forge-version');
  const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
  const lastBuilt = existsSync(versionFile) ? readFileSync(versionFile, 'utf-8').trim() : '';

  if (existsSync(buildIdPath) && lastBuilt === pkgVersion) return;

  if (existsSync(buildIdPath)) {
    console.log(`[forge] Upgrade detected (${lastBuilt || '(no marker)'} → v${pkgVersion}) — rebuilding .next/`);
    execSync('rm -rf .next', { cwd: ROOT });
  } else {
    console.log(`[forge] Building v${pkgVersion}...`);
  }
  buildNext();
  try { writeFileSync(versionFile, pkgVersion); } catch {}
}

// ── Parse arguments ──

function getArg(name) {
  const idx = process.argv.indexOf(name);
  if (idx === -1 || idx + 1 >= process.argv.length) return null;
  return process.argv[idx + 1];
}

// ── Version ──
if (process.argv.includes('--version') || process.argv.includes('-v')) {
  const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
  console.log(`@aion0/forge v${pkg.version}`);
  process.exit(0);
}

const isDev = process.argv.includes('--dev');
const isForeground = process.argv.includes('--foreground');
const isBackground = !isForeground && !isDev; // default background unless --foreground or --dev
const isStop = process.argv.includes('--stop');
const isRestart = process.argv.includes('--restart');
const isRebuild = process.argv.includes('--rebuild');
const resetTerminal = process.argv.includes('--reset-terminal');
const resetPassword = process.argv.includes('--reset-password');
const addEnterpriseKeyOnly = process.argv.includes('--add-enterprise-key');

// ── Enterprise keys (--enterprise-key=…) ──
//
// Collected here so they're seen at startup and (when --add-enterprise-key
// is also passed) we can persist + exit without spinning up the server.
// Supports both `--enterprise-key=foo` and `--enterprise-key foo` forms;
// repeat for multiple keys.
function collectEnterpriseKeys() {
  const keys = [];
  for (let i = 0; i < process.argv.length; i++) {
    const arg = process.argv[i];
    if (arg.startsWith('--enterprise-key=')) {
      keys.push(arg.slice('--enterprise-key='.length));
    } else if (arg === '--enterprise-key' && i + 1 < process.argv.length) {
      keys.push(process.argv[i + 1]);
      i += 1;
    }
  }
  return keys.map((k) => k.trim()).filter(Boolean);
}
const enterpriseKeysFromArgv = collectEnterpriseKeys();

const webPort = parseInt(getArg('--port')) || 8403;
const terminalPort = parseInt(getArg('--terminal-port')) || (webPort + 1);
const workspacePort = parseInt(getArg('--workspace-port')) || (webPort + 2);
// MCP server (inside workspace-standalone) was hardcoded to 8406, so a
// second instance (dev-test on a different webPort) collided with the
// main instance. Offset from webPort like every other service.
const mcpPort = parseInt(getArg('--mcp-port')) || (webPort + 3);
const DATA_DIR = getArg('--dir')?.replace(/^~/, homedir()) || join(homedir(), '.forge', 'data');

const PID_FILE = join(DATA_DIR, 'forge.pid');
const PIDS_FILE = join(DATA_DIR, 'forge.pids'); // all child process PIDs
const LOG_FILE = join(DATA_DIR, 'forge.log');

process.chdir(ROOT);

// ── Migrate old layout (~/.forge/*) to new (~/.forge/data/*) ──
if (!getArg('--dir')) {
  const oldSettings = join(homedir(), '.forge', 'settings.yaml');
  const newSettings = join(DATA_DIR, 'settings.yaml');
  if (existsSync(oldSettings) && !existsSync(newSettings)) {
    console.log('[forge] Migrating data from ~/.forge/ to ~/.forge/data/...');
    mkdirSync(DATA_DIR, { recursive: true });
    const migrateFiles = ['settings.yaml', '.encrypt-key', '.env.local', 'session-code.json', 'terminal-state.json', 'tunnel-state.json', 'preview.json', 'forge.pid', 'forge.log'];
    for (const f of migrateFiles) {
      const src = join(homedir(), '.forge', f);
      const dest = join(DATA_DIR, f);
      if (existsSync(src) && !existsSync(dest)) {
        try { const { copyFileSync } = await import('node:fs'); copyFileSync(src, dest); console.log(`  ${f}`); } catch {}
      }
    }
    // data.db → workflow.db
    const oldDb = join(homedir(), '.forge', 'data.db');
    const newDb = join(DATA_DIR, 'workflow.db');
    if (existsSync(oldDb) && !existsSync(newDb)) {
      try { const { copyFileSync } = await import('node:fs'); copyFileSync(oldDb, newDb); console.log('  data.db → workflow.db'); } catch {}
    }
    // Migrate directories
    for (const d of ['flows', 'pipelines']) {
      const src = join(homedir(), '.forge', d);
      const dest = join(DATA_DIR, d);
      if (existsSync(src) && !existsSync(dest)) {
        try { const { renameSync } = await import('node:fs'); renameSync(src, dest); console.log(`  ${d}/`); } catch {}
      }
    }
    console.log('[forge] Migration complete.');
  }
}

mkdirSync(DATA_DIR, { recursive: true });

// ── tmux check (required for browser terminals) ──
if (!isStop) {
  try {
    execSync('command -v tmux', { stdio: ['ignore', 'pipe', 'pipe'] });
  } catch {
    console.error('[forge] tmux is required but was not found on PATH.');
    console.error('[forge] Install it with one of:');
    if (process.platform === 'darwin') {
      console.error('    brew install tmux');
    } else if (process.platform === 'linux') {
      console.error('    sudo apt install -y tmux          # Debian/Ubuntu');
      console.error('    sudo dnf install -y tmux          # Fedora/RHEL');
      console.error('    sudo pacman -S tmux               # Arch');
    } else {
      console.error('    See https://github.com/tmux/tmux/wiki/Installing');
    }
    console.error('[forge] Then re-run: forge server start');
    process.exit(1);
  }
}

// ── Agent CLI check (warn-only — API-only profiles still work) ──
if (!isStop) {
  const has = (bin) => {
    try { execSync(`command -v ${bin}`, { stdio: ['ignore', 'pipe', 'pipe'] }); return true; }
    catch { return false; }
  };
  const hasClaude = has('claude');
  const hasCodex = has('codex');
  const hasAider = has('aider');
  if (!hasClaude && !hasCodex && !hasAider) {
    console.warn('[forge] ⚠️  No agent CLI found (claude / codex / aider).');
    console.warn('[forge] Forge needs at least one to drive terminals, tasks, and workspace agents.');
    console.warn('[forge] Install Claude Code:  npm install -g @anthropic-ai/claude-code');
    console.warn('[forge] Install Codex:        https://github.com/openai/codex#installation');
    console.warn('[forge] Or configure API-only profiles in Settings after login.');
  }

  // ── Optional helpers used by built-in workflows (mr-review-fix etc.).
  // Warn-only: Forge starts fine without them, but specific Jobs/Pipelines
  // will fail at the shell node if they're missing — better to flag here.
  const optional = [
    { bin: 'jq',   used: 'shell pipelines that parse JSON output' },
    { bin: 'glab', used: 'GitLab MR review / mr-review-fix workflow' },
  ];
  const missing = optional.filter((o) => !has(o.bin));
  if (missing.length) {
    console.warn(`[forge] ⚠️  Optional CLIs missing: ${missing.map((m) => m.bin).join(', ')}`);
    for (const m of missing) console.warn(`[forge]    - ${m.bin}: used by ${m.used}`);
    if (process.platform === 'darwin') {
      console.warn(`[forge] Install with: brew install ${missing.map((m) => m.bin).join(' ')}`);
    } else if (process.platform === 'linux') {
      console.warn('[forge] Install via your distro\'s package manager.');
      if (missing.some((m) => m.bin === 'glab')) {
        console.warn('[forge]    glab: see https://gitlab.com/gitlab-org/cli#installation');
      }
    }
  }
}

// ── Load <data-dir>/.env.local ──
const envFile = join(DATA_DIR, '.env.local');
if (existsSync(envFile)) {
  for (const line of readFileSync(envFile, 'utf-8').split('\n')) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const eq = trimmed.indexOf('=');
    if (eq === -1) continue;
    const key = trimmed.slice(0, eq).trim();
    const val = trimmed.slice(eq + 1).trim();
    if (!process.env[key]) process.env[key] = val;
  }
}

// Set env vars for Next.js and terminal server
process.env.PORT = String(webPort);
process.env.TERMINAL_PORT = String(terminalPort);
process.env.WORKSPACE_PORT = String(workspacePort);
process.env.MCP_PORT = String(mcpPort);
process.env.FORGE_DATA_DIR = DATA_DIR;

// ── Password setup (first run or --reset-password) ──
// Skipped in --add-enterprise-key persist-and-exit mode: registering a
// key from install.sh must not block on an interactive prompt.
if (!isStop && !addEnterpriseKeyOnly) {
  const YAML = await import('yaml');
  const settingsFile = join(DATA_DIR, 'settings.yaml');
  let settings = {};
  try { settings = YAML.parse(readFileSync(settingsFile, 'utf-8')) || {}; } catch {}

  const hasPassword = !!settings.telegramTunnelPassword;

  if (resetPassword || !hasPassword) {
    if (resetPassword) {
      console.log('[forge] Password reset requested');
    } else {
      console.log('[forge] First run — please set an admin password');
    }

    const readline = await import('node:readline');
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    const ask = (q) => new Promise((resolve, reject) => {
      rl.question(q, resolve);
      rl.once('close', () => reject(new Error('cancelled')));
    });

    let pw = '';
    try {
      while (true) {
        pw = await ask('  Enter admin password: ');
        if (!pw || pw.length < 4) {
          console.log('  Password must be at least 4 characters');
          continue;
        }
        const confirm = await ask('  Confirm password: ');
        if (pw !== confirm) {
          console.log('  Passwords do not match, try again');
          continue;
        }
        break;
      }
    } catch {
      console.log('\n[forge] Cancelled');
      process.exit(0);
    }
    rl.close();

    // Encrypt and save
    const crypto = await import('node:crypto');
    const KEY_FILE = join(DATA_DIR, '.encrypt-key');
    let encKey;
    if (existsSync(KEY_FILE)) {
      encKey = Buffer.from(readFileSync(KEY_FILE, 'utf-8').trim(), 'hex');
    } else {
      encKey = crypto.randomBytes(32);
      writeFileSync(KEY_FILE, encKey.toString('hex'), { mode: 0o600 });
    }
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv('aes-256-gcm', encKey, iv);
    const encrypted = Buffer.concat([cipher.update(pw, 'utf-8'), cipher.final()]);
    const tag = cipher.getAuthTag();
    settings.telegramTunnelPassword = `enc:${iv.toString('base64')}.${tag.toString('base64')}.${encrypted.toString('base64')}`;
    if (!existsSync(dirname(settingsFile))) mkdirSync(dirname(settingsFile), { recursive: true });
    writeFileSync(settingsFile, YAML.stringify(settings), 'utf-8');
    console.log('[forge] Admin password saved');

    if (resetPassword) {
      process.exit(0);
    }
  }
}

// ── Enterprise keys (--enterprise-key=…) ──
//
// Each key is encrypted with AES-256-GCM via the same `.encrypt-key`
// file the password uses, then appended to `<dataDir>/.enterprise-keys.json`
// as `{ v: 1, keys: ["enc:…"] }`. lib/enterprise.ts reads + decrypts on
// the runtime side. Duplicate-by-tenant dedup happens lazily on first
// listEnterpriseSources(); this layer just appends.
if (!isStop && (enterpriseKeysFromArgv.length > 0 || addEnterpriseKeyOnly)) {
  if (addEnterpriseKeyOnly && enterpriseKeysFromArgv.length === 0) {
    console.error('[forge] --add-enterprise-key requires at least one --enterprise-key=<value>');
    process.exit(1);
  }
  const crypto = await import('node:crypto');
  const ENC_KEY_FILE = join(DATA_DIR, '.encrypt-key');
  const KEYS_FILE = join(DATA_DIR, '.enterprise-keys.json');
  let encKey;
  if (existsSync(ENC_KEY_FILE)) {
    encKey = Buffer.from(readFileSync(ENC_KEY_FILE, 'utf-8').trim(), 'hex');
  } else {
    encKey = crypto.randomBytes(32);
    if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
    writeFileSync(ENC_KEY_FILE, encKey.toString('hex'), { mode: 0o600 });
  }
  const encryptOne = (plain) => {
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv('aes-256-gcm', encKey, iv);
    const enc = Buffer.concat([cipher.update(plain, 'utf-8'), cipher.final()]);
    const tag = cipher.getAuthTag();
    return `enc:${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
  };
  const decryptOne = (blob) => {
    if (!blob.startsWith('enc:')) return blob;
    try {
      const [ivB64, tagB64, dataB64] = blob.slice(4).split('.');
      const iv = Buffer.from(ivB64, 'base64');
      const tag = Buffer.from(tagB64, 'base64');
      const data = Buffer.from(dataB64, 'base64');
      const d = crypto.createDecipheriv('aes-256-gcm', encKey, iv);
      d.setAuthTag(tag);
      return Buffer.concat([d.update(data), d.final()]).toString('utf-8');
    } catch { return ''; }
  };
  let existing = { v: 1, keys: [] };
  if (existsSync(KEYS_FILE)) {
    try {
      const parsed = JSON.parse(readFileSync(KEYS_FILE, 'utf-8'));
      if (parsed && Array.isArray(parsed.keys)) existing = { v: 1, keys: parsed.keys };
    } catch {
      // Corrupt — start fresh; runtime will surface its own diagnostics if
      // anything was meaningful there.
    }
  }
  // Dedup against existing plaintexts so a second run of
  //   ./dev-test.sh --enterprise-key=…
  // doesn't quietly append a duplicate and grow the file forever.
  const existingPlaintexts = new Set(existing.keys.map(decryptOne).filter(Boolean));
  let added = 0;
  for (const k of enterpriseKeysFromArgv) {
    if (existingPlaintexts.has(k)) continue;
    existing.keys.push(encryptOne(k));
    existingPlaintexts.add(k);
    added += 1;
  }
  if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
  writeFileSync(KEYS_FILE, JSON.stringify(existing, null, 2), { mode: 0o600 });
  const skipped = enterpriseKeysFromArgv.length - added;
  console.log(`[forge] Persisted ${added} new enterprise key(s)${skipped ? `, skipped ${skipped} duplicate` : ''} → ${KEYS_FILE}`);

  if (addEnterpriseKeyOnly) {
    process.exit(0);
  }
}

// ── Find pids listening on a TCP port (portable) ──
// macOS ships lsof; minimal Linux containers (RHEL/CentOS/Alpine) often
// don't. Try lsof → ss (iproute2, ~universal on modern Linux) → fuser
// (psmisc) in order. Empty result means no listener, period.
//
// Without this, `forge server stop` on Linux silently leaked next-server
// because `lsof -ti:8403` threw ENOENT and the whole stop path was
// wrapped in try/catch — the port lookup just disappeared.
function findPortPids(port) {
  // 1) lsof
  try {
    const out = execSync(`lsof -ti:${port}`, {
      encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
    }).trim();
    if (out) return [...new Set(out.split('\n').map(s => s.trim()).filter(Boolean))];
  } catch { /* fall through */ }
  // 2) ss -tlnp — line looks like:
  //    LISTEN 0 511 *:8403 ... users:(("next-server",pid=903438,fd=14))
  try {
    const out = execSync(`ss -tlnp 2>/dev/null`, {
      encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
    });
    const pids = new Set();
    for (const line of out.split('\n')) {
      // Match port at end of local-address field, before either whitespace
      // (IPv4 `*:8403 `) or end-of-field (IPv6 `[::]:8403 `).
      if (!new RegExp(`:${port}\\b`).test(line)) continue;
      for (const m of line.matchAll(/pid=(\d+)/g)) pids.add(m[1]);
    }
    if (pids.size) return [...pids];
  } catch { /* fall through */ }
  // 3) fuser
  try {
    const out = execSync(`fuser ${port}/tcp 2>/dev/null`, {
      encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
    });
    const pids = out.trim().split(/\s+/).filter(p => /^\d+$/.test(p));
    if (pids.length) return [...new Set(pids)];
  } catch { /* nothing more */ }
  return [];
}

// ── Reset terminal server (kill port + tmux sessions) ──
if (resetTerminal) {
  console.log(`[forge] Resetting terminal server (port ${terminalPort})...`);
  const pids = findPortPids(terminalPort);
  if (pids.length === 0) {
    console.log(`[forge] No process on port ${terminalPort}`);
  } else {
    for (const pid of pids) {
      try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
    }
    console.log(`[forge] Killed terminal server on port ${terminalPort}`);
  }
}

// ── PID tracking for clean shutdown ──

function savePids(pids) {
  writeFileSync(PIDS_FILE, JSON.stringify(pids), 'utf-8');
}

function loadPids() {
  try { return JSON.parse(readFileSync(PIDS_FILE, 'utf-8')); } catch { return []; }
}

function killTrackedPids() {
  const pids = loadPids();
  for (const pid of pids) {
    try { process.kill(pid, 'SIGTERM'); } catch {}
  }
  // Give them a moment, then force kill
  if (pids.length > 0) {
    setTimeout(() => {
      for (const pid of pids) {
        try { process.kill(pid, 'SIGKILL'); } catch {}
      }
    }, 2000);
  }
  try { unlinkSync(PIDS_FILE); } catch {}
}

// ── Kill orphan standalone processes ──
const protectedPids = new Set();

function cleanupOrphans() {
  const myPid = String(process.pid);
  const instanceTag = `--forge-port=${webPort}`;
  try {
    // Kill processes on our ports
    for (const port of [webPort, terminalPort]) {
      for (const p of findPortPids(port)) {
        if (p === myPid || protectedPids.has(p)) continue;
        try { process.kill(parseInt(p), 'SIGTERM'); } catch {}
      }
    }
    // Kill standalone processes: our instance's + orphans without any tag
    try {
      const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone|memory-standalone' | grep -v grep`, {
        encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
      }).trim();
      for (const line of out.split('\n').filter(Boolean)) {
        const isOurs = line.includes(instanceTag);
        const isOrphan = !line.includes('--forge-port='); // no tag = legacy orphan
        if (!isOurs && !isOrphan) continue; // belongs to another instance, skip
        const pid = line.trim().split(/\s+/)[1];
        if (pid === myPid || protectedPids.has(pid)) continue;
        try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
      }
    } catch {}
    // Kill zombie tsx scripts holding workflow.db open. Any tsx script that
    // imported lib/task-manager (directly or via lib/pipeline) starts its
    // own setInterval task runner that never exits — those run in parallel
    // with the real runner and silently steal tasks. Detect via lsof on
    // workflow.db (Mac), with a fuser fallback for lsof-less Linux.
    try {
      const dbPath = join(DATA_DIR, 'workflow.db');
      let holders = [];
      try {
        const out = execSync(`lsof -t "${dbPath}"`, {
          encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
        }).trim();
        holders = out.split('\n').map(s => s.trim()).filter(Boolean);
      } catch {
        try {
          // `fuser <file>` writes pids to stderr, not stdout. Merge streams.
          const out = execSync(`fuser "${dbPath}" 2>&1`, {
            encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
          });
          holders = out.replace(/^.*?:/, '').trim().split(/\s+/).filter(p => /^\d+$/.test(p));
        } catch { /* both unavailable — zombie scan disabled */ }
      }
      for (const pid of holders) {
        if (pid === myPid || protectedPids.has(pid)) continue;
        let cmd = '';
        try {
          cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
        } catch { continue; }
        // Skip legit holders: next-server + our standalones (handled above)
        if (/next-server|next start|telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone|memory-standalone/.test(cmd)) continue;
        // Only kill tsx-loaded scripts (typical zombie debug runner shape)
        if (!/tsx/.test(cmd)) continue;
        console.log(`[forge] Killing zombie task-runner (pid=${pid}): ${cmd.slice(0, 120)}`);
        try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
      }
    } catch {}
  } catch {}
}

// ── Start standalone services (single instance each) ──
const services = [];

// When forge runs in the background, services need to fully detach from the
// invoking shell. Otherwise: closing that shell sends SIGHUP to its process
// group → terminal-standalone / telegram / workspace die → all browser
// terminals disconnect.
//
// `daemonize` (passed by startBackground) flips on:
//   - detached: true        → new process group, survives parent SIGHUP
//   - stdio redirected to forge.log fds (no inherited tty fds that go away)
//   - child.unref()         → child doesn't keep the launcher alive
function startServices(daemonize = false) {
  cleanupOrphans();

  const instanceTag = `--forge-port=${webPort}`;
  const stdio = daemonize
    ? ['ignore', openSync(LOG_FILE, 'a'), openSync(LOG_FILE, 'a')]
    : ['ignore', 'inherit', 'inherit'];
  const detached = daemonize;

  const spawnService = (label, script) => {
    const child = spawn('npx', ['tsx', script, instanceTag], {
      cwd: ROOT,
      stdio,
      detached,
      env: { ...process.env },
    });
    if (daemonize) child.unref();
    services.push(child);
    console.log(`[forge] ${label} started (pid: ${child.pid})`);
    return child;
  };

  spawnService('Terminal server', join(ROOT, 'lib', 'terminal-standalone.ts'));
  spawnService('Telegram bot',    join(ROOT, 'lib', 'telegram-standalone.ts'));
  spawnService('Workspace daemon', join(ROOT, 'lib', 'workspace-standalone.ts'));
  spawnService('Browser bridge',  join(ROOT, 'lib', 'browser-bridge-standalone.ts'));
  spawnService('Chat',            join(ROOT, 'lib', 'chat-standalone.ts'));
  spawnService('Memory worker',   join(ROOT, 'lib', 'memory-standalone.ts'));

  const childPids = services.map(c => c.pid).filter(Boolean);
  savePids(childPids);
}

function stopServices() {
  for (const child of services) {
    try { child.kill('SIGTERM'); } catch {}
  }
  services.length = 0;
  cleanupOrphans();
}

// ── Helper: stop running instance ──
async function stopServer() {
  stopServices();
  try { unlinkSync(join(DATA_DIR, 'tunnel-state.json')); } catch {}

  // Kill all tracked child PIDs first
  killTrackedPids();

  let stopped = false;

  // Try PID file first
  try {
    const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim());
    process.kill(pid, 'SIGTERM');
    console.log(`[forge] Stopped (pid ${pid})`);
    stopped = true;
  } catch {}
  try { unlinkSync(PID_FILE); } catch {}

  // Also kill by port (in case PID file is stale). Use findPortPids
  // so Linux-without-lsof still works.
  const portPids = findPortPids(webPort);
  for (const p of portPids) {
    const pid = parseInt(p);
    try { process.kill(pid, 'SIGTERM'); stopped = true; } catch {}
  }
  if (portPids.length > 0) console.log(`[forge] Killed processes on port ${webPort}`);

  // Force kill survivors after 2 seconds.
  if (portPids.length > 0) {
    await new Promise(r => setTimeout(r, 2000));
    for (const pid of portPids) {
      try { process.kill(parseInt(pid), 'SIGKILL'); } catch {}
    }
  }

  // Final verify — if anything still listens on the port (e.g. a child
  // re-bound, or our SIGKILL hit EPERM), surface it loudly. Silent leak
  // is exactly the bug we're fixing.
  const survivors = findPortPids(webPort);
  if (survivors.length > 0) {
    console.warn(
      `[forge] WARNING: port ${webPort} still bound by pid(s) ${survivors.join(', ')} after stop. ` +
      `Likely a different user / cron-launched / sudo'd instance. ` +
      `Run: kill ${survivors.join(' ')}  (or with sudo) to free it.`,
    );
  }

  if (!stopped) {
    console.log('[forge] No running server found');
  }
  return stopped;
}

// ── Helper: start background server ──
function startBackground() {
  ensureBuilt();

  const logFd = openSync(LOG_FILE, 'a');
  const nextBin = join(ROOT, 'node_modules', '.bin', 'next');
  const child = spawn(nextBin, ['start', '-p', String(webPort)], {
    cwd: ROOT,
    stdio: ['ignore', logFd, logFd],
    env: {
      ...process.env,
      FORGE_EXTERNAL_SERVICES: '1',
      // --use-system-ca lets Node 22+ trust the OS keychain in addition
      // to its baked-in CA bundle. Corp networks (FortiClient SSL
      // inspection, Zscaler, etc.) install a custom root via MDM; Node
      // doesn't see it without this flag, so marketplace sync fails with
      // 'self-signed certificate in certificate chain' on api.github.com.
      NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
    },
    detached: true,
  });

  writeFileSync(PID_FILE, String(child.pid));
  protectedPids.add(String(child.pid));
  child.unref();

  // Start services in background too — detached so closing the launcher's
  // shell can't take the daemons (and all attached browser terminals) down.
  startServices(true);

  console.log(`[forge] Started in background (pid ${child.pid})`);
  if (!getArg('--port')) {
    console.log(`[forge] ⚠️  Default port changed: 3000 → ${webPort}`);
    console.log(`[forge] ⚠️  Default port changed: 3000 → ${webPort}`);
    console.log(`[forge] ⚠️  Default port changed: 3000 → ${webPort}`);
    console.log(`[forge] ⚠️  Default port changed: 3000 → ${webPort}`);
    console.log(`[forge] ⚠️  Default port changed: 3000 → ${webPort}`);
  }
  console.log(`[forge] Web: http://localhost:${webPort}`);
  console.log(`[forge] Terminal: ws://localhost:${terminalPort}`);
  console.log(`[forge] Data: ${DATA_DIR}`);
  console.log(`[forge] Log: ${LOG_FILE}`);
}

// ── Stop ──
if (isStop) {
  await stopServer();
  process.exit(0);
}

// ── Restart ──
if (isRestart) {
  await stopServer();
  // Wait for port to fully release
  const net = await import('node:net');
  for (let i = 0; i < 20; i++) {
    await new Promise(r => setTimeout(r, 500));
    const free = await new Promise(resolve => {
      const s = net.createServer();
      s.once('error', () => resolve(false));
      s.once('listening', () => { s.close(); resolve(true); });
      s.listen(webPort);
    });
    if (free) break;
  }
  startBackground();
  process.exit(0);
}

// ── Rebuild ──
// Explicit `--rebuild` always wipes + rebuilds. Otherwise ensureBuilt()
// (called by every start path) handles upgrade detection itself.
if (isRebuild) {
  const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
  console.log(`[forge] Rebuilding (v${pkgVersion})...`);
  if (existsSync(join(ROOT, '.next'))) execSync('rm -rf .next', { cwd: ROOT });
  buildNext();
  try { writeFileSync(join(ROOT, '.next', '.forge-version'), pkgVersion); } catch {}
  console.log('[forge] Rebuild complete');
  process.exit(0);
}

// ── Background ──
if (isBackground) {
  startBackground();
  process.exit(0);
}

// ── Foreground ──

// Clean up services on exit
process.on('SIGINT', () => { stopServices(); process.exit(0); });
process.on('SIGTERM', () => { stopServices(); process.exit(0); });

if (isDev) {
  console.log(`[forge] Starting dev mode (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
  startServices();
  const child = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(webPort)], {
    cwd: ROOT,
    stdio: 'inherit',
    env: {
      ...process.env,
      FORGE_EXTERNAL_SERVICES: '1',
      // --use-system-ca lets Node 22+ trust the OS keychain in addition
      // to its baked-in CA bundle. Corp networks (FortiClient SSL
      // inspection, Zscaler, etc.) install a custom root via MDM; Node
      // doesn't see it without this flag, so marketplace sync fails with
      // 'self-signed certificate in certificate chain' on api.github.com.
      NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
    },
  });
  child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
} else {
  ensureBuilt();
  console.log(`[forge] Starting server (port ${webPort}, terminal ${terminalPort}, data ${DATA_DIR})`);
  startServices();
  const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
    cwd: ROOT,
    stdio: 'inherit',
    env: {
      ...process.env,
      FORGE_EXTERNAL_SERVICES: '1',
      // --use-system-ca lets Node 22+ trust the OS keychain in addition
      // to its baked-in CA bundle. Corp networks (FortiClient SSL
      // inspection, Zscaler, etc.) install a custom root via MDM; Node
      // doesn't see it without this flag, so marketplace sync fails with
      // 'self-signed certificate in certificate chain' on api.github.com.
      NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
    },
  });
  child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
}
