/**
 * Smoke tests for the audit-log viewer route (DC-050).
 *
 * Mirrors the caddy-upstreams.routes.test.js pattern: build the router with
 * stubbed dependencies, hit it via a tiny express app, assert the response
 * shape and the audit-logger calls.
 */

const express = require('express');

const FIXTURE_ENTRIES = [
  {
    id: 'a1', timestamp: '2026-08-17T10:00:00.000Z', ip: '1.1.1.1',
    action: 'service.create', resource: 'plex',
    details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
  },
  {
    id: 'a2', timestamp: '2026-08-17T11:00:00.000Z', ip: '1.1.1.1',
    action: 'auth.totp-setup', resource: 'u-1',
    details: { userId: 'u-1', userEmail: 'admin@sami' }, outcome: 'success',
  },
  {
    id: 'a3', timestamp: '2026-08-17T12:00:00.000Z', ip: '2.2.2.2',
    action: 'auth.api-key-generate', resource: 'unknown',
    details: { userId: null }, outcome: 'failure',
  },
  {
    id: 'a4', timestamp: '2026-08-17T13:00:00.000Z', ip: '1.1.1.1',
    action: 'backup.execute', resource: 'all-apps',
    details: {}, outcome: 'success',
  },
  {
    id: 'a5', timestamp: '2026-08-17T14:00:00.000Z', ip: '3.3.3.3',
    action: 'caddy.add-site', resource: 'test.sami',
    details: {}, outcome: 'failure',
  },
];

function buildFakeAuditLogger(entries = FIXTURE_ENTRIES) {
  return {
    query: jest.fn(async ({ limit = 50, offset = 0, action } = {}) => {
      let e = entries;
      if (action) e = e.filter((x) => x.action && x.action.startsWith(action));
      return e.slice(offset, offset + limit);
    }),
    clear: jest.fn(async () => {}),
    // log() is called by the DELETE handler to record `audit.clear` BEFORE
    // clearing — the act of clearing is itself an audit-worthy event.
    log: jest.fn(async () => {}),
  };
}

describe('routes/audit-log', () => {
  function buildRouter(logger) {
    const mod = require('../../routes/audit-log');
    return mod({
      asyncHandler: (fn) => async (req, res, next) => {
        try { await fn(req, res, next); } catch (e) { next(e); }
      },
      auditLogger: logger,
    });
  }

  test('router builds with the expected paths', () => {
    const logger = buildFakeAuditLogger();
    const router = buildRouter(logger);
    expect(router).toBeDefined();
    expect(typeof router.use).toBe('function');
    const paths = router.stack
      .filter((l) => l.route)
      .map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
      .flat();
    expect(paths).toEqual(expect.arrayContaining([
      'GET /audit-logs',
      'GET /audit-logs/actions',
      'DELETE /audit-logs',
    ]));
  });

  test('GET /audit-logs returns all entries when no filters', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=10`);
    const body = await res.json();
    server.close();
    expect(res.status).toBe(200);
    expect(body.success).toBe(true);
    expect(body.entries).toHaveLength(5);
    expect(body.total).toBe(5);
    expect(body.hasMore).toBe(false);
    expect(body.filters).toEqual({ action: null, since: null, until: null, outcome: null });
  });

  test('GET /audit-logs respects limit + offset', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=2&offset=0`);
    const body = await res.json();
    server.close();
    expect(body.entries).toHaveLength(2);
    expect(body.entries[0].id).toBe('a1');
    expect(body.hasMore).toBe(true);

    const server2 = app.listen(0);
    const { port: port2 } = server2.address();
    const res2 = await fetch(`http://127.0.0.1:${port2}/audit-logs?limit=2&offset=4`);
    const body2 = await res2.json();
    server2.close();
    expect(body2.entries).toHaveLength(1);
    expect(body2.entries[0].id).toBe('a5');
    expect(body2.hasMore).toBe(false);
  });

  test('GET /audit-logs?action=auth filters server-side via auditLogger.query', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=auth`);
    const body = await res.json();
    server.close();
    expect(res.status).toBe(200);
    expect(body.entries).toHaveLength(2);
    expect(body.entries.every((e) => e.action.startsWith('auth'))).toBe(true);
    // The action filter MUST be pushed down to the audit-logger so we don't
    // load the full 1000-entry store when the operator filters by category.
    expect(logger.query).toHaveBeenCalledWith(expect.objectContaining({ action: 'auth' }));
  });

  test('GET /audit-logs rejects unknown action prefix with 400', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?action=pwnz`);
    server.close();
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.success).toBe(false);
    expect(body.error).toMatch(/action must be one of/);
  });

  test('GET /audit-logs filters by since (date >= since)', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T13:00:00.000Z`);
    const body = await res.json();
    server.close();
    expect(body.entries).toHaveLength(2); // a4 + a5
    expect(body.entries.map((e) => e.id)).toEqual(['a4', 'a5']);
  });

  test('GET /audit-logs filters by outcome=failure', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?outcome=failure`);
    const body = await res.json();
    server.close();
    expect(body.entries).toHaveLength(2); // a3 + a5
    expect(body.entries.every((e) => e.outcome === 'failure')).toBe(true);
  });

  test('GET /audit-logs rejects since > until with 400', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T20:00:00Z&until=2026-08-17T10:00:00Z`);
    server.close();
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.success).toBe(false);
    expect(body.error).toMatch(/since must be <= until/);
  });

  test('GET /audit-logs rejects malformed ISO 8601 with 400', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=not-a-date`);
    server.close();
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toMatch(/since must be ISO 8601/);
  });

  test('GET /audit-logs caps limit at 500 (no DoS via huge page)', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=99999`);
    const body = await res.json();
    server.close();
    expect(body.limit).toBe(500);
  });

  test('GET /audit-logs/actions returns distinct action prefixes', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
    const body = await res.json();
    server.close();
    expect(res.status).toBe(200);
    expect(body.success).toBe(true);
    expect(body.prefixes).toEqual(['auth', 'backup', 'caddy', 'service']);
  });

  test('DELETE /audit-logs requires confirm=CLEAR body', async () => {
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
      method: 'DELETE',
      headers: { 'content-type': 'application/json' },
      body: '{}',
    });
    const body = await res.json();
    server.close();
    expect(res.status).toBe(400);
    expect(body.success).toBe(false);
    expect(body.error).toMatch(/confirm: "CLEAR"/);
    expect(logger.clear).not.toHaveBeenCalled();
  });

  test('DELETE /audit-logs with confirm=CLEAR calls auditLogger.clear()', async () => {
    const logger = buildFakeAuditFixtureSafe();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
      method: 'DELETE',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ confirm: 'CLEAR' }),
    });
    const body = await res.json();
    server.close();
    expect(res.status).toBe(200);
    expect(body.cleared).toBe(true);
    expect(logger.clear).toHaveBeenCalledTimes(1);
  });

  test('module.exports throws when auditLogger is missing query()', () => {
    const mod = require('../../routes/audit-log');
    expect(() => mod({ asyncHandler: (fn) => fn, auditLogger: {} }))
      .toThrow(/auditLogger with query/);
  });

  // ── GLM round-1 defect regressions ───────────────────────────────────────

  test('GET /audit-logs does NOT amputate the store when limit*5 < MAX_ENTRIES (cap-truncation fix)', async () => {
    // Round-1 [HIGH]: route previously fetched `limit * 5` entries from
    // the store and computed total/hasMore over that truncated slice.
    // With MAX_ENTRIES=1000 and limit=50, the cap was 250 — silently
    // hiding entries 251-1000. The fix fetches the full store (1000).
    const entries = Array.from({ length: 1000 }, (_, i) => ({
      id: `bulk-${i}`,
      timestamp: new Date(Date.parse('2026-08-17T00:00:00Z') + i * 1000).toISOString(),
      ip: '9.9.9.9',
      action: 'service.create',
      resource: `svc-${i}`,
      details: {},
      outcome: i % 3 === 0 ? 'failure' : 'success',
    }));
    const logger = buildFakeAuditLogger(entries);
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?limit=50&offset=200`);
    const body = await res.json();
    server.close();
    expect(body.total).toBe(1000);    // full store, not 250
    expect(body.hasMore).toBe(true);  // still more after offset 200
    expect(body.truncated).toBe(true); // signal that store was at cap
  });

  test('GET /audit-logs compares ISO timestamps numerically (lexicographic compare fix)', async () => {
    // Round-1 [MEDIUM]: '10:00:00.000Z' < '10:00:00Z' is false lexicographically
    // (the latter is a strict substring, breaking `>=`). Fix: use Date.parse().
    const fixedEntries = [
      { id: 'b1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
      { id: 'b2', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
    ];
    const logger = buildFakeAuditLogger(fixedEntries);
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    // Same instant as b1 in a different ISO format — must be included.
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T10:00:00Z`);
    const body = await res.json();
    server.close();
    expect(body.total).toBe(2);
    expect(body.entries.map((e) => e.id)).toEqual(['b1', 'b2']);
  });

  test('GET /audit-logs accepts ISO with positive UTC offset (numeric compare fix)', async () => {
    const fixedEntries = [
      { id: 'c1', timestamp: '2026-08-17T10:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
      { id: 'c2', timestamp: '2026-08-17T11:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
      { id: 'c3', timestamp: '2026-08-17T12:00:00.000Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
    ];
    const logger = buildFakeAuditLogger(fixedEntries);
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    // 11:00+02:00 = 09:00Z. Filter for entries AFTER 09:00Z. Expect c1 + c2 + c3.
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs?since=2026-08-17T11:00:00%2B02:00`);
    const body = await res.json();
    server.close();
    expect(body.total).toBe(3);
  });

  test('GET /audit-logs/actions only surfaces whitelisted prefixes', async () => {
    // Round-1 [LOW]: dropdown advertised prefixes (e.g. `logs`, `events`)
    // that GET /audit-logs?action=logs would then 400. Fix: intersect with
    // the whitelist before returning.
    const mixedEntries = [
      { id: 'd1', timestamp: '2026-08-17T10:00:00Z', ip: '', action: 'service.create', resource: '', details: {}, outcome: 'success' },
      { id: 'd2', timestamp: '2026-08-17T10:01:00Z', ip: '', action: 'logs.something', resource: '', details: {}, outcome: 'success' },
      { id: 'd3', timestamp: '2026-08-17T10:02:00Z', ip: '', action: 'events.publish', resource: '', details: {}, outcome: 'success' },
    ];
    const logger = buildFakeAuditLogger(mixedEntries);
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs/actions`);
    const body = await res.json();
    server.close();
    expect(body.prefixes).toEqual(['service']);  // logs/events filtered out
  });

  test('DELETE /audit-logs writes audit.clear BEFORE AND AFTER clear() — re-injection preserves the forensic breadcrumb', async () => {
    // GLM round-2 [MEDIUM]: a naive "log before clear()" self-erases —
    // clear() wipes the entry that was just written. Fix: log before
    // clear() (catches any failure path), then clear(), then log AGAIN
    // so the entry survives as the single row visible to the viewer.
    const logger = buildFakeAuditLogger();
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
      method: 'DELETE',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ confirm: 'CLEAR' }),
    });
    server.close();
    expect(res.status).toBe(200);
    // log() runs TWICE — once before clear (catches failure paths) and
    // once after clear (re-injects the forensic breadcrumb).
    expect(logger.log).toHaveBeenCalledTimes(2);
    expect(logger.log).toHaveBeenNthCalledWith(1, expect.objectContaining({
      action: 'audit.clear',
      resource: 'audit-log.json',
      outcome: 'success',
    }));
    expect(logger.log).toHaveBeenNthCalledWith(2, expect.objectContaining({
      action: 'audit.clear',
      resource: 'audit-log.json',
      outcome: 'success',
    }));
    // Ordering: log → clear → log (second log runs AFTER clear).
    const logOrders = logger.log.mock.invocationCallOrder;
    const clearOrder = logger.clear.mock.invocationCallOrder[0];
    expect(logOrders[0]).toBeLessThan(clearOrder);
    expect(logOrders[1]).toBeGreaterThan(clearOrder);
  });

  test('DELETE /audit-logs still calls clear() even if auditLogger.log() throws', async () => {
    // A failing audit-log write must NOT block the operator's clear.
    const logger = buildFakeAuditLogger();
    logger.log.mockRejectedValueOnce(new Error('disk full'));
    const app = express();
    app.use(express.json());
    app.use(buildRouter(logger));
    const server = app.listen(0);
    const { port } = server.address();
    const res = await fetch(`http://127.0.0.1:${port}/audit-logs`, {
      method: 'DELETE',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ confirm: 'CLEAR' }),
    });
    server.close();
    expect(res.status).toBe(200);
    expect(logger.clear).toHaveBeenCalledTimes(1);
  });
});

// Tiny helper — separated so the second clear test has a fresh mock.
function buildFakeAuditFixtureSafe() {
  return buildFakeAuditLogger();
}