/**
 * @file Universal webhook delivery for fired alerts. A "target" is an outbound
 * destination described by the provider registry (server/lib/webhook-providers.js)
 * — Slack, Discord, Teams, Mattermost, Rocket.Chat, Telegram, PagerDuty,
 * Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream, or a generic endpoint.
 * When the alerting engine fires an alert (server/lib/alerts.js), it calls
 * dispatchAlert(), which formats the provider-native payload and POSTs it to
 * every enabled target (optionally scoped to specific rules) with a timeout and
 * bounded retry/backoff. Every attempt-chain is recorded in webhook_deliveries.
 *
 * Delivery is detached and fully fail-safe: it never throws into, slows, or
 * blocks the alert path or hook ingestion.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const crypto = require("crypto");
const { stmts } = require("../db");
const {
  PROVIDERS,
  WEBHOOK_TYPES,
  isGenericFamily,
  resolveUrl,
  resolveAuthHeaders,
  formatPayload,
  truncate,
} = require("./webhook-providers");

// Tunables (env-overridable so tests can shrink timeouts/backoff). All read at
// module load — restart to change.
function posEnv(name, fallback) {
  const raw = parseInt(process.env[name], 10);
  return Number.isFinite(raw) && raw > 0 ? raw : fallback;
}
const TIMEOUT_MS = posEnv("WEBHOOK_TIMEOUT_MS", 10_000);
const MAX_ATTEMPTS = posEnv("WEBHOOK_MAX_ATTEMPTS", 3);
const RETRY_BASE_MS = posEnv("WEBHOOK_RETRY_BASE_MS", 1500);

// Enabled-target cache. Alert fires are hot; targets only change through the
// CRUD routes, which call invalidateWebhookCache().
let targetsCache = null;

function invalidateWebhookCache() {
  targetsCache = null;
}

/** Parse the JSON columns and coerce the enabled flag for a raw target row. */
function normalizeTarget(row) {
  if (!row) return null;
  let headers = null;
  let ruleIds = null;
  let config = null;
  try {
    headers = row.headers ? JSON.parse(row.headers) : null;
  } catch {
    /* tolerate hand-edited bad JSON — extra headers simply not applied */
  }
  try {
    ruleIds = row.rule_ids ? JSON.parse(row.rule_ids) : null;
  } catch {
    /* tolerate bad JSON — target falls back to "all rules" */
  }
  try {
    config = row.config ? JSON.parse(row.config) : null;
  } catch {
    /* tolerate bad JSON — provider config falls back to empty */
  }
  return { ...row, enabled: row.enabled === 1, headers, rule_ids: ruleIds, config };
}

function loadEnabledTargets() {
  if (targetsCache) return targetsCache;
  targetsCache = stmts.listEnabledWebhookTargets.all().map(normalizeTarget);
  return targetsCache;
}

/**
 * Build the HTTP request for a target + alert: resolved URL, provider-native
 * serialized body, and headers (provider auth headers, plus custom headers and
 * an optional HMAC-SHA256 signature for the generic family). Exported for tests.
 */
function buildRequest(target, alert) {
  const url = resolveUrl(target);
  if (!url) throw new Error(`no URL resolved for webhook type "${target.type}"`);

  const payload = formatPayload(target.type, alert, target.config || {});
  const body = JSON.stringify(payload);

  const headers = {
    "Content-Type": "application/json",
    "User-Agent": "claude-code-agent-monitor/webhooks",
    ...resolveAuthHeaders(target),
  };

  if (isGenericFamily(target.type)) {
    if (target.headers && typeof target.headers === "object") {
      for (const [k, v] of Object.entries(target.headers)) {
        if (typeof k !== "string" || typeof v !== "string") continue;
        // Never let a custom header clobber Content-Type or the signature.
        const lower = k.toLowerCase();
        if (lower === "content-type" || lower === "x-webhook-signature") continue;
        headers[k] = v;
      }
    }
    if (target.secret) {
      const ts = new Date().toISOString();
      const sig = crypto.createHmac("sha256", target.secret).update(`${ts}.${body}`).digest("hex");
      headers["X-Webhook-Timestamp"] = ts;
      headers["X-Webhook-Signature"] = `sha256=${sig}`;
    }
  }

  return { url, body, headers };
}

// ── Delivery ────────────────────────────────────────────────────────────────

function sleep(ms) {
  return new Promise((resolve) => {
    const t = setTimeout(resolve, ms);
    if (t.unref) t.unref();
  });
}

async function postOnce(url, body, headers) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
  if (timer.unref) timer.unref();
  try {
    const res = await fetch(url, {
      method: "POST",
      headers,
      body,
      signal: controller.signal,
      redirect: "follow",
    });
    // Read the response body — some providers (Splunk On-Call) signal failure
    // in the body despite a 200, so deliver() may need to inspect it. Also
    // frees the socket promptly. (Named distinctly from the `body` param.)
    let responseBody = "";
    try {
      responseBody = await res.text();
    } catch {
      /* body read is best-effort */
    }
    return {
      ok: res.status >= 200 && res.status < 300,
      status: res.status,
      error: null,
      body: responseBody,
    };
  } catch (err) {
    const timedOut = err?.name === "AbortError";
    return {
      ok: false,
      status: null,
      error: timedOut ? "timeout" : err?.message || "network error",
      body: "",
    };
  } finally {
    clearTimeout(timer);
  }
}

function recordDelivery(target, alertId, { status, statusCode, attempts, error }) {
  try {
    stmts.insertWebhookDelivery.run(
      target.id,
      target.name,
      target.type,
      alertId == null ? null : alertId,
      status,
      statusCode == null ? null : statusCode,
      attempts,
      error == null ? null : truncate(error, 500)
    );
    stmts.pruneWebhookDeliveries.run();
  } catch (err) {
    console.warn("[WEBHOOK] delivery log write failed:", err?.message || err);
  }
}

/**
 * Deliver one alert to one target with bounded retry. Retries on transport
 * errors, HTTP 429, and 5xx; gives up immediately on other 4xx (misconfigured
 * URL / bad payload won't fix themselves). Always records the outcome and
 * never throws. Returns `{ ok, status, attempts, error }`.
 */
async function deliver(target, alert) {
  let built;
  try {
    built = buildRequest(target, alert);
  } catch (err) {
    recordDelivery(target, alert.id, {
      status: "failed",
      statusCode: null,
      attempts: 0,
      error: `request build failed: ${err?.message || err}`,
    });
    return { ok: false, status: null, attempts: 0, error: "request build failed" };
  }

  let attempts = 0;
  let status = null;
  let error = null;
  const verifyResponse = PROVIDERS[target.type]?.verifyResponse;

  while (attempts < MAX_ATTEMPTS) {
    attempts += 1;
    const res = await postOnce(built.url, built.body, built.headers);
    status = res.status;
    error = res.error;
    if (res.ok) {
      // Some providers (Splunk On-Call) return 200 even on rejection — let the
      // provider veto a "successful" status by inspecting the response body.
      const verdict = verifyResponse ? verifyResponse(res.body) : { ok: true };
      if (verdict.ok) {
        recordDelivery(target, alert.id, {
          status: "success",
          statusCode: status,
          attempts,
          error: null,
        });
        return { ok: true, status, attempts };
      }
      // A logical rejection won't fix on retry — fail immediately.
      error = verdict.error || "provider reported failure";
      break;
    }
    const retryable = status == null || status === 429 || status >= 500;
    if (!retryable || attempts >= MAX_ATTEMPTS) break;
    await sleep(RETRY_BASE_MS * attempts);
  }

  recordDelivery(target, alert.id, {
    status: "failed",
    statusCode: status,
    attempts,
    error: error || (status ? `HTTP ${status}` : "request failed"),
  });
  return {
    ok: false,
    status,
    attempts,
    error: error || (status ? `HTTP ${status}` : "request failed"),
  };
}

/** A target receives an alert when it has no rule scope, or the alert's rule is in scope. */
function targetAppliesTo(target, alert) {
  if (!Array.isArray(target.rule_ids) || target.rule_ids.length === 0) return true;
  return target.rule_ids.includes(alert.rule_id);
}

/**
 * Fan an alert out to every enabled, in-scope target. Returns a promise that
 * settles when all deliveries finish (used by tests); callers in the alert
 * path invoke it fire-and-forget. Never rejects.
 */
function dispatchAlert(alert) {
  let targets;
  try {
    targets = loadEnabledTargets();
  } catch (err) {
    console.warn("[WEBHOOK] target load failed:", err?.message || err);
    return Promise.resolve([]);
  }
  const applicable = targets.filter((t) => {
    try {
      return targetAppliesTo(t, alert);
    } catch {
      return false;
    }
  });
  if (applicable.length === 0) return Promise.resolve([]);
  return Promise.allSettled(applicable.map((t) => deliver(t, alert)));
}

/**
 * Send a synthetic test alert to a single (already DB-loaded, un-redacted)
 * target. Awaits the result so the route can report success/failure inline.
 */
function sendTest(target) {
  const alert = {
    id: null,
    rule_id: null,
    rule_name: "Webhook test",
    rule_type: "test",
    session_id: null,
    agent_id: null,
    message: `Test notification from Claude Code Agent Monitor to "${target.name}". If you can read this, delivery works.`,
    details: { test: true, target: target.name, type: target.type },
    triggered_at: new Date().toISOString(),
  };
  return deliver(target, alert);
}

module.exports = {
  PROVIDERS,
  WEBHOOK_TYPES,
  invalidateWebhookCache,
  loadEnabledTargets,
  normalizeTarget,
  formatPayload,
  buildRequest,
  deliver,
  dispatchAlert,
  sendTest,
  targetAppliesTo,
};
