/**
 * db/op-audit.js — control-action audit trail for EXECUTING routes.
 *
 * Owns: audit_log writes for operations that change state on a customer server —
 *       db-ops, ebs-ops, ebs-middleware, ebs-concurrent, os-exec.
 * Does NOT own: the table itself (shared with db/ebs-control.js, which logs
 *       ebs-control PREVIEW attempts), nor authorization decisions.
 *
 * WHY THIS EXISTS
 * ---------------
 * Until 2026-08-11 none of the executing routes wrote an audit row at all. A
 * `db_shutdown_abort` through /api/db-ops/run, or a middleware bounce through
 * /api/ebs-ops/middleware-run, left no record of who ran it, against which
 * server, with what parameters, or whether it worked — while /security claimed
 * "Every control action is audit-logged".
 *
 * FAILURE SEMANTICS — the point of the whole module
 * -------------------------------------------------
 * `beginOpAudit()` is the GATE. It is awaited and it THROWS. Callers must let it
 * throw and must not execute the operation if it does:
 *
 *     let auditId;
 *     try {
 *       auditId = await beginOpAudit({ ... });
 *     } catch (e) {
 *       return res.status(503).json({ error: 'Audit unavailable — operation refused' });
 *     }
 *     // ... only now may the operation run
 *
 * Writing AFTER execution would reopen the exact hole this closes: a shutdown
 * that succeeds while the audit insert fails leaves no trace. So the attempt is
 * durable before anything is dispatched, and if it cannot be made durable the
 * operation does not happen. Refusing to act is the safe failure here — the
 * caller is asking to stop a database, not to read one.
 *
 * `completeOpAudit()` is the opposite: it NEVER throws. By the time it runs the
 * operation has already happened and the attempt row is already on disk, so a
 * failure to record the outcome must not turn a successful operation into a 500.
 * The row simply stays `pending`, which is itself a signal worth alerting on.
 *
 * Append-only nuance: completion is an UPDATE of outcome/outcome_detail/
 * completed_at on the attempt row — nothing is erased and no identifying field
 * is mutated, but it is not a strict append. The alternative (a second row per
 * operation) was rejected as it doubles volume and makes "did it finish" a join.
 */

'use strict';

const pool = require('./index');

/** Cap free-text so a huge stderr can never be the reason an audit write fails. */
const DETAIL_MAX = 3900;

function truncate(s, max = DETAIL_MAX) {
  if (s == null) return null;
  const str = typeof s === 'string' ? s : String(s);
  return str.length > max ? str.slice(0, max - 1) + '…' : str;
}

/**
 * Redact anything password-shaped before parameters reach the audit row.
 * EBS ops carry apps_pwd / weblogic_pwd in their payloads; an audit trail that
 * leaks credentials is worse than no audit trail.
 */
const SECRET_KEY_RE = /pwd|pass|secret|token|key|credential/i;

function redact(params) {
  if (!params || typeof params !== 'object') return {};
  const out = {};
  for (const [k, v] of Object.entries(params)) {
    if (SECRET_KEY_RE.test(k)) { out[k] = '[redacted]'; continue; }
    if (v && typeof v === 'object' && !Array.isArray(v)) { out[k] = redact(v); continue; }
    out[k] = typeof v === 'string' ? truncate(v, 500) : v;
  }
  return out;
}

/**
 * Record an ATTEMPT before the operation runs. Awaited; throws on failure.
 *
 * @param {Object}  o
 * @param {number|null} o.userId
 * @param {number|null} o.connectionId  — target server
 * @param {string}  o.action            — route family, e.g. 'db_ops.run'
 * @param {string}  o.opKey             — the operation, e.g. 'db_shutdown_abort'
 * @param {Object}  [o.params]          — full parameters (redacted here)
 * @param {string}  [o.sourceIp]
 * @param {string}  [o.userAgent]
 * @returns {Promise<number|null>} audit row id, to hand to completeOpAudit()
 */
async function beginOpAudit({ userId, connectionId, action, opKey, params = {}, sourceIp = null, userAgent = null }) {
  const ins = await pool.query(
    `INSERT INTO audit_log
       (user_id, connection_id, action, slug, allowed, source_ip, outcome, metadata)
     VALUES (:p1, :p2, :p3, :p4, :p5, :p6, 'pending', :p7)
     RETURNING id INTO :out_id`,
    {
      p1: userId || null,
      p2: connectionId || null,
      p3: action,
      p4: opKey ? String(opKey).slice(0, 200) : null,
      // `allowed` is NOT NULL and means "passed authorization to be attempted".
      // Every row written here has already cleared its route's auth + whitelist.
      p5: pool.bool(true),
      p6: sourceIp ? String(sourceIp).slice(0, 45) : null,
      p7: JSON.stringify({ params: redact(params), ua: truncate(userAgent, 300) }),
      out_id: pool.outNumber(),
    }
  );
  return ins.outBinds.out_id[0];
}

/**
 * Record the OUTCOME once the operation has finished. Never throws.
 *
 * @param {number|null} auditId  — from beginOpAudit(); no-op when null
 * @param {Object} o
 * @param {'success'|'failed'|'blocked'} o.outcome
 * @param {string} [o.detail]    — exit code, ORA error, stderr tail
 */
async function completeOpAudit(auditId, { outcome, detail = null }) {
  if (!auditId) return;
  try {
    await pool.query(
      `UPDATE audit_log
          SET outcome = :p1, outcome_detail = :p2, completed_at = CURRENT_TIMESTAMP
        WHERE id = :p3`,
      { p1: String(outcome).slice(0, 32), p2: truncate(detail), p3: auditId }
    );
  } catch (err) {
    // Deliberately swallowed — see module header. The attempt row is already
    // durable; losing the outcome must not fail an operation that has run.
    console.warn(JSON.stringify({
      level: 'warn', msg: 'op-audit.complete failed', auditId, err: err.message,
    }));
  }
}

/**
 * Attach outcome recording to the response lifecycle.
 *
 * These handlers have many exit points (validation 4xx, agent-offline 503, the
 * happy path, thrown errors caught by Express). Completing at each one by hand
 * would guarantee a missed branch, and a missed branch means a row stuck at
 * `pending` forever. `finish` fires exactly once for every one of them.
 *
 * Outcome is derived from the HTTP status, with an override for handlers that
 * answer 200 while the operation itself failed — the proxy SQL path returns
 * `200 {ok:false}` on an ORA error. Those set `res.locals.opFailed = true`
 * (and optionally `res.locals.opDetail`) before responding.
 */
function completeOnFinish(res, auditId) {
  if (!auditId) return;
  res.on('finish', () => {
    const failed = res.statusCode >= 400 || res.locals?.opFailed === true;
    completeOpAudit(auditId, {
      outcome: failed ? 'failed' : 'success',
      detail : res.locals?.opDetail || `http_${res.statusCode}`,
    });
  });
}

module.exports = { beginOpAudit, completeOpAudit, completeOnFinish, redact };
