/**
 * Per-form outbound webhooks.
 *
 * Distinct from the site-wide `WebhookEndpoint` registry: form owners can wire
 * delivery for their own forms without site-admin access. Secrets are stored
 * encrypted, URLs are SSRF-validated on write, payloads are HMAC-SHA256 signed,
 * and delivery goes through `safeFetch` (DNS-rebinding safe) with bounded
 * inline retries + exponential backoff.
 */
import { randomBytes } from 'node:crypto';
import { getDB } from '../db.js';
import { logEvent } from '../security/audit.js';
import { validateWebhookUrl } from '../security/webhook.js';
import { safeFetch, SsrfBlockedError } from '../security/safe-fetch.js';
import { encryptSecret, decryptSecret } from '../security/secret-storage.js';
import { signPayload } from '../webhooks/index.js';
import { createLogger } from '../diagnostics/logger.js';
const logger = createLogger('forms-webhooks');
export const FORM_WEBHOOK_EVENTS = [
    'form.submitted',
    'form.entry.starred',
    'form.entry.archived',
    'form.spam_detected',
];
const DEFAULT_MAX_RETRIES = 3;
const RETRY_DELAYS_MS = [1_000, 3_000, 8_000];
const DELIVERY_TIMEOUT_MS = 10_000;
const MAX_STATUS_LEN = 200;
function rawDb() {
    return getDB();
}
function toISO(value) {
    if (value instanceof Date)
        return value.toISOString();
    if (typeof value === 'string')
        return value;
    return new Date().toISOString();
}
function normalizeEvents(value) {
    const arr = Array.isArray(value) ? value : [];
    const valid = arr.filter((e) => FORM_WEBHOOK_EVENTS.includes(e));
    return valid.length > 0 ? [...new Set(valid)] : ['form.submitted'];
}
/** Parse a DB row to the public config shape. The secret is masked unless explicitly requested. */
function parseWebhook(row, includeSecret = false) {
    return {
        id: String(row.id),
        formId: String(row.formId),
        url: String(row.url),
        ...(includeSecret ? { secret: row.secret } : {}),
        enabled: Boolean(row.enabled),
        events: normalizeEvents(row.events),
        retryCount: Number(row.retryCount ?? 0),
        lastDeliveryAt: row.lastDeliveryAt ? toISO(row.lastDeliveryAt) : null,
        lastDeliveryStatus: row.lastDeliveryStatus ?? null,
        createdAt: toISO(row.createdAt),
        updatedAt: toISO(row.updatedAt),
    };
}
export class FormWebhookError extends Error {
    status;
    constructor(message, status = 400) {
        super(message);
        this.name = 'FormWebhookError';
        this.status = status;
    }
}
function generateSecret() {
    return `whsec_${randomBytes(24).toString('hex')}`;
}
export async function createFormWebhook(ctx, formId, input) {
    const urlCheck = validateWebhookUrl(input.url);
    if (!urlCheck.valid) {
        throw new FormWebhookError(`Invalid webhook URL: ${urlCheck.error}`, 400);
    }
    const secret = input.secret?.trim() || generateSecret();
    const row = await rawDb().formWebhook.create({
        data: {
            formId,
            url: input.url,
            secret: await encryptSecret(secret),
            events: normalizeEvents(input.events),
            enabled: input.enabled ?? true,
            retryCount: clampRetries(input.retryCount),
        },
    });
    await logEvent({
        event: 'form.webhook.created',
        userId: ctx.userId,
        details: { formId, webhookId: row.id, url: input.url },
    });
    // Return the plaintext secret exactly once, on creation.
    return { ...parseWebhook(row), secret };
}
export async function listFormWebhooks(formId) {
    const rows = await rawDb().formWebhook.findMany({
        where: { formId },
        orderBy: { createdAt: 'desc' },
    });
    return rows.map((r) => parseWebhook(r));
}
export async function getFormWebhook(id) {
    const row = await rawDb().formWebhook.findUnique({ where: { id } });
    return row ? parseWebhook(row) : null;
}
export async function updateFormWebhook(ctx, id, patch) {
    const existing = await rawDb().formWebhook.findUnique({ where: { id } });
    if (!existing)
        throw new FormWebhookError('Webhook not found', 404);
    if (patch.url !== undefined) {
        const urlCheck = validateWebhookUrl(patch.url);
        if (!urlCheck.valid)
            throw new FormWebhookError(`Invalid webhook URL: ${urlCheck.error}`, 400);
    }
    const data = {};
    if (patch.url !== undefined)
        data.url = patch.url;
    if (patch.events !== undefined)
        data.events = normalizeEvents(patch.events);
    if (patch.enabled !== undefined)
        data.enabled = patch.enabled;
    if (patch.retryCount !== undefined)
        data.retryCount = clampRetries(patch.retryCount);
    if (patch.secret !== undefined && patch.secret.trim()) {
        data.secret = await encryptSecret(patch.secret.trim());
    }
    const row = await rawDb().formWebhook.update({ where: { id }, data });
    await logEvent({
        event: 'form.webhook.updated',
        userId: ctx.userId,
        details: { formId: row.formId, webhookId: id },
    });
    return parseWebhook(row);
}
export async function deleteFormWebhook(ctx, id) {
    const existing = await rawDb().formWebhook.findUnique({ where: { id } });
    if (!existing)
        throw new FormWebhookError('Webhook not found', 404);
    await rawDb().formWebhook.delete({ where: { id } });
    await logEvent({
        event: 'form.webhook.deleted',
        userId: ctx.userId,
        details: { formId: existing.formId, webhookId: id },
    });
}
function clampRetries(value) {
    if (typeof value !== 'number' || !Number.isFinite(value))
        return DEFAULT_MAX_RETRIES;
    return Math.min(10, Math.max(0, Math.floor(value)));
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
 * Deliver a signed payload to one webhook URL with bounded retries. Each retry
 * waits an increasing, capped delay. Returns the final result; never throws.
 */
async function deliverOnce(url, secret, payload, maxRetries) {
    const body = JSON.stringify(payload);
    const signature = await signPayload(body, secret);
    const totalAttempts = maxRetries + 1;
    let lastError = '';
    let lastStatus = 0;
    for (let attempt = 1; attempt <= totalAttempts; attempt++) {
        try {
            const response = await safeFetch(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Actuate-Signature': signature,
                    'X-Actuate-Event': payload.event,
                    'X-Actuate-Timestamp': payload.timestamp,
                    'User-Agent': 'ActuateCMS-FormWebhook/1.0',
                },
                body,
                timeoutMs: DELIVERY_TIMEOUT_MS,
            });
            lastStatus = response.status;
            if (response.ok) {
                return { ok: true, status: response.status, attempts: attempt };
            }
            lastError = `HTTP ${response.status}`;
            // 4xx (except 408/429) won't succeed on retry — stop early.
            if (response.status >= 400 &&
                response.status < 500 &&
                response.status !== 408 &&
                response.status !== 429) {
                return { ok: false, status: response.status, attempts: attempt, error: lastError };
            }
        }
        catch (err) {
            lastStatus = 0;
            lastError =
                err instanceof SsrfBlockedError
                    ? `SSRF blocked: ${err.message}`
                    : err instanceof Error
                        ? err.message
                        : 'Connection failed';
            if (err instanceof SsrfBlockedError) {
                return { ok: false, status: 0, attempts: attempt, error: lastError };
            }
        }
        if (attempt < totalAttempts) {
            await sleep(RETRY_DELAYS_MS[attempt - 1] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]);
        }
    }
    return { ok: false, status: lastStatus, attempts: totalAttempts, error: lastError };
}
async function recordDelivery(id, result) {
    const status = (result.ok ? 'success' : `failed: ${result.error ?? result.status}`).slice(0, MAX_STATUS_LEN);
    try {
        await rawDb().formWebhook.update({
            where: { id },
            data: { lastDeliveryAt: new Date(), lastDeliveryStatus: status },
        });
    }
    catch (err) {
        logger.error('failed to record webhook delivery', {
            webhookId: id,
            reason: err instanceof Error ? err.message : String(err),
        });
    }
}
/**
 * Dispatch an event to all enabled webhooks for a form that subscribe to it.
 * Best-effort and non-throwing — intended to run in the request's async tail.
 */
export async function dispatchFormWebhooks(formId, payload) {
    let rows;
    try {
        rows = await rawDb().formWebhook.findMany({ where: { formId, enabled: true } });
    }
    catch (err) {
        logger.error('failed to load webhooks', {
            formId,
            reason: err instanceof Error ? err.message : String(err),
        });
        return { matched: 0, delivered: 0 };
    }
    const matching = rows.filter((r) => normalizeEvents(r.events).includes(payload.event));
    if (matching.length === 0)
        return { matched: 0, delivered: 0 };
    const full = { ...payload, timestamp: new Date().toISOString() };
    let delivered = 0;
    for (const row of matching) {
        let secret;
        try {
            secret = await decryptSecret(row.secret);
        }
        catch (err) {
            logger.error('failed to decrypt webhook secret', {
                webhookId: row.id,
                reason: err instanceof Error ? err.message : String(err),
            });
            continue;
        }
        const result = await deliverOnce(row.url, secret, full, clampRetries(row.retryCount));
        await recordDelivery(row.id, result);
        if (result.ok)
            delivered++;
    }
    return { matched: matching.length, delivered };
}
/** Send a sample payload to a single webhook to verify configuration. */
export async function testFormWebhook(ctx, id) {
    const row = await rawDb().formWebhook.findUnique({ where: { id } });
    if (!row)
        throw new FormWebhookError('Webhook not found', 404);
    let secret;
    try {
        secret = await decryptSecret(row.secret);
    }
    catch {
        throw new FormWebhookError('Unable to read webhook secret', 500);
    }
    const payload = {
        event: 'form.submitted',
        timestamp: new Date().toISOString(),
        formId: row.formId,
        submissionId: 'test_submission',
        data: { _test: true, message: 'This is a test delivery from Actuate CMS.' },
    };
    const result = await deliverOnce(row.url, secret, payload, 0);
    await recordDelivery(id, result);
    await logEvent({
        event: 'form.webhook.tested',
        userId: ctx.userId,
        details: { formId: row.formId, webhookId: id, ok: result.ok, status: result.status },
    });
    return result;
}
//# sourceMappingURL=webhooks.js.map