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

/**
 * Static XSS heuristic scanner — detects DOM XSS sinks and sources without a browser.
 *
 * Covers ~40-60% of reflected/DOM XSS patterns via regex-based sink/source analysis.
 * This is the static layer; full runtime DOM XSS requires Playwright (dom-xss-scanner.js).
 *
 * Ported from pipeline/xss_scanner.py (338 LOC Python).
 */

import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';

// ---------------------------------------------------------------------------
// Sink patterns — where untrusted data reaches dangerous APIs
// Note: No /g flag on any regex to avoid lastIndex state issues.
// ---------------------------------------------------------------------------

/** @type {Array<[string, string, RegExp]>} [name, severity, pattern] */
const SINK_PATTERNS = [
  ['innerHTML', 'critical', /\.\s*innerHTML\s*=(?!=)/i],
  ['outerHTML', 'critical', /\.\s*outerHTML\s*=(?!=)/i],
  ['document.write', 'critical', /document\s*\.\s*write(ln)?\s*\(/i],
  ['eval', 'critical', /\beval\s*\(/i],
  ['setTimeout-string', 'high', /setTimeout\s*\(\s*['"]/i],
  ['setInterval-string', 'high', /setInterval\s*\(\s*['"]/i],
  ['Function-constructor', 'critical', /\bnew\s+Function\s*\(/i],
  ['insertAdjacentHTML', 'high', /\.insertAdjacentHTML\s*\(/i],
  ['dangerouslySetInnerHTML', 'critical', /dangerouslySetInnerHTML\s*=\s*\{/i],
  ['v-html', 'high', /\bv-html\s*=/i],
  ['[innerHTML]', 'high', /\[innerHTML\]\s*=/i],
  ['bypassSecurityTrust', 'critical', /bypassSecurityTrust(Html|Script|Url|ResourceUrl|Style)/i],
  ['jquery-html', 'high', /\$\([^)]*\)\s*\.\s*html\s*\(/i],
  ['jquery-append-raw', 'medium', /\$\([^)]*\)\s*\.\s*append\s*\(/i],
  ['document.domain', 'high', /document\s*\.\s*domain\s*=/i],
  ['location-assign', 'high', /location\s*\.\s*(href|assign|replace)\s*=/i],
  ['srcdoc', 'high', /\bsrcdoc\s*=/i],
  ['postMessage-star', 'medium', /\.postMessage\s*\([^,]+,\s*['"]?\*/i],
];

// ---------------------------------------------------------------------------
// Source patterns — where untrusted data enters the application
// ---------------------------------------------------------------------------

/** @type {Array<[string, RegExp]>} [name, pattern] */
const SOURCE_PATTERNS = [
  ['location.hash', /location\s*\.\s*hash/i],
  ['location.search', /location\s*\.\s*search/i],
  ['location.href', /location\s*\.\s*href(?!\s*=)/i],
  ['document.URL', /document\s*\.\s*URL/i],
  ['document.referrer', /document\s*\.\s*referrer/i],
  ['document.cookie', /document\s*\.\s*cookie/i],
  ['window.name', /window\s*\.\s*name/i],
  ['URLSearchParams', /new\s+URLSearchParams/i],
  ['postMessage-handler', /addEventListener\s*\(\s*['"]message['"]/i],
];

// ---------------------------------------------------------------------------
// Template injection patterns (server-side)
// ---------------------------------------------------------------------------

/** @type {Array<[string, string, RegExp]>} [name, severity, pattern] */
const TEMPLATE_SINKS = [
  ['jinja2-raw', 'high', /\{\{.*\|.*safe\s*\}\}/i],
  ['jinja2-autoescape-off', 'critical', /\{%\s*autoescape\s+false/i],
  ['erb-raw', 'high', /<%=\s*raw\s+/i],
  ['php-echo-unescaped', 'high', /<\?=\s*\$_(GET|POST|REQUEST|COOKIE)/i],
  ['asp-response-write', 'high', /Response\.Write\s*\(\s*Request/i],
  ['thymeleaf-utext', 'high', /th:utext\s*=/i],
  ['razor-raw', 'high', /@Html\.Raw\s*\(/i],
];

// ---------------------------------------------------------------------------
// File extensions to scan
// ---------------------------------------------------------------------------

const SCANNABLE_EXTENSIONS = new Set([
  '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte',
  '.html', '.htm', '.php', '.erb', '.ejs', '.hbs',
  '.pug', '.jade', '.py', '.rb', '.java', '.cs',
  '.asp', '.aspx',
]);

const SKIP_DIRS = new Set(['node_modules', 'vendor', '.venv', '__pycache__']);

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

class XSSFinding {
  /**
   * @param {object} opts
   * @param {string} opts.file
   * @param {number} opts.lineNumber
   * @param {string} opts.lineContent
   * @param {string} opts.sinkName
   * @param {string} opts.severity  critical | high | medium
   * @param {string} opts.category  dom-sink | dom-source | template-injection
   * @param {string|null} [opts.sourceNearby=null]
   */
  constructor(opts) {
    this.file = opts.file;
    this.lineNumber = opts.lineNumber;
    this.lineContent = opts.lineContent;
    this.sinkName = opts.sinkName;
    this.severity = opts.severity;
    this.category = opts.category;
    this.sourceNearby = opts.sourceNearby ?? null;
  }

  toDict() {
    const d = {
      file: this.file,
      line: this.lineNumber,
      sink: this.sinkName,
      severity: this.severity,
      category: this.category,
      content: this.lineContent.trim().slice(0, 200),
    };
    if (this.sourceNearby) {
      d.source_nearby = this.sourceNearby;
    }
    return d;
  }
}

class XSSScanResult {
  /**
   * @param {object} [opts]
   * @param {number} [opts.filesScanned=0]
   * @param {XSSFinding[]} [opts.findings=[]]
   * @param {Array<object>} [opts.sourcesFound=[]]
   */
  constructor(opts = {}) {
    this.filesScanned = opts.filesScanned ?? 0;
    this.findings = opts.findings ?? [];
    this.sourcesFound = opts.sourcesFound ?? [];
  }

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

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

  toDict() {
    return {
      files_scanned: this.filesScanned,
      total_findings: this.findings.length,
      critical: this.criticalCount,
      high: this.highCount,
      medium: this.findings.length - this.criticalCount - this.highCount,
      findings: this.findings.map((f) => f.toDict()),
      sources_found: this.sourcesFound,
    };
  }

  summary() {
    if (this.findings.length === 0) {
      return `No XSS sinks detected in ${this.filesScanned} files.`;
    }
    return (
      `${this.findings.length} XSS sink(s) in ${this.filesScanned} files: ` +
      `${this.criticalCount} critical, ${this.highCount} high`
    );
  }
}

// ---------------------------------------------------------------------------
// XSSScanner
// ---------------------------------------------------------------------------

class XSSScanner {
  /**
   * Scan a single file for XSS patterns.
   * @param {string} filepath
   * @returns {XSSFinding[]}
   */
  scanFile(filepath) {
    const ext = extname(filepath).toLowerCase();
    if (!SCANNABLE_EXTENSIONS.has(ext)) return [];

    let content;
    try {
      content = readFileSync(filepath, 'utf-8');
    } catch {
      return [];
    }

    const lines = content.split('\n');
    const findings = [];
    /** @type {Map<number, string>} lineNumber → sourceName */
    const sourceLines = new Map();

    // First pass: find all source locations
    for (let i = 0; i < lines.length; i++) {
      for (const [srcName, srcPat] of SOURCE_PATTERNS) {
        if (srcPat.test(lines[i])) {
          sourceLines.set(i + 1, srcName);
          break;
        }
      }
    }

    // Second pass: find sinks + correlate nearby sources
    for (let i = 0; i < lines.length; i++) {
      const lineNo = i + 1;
      const line = lines[i];

      for (const [sinkName, severity, sinkPat] of SINK_PATTERNS) {
        if (sinkPat.test(line)) {
          // Check if a source is within 10 lines
          let nearbySource = null;
          let adjustedSeverity = severity;
          for (const [srcLineNo, srcName] of sourceLines) {
            if (Math.abs(srcLineNo - lineNo) <= 10) {
              nearbySource = srcName;
              adjustedSeverity = 'critical'; // Source + sink = confirmed dangerous
              break;
            }
          }
          findings.push(
            new XSSFinding({
              file: filepath,
              lineNumber: lineNo,
              lineContent: line,
              sinkName,
              severity: adjustedSeverity,
              category: 'dom-sink',
              sourceNearby: nearbySource,
            }),
          );
        }
      }

      for (const [sinkName, severity, sinkPat] of TEMPLATE_SINKS) {
        if (sinkPat.test(line)) {
          findings.push(
            new XSSFinding({
              file: filepath,
              lineNumber: lineNo,
              lineContent: line,
              sinkName,
              severity,
              category: 'template-injection',
            }),
          );
        }
      }
    }

    return findings;
  }

  /**
   * Scan a directory for XSS patterns.
   * @param {string} directory
   * @param {object} [opts]
   * @param {boolean} [opts.recursive=true]
   * @returns {XSSScanResult}
   */
  scanDirectory(directory, opts = {}) {
    const recursive = opts.recursive !== false;
    const result = new XSSScanResult();

    const walk = (dir) => {
      let entries;
      try {
        entries = readdirSync(dir, { withFileTypes: true });
      } catch {
        return;
      }
      for (const ent of entries.sort((a, b) => a.name.localeCompare(b.name))) {
        if (ent.name.startsWith('.') || SKIP_DIRS.has(ent.name)) continue;
        const full = join(dir, ent.name);
        if (ent.isDirectory() && recursive) {
          walk(full);
        } else if (ent.isFile() && SCANNABLE_EXTENSIONS.has(extname(ent.name).toLowerCase())) {
          result.filesScanned++;
          const findings = this.scanFile(full);
          result.findings.push(...findings);
        }
      }
    };
    walk(directory);
    return result;
  }

  /**
   * Scan added lines in a unified diff for XSS patterns.
   * @param {string} diffText
   * @param {string} [filename='<diff>']
   * @returns {XSSFinding[]}
   */
  scanDiff(diffText, filename = '<diff>') {
    const findings = [];
    let lineNo = 0;

    for (const line of diffText.split('\n')) {
      if (line.startsWith('@@')) {
        const m = line.match(/\+(\d+)/);
        lineNo = m ? parseInt(m[1], 10) : 0;
        continue;
      }
      if (line.startsWith('+') && !line.startsWith('+++')) {
        const content = line.slice(1);
        lineNo++;

        const allSinks = [...SINK_PATTERNS, ...TEMPLATE_SINKS];
        for (const [sinkName, severity, sinkPat] of allSinks) {
          if (sinkPat.test(content)) {
            const isTemplate = TEMPLATE_SINKS.some((t) => t[0] === sinkName);
            findings.push(
              new XSSFinding({
                file: filename,
                lineNumber: lineNo,
                lineContent: content,
                sinkName,
                severity,
                category: isTemplate ? 'template-injection' : 'dom-sink',
              }),
            );
          }
        }
      } else if (!line.startsWith('-')) {
        lineNo++;
      }
    }
    return findings;
  }
}

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

export {
  // Pattern arrays (for testing/inspection)
  SINK_PATTERNS,
  SOURCE_PATTERNS,
  TEMPLATE_SINKS,
  SCANNABLE_EXTENSIONS,
  // Data classes
  XSSFinding,
  XSSScanResult,
  // Scanner
  XSSScanner,
};
