import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
  ReportGenerator,
  defaultReportGenerator,
} from '../../src/security-scanner/report-generator.js';
import type { Finding, ScanResult } from '../../src/security-scanner/scanner.js';

let tmp: string;

beforeEach(async () => {
  tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'report-gen-'));
});

afterEach(async () => {
  await fs.rm(tmp, { recursive: true, force: true });
});

function mkFinding(over: Partial<Finding> = {}): Finding {
  return {
    title: 'Hard-coded API key',
    severity: 'critical',
    category: 'secrets',
    file: 'src/app.ts',
    line: 42,
    snippet: 'const KEY = "sk-...";',
    remediation: 'Move to environment variable.',
    ...over,
  } as Finding;
}

function mkScanResult(over: Partial<ScanResult> = {}): ScanResult {
  return {
    timestamp: '2026-05-22T10:00:00.000Z',
    projectRoot: '/proj',
    techStack: { stack: 'typescript', packageManager: 'pnpm' } as never,
    scannedFiles: 12,
    scanDurationMs: 1500,
    summary: { critical: 1, high: 1, medium: 0, low: 0, total: 2 },
    findings: [
      mkFinding(),
      mkFinding({
        title: 'XSS risk',
        severity: 'high',
        category: 'injection',
        file: 'src/render.ts',
        line: undefined,
        snippet: undefined,
        remediation: 'Escape user input.',
      }),
    ],
    errors: [],
    ...over,
  } as ScanResult;
}

describe('ReportGenerator', () => {
  it('exposes a default singleton', () => {
    expect(defaultReportGenerator).toBeInstanceOf(ReportGenerator);
  });

  // ── markdown ──────────────────────────────────────────────────────────────

  it('renders a markdown report grouped by severity by default', async () => {
    const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports') });
    const file = await gen.generate(mkScanResult());
    const md = await fs.readFile(file, 'utf8');
    expect(md).toContain('# Security Scan Report');
    expect(md).toContain('CRITICAL (1)');
    expect(md).toContain('HIGH (1)');
    expect(md).toContain('Hard-coded API key');
    expect(md).toContain('XSS risk');
    // No medium/low sections when empty
    expect(md).not.toContain('MEDIUM (');
    // Footer
    expect(md).toContain('Report generated by security scanner');
  });

  it('groups by category when groupBySeverity=false', async () => {
    const gen = new ReportGenerator({
      outputDir: path.join(tmp, 'reports'),
      groupBySeverity: false,
    });
    const file = await gen.generate(mkScanResult());
    const md = await fs.readFile(file, 'utf8');
    expect(md).toContain('SECRETS (1)');
    expect(md).toContain('INJECTION (1)');
  });

  it('omits remediation when includeRemediation=false', async () => {
    const gen = new ReportGenerator({
      outputDir: path.join(tmp, 'reports'),
      includeRemediation: false,
    });
    const file = await gen.generate(mkScanResult());
    const md = await fs.readFile(file, 'utf8');
    expect(md).not.toContain('Remediation:');
  });

  it('renders the "Scan Errors" section when result.errors is non-empty', async () => {
    const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports') });
    const file = await gen.generate(
      mkScanResult({ errors: ['perm denied', 'tool crashed'] } as never),
    );
    const md = await fs.readFile(file, 'utf8');
    expect(md).toContain('## Scan Errors');
    expect(md).toContain('perm denied');
    expect(md).toContain('tool crashed');
  });

  it('produces a report with empty findings + no errors gracefully', async () => {
    const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports') });
    const file = await gen.generate(
      mkScanResult({
        findings: [],
        errors: [],
        summary: { critical: 0, high: 0, medium: 0, low: 0, total: 0 } as never,
      }),
    );
    const md = await fs.readFile(file, 'utf8');
    expect(md).toContain('| **Total** | **0** |');
    expect(md).not.toContain('CRITICAL (');
  });

  it('skips snippet rendering when finding.snippet is missing', async () => {
    const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports') });
    const file = await gen.generate(
      mkScanResult({
        findings: [mkFinding({ snippet: undefined, line: undefined, title: 'No snippet' })],
        summary: { critical: 1, high: 0, medium: 0, low: 0, total: 1 } as never,
      }),
    );
    const md = await fs.readFile(file, 'utf8');
    expect(md).toContain('No snippet');
    // No fenced code block
    expect(md.indexOf('```')).toBe(-1);
  });

  // ── json ──────────────────────────────────────────────────────────────────

  it('renders a JSON report when format=json', async () => {
    const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports'), format: 'json' });
    const file = await gen.generate(mkScanResult());
    const raw = await fs.readFile(file, 'utf8');
    const parsed = JSON.parse(raw);
    expect(parsed.meta.projectRoot).toBe('/proj');
    expect(parsed.summary.total).toBe(2);
    expect(parsed.findings).toHaveLength(2);
  });

  // ── html ──────────────────────────────────────────────────────────────────

  it('renders an HTML report when format=html', async () => {
    const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports'), format: 'html' });
    const file = await gen.generate(mkScanResult());
    const html = await fs.readFile(file, 'utf8');
    expect(html).toContain('<!DOCTYPE html>');
    expect(html).toContain('Security Scan Report');
    expect(html).toContain('Hard-coded API key');
    expect(html).toContain('color: #dc2626'); // critical color
    expect(html).toContain('color: #ea580c'); // high color
  });

  // CRIT-002 (bug-hunter) / XSS guard: every finding field flows from
  // untrusted sources (LLM output, file paths, remediation text). Without
  // escaping, an LLM that returns `<script>alert(1)</script>` in `title`
  // would inject executable HTML when the report is served by the WebUI.
  describe('HTML XSS escaping (CRIT-002 fix)', () => {
    it('escapes <, >, &, ", and \' in finding fields', async () => {
      const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports'), format: 'html' });
      const file = await gen.generate(
        mkScanResult({
          findings: [
            mkFinding({
              title: '"><script>alert(1)</script>',
              category: 'injection"onload="x',
              file: "src/<bad>&'.ts",
              line: 1,
              remediation: 'Use <safe> & "quoted" text',
            }),
          ],
          summary: { critical: 1, high: 0, medium: 0, low: 0, total: 1 } as never,
        }),
      );
      const html = await fs.readFile(file, 'utf8');

      // The raw payload must NOT appear unescaped anywhere in the HTML body
      expect(html).not.toContain('<script>alert(1)</script>');
      expect(html).not.toContain('"onload="x');
      expect(html).not.toContain("src/<bad>&'.ts");

      // It must appear in escaped form
      expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
      expect(html).toContain('&quot;&gt;'); // ">  → &quot;&gt;
      expect(html).toContain('&amp;');
      expect(html).toContain('&#39;');
    });

    it('escapes projectRoot, techStack, and timestamp in header', async () => {
      const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports'), format: 'html' });
      const file = await gen.generate(
        mkScanResult({
          projectRoot: '/proj/<x>',
          techStack: { stack: 'type<script>', packageManager: 'pn&pm' } as never,
        }),
      );
      const html = await fs.readFile(file, 'utf8');
      expect(html).not.toContain('/proj/<x>');
      expect(html).not.toContain('type<script>');
      expect(html).not.toContain('pn&pm');
      expect(html).toContain('&lt;x&gt;');
      expect(html).toContain('pn&amp;pm');
    });

    it('falls back to black when severity is unknown (no undefined CSS)', async () => {
      // The map lookup `severityColors[f.severity]` previously produced
      // `undefined` for unknown severities, which would emit `color: undefined;`
      // into the style attribute. Now it falls back to #000000.
      const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports'), format: 'html' });
      const file = await gen.generate(
        mkScanResult({
          findings: [mkFinding({ severity: 'gibberish' as never })],
          summary: { critical: 0, high: 0, medium: 0, low: 0, total: 1 } as never,
        }),
      );
      const html = await fs.readFile(file, 'utf8');
      expect(html).not.toMatch(/color:\s*undefined/i);
      expect(html).toContain('color: #000000');
    });
  });

  // ── output directory creation ────────────────────────────────────────────

  it('creates the output directory when it does not exist', async () => {
    const outDir = path.join(tmp, 'nested', 'a', 'b');
    const gen = new ReportGenerator({ outputDir: outDir });
    const file = await gen.generate(mkScanResult());
    expect(file.startsWith(outDir)).toBe(true);
    const exists = await fs
      .stat(outDir)
      .then(() => true)
      .catch(() => false);
    expect(exists).toBe(true);
  });

  it('reuses an existing output directory', async () => {
    const outDir = path.join(tmp, 'exists');
    await fs.mkdir(outDir, { recursive: true });
    const gen = new ReportGenerator({ outputDir: outDir });
    const file = await gen.generate(mkScanResult());
    expect(file.startsWith(outDir)).toBe(true);
  });

  it('embeds an ISO-style timestamp in the filename', async () => {
    const gen = new ReportGenerator({ outputDir: path.join(tmp, 'reports') });
    const file = await gen.generate(mkScanResult());
    // Replace : and . with - → 2026-05-22T10-00-00 style
    expect(path.basename(file)).toMatch(
      /^security-report-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.markdown$/,
    );
  });
});
