/**
 * DC-081: log-insights dispose path + keepDays input validation hardening.
 *
 * Two coupled bugs surfaced in the 2026-08-19 sweep:
 *
 *  1. log-insights.js hardcoded the audit-log.json + security-events.jsonl
 *     paths to `/opt/dashcaddy/dashcaddy-api/data/...`, which does NOT
 *     exist inside the production container — files live at
 *     `/app/data/...` (mounted via the existing data bind). The dispose
 *     endpoint silently no-op'd: `fs.readFile('/opt/.../audit-log.json')`
 *     hit the `.catch` arm → `auditData = []` → wrote an empty file back.
 *
 *  2. `parseInt(req.body.keepDays) || 30` accepted negative numbers. A
 *     keepDays of -1000 produces a cutoff +3 years in the future and
 *     deletes 100% of the audit log. Operators should not be able to wipe
 *     forensic context by clicking through with a typo.
 *
 * DC-081 fix:
 *   - `_resolvePaths()` returns `{ auditPath, secPath }` from
 *     `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`
 *     — same canonical resolution as the audit-logger module.
 *   - `_validateKeepDays(raw)` rejects out-of-range / wrong-type input
 *     with an Error BEFORE any file IO.
 *   - POST /log-insights/dispose now requires `{ keepDays: integer 1..3650, confirm: true }`.
 *     The pre-confirm preview is read-only.
 *
 * Verified live on DNS2 2026-08-19: `/app/data/audit-log.json` (318 KB)
 * and `/app/data/security-events.jsonl` (15 MB) both exist; the old
 * `/opt/dashcaddy/dashcaddy-api/data/...` paths are ENOENT in the container.
 */

const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const os = require('os');
const path = require('path');

const logInsightsMod = require('../../routes/log-insights');

function tmpAuditLogger() {
  // The route module only uses auditLogger.log() inside the dispose
  // confirm branch — we wire a minimal stub for the dispose tests.
  return {
    query: async () => [],
    log: async () => {},
  };
}

function tmpSecurityEventStore() {
  return {
    query: () => ({ events: [], total: 0 }),
  };
}

function buildRouter(opts = {}) {
  const mod = logInsightsMod;
  return mod({
    asyncHandler: (fn) => async (req, res, next) => {
      try { await fn(req, res, next); } catch (e) { next(e); }
    },
    ok: (res, data) => res.json({ success: true, ...data }),
    auditLogger: opts.auditLogger || tmpAuditLogger(),
    securityEventStore: opts.securityEventStore || tmpSecurityEventStore(),
  });
}

function makeApp(router) {
  const app = express();
  app.use(express.json());
  app.use(router);
  // Capture errors so a thrown ValidationError doesn't crash the test
  // runner — the route uses asyncHandler which forwards to next().
  app.use((err, req, res, next) => res.status(err.statusCode || 500).json({ success: false, error: err.message, code: err.code }));
  return app;
}

// Drive requests through http directly so we exercise the FULL Express
// middleware stack (body parser, error handler).
function start(app) {
  return new Promise((resolve) => {
    const server = app.listen(0, '127.0.0.1', () => resolve(server));
  });
}

function stop(server) {
  return new Promise((resolve) => server.close(resolve));
}

function httpJson(server, httpMethod, urlPath) {
  return new Promise((resolve, reject) => {
    const port = server.address().port;
    const data = httpMethod === 'GET' ? '' : JSON.stringify({});
    const req = require('http').request({
      hostname: '127.0.0.1', port, path: urlPath, method: httpMethod,
      headers: httpMethod === 'GET'
        ? {}
        : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
    }, (res) => {
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
        catch (_) { resolve({ status: res.statusCode, body }); }
      });
    });
    req.on('error', reject);
    if (httpMethod !== 'GET') req.write(data);
    req.end();
  });
}

describe('routes/log-insights [DC-081]', () => {
  describe('_validateKeepDays', () => {
    const { _validateKeepDays } = logInsightsMod.__test;

    test('rejects undefined / null / missing', () => {
      expect(() => _validateKeepDays(undefined)).toThrow(/required/i);
      expect(() => _validateKeepDays(null)).toThrow(/required/i);
      expect(() => _validateKeepDays()).toThrow(/required/i);
    });

    test('rejects non-finite numbers (NaN, Infinity, -Infinity)', () => {
      expect(() => _validateKeepDays(NaN)).toThrow(/finite/i);
      expect(() => _validateKeepDays(Infinity)).toThrow(/finite/i);
      expect(() => _validateKeepDays(-Infinity)).toThrow(/finite/i);
      expect(() => _validateKeepDays('not-a-number')).toThrow(/finite/i);
    });

    test('rejects non-integers (floats, strings of floats)', () => {
      expect(() => _validateKeepDays(1.5)).toThrow(/integer/i);
      expect(() => _validateKeepDays(30.7)).toThrow(/integer/i);
      expect(() => _validateKeepDays('30.5')).toThrow(/integer/i);
    });

    test('rejects out-of-range values — the DC-081 core fix', () => {
      // The pre-fix bug: parseInt(-1000, 10) === -1000, accepted as keepDays.
      // cutoff = Date.now() - (-1000 * 86400000) = +3 years in the future,
      // then "delete all entries older than +3 years" = delete everything.
      expect(() => _validateKeepDays(-1)).toThrow(/between 1 and 3650/i);
      expect(() => _validateKeepDays(-1000)).toThrow(/between 1 and 3650/i);
      expect(() => _validateKeepDays(0)).toThrow(/between 1 and 3650/i);
      expect(() => _validateKeepDays(3651)).toThrow(/between 1 and 3650/i);
      expect(() => _validateKeepDays(1000000)).toThrow(/between 1 and 3650/i);
    });

    test('accepts integers in [1, 3650]', () => {
      expect(_validateKeepDays(1)).toBe(1);
      expect(_validateKeepDays(30)).toBe(30);
      expect(_validateKeepDays(90)).toBe(90);
      expect(_validateKeepDays(365)).toBe(365);
      expect(_validateKeepDays(3650)).toBe(3650);
    });

    test('coerces numeric strings', () => {
      expect(_validateKeepDays('30')).toBe(30);
      expect(_validateKeepDays('3650')).toBe(3650);
    });
  });

  describe('_resolvePaths', () => {
    const { _resolvePaths } = logInsightsMod.__test;

    test('falls back to platformPaths.dataDir when env unset', () => {
      const prevAudit = process.env.AUDIT_LOG_FILE;
      const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
      delete process.env.AUDIT_LOG_FILE;
      delete process.env.SECURITY_EVENT_LOG_FILE;
      try {
        const { auditPath, secPath } = _resolvePaths();
        // platformPaths.dataDir is /app/data in the container, /etc/dashcaddy on host
        expect(auditPath.endsWith('audit-log.json')).toBe(true);
        expect(secPath.endsWith('security-events.jsonl')).toBe(true);
        // Audit + security should land in the same data dir
        expect(path.dirname(auditPath)).toBe(path.dirname(secPath));
      } finally {
        if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
        if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
      }
    });

    test('honours AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE env overrides', () => {
      const prevAudit = process.env.AUDIT_LOG_FILE;
      const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
      process.env.AUDIT_LOG_FILE = '/tmp/dc-081-audit.json';
      process.env.SECURITY_EVENT_LOG_FILE = '/tmp/dc-081-sec.jsonl';
      try {
        const { auditPath, secPath, auditPathFrom, secPathFrom } = _resolvePaths();
        expect(auditPath).toBe('/tmp/dc-081-audit.json');
        expect(secPath).toBe('/tmp/dc-081-sec.jsonl');
        expect(auditPathFrom).toBe('env');
        expect(secPathFrom).toBe('env');
      } finally {
        if (prevAudit === undefined) delete process.env.AUDIT_LOG_FILE;
        else process.env.AUDIT_LOG_FILE = prevAudit;
        if (prevSec === undefined) delete process.env.SECURITY_EVENT_LOG_FILE;
        else process.env.SECURITY_EVENT_LOG_FILE = prevSec;
      }
    });

    test('matches the canonical paths used by audit-logger + event-store', async () => {
      // Sanity: load both modules' resolved paths and assert they match
      // what _resolvePaths returns. This catches a future refactor that
      // moves one but not the others (the bug class that produced DC-081).
      const prevAudit = process.env.AUDIT_LOG_FILE;
      const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
      delete process.env.AUDIT_LOG_FILE;
      delete process.env.SECURITY_EVENT_LOG_FILE;
      try {
        const auditLoggerMod = require('../../src/security/audit-logger');
        const eventStoreMod = require('../../src/security/event-store');
        // Trigger event-store module-load (it captures ENV at require time)
        eventStoreMod.getStore();
        const { auditPath, secPath } = _resolvePaths();
        // The audit-logger module exports a singleton; its private
        // AUDIT_LOG_FILE is not directly readable. Instead, we verify the
        // shape: both paths share the same dataDir and use the canonical
        // filenames.
        expect(path.basename(auditPath)).toBe('audit-log.json');
        expect(path.basename(secPath)).toBe('security-events.jsonl');
        // And the dirname matches platformPaths.dataDir
        const platformPaths = require('../../platform-paths');
        expect(path.dirname(auditPath)).toBe(platformPaths.dataDir);
        expect(path.dirname(secPath)).toBe(platformPaths.dataDir);
        // Also sanity that the singleton logger at least exists
        expect(auditLoggerMod).toBeDefined();
      } finally {
        if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
        if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
      }
    });
  });

  describe('POST /log-insights/dispose (TOTP-gated in production; here we hit the handler directly)', () => {
    let server;
    let app;
    let tmpDir;
    let auditFile;
    let secFile;

    beforeEach(async () => {
      tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dc-081-'));
      auditFile = path.join(tmpDir, 'audit-log.json');
      secFile = path.join(tmpDir, 'security-events.jsonl');
      // Stage files so the route resolves them via env override.
      process.env.AUDIT_LOG_FILE = auditFile;
      process.env.SECURITY_EVENT_LOG_FILE = secFile;
      const router = buildRouter();
      app = makeApp(router);
      server = await start(app);
    });

    afterEach(async () => {
      await stop(server);
      delete process.env.AUDIT_LOG_FILE;
      delete process.env.SECURITY_EVENT_LOG_FILE;
      await fsp.rm(tmpDir, { recursive: true, force: true });
    });

    function postKeepDays(body) {
      return new Promise((resolve, reject) => {
        const port = server.address().port;
        const data = JSON.stringify(body);
        const req = require('http').request({
          hostname: '127.0.0.1', port, path: '/log-insights/dispose',
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
        }, (res) => {
          const chunks = [];
          res.on('data', (c) => chunks.push(c));
          res.on('end', () => {
            const body = Buffer.concat(chunks).toString('utf8');
            try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
            catch (_) { resolve({ status: res.statusCode, body }); }
          });
        });
        req.on('error', reject);
        req.write(data);
        req.end();
      });
    }

    test('rejects negative keepDays with 400 + DC-081_INVALID_KEEP_DAYS', async () => {
      const r = await postKeepDays({ keepDays: -1000 });
      expect(r.status).toBe(400);
      expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
      expect(r.body.error).toMatch(/between 1 and 3650/i);
    });

    test('rejects 0 keepDays (no-op-but-lies)', async () => {
      const r = await postKeepDays({ keepDays: 0 });
      expect(r.status).toBe(400);
      expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
    });

    test('rejects keepDays=Infinity (NaN-via-parseInt fallback)', async () => {
      const r = await postKeepDays({ keepDays: Infinity });
      expect(r.status).toBe(400);
      expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
    });

    test('rejects non-integer keepDays', async () => {
      const r = await postKeepDays({ keepDays: 30.5 });
      expect(r.status).toBe(400);
      expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
    });

    test('rejects missing keepDays', async () => {
      const r = await postKeepDays({});
      expect(r.status).toBe(400);
      expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
    });

    test('rejects keepDays > 3650 (10-year cap)', async () => {
      const r = await postKeepDays({ keepDays: 10000 });
      expect(r.status).toBe(400);
      expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
    });

    test('preview pass: returns wouldDelete count without writing', async () => {
      const oldTs = new Date(Date.now() - 100 * 86400000).toISOString(); // 100 days ago
      const newTs = new Date(Date.now() - 5 * 86400000).toISOString();  // 5 days ago
      await fsp.writeFile(auditFile, JSON.stringify([
        { id: 'a1', timestamp: oldTs, action: 'service.create' },
        { id: 'a2', timestamp: oldTs, action: 'service.delete' },
        { id: 'a3', timestamp: newTs, action: 'auth.totp-verify' },
      ]));
      await fsp.writeFile(secFile, [
        JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
        JSON.stringify({ id: 's2', timestamp: oldTs, severity: 'info' }),
        JSON.stringify({ id: 's3', timestamp: newTs, severity: 'info' }),
      ].join('\n') + '\n');

      const r = await postKeepDays({ keepDays: 30 });
      expect(r.status).toBe(200);
      expect(r.body.preview).toBe(true);
      expect(r.body.wouldDelete.auditEntries).toBe(2);
      expect(r.body.wouldDelete.securityEvents).toBe(2);
      // Files untouched
      const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
      expect(afterAudit.length).toBe(3);
      const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean);
      expect(afterSec.length).toBe(3);
    });

    test('confirm pass: actually deletes old entries, keeps new ones', async () => {
      const oldTs = new Date(Date.now() - 100 * 86400000).toISOString();
      const newTs = new Date(Date.now() - 5 * 86400000).toISOString();
      await fsp.writeFile(auditFile, JSON.stringify([
        { id: 'a1', timestamp: oldTs, action: 'service.create' },
        { id: 'a2', timestamp: newTs, action: 'auth.totp-verify' },
      ]));
      await fsp.writeFile(secFile, [
        JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
        JSON.stringify({ id: 's2', timestamp: newTs, severity: 'info' }),
      ].join('\n') + '\n');

      const r = await postKeepDays({ keepDays: 30, confirm: true });
      expect(r.status).toBe(200);
      expect(r.body.disposed).toBe(true);
      expect(r.body.deleted.auditEntries).toBe(1);
      expect(r.body.deleted.securityEvents).toBe(1);
      expect(r.body.remaining.auditEntries).toBe(1);
      expect(r.body.remaining.securityEvents).toBe(1);

      const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
      expect(afterAudit.map(e => e.id)).toEqual(['a2']);
      const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean).map(JSON.parse);
      expect(afterSec.map(e => e.id)).toEqual(['s2']);
    });

    test('confirm=false treated as preview (not confirm)', async () => {
      const r = await postKeepDays({ keepDays: 30, confirm: false });
      expect(r.status).toBe(200);
      expect(r.body.preview).toBe(true);
      // confirm was false, so no dispose
      expect(r.body.disposed).toBeUndefined();
    });

    test('preview response includes resolved paths so operator knows what files will be touched', async () => {
      const r = await postKeepDays({ keepDays: 30 });
      expect(r.status).toBe(200);
      expect(r.body.paths.auditPath).toBe(auditFile);
      expect(r.body.paths.secPath).toBe(secFile);
    });

    test('handles missing audit-log file gracefully on preview', async () => {
      await fsp.unlink(auditFile).catch(() => {});
      // fs.readFile().catch returns '[]', so preview reports 0 deletions
      const r = await postKeepDays({ keepDays: 30 });
      expect(r.status).toBe(200);
      expect(r.body.wouldDelete.auditEntries).toBe(0);
    });

    test('returns 500 DC-081_AUDIT_PARSE_FAILED on corrupt audit-log file', async () => {
      await fsp.writeFile(auditFile, 'this-is-not-json{');
      const r = await postKeepDays({ keepDays: 30 });
      expect(r.status).toBe(500);
      expect(r.body.code).toBe('DC-081_AUDIT_PARSE_FAILED');
    });

    test('returns 500 DC-081_AUDIT_SHAPE_INVALID if audit-log is a JSON object, not array', async () => {
      await fsp.writeFile(auditFile, JSON.stringify({ not: 'an array' }));
      const r = await postKeepDays({ keepDays: 30 });
      expect(r.status).toBe(500);
      expect(r.body.code).toBe('DC-081_AUDIT_SHAPE_INVALID');
    });

    test('DC-081 CORE: pre-fix keptDays=-1000 no longer wipes everything', async () => {
      // Sanity-test the actual fix: a negative keepDays would, pre-fix,
      // compute a cutoff in the FUTURE and then delete everything. After
      // DC-081 it's a 400 with a clear error before any file read.
      const r = await postKeepDays({ keepDays: -1000, confirm: true });
      expect(r.status).toBe(400);
      expect(r.body.success).toBe(false);
      // No file IO occurred — confirm that an unrelated existing audit
      // log file would survive. Since we already wiped tmpDir's auditFile
      // is empty, write a sentinel and confirm it's still there after.
      await fsp.writeFile(auditFile, JSON.stringify([{ id: 'sentinel', timestamp: new Date().toISOString() }]));
      const r2 = await postKeepDays({ keepDays: -1000, confirm: true });
      expect(r2.status).toBe(400);
      const after = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
      expect(after.length).toBe(1);
      expect(after[0].id).toBe('sentinel');
    });
  });
});