// 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: Finding Normalizer
 *
 * Unifies ReviewFinding (static, file:line) and Finding (dynamic, host:url)
 * into a common CorrelatedFinding model. Preserves CWE, severity, location,
 * evidence, and source channel metadata. Findings without CWE IDs fall back
 * to title/category matching; partial locations are normalized with whatever
 * is available.
 *
 * @module correlation/normalizer
 */

import { randomUUID } from 'node:crypto';

// ---------------------------------------------------------------------------
// Severity ranking (shared with review engine)
// ---------------------------------------------------------------------------

const SEVERITY_RANK = Object.freeze({
  critical: 4,
  high: 3,
  medium: 2,
  low: 1,
  info: 0,
});

// ---------------------------------------------------------------------------
// CorrelatedFinding
// ---------------------------------------------------------------------------

/**
 * Unified finding model that bridges static (code review) and dynamic (scan)
 * findings into a single structure suitable for correlation and reporting.
 */
export class CorrelatedFinding {
  /**
   * @param {object} opts
   * @param {string}   [opts.id]             - Unique finding ID (auto-generated)
   * @param {string}   opts.title            - Short title
   * @param {string}   opts.severity         - critical|high|medium|low|info
   * @param {string[]} [opts.cweIds]         - CWE identifiers
   * @param {string[]} [opts.tags]           - MITRE ATT&CK, OWASP, etc.
   * @param {string}   opts.channel          - 'static' | 'dynamic'
   * @param {object}   [opts.staticLocation] - { file, line, column }
   * @param {object}   [opts.dynamicLocation]- { host, matchedAt }
   * @param {string}   [opts.description]    - Detailed explanation
   * @param {string}   [opts.proof]          - Code snippet or evidence
   * @param {string}   [opts.remediation]    - How to fix
   * @param {Array}    [opts.evidence]       - Array of evidence objects
   * @param {number}   [opts.confidence]     - Shannon-inspired confidence score (0-1), set by correlator
   * @param {object}   [opts.meta]           - Source-specific metadata including original finding ref
   */
  constructor(opts = {}) {
    this.id = opts.id ?? `CF-${randomUUID().slice(0, 8)}`;
    this.title = opts.title ?? '';
    this.severity = opts.severity ?? 'info';
    this.cweIds = opts.cweIds ?? [];
    this.tags = opts.tags ?? [];
    this.channel = opts.channel ?? '';
    this.staticLocation = opts.staticLocation ?? { file: '', line: 0, column: 0 };
    this.dynamicLocation = opts.dynamicLocation ?? { host: '', matchedAt: '' };
    this.description = opts.description ?? '';
    this.proof = opts.proof ?? '';
    this.remediation = opts.remediation ?? '';
    this.evidence = opts.evidence ?? [];
    this.confidence = opts.confidence ?? 0;
    this.meta = opts.meta ?? {};
  }

  /** Numeric severity rank for sorting (higher = more severe). */
  get rank() {
    return SEVERITY_RANK[this.severity] ?? 0;
  }

  /**
   * Matching key for correlation. Uses CWE IDs when available,
   * falls back to a normalized title slug.
   * @returns {string}
   */
  get matchKey() {
    if (this.cweIds.length > 0) {
      return `cwe:${[...this.cweIds].sort().join(',')}`;
    }
    return `title:${this.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
  }

  /**
   * Human-readable report format.
   * @returns {string}
   */
  toReport() {
    const lines = [
      `[${this.id}]`,
      `Severity   : ${this.severity.toUpperCase()}`,
      `Channel    : ${this.channel}`,
    ];
    if (this.cweIds.length) lines.push(`CWE        : ${this.cweIds.join(', ')}`);
    if (this.tags.length) lines.push(`Tags       : ${this.tags.join(', ')}`);

    // Location: show whichever is populated
    if (this.staticLocation.file) {
      const loc = this.staticLocation.line
        ? `${this.staticLocation.file}:${this.staticLocation.line}`
        : this.staticLocation.file;
      lines.push(`Static Loc : ${loc}`);
    }
    if (this.dynamicLocation.host) {
      const loc = this.dynamicLocation.matchedAt || this.dynamicLocation.host;
      lines.push(`Dynamic Loc: ${loc}`);
    }

    if (this.description) lines.push(`Description: ${this.description}`);
    if (this.proof) lines.push(`Proof      : ${this.proof}`);
    if (this.remediation) lines.push(`Remediation: ${this.remediation}`);
    if (this.confidence > 0) lines.push(`Confidence : ${(this.confidence * 100).toFixed(1)}%`);
    return lines.join('\n');
  }

  /**
   * SARIF result stub. Full implementation in a later task when
   * the SARIF integration is wired up.
   * @returns {object}
   */
  toSarifResult() {
    const result = {
      ruleId: this.cweIds[0] ?? this.id,
      level: this.severity === 'critical' || this.severity === 'high' ? 'error'
        : this.severity === 'medium' ? 'warning'
        : 'note',
      message: { text: this.description || this.title },
      locations: [],
      properties: {
        channel: this.channel,
        confidence: this.confidence,
        tags: this.tags,
        cweIds: this.cweIds,
      },
    };

    if (this.staticLocation.file) {
      result.locations.push({
        physicalLocation: {
          artifactLocation: { uri: this.staticLocation.file },
          region: {
            startLine: this.staticLocation.line || 1,
            startColumn: this.staticLocation.column || 1,
          },
        },
      });
    }

    if (this.dynamicLocation.host) {
      result.locations.push({
        physicalLocation: {
          artifactLocation: { uri: this.dynamicLocation.matchedAt || this.dynamicLocation.host },
        },
      });
    }

    return result;
  }
}

// ---------------------------------------------------------------------------
// FindingNormalizer
// ---------------------------------------------------------------------------

/**
 * Normalizes ReviewFinding (static) and Finding (dynamic) into
 * CorrelatedFinding instances, preserving all available metadata.
 */
export class FindingNormalizer {
  /**
   * Normalize a static ReviewFinding into a CorrelatedFinding.
   * @param {import('../review/engine.js').ReviewFinding} rf
   * @returns {CorrelatedFinding}
   */
  normalizeReviewFinding(rf) {
    const evidence = [];
    if (rf.proof) {
      evidence.push({ type: 'code-snippet', content: rf.proof, source: 'static-review' });
    }

    return new CorrelatedFinding({
      title: rf.title,
      severity: rf.severity || 'info',
      cweIds: Array.isArray(rf.cweIds) ? [...rf.cweIds] : [],
      tags: Array.isArray(rf.tags) ? [...rf.tags] : [],
      channel: 'static',
      staticLocation: {
        file: rf.file || '',
        line: rf.line || 0,
        column: rf.column || 0,
      },
      description: rf.description || '',
      proof: rf.proof || '',
      remediation: rf.remediation || '',
      evidence,
      meta: {
        original: rf,
        sourceId: rf.id || '',
        layer: rf.layer || '',
        language: rf.language || '',
      },
    });
  }

  /**
   * Normalize a dynamic scan Finding into a CorrelatedFinding.
   * @param {import('../pipeline/scanner.js').Finding} sf
   * @returns {CorrelatedFinding}
   */
  normalizeScanFinding(sf) {
    const evidence = [];
    if (sf.raw && Object.keys(sf.raw).length > 0) {
      evidence.push({ type: 'scan-output', content: sf.raw, source: 'dynamic-scan' });
    }

    // Merge CVE IDs into tags for cross-referencing
    const tags = Array.isArray(sf.tags) ? [...sf.tags] : [];
    if (Array.isArray(sf.cveIds)) {
      for (const cve of sf.cveIds) {
        if (cve && !tags.includes(cve)) tags.push(cve);
      }
    }

    return new CorrelatedFinding({
      title: sf.name || sf.templateId || '',
      severity: sf.severity || 'info',
      cweIds: Array.isArray(sf.cweIds) ? [...sf.cweIds] : [],
      tags,
      channel: 'dynamic',
      dynamicLocation: {
        host: sf.host || '',
        matchedAt: sf.matchedAt || '',
      },
      description: sf.description || '',
      proof: sf.matcherName ? `Matcher: ${sf.matcherName}` : '',
      remediation: Array.isArray(sf.reference) ? sf.reference.join(', ') : '',
      evidence,
      meta: {
        original: sf,
        templateId: sf.templateId || '',
        matcherName: sf.matcherName || '',
      },
    });
  }

  /**
   * Batch-normalize both static and dynamic finding arrays.
   * @param {Array} reviewFindings - Array of ReviewFinding instances
   * @param {Array} scanFindings   - Array of Finding instances
   * @returns {CorrelatedFinding[]}
   */
  normalizeAll(reviewFindings = [], scanFindings = []) {
    const results = [];
    for (const rf of reviewFindings) {
      results.push(this.normalizeReviewFinding(rf));
    }
    for (const sf of scanFindings) {
      results.push(this.normalizeScanFinding(sf));
    }
    return results;
  }
}
