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

/**
 * Runtime DOM XSS scanner using Playwright headless browser.
 *
 * Complements the static XSS scanner (xss-scanner.js) by executing JavaScript
 * in a real browser context to detect XSS that only manifests at runtime.
 *
 * Playwright is an OPTIONAL dependency — scanner gracefully degrades when
 * it's not installed. Do NOT add playwright to package.json.
 *
 * Ported from pipeline/dom_xss_scanner.py (390 LOC Python).
 */

import { URL } from 'node:url';

// ---------------------------------------------------------------------------
// Payloads
// ---------------------------------------------------------------------------

/** @type {Array<[string, string, string]>} [label, payload, detection] */
const XSS_PAYLOADS = Object.freeze([
  ['script-alert', '<script>alert("NERF-XSS-1")</script>', 'dialog'],
  ['img-onerror', '<img src=x onerror=alert("NERF-XSS-2")>', 'dialog'],
  ['svg-onload', '<svg/onload=alert("NERF-XSS-3")>', 'dialog'],
  ['body-onload', '<body onload=alert("NERF-XSS-4")>', 'dialog'],
  ['event-handler', '" onfocus=alert("NERF-XSS-5") autofocus="', 'dialog'],
  ['javascript-uri', 'javascript:alert("NERF-XSS-6")', 'dialog'],
  ['template-literal', '${alert("NERF-XSS-7")}', 'dialog'],
  ['iframe-srcdoc', '<iframe srcdoc="<script>alert(\'NERF-XSS-8\')</script>">', 'dialog'],
]);

/** @type {Array<[string, string]>} [label, payload] */
const URL_PAYLOADS = Object.freeze([
  ['reflected-script', '<script>alert("NERF-XSS-R1")</script>'],
  ['reflected-img', '<img src=x onerror=alert("NERF-XSS-R2")>'],
  ['reflected-svg', '<svg/onload=alert("NERF-XSS-R3")>'],
  ['hash-injection', '#<img src=x onerror=alert("NERF-XSS-R4")>'],
]);

// Common parameter names to test when URL has no existing params
const COMMON_PARAMS = ['q', 'search', 'query', 'id', 'name', 'page', 'url', 'redirect', 'next', 'callback'];

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

class DOMXSSFinding {
  /**
   * @param {object} opts
   * @param {string} opts.url
   * @param {string} opts.vector  input | url-param | fragment | form
   * @param {string} opts.inputName
   * @param {string} opts.payload
   * @param {string} opts.payloadLabel
   * @param {boolean} [opts.confirmed=true]
   * @param {string} [opts.reproduction='']
   */
  constructor(opts) {
    this.url = opts.url;
    this.vector = opts.vector;
    this.inputName = opts.inputName;
    this.payload = opts.payload;
    this.payloadLabel = opts.payloadLabel;
    this.confirmed = opts.confirmed !== false;
    this.reproduction = opts.reproduction ?? '';
  }

  toDict() {
    return {
      url: this.url,
      vector: this.vector,
      input: this.inputName,
      payload_label: this.payloadLabel,
      confirmed: this.confirmed,
      reproduction: this.reproduction,
    };
  }
}

class DOMXSSScanResult {
  /**
   * @param {object} opts
   * @param {string} opts.target
   * @param {number} [opts.pagesTested=0]
   * @param {number} [opts.inputsTested=0]
   * @param {number} [opts.paramsTested=0]
   * @param {DOMXSSFinding[]} [opts.findings=[]]
   * @param {string[]} [opts.errors=[]]
   */
  constructor(opts) {
    this.target = opts.target;
    this.pagesTested = opts.pagesTested ?? 0;
    this.inputsTested = opts.inputsTested ?? 0;
    this.paramsTested = opts.paramsTested ?? 0;
    this.findings = opts.findings ?? [];
    this.errors = opts.errors ?? [];
  }

  toDict() {
    return {
      target: this.target,
      pages_tested: this.pagesTested,
      inputs_tested: this.inputsTested,
      params_tested: this.paramsTested,
      findings: this.findings.map((f) => f.toDict()),
      confirmed_xss: this.findings.length,
      errors: this.errors,
    };
  }

  summary() {
    if (this.findings.length > 0) {
      return (
        `${this.findings.length} confirmed DOM XSS in ${this.target} ` +
        `(${this.inputsTested} inputs, ${this.paramsTested} params tested)`
      );
    }
    return `No DOM XSS found in ${this.target} (${this.inputsTested} inputs tested)`;
  }
}

// ---------------------------------------------------------------------------
// DOMXSSScanner
// ---------------------------------------------------------------------------

class DOMXSSScanner {
  /**
   * @param {object} [opts]
   * @param {boolean} [opts.headless=true]
   * @param {number} [opts.timeout=5000]
   */
  constructor(opts = {}) {
    this._headless = opts.headless !== false;
    this._timeout = opts.timeout ?? 5000;
  }

  /**
   * Check if Playwright is available.
   * @returns {Promise<boolean>}
   */
  async available() {
    try {
      await import('playwright');
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Scan a URL for DOM XSS vulnerabilities.
   * @param {string} url
   * @param {object} [opts]
   * @param {boolean} [opts.testParams=true]
   * @param {boolean} [opts.testInputs=true]
   * @param {boolean} [opts.testForms=true]
   * @returns {Promise<DOMXSSScanResult>}
   */
  async scanUrl(url, opts = {}) {
    const testParams = opts.testParams !== false;
    const testInputs = opts.testInputs !== false;
    const testForms = opts.testForms !== false;

    let pw;
    try {
      pw = await import('playwright');
    } catch {
      return new DOMXSSScanResult({
        target: url,
        errors: ['Playwright not installed — run: npm install playwright && npx playwright install chromium'],
      });
    }

    const result = new DOMXSSScanResult({ target: url });

    try {
      const browser = await pw.chromium.launch({ headless: this._headless });
      const context = await browser.newContext({
        ignoreHTTPSErrors: true,
        javaScriptEnabled: true,
      });

      if (testParams) {
        await this._testUrlParams(context, url, result);
      }
      if (testInputs) {
        await this._testInputFields(context, url, result);
      }
      if (testForms) {
        await this._testForms(context, url, result);
      }

      result.pagesTested++;
      await context.close();
      await browser.close();
    } catch (err) {
      result.errors.push(`Scanner error: ${err.message}`);
    }

    return result;
  }

  /**
   * Inject payloads into URL query parameters.
   * @private
   */
  async _testUrlParams(context, url, result) {
    const parsed = new URL(url);
    const params = parsed.searchParams;

    if ([...params.keys()].length === 0) {
      // Try common parameter names
      for (const paramName of COMMON_PARAMS) {
        for (const [label, payload] of URL_PAYLOADS) {
          result.paramsTested++;
          const injected = new URL(url);
          injected.searchParams.set(paramName, payload);
          const finding = await this._checkUrlXss(
            context, injected.toString(), 'url-param', paramName, label, payload,
          );
          if (finding) {
            result.findings.push(finding);
            return; // One confirmed finding is enough
          }
        }
      }
    } else {
      for (const paramName of params.keys()) {
        for (const [label, payload] of URL_PAYLOADS) {
          result.paramsTested++;
          const injected = new URL(url);
          injected.searchParams.set(paramName, payload);
          const finding = await this._checkUrlXss(
            context, injected.toString(), 'url-param', paramName, label, payload,
          );
          if (finding) {
            result.findings.push(finding);
            break; // Move to next param
          }
        }
      }
    }

    // Test fragment injection
    for (const [label, payload] of URL_PAYLOADS) {
      if (label.startsWith('hash')) {
        result.paramsTested++;
        const injected = `${url}${payload}`;
        const finding = await this._checkUrlXss(
          context, injected, 'fragment', '#', label, payload,
        );
        if (finding) {
          result.findings.push(finding);
        }
      }
    }
  }

  /**
   * Inject payloads into input fields on the page.
   * @private
   */
  async _testInputFields(context, url, result) {
    const page = await context.newPage();
    const dialogs = [];
    page.on('dialog', async (d) => {
      dialogs.push(d.message());
      await d.accept();
    });

    try {
      await page.goto(url, { waitUntil: 'networkidle', timeout: this._timeout * 2 });
    } catch (err) {
      result.errors.push(`Navigation failed: ${err.message}`);
      await page.close();
      return;
    }

    const inputs = await page.$$(
      "input[type='text'], input[type='search'], input:not([type]), textarea, [contenteditable='true']",
    );

    for (const inp of inputs) {
      const inputName =
        (await inp.getAttribute('name')) ||
        (await inp.getAttribute('id')) ||
        (await inp.getAttribute('placeholder')) ||
        'unnamed';

      for (const [label, payload] of XSS_PAYLOADS) {
        result.inputsTested++;
        dialogs.length = 0;
        try {
          await inp.fill('');
          await inp.fill(payload);
          await inp.dispatchEvent('change');
          await inp.dispatchEvent('blur');
          await page.waitForTimeout(500);

          if (dialogs.some((d) => d.includes('NERF-XSS'))) {
            result.findings.push(
              new DOMXSSFinding({
                url,
                vector: 'input',
                inputName,
                payload,
                payloadLabel: label,
                reproduction: `Fill input '${inputName}' with: ${payload}`,
              }),
            );
            break; // One payload per input
          }
        } catch {
          continue;
        }
      }
    }
    await page.close();
  }

  /**
   * Test form submissions with XSS payloads.
   * @private
   */
  async _testForms(context, url, result) {
    const page = await context.newPage();
    const dialogs = [];
    page.on('dialog', async (d) => {
      dialogs.push(d.message());
      await d.accept();
    });

    try {
      await page.goto(url, { waitUntil: 'networkidle', timeout: this._timeout * 2 });
    } catch {
      await page.close();
      return;
    }

    const forms = await page.$$('form');
    for (const form of forms) {
      const formInputs = await form.$$(
        "input[type='text'], input:not([type]), textarea",
      );
      if (formInputs.length === 0) continue;

      const payload = XSS_PAYLOADS[0][1]; // script-alert
      dialogs.length = 0;
      try {
        for (const fi of formInputs) {
          await fi.fill(payload);
        }
        const submitBtn = await form.$("button[type='submit'], input[type='submit']");
        if (submitBtn) {
          await submitBtn.click();
        } else {
          await form.dispatchEvent('submit');
        }
        await page.waitForTimeout(1000);

        if (dialogs.some((d) => d.includes('NERF-XSS'))) {
          const formId =
            (await form.getAttribute('id')) ||
            (await form.getAttribute('action')) ||
            'form';
          result.findings.push(
            new DOMXSSFinding({
              url,
              vector: 'form',
              inputName: formId,
              payload,
              payloadLabel: 'form-submit',
              reproduction: `Submit form '${formId}' with XSS in all text inputs`,
            }),
          );
        }
      } catch {
        continue;
      }
    }
    await page.close();
  }

  /**
   * Navigate to an injected URL and check for XSS execution.
   * @private
   */
  async _checkUrlXss(context, url, vector, paramName, label, payload) {
    const page = await context.newPage();
    const dialogs = [];
    page.on('dialog', async (d) => {
      dialogs.push(d.message());
      await d.accept();
    });

    try {
      await page.goto(url, { waitUntil: 'networkidle', timeout: this._timeout });
      await page.waitForTimeout(500);
    } catch {
      // Navigation errors are expected for some payloads
    } finally {
      await page.close();
    }

    if (dialogs.some((d) => d.includes('NERF-XSS'))) {
      return new DOMXSSFinding({
        url,
        vector,
        inputName: paramName,
        payload,
        payloadLabel: label,
        reproduction: `Navigate to: ${url}`,
      });
    }
    return null;
  }
}

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

export {
  // Payloads (for testing/inspection)
  XSS_PAYLOADS,
  URL_PAYLOADS,
  // Data classes
  DOMXSSFinding,
  DOMXSSScanResult,
  // Scanner
  DOMXSSScanner,
};
