/**
 * src/server/webhookAuth.js
 *
 * Webhook authentication middleware for ServiceChannel (and generic HMAC
 * webhooks).
 *
 * Per ServiceChannel's docs
 *   https://developer.servicechannel.com/guides/wh/receive-events-and-respond/
 * each webhook request includes:
 *   Sign-Type: HMACSHA256
 *   Sign-Data: <base64(HMAC-SHA256(rawBody, signingKey))>
 *
 * The Signing Key is fetched from
 *   GET /v3/NotificationSubscriptions/SigningKey
 * (or copied out of the SC UI). Paste it into SC_WEBHOOK_SIGNING_SECRET and
 * the defaults below match SC's format — you shouldn't need to override any
 * other variable.
 *
 * Modes (via SC_WEBHOOK_AUTH_MODE):
 *   "off"     — do nothing. Every request is accepted. (default)
 *   "log"     — validate if credentials are set; log successes/failures with
 *               full diagnostics but still accept every request. Use this to
 *               confirm signatures line up before flipping to enforce.
 *   "enforce" — reject requests that fail validation with 401.
 *
 * Discovery aids (log mode only):
 *   - Auto-probes several known signature header names (SC's `sign-data`
 *     first) so a misconfigured header name doesn't silently fail.
 *   - Tries both base64 and hex encodings so you can't get bitten by that.
 *   - Reads the `Sign-Type` header when present to auto-pick the algorithm.
 *   - When nothing matches, logs the incoming x- and sign- headers
 *     (truncated) so you can see what SC actually sent.
 *
 * Configuration (all optional):
 *   SC_WEBHOOK_AUTH_MODE          off | log | enforce   (default: off)
 *   SC_WEBHOOK_SIGNING_SECRET     Signing Key from ServiceChannel
 *   SC_WEBHOOK_SIGNATURE_HEADER   HTTP header name         (default: sign-data)
 *   SC_WEBHOOK_SIGNATURE_PREFIX   e.g. "sha256=" if platform prefixes digest
 *   SC_WEBHOOK_SIGNATURE_ENCODING base64 | hex | auto     (default: auto)
 *   SC_WEBHOOK_SIGNATURE_ALGO     sha256 | sha1 | sha512  (default: sha256;
 *                                                          overridden by
 *                                                          Sign-Type header
 *                                                          when present)
 *   SC_WEBHOOK_TOKEN              Static shared secret (alternative to HMAC)
 *   SC_WEBHOOK_TOKEN_HEADER       HTTP header name         (default: x-webhook-token)
 *
 * IMPORTANT: for signature verification to work, the Express body parser MUST
 * capture the raw body. In app.js we already use
 *   bodyParser.json({ verify: (req, res, buf) => { req.rawBody = buf; } })
 * so `req.rawBody` is a Buffer of the raw JSON. If that ever changes, HMAC
 * checks will silently start failing — the middleware logs a WARN in that case.
 */

import crypto from 'node:crypto';
import { logger } from '../utils/logger.js';

// SC's own header comes first; the rest are common alternates so log-mode
// discovery still catches things even if the platform tweaks its scheme.
const KNOWN_SIG_HEADER_CANDIDATES = [
  'sign-data',
  'x-sc-signature',
  'x-servicechannel-signature',
  'x-webhook-signature',
  'x-hub-signature-256',
  'x-hub-signature',
  'x-signature',
  'signature',
];

/**
 * Map ServiceChannel's `Sign-Type: HMACSHA256` (and similar) to the algorithm
 * name Node's crypto module wants.
 */
function algoFromSignType(signType) {
  if (typeof signType !== 'string') return null;
  const s = signType.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
  if (s === 'HMACSHA256' || s === 'SHA256') return 'sha256';
  if (s === 'HMACSHA1'   || s === 'SHA1')   return 'sha1';
  if (s === 'HMACSHA512' || s === 'SHA512') return 'sha512';
  return null;
}

function getMode() {
  const raw = (process.env.SC_WEBHOOK_AUTH_MODE || 'off').toLowerCase().trim();
  if (raw === 'log' || raw === 'enforce' || raw === 'off') return raw;
  return 'off';
}

function timingSafeEqStr(a, b) {
  if (typeof a !== 'string' || typeof b !== 'string') return false;
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}

function stripPrefix(sig, prefix) {
  if (!prefix) return sig;
  return sig.startsWith(prefix) ? sig.slice(prefix.length) : sig;
}

/**
 * Compute HMAC digests for the raw body under the configured algorithm.
 * Returns { hex, base64 } so we can compare against either encoding.
 */
function computeDigests(secret, rawBody, algo) {
  const hex = crypto.createHmac(algo, secret).update(rawBody).digest('hex');
  const base64 = crypto.createHmac(algo, secret).update(rawBody).digest('base64');
  return { hex, base64 };
}

function checkSignature(req) {
  const secret = process.env.SC_WEBHOOK_SIGNING_SECRET;
  if (!secret) return { attempted: false };

  if (!req.rawBody || !Buffer.isBuffer(req.rawBody)) {
    return { attempted: true, ok: false, reason: 'rawBody not captured (bodyParser misconfigured)' };
  }

  // If the sender advertises Sign-Type (SC does: "HMACSHA256"), let that pick
  // the algorithm — it's more authoritative than any env var we set. Fall
  // back to SC_WEBHOOK_SIGNATURE_ALGO or sha256.
  const declaredAlgo = algoFromSignType(req.headers['sign-type']);
  const algo = declaredAlgo || (process.env.SC_WEBHOOK_SIGNATURE_ALGO || 'sha256').toLowerCase();

  const prefix = process.env.SC_WEBHOOK_SIGNATURE_PREFIX || '';
  const encPref = (process.env.SC_WEBHOOK_SIGNATURE_ENCODING || 'auto').toLowerCase();
  const configuredHeader = (process.env.SC_WEBHOOK_SIGNATURE_HEADER || 'sign-data').toLowerCase();

  // 1. Try the configured header first.
  let providedHeaderName = configuredHeader;
  let provided = req.headers[configuredHeader];

  // 2. If the configured header is absent, opportunistically probe the well-
  //    known candidate list (this makes log-mode discovery easier — we can
  //    tell the user which header actually carries the signature).
  if (typeof provided !== 'string') {
    for (const cand of KNOWN_SIG_HEADER_CANDIDATES) {
      if (cand === configuredHeader) continue;
      const v = req.headers[cand];
      if (typeof v === 'string' && v.length > 0) {
        provided = v;
        providedHeaderName = cand;
        break;
      }
    }
  }

  if (typeof provided !== 'string') {
    return {
      attempted: true,
      ok: false,
      reason: `no signature header found (looked for: ${configuredHeader}, ${KNOWN_SIG_HEADER_CANDIDATES.filter(h => h !== configuredHeader).join(', ')})`,
      headerName: null,
    };
  }

  const stripped = stripPrefix(provided.trim(), prefix);

  let digests;
  try {
    digests = computeDigests(secret, req.rawBody, algo);
  } catch (err) {
    return { attempted: true, ok: false, reason: `hmac compute failed (${algo}): ${err.message}`, headerName: providedHeaderName };
  }

  // Try both encodings unless the caller pinned one.
  const tryHex    = encPref === 'auto' || encPref === 'hex';
  const tryBase64 = encPref === 'auto' || encPref === 'base64';

  let matchedEncoding = null;
  if (tryHex    && timingSafeEqStr(stripped, digests.hex))    matchedEncoding = 'hex';
  if (!matchedEncoding && tryBase64 && timingSafeEqStr(stripped, digests.base64)) matchedEncoding = 'base64';

  return {
    attempted: true,
    ok: !!matchedEncoding,
    reason: matchedEncoding ? null : 'hmac mismatch',
    headerName: providedHeaderName,
    headerNameMatchesConfig: providedHeaderName === configuredHeader,
    encoding: matchedEncoding,
    algo,
    // Only expose the leading chars in logs — never dump full signatures.
    providedPreview: provided.substring(0, 16) + (provided.length > 16 ? '…' : ''),
    expectedHexPreview:    digests.hex.substring(0, 16)    + '…',
    expectedBase64Preview: digests.base64.substring(0, 16) + '…',
  };
}

function checkStaticToken(req) {
  const token = process.env.SC_WEBHOOK_TOKEN;
  if (!token) return { attempted: false };

  const headerName = (process.env.SC_WEBHOOK_TOKEN_HEADER || 'x-webhook-token').toLowerCase();
  const provided = req.headers[headerName];
  if (!provided || typeof provided !== 'string') {
    return { attempted: true, ok: false, reason: `missing header ${headerName}` };
  }
  return {
    attempted: true,
    ok: timingSafeEqStr(provided, token),
    reason: 'token mismatch',
  };
}

function summarizeIncomingHeaders(req) {
  // Only show headers that could plausibly carry auth info, and cap values
  // so we don't spill full signatures / tokens into the log every request.
  const interesting = {};
  for (const [name, value] of Object.entries(req.headers)) {
    const lc = name.toLowerCase();
    const isInteresting =
      /^x-/.test(lc) ||
      lc === 'authorization' ||
      lc === 'signature' ||
      lc.startsWith('sign-'); // catches SC's Sign-Data / Sign-Type
    if (!isInteresting) continue;
    const v = Array.isArray(value) ? value.join(',') : String(value);
    interesting[name] = v.length > 24 ? v.substring(0, 24) + `…(${v.length} chars)` : v;
  }
  return interesting;
}

/**
 * Express middleware. Adds:
 *   req.webhookAuth = { mode, valid, checks: [...] }
 * so downstream handlers can log/observe. Only rejects requests when mode === 'enforce'.
 */
export function verifyWebhook(req, res, next) {
  const mode = getMode();

  const sig = checkSignature(req);
  const tok = checkStaticToken(req);

  // Nothing configured to check — bail early.
  if (!sig.attempted && !tok.attempted) {
    req.webhookAuth = { mode, valid: null, checks: [] };
    if (mode === 'enforce') {
      logger('webhookAuth', 'enforce mode with no SC_WEBHOOK_SIGNING_SECRET or SC_WEBHOOK_TOKEN set — refusing request', 'error');
      return res.status(500).json({ error: 'webhook auth misconfigured' });
    }
    return next();
  }

  const anyOk = (sig.attempted && sig.ok) || (tok.attempted && tok.ok);

  req.webhookAuth = {
    mode,
    valid: anyOk,
    checks: [
      sig.attempted ? { name: 'signature', ok: !!sig.ok, reason: sig.ok ? null : sig.reason, meta: {
        headerName: sig.headerName,
        headerNameMatchesConfig: sig.headerNameMatchesConfig,
        encoding: sig.encoding,
        algo: sig.algo,
      }} : null,
      tok.attempted ? { name: 'token', ok: !!tok.ok, reason: tok.ok ? null : tok.reason } : null,
    ].filter(Boolean),
  };

  if (anyOk) {
    if (mode !== 'off') {
      const details = [];
      if (sig.attempted && sig.ok) {
        details.push(`sig[header=${sig.headerName}${sig.headerNameMatchesConfig ? '' : '/UNCONFIGURED'} algo=${sig.algo} enc=${sig.encoding}]`);
      }
      if (tok.attempted && tok.ok) details.push('token');
      logger('webhookAuth', `OK ${details.join(' ')} mode=${mode}`);

      // If the signature came in on a header we weren't configured for, nudge
      // the operator to pin it explicitly so we stop probing every request.
      if (sig.attempted && sig.ok && !sig.headerNameMatchesConfig) {
        logger('webhookAuth',
          `Detected signature on header "${sig.headerName}" but SC_WEBHOOK_SIGNATURE_HEADER is set to "${(process.env.SC_WEBHOOK_SIGNATURE_HEADER || 'sign-data').toLowerCase()}". Update .env to pin it.`,
          'warn'
        );
      }
    }
    return next();
  }

  // Something failed — write a detailed diagnostic in log mode.
  const failedReasons = [];
  if (sig.attempted && !sig.ok) failedReasons.push(`sig:${sig.reason}`);
  if (tok.attempted && !tok.ok) failedReasons.push(`tok:${tok.reason}`);

  if (mode === 'log') {
    logger('webhookAuth', `WOULD-REJECT (log-only): ${failedReasons.join('; ')}`, 'warn');
    if (sig.attempted && !sig.ok) {
      logger('webhookAuth',
        `  provided[${sig.headerName || '?'}]=${sig.providedPreview || '(none)'}  ` +
        `expected.hex=${sig.expectedHexPreview || 'n/a'}  ` +
        `expected.b64=${sig.expectedBase64Preview || 'n/a'}`,
        'warn'
      );
      const headers = summarizeIncomingHeaders(req);
      logger('webhookAuth', `  incoming x-headers: ${JSON.stringify(headers)}`, 'warn');
    }
  } else if (mode === 'enforce') {
    logger('webhookAuth', `REJECT: ${failedReasons.join('; ')}`, 'warn');
    return res.status(401).json({ error: 'unauthorized webhook' });
  }
  return next();
}

export default verifyWebhook;
