#!/usr/bin/env bun
/**
 * Sweetistics runner wrapper: enforces timeouts, git policy, and trash-safe deletes before dispatching any repo command.
 * When you tweak its behavior, add a short note to AGENTS.md via `./scripts/committer "docs: update AGENTS for runner" "AGENTS.md"` so other agents know the new expectations.
 */

import { type ChildProcess, spawn } from 'node:child_process';
import { cpSync, existsSync, renameSync, rmSync } from 'node:fs';
import { constants as osConstants } from 'node:os';
import { basename, isAbsolute, join, normalize, resolve } from 'node:path';
import process from 'node:process';

import {
  analyzeGitExecution,
  evaluateGitPolicies,
  type GitCommandInfo,
  type GitExecutionContext,
  type GitInvocation,
} from './git-policy';

const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
const EXTENDED_TIMEOUT_MS = 20 * 60 * 1000;
const LONG_TIMEOUT_MS = 25 * 60 * 1000; // Build + full-suite commands (Next.js build, test:all) routinely spike past 20 minutes—give them explicit headroom before tmux escalation.
const LINT_TIMEOUT_MS = 30 * 60 * 1000;
const LONG_RUN_REPORT_THRESHOLD_MS = 60 * 1000;
const ENABLE_DEBUG_LOGS = process.env.RUNNER_DEBUG === '1';
const MAX_SLEEP_SECONDS = 30;

const WRAPPER_COMMANDS = new Set([
  'sudo',
  '/usr/bin/sudo',
  'env',
  '/usr/bin/env',
  'command',
  '/bin/command',
  'nohup',
  '/usr/bin/nohup',
]);

type SummaryStyle = 'compact' | 'minimal' | 'verbose';
const SUMMARY_STYLE = resolveSummaryStyle(process.env.RUNNER_SUMMARY_STYLE);

// biome-ignore format: keep each keyword on its own line for grep-friendly diffs.
const LONG_SCRIPT_KEYWORDS = [
  'build',
  'test:all',
  'test:browser',
  'test:e2e',
  'test:e2e:headed',
  'vitest.browser',
  'vitest.browser.config.ts',
];
const EXTENDED_SCRIPT_KEYWORDS = ['lint', 'test', 'playwright', 'check', 'docker'];
const SINGLE_TEST_SCRIPTS = new Set(['test:file']);
const SINGLE_TEST_FLAGS = new Set(['--run', '--filter']);
const TEST_BINARIES = new Set(['vitest', 'playwright', 'jest']);
const LINT_BINARIES = new Set(['eslint', 'biome', 'oxlint', 'knip']);

type RunnerExecutionContext = {
  commandArgs: string[];
  workspaceDir: string;
  timeoutMs: number;
};

type CommandInterceptionResult = { handled: true } | { handled: false; gitContext: GitExecutionContext };

type GitRmPlan = {
  paths: string[];
  stagingOptions: string[];
  allowMissing: boolean;
  shouldIntercept: boolean;
};

type MoveResult = {
  missing: string[];
  errors: string[];
};

let cachedTrashCliCommand: string | null | undefined;

(async () => {
  const commandArgs = parseArgs(process.argv.slice(2));

  if (commandArgs.length === 0) {
    printUsage('Missing command to execute.');
    process.exit(1);
  }

  const workspaceDir = process.cwd();
  const timeoutMs = determineEffectiveTimeoutMs(commandArgs);
  const context: RunnerExecutionContext = {
    commandArgs,
    workspaceDir,
    timeoutMs,
  };

  const interception = await resolveCommandInterception(context);
  if (interception.handled) {
    return;
  }

  enforceGitPolicies(interception.gitContext);

  await runCommand(context);
})().catch((error) => {
  console.error('[runner] Unexpected failure:', error instanceof Error ? error.message : String(error));
  process.exit(1);
});

// Parses the runner CLI args and rejects unsupported flags early.
function parseArgs(argv: string[]): string[] {
  const commandArgs: string[] = [];
  let parsingOptions = true;

  for (const token of argv) {
    if (!parsingOptions) {
      commandArgs.push(token);
      continue;
    }

    if (token === '--') {
      parsingOptions = false;
      continue;
    }

    if (token === '--help' || token === '-h') {
      printUsage();
      process.exit(0);
    }

    if (token === '--timeout' || token.startsWith('--timeout=')) {
      console.error('[runner] --timeout is no longer supported; rely on the automatic timeouts.');
      process.exit(1);
    }

    parsingOptions = false;
    commandArgs.push(token);
  }

  return commandArgs;
}

// Computes the timeout tier for the provided command tokens.
function determineEffectiveTimeoutMs(commandArgs: string[]): number {
  const strippedTokens = stripWrappersAndAssignments(commandArgs);
  if (isTestRunnerSuiteInvocation(strippedTokens, 'integration')) {
    return EXTENDED_TIMEOUT_MS;
  }
  if (referencesIntegrationSpec(strippedTokens)) {
    return EXTENDED_TIMEOUT_MS;
  }
  if (shouldUseLintTimeout(commandArgs)) {
    return LINT_TIMEOUT_MS;
  }
  if (shouldUseLongTimeout(commandArgs)) {
    return LONG_TIMEOUT_MS;
  }
  if (shouldExtendTimeout(commandArgs) && !isSingleTestInvocation(commandArgs)) {
    return EXTENDED_TIMEOUT_MS;
  }
  return DEFAULT_TIMEOUT_MS;
}

// Determines whether the command matches any keyword requiring extra time.
function shouldExtendTimeout(commandArgs: string[]): boolean {
  const tokens = stripWrappersAndAssignments(commandArgs);
  if (tokens.length === 0) {
    return false;
  }

  const [first, ...rest] = tokens;
  if (!first) {
    return false;
  }

  if (first === 'pnpm') {
    return shouldExtendViaPnpm(rest);
  }
  if (first === 'bun') {
    return shouldExtendViaBun(rest);
  }

  if (shouldExtendForScript(first) || TEST_BINARIES.has(first.toLowerCase())) {
    return true;
  }

  return rest.some((token) => shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase()));
}

function shouldExtendViaPnpm(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && shouldExtendForScript(script);
  }
  if (subcommand === 'exec') {
    const execTarget = rest[1];
    if (execTarget && (shouldExtendForScript(execTarget) || TEST_BINARIES.has(execTarget.toLowerCase()))) {
      return true;
    }
    return rest.slice(1).some((token) => shouldExtendForScript(token) || TEST_BINARIES.has(token.toLowerCase()));
  }
  return shouldExtendForScript(subcommand);
}

function shouldExtendViaBun(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && shouldExtendForScript(script);
  }
  if (subcommand === 'test') {
    return true;
  }
  if (subcommand === 'x' || subcommand === 'bunx') {
    const execTarget = rest[1];
    if (execTarget && TEST_BINARIES.has(execTarget.toLowerCase())) {
      return true;
    }
  }
  return shouldExtendForScript(subcommand);
}

// Checks script names for long-running markers (lint/test/build/etc.).
function shouldExtendForScript(script: string): boolean {
  if (SINGLE_TEST_SCRIPTS.has(script)) {
    return false;
  }
  return matchesScriptKeyword(script, EXTENDED_SCRIPT_KEYWORDS);
}

// Gives lint invocations the dedicated timeout bucket.
function shouldUseLintTimeout(commandArgs: string[]): boolean {
  const tokens = stripWrappersAndAssignments(commandArgs);
  if (tokens.length === 0) {
    return false;
  }

  const [first, ...rest] = tokens;
  if (!first) {
    return false;
  }

  if (first === 'pnpm') {
    return shouldUseLintTimeoutViaPnpm(rest);
  }
  if (first === 'bun') {
    return shouldUseLintTimeoutViaBun(rest);
  }

  return LINT_BINARIES.has(first.toLowerCase());
}

function shouldUseLintTimeoutViaPnpm(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && script.startsWith('lint');
  }
  if (subcommand === 'exec') {
    const execTarget = rest[1];
    if (execTarget && LINT_BINARIES.has(execTarget.toLowerCase())) {
      return true;
    }
    return rest.slice(1).some((token) => LINT_BINARIES.has(token.toLowerCase()));
  }
  return LINT_BINARIES.has(subcommand.toLowerCase());
}

function shouldUseLintTimeoutViaBun(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && script.startsWith('lint');
  }
  if (subcommand === 'x' || subcommand === 'bunx') {
    return rest.slice(1).some((token) => LINT_BINARIES.has(token.toLowerCase()));
  }
  return LINT_BINARIES.has(subcommand.toLowerCase());
}

// Detects when a user is running a single spec so we can keep the shorter timeout.
function isSingleTestInvocation(commandArgs: string[]): boolean {
  const tokens = stripWrappersAndAssignments(commandArgs);
  if (tokens.length === 0) {
    return false;
  }

  if (tokens.some((token) => SINGLE_TEST_FLAGS.has(token))) {
    return true;
  }

  const [first, ...rest] = tokens;
  if (!first) {
    return false;
  }

  if (first === 'pnpm') {
    return isSingleTestViaPnpm(rest);
  }
  if (first === 'bun') {
    return isSingleTestViaBun(rest);
  }
  if (first === 'vitest') {
    return rest.some((token) => SINGLE_TEST_FLAGS.has(token));
  }

  return SINGLE_TEST_SCRIPTS.has(first);
}

function isSingleTestViaPnpm(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && SINGLE_TEST_SCRIPTS.has(script);
  }
  if (subcommand === 'exec') {
    return rest.slice(1).some((token) => SINGLE_TEST_FLAGS.has(token));
  }
  return SINGLE_TEST_SCRIPTS.has(subcommand);
}

function isSingleTestViaBun(rest: string[]): boolean {
  if (rest.length === 0) {
    return false;
  }
  const subcommand = rest[0];
  if (!subcommand) {
    return false;
  }
  if (subcommand === 'run') {
    const script = rest[1];
    return typeof script === 'string' && SINGLE_TEST_SCRIPTS.has(script);
  }
  if (subcommand === 'test') {
    return true;
  }
  if (subcommand === 'x' || subcommand === 'bunx') {
    return rest.slice(1).some((token) => SINGLE_TEST_FLAGS.has(token));
  }
  return false;
}

// Normalizes potential file paths/flags to aid comparison across shells.
function normalizeForPathComparison(token: string): string {
  return token.replaceAll('\\', '/');
}

// Heuristically checks if a CLI token references an integration spec.
function tokenReferencesIntegrationTest(token: string): boolean {
  const normalized = normalizeForPathComparison(token);
  if (normalized.includes('tests/integration/')) {
    return true;
  }
  if (normalized.startsWith('--run=') || normalized.startsWith('--include=')) {
    const value = normalized.split('=', 2)[1] ?? '';
    return value.includes('tests/integration/');
  }
  return false;
}

// Scans the entire command for integration spec references.
function referencesIntegrationSpec(tokens: string[]): boolean {
  for (let index = 0; index < tokens.length; index += 1) {
    const token = tokens[index];
    if (!token) {
      continue;
    }
    if (token === '--run' || token === '--include') {
      const next = tokens[index + 1];
      if (next && tokenReferencesIntegrationTest(next)) {
        return true;
      }
    }
    if (tokenReferencesIntegrationTest(token)) {
      return true;
    }
  }
  return false;
}

// Helper that matches a script token against a keyword allowlist.
function matchesScriptKeyword(script: string, keywords: readonly string[]): boolean {
  const lowered = script.toLowerCase();
  return keywords.some((keyword) => lowered === keyword || lowered.startsWith(`${keyword}:`));
}

// Removes wrapper binaries/env assignments so heuristics see the real command.
function stripWrappersAndAssignments(args: string[]): string[] {
  const tokens = [...args];

  while (tokens.length > 0) {
    const candidate = tokens[0];
    if (!candidate) {
      break;
    }
    if (!isEnvAssignment(candidate)) {
      break;
    }
    tokens.shift();
  }

  while (tokens.length > 0) {
    const wrapper = tokens[0];
    if (!wrapper) {
      break;
    }
    if (!WRAPPER_COMMANDS.has(wrapper)) {
      break;
    }
    tokens.shift();
    while (tokens.length > 0) {
      const assignment = tokens[0];
      if (!assignment) {
        break;
      }
      if (!isEnvAssignment(assignment)) {
        break;
      }
      tokens.shift();
    }
  }

  return tokens;
}

// Checks whether a token is an inline environment variable assignment.
function isEnvAssignment(token: string): boolean {
  return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
}

// Detects `pnpm test:<suite>` style calls regardless of wrappers.
function isTestRunnerSuiteInvocation(tokens: string[], suite: string): boolean {
  if (tokens.length === 0) {
    return false;
  }

  const normalizedSuite = suite.toLowerCase();
  for (let index = 0; index < tokens.length; index += 1) {
    const token = tokens[index];
    if (!token) {
      continue;
    }
    const normalizedToken = token.replace(/^[./\\]+/, '');
    if (normalizedToken === 'scripts/test-runner.ts' || normalizedToken.endsWith('/scripts/test-runner.ts')) {
      const suiteToken = tokens[index + 1]?.toLowerCase();
      if (suiteToken === normalizedSuite) {
        return true;
      }
    }
  }

  return false;
}

// Grants the longest timeout to explicitly tagged long-running scripts.
function shouldUseLongTimeout(commandArgs: string[]): boolean {
  const tokens = stripWrappersAndAssignments(commandArgs);
  if (tokens.length === 0) {
    return false;
  }

  const first = tokens[0];
  if (!first) {
    return false;
  }
  const rest = tokens.slice(1);
  const matches = (token: string): boolean => matchesScriptKeyword(token, LONG_SCRIPT_KEYWORDS);

  if (first === 'pnpm') {
    if (rest.length === 0) {
      return false;
    }
    const subcommand = rest[0];
    if (!subcommand) {
      return false;
    }
    if (subcommand === 'run') {
      const script = rest[1];
      if (script && matches(script)) {
        return true;
      }
    } else if (matches(subcommand)) {
      return true;
    }
    for (const token of rest.slice(1)) {
      if (matches(token)) {
        return true;
      }
    }
    return false;
  }

  if (matches(first)) {
    return true;
  }

  for (const token of rest) {
    if (matches(token)) {
      return true;
    }
  }

  return false;
}

// Kicks off the requested command with logging, timeouts, and monitoring.
async function runCommand(context: RunnerExecutionContext): Promise<void> {
  const { command, args, env } = buildExecutionParams(context.commandArgs);
  const commandLabel = formatDisplayCommand(context.commandArgs);

  const startTime = Date.now();

  const child = spawn(command, args, {
    cwd: context.workspaceDir,
    env,
    stdio: ['inherit', 'pipe', 'pipe'],
  });

  if (isRunnerTmuxSession()) {
    const childPidInfo = typeof child.pid === 'number' ? ` (pid ${child.pid})` : '';
    console.error(`[runner] Watching ${commandLabel}${childPidInfo}. Wait for the closing sentinel before moving on.`);
  }

  const removeSignalHandlers = registerSignalForwarding(child);

  if (child.stdout) {
    child.stdout.on('data', (chunk: Buffer) => {
      process.stdout.write(chunk);
    });
  }

  if (child.stderr) {
    child.stderr.on('data', (chunk: Buffer) => {
      process.stderr.write(chunk);
    });
  }

  let killTimer: NodeJS.Timeout | null = null;
  try {
    const result = await new Promise<{ exitCode: number; timedOut: boolean }>((resolve, reject) => {
      let timedOut = false;
      const timeout = setTimeout(() => {
        timedOut = true;
        if (ENABLE_DEBUG_LOGS) {
          console.error(`[runner] Command exceeded ${formatDuration(context.timeoutMs)}; sending SIGTERM.`);
        }
        if (!child.killed) {
          child.kill('SIGTERM');
          killTimer = setTimeout(() => {
            if (!child.killed) {
              child.kill('SIGKILL');
            }
          }, 5_000);
        }
      }, context.timeoutMs);

      child.once('error', (error) => {
        clearTimeout(timeout);
        if (killTimer) {
          clearTimeout(killTimer);
        }
        removeSignalHandlers();
        reject(error);
      });

      child.once('exit', (code, signal) => {
        clearTimeout(timeout);
        if (killTimer) {
          clearTimeout(killTimer);
        }
        removeSignalHandlers();
        resolve({ exitCode: code ?? exitCodeFromSignal(signal), timedOut });
      });
    });
    const { exitCode, timedOut } = result;

    const elapsedMs = Date.now() - startTime;
    if (timedOut) {
      console.error(
        `[runner] Command terminated after ${formatDuration(
          context.timeoutMs
        )}. Re-run inside tmux for long-lived work.`
      );
      console.error(formatCompletionSummary({ exitCode, elapsedMs, timedOut: true, commandLabel }));
      process.exit(124);
    }

    if (elapsedMs >= LONG_RUN_REPORT_THRESHOLD_MS) {
      console.error(
        `[runner] Completed in ${formatDuration(elapsedMs)}. For long-running tasks, prefer tmux directly.`
      );
    }

    console.error(formatCompletionSummary({ exitCode, elapsedMs, commandLabel }));
    process.exit(exitCode);
  } catch (error) {
    console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error));
    process.exit(1);
    return;
  }
}

async function runCommandWithoutTimeout(context: RunnerExecutionContext): Promise<void> {
  const { command, args, env } = buildExecutionParams(context.commandArgs);
  const commandLabel = formatDisplayCommand(context.commandArgs);
  const startTime = Date.now();

  const child = spawn(command, args, {
    cwd: context.workspaceDir,
    env,
    stdio: 'inherit',
  });

  const removeSignalHandlers = registerSignalForwarding(child);

  try {
    const exitCode = await new Promise<number>((resolve, reject) => {
      child.once('error', (error) => {
        removeSignalHandlers();
        reject(error);
      });
      child.once('exit', (code, signal) => {
        removeSignalHandlers();
        resolve(code ?? exitCodeFromSignal(signal));
      });
    });
    const elapsedMs = Date.now() - startTime;
    console.error(formatCompletionSummary({ exitCode, elapsedMs, commandLabel }));
    process.exit(exitCode);
  } catch (error) {
    console.error('[runner] Failed to launch command:', error instanceof Error ? error.message : String(error));
    process.exit(1);
  }
}

// Prepares the executable, args, and sanitized env for the child process.
function buildExecutionParams(commandArgs: string[]): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
  const env = { ...process.env };
  const args: string[] = [];
  let commandStarted = false;

  for (const token of commandArgs) {
    if (!commandStarted && isEnvAssignment(token)) {
      const [key, ...rest] = token.split('=');
      if (key) {
        env[key] = rest.join('=');
      }
      continue;
    }
    commandStarted = true;
    args.push(token);
  }

  if (args.length === 0 || !args[0]) {
    printUsage('Missing command to execute.');
    process.exit(1);
  }

  const [command, ...restArgs] = args;
  return { command, args: restArgs, env };
}

// Forwards termination signals to the child and returns an unregister hook.
function registerSignalForwarding(child: ChildProcess): () => void {
  const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
  const handlers = new Map<NodeJS.Signals, () => void>();

  for (const signal of signals) {
    const handler = () => {
      if (!child.killed) {
        child.kill(signal);
      }
    };
    handlers.set(signal, handler);
    process.on(signal, handler);
  }

  return () => {
    for (const [signal, handler] of handlers) {
      process.off(signal, handler);
    }
  };
}

// Maps a terminating signal to the exit code conventions bash expects.
function exitCodeFromSignal(signal: NodeJS.Signals | null): number {
  if (!signal) {
    return 0;
  }
  const code = (osConstants.signals as Record<string, number | undefined>)[signal];
  if (typeof code === 'number') {
    return 128 + code;
  }
  return 1;
}

// Gives policy interceptors a chance to fully handle a command before exec.
async function resolveCommandInterception(context: RunnerExecutionContext): Promise<CommandInterceptionResult> {
  const interceptors: Array<(ctx: RunnerExecutionContext) => Promise<boolean>> = [
    maybeHandleTmuxInvocation,
    maybeHandleFindInvocation,
    maybeHandleRmInvocation,
    maybeHandleSleepInvocation,
  ];

  for (const interceptor of interceptors) {
    if (await interceptor(context)) {
      return { handled: true };
    }
  }

  const gitContext = analyzeGitExecution(context.commandArgs, context.workspaceDir);

  if (await maybeHandleGitRm(gitContext)) {
    return { handled: true };
  }

  return { handled: false, gitContext };
}

// Runs the shared git policy analyzers before dispatching the command.
function enforceGitPolicies(gitContext: GitExecutionContext) {
  const evaluation = evaluateGitPolicies(gitContext);
  const hasConsentOverride = process.env.RUNNER_THE_USER_GAVE_ME_CONSENT === '1';

  if (gitContext.subcommand === 'rebase' && !hasConsentOverride) {
    console.error(
      'git rebase requires the user to explicitly type "rebase" in chat. Once they do, rerun with RUNNER_THE_USER_GAVE_ME_CONSENT=1 in the same command (e.g. RUNNER_THE_USER_GAVE_ME_CONSENT=1 ./runner git rebase --continue).'
    );
    process.exit(1);
  }

  if (evaluation.requiresCommitHelper) {
    console.error(
      'Direct git add/commit is disabled. Use ./scripts/committer "chore(runner): describe change" "scripts/runner.ts" instead—see AGENTS.md and ./scripts/committer for details. The helper auto-stashes unrelated files before committing.'
    );
    process.exit(1);
  }

  if (evaluation.requiresExplicitConsent || evaluation.isDestructive) {
    if (hasConsentOverride) {
      if (ENABLE_DEBUG_LOGS) {
        const reason = evaluation.isDestructive ? 'destructive git command' : 'guarded git command';
        console.error(`[runner] Proceeding with ${reason} because RUNNER_THE_USER_GAVE_ME_CONSENT=1.`);
      }
    } else {
      if (evaluation.isDestructive) {
        console.error(
          `git ${gitContext.subcommand ?? ''} can overwrite or discard work. Confirm with the user first, then re-run with RUNNER_THE_USER_GAVE_ME_CONSENT=1 if they approve.`
        );
      } else {
        console.error(
          `Using git ${gitContext.subcommand ?? ''} requires consent. Set RUNNER_THE_USER_GAVE_ME_CONSENT=1 after verifying with the user, or ask them explicitly before proceeding.`
        );
      }
      process.exit(1);
    }
  }
}

// Handles guarded `find` invocations that may delete files outright.
async function maybeHandleFindInvocation(context: RunnerExecutionContext): Promise<boolean> {
  const findInvocation = extractFindInvocation(context.commandArgs);
  if (!findInvocation) {
    return false;
  }

  const findPlan = await buildFindDeletePlan(findInvocation.argv, context.workspaceDir);
  if (!findPlan) {
    return false;
  }

  const moveResult = await movePathsToTrash(findPlan.paths, context.workspaceDir, { allowMissing: false });
  if (moveResult.missing.length > 0) {
    for (const path of moveResult.missing) {
      console.error(`find: ${path}: No such file or directory`);
    }
    process.exit(1);
  }
  if (moveResult.errors.length > 0) {
    for (const error of moveResult.errors) {
      console.error(error);
    }
    process.exit(1);
  }
  process.exit(0);
  return true;
}

// Intercepts plain `rm` commands to route them through trash safeguards.
async function maybeHandleRmInvocation(context: RunnerExecutionContext): Promise<boolean> {
  const rmInvocation = extractRmInvocation(context.commandArgs);
  if (!rmInvocation) {
    return false;
  }

  const rmPlan = parseRmArguments(rmInvocation.argv);
  if (!rmPlan?.shouldIntercept) {
    return false;
  }

  try {
    const moveResult = await movePathsToTrash(rmPlan.targets, context.workspaceDir, { allowMissing: rmPlan.force });
    reportMissingForRm(moveResult.missing, rmPlan.force);
    if (moveResult.errors.length > 0) {
      for (const error of moveResult.errors) {
        console.error(error);
      }
      process.exit(1);
    }
    process.exit(0);
  } catch (error) {
    console.error(formatTrashError(error));
    process.exit(1);
  }
  return true;
}

// Applies git-specific rm protections before the command executes.
async function maybeHandleGitRm(gitContext: GitExecutionContext): Promise<boolean> {
  if (gitContext.command?.name !== 'rm' || !gitContext.invocation) {
    return false;
  }

  const gitRmPlan = parseGitRmArguments(gitContext.invocation.argv, gitContext.command);
  if (!gitRmPlan?.shouldIntercept) {
    return false;
  }

  try {
    const moveResult = await movePathsToTrash(gitRmPlan.paths, gitContext.workDir, {
      allowMissing: gitRmPlan.allowMissing,
    });
    if (!gitRmPlan.allowMissing && moveResult.missing.length > 0) {
      for (const path of moveResult.missing) {
        console.error(`git rm: ${path}: No such file or directory`);
      }
      process.exit(1);
    }
    if (moveResult.errors.length > 0) {
      for (const error of moveResult.errors) {
        console.error(error);
      }
      process.exit(1);
    }
    await stageGitRm(gitContext.workDir, gitRmPlan);
    process.exit(0);
  } catch (error) {
    console.error(formatTrashError(error));
    process.exit(1);
  }
  return true;
}

// Blocks `sleep` calls longer than the AGENTS.md ceiling so scripts cannot stall the runner.
async function maybeHandleSleepInvocation(context: RunnerExecutionContext): Promise<boolean> {
  const tokens = stripWrappersAndAssignments(context.commandArgs);
  if (tokens.length === 0) {
    return false;
  }
  const [first, ...rest] = tokens;
  if (!first || !isSleepBinary(first) || rest.length === 0) {
    return false;
  }

  const commandIndex = context.commandArgs.length - tokens.length;
  if (commandIndex < 0) {
    return false;
  }

  const adjustedArgs = [...context.commandArgs];
  const adjustments: string[] = [];

  for (let offset = 0; offset < rest.length; offset += 1) {
    const token = rest[offset];
    if (!token) {
      continue;
    }
    const durationSeconds = parseSleepDurationSeconds(token);
    if (durationSeconds == null || durationSeconds <= MAX_SLEEP_SECONDS) {
      continue;
    }
    adjustments.push(`${token}→${formatSleepDuration(MAX_SLEEP_SECONDS)}`);
    adjustedArgs[commandIndex + 1 + offset] = formatSleepArgument(MAX_SLEEP_SECONDS);
  }

  if (adjustments.length === 0) {
    return false;
  }

  console.error(`[runner] sleep arguments exceed ${MAX_SLEEP_SECONDS}s; clamping (${adjustments.join(', ')}).`);
  context.commandArgs = adjustedArgs;
  return false;
}

async function maybeHandleTmuxInvocation(context: RunnerExecutionContext): Promise<boolean> {
  const tokens = stripWrappersAndAssignments(context.commandArgs);
  if (tokens.length === 0) {
    return false;
  }
  const candidate = tokens[0];
  if (!candidate) {
    return false;
  }
  if (basename(candidate) !== 'tmux') {
    return false;
  }
  console.error('[runner] Detected tmux invocation; executing command without runner timeout guardrails.');
  await runCommandWithoutTimeout(context);
  return true;
}

function parseSleepDurationSeconds(token: string): number | null {
  const match = /^(\d+(?:\.\d+)?)([smhdSMHD]?)$/.exec(token);
  if (!match) {
    return null;
  }
  const value = Number(match[1]);
  if (!Number.isFinite(value)) {
    return null;
  }
  const unit = match[2]?.toLowerCase() ?? '';
  const multiplier = unit === 'm' ? 60 : unit === 'h' ? 60 * 60 : unit === 'd' ? 60 * 60 * 24 : 1;
  return value * multiplier;
}

function formatSleepArgument(seconds: number): string {
  return Number.isInteger(seconds) ? `${seconds}` : seconds.toString();
}

function formatSleepDuration(seconds: number): string {
  if (Number.isInteger(seconds)) {
    return `${seconds}s`;
  }
  return `${seconds.toFixed(2)}s`;
}

function isSleepBinary(token: string): boolean {
  return token === 'sleep' || token.endsWith('/sleep');
}

// Detects `git find` invocations that need policy enforcement.
function extractFindInvocation(commandArgs: string[]): GitInvocation | null {
  for (const [index, token] of commandArgs.entries()) {
    if (token === 'find' || token.endsWith('/find')) {
      return { index, argv: commandArgs.slice(index) };
    }
  }
  return null;
}

// Detects `git rm` variants so we can intercept destructive operations.
function extractRmInvocation(commandArgs: string[]): GitInvocation | null {
  if (commandArgs.length === 0) {
    return null;
  }

  const wrappers = new Set([
    'sudo',
    '/usr/bin/sudo',
    'env',
    '/usr/bin/env',
    'command',
    '/bin/command',
    'nohup',
    '/usr/bin/nohup',
  ]);

  let index = 0;
  while (index < commandArgs.length) {
    const token = commandArgs[index];
    if (!token) {
      break;
    }
    if (token.includes('=') && !token.startsWith('-')) {
      index += 1;
      continue;
    }
    if (wrappers.has(token)) {
      index += 1;
      continue;
    }
    break;
  }

  const commandToken = commandArgs[index];
  if (!commandToken) {
    return null;
  }

  const isRmCommand =
    commandToken === 'rm' ||
    commandToken.endsWith('/rm') ||
    commandToken === 'rm.exe' ||
    commandToken.endsWith('\\rm.exe');

  if (!isRmCommand) {
    return null;
  }

  return { index, argv: commandArgs.slice(index) };
}

// Expands guarded find expressions into an explicit delete plan for review.
async function buildFindDeletePlan(findArgs: string[], workspaceDir: string): Promise<{ paths: string[] } | null> {
  if (!findArgs.some((token) => token === '-delete')) {
    return null;
  }

  if (findArgs.some((token) => token === '-exec' || token === '-execdir' || token === '-ok' || token === '-okdir')) {
    console.error(
      'Runner cannot safely translate find invocations that combine -delete with -exec/-ok. Run the command manually after reviewing the paths.'
    );
    process.exit(1);
  }

  const printableArgs: string[] = [];
  for (const token of findArgs) {
    if (token === '-delete') {
      continue;
    }
    printableArgs.push(token);
  }
  printableArgs.push('-print0');

  const proc = Bun.spawn(printableArgs, {
    cwd: workspaceDir,
    stdout: 'pipe',
    stderr: 'pipe',
  });

  const [exitCode, stdoutBuf, stderrBuf] = await Promise.all([
    proc.exited,
    readProcessStream(proc.stdout),
    readProcessStream(proc.stderr),
  ]);

  if (exitCode !== 0) {
    const stderrText = stderrBuf.trim();
    const stdoutText = stdoutBuf.trim();
    if (stderrText.length > 0) {
      console.error(stderrText);
    } else if (stdoutText.length > 0) {
      console.error(stdoutText);
    }
    process.exit(exitCode);
  }

  const matches = stdoutBuf.split('\0').filter((entry: string) => entry.length > 0);
  if (matches.length === 0) {
    return { paths: [] };
  }

  const uniquePaths = new Map<string, string>();
  const workspaceCanonical = normalize(workspaceDir);

  for (const match of matches) {
    const absolute = isAbsolute(match) ? match : resolve(workspaceDir, match);
    const canonical = normalize(absolute);
    if (canonical === workspaceCanonical) {
      console.error('Refusing to trash the current workspace via find -delete. Narrow your find predicate.');
      process.exit(1);
    }
    if (!uniquePaths.has(canonical)) {
      uniquePaths.set(canonical, match);
    }
  }

  return { paths: Array.from(uniquePaths.values()) };
}

// Parses rm flags/targets to decide whether the runner should intervene.
function parseRmArguments(argv: string[]): { targets: string[]; force: boolean; shouldIntercept: boolean } | null {
  if (argv.length <= 1) {
    return null;
  }
  const targets: string[] = [];
  let force = false;
  let treatAsTarget = false;

  let index = 1;
  while (index < argv.length) {
    const token = argv[index];
    if (token === undefined) {
      break;
    }
    if (!treatAsTarget && token === '--') {
      treatAsTarget = true;
      index += 1;
      continue;
    }
    if (!treatAsTarget && token.startsWith('-') && token.length > 1) {
      if (token.includes('f')) {
        force = true;
      }
      if (token.includes('i') || token === '--interactive') {
        return null;
      }
      if (token === '--help' || token === '--version') {
        return null;
      }
      index += 1;
      continue;
    }
    targets.push(token);
    index += 1;
  }

  const firstTarget = targets[0];
  if (firstTarget === undefined) {
    return null;
  }

  return { targets, force, shouldIntercept: true };
}

// Generates a safe plan for git rm invocations, honoring guarded paths.
function parseGitRmArguments(argv: string[], command: GitCommandInfo): GitRmPlan | null {
  const stagingOptions: string[] = [];
  const paths: string[] = [];
  const optionsExpectingValue = new Set(['--pathspec-from-file']);
  let allowMissing = false;
  let treatAsPath = false;

  let index = command.index + 1;
  while (index < argv.length) {
    const token = argv[index];
    if (token === undefined) {
      break;
    }
    if (!treatAsPath && token === '--') {
      treatAsPath = true;
      index += 1;
      continue;
    }
    if (!treatAsPath && token.startsWith('-') && token.length > 1) {
      if (token === '--cached' || token === '--dry-run' || token === '-n') {
        return null;
      }
      if (token === '--ignore-unmatch' || token === '--force' || token === '-f') {
        allowMissing = true;
        stagingOptions.push(token);
        index += 1;
        continue;
      }
      if (optionsExpectingValue.has(token)) {
        const value = argv[index + 1];
        if (value) {
          stagingOptions.push(token, value);
          index += 2;
        } else {
          index += 1;
        }
        continue;
      }
      if (!token.startsWith('--')) {
        const flags = token.slice(1).split('');
        const retainedFlags: string[] = [];
        for (const flag of flags) {
          if (flag === 'n') {
            return null;
          }
          if (flag === 'f') {
            allowMissing = true;
            continue;
          }
          retainedFlags.push(flag);
        }
        if (retainedFlags.length > 0) {
          stagingOptions.push(`-${retainedFlags.join('')}`);
        }
        index += 1;
        continue;
      }
      stagingOptions.push(token);
      index += 1;
      continue;
    }
    if (token.length > 0) {
      paths.push(token);
    }
    index += 1;
  }

  if (paths.length === 0) {
    return null;
  }
  return {
    paths,
    stagingOptions,
    allowMissing,
    shouldIntercept: true,
  };
}

// Emits actionable messaging when git rm targets are already gone.
function reportMissingForRm(missing: string[], forced: boolean) {
  if (missing.length === 0 || forced) {
    return;
  }
  for (const path of missing) {
    console.error(`rm: ${path}: No such file or directory`);
  }
  process.exit(1);
}

// Attempts to move the provided paths into trash instead of deleting in place.
async function movePathsToTrash(
  paths: string[],
  baseDir: string,
  options: { allowMissing: boolean }
): Promise<MoveResult> {
  const missing: string[] = [];
  const existing: { raw: string; absolute: string }[] = [];

  for (const rawPath of paths) {
    const absolute = resolvePath(baseDir, rawPath);
    if (!existsSync(absolute)) {
      if (!options.allowMissing) {
        missing.push(rawPath);
      }
      continue;
    }
    existing.push({ raw: rawPath, absolute });
  }

  if (existing.length === 0) {
    return { missing, errors: [] };
  }

  const trashCliCommand = await findTrashCliCommand();
  if (trashCliCommand) {
    try {
      const cliArgs = [trashCliCommand, ...existing.map((item) => item.absolute)];
      const proc = Bun.spawn(cliArgs, {
        stdout: 'ignore',
        stderr: 'pipe',
      });
      const [exitCode, stderrText] = await Promise.all([proc.exited, readProcessStream(proc.stderr)]);
      if (exitCode === 0) {
        return { missing, errors: [] };
      }
      if (ENABLE_DEBUG_LOGS && stderrText.trim().length > 0) {
        console.error(`[runner] trash-cli error (${trashCliCommand}): ${stderrText.trim()}`);
      }
    } catch (error) {
      if (ENABLE_DEBUG_LOGS) {
        console.error(`[runner] trash-cli invocation failed: ${formatTrashError(error)}`);
      }
    }
  }

  const trashDir = getTrashDirectory();
  if (!trashDir) {
    return {
      missing,
      errors: ['Unable to locate macOS Trash directory (HOME/.Trash).'],
    };
  }

  const errors: string[] = [];

  for (const item of existing) {
    try {
      const target = buildTrashTarget(trashDir, item.absolute);
      try {
        renameSync(item.absolute, target);
      } catch (error) {
        if (isCrossDeviceError(error)) {
          cpSync(item.absolute, target, { recursive: true });
          rmSync(item.absolute, { recursive: true, force: true });
        } else {
          throw error;
        }
      }
    } catch (error) {
      errors.push(`Failed to move ${item.raw} to Trash: ${formatTrashError(error)}`);
    }
  }

  return { missing, errors };
}

// Resolves a potentially relative path against the workspace root.
function resolvePath(baseDir: string, input: string): string {
  if (input.startsWith('/')) {
    return input;
  }
  return resolve(baseDir, input);
}

// Returns the trash CLI directory if available so deletes can be safe.
function getTrashDirectory(): string | null {
  const home = process.env.HOME;
  if (!home) {
    return null;
  }
  const trash = join(home, '.Trash');
  if (!existsSync(trash)) {
    return null;
  }
  return trash;
}

// Builds the destination path inside the trash directory for a file.
function buildTrashTarget(trashDir: string, absolutePath: string): string {
  const baseName = basename(absolutePath);
  const timestamp = Date.now();
  let attempt = 0;
  let candidate = join(trashDir, baseName);
  while (existsSync(candidate)) {
    candidate = join(trashDir, `${baseName}-${timestamp}${attempt > 0 ? `-${attempt}` : ''}`);
    attempt += 1;
  }
  return candidate;
}

// Determines whether a rename failed because the devices differ.
function isCrossDeviceError(error: unknown): boolean {
  return error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EXDEV';
}

// Normalizes trash/rename errors into a readable string.
function formatTrashError(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

// Replays a git rm plan via spawn so we can surface errors consistently.
async function stageGitRm(workDir: string, plan: GitRmPlan) {
  if (plan.paths.length === 0) {
    return;
  }
  const args = ['git', 'rm', '--cached', '--quiet', ...plan.stagingOptions, '--', ...plan.paths];
  const proc = Bun.spawn(args, {
    cwd: workDir,
    stdout: 'inherit',
    stderr: 'inherit',
  });
  const exitCode = await proc.exited;
  if (exitCode !== 0) {
    throw new Error(`git rm --cached exited with status ${exitCode}.`);
  }
}

// Locates a usable trash CLI binary, caching the lookup per runner process.
async function findTrashCliCommand(): Promise<string | null> {
  if (cachedTrashCliCommand !== undefined) {
    return cachedTrashCliCommand;
  }

  const candidateNames = ['trash-put', 'trash'];
  const searchDirs = new Set<string>();

  if (process.env.PATH) {
    for (const segment of process.env.PATH.split(':')) {
      if (segment && segment.length > 0) {
        searchDirs.add(segment);
      }
    }
  }

  const homebrewPrefix = process.env.HOMEBREW_PREFIX ?? '/opt/homebrew';
  searchDirs.add(join(homebrewPrefix, 'opt', 'trash', 'bin'));
  searchDirs.add('/usr/local/opt/trash/bin');

  const candidatePaths = new Set<string>();
  for (const name of candidateNames) {
    candidatePaths.add(name);
    for (const dir of searchDirs) {
      candidatePaths.add(join(dir, name));
    }
  }

  for (const candidate of candidatePaths) {
    try {
      const proc = Bun.spawn([candidate, '--help'], {
        stdout: 'ignore',
        stderr: 'ignore',
      });
      const exitCode = await proc.exited;
      if (exitCode === 0 || exitCode === 1) {
        cachedTrashCliCommand = candidate;
        return candidate;
      }
    } catch (error) {
      if (ENABLE_DEBUG_LOGS) {
        console.error(`[runner] trash-cli probe failed for ${candidate}: ${formatTrashError(error)}`);
      }
    }
  }

  cachedTrashCliCommand = null;
  return null;
}

// Consumes a child process stream to completion for logging/error output.
async function readProcessStream(stream: unknown): Promise<string> {
  if (!stream) {
    return '';
  }
  try {
    const candidate = stream as { text?: () => Promise<string> };
    if (candidate.text) {
      return (await candidate.text()) ?? '';
    }
  } catch {
    // ignore
  }
  try {
    if (stream instanceof ReadableStream) {
      return await new Response(stream).text();
    }
    if (typeof stream === 'object' && stream !== null) {
      return await new Response(stream as BodyInit).text();
    }
  } catch {
    // ignore errors and return empty string
  }
  return '';
}

// Shows CLI usage plus optional error messaging.
function printUsage(message?: string) {
  if (message) {
    console.error(`[runner] ${message}`);
  }
  console.error('Usage: runner [--] <command...>');
  console.error('');
  console.error(
    `Defaults: ${formatDuration(DEFAULT_TIMEOUT_MS)} timeout for most commands, ${formatDuration(
      EXTENDED_TIMEOUT_MS
    )} when lint/test suites are detected.`
  );
}

// Pretty-prints a millisecond duration for logs.
function formatDuration(durationMs: number): string {
  if (durationMs < 1000) {
    return `${durationMs}ms`;
  }
  const seconds = durationMs / 1000;
  if (seconds < 60) {
    return `${seconds.toFixed(1)}s`;
  }
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = Math.round(seconds % 60);
  if (minutes < 60) {
    if (remainingSeconds === 0) {
      return `${minutes}m`;
    }
    return `${minutes}m ${remainingSeconds}s`;
  }
  const hours = Math.floor(minutes / 60);
  const remainingMinutes = minutes % 60;
  if (remainingMinutes === 0) {
    return `${hours}h`;
  }
  return `${hours}h ${remainingMinutes}m`;
}

function resolveSummaryStyle(rawValue: string | undefined | null): SummaryStyle {
  if (!rawValue) {
    return 'compact';
  }
  const normalized = rawValue.trim().toLowerCase();
  if (normalized === 'minimal') {
    return 'minimal';
  }
  if (normalized === 'verbose') {
    return 'verbose';
  }
  if (normalized === 'short' || normalized === 'compact') {
    return 'compact';
  }
  return 'compact';
}

function formatCompletionSummary(options: {
  exitCode: number;
  elapsedMs?: number;
  timedOut?: boolean;
  commandLabel: string;
}): string {
  const { exitCode, elapsedMs, timedOut, commandLabel } = options;
  const durationText = typeof elapsedMs === 'number' ? formatDuration(elapsedMs) : null;
  switch (SUMMARY_STYLE) {
    case 'minimal': {
      const parts = [`${exitCode}`];
      if (durationText) {
        parts.push(durationText);
      }
      if (timedOut) {
        parts.push('timeout');
      }
      return `[runner] ${parts.join(' · ')}`;
    }
    case 'verbose': {
      const elapsedPart = durationText ? `, elapsed ${durationText}` : '';
      const timeoutPart = timedOut ? '; timed out' : '';
      return `[runner] Finished ${commandLabel} (exit ${exitCode}${elapsedPart}${timeoutPart}).`;
    }
    default: {
      const elapsedPart = durationText ? ` in ${durationText}` : '';
      const timeoutPart = timedOut ? ' (timeout)' : '';
      return `[runner] exit ${exitCode}${elapsedPart}${timeoutPart}`;
    }
  }
}

// Joins the command args in a shell-friendly way for log display.
function formatDisplayCommand(commandArgs: string[]): string {
  return commandArgs.map((token) => (token.includes(' ') ? `"${token}"` : token)).join(' ');
}

// Tells whether the runner is already executing inside the tmux guard.
function isRunnerTmuxSession(): boolean {
  const value = process.env.RUNNER_TMUX;
  if (value) {
    return value !== '0' && value.toLowerCase() !== 'false';
  }
  return Boolean(process.env.TMUX);
}
