/**
 * Outbound webhook dispatcher.
 *
 * Fetches active webhooks matching a given event, sends POST requests
 * with JSON payloads, and optionally signs them with HMAC-SHA256.
 *
 * Fire-and-forget — errors are logged, never thrown.
 */
import { createHmac } from "node:crypto";
import { logger } from "./logger.js";
import { sendSlackNotification } from "./slack-notify.js";
import { assertWebhookUrlAllowed } from "./ssrf-guard.js";

/** Available webhook event types. */
export const WEBHOOK_EVENTS = [
  "allocation.created",
  "allocation.updated",
  "allocation.deleted",
  "project.created",
  "project.status_changed",
  "vacation.approved",
  "estimate.submitted",
  "estimate.approved",
] as const;

export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];

interface MinimalDb {
  webhook: {
    findMany: (args: {
      where: { isActive: boolean; events: { has: string } };
    }) => Promise<
      Array<{
        id: string;
        name: string;
        url: string;
        secret: string | null;
        events: string[];
      }>
    >;
  };
}

/**
 * Dispatch an event to all matching active webhooks.
 * This is fire-and-forget: errors are logged and swallowed.
 */
export function dispatchWebhooks(
  db: MinimalDb,
  event: string,
  payload: Record<string, unknown>,
): void {
  void _dispatch(db, event, payload);
}

async function _dispatch(
  db: MinimalDb,
  event: string,
  payload: Record<string, unknown>,
): Promise<void> {
  try {
    const webhooks = await db.webhook.findMany({
      where: { isActive: true, events: { has: event } },
    });

    if (webhooks.length === 0) return;

    const timestamp = new Date().toISOString();
    const body = JSON.stringify({ event, timestamp, payload });

    const promises = webhooks.map((wh) =>
      _sendToWebhook(wh, event, body, timestamp, payload),
    );

    await Promise.allSettled(promises);
  } catch (err) {
    logger.error({ err, event }, "Failed to dispatch webhooks");
  }
}

async function _sendToWebhook(
  wh: { id: string; name: string; url: string; secret: string | null },
  event: string,
  body: string,
  timestamp: string,
  payload: Record<string, unknown>,
): Promise<void> {
  try {
    await assertWebhookUrlAllowed(wh.url);

    // Slack-specific path: use the Slack notification helper.
    // Use strict hostname match to prevent bypass via "hooks.slack.com.attacker.example.com".
    const parsedUrl = new URL(wh.url);
    if (parsedUrl.hostname === "hooks.slack.com") {
      const message = formatSlackMessage(event, payload);
      await sendSlackNotification(wh.url, message);
      return;
    }

    const headers: Record<string, string> = {
      "Content-Type": "application/json",
      "X-Webhook-Event": event,
      "X-Webhook-Timestamp": timestamp,
    };

    if (wh.secret) {
      const signature = createHmac("sha256", wh.secret)
        .update(body)
        .digest("hex");
      headers["X-Webhook-Signature"] = signature;
    }

    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 5_000);

    try {
      const response = await fetch(wh.url, {
        method: "POST",
        headers,
        body,
        signal: controller.signal,
      });

      if (!response.ok) {
        throw new Error(`Webhook responded with HTTP ${response.status}`);
      }
    } finally {
      clearTimeout(timeout);
    }
  } catch (err) {
    logger.warn(
      { err, event, webhookId: wh.id, webhookName: wh.name, webhookUrl: wh.url },
      "Webhook delivery failed",
    );
  }
}

/**
 * Format a human-readable Slack message from a webhook event.
 */
function formatSlackMessage(
  event: string,
  payload: Record<string, unknown>,
): string {
  const label = event.replace(/\./g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
  const id = (payload["id"] as string) ?? (payload["projectId"] as string) ?? "";
  const name = (payload["name"] as string) ?? "";
  const parts = [`*${label}*`];
  if (name) parts.push(`\u2022 ${name}`);
  if (id) parts.push(`ID: \`${id}\``);
  return parts.join("\n");
}
