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

/**
 * Evaluator formatter: normalize heterogeneous finding types into a common
 * shape, serialize them into an evaluation prompt, and parse LLM verdicts.
 */

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const DEFAULT_MAX_FINDINGS = 25;
const DEFAULT_MAX_CHARS_PER_FINDING = 2000;

const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];

// ---------------------------------------------------------------------------
// Finding type detection (duck-typing)
// ---------------------------------------------------------------------------

/**
 * Detect finding type by duck-typing its fields.
 * - ReviewFinding: has `.layer`
 * - Finding (scan): has `.templateId`
 * - CorrelatedFinding: has `.channel`
 * @param {object} finding
 * @returns {'static'|'dynamic'|'correlated'|'unknown'}
 */
function detectType(finding) {
  // Order matters: CorrelatedFinding may also have fields from sub-types
  if (typeof finding.channel === 'string' && finding.channel !== '') return 'correlated';
  if (typeof finding.templateId === 'string' && finding.templateId !== '') return 'dynamic';
  if (typeof finding.layer === 'string' && finding.layer !== '') return 'static';
  return 'unknown';
}

// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------

/**
 * Normalize a single finding into the common evaluator shape.
 * @param {object} f - Any finding type
 * @returns {object} Normalized finding
 */
function normalizeSingle(f) {
  const type = detectType(f);
  const base = {
    id: f.id ?? '',
    title: f.title ?? f.name ?? '',
    severity: (f.severity ?? 'info').toLowerCase(),
    type,
    location: '',
    description: f.description ?? '',
    proof: f.proof ?? '',
    remediation: f.remediation ?? '',
    cweIds: f.cweIds ?? [],
    tags: f.tags ?? [],
    meta: {},
  };

  switch (type) {
    case 'static':
      base.location = f.file ? (f.line ? `${f.file}:${f.line}` : f.file) : '';
      base.meta = { layer: f.layer, language: f.language ?? '', column: f.column ?? 0 };
      break;

    case 'dynamic':
      base.location = f.matchedAt || f.host || '';
      base.meta = {
        templateId: f.templateId,
        host: f.host ?? '',
        matcherName: f.matcherName ?? '',
        cveIds: f.cveIds ?? [],
        reference: f.reference ?? [],
      };
      // Dynamic findings use `name` as title
      if (!base.title && f.name) base.title = f.name;
      break;

    case 'correlated':
      // Prefer static location, fall back to dynamic
      if (f.staticLocation?.file) {
        const loc = f.staticLocation;
        base.location = loc.line ? `${loc.file}:${loc.line}` : loc.file;
      } else if (f.dynamicLocation?.host) {
        base.location = f.dynamicLocation.matchedAt || f.dynamicLocation.host;
      }
      base.meta = {
        channel: f.channel,
        confidence: f.confidence ?? 0,
        staticLocation: f.staticLocation ?? null,
        dynamicLocation: f.dynamicLocation ?? null,
        evidence: f.evidence ?? [],
      };
      break;

    default:
      // Unknown type: best-effort extraction
      base.location = f.file ?? f.host ?? f.matchedAt ?? '';
      base.meta = f.meta ?? {};
      break;
  }

  return base;
}

/**
 * Normalize an array of findings into the common evaluator shape.
 * Handles ReviewFinding, Finding (scan), CorrelatedFinding, and unknown types.
 *
 * @param {object[]} findings - Array of any finding type
 * @returns {object[]} Normalized findings
 */
export function normalizeFindings(findings) {
  if (!Array.isArray(findings)) return [];
  return findings.map(normalizeSingle);
}

// ---------------------------------------------------------------------------
// Prompt formatting
// ---------------------------------------------------------------------------

/**
 * Truncate a string to maxLen, appending "... [truncated]" if it exceeds.
 * @param {string} str
 * @param {number} maxLen
 * @returns {string}
 */
function truncate(str, maxLen) {
  if (!str || str.length <= maxLen) return str || '';
  return str.slice(0, maxLen - 16) + '... [truncated]';
}

/**
 * Serialize normalized findings + rubric criteria into a markdown evaluation prompt.
 *
 * @param {object[]} normalizedFindings - Output from normalizeFindings()
 * @param {object} rubric - Rubric from RUBRICS (has .type, .criteria, .requiredFields)
 * @param {object} [options]
 * @param {number} [options.maxFindings=25] - Cap batch size (prioritize high severity)
 * @param {number} [options.maxCharsPerFinding=2000] - Truncate verbose proofs
 * @returns {string} Markdown evaluation prompt
 */
export function formatForEvaluation(normalizedFindings, rubric, options = {}) {
  const maxFindings = options.maxFindings ?? DEFAULT_MAX_FINDINGS;
  const maxChars = options.maxCharsPerFinding ?? DEFAULT_MAX_CHARS_PER_FINDING;

  // Sort by severity (critical first), then take top N
  const sorted = [...normalizedFindings].sort((a, b) => {
    const ai = SEVERITY_ORDER.indexOf(a.severity);
    const bi = SEVERITY_ORDER.indexOf(b.severity);
    return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
  });
  const batch = sorted.slice(0, maxFindings);

  const lines = [
    '# Security Finding Evaluation',
    '',
    `## Rubric: ${rubric.type}`,
    '',
    '### Evaluation Criteria',
    '',
  ];

  for (const criterion of rubric.criteria) {
    lines.push(`- ${criterion}`);
  }

  lines.push('', `### Required Fields: ${rubric.requiredFields.join(', ')}`, '');
  lines.push(`## Findings (${batch.length} of ${normalizedFindings.length})`, '');

  for (const f of batch) {
    lines.push(`### ${f.id || '(no id)'}: ${f.title || '(no title)'}`);
    lines.push('');
    lines.push(`- **Severity:** ${f.severity}`);
    lines.push(`- **Type:** ${f.type}`);
    if (f.location) lines.push(`- **Location:** ${f.location}`);
    if (f.cweIds.length) lines.push(`- **CWE:** ${f.cweIds.join(', ')}`);
    if (f.tags.length) lines.push(`- **Tags:** ${f.tags.join(', ')}`);
    if (f.description) lines.push(`- **Description:** ${truncate(f.description, maxChars)}`);
    if (f.proof) lines.push(`- **Proof:** ${truncate(f.proof, maxChars)}`);
    if (f.remediation) lines.push(`- **Remediation:** ${truncate(f.remediation, maxChars)}`);
    lines.push('');
  }

  lines.push('## Instructions', '');
  lines.push('Evaluate each finding against the rubric criteria above.');
  lines.push('Return a JSON array of verdict objects, one per finding:');
  lines.push('');
  lines.push('```json');
  lines.push('[');
  lines.push('  {');
  lines.push('    "findingId": "<finding id>",');
  lines.push('    "verdict": "pass|flag|reject",');
  lines.push('    "reason": "<concise explanation>",');
  lines.push('    "suggestedSeverity": "<only if severity should change>"');
  lines.push('  }');
  lines.push(']');
  lines.push('```');

  return lines.join('\n');
}

// ---------------------------------------------------------------------------
// Verdict parsing
// ---------------------------------------------------------------------------

/**
 * Extract JSON verdict array from LLM response text.
 * Tries three strategies in order:
 * 1. JSON.parse on the full response
 * 2. Regex extraction of ```json blocks
 * 3. Line-by-line JSON object extraction
 *
 * @param {string} response - Raw LLM response text
 * @returns {{ verdicts: object[], parseErrors: string[] }}
 */
export function parseVerdicts(response) {
  const parseErrors = [];
  if (!response || typeof response !== 'string') {
    return { verdicts: [], parseErrors: ['Empty or non-string response'] };
  }

  // Strategy 1: Direct JSON.parse
  try {
    const parsed = JSON.parse(response.trim());
    if (Array.isArray(parsed)) {
      return { verdicts: validateVerdicts(parsed), parseErrors };
    }
    // If it's a single object, wrap it
    if (parsed && typeof parsed === 'object' && parsed.findingId) {
      return { verdicts: validateVerdicts([parsed]), parseErrors };
    }
  } catch {
    parseErrors.push('Direct JSON.parse failed');
  }

  // Strategy 2: Extract from ```json blocks
  const jsonBlockRe = /```(?:json)?\s*\n?([\s\S]*?)```/g;
  let match;
  while ((match = jsonBlockRe.exec(response)) !== null) {
    try {
      const parsed = JSON.parse(match[1].trim());
      const arr = Array.isArray(parsed) ? parsed : [parsed];
      const valid = validateVerdicts(arr);
      if (valid.length > 0) {
        return { verdicts: valid, parseErrors };
      }
    } catch {
      parseErrors.push('JSON block parse failed');
    }
  }

  // Strategy 3: Line-by-line JSON object extraction
  const objects = [];
  const objectRe = /\{[^{}]*"findingId"[^{}]*\}/g;
  let objMatch;
  while ((objMatch = objectRe.exec(response)) !== null) {
    try {
      const obj = JSON.parse(objMatch[0]);
      if (obj.findingId && obj.verdict) {
        objects.push(obj);
      }
    } catch {
      parseErrors.push('Line-level object parse failed');
    }
  }

  if (objects.length > 0) {
    return { verdicts: validateVerdicts(objects), parseErrors };
  }

  parseErrors.push('No valid verdict objects found in response');
  return { verdicts: [], parseErrors };
}

/**
 * Validate verdict objects have required fields.
 * @param {object[]} arr
 * @returns {object[]} Valid verdicts only
 */
function validateVerdicts(arr) {
  const VALID_VERDICTS = new Set(['pass', 'flag', 'reject']);
  return arr.filter(
    (v) =>
      v &&
      typeof v === 'object' &&
      typeof v.findingId === 'string' &&
      VALID_VERDICTS.has(v.verdict) &&
      typeof v.reason === 'string'
  );
}
