/**
 * Tests for the vulnerability scan / policy storage layer.
 *
 * Mirrors the SQL and logic in DatabaseService for the three vulnerability
 * tables (vulnerability_scans, vulnerability_details, scan_policies) against
 * an in-memory SQLite database so behavior can be asserted without booting
 * the real DatabaseService singleton.
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';

type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';

const SCHEMA = `
  CREATE TABLE IF NOT EXISTS vulnerability_scans (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    node_id INTEGER NOT NULL,
    image_ref TEXT NOT NULL,
    image_digest TEXT,
    scanned_at INTEGER NOT NULL,
    total_vulnerabilities INTEGER NOT NULL DEFAULT 0,
    critical_count INTEGER NOT NULL DEFAULT 0,
    high_count INTEGER NOT NULL DEFAULT 0,
    medium_count INTEGER NOT NULL DEFAULT 0,
    low_count INTEGER NOT NULL DEFAULT 0,
    unknown_count INTEGER NOT NULL DEFAULT 0,
    fixable_count INTEGER NOT NULL DEFAULT 0,
    highest_severity TEXT,
    os_info TEXT,
    trivy_version TEXT,
    scan_duration_ms INTEGER,
    triggered_by TEXT NOT NULL DEFAULT 'manual',
    status TEXT NOT NULL DEFAULT 'completed',
    error TEXT,
    stack_context TEXT
  );

  CREATE TABLE IF NOT EXISTS vulnerability_details (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    scan_id INTEGER NOT NULL,
    vulnerability_id TEXT NOT NULL,
    pkg_name TEXT NOT NULL,
    installed_version TEXT NOT NULL,
    fixed_version TEXT,
    severity TEXT NOT NULL,
    title TEXT,
    description TEXT,
    primary_url TEXT,
    FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
  );

  CREATE TABLE IF NOT EXISTS scan_policies (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    node_id INTEGER,
    stack_pattern TEXT,
    max_severity TEXT NOT NULL DEFAULT 'CRITICAL',
    block_on_deploy INTEGER NOT NULL DEFAULT 0,
    enabled INTEGER NOT NULL DEFAULT 1,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
  );
`;

interface ScanRow {
  id: number;
  node_id: number;
  image_ref: string;
  image_digest: string | null;
  scanned_at: number;
  total_vulnerabilities: number;
  critical_count: number;
  high_count: number;
  medium_count: number;
  low_count: number;
  unknown_count: number;
  fixable_count: number;
  highest_severity: Severity | null;
  status: string;
  triggered_by: string;
}

interface DetailRow {
  id: number;
  scan_id: number;
  vulnerability_id: string;
  pkg_name: string;
  installed_version: string;
  fixed_version: string | null;
  severity: Severity;
}

interface PolicyRow {
  id: number;
  name: string;
  node_id: number | null;
  stack_pattern: string | null;
  max_severity: Severity;
  block_on_deploy: number;
  enabled: number;
  created_at: number;
  updated_at: number;
}

describe('Vulnerability scan storage (in-memory SQLite)', () => {
  let db: Database.Database;

  beforeEach(() => {
    db = new Database(':memory:');
    db.pragma('journal_mode = WAL');
    db.pragma('foreign_keys = ON');
    for (const stmt of SCHEMA.split(';').map((s) => s.trim()).filter(Boolean)) {
      db.prepare(stmt + ';').run();
    }
  });

  afterEach(() => {
    db.close();
  });

  // Helpers that mirror the DatabaseService implementation.

  function insertScan(overrides: Partial<ScanRow> = {}): number {
    const scan: Omit<ScanRow, 'id'> = {
      node_id: 1,
      image_ref: 'nginx:latest',
      image_digest: 'sha256:abc',
      scanned_at: Date.now(),
      total_vulnerabilities: 0,
      critical_count: 0,
      high_count: 0,
      medium_count: 0,
      low_count: 0,
      unknown_count: 0,
      fixable_count: 0,
      highest_severity: null,
      status: 'completed',
      triggered_by: 'manual',
      ...overrides,
    };
    const r = db
      .prepare(
        `INSERT INTO vulnerability_scans (
          node_id, image_ref, image_digest, scanned_at,
          total_vulnerabilities, critical_count, high_count, medium_count,
          low_count, unknown_count, fixable_count, highest_severity,
          os_info, trivy_version, scan_duration_ms, triggered_by, status,
          error, stack_context
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      )
      .run(
        scan.node_id,
        scan.image_ref,
        scan.image_digest,
        scan.scanned_at,
        scan.total_vulnerabilities,
        scan.critical_count,
        scan.high_count,
        scan.medium_count,
        scan.low_count,
        scan.unknown_count,
        scan.fixable_count,
        scan.highest_severity,
        null,
        null,
        null,
        scan.triggered_by,
        scan.status,
        null,
        null,
      );
    return r.lastInsertRowid as number;
  }

  function insertDetails(scanId: number, rows: Array<Partial<DetailRow>>): void {
    const stmt = db.prepare(
      `INSERT INTO vulnerability_details (
        scan_id, vulnerability_id, pkg_name, installed_version,
        fixed_version, severity, title, description, primary_url
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
    );
    const txn = db.transaction((items: Array<Partial<DetailRow>>) => {
      for (const d of items) {
        stmt.run(
          scanId,
          d.vulnerability_id ?? 'CVE-0000-0000',
          d.pkg_name ?? 'libssl',
          d.installed_version ?? '1.0.0',
          d.fixed_version ?? null,
          d.severity ?? 'LOW',
          null,
          null,
          null,
        );
      }
    });
    txn(rows);
  }

  function insertPolicy(overrides: Partial<PolicyRow> = {}): number {
    const now = Date.now();
    const r = db
      .prepare(
        `INSERT INTO scan_policies (name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, created_at, updated_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
      )
      .run(
        overrides.name ?? 'default',
        overrides.node_id ?? null,
        overrides.stack_pattern ?? null,
        overrides.max_severity ?? 'CRITICAL',
        overrides.block_on_deploy ?? 0,
        overrides.enabled ?? 1,
        overrides.created_at ?? now,
        overrides.updated_at ?? now,
      );
    return r.lastInsertRowid as number;
  }

  function count(table: string): number {
    return (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c;
  }

  // ── vulnerability_scans CRUD ──────────────────────────────────────

  describe('scan insert / retrieve', () => {
    it('round-trips a scan record by id', () => {
      const id = insertScan({
        image_ref: 'alpine:3.19',
        critical_count: 2,
        high_count: 5,
        total_vulnerabilities: 7,
        highest_severity: 'CRITICAL',
      });
      const row = db.prepare('SELECT * FROM vulnerability_scans WHERE id = ?').get(id) as ScanRow;
      expect(row).toBeDefined();
      expect(row.image_ref).toBe('alpine:3.19');
      expect(row.critical_count).toBe(2);
      expect(row.high_count).toBe(5);
      expect(row.total_vulnerabilities).toBe(7);
      expect(row.highest_severity).toBe('CRITICAL');
    });

    it('returns undefined for an unknown id', () => {
      const row = db.prepare('SELECT * FROM vulnerability_scans WHERE id = ?').get(999);
      expect(row).toBeUndefined();
    });
  });

  // ── getLatestScanByDigest ────────────────────────────────────────

  describe('getLatestScanByDigest', () => {
    it('returns the most recent completed scan for a digest', () => {
      insertScan({ image_digest: 'sha256:target', scanned_at: 1000, status: 'completed' });
      insertScan({ image_digest: 'sha256:target', scanned_at: 2000, status: 'completed' });
      insertScan({ image_digest: 'sha256:other', scanned_at: 3000, status: 'completed' });

      const row = db
        .prepare(
          "SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
        )
        .get('sha256:target') as ScanRow | undefined;
      expect(row?.scanned_at).toBe(2000);
    });

    it('ignores failed or in_progress scans when resolving the cache', () => {
      insertScan({ image_digest: 'sha256:target', scanned_at: 5000, status: 'in_progress' });
      insertScan({ image_digest: 'sha256:target', scanned_at: 4000, status: 'failed' });
      insertScan({ image_digest: 'sha256:target', scanned_at: 1000, status: 'completed' });

      const row = db
        .prepare(
          "SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
        )
        .get('sha256:target') as ScanRow | undefined;
      expect(row?.scanned_at).toBe(1000);
    });
  });

  // ── deleteOldScans ───────────────────────────────────────────────

  describe('deleteOldScans', () => {
    it('removes scans older than the cutoff and keeps newer ones', () => {
      const now = Date.now();
      insertScan({ scanned_at: now - 100_000_000 });
      insertScan({ scanned_at: now - 200_000_000 });
      insertScan({ scanned_at: now - 1_000 });

      const cutoff = now - 50_000_000;
      const deleted = db
        .prepare('DELETE FROM vulnerability_scans WHERE scanned_at < ?')
        .run(cutoff).changes;

      expect(deleted).toBe(2);
      expect(count('vulnerability_scans')).toBe(1);
    });
  });

  // ── markStaleScansAsFailed ───────────────────────────────────────

  describe('markStaleScansAsFailed', () => {
    function markStale(olderThanMs: number): number {
      const cutoff = Date.now() - olderThanMs;
      const result = db
        .prepare(
          `UPDATE vulnerability_scans
           SET status = 'failed',
               error = 'Scan did not complete within expected time',
               scan_duration_ms = ? - scanned_at
           WHERE status = 'in_progress' AND scanned_at < ?`,
        )
        .run(Date.now(), cutoff);
      return result.changes;
    }

    it('flips in_progress scans older than the cutoff to failed', () => {
      const now = Date.now();
      const stale = insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 });
      const fresh = insertScan({ status: 'in_progress', scanned_at: now - 1_000 });

      const changed = markStale(15 * 60 * 1000);
      expect(changed).toBe(1);

      const staleRow = db.prepare('SELECT status, error FROM vulnerability_scans WHERE id = ?').get(stale) as {
        status: string;
        error: string;
      };
      const freshRow = db.prepare('SELECT status FROM vulnerability_scans WHERE id = ?').get(fresh) as {
        status: string;
      };
      expect(staleRow.status).toBe('failed');
      expect(staleRow.error).toMatch(/did not complete/i);
      expect(freshRow.status).toBe('in_progress');
    });

    it('leaves completed and failed rows untouched', () => {
      const now = Date.now();
      const completed = insertScan({ status: 'completed', scanned_at: now - 30 * 60 * 1000 });
      const failed = insertScan({ status: 'failed', scanned_at: now - 30 * 60 * 1000 });

      const changed = markStale(15 * 60 * 1000);
      expect(changed).toBe(0);

      const rows = db
        .prepare('SELECT id, status FROM vulnerability_scans WHERE id IN (?, ?)')
        .all(completed, failed) as Array<{ id: number; status: string }>;
      expect(rows.find((r) => r.id === completed)?.status).toBe('completed');
      expect(rows.find((r) => r.id === failed)?.status).toBe('failed');
    });

    it('is idempotent when called repeatedly', () => {
      const now = Date.now();
      insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 });

      expect(markStale(15 * 60 * 1000)).toBe(1);
      expect(markStale(15 * 60 * 1000)).toBe(0);
      expect(markStale(15 * 60 * 1000)).toBe(0);
    });
  });

  // ── vulnerability_details cascade ────────────────────────────────

  describe('vulnerability_details cascade', () => {
    it('cascades deletes when the parent scan is removed', () => {
      const id = insertScan();
      insertDetails(id, [
        { vulnerability_id: 'CVE-1', severity: 'HIGH' },
        { vulnerability_id: 'CVE-2', severity: 'LOW' },
        { vulnerability_id: 'CVE-3', severity: 'CRITICAL', fixed_version: '2.0.0' },
      ]);

      expect(count('vulnerability_details')).toBe(3);

      db.prepare('DELETE FROM vulnerability_scans WHERE id = ?').run(id);

      expect(count('vulnerability_scans')).toBe(0);
      expect(count('vulnerability_details')).toBe(0);
    });

    it('batches inserts inside a transaction (no partial writes on scan isolation)', () => {
      const id = insertScan();
      const other = insertScan({ image_ref: 'other:1.0' });

      insertDetails(id, [
        { vulnerability_id: 'CVE-A', severity: 'HIGH' },
        { vulnerability_id: 'CVE-B', severity: 'HIGH' },
      ]);

      const rows = db
        .prepare('SELECT vulnerability_id FROM vulnerability_details WHERE scan_id = ?')
        .all(id) as { vulnerability_id: string }[];
      expect(rows.map((r) => r.vulnerability_id).sort()).toEqual(['CVE-A', 'CVE-B']);

      // Other scan unaffected.
      const otherCount = db
        .prepare('SELECT COUNT(*) as c FROM vulnerability_details WHERE scan_id = ?')
        .get(other) as { c: number };
      expect(otherCount.c).toBe(0);
    });
  });

  // ── getVulnerabilityDetails ordering ─────────────────────────────

  describe('getVulnerabilityDetails ordering', () => {
    it('orders by severity (CRITICAL → HIGH → MEDIUM → LOW → UNKNOWN) then pkg_name', () => {
      const id = insertScan();
      insertDetails(id, [
        { vulnerability_id: 'CVE-L', severity: 'LOW', pkg_name: 'aaa' },
        { vulnerability_id: 'CVE-C', severity: 'CRITICAL', pkg_name: 'bbb' },
        { vulnerability_id: 'CVE-H', severity: 'HIGH', pkg_name: 'ccc' },
        { vulnerability_id: 'CVE-U', severity: 'UNKNOWN', pkg_name: 'ddd' },
        { vulnerability_id: 'CVE-M', severity: 'MEDIUM', pkg_name: 'eee' },
      ]);

      const severityOrder = `CASE severity
        WHEN 'CRITICAL' THEN 0
        WHEN 'HIGH' THEN 1
        WHEN 'MEDIUM' THEN 2
        WHEN 'LOW' THEN 3
        ELSE 4 END`;
      const rows = db
        .prepare(
          `SELECT severity, pkg_name FROM vulnerability_details WHERE scan_id = ? ORDER BY ${severityOrder}, pkg_name`,
        )
        .all(id) as Array<{ severity: Severity; pkg_name: string }>;

      expect(rows.map((r) => r.severity)).toEqual([
        'CRITICAL',
        'HIGH',
        'MEDIUM',
        'LOW',
        'UNKNOWN',
      ]);
    });

    it('filters by severity when requested', () => {
      const id = insertScan();
      insertDetails(id, [
        { vulnerability_id: 'CVE-1', severity: 'HIGH' },
        { vulnerability_id: 'CVE-2', severity: 'HIGH' },
        { vulnerability_id: 'CVE-3', severity: 'LOW' },
      ]);

      const highs = db
        .prepare('SELECT COUNT(*) as c FROM vulnerability_details WHERE scan_id = ? AND severity = ?')
        .get(id, 'HIGH') as { c: number };
      expect(highs.c).toBe(2);
    });
  });

  // ── getImageScanSummaries (latest-per-image JOIN) ────────────────

  describe('getImageScanSummaries', () => {
    const SUMMARIES_SQL = `
      SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities,
          vs.critical_count, vs.high_count, vs.medium_count, vs.low_count,
          vs.unknown_count, vs.fixable_count, vs.scanned_at
       FROM vulnerability_scans vs
       INNER JOIN (
         SELECT image_ref, MAX(scanned_at) AS max_scanned
         FROM vulnerability_scans
         WHERE node_id = ? AND status = 'completed'
         GROUP BY image_ref
       ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
       WHERE vs.node_id = ? AND vs.status = 'completed'`;

    it('returns only the latest scan per image_ref', () => {
      insertScan({ image_ref: 'nginx:1', scanned_at: 100, highest_severity: 'LOW' });
      insertScan({ image_ref: 'nginx:1', scanned_at: 200, highest_severity: 'HIGH' });
      insertScan({ image_ref: 'nginx:1', scanned_at: 300, highest_severity: 'CRITICAL' });
      insertScan({ image_ref: 'redis:7', scanned_at: 150, highest_severity: 'MEDIUM' });

      const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{
        image_ref: string;
        scanned_at: number;
        highest_severity: Severity;
      }>;

      expect(rows.length).toBe(2);
      const byImage = Object.fromEntries(rows.map((r) => [r.image_ref, r]));
      expect(byImage['nginx:1'].scanned_at).toBe(300);
      expect(byImage['nginx:1'].highest_severity).toBe('CRITICAL');
      expect(byImage['redis:7'].scanned_at).toBe(150);
    });

    it('ignores in_progress and failed scans', () => {
      insertScan({ image_ref: 'alpine:3', scanned_at: 500, status: 'in_progress' });
      insertScan({ image_ref: 'alpine:3', scanned_at: 100, status: 'completed', highest_severity: 'LOW' });

      const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{ image_ref: string; scanned_at: number }>;
      expect(rows.length).toBe(1);
      expect(rows[0].scanned_at).toBe(100);
    });

    it('scopes results to the requested node', () => {
      insertScan({ image_ref: 'nginx:1', scanned_at: 100, node_id: 1 });
      insertScan({ image_ref: 'nginx:1', scanned_at: 200, node_id: 2 });

      const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{ scanned_at: number }>;
      expect(rows.length).toBe(1);
      expect(rows[0].scanned_at).toBe(100);
    });
  });

  // ── scan_policies CRUD + glob matching ───────────────────────────

  describe('scan_policies CRUD', () => {
    it('creates and retrieves a policy', () => {
      const id = insertPolicy({ name: 'prod-gate', stack_pattern: 'prod-*', max_severity: 'HIGH', block_on_deploy: 1 });
      const row = db.prepare('SELECT * FROM scan_policies WHERE id = ?').get(id) as PolicyRow;
      expect(row.name).toBe('prod-gate');
      expect(row.stack_pattern).toBe('prod-*');
      expect(row.max_severity).toBe('HIGH');
      expect(row.block_on_deploy).toBe(1);
      expect(row.enabled).toBe(1);
    });

    it('deletes a policy by id', () => {
      const id = insertPolicy();
      insertPolicy({ name: 'other' });
      db.prepare('DELETE FROM scan_policies WHERE id = ?').run(id);
      expect(count('scan_policies')).toBe(1);
    });
  });

  describe('getMatchingPolicy (glob + scope priority)', () => {
    // Mirrors the matching logic in DatabaseService.getMatchingPolicy.
    function findMatching(nodeId: number, stackName: string | null): PolicyRow | null {
      const policies = db
        .prepare(
          'SELECT * FROM scan_policies WHERE enabled = 1 AND (node_id IS NULL OR node_id = ?)',
        )
        .all(nodeId) as PolicyRow[];
      const matchesStack = (pattern: string | null): boolean => {
        if (!pattern) return true;
        if (!stackName) return false;
        const regex = new RegExp(
          '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
        );
        return regex.test(stackName);
      };
      const scoped = policies.filter((p) => matchesStack(p.stack_pattern));
      if (scoped.length === 0) return null;
      scoped.sort((a, b) => {
        if (a.node_id && !b.node_id) return -1;
        if (!a.node_id && b.node_id) return 1;
        if (a.stack_pattern && !b.stack_pattern) return -1;
        if (!a.stack_pattern && b.stack_pattern) return 1;
        return 0;
      });
      return scoped[0];
    }

    it('matches a glob pattern against the stack name', () => {
      insertPolicy({ name: 'prod-policy', stack_pattern: 'prod-*', max_severity: 'HIGH' });
      expect(findMatching(1, 'prod-web')?.name).toBe('prod-policy');
      expect(findMatching(1, 'dev-api')).toBeNull();
    });

    it('null pattern acts as a wildcard (matches any stack)', () => {
      insertPolicy({ name: 'catch-all', stack_pattern: null });
      expect(findMatching(1, 'anything')?.name).toBe('catch-all');
      expect(findMatching(1, null)?.name).toBe('catch-all');
    });

    it('scoped policy (node_id set) wins over global when both match', () => {
      insertPolicy({ name: 'global', stack_pattern: null, node_id: null });
      insertPolicy({ name: 'node-specific', stack_pattern: null, node_id: 1 });
      expect(findMatching(1, 'whatever')?.name).toBe('node-specific');
    });

    it('patterned policy wins over wildcard when both match', () => {
      insertPolicy({ name: 'wildcard', stack_pattern: null });
      insertPolicy({ name: 'patterned', stack_pattern: 'prod-*' });
      expect(findMatching(1, 'prod-web')?.name).toBe('patterned');
    });

    it('ignores disabled policies', () => {
      insertPolicy({ name: 'disabled', stack_pattern: null, enabled: 0 });
      expect(findMatching(1, 'prod-web')).toBeNull();
    });

    it('escapes regex metacharacters in patterns (dots are literal)', () => {
      insertPolicy({ name: 'literal-dot', stack_pattern: 'app.prod' });
      expect(findMatching(1, 'app.prod')?.name).toBe('literal-dot');
      // The dot must NOT act as "any character", otherwise "appXprod" would match.
      expect(findMatching(1, 'appXprod')).toBeNull();
    });

    it('ignores policies scoped to a different node', () => {
      insertPolicy({ name: 'node-2-only', node_id: 2, stack_pattern: null });
      expect(findMatching(1, 'whatever')).toBeNull();
    });
  });
});
