// Allow-list wrapper around shell-outs.
//
// Every privileged route in this backend ultimately runs `docker`, `bash`,
// `npm`, etc. on the host. Historically those were issued as template-literal
// strings passed through `child_process.exec`, which means a misvalidated
// path param can become a shell-injection. This module fixes that by:
//
//   1. Always passing argv as a real array to `execFile` (no shell expansion,
//      no string templating). `execAllowed()` is the only escape hatch and it
//      still uses `execFile`, never `exec`.
//   2. Exposing per-command helpers (`dockerRestart`, `dockerPrune`,
//      `runBashScript`, `runNpmScript`) that validate their inputs against
//      a per-command allow-list regex. Repos call these instead of building
//      `docker ...` strings directly.
//
// This is the "allow-list wrapper" item from the DEPLOYMENT.md privilege-
// surface mitigation roadmap.

import { execFile } from 'child_process';
import { isAbsolute, normalize, relative, resolve } from 'path';
import { promisify } from 'util';
import { childLogger } from './logger.js';

const execFileAsync = promisify(execFile);
const log = childLogger('lib/shell');

export interface ShellExecOptions {
  cwd?: string;
  timeoutMs?: number;
  env?: NodeJS.ProcessEnv;
}

export interface ShellResult {
  stdout: string;
  stderr: string;
}

/**
 * Run a single command with an explicit `argv` array. No shell expansion,
 * no string interpolation. Prefer the per-command helpers below; reach for
 * this when the command isn't on the allow-list yet.
 */
export async function execAllowed(
  command: string,
  args: string[],
  options: ShellExecOptions = {},
): Promise<ShellResult> {
  log.debug({ command, args, cwd: options.cwd }, 'shell exec');
  const { stdout, stderr } = await execFileAsync(command, args, {
    cwd: options.cwd,
    timeout: options.timeoutMs ?? 30_000,
    env: options.env,
    maxBuffer: 10 * 1024 * 1024,
  });
  return {
    stdout: stdout?.toString?.() ?? String(stdout ?? ''),
    stderr: stderr?.toString?.() ?? String(stderr ?? ''),
  };
}

// --- Docker allow-list ------------------------------------------------------

// Container/volume/image names from the docker daemon. Docker's own rule is
// `[a-zA-Z0-9][a-zA-Z0-9_.-]+` but we tighten the leading char too.
const CONTAINER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;

export class InvalidShellArgError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'InvalidShellArgError';
  }
}

function assertContainerName(name: string): void {
  if (typeof name !== 'string' || !CONTAINER_NAME_RE.test(name)) {
    throw new InvalidShellArgError(`Invalid container name: ${JSON.stringify(name)}`);
  }
}

/** `docker restart <name>` — validated. */
export async function dockerRestart(name: string): Promise<ShellResult> {
  assertContainerName(name);
  return execAllowed('docker', ['restart', name], { timeoutMs: 30_000 });
}

const PRUNE_KINDS = ['container', 'image', 'volume', 'builder'] as const;
export type PruneKind = typeof PRUNE_KINDS[number];

/** `docker <kind> prune -f` (`-a` only valid for `image`). */
export async function dockerPrune(kind: PruneKind, opts: { all?: boolean } = {}): Promise<ShellResult> {
  if (!PRUNE_KINDS.includes(kind)) {
    throw new InvalidShellArgError(`Invalid prune kind: ${JSON.stringify(kind)}`);
  }
  const args: string[] = [kind, 'prune', '-f'];
  if (opts.all) {
    if (kind !== 'image') throw new InvalidShellArgError('`all` is only valid for image prune');
    args.splice(2, 0, '-a');
  }
  return execAllowed('docker', args, { timeoutMs: 60_000 });
}

// --- Filesystem-path allow-list --------------------------------------------

/**
 * Verify that `candidate` is an absolute path that resolves inside one of
 * the allowed roots. Used to lock down request-supplied `cwd` values
 * (e.g. `/code-quality/check`'s `projectPath`) so callers can't run
 * lifecycle scripts in arbitrary directories.
 */
export function assertPathInAllowedRoots(candidate: string, allowedRoots: string[]): string {
  if (typeof candidate !== 'string' || !isAbsolute(candidate)) {
    throw new InvalidShellArgError(`Path must be absolute: ${JSON.stringify(candidate)}`);
  }
  const resolved = resolve(normalize(candidate));
  for (const root of allowedRoots) {
    const resolvedRoot = resolve(normalize(root));
    const rel = relative(resolvedRoot, resolved);
    // Inside the root iff the relative path doesn't escape upward
    // (no leading `..`) and isn't an absolute path back out.
    if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
      return resolved;
    }
  }
  throw new InvalidShellArgError(
    `Path is not inside an allowed root: ${JSON.stringify(candidate)}`,
  );
}

// --- bash / npm wrappers ----------------------------------------------------

/**
 * Run a `bash <script>` invocation with `cwd` constrained to allowed
 * roots. The script path itself must also be inside an allowed root.
 */
export async function runBashScript(
  scriptPath: string,
  args: string[] = [],
  options: ShellExecOptions & { allowedRoots: string[] } = { allowedRoots: [] },
): Promise<ShellResult> {
  const safeScript = assertPathInAllowedRoots(scriptPath, options.allowedRoots);
  if (options.cwd) assertPathInAllowedRoots(options.cwd, options.allowedRoots);
  return execAllowed('bash', [safeScript, ...args], {
    cwd: options.cwd,
    timeoutMs: options.timeoutMs ?? 300_000,
    env: options.env,
  });
}

const NPM_LIFECYCLE = ['typecheck', 'lint', 'build', 'test', 'test:run', 'start'] as const;
export type NpmLifecycle = typeof NPM_LIFECYCLE[number];

/**
 * `npm run <script>` constrained to a known set of lifecycle scripts and
 * run only inside an allowed project root. Used by `/code-quality/check`.
 */
export async function runNpmScript(
  script: NpmLifecycle,
  options: ShellExecOptions & { allowedRoots: string[] } = { allowedRoots: [] },
): Promise<ShellResult> {
  if (!NPM_LIFECYCLE.includes(script)) {
    throw new InvalidShellArgError(`npm script not in allow-list: ${JSON.stringify(script)}`);
  }
  if (!options.cwd) throw new InvalidShellArgError('npm run requires a cwd');
  assertPathInAllowedRoots(options.cwd, options.allowedRoots);
  return execAllowed('npm', ['run', script], {
    cwd: options.cwd,
    timeoutMs: options.timeoutMs ?? 120_000,
    env: options.env,
  });
}
