// Copyright (c) 2026 defconxt. All rights reserved.
// Licensed under AGPL-3.0 — see LICENSE file for details.
// NERF is a trademark of defconxt.

/**
 * NERF Scanning Pipeline — Nuclei/Katana integration layer.
 *
 * Defines all shared data classes (Finding, ScanResult, CrawlResult,
 * PipelineResult, ScanProfile, ScanDomain) and the core subprocess
 * spawning pattern for the pipeline module. Every other pipeline module
 * references these types.
 *
 * Ported from pipeline/scanner.py (845 LOC Python).
 */

import { spawn, execFileSync } from 'node:child_process';

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const DEFAULT_TIMEOUT = 600; // 10 minutes
const TOOL_MISSING = (tool, path) =>
  `${tool} not found at '${path}'. Install: https://github.com/projectdiscovery/${tool}#installation`;

// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------

/** @enum {string} NERF operating domains mapped to scan profiles. */
const ScanDomain = Object.freeze({
  RED: 'red',
  BLUE: 'blue',
  RECON: 'recon',
  PRIVACY: 'privacy',
  ARCHITECT: 'architect',
});

// ---------------------------------------------------------------------------
// Profile configs
// ---------------------------------------------------------------------------

const PROFILE_CONFIGS = Object.freeze({
  red: {
    name: 'red-team',
    tags: ['cve', 'rce', 'sqli', 'xss', 'ssrf', 'lfi', 'auth-bypass'],
    severity: ['critical', 'high', 'medium'],
    rateLimit: 100,
    bulkSize: 15,
    headless: true,
  },
  blue: {
    name: 'hardening-audit',
    tags: ['misconfig', 'exposure', 'default-login', 'unauth'],
    severity: ['critical', 'high', 'medium', 'low'],
    rateLimit: 200,
    bulkSize: 30,
    headless: false,
  },
  recon: {
    name: 'recon-sweep',
    tags: ['tech', 'token', 'exposure', 'dns'],
    severity: ['info', 'low', 'medium'],
    rateLimit: 250,
    bulkSize: 50,
    headless: false,
  },
  privacy: {
    name: 'privacy-audit',
    tags: ['exposure', 'token', 'misconfig', 'unauth'],
    severity: ['critical', 'high', 'medium'],
    rateLimit: 150,
    bulkSize: 25,
    headless: false,
  },
  pentest: {
    name: 'pentest',
    tags: ['cve', 'rce', 'sqli', 'xss', 'ssrf', 'misconfig'],
    severity: ['critical', 'high', 'medium'],
    rateLimit: 150,
    bulkSize: 25,
    headless: false,
  },
});

// ---------------------------------------------------------------------------
// Data classes
// ---------------------------------------------------------------------------

/**
 * Single vulnerability finding from Nuclei.
 */
class Finding {
  constructor(opts = {}) {
    this.templateId = opts.templateId ?? '';
    this.name = opts.name ?? '';
    this.severity = opts.severity ?? '';
    this.host = opts.host ?? '';
    this.matchedAt = opts.matchedAt ?? '';
    this.matcherName = opts.matcherName ?? '';
    this.description = opts.description ?? '';
    this.reference = opts.reference ?? [];
    this.tags = opts.tags ?? [];
    this.cveIds = opts.cveIds ?? [];
    this.cweIds = opts.cweIds ?? [];
    this.raw = opts.raw ?? {};
  }

  /**
   * Parse a single Nuclei JSONL object.
   * @param {object} data - Parsed JSON object from Nuclei output
   * @returns {Finding}
   */
  static fromNucleiJson(data) {
    const info = data.info ?? {};
    const classif = info.classification ?? {};
    return new Finding({
      templateId: data['template-id'] ?? data.template_id ?? '',
      name: info.name ?? '',
      severity: info.severity ?? 'unknown',
      host: data.host ?? '',
      matchedAt: data['matched-at'] ?? data.matched_at ?? '',
      matcherName: data['matcher-name'] ?? data.matcher_name ?? '',
      description: info.description ?? '',
      reference: info.reference ?? [],
      tags: info.tags ?? [],
      cveIds: classif['cve-id'] ?? [],
      cweIds: (classif['cwe-id'] ?? []).map(String),
      raw: data,
    });
  }
}

/**
 * Aggregated result from a Nuclei scan.
 */
class ScanResult {
  constructor(opts = {}) {
    this.target = opts.target ?? '';
    this.findings = opts.findings ?? [];
    this.stats = opts.stats ?? {};
    this.errors = opts.errors ?? [];
    this.command = opts.command ?? '';
    this.durationSeconds = opts.durationSeconds ?? 0;
    this.success = opts.success ?? false;
  }

  get criticalCount() {
    return this.findings.filter(f => f.severity === 'critical').length;
  }

  get highCount() {
    return this.findings.filter(f => f.severity === 'high').length;
  }

  /**
   * Export scan results as SARIF v2.1.0 JSON string.
   * Lazy-imports ./sarif.js — may not exist until T03.
   * @returns {Promise<string>}
   */
  async toSarif() {
    const { SarifReport } = await import('./sarif.js');
    const report = new SarifReport();
    for (const f of this.findings) {
      report.addFinding({
        templateId: f.templateId,
        name: f.name,
        description: f.description,
        severity: f.severity,
        host: f.host,
        matchedAt: f.matchedAt,
        tags: f.tags,
        cveIds: f.cveIds,
        reference: f.reference,
      });
    }
    return report.toJson();
  }

  /**
   * Auto-generate nuclei templates from critical/high findings.
   * Lazy-imports ./template-manager.js — may not exist until T03.
   * @returns {Promise<string[]>}
   */
  async generateCustomTemplates() {
    const { NucleiTemplateManager } = await import('./template-manager.js');
    const mgr = new NucleiTemplateManager();
    const templates = [];
    for (const f of this.findings) {
      if (f.severity === 'critical' || f.severity === 'high') {
        let path = '/';
        if (f.matchedAt && f.matchedAt.includes('//')) {
          const afterScheme = f.matchedAt.split('//').slice(1).join('//');
          const parts = afterScheme.split('/');
          path = parts.length > 1 ? '/' + parts.slice(1).join('/') : '/';
        }
        const templateYaml = mgr.generateTemplate({
          title: f.name,
          description: f.description,
          severity: f.severity,
          path,
          matchers: f.name ? [f.name] : [],
        });
        templates.push(templateYaml);
      }
    }
    return templates;
  }
}

/**
 * Aggregated result from a Katana crawl.
 */
class CrawlResult {
  constructor(opts = {}) {
    this.target = opts.target ?? '';
    this.urls = opts.urls ?? [];
    this.endpoints = opts.endpoints ?? [];
    this.forms = opts.forms ?? [];
    this.jsFiles = opts.jsFiles ?? [];
    this.errors = opts.errors ?? [];
    this.command = opts.command ?? '';
    this.success = opts.success ?? false;
  }
}

/**
 * Combined result from a full scan pipeline run.
 */
class PipelineResult {
  constructor(opts = {}) {
    this.target = opts.target ?? '';
    this.crawl = opts.crawl ?? null;
    this.scan = opts.scan ?? null;
    this.storedEntryIds = opts.storedEntryIds ?? [];
    this.errors = opts.errors ?? [];
    this.success = opts.success ?? false;
  }

  get urlsCrawled() {
    return this.crawl ? this.crawl.urls.length : 0;
  }

  get findingsCount() {
    return this.scan ? this.scan.findings.length : 0;
  }

  get findingsStored() {
    return this.storedEntryIds.length;
  }

  get duration() {
    return this.scan ? this.scan.durationSeconds : 0;
  }

  get findings() {
    return this.scan ? this.scan.findings : [];
  }
}

// ---------------------------------------------------------------------------
// ScanProfile
// ---------------------------------------------------------------------------

class ScanProfile {
  constructor(opts = {}) {
    this.name = opts.name ?? 'pentest';
    this.tags = opts.tags ?? [];
    this.severity = opts.severity ?? [];
    this.rateLimit = opts.rateLimit ?? 150;
    this.bulkSize = opts.bulkSize ?? 25;
    this.headless = opts.headless ?? false;
    this.extraArgs = opts.extraArgs ?? [];
  }

  /**
   * Build a ScanProfile from a NERF domain name.
   * Falls back to 'pentest' profile for unknown domains.
   * @param {string} domain
   * @returns {ScanProfile}
   */
  static fromDomain(domain) {
    const key = (domain ?? '').toLowerCase().trim();
    const cfg = PROFILE_CONFIGS[key] ?? PROFILE_CONFIGS.pentest;
    return new ScanProfile({
      name: cfg.name,
      tags: [...cfg.tags],
      severity: [...cfg.severity],
      rateLimit: cfg.rateLimit,
      bulkSize: cfg.bulkSize,
      headless: cfg.headless,
    });
  }
}

// ---------------------------------------------------------------------------
// NucleiRunner
// ---------------------------------------------------------------------------

/**
 * Check if a binary is available on PATH.
 * @param {string} binary
 * @returns {string|null} Resolved path or null
 */
function whichSync(binary) {
  try {
    return execFileSync('which', [binary], { encoding: 'utf8' }).trim() || null;
  } catch {
    return null;
  }
}

class NucleiRunner {
  /**
   * @param {object} opts
   * @param {string} [opts.nucleiPath='nuclei']
   * @param {string} [opts.templatesDir='']
   * @param {string} [opts.outputDir='']
   */
  constructor(opts = {}) {
    this.nucleiPath = opts.nucleiPath ?? 'nuclei';
    this.templatesDir = opts.templatesDir ?? '';
    this.outputDir = opts.outputDir ?? '';
    this._resolved = whichSync(this.nucleiPath);
  }

  get available() {
    return this._resolved !== null;
  }

  _fail(target) {
    return new ScanResult({
      target,
      success: false,
      errors: [TOOL_MISSING('nuclei', this.nucleiPath)],
    });
  }

  /**
   * Run Nuclei against a target using a named scan profile.
   * @param {string} target
   * @param {object} opts
   * @param {string|ScanProfile} [opts.profile='pentest']
   * @param {string[]} [opts.tags]
   * @param {string[]} [opts.severity]
   * @param {number} [opts.timeout=600]
   * @returns {Promise<ScanResult>}
   */
  scan(target, opts = {}) {
    if (!this.available) return Promise.resolve(this._fail(target));

    const profile = opts.profile ?? 'pentest';
    const sp = typeof profile === 'string' ? ScanProfile.fromDomain(profile) : profile;
    const effTags = [...new Set([...(opts.tags ?? []), ...sp.tags])];
    const effSev = opts.severity ?? sp.severity;

    const bin = this._resolved || this.nucleiPath;
    const cmd = [bin, '-jsonl', '-silent'];
    if (this.templatesDir) cmd.push('-t', this.templatesDir);
    cmd.push('-u', target);
    if (effTags.length) cmd.push('-tags', effTags.join(','));
    if (effSev.length) cmd.push('-severity', effSev.join(','));
    cmd.push('-rl', String(sp.rateLimit), '-bs', String(sp.bulkSize));
    if (sp.concurrency) cmd.push('-c', String(sp.concurrency));
    if (sp.requestTimeout) cmd.push('-timeout', String(sp.requestTimeout));
    if (sp.headless) cmd.push('-headless');
    if (sp.extraArgs) cmd.push(...sp.extraArgs);

    return this._execute(cmd, target, opts.timeout ?? DEFAULT_TIMEOUT);
  }

  /**
   * Run Nuclei with explicit template paths.
   * @param {string} target
   * @param {object} opts
   * @param {string[]} [opts.templatePaths]
   * @param {number} [opts.timeout=600]
   * @returns {Promise<ScanResult>}
   */
  /**
   * Run Nuclei with explicit template paths (synchronous — for quick scans).
   * Uses execFileSync to avoid Go pipe-buffering issues with spawn.
   * @param {string} target
   * @param {object} opts
   * @param {string[]} [opts.templatePaths]
   * @param {number} [opts.timeout=30]
   * @param {number} [opts.rateLimit=150]
   * @param {number} [opts.concurrency=25]
   * @param {number} [opts.requestTimeout=10]
   * @returns {Promise<ScanResult>}
   */
  scanWithTemplates(target, opts = {}) {
    if (!this.available) return Promise.resolve(this._fail(target));
    const bin = this._resolved || this.nucleiPath;
    const args = ['-jsonl', '-silent', '-u', target];
    for (const tp of opts.templatePaths ?? []) {
      args.push('-t', tp);
    }
    args.push('-rl', String(opts.rateLimit ?? 150));
    args.push('-c', String(opts.concurrency ?? 25));
    args.push('-timeout', String(opts.requestTimeout ?? 10));

    const start = Date.now();
    try {
      const out = execFileSync(bin, args, {
        encoding: 'utf-8',
        timeout: (opts.timeout ?? 30) * 1000,
        maxBuffer: 50 * 1024 * 1024,
        stdio: ['pipe', 'pipe', 'pipe'],
      });
      const elapsed = (Date.now() - start) / 1000;
      const findings = [];
      for (const line of out.split('\n')) {
        if (line.startsWith('{')) {
          try { findings.push(Finding.fromNucleiJson(JSON.parse(line))); }
          catch { /* skip parse errors */ }
        }
      }
      const sevCounts = {};
      for (const f of findings) sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
      return Promise.resolve(new ScanResult({
        target, findings,
        stats: { total: findings.length, ...sevCounts },
        command: `${bin} ${args.join(' ')}`,
        durationSeconds: Math.round(elapsed * 100) / 100,
        success: true,
      }));
    } catch (err) {
      const elapsed = (Date.now() - start) / 1000;
      const timedOut = err.killed || err.signal === 'SIGTERM';
      // Even on timeout, capture any partial output
      const out = err.stdout || '';
      const findings = [];
      for (const line of out.split('\n')) {
        if (line.startsWith('{')) {
          try { findings.push(Finding.fromNucleiJson(JSON.parse(line))); }
          catch { /* skip */ }
        }
      }
      return Promise.resolve(new ScanResult({
        target, findings,
        stats: { total: findings.length },
        errors: timedOut ? [`Scan timed out after ${opts.timeout ?? 30}s`] : [err.message],
        command: `${bin} ${args.join(' ')}`,
        durationSeconds: Math.round(elapsed * 100) / 100,
        success: !timedOut && findings.length > 0,
      }));
    }
  }

  /**
   * Enumerate available Nuclei templates.
   * @param {object} [opts]
   * @param {string[]} [opts.tags]
   * @param {string[]} [opts.severity]
   * @returns {object[]}
   */
  listTemplates(opts = {}) {
    if (!this.available) return [];
    const bin = this._resolved || this.nucleiPath;
    const cmd = [bin, '-tl', '-jsonl'];
    if (opts.tags?.length) cmd.push('-tags', opts.tags.join(','));
    if (opts.severity?.length) cmd.push('-severity', opts.severity.join(','));
    try {
      const out = execFileSync(cmd[0], cmd.slice(1), {
        encoding: 'utf8',
        timeout: 120_000,
      });
      const results = [];
      for (const line of out.trim().split('\n')) {
        const trimmed = line.trim();
        if (!trimmed) continue;
        if (trimmed.startsWith('{')) {
          try {
            results.push(JSON.parse(trimmed));
          } catch { /* skip malformed */ }
        } else {
          results.push({ id: trimmed });
        }
      }
      return results;
    } catch {
      return [];
    }
  }

  /**
   * Validate Nuclei template syntax in a directory.
   * @param {string} templateDir
   * @returns {{ valid: boolean, count: number, errors: string[] }}
   */
  validateTemplates(templateDir) {
    if (!this.available) {
      return { valid: false, count: 0, errors: [TOOL_MISSING('nuclei', this.nucleiPath)] };
    }
    try {
      const bin = this._resolved || this.nucleiPath;
      const result = execFileSync(bin, ['-validate', '-t', templateDir], {
        encoding: 'utf8',
        timeout: 120_000,
        stdio: ['pipe', 'pipe', 'pipe'],
      });
      const lines = result.trim().split('\n').filter(l => l.trim());
      return { valid: true, count: lines.length, errors: [] };
    } catch (err) {
      const stderr = err.stderr?.toString() ?? '';
      const errors = stderr.split('\n').filter(ln => ln.toLowerCase().includes('error'));
      return { valid: false, count: 0, errors };
    }
  }

  /**
   * Execute a Nuclei command, streaming JSONL from stdout.
   * Replaces Python's select.select with Node.js readline + event streams.
   * @param {string[]} cmd - Full command array
   * @param {string} target
   * @param {number} timeout - Seconds
   * @returns {Promise<ScanResult>}
   */
  _execute(cmd, target, timeout) {
    const cmdStr = cmd.join(' ');
    const start = Date.now();

    return new Promise((resolve) => {
      const findings = [];
      const parseErrors = [];
      const stderrLines = [];
      let proc;

      try {
        proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
      } catch (err) {
        return resolve(new ScanResult({
          target,
          command: cmdStr,
          errors: [`Failed to execute nuclei: ${err.message}`],
        }));
      }

      // Handle spawn error (e.g. ENOENT)
      proc.on('error', (err) => {
        resolve(new ScanResult({
          target,
          command: cmdStr,
          findings,
          errors: [`Failed to execute nuclei: ${err.message}`, ...parseErrors],
        }));
      });

      // Read stdout line-by-line for JSONL findings (manual line buffer — no readline needed)
      let stdoutBuf = '';
      proc.stdout.on('data', (chunk) => {
        stdoutBuf += chunk.toString();
        let nl;
        while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
          const line = stdoutBuf.slice(0, nl);
          stdoutBuf = stdoutBuf.slice(nl + 1);
          if (line.startsWith('{')) {
            try {
              findings.push(Finding.fromNucleiJson(JSON.parse(line)));
            } catch (err) {
              parseErrors.push(`Parse error: ${err.message}`);
            }
          }
        }
      });

      // Capture stderr
      proc.stderr.on('data', (chunk) => {
        for (const line of chunk.toString().split('\n')) {
          if (line.trim()) stderrLines.push(line);
        }
      });

      // Timeout guard
      const timer = setTimeout(() => {
        proc.kill('SIGKILL');
      }, timeout * 1000);

      proc.on('close', (code, signal) => {
        clearTimeout(timer);
        const elapsed = (Date.now() - start) / 1000;
        const stderrErrors = stderrLines.filter(ln => ln.toLowerCase().includes('error'));
        const timedOut = signal === 'SIGKILL';

        const sevCounts = {};
        for (const f of findings) {
          sevCounts[f.severity] = (sevCounts[f.severity] || 0) + 1;
        }

        resolve(new ScanResult({
          target,
          findings,
          stats: { total: findings.length, ...sevCounts },
          errors: [
            ...parseErrors,
            ...stderrErrors,
            ...(timedOut ? [`Scan timed out after ${timeout}s`] : []),
          ],
          command: cmdStr,
          durationSeconds: Math.round(elapsed * 100) / 100,
          success: !timedOut && code === 0,
        }));
      });
    });
  }
}

// ---------------------------------------------------------------------------
// KatanaRunner
// ---------------------------------------------------------------------------

/** API endpoint path indicators. */
const API_INDICATORS = ['/api/', '/v1/', '/v2/', '/v3/', '/graphql', '/rest/'];

class KatanaRunner {
  /**
   * @param {object} opts
   * @param {string} [opts.katanaPath='katana']
   */
  constructor(opts = {}) {
    this.katanaPath = opts.katanaPath ?? 'katana';
    this._resolved = whichSync(this.katanaPath);
  }

  get available() {
    return this._resolved !== null;
  }

  _fail(target) {
    return new CrawlResult({
      target,
      success: false,
      errors: [TOOL_MISSING('katana', this.katanaPath)],
    });
  }

  /**
   * Crawl a target with Katana, returning discovered URLs and assets.
   * Handles both JSONL and plain-text output modes.
   * @param {string} target
   * @param {object} opts
   * @param {number} [opts.depth=3]
   * @param {boolean} [opts.headless=false]
   * @param {string} [opts.scope='']
   * @param {boolean} [opts.jsCrawl=true]
   * @param {number} [opts.timeout=600]
   * @returns {Promise<CrawlResult>}
   */
  crawl(target, opts = {}) {
    if (!this.available) return Promise.resolve(this._fail(target));

    const depth = opts.depth ?? 3;
    const headless = opts.headless ?? false;
    const scope = opts.scope ?? '';
    const jsCrawl = opts.jsCrawl !== false;
    const timeout = opts.timeout ?? DEFAULT_TIMEOUT;

    const bin = this._resolved || this.katanaPath;
    const cmd = [bin, '-u', target, '-d', String(depth), '-jsonl', '-silent'];
    if (headless) cmd.push('-headless');
    if (scope) cmd.push('-cs', scope);
    if (jsCrawl) cmd.push('-jc');

    const cmdStr = cmd.join(' ');

    return new Promise((resolve) => {
      const stdoutLines = [];
      const stderrLines = [];
      let proc;

      try {
        proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
      } catch (err) {
        return resolve(new CrawlResult({
          target,
          command: cmdStr,
          errors: [`Failed to execute katana: ${err.message}`],
        }));
      }

      proc.on('error', (err) => {
        resolve(new CrawlResult({
          target,
          command: cmdStr,
          errors: [`Failed to execute katana: ${err.message}`],
        }));
      });

      let stdoutBuf = '';
      proc.stdout.on('data', (chunk) => {
        stdoutBuf += chunk.toString();
        let nl;
        while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
          const line = stdoutBuf.slice(0, nl).trim();
          stdoutBuf = stdoutBuf.slice(nl + 1);
          if (line) stdoutLines.push(line);
        }
      });

      proc.stderr.on('data', (chunk) => {
        for (const line of chunk.toString().split('\n')) {
          if (line.trim()) stderrLines.push(line.trim());
        }
      });

      const timer = setTimeout(() => {
        proc.kill('SIGKILL');
      }, timeout * 1000);

      proc.on('close', (code) => {
        clearTimeout(timer);
        resolve(this._classifyOutput(stdoutLines, target, cmdStr, code === 0));
      });
    });
  }

  /**
   * Classify raw output lines into urls, endpoints, js files, forms.
   * Handles both JSONL (preferred) and plain-text output.
   */
  _classifyOutput(lines, target, command, processSuccess) {
    const urls = [];
    const endpoints = [];
    const forms = [];
    const jsFiles = [];

    for (const line of lines) {
      let url = line;
      if (line.startsWith('{')) {
        try {
          const entry = JSON.parse(line);
          url = entry.request?.endpoint ?? line;
          if (entry.request?.method?.toUpperCase() === 'POST') {
            forms.push({
              url,
              method: 'POST',
              body: entry.request?.body ?? '',
            });
          }
        } catch { /* treat as plain text */ }
      }
      urls.push(url);
      const lower = url.toLowerCase();
      if (lower.endsWith('.js') || lower.endsWith('.mjs')) {
        jsFiles.push(url);
      }
      if (API_INDICATORS.some(ind => lower.includes(ind))) {
        endpoints.push(url);
      }
    }

    return new CrawlResult({
      target,
      urls: [...new Set(urls)].sort(),
      endpoints: [...new Set(endpoints)].sort(),
      forms,
      jsFiles: [...new Set(jsFiles)].sort(),
      command,
      success: processSuccess,
    });
  }

  /**
   * Crawl and return only API-like endpoints.
   * @param {string} target
   * @param {object} opts
   * @param {number} [opts.timeout=600]
   * @returns {Promise<string[]>}
   */
  async extractEndpoints(target, opts = {}) {
    const result = await this.crawl(target, { depth: 2, jsCrawl: true, timeout: opts.timeout });
    return result.endpoints;
  }
}

// ---------------------------------------------------------------------------
// File extension → Nuclei tag mapping for PR scans
// ---------------------------------------------------------------------------

const EXT_TAG_MAP = Object.freeze({
  '.yaml': ['misconfig'],
  '.yml': ['misconfig'],
  '.json': ['misconfig', 'exposure'],
  '.toml': ['misconfig'],
  '.env': ['exposure', 'token'],
  '.py': ['sqli', 'xss', 'ssrf', 'rce'],
  '.js': ['sqli', 'xss', 'ssrf', 'rce', 'prototype-pollution'],
  '.ts': ['sqli', 'xss', 'ssrf', 'rce'],
  '.go': ['sqli', 'ssrf', 'rce'],
  '.java': ['sqli', 'xss', 'ssrf', 'rce', 'deserialization'],
  '.php': ['sqli', 'xss', 'ssrf', 'rce', 'lfi'],
  '.rb': ['sqli', 'xss', 'ssrf', 'rce'],
  '.tf': ['misconfig', 'exposure'],
  '.dockerfile': ['misconfig'],
});

// ---------------------------------------------------------------------------
// ScanPipeline
// ---------------------------------------------------------------------------

class ScanPipeline {
  /**
   * Orchestrates Katana crawl → Nuclei scan → memory storage.
   * @param {object} opts
   * @param {NucleiRunner} opts.nucleiRunner
   * @param {KatanaRunner} opts.katanaRunner
   * @param {function} [opts.memoryCallback] - fn(findings, target, context) => string[]
   */
  constructor(opts = {}) {
    this.nucleiRunner = opts.nucleiRunner;
    this.katanaRunner = opts.katanaRunner;
    this.memoryCallback = opts.memoryCallback ?? null;
  }

  /**
   * Full pipeline: crawl → scan → store findings.
   * @param {string} target
   * @param {object} opts
   * @param {string} [opts.profile='pentest']
   * @returns {Promise<PipelineResult>}
   */
  async fullScan(target, opts = {}) {
    const profile = opts.profile ?? 'pentest';
    const errors = [];

    const crawlResult = await this.katanaRunner.crawl(target);
    if (!crawlResult.success) {
      errors.push(...crawlResult.errors);
    }

    const scanResult = await this.nucleiRunner.scan(target, { profile });
    if (!scanResult.success) {
      errors.push(...scanResult.errors);
    }

    const stored = this._storeFindings(scanResult.findings, target, 'full_scan');

    return new PipelineResult({
      target,
      crawl: crawlResult,
      scan: scanResult,
      storedEntryIds: stored,
      errors,
      success: scanResult.success,
    });
  }

  /**
   * Scan PR changes — derives Nuclei tags from changed file extensions.
   * @param {string} repoUrl
   * @param {number} prNumber
   * @param {string[]} changedFiles
   * @returns {Promise<PipelineResult>}
   */
  async prScan(repoUrl, prNumber, changedFiles) {
    const tags = new Set();
    for (const fpath of changedFiles) {
      const ext = extname(fpath).toLowerCase();
      const mapped = EXT_TAG_MAP[ext];
      if (mapped) mapped.forEach(t => tags.add(t));
      const basename = fpath.split(/[/\\]/).pop().toLowerCase();
      if (basename === 'dockerfile' || basename === 'docker-compose.yml') {
        tags.add('misconfig');
        tags.add('exposure');
      }
    }
    const effTags = tags.size > 0 ? [...tags].sort() : ['cve', 'misconfig'];

    const scanResult = await this.nucleiRunner.scan(repoUrl, { profile: 'pentest', tags: effTags });
    const stored = this._storeFindings(scanResult.findings, repoUrl, `pr_scan:PR-${prNumber}`);

    return new PipelineResult({
      target: repoUrl,
      scan: scanResult,
      storedEntryIds: stored,
      errors: scanResult.errors,
      success: scanResult.success,
    });
  }

  /**
   * Re-run scans for prior engagement findings to verify remediation.
   * @param {string} engagementId
   * @returns {Promise<PipelineResult>}
   */
  async regressionScan(engagementId) {
    // Regression scan requires memory to load prior findings
    return new PipelineResult({
      target: engagementId,
      success: false,
      errors: ['Regression scan requires memory integration — not yet implemented in Node.js'],
    });
  }

  /**
   * Persist findings via the memory callback if provided.
   * @param {Finding[]} findings
   * @param {string} target
   * @param {string} context
   * @returns {string[]} Stored entry IDs
   */
  _storeFindings(findings, target, context) {
    if (!this.memoryCallback || !findings.length) return [];
    try {
      return this.memoryCallback(findings, target, context);
    } catch {
      return [];
    }
  }
}

/**
 * Extract file extension from a path.
 * @param {string} filePath
 * @returns {string}
 */
function extname(filePath) {
  const idx = filePath.lastIndexOf('.');
  return idx >= 0 ? filePath.slice(idx) : '';
}

// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------

export {
  // Enums
  ScanDomain,
  PROFILE_CONFIGS,
  EXT_TAG_MAP,
  // Data classes
  Finding,
  ScanResult,
  CrawlResult,
  PipelineResult,
  ScanProfile,
  // Runners
  NucleiRunner,
  KatanaRunner,
  ScanPipeline,
  // Utilities
  whichSync,
};
