import {
  type ChildProcess,
  execFile,
  type ExecFileOptions,
  spawn,
  type SpawnOptions,
  spawnSync,
} from 'child_process';
import { existsSync, readFileSync } from 'fs';
import path from 'path';

/**
 * Promise wrapper for execFile that always returns { stdout, stderr }.
 * Unlike promisify(execFile), this works correctly with mocked execFile
 * (promisify relies on a custom symbol that mocks don't have).
 */
function execFileAsync(
  cmd: string,
  args: string[],
  options: ExecFileOptions = {}
): Promise<{ stdout: string; stderr: string }> {
  return new Promise((resolve, reject) => {
    const { timeout, killSignal, ...execOptions } = options;
    const timeoutMs = typeof timeout === 'number' && timeout > 0 ? timeout : 0;
    const timeoutSignal = normalizeKillSignal(killSignal);
    let child: ChildProcess | null = null;
    let settled = false;
    let stdoutText = '';
    let stderrText = '';
    let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
    child = execFile(cmd, args, execOptions, (err, stdout, stderr) => {
      if (settled) {
        return;
      }
      settled = true;
      timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
      if (err) {
        const normalizedError =
          err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
        Object.assign(normalizedError, {
          stdout: String(stdout),
          stderr: String(stderr),
        });
        reject(normalizedError);
      } else resolve({ stdout: String(stdout), stderr: String(stderr) });
    });
    if (!settled) {
      trackCliProcess(child);
      if (timeoutMs > 0) {
        child.stdout?.on('data', (chunk: Buffer | string) => {
          stdoutText += chunk.toString();
        });
        child.stderr?.on('data', (chunk: Buffer | string) => {
          stderrText += chunk.toString();
        });
        timeoutHandle = setTimeout(() => {
          if (settled) {
            return;
          }
          settled = true;
          timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
          killProcessTree(child, timeoutSignal);
          const error = new Error(
            `Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}`
          );
          Object.assign(error, {
            killed: true,
            signal: timeoutSignal,
            stdout: stdoutText,
            stderr: stderrText,
          });
          reject(error);
        }, timeoutMs);
        timeoutHandle.unref?.();
      }
    }
  });
}

/**
 * cmd.exe fallback implemented through execFile so Node does not invoke an
 * additional shell around the guarded command string.
 */
function execShellAsync(
  cmd: string,
  options: ExecFileOptions = {}
): Promise<{ stdout: string; stderr: string }> {
  return execFileAsync(getWindowsCmdPath(), ['/d', '/s', '/c', cmd], options);
}

function cleanupTimedCliProcess(
  child: ChildProcess | null,
  timeoutHandle: ReturnType<typeof setTimeout> | null
): null {
  if (timeoutHandle) {
    clearTimeout(timeoutHandle);
  }
  untrackCliProcess(child);
  return null;
}

/**
 * Returns true if the string contains any non-ASCII character.
 */
function containsNonAscii(str: string): boolean {
  return [...str].some((c) => c.charCodeAt(0) > 127);
}

/**
 * On Windows, batch launchers need cmd.exe, and creating a process whose
 * path contains non-ASCII characters will often fail with `spawn EINVAL`.
 * Detect both cases so callers can launch through a shell when needed.
 */
function needsShell(binaryPath: string): boolean {
  if (process.platform !== 'win32') return false;
  if (!binaryPath) return false;
  const extension = path.extname(binaryPath).toLowerCase();
  return extension === '.cmd' || extension === '.bat' || containsNonAscii(binaryPath);
}

interface DirectWindowsLauncher {
  command: string;
  argsPrefix: string[];
}

function isWindowsBatchLauncher(binaryPath: string): boolean {
  const extension = path.extname(binaryPath).toLowerCase();
  return extension === '.cmd' || extension === '.bat';
}

function resolveCmdPathTemplate(template: string, launcherDir: string): string {
  const dirWithSep = launcherDir.endsWith(path.sep) ? launcherDir : `${launcherDir}${path.sep}`;
  return path.resolve(
    template
      .replace(/%SCRIPT_DIR%/gi, dirWithSep)
      .replace(/%~dp0/gi, dirWithSep)
      .replace(/%dp0%/gi, dirWithSep)
      .replace(/\\/g, path.sep)
  );
}

function resolveGeneratedBunLauncher(
  content: string,
  launcherDir: string
): DirectWindowsLauncher | null {
  if (!/\bbun\s+"%TARGET%"\s+%\*/i.test(content)) {
    return null;
  }
  const targetMatch = /set\s+"TARGET=([^"]+)"/i.exec(content);
  const targetTemplate = targetMatch?.[1];
  if (!targetTemplate) {
    return null;
  }

  const target = resolveCmdPathTemplate(targetTemplate, launcherDir);
  if (!existsSync(target)) {
    return null;
  }
  return { command: 'bun', argsPrefix: [target] };
}

function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
  const scriptMatch = /"%_prog%"\s+"([^"]+(?:\.(?:cjs|mjs|js))?)"\s+%\*/i.exec(content);
  const scriptTemplate = scriptMatch?.[1];
  if (!scriptTemplate) {
    return null;
  }

  const scriptPath = resolveCmdPathTemplate(scriptTemplate, launcherDir);
  if (!existsSync(scriptPath)) {
    return null;
  }

  const localNode = path.join(launcherDir, 'node.exe');
  return {
    command: existsSync(localNode) ? localNode : 'node',
    argsPrefix: [scriptPath],
  };
}

function resolveNpmNativeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
  const nativeTarget = /(?:^|[&|])\s*"([^"]+\.(?:exe|com))"\s+%\*/im.exec(content)?.[1];
  if (!nativeTarget) {
    return null;
  }

  const target = resolveCmdPathTemplate(nativeTarget, launcherDir);
  if (!existsSync(target)) {
    return null;
  }

  return { command: target, argsPrefix: [] };
}

/**
 * Some Windows launchers are thin wrappers around a real JS entrypoint.
 * Running that entrypoint directly with an argv array avoids cmd.exe's
 * percent expansion, which cannot safely represent args like `%PATH%`.
 */
function resolveDirectWindowsLauncher(binaryPath: string): DirectWindowsLauncher | null {
  if (process.platform !== 'win32' || !isWindowsBatchLauncher(binaryPath)) {
    return null;
  }

  try {
    const content = readFileSync(binaryPath, 'utf8');
    const launcherDir = path.dirname(binaryPath);
    return (
      resolveGeneratedBunLauncher(content, launcherDir) ??
      resolveNpmNodeShim(content, launcherDir) ??
      resolveNpmNativeShim(content, launcherDir)
    );
  } catch {
    return null;
  }
}

/**
 * Quote an argument for cmd.exe shell invocation on Windows.
 *
 * cmd.exe rules:
 * - Double-quote args containing spaces or special characters
 * - Inside double quotes, escape literal `"` as `\"` for the target argv parser
 * - Double trailing backslashes so they do not escape the closing quote
 * - `%` is expanded as env var even inside double quotes. Keep it outside
 *   quoted chunks and escape it as `^%`.
 * - `^`, `&`, `|`, `<`, `>` are safe inside double quotes
 *
 * Our callers only pass controlled strings (binary paths, CLI flags),
 * NOT arbitrary user input.
 */
function quoteCmdChunk(chunk: string): string {
  const escaped = chunk
    .replace(/(\\*)"/g, (_match, backslashes: string) => `${backslashes}${backslashes}\\"`)
    .replace(/(\\+)$/g, '$1$1');
  return `"${escaped}"`;
}

export function quoteWindowsCmdArg(arg: string): string {
  if (/[^A-Za-z0-9_\-/.]/.test(arg)) {
    return arg.split('%').map(quoteCmdChunk).join('^%');
  }
  return arg;
}

function quoteArg(arg: string): string {
  return quoteWindowsCmdArg(arg);
}

function containsWindowsShellUnsafeControlChar(part: string): boolean {
  for (let index = 0; index < part.length; index += 1) {
    const code = part.charCodeAt(index);
    if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
      return true;
    }
  }
  return false;
}

function assertSafeWindowsShellFallbackPart(part: string): void {
  if (containsWindowsShellUnsafeControlChar(part)) {
    throw new Error('Unsafe Windows shell fallback argument: control characters are not allowed');
  }
}

function buildWindowsShellFallbackCommand(parts: string[]): string {
  for (const part of parts) {
    assertSafeWindowsShellFallbackPart(part);
  }
  return parts.map(quoteArg).join(' ');
}

function getWindowsCmdPath(): string {
  return path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe');
}

function spawnWindowsShellFallback(
  cmd: string,
  options: ReturnType<typeof withCliProcessDefaults<SpawnOptions>>
): ReturnType<typeof spawn> {
  return spawn(getWindowsCmdPath(), ['/d', '/s', '/c', cmd], {
    ...options,
    shell: false,
  });
}

/** Env vars injected into every spawned Claude CLI process. */
const CLI_ENV_DEFAULTS: Record<string, string> = {
  CLAUDE_HOOK_JUDGE_MODE: 'true',
};

const activeCliProcesses = new Set<ChildProcess>();

export function untrackCliProcess(child: ChildProcess | null): void {
  if (child) {
    activeCliProcesses.delete(child);
  }
}

function trackCliProcess<T extends ChildProcess>(child: T): T {
  activeCliProcesses.add(child);
  const cleanup = (): void => {
    activeCliProcesses.delete(child);
  };
  child.once?.('exit', cleanup);
  child.once?.('close', cleanup);
  child.once?.('error', cleanup);
  return child;
}

export function killTrackedCliProcesses(signal: NodeJS.Signals = 'SIGKILL'): void {
  for (const child of Array.from(activeCliProcesses)) {
    try {
      killProcessTree(child, signal);
    } catch {
      // Best effort during shutdown.
    }
  }
}

/** Apply shared CLI process defaults without overriding explicit caller choices. */
function withCliProcessDefaults<
  T extends {
    env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
    windowsHide?: boolean;
  },
>(options: T): T & { windowsHide: boolean } {
  return {
    ...options,
    windowsHide: options.windowsHide ?? true,
    env: { ...(options.env ?? process.env), ...CLI_ENV_DEFAULTS },
  };
}

/**
 * Execute a CLI binary, falling back to running the command through a
 * shell on Windows if the normal path-based spawn fails.
 *
 * The return value matches the shape of Node's `execFile` promise: an
 * object with `stdout` and `stderr` strings.
 */
export interface ExecCliOptions extends ExecFileOptions {
  /**
   * Some generated Windows launchers are safe to run directly, but callers can
   * force the .cmd/.bat path when they need the launcher environment exactly.
   */
  preferShellForWindowsBatch?: boolean;
}

export async function execCli(
  binaryPath: string | null,
  args: string[],
  options: ExecCliOptions = {}
): Promise<{ stdout: string; stderr: string }> {
  if (!binaryPath) {
    throw new Error(
      'Claude CLI binary path is null. Resolve the binary via ClaudeBinaryResolver before calling execCli.'
    );
  }
  const target = binaryPath;
  const { preferShellForWindowsBatch = false, ...execOptions } = options;
  const opts = withCliProcessDefaults(execOptions);
  const directLauncher =
    preferShellForWindowsBatch && isWindowsBatchLauncher(target)
      ? null
      : resolveDirectWindowsLauncher(target);
  if (directLauncher) {
    const result = await execFileAsync(
      directLauncher.command,
      [...directLauncher.argsPrefix, ...args],
      opts
    );
    return { stdout: String(result.stdout), stderr: String(result.stderr) };
  }

  // attempt the normal execFile path first
  if (!needsShell(target)) {
    try {
      const result = await execFileAsync(target, args, opts);
      return { stdout: String(result.stdout), stderr: String(result.stderr) };
    } catch (err: unknown) {
      // fall through to shell fallback only when the error matches the
      // Windows "invalid argument" problem; otherwise rethrow.
      const code =
        err && typeof err === 'object' && 'code' in err
          ? (err as { code?: string }).code
          : undefined;
      if (code !== 'EINVAL') {
        throw err;
      }
    }
  }

  // shell fallback (Windows only; others shouldn't reach here)
  const cmd = buildWindowsShellFallbackCommand([target, ...args]);
  const shellResult = await execShellAsync(cmd, opts);
  return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) };
}

/**
 * Spawn a child process.  If the initial `spawn()` call throws
 * synchronously with EINVAL on Windows, retry using a shell-based
 * command string.  The returned `ChildProcess` is whatever the
 * underlying call returned; listeners may safely be attached to it.
 */
export function spawnCli(
  binaryPath: string,
  args: string[],
  options: SpawnOptions = {}
): ReturnType<typeof spawn> {
  const opts = withCliProcessDefaults(options);
  const directLauncher = resolveDirectWindowsLauncher(binaryPath);
  if (directLauncher) {
    const directOpts = { ...opts };
    delete directOpts.shell;
    return trackCliProcess(
      spawn(directLauncher.command, [...directLauncher.argsPrefix, ...args], directOpts)
    );
  }

  if (process.platform === 'win32' && needsShell(binaryPath)) {
    const cmd = buildWindowsShellFallbackCommand([binaryPath, ...args]);
    return trackCliProcess(spawnWindowsShellFallback(cmd, opts));
  }

  try {
    return trackCliProcess(spawn(binaryPath, args, opts));
  } catch (err: unknown) {
    const code =
      err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined;
    if (process.platform === 'win32' && code === 'EINVAL') {
      const cmd = buildWindowsShellFallbackCommand([binaryPath, ...args]);
      return trackCliProcess(spawnWindowsShellFallback(cmd, opts));
    }
    throw err;
  }
}

/**
 * Kill a child process and its entire process tree.
 *
 * On Windows with `shell: true`, `child.kill()` only kills the intermediate
 * `cmd.exe` shell, leaving the actual process (e.g. `claude.cmd`) orphaned.
 * `taskkill /T /F /PID` recursively kills the entire process tree.
 *
 * On macOS/Linux, kill the child and descendants by PID so shell wrappers
 * and spawned grandchildren do not survive a timeout or team stop.
 */
export function killProcessTree(
  child: ChildProcess | null | undefined,
  signal?: NodeJS.Signals
): void {
  if (!child?.pid) {
    // Process is null, never started, or already exited
    return;
  }

  if (process.platform === 'win32') {
    try {
      const taskkillPath = path.join(
        process.env.SystemRoot ?? 'C:\\Windows',
        'System32',
        'taskkill.exe'
      );
      execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], { windowsHide: true }, () => {
        // Best-effort - ignore errors (process may have already exited)
      });
      return;
    } catch {
      // taskkill failed, fall through to standard kill
    }
  }

  const childPid = child.pid;
  const descendants = getDescendantProcessIds(childPid);
  const targetSignal = signal ?? 'SIGTERM';
  for (const pid of [childPid, ...descendants.reverse()]) {
    try {
      process.kill(pid, targetSignal);
    } catch {
      // Best-effort - process may have already exited.
    }
  }
}

function normalizeKillSignal(signal: ExecFileOptions['killSignal']): NodeJS.Signals {
  return typeof signal === 'string' ? signal : 'SIGTERM';
}

function getDescendantProcessIds(parentPid: number): number[] {
  if (process.platform === 'win32') {
    return [];
  }

  try {
    const result = spawnSync('ps', ['-axo', 'pid=,ppid='], {
      encoding: 'utf8',
      windowsHide: true,
    });
    if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
      return [];
    }

    const childrenByParent = new Map<number, number[]>();
    for (const line of result.stdout.split('\n')) {
      const match = /^(\d+)\s+(\d+)$/.exec(line.trim());
      if (!match) {
        continue;
      }
      const pid = Number(match[1]);
      const ppid = Number(match[2]);
      const children = childrenByParent.get(ppid);
      if (children) {
        children.push(pid);
      } else {
        childrenByParent.set(ppid, [pid]);
      }
    }

    const descendants: number[] = [];
    const stack = [...(childrenByParent.get(parentPid) ?? [])];
    const seen = new Set<number>();
    while (stack.length > 0) {
      const pid = stack.pop();
      if (!pid || seen.has(pid) || pid === process.pid) {
        continue;
      }
      seen.add(pid);
      descendants.push(pid);
      stack.push(...(childrenByParent.get(pid) ?? []));
    }
    return descendants;
  } catch {
    return [];
  }
}
