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

/**
 * Async scanning pipeline for non-blocking, Promise-based scans.
 *
 * Provides AsyncNucleiRunner and AsyncKatanaRunner that wrap
 * child_process.spawn in Promise APIs, plus AsyncScanManager with
 * semaphore-based concurrency control.
 *
 * Ported from pipeline/async_scanner.py (331 LOC Python).
 * Python used asyncio — Node.js uses Promises + spawn.
 */

import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';

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

/** @enum {string} Status of an async scan job. */
const AsyncScanStatus = Object.freeze({
  QUEUED: 'queued',
  RUNNING: 'running',
  COMPLETED: 'completed',
  FAILED: 'failed',
  CANCELLED: 'cancelled',
});

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

/**
 * A finding from an async scan.
 */
class AsyncFinding {
  constructor(opts = {}) {
    this.id = opts.id ?? '';
    this.name = opts.name ?? '';
    this.severity = opts.severity ?? 'info';
    this.url = opts.url ?? '';
    this.description = opts.description ?? '';
    this.matchedAt = opts.matchedAt ?? '';
    this.template = opts.template ?? '';
    this.timestamp = opts.timestamp ?? 0;
  }
}

/**
 * Result of an async scan job.
 */
class AsyncScanResult {
  constructor(opts = {}) {
    this.scanId = opts.scanId ?? '';
    this.target = opts.target ?? '';
    this.status = opts.status ?? AsyncScanStatus.QUEUED;
    this.findings = opts.findings ?? [];
    this.startedAt = opts.startedAt ?? 0;
    this.completedAt = opts.completedAt ?? 0;
    this.error = opts.error ?? '';
    this.profile = opts.profile ?? 'standard';
    this.totalRequests = opts.totalRequests ?? 0;
  }
}

// ---------------------------------------------------------------------------
// Semaphore
// ---------------------------------------------------------------------------

/**
 * Promise-based semaphore for concurrency control.
 * acquire() returns a Promise that resolves when a slot is available.
 * release() frees a slot and resolves the next queued waiter.
 */
class Semaphore {
  /**
   * @param {number} max - Maximum concurrent slots
   */
  constructor(max) {
    this._max = max;
    this._count = 0;
    this._queue = [];
  }

  /**
   * Acquire a slot. Resolves immediately if available, otherwise queues.
   * @returns {Promise<void>}
   */
  acquire() {
    if (this._count < this._max) {
      this._count++;
      return Promise.resolve();
    }
    return new Promise((resolve) => {
      this._queue.push(resolve);
    });
  }

  /**
   * Release a slot. If waiters are queued, the next one is resolved.
   */
  release() {
    if (this._queue.length > 0) {
      const next = this._queue.shift();
      next();
    } else {
      this._count--;
    }
  }

  /** Current number of active slots. */
  get active() {
    return this._count;
  }

  /** Number of waiters in the queue. */
  get waiting() {
    return this._queue.length;
  }
}

// ---------------------------------------------------------------------------
// whichSync (local utility)
// ---------------------------------------------------------------------------

import { execFileSync } from 'node:child_process';

function whichSync(binary) {
  try {
    return execFileSync('which', [binary], { encoding: 'utf8' }).trim() || null;
  } catch {
    return null;
  }
}

// ---------------------------------------------------------------------------
// AsyncNucleiRunner
// ---------------------------------------------------------------------------

class AsyncNucleiRunner {
  /**
   * @param {object} opts
   * @param {string} [opts.binary='nuclei']
   * @param {number} [opts.timeout=300]
   */
  constructor(opts = {}) {
    this.binary = opts.binary ?? 'nuclei';
    this.timeout = opts.timeout ?? 300;
    this._available = null;
  }

  /**
   * Check if nuclei binary is in PATH.
   * @returns {boolean}
   */
  isAvailable() {
    if (this._available === null) {
      this._available = whichSync(this.binary) !== null;
    }
    return this._available;
  }

  /**
   * Run nuclei scan asynchronously.
   * @param {string} target
   * @param {object} opts
   * @param {string} [opts.severity='medium,high,critical']
   * @param {string} [opts.templates='']
   * @param {number} [opts.rateLimit=150]
   * @returns {Promise<AsyncFinding[]>}
   */
  scan(target, opts = {}) {
    if (!this.isAvailable()) {
      return Promise.reject(new Error('nuclei binary not found in PATH'));
    }

    const severity = opts.severity ?? 'medium,high,critical';
    const rateLimit = opts.rateLimit ?? 150;

    const cmd = [
      this.binary, '-target', target,
      '-severity', severity,
      '-rate-limit', String(rateLimit),
      '-jsonl', '-silent',
    ];
    if (opts.templates) cmd.push('-t', opts.templates);

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

      try {
        proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
      } catch (err) {
        return reject(err);
      }

      proc.on('error', reject);

      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);
          try {
            const data = JSON.parse(line);
            findings.push(new AsyncFinding({
              id: data['template-id'] ?? '',
              name: data.info?.name ?? '',
              severity: data.info?.severity ?? 'info',
              url: data['matched-at'] ?? target,
              description: data.info?.description ?? '',
              matchedAt: data['matched-at'] ?? '',
              template: data.template ?? '',
              timestamp: Date.now() / 1000,
            }));
          } catch { /* skip malformed lines */ }
        }
      });

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

      proc.on('close', () => {
        clearTimeout(timer);
        resolve(findings);
      });
    });
  }
}

// ---------------------------------------------------------------------------
// AsyncKatanaRunner
// ---------------------------------------------------------------------------

class AsyncKatanaRunner {
  /**
   * @param {object} opts
   * @param {string} [opts.binary='katana']
   * @param {number} [opts.timeout=120]
   */
  constructor(opts = {}) {
    this.binary = opts.binary ?? 'katana';
    this.timeout = opts.timeout ?? 120;
    this._available = null;
  }

  isAvailable() {
    if (this._available === null) {
      this._available = whichSync(this.binary) !== null;
    }
    return this._available;
  }

  /**
   * Run katana crawl asynchronously.
   * @param {string} target
   * @param {object} opts
   * @param {number} [opts.depth=3]
   * @param {number} [opts.concurrency=10]
   * @returns {Promise<string[]>}
   */
  crawl(target, opts = {}) {
    if (!this.isAvailable()) {
      return Promise.reject(new Error('katana binary not found in PATH'));
    }

    const depth = opts.depth ?? 3;
    const concurrency = opts.concurrency ?? 10;

    const cmd = [
      this.binary, '-u', target,
      '-depth', String(depth),
      '-concurrency', String(concurrency),
      '-silent',
    ];

    return new Promise((resolve, reject) => {
      const urls = [];
      let proc;

      try {
        proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
      } catch (err) {
        return reject(err);
      }

      proc.on('error', reject);

      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) urls.push(line);
        }
      });

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

      proc.on('close', () => {
        clearTimeout(timer);
        resolve(urls);
      });
    });
  }
}

// ---------------------------------------------------------------------------
// AsyncScanManager
// ---------------------------------------------------------------------------

/**
 * Map scan profile name → Nuclei severity filter string.
 */
function severityForProfile(profile) {
  const map = {
    quick: 'critical,high',
    standard: 'medium,high,critical',
    deep: 'info,low,medium,high,critical',
    pentest: 'info,low,medium,high,critical',
  };
  return map[profile] ?? 'medium,high,critical';
}

class AsyncScanManager {
  /**
   * @param {object} opts
   * @param {number} [opts.maxConcurrent=3]
   */
  constructor(opts = {}) {
    this.maxConcurrent = opts.maxConcurrent ?? 3;
    this._jobs = new Map();
    this._semaphore = new Semaphore(this.maxConcurrent);
    this._nuclei = new AsyncNucleiRunner();
    this._katana = new AsyncKatanaRunner();
  }

  /**
   * Submit a scan job. Returns immediately with QUEUED status.
   * @param {string} scanId
   * @param {string} target
   * @param {object} opts
   * @param {string} [opts.profile='standard']
   * @returns {AsyncScanResult}
   */
  submitScan(scanId, target, opts = {}) {
    const result = new AsyncScanResult({
      scanId,
      target,
      profile: opts.profile ?? 'standard',
    });
    this._jobs.set(scanId, result);
    return result;
  }

  /**
   * Execute a scan job asynchronously, respecting the semaphore.
   * @param {string} scanId
   * @returns {Promise<AsyncScanResult>}
   */
  async runScan(scanId) {
    const result = this._jobs.get(scanId);
    if (!result) throw new Error(`Scan ${scanId} not found`);

    await this._semaphore.acquire();
    try {
      // Check if cancelled while waiting
      if (result.status === AsyncScanStatus.CANCELLED) {
        return result;
      }
      result.status = AsyncScanStatus.RUNNING;
      result.startedAt = Date.now() / 1000;

      try {
        if (this._nuclei.isAvailable()) {
          const findings = await this._nuclei.scan(result.target, {
            severity: severityForProfile(result.profile),
          });
          result.findings = findings;
        } else {
          result.error = 'nuclei not available';
        }
        result.status = AsyncScanStatus.COMPLETED;
      } catch (err) {
        result.status = AsyncScanStatus.FAILED;
        result.error = err.message;
      }

      result.completedAt = Date.now() / 1000;
    } finally {
      this._semaphore.release();
    }

    return result;
  }

  /**
   * Get current status of a scan job.
   * @param {string} scanId
   * @returns {AsyncScanResult|null}
   */
  getStatus(scanId) {
    return this._jobs.get(scanId) ?? null;
  }

  /**
   * Get summary of all jobs.
   * @returns {object}
   */
  getAllJobs() {
    const out = {};
    for (const [sid, r] of this._jobs) {
      out[sid] = {
        scanId: r.scanId,
        target: r.target,
        status: r.status,
        findingsCount: r.findings.length,
        error: r.error,
      };
    }
    return out;
  }

  /**
   * Cancel a queued scan.
   * @param {string} scanId
   * @returns {boolean}
   */
  cancelScan(scanId) {
    const result = this._jobs.get(scanId);
    if (result && result.status === AsyncScanStatus.QUEUED) {
      result.status = AsyncScanStatus.CANCELLED;
      return true;
    }
    return false;
  }
}

// ---------------------------------------------------------------------------
// Convenience function
// ---------------------------------------------------------------------------

/**
 * Run a one-shot async scan and return results.
 * @param {string} target
 * @param {object} opts
 * @param {string} [opts.profile='standard']
 * @param {string} [opts.scanId='']
 * @returns {Promise<object>}
 */
async function asyncScan(target, opts = {}) {
  const scanId = opts.scanId || `scan-${randomBytes(4).toString('hex')}`;
  const profile = opts.profile ?? 'standard';

  const mgr = new AsyncScanManager();
  mgr.submitScan(scanId, target, { profile });
  const result = await mgr.runScan(scanId);

  return {
    scanId: result.scanId,
    target: result.target,
    status: result.status,
    profile: result.profile,
    findings: result.findings.map(f => ({
      id: f.id,
      name: f.name,
      severity: f.severity,
      url: f.url,
      description: f.description,
    })),
    totalFindings: result.findings.length,
    durationS: result.completedAt
      ? Math.round((result.completedAt - result.startedAt) * 100) / 100
      : 0,
    error: result.error,
  };
}

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

export {
  // Enums
  AsyncScanStatus,
  // Data classes
  AsyncFinding,
  AsyncScanResult,
  // Runners
  AsyncNucleiRunner,
  AsyncKatanaRunner,
  // Manager
  AsyncScanManager,
  Semaphore,
  // Convenience
  asyncScan,
  // Utility
  severityForProfile,
};
