// wall-e/tools/shell-analyzer.js
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/tool/bash.ts
// Tree-sitter-based shell command analysis for safety checking.
'use strict';

const path = require('path');
const os = require('os');

let Parser, bashLanguage;

/**
 * Initialize the tree-sitter parser. Call once at startup.
 * Loads web-tree-sitter WASM runtime and tree-sitter-bash grammar.
 */
async function initParser() {
  if (Parser) return; // already initialized
  const TS = require('web-tree-sitter');
  // web-tree-sitter CJS: init() is a static on Parser class
  const ParserClass = TS.Parser || TS.default?.Parser || TS;
  if (typeof ParserClass.init === 'function') {
    await ParserClass.init();
  }
  Parser = new ParserClass();
  const langPath = require.resolve('tree-sitter-bash/tree-sitter-bash.wasm');
  const LanguageClass = TS.Language || TS.default?.Language;
  bashLanguage = await LanguageClass.load(langPath);
  Parser.setLanguage(bashLanguage);
}

// ── File-touching commands (need path extraction) ──
const FILE_COMMANDS = new Set([
  'cd', 'rm', 'cp', 'mv', 'mkdir', 'touch', 'chmod', 'chown', 'cat',
  'ln', 'rmdir',
]);

// ── PowerShell support ──
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/tool/bash.ts — PS cmdlet sets and flag handling

const PS_CMDLET_PREFIXES = ['get-', 'set-', 'new-', 'remove-', 'invoke-', 'test-',
  'write-', 'read-', 'out-', 'start-', 'stop-', 'copy-', 'move-', 'rename-',
  'add-', 'clear-', 'select-', 'where-', 'foreach-', 'sort-', 'measure-',
  'import-', 'export-', 'convertto-', 'convertfrom-', 'push-location', 'set-location'];

const PS_FILE_COMMANDS = new Set([
  'get-content', 'set-content', 'add-content', 'copy-item', 'move-item',
  'remove-item', 'new-item', 'rename-item', 'push-location', 'set-location',
]);

// Ported from OpenCode bash.ts FLAGS set — PS parameter names that take path arguments
const PS_PATH_FLAGS = new Set(['-path', '-destination', '-literalpath']);

// Ported from OpenCode bash.ts SWITCHES set — PS switch parameters (no value)
const PS_SWITCHES = new Set(['-confirm', '-debug', '-force', '-nonewline',
  '-recurse', '-verbose', '-whatif']);

// PowerShell cmdlet risk mapping
const PS_RISK = {
  'remove-item': 'deny',
  'set-content': 'write',
  'add-content': 'write',
  'new-item': 'write',
  'rename-item': 'write',
  'copy-item': 'write',
  'move-item': 'write',
  'get-content': 'read',
  'get-childitem': 'read',
  'get-item': 'read',
  'test-path': 'read',
  'push-location': 'read',
  'set-location': 'read',
};

/**
 * Detect if a command looks like PowerShell.
 * Ported from OpenCode bash.ts — PS detection heuristics.
 * @param {string} commandStr
 * @returns {boolean}
 */
function isPowerShell(commandStr) {
  if (!commandStr) return false;
  const lower = commandStr.toLowerCase().trim();
  // Check if it starts with a known PS cmdlet pattern (Verb-Noun)
  for (const prefix of PS_CMDLET_PREFIXES) {
    if (lower.startsWith(prefix)) return true;
  }
  // Check for PS-style parameters or pipe to PS cmdlets
  if (/\|\s*(get|set|new|remove|invoke|where|select|foreach|sort|measure)-/i.test(commandStr)) return true;
  if (/-(Path|Destination|LiteralPath)\s/i.test(commandStr)) return true;
  // $env:VAR is PS-only syntax
  if (/\$env:/i.test(commandStr)) return true;
  return false;
}

/**
 * Extract path arguments from PowerShell command parts.
 * Ported from OpenCode bash.ts pathArgs() for PS mode.
 * @param {Array<{type: string, text: string}>} parts
 * @returns {string[]}
 */
function extractPSPathArgs(parts) {
  const out = [];
  let wantPath = false;
  for (const item of parts.slice(1)) { // skip cmdlet name
    const lower = item.text.toLowerCase();
    if (wantPath) {
      out.push(item.text);
      wantPath = false;
      continue;
    }
    if (PS_SWITCHES.has(lower)) continue;
    if (PS_PATH_FLAGS.has(lower)) {
      wantPath = true;
      continue;
    }
    // Skip other flags (start with -)
    if (item.text.startsWith('-')) continue;
    out.push(item.text);
  }
  return out;
}

/**
 * Analyze a PowerShell command string using regex-based parsing.
 * Ported from OpenCode bash.ts — PowerShell uses simpler syntax, no tree-sitter needed.
 *
 * @param {string} commandStr
 * @param {string} cwd
 * @returns {{ allowed: boolean, reason?: string, commands: string[], paths: string[], riskLevel: string }}
 */
function analyzePowerShellCommand(commandStr, cwd) {
  const result = {
    allowed: true,
    reason: undefined,
    commands: [],
    paths: [],
    commandTokens: [],
    riskLevel: 'read', // overall risk: read | write | deny
  };

  if (!commandStr || !commandStr.trim()) return result;

  // Split on pipes to get individual commands
  const segments = commandStr.split(/\s*\|\s*/);

  for (const segment of segments) {
    const tokens = segment.trim().split(/\s+/);
    if (tokens.length === 0) continue;

    const cmdlet = tokens[0].toLowerCase();
    result.commands.push(cmdlet);
    result.commandTokens.push(tokens);

    // Risk classification
    const risk = PS_RISK[cmdlet];
    if (risk === 'deny') {
      result.allowed = false;
      result.reason = `Denied: ${cmdlet} (destructive PowerShell operation)`;
      result.riskLevel = 'deny';
      return result;
    }
    if (risk === 'write' && result.riskLevel !== 'deny') {
      result.riskLevel = 'write';
    }

    // Path extraction for file-touching PS cmdlets
    if (PS_FILE_COMMANDS.has(cmdlet)) {
      const parts = tokens.map(t => ({ type: 'word', text: t }));
      for (const arg of extractPSPathArgs(parts)) {
        const cleaned = expandEnvVars(expandHome(unquote(arg)), cwd);
        if (isDynamic(cleaned) || isPSDynamic(cleaned)) continue;
        const resolved = path.resolve(cwd, cleaned);
        result.paths.push(resolved);
      }
    }
  }

  return result;
}

/**
 * Check if a PS expression is dynamic (can't resolve statically).
 * Ported from OpenCode bash.ts dynamic() for PS mode.
 */
function isPSDynamic(text) {
  if (text.startsWith('(') || text.startsWith('@(')) return true;
  // $var (but not $env:) is dynamic in PS
  if (/\$(?!env:)[a-z]/i.test(text)) return true;
  return false;
}

/**
 * Get the default shell for the current platform.
 * On Windows, default to PowerShell; elsewhere, bash.
 * Ported from OpenCode bash.ts — PS.has() check for Windows shells.
 */
function getDefaultShell() {
  if (process.platform === 'win32') return 'powershell';
  return process.env.SHELL || '/bin/bash';
}

/**
 * Unquote a shell string (remove surrounding quotes).
 */
function unquote(text) {
  if (text.length < 2) return text;
  const first = text[0], last = text[text.length - 1];
  if ((first === '"' || first === "'") && first === last) return text.slice(1, -1);
  return text;
}

/**
 * Expand ~ to home directory.
 */
function expandHome(text) {
  if (text === '~') return os.homedir();
  if (text.startsWith('~/') || text.startsWith('~\\')) return path.join(os.homedir(), text.slice(2));
  return text;
}

/**
 * Check if a string contains dynamic expressions that can't be statically analyzed.
 * Ported from OpenCode bash.ts dynamic() — excludes known env vars we can expand.
 */
function isDynamic(text) {
  // Subshell and backtick expansions are always dynamic
  if (text.includes('$(') || text.includes('`')) return true;
  // ${VAR} with unknown vars is dynamic, but we handle known ones in expandEnvVars
  // so only flag truly unresolvable patterns
  const withoutKnown = text
    .replace(/\$\{?(HOME|PWD)\}?/g, '')
    .replace(/\$env:[A-Za-z_][A-Za-z0-9_]*/gi, '')
    .replace(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g, '')
    .replace(/%[A-Za-z_][A-Za-z0-9_]*%/g, '');
  // If there's still a $ left, it's a dynamic variable we can't resolve
  return withoutKnown.includes('$');
}

// ── Environment variable expansion ──
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/tool/bash.ts — expand(), auto(), envValue() functions

/**
 * Look up an environment variable value (case-insensitive on Windows).
 * Ported from OpenCode bash.ts envValue().
 * @param {string} key
 * @returns {string|undefined}
 */
function envValue(key) {
  if (process.platform !== 'win32') return process.env[key];
  const name = Object.keys(process.env).find(k => k.toLowerCase() === key.toLowerCase());
  return name ? process.env[name] : undefined;
}

/**
 * Expand environment variables in a path string.
 * Handles: $HOME, ~, $PWD, $env:VAR (PowerShell), ${VAR} (bash), %VAR% (cmd.exe on Windows).
 * Ported from OpenCode bash.ts expand() + auto().
 *
 * @param {string} text - Path string to expand
 * @param {string} cwd - Current working directory for $PWD
 * @returns {string} Expanded path, or original if contains unresolvable expressions
 */
function expandEnvVars(text, cwd) {
  let out = text;

  // Expand ${env:VAR} (PowerShell brace syntax)
  out = out.replace(/\$\{env:([^}]+)\}/gi, (_, key) => envValue(key) || '');

  // Expand $env:VAR (PowerShell bare syntax)
  out = out.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (_, key) => envValue(key) || '');

  // Expand ${VAR} (bash brace syntax)
  out = out.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => {
    if (key === 'HOME') return os.homedir();
    if (key === 'PWD') return cwd;
    return envValue(key) || '';
  });

  // Expand $HOME and $PWD (bare, not already caught)
  out = out.replace(/\$(HOME|PWD)(?=$|[/\\])/g, (_, key) => {
    if (key === 'HOME') return os.homedir();
    if (key === 'PWD') return cwd;
    return '';
  });

  // On Windows, also handle %VAR% cmd.exe syntax
  if (process.platform === 'win32') {
    out = out.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (_, key) => envValue(key) || '');
  }

  // Expand ~ (tilde) to home directory
  out = expandHome(out);

  return out;
}

/**
 * Extract parts (command name + arguments) from a command AST node.
 * Ported from OpenCode's parts() function in bash.ts.
 */
function extractParts(node) {
  const out = [];
  for (let i = 0; i < node.childCount; i++) {
    const child = node.child(i);
    if (!child) continue;
    // Skip redirections and other non-argument nodes
    if (child.type === 'file_redirect' || child.type === 'heredoc_redirect' ||
        child.type === 'herestring_redirect') continue;
    if (child.type === 'command_name' || child.type === 'word' ||
        child.type === 'string' || child.type === 'raw_string' ||
        child.type === 'concatenation') {
      out.push({ type: child.type, text: child.text });
    }
  }
  return out;
}

/**
 * Recursively collect all 'command' nodes from the AST.
 */
function collectCommandNodes(node) {
  const results = [];
  if (node.type === 'command') {
    results.push(node);
  }
  for (let i = 0; i < node.childCount; i++) {
    const child = node.child(i);
    if (child) results.push(...collectCommandNodes(child));
  }
  return results;
}

/**
 * Extract non-flag arguments (potential file paths) from a command's parts.
 */
function extractPathArgs(parts) {
  return parts
    .slice(1) // skip command name
    .filter(p => !p.text.startsWith('-') && !(parts[0]?.text === 'chmod' && p.text.startsWith('+')))
    .map(p => p.text);
}

/**
 * Analyze a shell command string using tree-sitter.
 *
 * @param {string} commandStr - Shell command to analyze
 * @param {string} cwd - Current working directory for path resolution
 * @returns {{ allowed: boolean, reason?: string, commands: string[], paths: string[], commandTokens: string[][] }}
 */
async function analyzeShellCommand(commandStr, cwd) {
  if (!Parser) await initParser();

  const result = {
    allowed: true,
    reason: undefined,
    commands: [],      // extracted command names (e.g., ['grep', 'wc'])
    paths: [],         // resolved file paths
    commandTokens: [], // raw token arrays per command (for BashArity)
  };

  if (!commandStr || !commandStr.trim()) return result;

  const tree = Parser.parse(commandStr);
  if (!tree) return result;

  const { SHELL_ALLOWLIST, SHELL_DENYLIST } = require('./shell-policy');
  const commandNodes = collectCommandNodes(tree.rootNode);

  for (const node of commandNodes) {
    const parts = extractParts(node);
    if (parts.length === 0) continue;

    const cmdName = path.basename(unquote(parts[0].text));
    result.commands.push(cmdName);

    // Collect raw tokens for BashArity prefix matching
    const tokens = parts.map(p => unquote(p.text));
    result.commandTokens.push(tokens);

    // Denylist check
    if (SHELL_DENYLIST.has(cmdName)) {
      result.allowed = false;
      result.reason = `Denied: ${cmdName} (destructive operation)`;
      return result;
    }

    // Allowlist check
    if (!SHELL_ALLOWLIST.has(cmdName)) {
      result.allowed = false;
      result.reason = `Not in allowlist: ${cmdName}`;
      return result;
    }

    // Path extraction for file-touching commands
    if (FILE_COMMANDS.has(cmdName)) {
      for (const arg of extractPathArgs(parts)) {
        const cleaned = expandEnvVars(unquote(arg), cwd);
        if (isDynamic(cleaned)) continue; // can't resolve dynamic paths
        const resolved = path.resolve(cwd, cleaned);
        result.paths.push(resolved);
      }
    }
  }

  return result;
}

// ── BashArity: command -> token count lookup ──
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/permission/arity.ts
// 162-entry lookup table mapping command prefixes to their arity
// (how many tokens define the "command" vs "arguments").

const ARITY = {
  cat: 1, cd: 1, chmod: 1, chown: 1, cp: 1, echo: 1, env: 1,
  export: 1, grep: 1, kill: 1, killall: 1, ln: 1, ls: 1, mkdir: 1,
  mv: 1, ps: 1, pwd: 1, rm: 1, rmdir: 1, sleep: 1, source: 1,
  tail: 1, touch: 1, unset: 1, which: 1,
  aws: 3, az: 3,
  bazel: 2, brew: 2, bun: 2, 'bun run': 3, 'bun x': 3,
  cargo: 2, 'cargo add': 3, 'cargo run': 3,
  cdk: 2, cf: 2, cmake: 2, composer: 2,
  consul: 2, 'consul kv': 3,
  crictl: 2, deno: 2, 'deno task': 3,
  doctl: 3,
  docker: 2, 'docker builder': 3, 'docker compose': 3,
  'docker container': 3, 'docker image': 3,
  'docker network': 3, 'docker volume': 3,
  eksctl: 2, 'eksctl create': 3,
  firebase: 2, flyctl: 2,
  gcloud: 3, gh: 3,
  git: 2, 'git config': 3, 'git remote': 3, 'git stash': 3,
  go: 2, gradle: 2,
  helm: 2, heroku: 2, hugo: 2,
  ip: 2, 'ip addr': 3, 'ip link': 3, 'ip netns': 3, 'ip route': 3,
  kind: 2, 'kind create': 3,
  kubectl: 2, 'kubectl kustomize': 3, 'kubectl rollout': 3,
  kustomize: 2,
  make: 2, mc: 2, 'mc admin': 3,
  minikube: 2, mongosh: 2, mysql: 2, mvn: 2,
  ng: 2, npm: 2, 'npm exec': 3, 'npm init': 3, 'npm run': 3, 'npm view': 3,
  nvm: 2, nx: 2,
  openssl: 2, 'openssl req': 3, 'openssl x509': 3,
  pip: 2, pipenv: 2,
  pnpm: 2, 'pnpm dlx': 3, 'pnpm exec': 3, 'pnpm run': 3,
  poetry: 2, podman: 2, 'podman container': 3, 'podman image': 3,
  psql: 2, pulumi: 2, 'pulumi stack': 3,
  pyenv: 2, python: 2,
  rake: 2, rbenv: 2, 'redis-cli': 2,
  rustup: 2,
  serverless: 2, sfdx: 3, skaffold: 2, sls: 2, sst: 2,
  swift: 2, systemctl: 2,
  terraform: 2, 'terraform workspace': 3,
  tmux: 2, turbo: 2,
  ufw: 2,
  vault: 2, 'vault auth': 3, 'vault kv': 3,
  vercel: 2, volta: 2,
  wp: 2,
  yarn: 2, 'yarn dlx': 3, 'yarn run': 3,
};

/**
 * Given a command's raw tokens, return the prefix tokens that identify
 * the "command" (vs arguments). Uses the ARITY lookup table.
 *
 * Examples:
 *   ['git', 'push', '--force'] -> ['git', 'push']  (arity 2)
 *   ['npm', 'run', 'dev']     -> ['npm', 'run', 'dev']  (arity 3)
 *   ['ls', '-la']             -> ['ls']  (arity 1)
 *   ['mycmd', 'arg']          -> ['mycmd']  (default: 1)
 *
 * @param {string[]} tokens - Raw command tokens
 * @returns {string[]} Prefix tokens identifying the command
 */
function bashArityPrefix(tokens) {
  if (tokens.length === 0) return [];
  // Try longest prefix first (matches OpenCode's algorithm)
  for (let len = Math.min(tokens.length, 3); len > 0; len--) {
    const prefix = tokens.slice(0, len).join(' ');
    const arity = ARITY[prefix];
    if (arity !== undefined) return tokens.slice(0, Math.min(arity, tokens.length));
  }
  // Unknown command: default to 1 token
  return tokens.slice(0, 1);
}

/**
 * Expand a path argument, returning null if it contains unresolvable dynamic expressions.
 * Combines env var expansion + dynamic detection in one call.
 * Ported from OpenCode bash.ts argPath().
 *
 * @param {string} arg - Raw argument string (may be quoted)
 * @param {string} cwd - Current working directory
 * @param {boolean} [ps=false] - Whether to treat as PowerShell
 * @returns {{ resolved: string, dynamic: boolean }} Expanded path or dynamic marker
 */
function expandPath(arg, cwd, ps = false) {
  const text = unquote(arg);
  // Check for subshell / backtick expressions first
  if (text.includes('$(') || text.includes('`')) {
    return { resolved: null, dynamic: true };
  }
  // PS-specific dynamic checks
  if (ps && isPSDynamic(text)) {
    return { resolved: null, dynamic: true };
  }
  const expanded = expandEnvVars(expandHome(text), cwd);
  // After expansion, check if there are still unresolvable vars
  if (isDynamic(expanded)) {
    return { resolved: null, dynamic: true };
  }
  return { resolved: path.resolve(cwd, expanded), dynamic: false };
}

module.exports = {
  initParser, analyzeShellCommand, bashArityPrefix, ARITY,
  // PowerShell support (Task 1)
  isPowerShell, analyzePowerShellCommand, getDefaultShell,
  PS_FILE_COMMANDS, PS_RISK, extractPSPathArgs, isPSDynamic,
  // Environment variable expansion (Task 2)
  expandEnvVars, expandPath, envValue, isDynamic, expandHome, unquote,
};
