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

/**
 * Static-Dynamic Correlation: Exploitation Queue Builder
 *
 * Converts normalized static findings (CorrelatedFinding) into a bounded,
 * severity-sorted queue of Nuclei scan configurations or lightweight HTTP
 * probe configs. Classifies findings as dynamically-verifiable (web vulns
 * with inferable network surfaces) or static-only (no network attack path).
 *
 * @module correlation/queue-builder
 */

import { NucleiTemplateManager } from '../pipeline/template-manager.js';
import { ScanProfile } from '../pipeline/scanner.js';

// ---------------------------------------------------------------------------
// CWE classification map: CWEs that imply a network-reachable attack surface
// ---------------------------------------------------------------------------

const DYNAMIC_CWES = new Set([
  'CWE-89',   // SQL Injection
  'CWE-79',   // Cross-site Scripting (XSS)
  'CWE-918',  // Server-Side Request Forgery (SSRF)
  'CWE-22',   // Path Traversal
  'CWE-78',   // OS Command Injection
  'CWE-77',   // Command Injection (generic)
  'CWE-94',   // Code Injection
  'CWE-434',  // Unrestricted Upload
  'CWE-611',  // XXE
  'CWE-352',  // CSRF
  'CWE-601',  // Open Redirect
  'CWE-502',  // Deserialization (network-reachable variant)
]);

// CWE to Nuclei tag mapping for template discovery
const CWE_TAG_MAP = {
  'CWE-89':  'sqli',
  'CWE-79':  'xss',
  'CWE-918': 'ssrf',
  'CWE-22':  'lfi',
  'CWE-78':  'rce',
  'CWE-77':  'rce',
  'CWE-94':  'rce',
  'CWE-434': 'fileupload',
  'CWE-611': 'xxe',
  'CWE-352': 'csrf',
  'CWE-601': 'redirect',
  'CWE-502': 'deserialization',
};

// CWE to HTTP method heuristic for probe generation
const CWE_METHOD_MAP = {
  'CWE-89':  'GET',
  'CWE-79':  'GET',
  'CWE-918': 'GET',
  'CWE-22':  'GET',
  'CWE-78':  'POST',
  'CWE-77':  'POST',
  'CWE-94':  'POST',
  'CWE-434': 'POST',
  'CWE-611': 'POST',
  'CWE-352': 'POST',
  'CWE-601': 'GET',
  'CWE-502': 'POST',
};

const DEFAULT_MAX_QUEUE_SIZE = 50;

// ---------------------------------------------------------------------------
// ExploitationQueueBuilder
// ---------------------------------------------------------------------------

export class ExploitationQueueBuilder {
  /**
   * @param {object} [opts]
   * @param {NucleiTemplateManager} [opts.templateManager] - Injected manager; creates default if omitted
   * @param {number} [opts.maxQueueSize] - Default cap on queue entries
   */
  constructor(opts = {}) {
    this.templateManager = opts.templateManager ?? new NucleiTemplateManager();
    this.maxQueueSize = opts.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
  }

  /**
   * Classify a CorrelatedFinding as 'dynamic' (network-verifiable) or 'static-only'.
   *
   * A finding is dynamically verifiable if any of its CWE IDs map to a known
   * network-reachable vulnerability class. Findings without CWEs or with
   * CWEs that lack a network surface (hardcoded secrets, weak crypto, etc.)
   * are classified as static-only.
   *
   * @param {import('./normalizer.js').CorrelatedFinding} finding
   * @returns {'dynamic'|'static-only'}
   */
  classifyFinding(finding) {
    if (!finding || !Array.isArray(finding.cweIds)) return 'static-only';
    for (const cwe of finding.cweIds) {
      if (DYNAMIC_CWES.has(cwe)) return 'dynamic';
    }
    return 'static-only';
  }

  /**
   * Build a bounded, severity-sorted exploitation queue from correlated findings.
   *
   * Filters to dynamically-verifiable findings, sorts by severity (highest first),
   * caps at maxQueueSize, and generates Nuclei template YAML + ScanProfile entries.
   *
   * @param {import('./normalizer.js').CorrelatedFinding[]} findings
   * @param {object} [opts]
   * @param {number} [opts.maxQueueSize] - Override default cap
   * @returns {{ queue: Array<{ finding: object, template: string, profile: object, probeConfig: object }>, skipped: Array<{ id: string, reason: string }> }}
   */
  buildQueue(findings, opts = {}) {
    const maxSize = opts.maxQueueSize ?? this.maxQueueSize;

    if (!Array.isArray(findings) || findings.length === 0) {
      return { queue: [], skipped: [] };
    }

    const dynamic = [];
    const skipped = [];

    for (const f of findings) {
      const classification = this.classifyFinding(f);
      if (classification === 'dynamic') {
        dynamic.push(f);
      } else {
        skipped.push({ id: f.id, title: f.title, reason: 'static-only: no network-verifiable CWE' });
      }
    }

    // Sort by severity descending (rank is higher for more severe)
    dynamic.sort((a, b) => b.rank - a.rank);

    // Cap at max queue size
    const capped = dynamic.slice(0, maxSize);
    if (dynamic.length > maxSize) {
      for (const f of dynamic.slice(maxSize)) {
        skipped.push({ id: f.id, title: f.title, reason: `exceeded maxQueueSize (${maxSize})` });
      }
    }

    const queue = capped.map((f) => this._buildQueueEntry(f));

    return { queue, skipped };
  }

  /**
   * Build a lightweight HTTP probe config for a finding.
   *
   * Probes are simple HEAD/GET requests to the inferred endpoint path,
   * useful for quick reachability checks before running full Nuclei scans.
   *
   * @param {import('./normalizer.js').CorrelatedFinding} finding
   * @returns {{ method: string, path: string, headers: object, timeout: number, expectedStatus: number[], description: string }}
   */
  buildProbeConfig(finding) {
    if (!finding) {
      return { method: 'GET', path: '/', headers: {}, timeout: 5000, expectedStatus: [200], description: '' };
    }

    const path = this._inferEndpointPath(finding);
    const method = this._inferMethod(finding);

    return {
      method,
      path,
      headers: {
        'User-Agent': 'NERF-Probe/1.0',
      },
      timeout: 5000,
      expectedStatus: [200, 301, 302, 403],
      description: `Probe: ${finding.title} at ${path}`,
    };
  }

  // -----------------------------------------------------------------------
  // Private helpers
  // -----------------------------------------------------------------------

  /**
   * Build a single queue entry with Nuclei template and ScanProfile.
   * @param {import('./normalizer.js').CorrelatedFinding} finding
   * @returns {{ finding: object, template: string, profile: object, probeConfig: object }}
   */
  _buildQueueEntry(finding) {
    const path = this._inferEndpointPath(finding);
    const method = this._inferMethod(finding);

    // Build template generation input compatible with NucleiTemplateManager.generateTemplate()
    const templateInput = {
      title: finding.title,
      description: finding.description || finding.title,
      severity: finding.severity,
      method,
      path,
      matchers: this._inferMatchers(finding),
    };

    const template = this.templateManager.generateTemplate(templateInput);

    // Build a ScanProfile scoped to the finding's vulnerability class
    const tags = this._cweTags(finding);
    const profile = new ScanProfile({
      name: `exploit-${finding.id}`,
      tags,
      severity: [finding.severity],
      rateLimit: 50,
      bulkSize: 5,
      headless: false,
    });

    const probeConfig = this.buildProbeConfig(finding);

    return {
      finding: {
        id: finding.id,
        title: finding.title,
        severity: finding.severity,
        cweIds: finding.cweIds,
        matchKey: finding.matchKey,
      },
      template,
      profile,
      probeConfig,
    };
  }

  /**
   * Infer an endpoint path from finding location metadata.
   * @param {import('./normalizer.js').CorrelatedFinding} finding
   * @returns {string}
   */
  _inferEndpointPath(finding) {
    // Dynamic location is the most direct signal
    if (finding.dynamicLocation?.matchedAt) {
      try {
        const url = new URL(finding.dynamicLocation.matchedAt);
        return url.pathname || '/';
      } catch {
        // matchedAt might be a path already
        if (finding.dynamicLocation.matchedAt.startsWith('/')) {
          return finding.dynamicLocation.matchedAt;
        }
      }
    }

    // Static location: try to infer from file path (e.g. routes/api/users.js -> /api/users)
    if (finding.staticLocation?.file) {
      const file = finding.staticLocation.file;
      const routeMatch = file.match(/(?:routes|api|pages|endpoints)\/(.+?)(?:\.\w+)?$/);
      if (routeMatch) {
        return '/' + routeMatch[1].replace(/\\/g, '/');
      }
    }

    return '/';
  }

  /**
   * Infer HTTP method from the finding's CWE IDs.
   * @param {import('./normalizer.js').CorrelatedFinding} finding
   * @returns {string}
   */
  _inferMethod(finding) {
    for (const cwe of (finding.cweIds || [])) {
      if (CWE_METHOD_MAP[cwe]) return CWE_METHOD_MAP[cwe];
    }
    return 'GET';
  }

  /**
   * Infer Nuclei matcher keywords from finding metadata.
   * @param {import('./normalizer.js').CorrelatedFinding} finding
   * @returns {string[]}
   */
  _inferMatchers(finding) {
    const matchers = [];

    // Use proof text as a matcher hint if available
    if (finding.proof && finding.proof.length < 200) {
      // Extract short error signatures from proof
      const errorPatterns = finding.proof.match(/(?:error|exception|warning|stack trace)[^"']*/i);
      if (errorPatterns) {
        matchers.push(errorPatterns[0].trim().slice(0, 80));
      }
    }

    // CWE-specific default matchers
    for (const cwe of (finding.cweIds || [])) {
      switch (cwe) {
        case 'CWE-89': matchers.push('SQL syntax'); break;
        case 'CWE-79': matchers.push('<script>'); break;
        case 'CWE-22': matchers.push('root:'); break;
        case 'CWE-78': matchers.push('uid='); break;
      }
    }

    return matchers;
  }

  /**
   * Map finding CWEs to Nuclei template tags.
   * @param {import('./normalizer.js').CorrelatedFinding} finding
   * @returns {string[]}
   */
  _cweTags(finding) {
    const tags = new Set();
    for (const cwe of (finding.cweIds || [])) {
      if (CWE_TAG_MAP[cwe]) tags.add(CWE_TAG_MAP[cwe]);
    }
    return tags.size > 0 ? [...tags] : ['custom'];
  }
}

export { DYNAMIC_CWES, CWE_TAG_MAP, DEFAULT_MAX_QUEUE_SIZE };
