// output.test.mjs — Tests for scanners/lib/output.mjs
// Zero external dependencies: node:test + node:assert only.

import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
  resetCounter,
  finding,
  scannerResult,
  envelope,
} from '../../scanners/lib/output.mjs';

// ---------------------------------------------------------------------------
// finding + resetCounter
// ---------------------------------------------------------------------------

describe('finding', () => {
  beforeEach(() => {
    resetCounter();
  });

  it('returns an object with auto-incrementing ID in DS-SCANNER-NNN format', () => {
    const f = finding({ scanner: 'UNI', severity: 'high', title: 'Test', description: 'Desc' });
    assert.equal(f.id, 'DS-UNI-001');
  });

  it('increments ID with each call', () => {
    const f1 = finding({ scanner: 'UNI', severity: 'high', title: 'A', description: 'Desc' });
    const f2 = finding({ scanner: 'ENT', severity: 'medium', title: 'B', description: 'Desc' });
    const f3 = finding({ scanner: 'PRM', severity: 'low', title: 'C', description: 'Desc' });
    assert.equal(f1.id, 'DS-UNI-001');
    assert.equal(f2.id, 'DS-ENT-002');
    assert.equal(f3.id, 'DS-PRM-003');
  });

  it('zero-pads counter to 3 digits', () => {
    for (let i = 0; i < 9; i++) {
      finding({ scanner: 'UNI', severity: 'info', title: `F${i}`, description: 'x' });
    }
    const f10 = finding({ scanner: 'UNI', severity: 'info', title: 'F10', description: 'x' });
    assert.equal(f10.id, 'DS-UNI-010');
  });

  it('includes all required fields', () => {
    const f = finding({
      scanner: 'ENT',
      severity: 'critical',
      title: 'High Entropy Secret',
      description: 'Found a high-entropy string that looks like an API key.',
    });
    assert.equal(f.scanner, 'ENT');
    assert.equal(f.severity, 'critical');
    assert.equal(f.title, 'High Entropy Secret');
    assert.equal(f.description, 'Found a high-entropy string that looks like an API key.');
  });

  it('sets optional fields to null when not provided', () => {
    const f = finding({ scanner: 'UNI', severity: 'low', title: 'T', description: 'D' });
    assert.equal(f.file, null);
    assert.equal(f.line, null);
    assert.equal(f.evidence, null);
    assert.equal(f.owasp, null);
    assert.equal(f.recommendation, null);
  });

  it('includes all provided optional fields', () => {
    const f = finding({
      scanner: 'TNT',
      severity: 'high',
      title: 'Taint flow',
      description: 'Untrusted data flows into eval().',
      file: 'src/runner.mjs',
      line: 42,
      evidence: 'eval(userInput)',
      owasp: 'LLM01',
      recommendation: 'Sanitize user input before evaluation.',
    });
    assert.equal(f.file, 'src/runner.mjs');
    assert.equal(f.line, 42);
    assert.equal(f.evidence, 'eval(userInput)');
    assert.equal(f.owasp, 'LLM01');
    assert.equal(f.recommendation, 'Sanitize user input before evaluation.');
  });
});

describe('resetCounter', () => {
  it('resets the counter so the next finding starts at 001', () => {
    // Advance counter to some arbitrary position
    finding({ scanner: 'UNI', severity: 'info', title: 'A', description: 'x' });
    finding({ scanner: 'UNI', severity: 'info', title: 'B', description: 'x' });
    finding({ scanner: 'UNI', severity: 'info', title: 'C', description: 'x' });

    resetCounter();

    const f = finding({ scanner: 'ENT', severity: 'low', title: 'After reset', description: 'x' });
    assert.equal(f.id, 'DS-ENT-001');
  });

  it('can be called multiple times without error', () => {
    assert.doesNotThrow(() => {
      resetCounter();
      resetCounter();
      resetCounter();
    });
  });
});

// ---------------------------------------------------------------------------
// scannerResult
// ---------------------------------------------------------------------------

describe('scannerResult', () => {
  beforeEach(() => {
    resetCounter();
  });

  it('returns an object with the expected top-level keys', () => {
    const result = scannerResult('unicode-scanner', 'ok', [], 10, 42);
    assert.ok('scanner' in result);
    assert.ok('status' in result);
    assert.ok('findings' in result);
    assert.ok('counts' in result);
    assert.ok('files_scanned' in result);
    assert.ok('duration_ms' in result);
  });

  it('sets scanner name and status correctly', () => {
    const result = scannerResult('entropy-scanner', 'ok', [], 5, 100);
    assert.equal(result.scanner, 'entropy-scanner');
    assert.equal(result.status, 'ok');
  });

  it('returns empty counts for no findings', () => {
    const result = scannerResult('dep-auditor', 'ok', [], 0, 0);
    assert.deepEqual(result.counts, { critical: 0, high: 0, medium: 0, low: 0, info: 0 });
  });

  it('counts findings by severity correctly', () => {
    const f1 = finding({ scanner: 'ENT', severity: 'critical', title: 'A', description: 'x' });
    const f2 = finding({ scanner: 'ENT', severity: 'high', title: 'B', description: 'x' });
    const f3 = finding({ scanner: 'ENT', severity: 'high', title: 'C', description: 'x' });
    const f4 = finding({ scanner: 'ENT', severity: 'medium', title: 'D', description: 'x' });

    const result = scannerResult('entropy-scanner', 'ok', [f1, f2, f3, f4], 20, 300);
    assert.equal(result.counts.critical, 1);
    assert.equal(result.counts.high, 2);
    assert.equal(result.counts.medium, 1);
    assert.equal(result.counts.low, 0);
    assert.equal(result.counts.info, 0);
  });

  it('stores findings array as provided', () => {
    const f = finding({ scanner: 'UNI', severity: 'low', title: 'X', description: 'y' });
    const result = scannerResult('unicode-scanner', 'ok', [f], 1, 10);
    assert.equal(result.findings.length, 1);
    assert.equal(result.findings[0].id, f.id);
  });

  it('sets files_scanned and duration_ms', () => {
    const result = scannerResult('git-forensics', 'ok', [], 77, 1234);
    assert.equal(result.files_scanned, 77);
    assert.equal(result.duration_ms, 1234);
  });

  it('does not include error field when errorMsg is not provided', () => {
    const result = scannerResult('taint-tracer', 'ok', [], 5, 50);
    assert.ok(!('error' in result));
  });

  it('includes error field when errorMsg is provided', () => {
    const result = scannerResult('dep-auditor', 'error', [], 0, 10, 'ENOENT: package.json not found');
    assert.equal(result.error, 'ENOENT: package.json not found');
    assert.equal(result.status, 'error');
  });

  it('handles skipped status', () => {
    const result = scannerResult('network-mapper', 'skipped', [], 0, 0);
    assert.equal(result.status, 'skipped');
  });
});

// ---------------------------------------------------------------------------
// envelope
// ---------------------------------------------------------------------------

describe('envelope', () => {
  beforeEach(() => {
    resetCounter();
  });

  it('returns an object with meta, scanners, and aggregate keys', () => {
    const result = envelope('/some/path', {}, 100);
    assert.ok('meta' in result);
    assert.ok('scanners' in result);
    assert.ok('aggregate' in result);
  });

  it('meta contains target, timestamp, node_version, total_duration_ms', () => {
    const result = envelope('/my/project', {}, 999);
    assert.equal(result.meta.target, '/my/project');
    assert.ok(typeof result.meta.timestamp === 'string');
    assert.ok(result.meta.timestamp.length > 0);
    assert.ok(typeof result.meta.node_version === 'string');
    assert.equal(result.meta.total_duration_ms, 999);
  });

  it('aggregate contains risk_score and verdict', () => {
    const result = envelope('/project', {}, 0);
    assert.ok('risk_score' in result.aggregate);
    assert.ok('verdict' in result.aggregate);
  });

  it('aggregate has zero counts and ALLOW verdict for empty scanner results', () => {
    const result = envelope('/project', {}, 0);
    assert.equal(result.aggregate.total_findings, 0);
    assert.equal(result.aggregate.risk_score, 0);
    assert.equal(result.aggregate.verdict, 'ALLOW');
    assert.deepEqual(result.aggregate.counts, { critical: 0, high: 0, medium: 0, low: 0, info: 0 });
  });

  it('aggregates counts from multiple scanner results', () => {
    const f1 = finding({ scanner: 'UNI', severity: 'critical', title: 'A', description: 'x' });
    const f2 = finding({ scanner: 'ENT', severity: 'high', title: 'B', description: 'x' });

    const scanners = {
      unicode: scannerResult('unicode-scanner', 'ok', [f1], 10, 50),
      entropy: scannerResult('entropy-scanner', 'ok', [f2], 10, 75),
    };

    const result = envelope('/project', scanners, 125);
    assert.equal(result.aggregate.total_findings, 2);
    assert.equal(result.aggregate.counts.critical, 1);
    assert.equal(result.aggregate.counts.high, 1);
  });

  it('computes correct risk_score from aggregated counts', () => {
    // v2 formula (v7.0.0+): 1 critical = score 80 (70 + log2(2)*10 = 80)
    const f = finding({ scanner: 'ENT', severity: 'critical', title: 'C', description: 'x' });
    const scanners = {
      entropy: scannerResult('entropy-scanner', 'ok', [f], 5, 30),
    };
    const result = envelope('/project', scanners, 30);
    assert.equal(result.aggregate.risk_score, 80);
  });

  it('returns BLOCK verdict when critical finding present', () => {
    const f = finding({ scanner: 'UNI', severity: 'critical', title: 'Critical', description: 'x' });
    const scanners = {
      uni: scannerResult('unicode-scanner', 'ok', [f], 1, 10),
    };
    const result = envelope('/project', scanners, 10);
    assert.equal(result.aggregate.verdict, 'BLOCK');
  });

  it('tracks scanner ok/error/skipped counts', () => {
    const scanners = {
      uni: scannerResult('unicode-scanner', 'ok', [], 5, 10),
      ent: scannerResult('entropy-scanner', 'error', [], 0, 5, 'failed'),
      net: scannerResult('network-mapper', 'skipped', [], 0, 0),
    };
    const result = envelope('/project', scanners, 15);
    assert.equal(result.aggregate.scanners_ok, 1);
    assert.equal(result.aggregate.scanners_error, 1);
    assert.equal(result.aggregate.scanners_skipped, 1);
  });

  it('includes owasp_breakdown in aggregate', () => {
    const result = envelope('/project', {}, 0);
    assert.ok('owasp_breakdown' in result.aggregate);
  });

  it('passes through scanner results as-is in scanners field', () => {
    const sr = scannerResult('unicode-scanner', 'ok', [], 3, 20);
    const scanners = { uni: sr };
    const result = envelope('/project', scanners, 20);
    assert.deepEqual(result.scanners, scanners);
  });
});
