#!/usr/bin/env node
/**
 * Framework boot-path scanner (router-gate v4 Stream C, spec §5.2 F7 closure).
 *
 * Closes: Edit a framework boot file (e.g. app/Providers/AppServiceProvider.php)
 * with an RCE payload, then `composer test` -> payload executes during boot.
 * Detects test-runner Bash commands whose project has edited boot files, and
 * scans those files for suspicious patterns. Pure — fileExists / readFile injected.
 */

import { isTestRunner, scanContent, detectLanguage } from './static-content-scanner.mjs';

// project type -> predicate over an injected fileExists(relPath).
// Detection rule per spec §5.2: each type needs its characteristic markers.
const PROJECT_MARKERS = {
  laravel: (e) => e('composer.json') && e('app/Providers/AppServiceProvider.php'),
  rails: (e) => e('Gemfile') && e('config/application.rb'),
  express: (e) => e('package.json') && (e('app.js') || e('server.js')),
  django: (e) => e('manage.py'),
  spring: (e) => e('pom.xml') || e('build.gradle'),
};

/**
 * @param {(relPath: string) => boolean} fileExists - injected (e.g. fs.existsSync).
 * @returns {string[]} detected project types (may be multiple).
 */
export function detectProjectType(fileExists) {
  if (typeof fileExists !== 'function') return [];
  const types = [];
  for (const [type, predicate] of Object.entries(PROJECT_MARKERS)) {
    try {
      if (predicate(fileExists)) types.push(type);
    } catch {
      /* ignore predicate errors */
    }
  }
  return types;
}

// Boot-path glob sets per project type (spec §5.2 "Boot-path file set").
const BOOT_PATHS = {
  laravel: [
    'app/Providers/**/*.php',
    'bootstrap/**/*.php',
    'routes/*.php',
    'app/Http/Kernel.php',
    'app/Console/Kernel.php',
    'app/Http/Middleware/**/*.php',
    'app/Console/Commands/**/*.php',
    'app/Models/*.php', // scan boot() method specifically (see decideBootScan)
  ],
  rails: [
    'config/initializers/**/*.rb',
    'config/application.rb',
    'config/routes.rb',
  ],
  express: [
    'app.js',
    'server.js',
    'index.js',
    'routes/**/*.js',
    'middleware/**/*.js',
  ],
  django: [
    '*/apps.py', // scan ready() method
    '*/middleware.py',
    'urls.py',
  ],
  spring: [], // Spring matched by annotation content scan, not a fixed path set
};

/**
 * @param {string} projectType
 * @returns {string[]} glob patterns of boot-path files.
 */
export function bootPathSet(projectType) {
  return BOOT_PATHS[projectType] ? [...BOOT_PATHS[projectType]] : [];
}

/**
 * Glob match without external deps. Supports `**` (cross-dir) and `*` (single
 * segment). Single-pass tokenizer (no placeholder literals) so `**` is handled
 * before `*`. No nested quantifiers -> no catastrophic backtracking.
 */
export function matchesBootPattern(filePath, pattern) {
  if (typeof filePath !== 'string' || typeof pattern !== 'string') return false;
  const f = filePath.replace(/\\/g, '/');
  const p = pattern.replace(/\\/g, '/');
  let rx = '';
  let i = 0;
  while (i < p.length) {
    if (p[i] === '*' && p[i + 1] === '*') {
      if (p[i + 2] === '/') { rx += '(?:.*/)?'; i += 3; } // **/ -> any dirs (incl none)
      else { rx += '.*'; i += 2; } //                        ** -> anything (cross-dir)
    } else if (p[i] === '*') {
      rx += '[^/]*'; i += 1; //                               * -> single path segment
    } else {
      const ch = p[i];
      rx += /[.+^${}()|[\]\\]/.test(ch) ? '\\' + ch : ch; // escape one literal char
      i += 1;
    }
  }
  return new RegExp('^' + rx + '$').test(f);
}

/**
 * Edited files that match any boot-path pattern for the given project type.
 * @param {string} projectType
 * @param {string[]} editedFiles
 * @returns {string[]}
 */
export function intersectEditedBootFiles(projectType, editedFiles) {
  if (!Array.isArray(editedFiles) || editedFiles.length === 0) return [];
  const patterns = bootPathSet(projectType);
  if (patterns.length === 0) return [];
  return editedFiles.filter((file) => patterns.some((pat) => matchesBootPattern(file, pat)));
}

/**
 * Extract a PHP/JS-style method body by brace matching (char-scan, no regex on
 * the body, no backtracking). Returns the text between the method's opening { and
 * its matching }. Empty string if not found.
 *
 * NOTE (best-effort): the matcher is string/comment-naive — a `}` inside a string
 * literal or comment closes the body early, so this can under-report. It is only
 * used to scope the `findings` evidence for Model files; the security control is
 * the intersection-based hard-block in decideBootScan, which fires regardless of
 * findings. See the "hidden behind a } inside a string" test.
 */
export function extractPhpMethodBody(source, methodName) {
  if (typeof source !== 'string' || typeof methodName !== 'string') return '';
  const sigRe = new RegExp('\\bfunction\\s+' + methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\(');
  const m = sigRe.exec(source);
  if (!m) return '';
  let i = source.indexOf('{', m.index); // first '{' after the signature
  if (i < 0) return '';
  let depth = 0;
  const start = i + 1;
  for (; i < source.length; i++) {
    const ch = source[i];
    if (ch === '{') depth++;
    else if (ch === '}') {
      depth--;
      if (depth === 0) return source.slice(start, i);
    }
  }
  return source.slice(start); // unbalanced — return rest defensively
}

// Patterns whose files should be scanned boot()-body-only (model files edited
// legitimately for domain logic; only their boot() hook is RCE-relevant).
const BOOT_BODY_ONLY = [/(?:^|\/)app\/Models\/[^/]+\.php$/i];

/**
 * F7 decision. Blocks a test-runner command when an edited file is a boot-path
 * file, and surfaces suspicious-pattern findings from those files.
 *
 * @param {{command: string, projectTypes: string[], editedFiles: string[],
 *          readFile: (p: string) => string}} args
 * @returns {{block: boolean, reason?: string, intersection?: string[], findings?: object[]}}
 */
export function decideBootScan({ command, projectTypes, editedFiles, readFile }) {
  if (!isTestRunner(command)) return { block: false };
  if (!Array.isArray(projectTypes) || projectTypes.length === 0) return { block: false };

  const intersection = [];
  for (const type of projectTypes) {
    for (const f of intersectEditedBootFiles(type, editedFiles || [])) {
      if (!intersection.includes(f)) intersection.push(f);
    }
  }
  if (intersection.length === 0) return { block: false };

  // Scan content of intersected boot files (extra evidence), max 50 files.
  const findings = [];
  for (const file of intersection.slice(0, 50)) {
    const lang = detectLanguage(file);
    if (!lang) continue;
    let source;
    try {
      source = readFile(file);
    } catch {
      continue;
    }
    const norm = file.replace(/\\/g, '/');
    const bodyOnly = BOOT_BODY_ONLY.some((re) => re.test(norm));
    const scanText = bodyOnly ? extractPhpMethodBody(source, 'boot') : source;
    for (const fnd of scanContent(scanText, lang)) {
      findings.push({ ...fnd, file });
    }
  }

  const reason = [
    `Test runner "${command}" while boot-path file(s) edited: ${intersection.join(', ')}.`,
    `Possible framework boot RCE (payload executes during boot). Request AskUser approval`,
    `with explicit test command + review of boot file content.`,
  ].join(' ');

  return { block: true, reason, intersection, findings };
}
