// vsix-sandbox.mjs — Spawn vsix-fetch-worker.mjs under an OS sandbox so any
// filesystem writes the ZIP extractor performs are restricted to a single
// temp directory.
//
// Reuses the same sandbox primitives proven by `git-clone.mjs`:
//   - macOS: sandbox-exec with a deny-file-write profile + subpath allow
//   - Linux: bwrap with --ro-bind / --bind / --unshare-all
//   - Windows / fallback: spawn directly + WARN to stderr (no OS sandbox)
//
// Defense-in-depth: even if our own zip-extract.mjs has a zip-slip / symlink
// bypass we did not foresee, the OS will refuse the write. The in-process
// validation in zip-extract.mjs remains the first line of defense.

import { spawn } from 'node:child_process';
import { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve as resolvePath } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_VSIX_WORKER_PATH = resolvePath(__dirname, 'vsix-fetch-worker.mjs');
const DEFAULT_JETBRAINS_WORKER_PATH = resolvePath(__dirname, 'jetbrains-fetch-worker.mjs');
// Backward-compat alias — older internal refs may still import `WORKER_PATH`.
const WORKER_PATH = DEFAULT_VSIX_WORKER_PATH;
const WORKER_TIMEOUT_MS = 35_000; // fetch is 30s, give worker 5s of slack
const MAX_OUTPUT_BYTES = 1024 * 1024; // 1MB JSON cap (output is tiny in practice)

/**
 * Build the sandbox-exec profile that allows writes only to `allowedWritePath`.
 * Mirrors `buildSandboxProfile` in git-clone.mjs.
 * @returns {string|null}  null if not on macOS or sandbox-exec missing
 */
export function buildSandboxProfile(allowedWritePath) {
  if (process.platform !== 'darwin') return null;
  const check = spawnSync('which', ['sandbox-exec'], { encoding: 'utf8' });
  if (check.status !== 0) return null;
  const realPath = realpathSync(allowedWritePath);
  return [
    '(version 1)',
    '(allow default)',
    '(deny file-write*)',
    `(allow file-write* (subpath "${realPath}"))`,
    '(allow file-write* (literal "/dev/null"))',
    '(allow file-write* (literal "/dev/tty"))',
  ].join('');
}

/**
 * Build bwrap arguments that allow writes only to `allowedWritePath`.
 * Mirrors `buildBwrapArgs` in git-clone.mjs.
 * @returns {string[]|null}  null if not on Linux or bwrap unusable
 */
export function buildBwrapArgs(allowedWritePath, innerArgs) {
  if (process.platform !== 'linux') return null;
  const check = spawnSync('which', ['bwrap'], { encoding: 'utf8' });
  if (check.status !== 0) return null;
  // Probe — bwrap is shipped on Ubuntu 24.04+ but may need admin AppArmor config.
  const probe = spawnSync(
    'bwrap',
    ['--ro-bind', '/', '/', '--dev', '/dev', '/bin/true'],
    { stdio: 'ignore', timeout: 5000 },
  );
  if (probe.status !== 0) return null;
  return [
    '--ro-bind', '/', '/',
    '--bind', allowedWritePath, allowedWritePath,
    '--dev', '/dev',
    '--unshare-all',
    '--new-session',
    '--die-with-parent',
    ...innerArgs,
  ];
}

/**
 * Resolve the spawn command for a worker invocation. Returns the OS sandbox
 * if available; otherwise plain `node` with a `sandbox: null` flag so the
 * caller can WARN. Identical pattern to `buildSandboxedClone`.
 *
 * @param {string} tmpDir  writable temp dir for the worker
 * @param {string[]} workerArgs  argv for the worker (after `node <worker>`)
 * @param {string} [workerPath=DEFAULT_VSIX_WORKER_PATH]  absolute path to the
 *   worker module. Defaults to the VSIX worker for backward compat; Step 12
 *   passes `DEFAULT_JETBRAINS_WORKER_PATH` for JetBrains plugin fetches.
 * @returns {{cmd:string, args:string[], sandbox: 'sandbox-exec'|'bwrap'|null}}
 */
export function buildSandboxedWorker(tmpDir, workerArgs, workerPath = DEFAULT_VSIX_WORKER_PATH) {
  const innerArgs = ['node', workerPath, ...workerArgs];

  const profile = buildSandboxProfile(tmpDir);
  if (profile) {
    return { cmd: 'sandbox-exec', args: ['-p', profile, ...innerArgs], sandbox: 'sandbox-exec' };
  }

  const bwrapArgs = buildBwrapArgs(tmpDir, innerArgs);
  if (bwrapArgs) {
    return { cmd: 'bwrap', args: bwrapArgs, sandbox: 'bwrap' };
  }

  // Fallback — Windows or sandbox tools missing. Worker still runs, just not boxed.
  return { cmd: innerArgs[0], args: innerArgs.slice(1), sandbox: null };
}

/**
 * Spawn the VSIX worker and parse its single JSON line of output.
 *
 * @param {string} url       URL to fetch (passed via argv to worker)
 * @param {string} tmpDir    writable directory for extracted files
 * @param {object} [opts]
 * @param {boolean} [opts.allowFallback=true]  if false, throw when no OS sandbox
 * @returns {Promise<{ok:boolean, sandbox:'sandbox-exec'|'bwrap'|null, payload:object}>}
 */
export function runVsixWorker(url, tmpDir, opts = {}) {
  return runPluginWorker(
    DEFAULT_VSIX_WORKER_PATH,
    ['--url', url, '--tmpdir', tmpDir],
    tmpDir,
    opts,
  );
}

/**
 * Generalized sandboxed-worker runner. Spawns any worker module, captures a
 * single JSON line of stdout, enforces the same timeout / output cap as
 * `runVsixWorker`. Step 12 uses this for the JetBrains worker — it must NOT
 * duplicate spawn/parse logic.
 *
 * @param {string} workerPath  absolute path to the worker .mjs file
 * @param {string[]} workerArgs  argv for the worker
 * @param {string} tmpDir  writable temp dir for the worker
 * @param {{allowFallback?: boolean}} [opts]
 * @returns {Promise<{ok:boolean, sandbox:'sandbox-exec'|'bwrap'|null, payload:object}>}
 */
export function runPluginWorker(workerPath, workerArgs, tmpDir, opts = {}) {
  const { allowFallback = true } = opts;
  const { cmd, args, sandbox } = buildSandboxedWorker(tmpDir, workerArgs, workerPath);

  if (!sandbox && !allowFallback) {
    return Promise.reject(new Error('no OS sandbox available and fallback disabled'));
  }

  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      stdio: ['ignore', 'pipe', 'pipe'],
      timeout: WORKER_TIMEOUT_MS,
      env: { ...process.env },
    });

    let stdout = '';
    let stdoutBytes = 0;
    let truncated = false;
    child.stdout.on('data', (chunk) => {
      stdoutBytes += chunk.length;
      if (stdoutBytes > MAX_OUTPUT_BYTES) {
        truncated = true;
        try { child.kill('SIGKILL'); } catch {}
        return;
      }
      stdout += chunk.toString('utf8');
    });

    let stderr = '';
    child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });

    child.on('error', (err) => reject(new Error(`worker spawn failed: ${err.message}`)));

    child.on('close', (code, signal) => {
      if (truncated) {
        return reject(new Error('worker output exceeded 1MB cap'));
      }
      if (signal === 'SIGTERM' && code === null) {
        return reject(new Error(`worker timed out after ${WORKER_TIMEOUT_MS}ms`));
      }
      // Parse the last non-empty line as JSON (worker writes one line on success/failure).
      const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
      const last = lines[lines.length - 1];
      if (!last) {
        const tail = stderr.trim().slice(0, 200);
        return reject(new Error(`worker produced no output${tail ? ` (stderr: ${tail})` : ''}`));
      }
      let payload;
      try {
        payload = JSON.parse(last);
      } catch {
        return reject(new Error(`worker emitted non-JSON: ${last.slice(0, 120)}`));
      }
      resolve({ ok: payload.ok === true, sandbox, payload });
    });
  });
}

export {
  DEFAULT_VSIX_WORKER_PATH,
  DEFAULT_JETBRAINS_WORKER_PATH,
};

export const __testing = {
  WORKER_PATH,
  DEFAULT_VSIX_WORKER_PATH,
  DEFAULT_JETBRAINS_WORKER_PATH,
  WORKER_TIMEOUT_MS,
  MAX_OUTPUT_BYTES,
  resolveWorkerPath: (name) => resolvePath(__dirname, name),
};
