import { readFileSync, realpathSync } from "node:fs";
import { relative, resolve, sep } from "node:path";

import { resolveAdapterGlobalSettingsPaths } from "./util/claude-config.js";

// ==============================================================================
// Types
// ==============================================================================

export type PermissionDecision = "allow" | "deny" | "ask";

export interface SecurityPolicy {
  allow: string[];
  deny: string[];
  ask: string[];
}

// ==============================================================================
// Pattern Parsing
// ==============================================================================

/**
 * Extract the glob from a Bash permission pattern.
 * "Bash(sudo *)" returns "sudo *", "Read(.env)" returns null.
 */
export function parseBashPattern(pattern: string): string | null {
  // .+ is greedy: for "Bash(echo (foo))" it captures "echo (foo)"
  // because $ forces the final \) to match only the last paren.
  const match = pattern.match(/^Bash\((.+)\)$/);
  return match ? match[1] : null;
}

/**
 * Parse any tool permission pattern like "ToolName(glob)".
 * Returns { tool, glob } or null if not a valid pattern.
 */
export function parseToolPattern(
  pattern: string,
): { tool: string; glob: string } | null {
  // .+ is greedy: for "Read(some(path))" it captures "some(path)"
  // because $ forces the final \) to match only the last paren.
  const match = pattern.match(/^(\w+)\((.+)\)$/);
  return match ? { tool: match[1], glob: match[2] } : null;
}

// ==============================================================================
// Glob-to-Regex Conversion
// ==============================================================================

/** Escape all regex special characters (including *). */
function escapeRegex(str: string): string {
  return str.replace(/[.*+?^${}()|[\]\\\/\-]/g, "\\$&");
}

/** Escape regex specials except *, then convert * to .* */
function convertGlobPart(glob: string): string {
  return glob
    .replace(/[.+?^${}()|[\]\\\/\-]/g, "\\$&")
    .replace(/\*/g, ".*");
}

/**
 * Convert a Bash permission glob to a regex.
 *
 * Two formats:
 * - Colon: "tree:*" becomes /^tree(\s.*)?$/ (command with optional args)
 * - Space: "sudo *" becomes /^sudo .*$/  (literal glob match)
 */
export function globToRegex(
  glob: string,
  caseInsensitive: boolean = false,
): RegExp {
  let regexStr: string;

  const colonIdx = glob.indexOf(":");
  if (colonIdx !== -1) {
    // Colon format: "command:argsGlob"
    const command = glob.slice(0, colonIdx);
    const argsGlob = glob.slice(colonIdx + 1);
    const escapedCmd = escapeRegex(command);
    const argsRegex = convertGlobPart(argsGlob);
    // Match command alone OR command + space + args
    regexStr = `^${escapedCmd}(\\s${argsRegex})?$`;
  } else {
    // Plain glob: "sudo *", "ls*", "* commit *"
    regexStr = `^${convertGlobPart(glob)}$`;
  }

  return new RegExp(regexStr, caseInsensitive ? "i" : "");
}

/**
 * Convert a file path glob to a regex.
 *
 * Unlike `globToRegex` (which handles command patterns with colon and
 * space semantics), this handles file path globs where:
 * - `**` matches any number of path segments (including zero)
 * - `*` matches anything except path separators
 * - Paths are matched with forward slashes (callers normalize first)
 */
export function fileGlobToRegex(
  glob: string,
  caseInsensitive: boolean = false,
): RegExp {
  let regexStr = "";
  let i = 0;

  while (i < glob.length) {
    // Handle ** (globstar): match any number of directory segments
    if (glob[i] === "*" && glob[i + 1] === "*") {
      // **/ at the start or after a slash means "zero or more directories"
      if (i + 2 < glob.length && glob[i + 2] === "/") {
        regexStr += "(.*/)?";
        i += 3; // skip "*" "*" "/"
      } else {
        // Trailing ** matches everything
        regexStr += ".*";
        i += 2;
      }
    } else if (glob[i] === "*") {
      // Single * matches anything except /
      regexStr += "[^/]*";
      i++;
    } else if (glob[i] === "?") {
      regexStr += "[^/]";
      i++;
    } else {
      // Escape regex-special characters
      regexStr += glob[i].replace(/[.+^${}()|[\]\\\/\-]/g, "\\$&");
      i++;
    }
  }

  return new RegExp(`^${regexStr}$`, caseInsensitive ? "i" : "");
}

/**
 * Check if a command matches any Bash pattern in the list.
 * Returns the matching pattern string, or null.
 */
export function matchesAnyPattern(
  command: string,
  patterns: string[],
  caseInsensitive: boolean = false,
): string | null {
  for (const pattern of patterns) {
    const glob = parseBashPattern(pattern);
    if (!glob) continue;
    if (globToRegex(glob, caseInsensitive).test(command)) return pattern;
  }
  return null;
}

// ==============================================================================
// Chained Command Splitting & Subshell Extraction
// ==============================================================================

function isEscaped(command: string, index: number): boolean {
  let backslashes = 0;
  for (let i = index - 1; i >= 0 && command[i] === "\\"; i--) {
    backslashes++;
  }
  return backslashes % 2 === 1;
}

/**
 * Split a shell command on chain operators (&&, ||, ;, |, \n, \r, &) while
 * respecting single/double quotes, backticks, subshells, and escape backslashes.
 *
 * "echo hello && sudo rm -rf /" → ["echo hello", "sudo rm -rf /"]
 *
 * This prevents bypassing deny patterns by prepending innocent commands.
 */
export function splitChainedCommands(command: string): string[] {
  const parts: string[] = [];
  let current = "";
  let inSingle = false;
  let inDouble = false;
  let inBacktick = false;
  let dollarParenDepth = 0;

  for (let i = 0; i < command.length; i++) {
    const ch = command[i];
    const escaped = isEscaped(command, i);

    if (ch === "'" && !inDouble && !inBacktick && !escaped) {
      inSingle = !inSingle;
      current += ch;
    } else if (ch === '"' && !inSingle && !inBacktick && !escaped) {
      inDouble = !inDouble;
      current += ch;
    } else if (ch === "`" && !inSingle && !inDouble && !escaped) {
      inBacktick = !inBacktick;
      current += ch;
    } else if (!inSingle && !inDouble && !inBacktick) {
      if (ch === "$" && command[i + 1] === "(" && !escaped) {
        dollarParenDepth++;
        current += ch + command[i + 1];
        i++;
      } else if (dollarParenDepth > 0 && ch === "(" && !escaped) {
        dollarParenDepth++;
        current += ch;
      } else if (ch === ")" && dollarParenDepth > 0 && !escaped) {
        dollarParenDepth--;
        current += ch;
      } else if (
        dollarParenDepth === 0 &&
        (ch === ";" || ch === "\n" || ch === "\r") &&
        !escaped
      ) {
        parts.push(current.trim());
        current = "";
      } else if (dollarParenDepth === 0 && ch === "|" && command[i + 1] === "|") {
        parts.push(current.trim());
        current = "";
        i++; // skip second |
      } else if (dollarParenDepth === 0 && ch === "&" && command[i + 1] === "&") {
        parts.push(current.trim());
        current = "";
        i++; // skip second &
      } else if (dollarParenDepth === 0 && ch === "&" && !escaped) {
        parts.push(current.trim());
        current = "";
      } else if (dollarParenDepth === 0 && ch === "|") {
        // Single pipe — left side is a command too
        parts.push(current.trim());
        current = "";
      } else {
        current += ch;
      }
    } else {
      current += ch;
    }
  }
  if (current.trim()) parts.push(current.trim());
  return parts.filter((p) => p.length > 0);
}

/**
 * Recursively extract all nested subshell commands from `$()` and `` `...` ``.
 * Handles escaping and quote contexts to ensure correct command boundary detection.
 */
export function extractSubshellCommands(command: string): string[] {
  const subshells: string[] = [];
  let inSingle = false;
  let inDouble = false;
  let backtickStart = -1;

  const dollarParenStarts: number[] = [];
  const dollarParenDepths: number[] = [];
  let parenDepth = 0;

  for (let i = 0; i < command.length; i++) {
    const ch = command[i];
    const escaped = isEscaped(command, i);

    if (ch === "'" && !inDouble && backtickStart === -1 && !escaped) {
      inSingle = !inSingle;
    } else if (ch === '"' && !inSingle && backtickStart === -1 && !escaped) {
      inDouble = !inDouble;
    } else if (ch === "`" && !inSingle && !inDouble && !escaped) {
      if (backtickStart === -1) {
        backtickStart = i + 1;
      } else {
        const sub = command.slice(backtickStart, i);
        subshells.push(sub);
        subshells.push(...extractSubshellCommands(sub));
        backtickStart = -1;
      }
    } else if (!inSingle && backtickStart === -1) {
      if (ch === "$" && command[i + 1] === "(" && !escaped) {
        if (command[i + 2] === "(") {
          // Arithmetic expansion is not command execution, but nested command
          // substitutions inside it still get discovered by the scanner.
          parenDepth += 2;
          i += 2; // skip '(('
        } else {
          dollarParenStarts.push(i + 2);
          dollarParenDepths.push(parenDepth);
          parenDepth++;
          i++; // skip '('
        }
      } else if (ch === "(" && !escaped) {
        parenDepth++;
      } else if (ch === ")" && !escaped) {
        if (parenDepth > 0) {
          parenDepth--;
        }
        if (
          dollarParenDepths.length > 0 &&
          parenDepth === dollarParenDepths[dollarParenDepths.length - 1]
        ) {
          dollarParenDepths.pop();
          const start = dollarParenStarts.pop()!;
          const sub = command.slice(start, i);
          subshells.push(sub);
        }
      }
    }
  }
  return subshells;
}

function collectCommandElements(command: string): string[] {
  const elements: string[] = [];
  const segments = splitChainedCommands(command);
  for (const segment of segments) {
    elements.push(segment);
    for (const subshell of extractSubshellCommands(segment)) {
      elements.push(...collectCommandElements(subshell));
    }
  }
  return elements;
}

// ==============================================================================
// Settings Reader
// ==============================================================================

/** Read one settings file and return a SecurityPolicy with only Bash patterns. */
function readSingleSettings(path: string): SecurityPolicy | null {
  let raw: string;
  try {
    raw = readFileSync(path, "utf-8");
  } catch {
    return null;
  }

  let parsed: any;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return null;
  }

  const perms = parsed?.permissions;
  if (!perms || typeof perms !== "object") return null;

  const filterBash = (arr: unknown): string[] => {
    if (!Array.isArray(arr)) return [];
    return arr.filter(
      (p): p is string => typeof p === "string" && parseBashPattern(p) !== null,
    );
  };

  return {
    allow: filterBash(perms.allow),
    deny: filterBash(perms.deny),
    ask: filterBash(perms.ask),
  };
}

/**
 * Read Bash permission policies from up to 3 settings files.
 *
 * Returns policies in precedence order (most local first):
 *   1. .claude/settings.local.json  (project-local)
 *   2. .claude/settings.json        (project-shared)
 *   3. ~/.claude/settings.json      (global)
 *
 * Missing or invalid files are silently skipped.
 */
export function readBashPolicies(
  projectDir?: string,
  globalSettingsPath?: string,
): SecurityPolicy[] {
  const policies: SecurityPolicy[] = [];

  if (projectDir) {
    const localPath = resolve(projectDir, ".claude", "settings.local.json");
    const localPolicy = readSingleSettings(localPath);
    if (localPolicy) policies.push(localPolicy);

    const sharedPath = resolve(projectDir, ".claude", "settings.json");
    const sharedPolicy = readSingleSettings(sharedPath);
    if (sharedPolicy) policies.push(sharedPolicy);
  }

  // Issue #451 round-3: read settings from EVERY adapter-specific global path
  // PLUS the claude global (defense in depth). When the caller passes an
  // explicit globalSettingsPath we honor it verbatim (back-compat with tests
  // and callers that already know which file to read).
  const globalPaths =
    globalSettingsPath !== undefined
      ? [globalSettingsPath]
      : resolveAdapterGlobalSettingsPaths();

  for (const globalPath of globalPaths) {
    const globalPolicy = readSingleSettings(globalPath);
    if (globalPolicy) policies.push(globalPolicy);
  }

  return policies;
}

/**
 * Read deny patterns for a specific tool from settings files.
 *
 * Reads the same 3-tier settings as `readBashPolicies`, but extracts
 * only deny globs for the given tool. Used for Read and Grep enforcement
 * — checks if file paths should be blocked by deny patterns.
 *
 * Returns an array of arrays (one per settings file, in precedence order).
 * Each inner array contains the extracted glob strings.
 */
export function readToolDenyPatterns(
  toolName: string,
  projectDir?: string,
  globalSettingsPath?: string,
): string[][] {
  return readToolPermissionPatterns(toolName, "deny", projectDir, globalSettingsPath);
}

/**
 * Read `permissions.{deny|allow}` globs for a tool from every settings file in
 * precedence order (project local → project shared → adapter globals).
 *
 * Generalizes the original deny-only reader so the project-boundary guard
 * (#852) can consult the SAME `permissions.allow` rules the user already
 * maintains for the host's `Read` tool — instead of inventing a context-mode-
 * specific opt-out env that would rot into dead code. A user who legitimately
 * needs an out-of-project read expresses it once, in the host config, e.g.
 * `"permissions": { "allow": ["Read(/var/log/**)"] }`, and both the host and
 * context-mode honor it.
 */
export function readToolPermissionPatterns(
  toolName: string,
  kind: "deny" | "allow",
  projectDir?: string,
  globalSettingsPath?: string,
): string[][] {
  const result: string[][] = [];

  const extractGlobs = (path: string): string[] | null => {
    let raw: string;
    try {
      raw = readFileSync(path, "utf-8");
    } catch {
      return null;
    }

    let parsed: any;
    try {
      parsed = JSON.parse(raw);
    } catch {
      return null;
    }

    const entries = parsed?.permissions?.[kind];
    if (!Array.isArray(entries)) return [];

    const globs: string[] = [];
    for (const entry of entries) {
      if (typeof entry !== "string") continue;
      const tp = parseToolPattern(entry);
      if (tp && tp.tool === toolName) {
        globs.push(tp.glob);
      }
    }
    return globs;
  };

  if (projectDir) {
    const localGlobs = extractGlobs(
      resolve(projectDir, ".claude", "settings.local.json"),
    );
    if (localGlobs !== null) result.push(localGlobs);

    const sharedGlobs = extractGlobs(
      resolve(projectDir, ".claude", "settings.json"),
    );
    if (sharedGlobs !== null) result.push(sharedGlobs);
  }

  // Issue #451 round-3: union over every adapter-specific global path PLUS
  // claude global. Each settings file contributes its own globs array entry
  // so the precedence ordering downstream remains per-file rather than
  // collapsed.
  const globalPaths =
    globalSettingsPath !== undefined
      ? [globalSettingsPath]
      : resolveAdapterGlobalSettingsPaths();

  for (const globalPath of globalPaths) {
    const globalGlobs = extractGlobs(globalPath);
    if (globalGlobs !== null) result.push(globalGlobs);
  }

  return result;
}

// ==============================================================================
// Decision Engine
// ==============================================================================

interface CommandDecision {
  decision: PermissionDecision;
  matchedPattern?: string;
}

/**
 * Evaluate a command against policies in precedence order.
 *
 * Splits chained commands (&&, ||, ;, |) and checks each segment
 * against deny patterns — prevents bypassing deny by prepending
 * innocent commands like "echo ok && sudo rm -rf /".
 *
 * Within each policy: deny > ask > allow (most restrictive wins).
 * First definitive match across policies wins.
 * Default (no match in any policy): "ask".
 */
export function evaluateCommand(
  command: string,
  policies: SecurityPolicy[],
  caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin",
): CommandDecision {
  // Extract all main segments and nested subshell commands
  const allCommands = collectCommandElements(command);

  // 1. Deny check: If ANY segment or subshell command is denied, block the entire command
  for (const cmdElement of allCommands) {
    for (const policy of policies) {
      const denyMatch = matchesAnyPattern(cmdElement, policy.deny, caseInsensitive);
      if (denyMatch) return { decision: "deny", matchedPattern: denyMatch };
    }
  }

  // 2. Allow/Ask check: Evaluate segment-by-segment in precedence order.
  // The command is allowed if and only if EVERY segment and subshell is explicitly allowed.
  // If any element matches an ask pattern or matches no allow pattern, it defaults to ask.
  for (const policy of policies) {
    let allAllowed = true;
    let anyAsk = false;
    let matchedAskPattern: string | undefined;
    let matchedAllowPattern: string | undefined;

    for (const cmdElement of allCommands) {
      const askMatch = matchesAnyPattern(cmdElement, policy.ask, caseInsensitive);
      if (askMatch) {
        anyAsk = true;
        matchedAskPattern = askMatch;
        break; // Ask wins immediately within this policy
      }

      const allowMatch = matchesAnyPattern(cmdElement, policy.allow, caseInsensitive);
      if (!allowMatch) {
        allAllowed = false;
      } else {
        matchedAllowPattern = allowMatch;
      }
    }

    if (anyAsk) {
      return { decision: "ask", matchedPattern: matchedAskPattern };
    }

    if (allAllowed && allCommands.length > 0) {
      return { decision: "allow", matchedPattern: matchedAllowPattern };
    }
  }

  return { decision: "ask" };
}

/**
 * Server-side variant: only enforce deny patterns.
 *
 * The server has no UI for "ask" prompts, so allow/ask patterns are
 * irrelevant. Returns "deny" if any deny pattern matches, otherwise "allow".
 *
 * Also splits chained commands and nested subshells to prevent bypass.
 */
export function evaluateCommandDenyOnly(
  command: string,
  policies: SecurityPolicy[],
  caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin",
): { decision: "deny" | "allow"; matchedPattern?: string } {
  const allCommands = collectCommandElements(command);

  for (const cmdElement of allCommands) {
    for (const policy of policies) {
      const denyMatch = matchesAnyPattern(cmdElement, policy.deny, caseInsensitive);
      if (denyMatch) return { decision: "deny", matchedPattern: denyMatch };
    }
  }

  return { decision: "allow" };
}

// ==============================================================================
// File Path Evaluation
// ==============================================================================

/**
 * Check if a file path should be denied based on deny globs.
 *
 * Normalizes backslashes to forward slashes before matching so that
 * Windows paths work with Unix-style glob patterns.
 *
 * When `projectRoot` is supplied, the path is also matched in its
 * fully-resolved absolute form **and** — when the file exists — in
 * its canonical form (`fs.realpathSync`). This prevents two classes
 * of bypass:
 *
 *   1. `..` traversal: a relative path like `../../.ssh/id_rsa` no
 *      longer evades absolute-path deny rules.
 *   2. Symlink escape: a project-local path whose realpath points
 *      outside the project (e.g. `safe.log -> ~/.ssh/id_rsa`) no
 *      longer evades absolute-path deny rules.
 *
 * realpath is best-effort: if the file does not exist yet (ENOENT)
 * or the syscall fails for any reason, the lexical resolved form is
 * still checked. This keeps the function usable for paths that will
 * be created during execution.
 */
export function evaluateFilePath(
  filePath: string,
  denyGlobs: string[][],
  caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin",
  projectRoot?: string,
): { denied: boolean; matchedPattern?: string } {
  const toForward = (path: string): string => path.replace(/\\/g, "/");

  // Match against the raw input, the lexically-resolved absolute path,
  // and the canonical (symlink-resolved) path when the file exists.
  // Deduplicated so absolute inputs and paths that don't cross symlinks
  // don't pay the matching cost multiple times.
  const candidates = new Set<string>();
  candidates.add(toForward(filePath));
  if (projectRoot) {
    const lexical = resolve(projectRoot, filePath);
    candidates.add(toForward(lexical));
    try {
      candidates.add(toForward(realpathSync(lexical)));
    } catch {
      // File does not exist yet, or realpath failed — rely on lexical form.
    }
  }

  for (const globs of denyGlobs) {
    for (const glob of globs) {
      // Normalize the glob's path separators the same way candidates were
      // normalized — otherwise a Windows absolute deny rule like
      // `Read(C:\Users\...\secret.env)` parses with literal backslashes that
      // never match a forward-slash candidate.
      const regex = fileGlobToRegex(toForward(glob), caseInsensitive);
      for (const candidate of candidates) {
        if (regex.test(candidate)) {
          return { denied: true, matchedPattern: glob };
        }
      }
    }
  }

  return { denied: false };
}

// ==============================================================================
// Project-Boundary Containment (Issue #852)
// ==============================================================================

/**
 * Pure, algorithmic (no-regex) test: does `filePath` resolve to a location
 * inside `projectRoot`?
 *
 * Issue #852 — `ctx_execute_file` previously fed its `path` argument straight
 * into `resolve(projectRoot, path)`. Because `path.resolve` lets an *absolute*
 * argument win outright, an agent could read any file on the host
 * (`/home/user/secret`, `/etc/passwd`) regardless of the project root, and
 * `../` traversal escaped just as easily. Claude Code's harness sandbox cannot
 * inspect MCP input params, so the user approving the MCP call could not see
 * that the path escaped the workspace. This guard re-anchors the path to the
 * project boundary.
 *
 * Containment is decided on the *resolved* form. When the file (or its parent
 * chain) exists, the symlink-canonical form is ALSO required to stay inside —
 * this closes the symlink-escape class (a project-local `safe.log` whose
 * realpath points at `~/.ssh/id_rsa`), mirroring `evaluateFilePath`.
 *
 * A path equal to the project root itself counts as inside. Comparison is
 * case-insensitive on Windows/macOS to match those filesystems' semantics.
 *
 * Returns `true` when `projectRoot` is falsy (no boundary to enforce) so the
 * caller's fail-open posture is preserved when the root cannot be resolved.
 */
export function isPathInsideProject(
  filePath: string,
  projectRoot: string | undefined,
  caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin",
): boolean {
  if (!projectRoot) return true;

  const root = resolve(projectRoot);
  const lexical = resolve(projectRoot, filePath);

  const within = (root: string, candidate: string): boolean => {
    let a = root;
    let b = candidate;
    if (caseInsensitive) {
      a = a.toLowerCase();
      b = b.toLowerCase();
    }
    if (a === b) return true;
    // `path.relative` is pure string arithmetic — no regex. A candidate inside
    // the root yields a relative path that neither starts with `..` (escapes
    // upward) nor is absolute (a different drive/root on Windows that cannot be
    // expressed relatively).
    const rel = relative(a, b);
    if (rel === "") return true;
    if (rel === ".." || rel.startsWith(".." + sep)) return false;
    if (isAbsoluteRel(rel)) return false;
    return true;
  };

  // Lexical containment is the primary gate.
  if (!within(root, lexical)) return false;

  // Defense-in-depth: when the path (or a parent) is a symlink that points
  // outside the project, the canonical form must ALSO stay inside. Best-effort
  // — a not-yet-created file (ENOENT) falls back to the lexical decision above.
  try {
    const canonicalRoot = realpathSync(root);
    const canonical = realpathSync(lexical);
    if (!within(canonicalRoot, canonical)) return false;
  } catch {
    /* file does not exist yet / realpath failed — lexical decision stands */
  }

  return true;
}

/** Pure helper: is a `path.relative` result an absolute path? (no regex) */
function isAbsoluteRel(rel: string): boolean {
  if (rel.startsWith("/")) return true; // POSIX absolute
  // Windows drive-absolute: "C:\..." or "C:/..."
  if (rel.length >= 3 && rel[1] === ":" && (rel[2] === "\\" || rel[2] === "/")) {
    const c = rel.charCodeAt(0);
    return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
  }
  return false;
}

/**
 * Decide whether `filePath` may be processed, given the project boundary AND
 * the user's existing host `Read(...)` allow rules.
 *
 * Decision order:
 *   1. Inside the project root  → allowed (the common case; no config needed).
 *   2. Outside the project, but matching a `permissions.allow` `Read(...)` glob
 *      the user already configured for the host → allowed. This is the
 *      principled escape hatch: a deliberate out-of-project read is expressed
 *      ONCE in the host config the user already maintains, reusing the same
 *      mechanism Claude Code itself uses to whitelist a path outside the
 *      sandbox — no context-mode-specific opt-out env that would rot into
 *      dead code.
 *   3. Outside the project, no allow match → denied (closes the #852 escape).
 *
 * `allowGlobs` has the same per-settings-file shape as the deny globs returned
 * by `readToolPermissionPatterns(toolName, "allow", …)`. Allow-matching reuses
 * `evaluateFilePath` so absolute/`..`/symlink-canonical candidate resolution is
 * identical to the deny path — one matcher, no divergence.
 *
 * Fail-open on an unknown project root (boundary cannot be computed) so the
 * guard never blocks legitimate in-project work when resolution fails.
 */
export function evaluateProjectContainment(
  filePath: string,
  projectRoot: string | undefined,
  allowGlobs: string[][] = [],
  caseInsensitive: boolean = process.platform === "win32" || process.platform === "darwin",
): { allowed: boolean; reason: "inside" | "allow-rule" | "outside" } {
  if (isPathInsideProject(filePath, projectRoot, caseInsensitive)) {
    return { allowed: true, reason: "inside" };
  }
  // Outside the project — permit only if the user explicitly allowed this path
  // for the host Read tool. `evaluateFilePath` returns `denied:true` when a glob
  // MATCHES, so a match here means "explicitly allowed".
  if (allowGlobs.some((g) => g.length > 0)) {
    const matched = evaluateFilePath(filePath, allowGlobs, caseInsensitive, projectRoot);
    if (matched.denied) return { allowed: true, reason: "allow-rule" };
  }
  return { allowed: false, reason: "outside" };
}

// ==============================================================================
// Shell-Escape Scanner
// ==============================================================================

// Regex patterns that detect shell-escape calls in non-shell languages.
// Each pattern uses capture groups so that the embedded command string
// can be extracted from the last non-quote group.
//
// NOTE: These regexes contain literal strings like "execSync" — they are
// patterns for *detecting* shell escapes in user code, not actual usage.
const SHELL_ESCAPE_PATTERNS: Record<string, RegExp[]> = {
  python: [
    /os\.system\(\s*(['"])(.*?)\1\s*\)/g,
    /subprocess\.(?:run|call|Popen|check_output|check_call)\(\s*(['"])(.*?)\1/g,
  ],
  javascript: [
    /exec(?:Sync|File|FileSync)?\(\s*(['"`])(.*?)\1/g,
    /spawn(?:Sync)?\(\s*(['"`])(.*?)\1/g,
  ],
  typescript: [
    /exec(?:Sync|File|FileSync)?\(\s*(['"`])(.*?)\1/g,
    /spawn(?:Sync)?\(\s*(['"`])(.*?)\1/g,
  ],
  ruby: [
    /system\(\s*(['"])(.*?)\1/g,
    /`(.*?)`/g,
  ],
  go: [
    /exec\.Command\(\s*(['"`])(.*?)\1/g,
  ],
  php: [
    /shell_exec\(\s*(['"`])(.*?)\1/g,
    /(?:^|[^.])exec\(\s*(['"`])(.*?)\1/g,
    /(?:^|[^.])system\(\s*(['"`])(.*?)\1/g,
    /passthru\(\s*(['"`])(.*?)\1/g,
    /proc_open\(\s*(['"`])(.*?)\1/g,
  ],
  rust: [
    /Command::new\(\s*(['"`])(.*?)\1/g,
  ],
};

/**
 * Extract all string elements from a Python subprocess list call.
 *
 * subprocess.run(["rm", "-rf", "/"]) → "rm -rf /"
 *
 * This catches the list-of-strings form that the single-string regex misses.
 */
function extractPythonSubprocessListArgs(code: string): string[] {
  const commands: string[] = [];
  const pattern =
    /subprocess\.(?:run|call|Popen|check_output|check_call)\(\s*\[([^\]]+)\]/g;

  let match: RegExpExecArray | null;
  while ((match = pattern.exec(code)) !== null) {
    const listContent = match[1];
    const args = [...listContent.matchAll(/(['"])(.*?)\1/g)].map((m) => m[2]);
    if (args.length > 0) {
      commands.push(args.join(" "));
    }
  }

  return commands;
}

/**
 * Scan non-shell code for shell-escape calls and extract the embedded
 * command strings.
 *
 * Returns an array of command strings found in the code. For unknown
 * languages or code without shell-escape calls, returns an empty array.
 */
export function extractShellCommands(
  code: string,
  language: string,
): string[] {
  const patterns = SHELL_ESCAPE_PATTERNS[language];
  if (!patterns && language !== "python") return [];

  const commands: string[] = [];

  if (patterns) {
    for (const pattern of patterns) {
      // Reset lastIndex since we reuse the global regex
      pattern.lastIndex = 0;

      let match: RegExpExecArray | null;
      while ((match = pattern.exec(code)) !== null) {
        // The command string is in the last capture group that isn't the
        // quote delimiter. For patterns with 2 groups (quote + content),
        // it's group 2. For Ruby backticks with 1 group, it's group 1.
        const command = match[match.length - 1];
        if (command) commands.push(command);
      }
    }
  }

  // Python: also extract subprocess list-form args
  if (language === "python") {
    commands.push(...extractPythonSubprocessListArgs(code));
  }

  return commands;
}
