// 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 Engine
 *
 * Joins normalized static and dynamic findings by CWE/location/endpoint
 * proximity, computes Shannon-inspired confidence scores, and produces
 * a final correlated report.
 *
 * Shannon model: each independent detection channel adds information.
 * Static-only → base score (0.4). Dynamic-only → base score (0.5).
 * Both channels → compound score: 1 - ∏(1 - channelScore) per channel,
 * plus bonuses for CWE match and severity. Scores are on a 0-1 scale.
 *
 * @module correlation/engine
 */

import { CorrelatedFinding } from './normalizer.js';
import { SarifReport, SarifRule, SarifResult, severityToLevel, severityScore } from '../pipeline/sarif.js';

// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------

const DEFAULT_THRESHOLDS = Object.freeze({
  confirmed: 0.7,
  probable: 0.4,
  possible: 0.2,
});

const CHANNEL_BASE_SCORES = Object.freeze({
  static: 0.4,
  dynamic: 0.5,
});

const CWE_MATCH_BONUS = 0.1;

const SEVERITY_WEIGHTS = Object.freeze({
  critical: 0.10,
  high: 0.05,
  medium: 0.0,
  low: -0.05,
  info: -0.10,
});

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
 * Extract path segments from a file path for fuzzy endpoint matching.
 * e.g. "src/routes/api/users.js" → ["src", "routes", "api", "users"]
 * @param {string} filePath
 * @returns {string[]}
 */
function extractPathSegments(filePath) {
  if (!filePath) return [];
  return filePath
    .replace(/\\/g, '/')
    .split('/')
    .map(seg => seg.replace(/\.[^.]+$/, '').toLowerCase()) // strip extensions
    .filter(Boolean);
}

/**
 * Extract path segments from a URL for fuzzy endpoint matching.
 * e.g. "https://example.com/api/users/login" → ["api", "users", "login"]
 * @param {string} url
 * @returns {string[]}
 */
function extractUrlSegments(url) {
  if (!url) return [];
  try {
    const parsed = new URL(url);
    return parsed.pathname
      .split('/')
      .map(s => s.toLowerCase())
      .filter(Boolean);
  } catch {
    // Not a full URL, treat as path
    return url
      .split('/')
      .map(s => s.toLowerCase().replace(/[?#].*$/, ''))
      .filter(Boolean);
  }
}

/**
 * Compute Jaccard similarity between two string arrays.
 * Returns 0-1 where 1 means identical sets.
 * @param {string[]} a
 * @param {string[]} b
 * @returns {number}
 */
function jaccardSimilarity(a, b) {
  if (a.length === 0 && b.length === 0) return 0;
  const setA = new Set(a);
  const setB = new Set(b);
  let intersection = 0;
  for (const item of setA) {
    if (setB.has(item)) intersection++;
  }
  const union = new Set([...setA, ...setB]).size;
  return union === 0 ? 0 : intersection / union;
}

/**
 * Check if static and dynamic findings have endpoint proximity.
 * Compares file path segments against URL path segments.
 * @param {CorrelatedFinding} staticFinding
 * @param {CorrelatedFinding} dynamicFinding
 * @param {number} [threshold=0.2]
 * @returns {boolean}
 */
function hasEndpointProximity(staticFinding, dynamicFinding, threshold = 0.2) {
  const filePath = staticFinding.staticLocation?.file || '';
  const url = dynamicFinding.dynamicLocation?.matchedAt
    || dynamicFinding.dynamicLocation?.host
    || '';

  if (!filePath || !url) return false;

  const fileSegs = extractPathSegments(filePath);
  const urlSegs = extractUrlSegments(url);

  return jaccardSimilarity(fileSegs, urlSegs) >= threshold;
}

// ---------------------------------------------------------------------------
// CorrelationEngine
// ---------------------------------------------------------------------------

/**
 * Correlates static and dynamic findings, computes Shannon-inspired
 * confidence scores, and produces a report.
 */
export class CorrelationEngine {
  /**
   * @param {object} [opts]
   * @param {object} [opts.thresholds] - Confidence thresholds { confirmed, probable, possible }
   * @param {object} [opts.channelScores] - Base scores per channel { static, dynamic }
   * @param {number} [opts.cweBonus] - Bonus for CWE match between channels
   * @param {number} [opts.proximityThreshold] - Jaccard threshold for endpoint proximity
   */
  constructor(opts = {}) {
    this.thresholds = { ...DEFAULT_THRESHOLDS, ...opts.thresholds };
    this.channelScores = { ...CHANNEL_BASE_SCORES, ...opts.channelScores };
    this.cweBonus = opts.cweBonus ?? CWE_MATCH_BONUS;
    this.proximityThreshold = opts.proximityThreshold ?? 0.2;
  }

  /**
   * Correlate static and dynamic findings by CWE and endpoint proximity.
   * Matched pairs merge into a single finding with both channels as evidence.
   * Unmatched findings pass through as single-channel results.
   *
   * @param {CorrelatedFinding[]} staticFindings - Normalized static findings
   * @param {CorrelatedFinding[]} dynamicFindings - Normalized dynamic findings
   * @param {object} [opts]
   * @param {boolean} [opts.includeNoise] - Include noise-classified findings (default: false)
   * @returns {CorrelatedFinding[]} Correlated findings sorted by confidence desc
   */
  correlate(staticFindings = [], dynamicFindings = [], opts = {}) {
    const includeNoise = opts.includeNoise ?? false;

    // Index dynamic findings by matchKey for O(n) lookup
    /** @type {Map<string, CorrelatedFinding[]>} */
    const dynamicByKey = new Map();
    for (const df of dynamicFindings) {
      const key = df.matchKey;
      if (!dynamicByKey.has(key)) dynamicByKey.set(key, []);
      dynamicByKey.get(key).push(df);
    }

    const matchedDynamic = new Set();
    const results = [];

    // Phase 1: Match static findings to dynamic findings
    for (const sf of staticFindings) {
      const key = sf.matchKey;
      const candidates = dynamicByKey.get(key) || [];

      // Try exact matchKey match first, then endpoint proximity
      let bestMatch = null;
      let bestScore = -1;

      for (const df of candidates) {
        if (matchedDynamic.has(df.id)) continue;

        // CWE key match is the primary signal
        let score = 1.0;

        // Endpoint proximity is a secondary signal that strengthens the match
        if (hasEndpointProximity(sf, df, this.proximityThreshold)) {
          score += 0.5;
        }

        if (score > bestScore) {
          bestScore = score;
          bestMatch = df;
        }
      }

      if (bestMatch) {
        // Merge: create a correlated finding with both channels
        matchedDynamic.add(bestMatch.id);
        const merged = this._mergeFindings(sf, bestMatch);
        merged.confidence = this.computeConfidence(merged);
        results.push(merged);
      } else {
        // Static-only: check proximity against all unmatched dynamic findings
        let proximityMatch = null;
        for (const [, dfs] of dynamicByKey) {
          for (const df of dfs) {
            if (matchedDynamic.has(df.id)) continue;
            if (hasEndpointProximity(sf, df, this.proximityThreshold)) {
              proximityMatch = df;
              break;
            }
          }
          if (proximityMatch) break;
        }

        if (proximityMatch) {
          matchedDynamic.add(proximityMatch.id);
          const merged = this._mergeFindings(sf, proximityMatch);
          merged.confidence = this.computeConfidence(merged);
          results.push(merged);
        } else {
          // Truly unmatched static finding
          const solo = this._cloneFinding(sf);
          solo.confidence = this.computeConfidence(solo);
          results.push(solo);
        }
      }
    }

    // Phase 2: Add unmatched dynamic findings
    for (const df of dynamicFindings) {
      if (matchedDynamic.has(df.id)) continue;
      const solo = this._cloneFinding(df);
      solo.confidence = this.computeConfidence(solo);
      results.push(solo);
    }

    // Filter noise if requested
    const filtered = includeNoise
      ? results
      : results.filter(f => this.classifyConfidence(f.confidence) !== 'noise');

    // Sort by confidence descending, then severity descending
    filtered.sort((a, b) => {
      if (b.confidence !== a.confidence) return b.confidence - a.confidence;
      return b.rank - a.rank;
    });

    return filtered;
  }

  /**
   * Compute Shannon-inspired confidence score for a finding.
   *
   * The model: each independent detection channel provides information.
   * Compound formula: 1 - ∏(1 - channelScore) for each channel present.
   * Bonuses: CWE match between channels (+0.1), severity adjustment.
   * Clamped to [0, 1].
   *
   * @param {CorrelatedFinding} finding
   * @returns {number} Confidence score 0-1
   */
  computeConfidence(finding) {
    const channels = this._detectChannels(finding);

    if (channels.length === 0) return 0;

    // Single channel: return base score + severity adjustment
    if (channels.length === 1) {
      const base = this.channelScores[channels[0]] ?? 0.3;
      const sevAdj = SEVERITY_WEIGHTS[finding.severity] ?? 0;
      return clamp(base + sevAdj, 0, 1);
    }

    // Multi-channel: compound formula 1 - ∏(1 - channelScore)
    let product = 1;
    for (const ch of channels) {
      const score = this.channelScores[ch] ?? 0.3;
      product *= (1 - score);
    }
    let compounded = 1 - product;

    // CWE match bonus: if the finding has CWE IDs and evidence from both channels
    if (finding.cweIds.length > 0 && channels.includes('static') && channels.includes('dynamic')) {
      compounded += this.cweBonus;
    }

    // Severity adjustment
    const sevAdj = SEVERITY_WEIGHTS[finding.severity] ?? 0;
    compounded += sevAdj;

    return clamp(compounded, 0, 1);
  }

  /**
   * Classify a confidence score into a human-readable category.
   *
   * @param {number} score - Confidence score 0-1
   * @param {object} [thresholds] - Override thresholds
   * @returns {'confirmed'|'probable'|'possible'|'noise'}
   */
  classifyConfidence(score, thresholds) {
    const t = thresholds ?? this.thresholds;
    if (score >= t.confirmed) return 'confirmed';
    if (score >= t.probable) return 'probable';
    if (score >= t.possible) return 'possible';
    return 'noise';
  }

  /**
   * Convert correlated findings to a SARIF v2.1.0 report.
   * Includes both static and dynamic evidence in SARIF results.
   *
   * @param {CorrelatedFinding[]} findings
   * @param {object} [opts]
   * @param {string} [opts.toolName]
   * @param {string} [opts.toolVersion]
   * @returns {SarifReport}
   */
  toSarifReport(findings, opts = {}) {
    const report = new SarifReport({
      toolName: opts.toolName ?? 'NERF Correlation Engine',
      toolVersion: opts.toolVersion ?? '2.0.0',
    });

    for (const finding of findings) {
      const classification = this.classifyConfidence(finding.confidence);
      const ruleId = finding.cweIds[0] ?? finding.id;
      const severity = finding.severity || 'info';
      const level = severityToLevel(severity);

      // Register rule
      report.addRule(new SarifRule({
        id: ruleId,
        name: finding.title,
        shortDescription: finding.description || finding.title,
        level,
        tags: [
          ...finding.tags,
          `confidence:${classification}`,
          `channel:${finding.channel}`,
        ],
      }));

      // Build locations from both static and dynamic
      const locations = [];
      if (finding.staticLocation?.file) {
        locations.push({
          physicalLocation: {
            artifactLocation: { uri: finding.staticLocation.file },
            region: {
              startLine: finding.staticLocation.line || 1,
              startColumn: finding.staticLocation.column || 1,
            },
          },
        });
      }
      if (finding.dynamicLocation?.host || finding.dynamicLocation?.matchedAt) {
        locations.push({
          physicalLocation: {
            artifactLocation: {
              uri: finding.dynamicLocation.matchedAt || finding.dynamicLocation.host,
            },
          },
        });
      }

      // Build result with evidence
      report.addResult(new SarifResult({
        ruleId,
        message: finding.description || finding.title,
        level,
        uri: finding.staticLocation?.file
          || finding.dynamicLocation?.matchedAt
          || finding.dynamicLocation?.host
          || '',
        startLine: finding.staticLocation?.line || 0,
        startColumn: finding.staticLocation?.column || 0,
        snippet: finding.proof || '',
        properties: {
          confidence: finding.confidence,
          classification,
          channel: finding.channel,
          cweIds: finding.cweIds,
          'security-severity': severityScore(severity),
          evidence: finding.evidence.map(e => ({
            type: e.type,
            source: e.source,
          })),
        },
      }));
    }

    return report;
  }

  // -----------------------------------------------------------------------
  // Internal helpers
  // -----------------------------------------------------------------------

  /**
   * Merge a static and dynamic finding into a single correlated finding.
   * @param {CorrelatedFinding} staticFinding
   * @param {CorrelatedFinding} dynamicFinding
   * @returns {CorrelatedFinding}
   */
  _mergeFindings(staticFinding, dynamicFinding) {
    // Use the higher severity
    const severity = staticFinding.rank >= dynamicFinding.rank
      ? staticFinding.severity
      : dynamicFinding.severity;

    // Merge CWE IDs (deduplicated)
    const cweSet = new Set([...staticFinding.cweIds, ...dynamicFinding.cweIds]);

    // Merge tags (deduplicated)
    const tagSet = new Set([...staticFinding.tags, ...dynamicFinding.tags]);

    // Merge evidence from both channels
    const evidence = [
      ...staticFinding.evidence.map(e => ({ ...e })),
      ...dynamicFinding.evidence.map(e => ({ ...e })),
    ];

    // Use the more descriptive title (longer one, or static as default)
    const title = staticFinding.title.length >= dynamicFinding.title.length
      ? staticFinding.title
      : dynamicFinding.title;

    return new CorrelatedFinding({
      title,
      severity,
      cweIds: [...cweSet],
      tags: [...tagSet],
      channel: 'static+dynamic',
      staticLocation: { ...staticFinding.staticLocation },
      dynamicLocation: { ...dynamicFinding.dynamicLocation },
      description: staticFinding.description || dynamicFinding.description,
      proof: [staticFinding.proof, dynamicFinding.proof].filter(Boolean).join('\n---\n'),
      remediation: staticFinding.remediation || dynamicFinding.remediation,
      evidence,
      meta: {
        staticId: staticFinding.id,
        dynamicId: dynamicFinding.id,
        correlationMethod: cweSet.size > 0 ? 'cwe-match' : 'endpoint-proximity',
      },
    });
  }

  /**
   * Clone a finding for independent mutation (confidence assignment).
   * @param {CorrelatedFinding} finding
   * @returns {CorrelatedFinding}
   */
  _cloneFinding(finding) {
    return new CorrelatedFinding({
      id: finding.id,
      title: finding.title,
      severity: finding.severity,
      cweIds: [...finding.cweIds],
      tags: [...finding.tags],
      channel: finding.channel,
      staticLocation: { ...finding.staticLocation },
      dynamicLocation: { ...finding.dynamicLocation },
      description: finding.description,
      proof: finding.proof,
      remediation: finding.remediation,
      evidence: finding.evidence.map(e => ({ ...e })),
      confidence: finding.confidence,
      meta: { ...finding.meta },
    });
  }

  /**
   * Detect which channels are present for a finding.
   * A merged finding has channel 'static+dynamic'.
   * @param {CorrelatedFinding} finding
   * @returns {string[]}
   */
  _detectChannels(finding) {
    const ch = finding.channel;
    if (ch === 'static+dynamic') return ['static', 'dynamic'];
    if (ch === 'static') return ['static'];
    if (ch === 'dynamic') return ['dynamic'];
    return [];
  }
}

/**
 * Clamp a number between min and max.
 * @param {number} val
 * @param {number} min
 * @param {number} max
 * @returns {number}
 */
function clamp(val, min, max) {
  return Math.min(Math.max(val, min), max);
}

// Export helpers for testing
export {
  extractPathSegments,
  extractUrlSegments,
  jaccardSimilarity,
  hasEndpointProximity,
  DEFAULT_THRESHOLDS,
  CHANNEL_BASE_SCORES,
  CWE_MATCH_BONUS,
  SEVERITY_WEIGHTS,
};
