/**
 * Audit log viewer routes
 *
 * Exposes:
 *   GET    /api/v1/audit-logs            — paginated audit entries (auth-gated)
 *   GET    /api/v1/audit-logs/actions    — distinct action prefixes (for filter dropdowns)
 *   DELETE /api/v1/audit-logs            — clear the audit log (admin-gated)
 *
 * The frontend at status/js/audit-log.js already calls /api/v1/audit-logs
 * with {limit, offset, action=<prefix>}. Before this route existed the
 * frontend silently 404'd (see STATE.md Queue item #1, DC-050).
 *
 * Auth: same as the rest of /api/v1 — handled by the global middleware
 * (the router is mounted under the auth-gated apiRouter in app.js).
 *
 * @module routes/audit-log
 */

const express = require('express');
const { success, errorResponse } = require('../src/utils/responses');

// Action prefixes that the dashboard's filter dropdown offers + that the
// `action` query parameter will accept. Curated, NOT derived from current
// log contents — see /audit-logs/actions for the live set.
const ACTION_PREFIX_WHITELIST = [
  'service', 'container', 'caddy', 'dns', 'backup', 'config',
  'auth', 'totp', 'update', 'monitoring', 'site', 'arr', 'tailscale',
];

const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;

function parseInt10(value, fallback) {
  const n = parseInt(value, 10);
  return Number.isFinite(n) ? n : fallback;
}

function isValidActionPrefix(value) {
  return ACTION_PREFIX_WHITELIST.includes(value);
}

function isValidIso(value) {
  if (typeof value !== 'string' || value.length < 10) return false;
  return ISO8601_RE.test(value);
}

// Parse an ISO 8601 string into ms-since-epoch. Returns NaN for invalid
// input — callers must pre-validate with isValidIso(). Used to compare
// timestamps numerically (lexicographic compare breaks when the two
// strings use different offset formats).
function toEpochMs(iso) {
  const ms = Date.parse(iso);
  return ms;
}

module.exports = function({ asyncHandler, auditLogger }) {
  if (!auditLogger || typeof auditLogger.query !== 'function') {
    throw new Error('audit-log route requires auditLogger with query()');
  }

  const router = express.Router();

  // GET /audit-logs?limit=50&offset=0&action=<prefix>&since=<iso>&until=<iso>&outcome=<success|failure>
  router.get('/audit-logs', asyncHandler(async (req, res) => {
    const limit = Math.min(Math.max(parseInt10(req.query.limit, 50), 1), 500);
    const offset = Math.max(parseInt10(req.query.offset, 0), 0);
    const actionPrefix = typeof req.query.action === 'string' && req.query.action.length > 0
      ? req.query.action
      : null;
    const sinceRaw = typeof req.query.since === 'string' && req.query.since.length > 0
      ? req.query.since
      : null;
    const untilRaw = typeof req.query.until === 'string' && req.query.until.length > 0
      ? req.query.until
      : null;
    const outcome = typeof req.query.outcome === 'string' && req.query.outcome.length > 0
      ? req.query.outcome
      : null;

    if (actionPrefix !== null && !isValidActionPrefix(actionPrefix)) {
      return errorResponse(res, 400,
        `action must be one of: ${ACTION_PREFIX_WHITELIST.join(', ')}`);
    }
    if (sinceRaw !== null && !isValidIso(sinceRaw)) {
      return errorResponse(res, 400, 'since must be ISO 8601 (e.g. 2026-08-17T00:00:00Z)');
    }
    if (untilRaw !== null && !isValidIso(untilRaw)) {
      return errorResponse(res, 400, 'until must be ISO 8601 (e.g. 2026-08-18T00:00:00Z)');
    }
    if (outcome !== null && !['success', 'failure', 'unknown'].includes(outcome)) {
      return errorResponse(res, 400, 'outcome must be one of: success, failure, unknown');
    }
    const sinceMs = sinceRaw !== null ? toEpochMs(sinceRaw) : null;
    const untilMs = untilRaw !== null ? toEpochMs(untilRaw) : null;
    if (sinceMs !== null && untilMs !== null && sinceMs > untilMs) {
      return errorResponse(res, 400, 'since must be <= until');
    }

    // Pull the FULL store (capped at MAX_ENTRIES by audit-logger) so
    // date + outcome filters see the whole log, not the newest-N-only slice.
    // The store is bounded by design; a 1000-entry in-memory filter pass is
    // cheap (~tens of ms) and correct. Read the env-tunable MAX_ENTRIES so
    // operators who raise AUDIT_MAX_ENTRIES get correct filter coverage.
    const MAX_AUDIT_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
    const allEntries = await auditLogger.query({
      limit: MAX_AUDIT_ENTRIES,
      offset: 0,
      action: actionPrefix || undefined,
    });

    let filtered = allEntries;
    if (sinceMs !== null) {
      filtered = filtered.filter((e) => {
        const t = toEpochMs(e.timestamp);
        return Number.isFinite(t) && t >= sinceMs;
      });
    }
    if (untilMs !== null) {
      filtered = filtered.filter((e) => {
        const t = toEpochMs(e.timestamp);
        return Number.isFinite(t) && t <= untilMs;
      });
    }
    if (outcome !== null) {
      filtered = filtered.filter((e) => (e.outcome || 'unknown') === outcome);
    }

    const total = filtered.length;
    const page = filtered.slice(offset, offset + limit);

    return success(res, {
      entries: page,
      total,
      limit,
      offset,
      // truncated: true tells the caller the total is bounded by the
      // store's MAX_AUDIT_ENTRIES — the operator can see the whole log
      // but if more entries have been written since the last clear,
      // older rows are dropped at write-time, not at read-time.
      truncated: allEntries.length >= MAX_AUDIT_ENTRIES,
      hasMore: offset + page.length < total,
      filters: { action: actionPrefix, since: sinceRaw, until: untilRaw, outcome },
    });
  }, 'audit-logs-list'));

  // GET /audit-logs/actions — return the distinct action prefixes present
  // in the current log, INTERSECTED with the whitelist so the dropdown
  // only offers prefixes the GET /audit-logs filter will actually accept.
  router.get('/audit-logs/actions', asyncHandler(async (req, res) => {
    const maxAudit = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
    const entries = await auditLogger.query({ limit: maxAudit, offset: 0 });
    const seen = new Set();
    for (const e of entries) {
      if (!e.action) continue;
      const dot = e.action.indexOf('.');
      const prefix = dot > 0 ? e.action.slice(0, dot) : e.action;
      // Only surface prefixes that are also in the whitelist — otherwise
      // the dropdown would offer a prefix that GET /audit-logs would 400.
      if (ACTION_PREFIX_WHITELIST.includes(prefix)) seen.add(prefix);
    }
    const prefixes = Array.from(seen).sort();
    return success(res, { prefixes });
  }, 'audit-logs-actions'));

  // DELETE /audit-logs — clear the audit log. The frontend's "Clear Log"
  // button already calls DELETE /api/v1/audit-logs (status/js/audit-log.js).
  // Body must include { confirm: 'CLEAR' } as an opt-in guard against
  // accidental destructive calls.
  //
  // Forensic integrity: clear() wipes audit-log.json to []. A naive
  // "log audit.clear before clear()" leaves zero trace because clear()
  // runs after — the new entry is wiped with the rest. Fix: write the
  // audit.clear entry FIRST so it's in the buffer, then clear() the
  // store, then RE-INJECT the audit.clear entry as the single surviving
  // row. The viewer shows "1 entry: audit.clear by <user> at <ts>" — a
  // visible forensic breadcrumb that the log was just wiped.
  router.delete('/audit-logs', asyncHandler(async (req, res) => {
    const confirm = req.body?.confirm;
    if (confirm !== 'CLEAR') {
      return errorResponse(res, 400,
        'destructive op: pass { confirm: "CLEAR" } in JSON body');
    }
    const ip = req.ip || req.socket?.remoteAddress || '';
    const userAttrs = (req.user && req.user.id) ? {
      userId: req.user.id,
      userRole: req.user.role || null,
      userEmail: req.user.email || null,
    } : {};
    const clearEntry = {
      action: 'audit.clear',
      resource: 'audit-log.json',
      outcome: 'success',
      ip,
      details: {
        confirmedBy: req.body?.confirmedBy || 'dashboard',
        ...userAttrs,
      },
    };
    // Write the clear entry FIRST so it lands at index 0 of the buffer.
    // Failure is non-fatal — the operator still wants the log cleared.
    try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ }
    // Now wipe the store. The just-written audit.clear entry is wiped too.
    await auditLogger.clear();
    // Re-inject the audit.clear entry so the forensic breadcrumb survives.
    // This is the difference between "log wiped, zero trace" and
    // "log wiped, viewer shows one entry: audit.clear by X at T".
    try { await auditLogger.log(clearEntry); } catch (_) { /* non-fatal */ }
    return success(res, { cleared: true });
  }, 'audit-logs-clear'));

  return router;
};