/**
 * Public submission pipeline.
 *
 * Orchestrates the full lifecycle for an incoming public submission:
 *   resolve schema → validate + normalize → sanitize → score spam →
 *   derive sender/preview → assign entry number (transaction) → persist →
 *   attach files → fire plugin hooks → audit log → build confirmation.
 *
 * The HTTP layer (`api/handlers.ts`) is responsible only for parsing the
 * request (JSON or multipart), resolving the form, rate-limiting, CORS, and
 * optional CAPTCHA verification. All trust decisions live here so the same
 * pipeline backs every entry point (public slug route, legacy id route, and
 * the future page-builder block).
 */
import { getDB } from '../db.js';
import { logEvent } from '../security/audit.js';
import { hashClientIp } from '../security/client-ip.js';
import { getActuateConfig } from '../config/runtime.js';
import { getSchemaVersion } from './service.js';
import { validateAndNormalizeSubmission } from './validation.js';
import { scoreSubmissionSpam } from './spam.js';
import { persistSubmissionFiles } from './files.js';
import { sendFormNotifications } from './notify.js';
import { dispatchFormWebhooks } from './webhooks.js';
import { encryptSubmissionData } from './encryption.js';
/** Thrown when field validation fails; mapped to HTTP 422 by the API layer. */
export class SubmissionValidationError extends Error {
    fieldErrors;
    constructor(fieldErrors) {
        super('Submission validation failed');
        this.name = 'SubmissionValidationError';
        this.fieldErrors = fieldErrors;
    }
}
const DEFAULT_SUCCESS_MESSAGE = 'Thank you! Your submission has been received.';
function asString(value) {
    if (typeof value === 'string')
        return value;
    if (typeof value === 'number' || typeof value === 'boolean')
        return String(value);
    return '';
}
/** Resolve the field schema the submission should be validated against. */
async function resolveFields(form) {
    if (form.activeSchemaVersionId) {
        const version = await getSchemaVersion(form.activeSchemaVersionId);
        if (version && version.fields.length > 0) {
            return { fields: version.fields, versionId: version.id };
        }
    }
    return { fields: form.fields ?? [], versionId: form.activeSchemaVersionId ?? null };
}
function deriveSender(fields, data) {
    let name = null;
    let email = null;
    let phone = null;
    for (const field of fields) {
        const v = asString(data[field.key]).trim();
        if (!v)
            continue;
        if (!email && field.type === 'email')
            email = v;
        if (!phone && field.type === 'phone')
            phone = v;
        if (!name && /name/i.test(field.key))
            name = v;
    }
    // Fallbacks by common key naming when the schema didn't use typed fields.
    if (!email)
        email = firstByKeys(data, ['email', 'emailaddress', 'email_address']);
    if (!name)
        name = firstByKeys(data, ['name', 'fullname', 'full_name', 'firstname', 'first_name']);
    if (!phone)
        phone = firstByKeys(data, ['phone', 'phonenumber', 'phone_number', 'tel']);
    return { name, email, phone };
}
function firstByKeys(data, candidates) {
    const lower = new Map(Object.entries(data).map(([k, v]) => [k.toLowerCase(), v]));
    for (const c of candidates) {
        const v = asString(lower.get(c)).trim();
        if (v)
            return v;
    }
    return null;
}
function derivePreview(fields, data) {
    // Prefer long-form fields, then any non-empty string value.
    for (const field of fields) {
        if (field.type === 'textarea') {
            const v = asString(data[field.key]).trim();
            if (v)
                return v.slice(0, 200);
        }
    }
    const messageKeys = ['message', 'body', 'comments', 'notes', 'description'];
    const byKey = firstByKeys(data, messageKeys);
    if (byKey)
        return byKey.slice(0, 200);
    for (const v of Object.values(data)) {
        const s = asString(v).trim();
        if (s)
            return s.slice(0, 200);
    }
    return null;
}
/** Site-wide default spam strategies; per-form strategies are merged in `scoreSubmissionSpam`. */
function defaultSpamStrategies() {
    return (getActuateConfig()?.forms?.defaultSpamProtection ?? []);
}
async function assignAndCreate(db, row, formId) {
    // Sequential per-form entry number assigned inside a transaction so two
    // concurrent submissions don't read the same max. Not globally unique by DB
    // constraint, but the transaction makes collisions vanishingly unlikely.
    if (typeof db.$transaction === 'function') {
        return db.$transaction(async (tx) => {
            const agg = await tx.formSubmission.aggregate({
                where: { formId },
                _max: { entryNumber: true },
            });
            const entryNumber = (agg?._max?.entryNumber ?? 0) + 1;
            const created = await tx.formSubmission.create({
                data: { ...row, entryNumber },
                select: { id: true, entryNumber: true },
            });
            return { id: String(created.id), entryNumber: created.entryNumber ?? entryNumber };
        });
    }
    const created = await db.formSubmission.create({
        data: row,
        select: { id: true, entryNumber: true },
    });
    return { id: String(created.id), entryNumber: created.entryNumber ?? null };
}
function buildConfirmation(form) {
    if (form.redirectUrl && form.redirectUrl.trim()) {
        return { type: 'redirect', redirectUrl: form.redirectUrl.trim() };
    }
    const message = form.successMessage?.trim() ||
        getActuateConfig()?.forms?.defaultSuccessMessage ||
        DEFAULT_SUCCESS_MESSAGE;
    return { type: 'message', message };
}
async function fireSubmissionHooks(formId, data) {
    try {
        const config = getActuateConfig();
        const hooks = (config?._pluginHooks ?? []);
        for (const hook of hooks.filter((h) => h?.event === 'afterCreate:form-submissions')) {
            await hook.handler({ formId, data });
        }
    }
    catch (err) {
        if (process.env.NODE_ENV !== 'test') {
            console.error('[Actuate CMS] Form submission hook error:', err);
        }
    }
}
/**
 * Best-effort notification + webhook fan-out after a submission is persisted.
 * Exported so the API layer (or tests) can await it deterministically; the
 * pipeline itself fires it without blocking the public response. Every branch
 * is wrapped so a single failing channel can't take down the others.
 */
export async function runSubmissionSideEffects(input) {
    const { form, fields, data, spamStatus } = input;
    const webhookEvent = spamStatus === 'spam' ? 'form.spam_detected' : 'form.submitted';
    // Plugin hooks + admin email + autoresponder: clean submissions only.
    if (spamStatus !== 'spam') {
        await fireSubmissionHooks(form.id, data).catch(() => { });
        const emailCtx = {
            formId: form.id,
            formName: form.name,
            entryNumber: input.entryNumber,
            submittedAt: input.submittedAt,
            fields,
            data,
            senderName: input.sender.name,
            senderEmail: input.sender.email,
        };
        await sendFormNotifications(form.notificationSettings, emailCtx).catch((err) => {
            if (process.env.NODE_ENV !== 'test') {
                console.error('[Actuate CMS] Form notification error:', err);
            }
        });
    }
    // Webhooks fire for both clean and spam submissions (subscribers opt in per event).
    await dispatchFormWebhooks(form.id, {
        event: webhookEvent,
        formId: form.id,
        formSlug: input.formSlug,
        submissionId: input.submissionId,
        entryNumber: input.entryNumber,
        data,
    }).catch((err) => {
        if (process.env.NODE_ENV !== 'test') {
            console.error('[Actuate CMS] Form webhook dispatch error:', err);
        }
    });
}
/**
 * Process a validated request into a persisted submission. Throws
 * `SubmissionValidationError` when field validation fails so the caller can
 * return a structured 422.
 */
export async function processSubmission(form, ctx) {
    const db = getDB();
    const { fields, versionId } = await resolveFields(form);
    const fileKeys = new Set((ctx.files ?? []).map((f) => f.fieldKey));
    const validation = validateAndNormalizeSubmission(fields, ctx.values, { fileKeys });
    if (!validation.ok) {
        throw new SubmissionValidationError(validation.errors);
    }
    const sender = deriveSender(fields, validation.data);
    const spam = scoreSubmissionSpam({
        fields,
        rawValues: ctx.values,
        settings: form.spamSettings,
        defaultStrategies: defaultSpamStrategies(),
        senderEmail: sender.email,
        elapsedMs: ctx.elapsedMs ?? null,
        origin: ctx.origin ?? null,
        referrer: ctx.referrer ?? null,
    });
    const attribution = ctx.attribution && typeof ctx.attribution === 'object' ? ctx.attribution : null;
    // Encrypt sensitive field values at rest (PII). Sender/preview columns above
    // are derived from the plaintext values first so the inbox still works.
    const encryptedData = await encryptSubmissionData(fields, validation.data);
    const encryptedNormalized = await encryptSubmissionData(fields, validation.normalized);
    const row = {
        formId: form.id,
        schemaVersionId: versionId,
        data: encryptedData.data,
        normalizedData: encryptedNormalized.data,
        attribution,
        senderName: sender.name,
        senderEmail: sender.email,
        senderPhone: sender.phone,
        preview: derivePreview(fields, validation.data),
        spamStatus: spam.status,
        unread: true,
        ipAddressHash: hashClientIp(ctx.ip ?? 'unknown'),
        userAgent: ctx.userAgent ?? null,
        referrer: ctx.referrer ?? null,
        pageUrl: ctx.pageUrl ?? null,
        utmSource: asString(attribution?.source) || null,
        utmMedium: asString(attribution?.medium) || null,
        utmCampaign: asString(attribution?.campaign) || null,
        submittedAt: new Date(),
    };
    const { id, entryNumber } = await assignAndCreate(db, row, form.id);
    if ((ctx.files?.length ?? 0) > 0) {
        try {
            await persistSubmissionFiles(form.id, id, ctx.files ?? []);
        }
        catch (err) {
            if (process.env.NODE_ENV !== 'test') {
                console.error('[Actuate CMS] Failed to persist submission files:', err);
            }
        }
    }
    // Notification + webhook fan-out (admin email, autoresponder, signed
    // webhooks). Fire-and-forget so a slow/failing channel never blocks or
    // breaks the public response. Spam suppresses email but still fires
    // `form.spam_detected` webhooks for subscribers.
    void runSubmissionSideEffects({
        form,
        fields,
        data: validation.data,
        submissionId: id,
        entryNumber,
        spamStatus: spam.status,
        sender: { name: sender.name, email: sender.email },
        submittedAt: new Date().toISOString(),
        formSlug: form.slug,
    });
    await logEvent({
        event: spam.status === 'spam' ? 'form.spam_detected' : 'form.submitted',
        details: {
            formId: form.id,
            submissionId: id,
            entryNumber,
            spamStatus: spam.status,
            ...(spam.reasons.length > 0 ? { spamReasons: spam.reasons } : {}),
        },
    });
    return {
        submissionId: id,
        entryNumber,
        spamStatus: spam.status,
        confirmation: buildConfirmation(form),
    };
}
/**
 * Project a form definition into the public, embeddable schema. Strips every
 * server-only concern (access rules, notification recipients, webhook secrets,
 * blocklists, encryption flags) so nothing sensitive leaks to the browser.
 */
export function buildPublicForm(form, fields, schemaVersionId, options = {}) {
    const publicFields = fields
        .filter((f) => f.type !== 'honeypot')
        .map((f) => ({
        id: f.id,
        key: f.key,
        label: f.label,
        type: f.type,
        required: Boolean(f.required),
        placeholder: f.placeholder,
        helpText: f.helpText,
        defaultValue: f.defaultValue ?? undefined,
        options: f.options,
        width: f.width,
        sortOrder: f.sortOrder,
        validationRules: f.validationRules
            ? sanitizePublicRules(f.validationRules)
            : undefined,
        conditionalLogic: f.conditionalLogic,
    }));
    const basePath = getActuateConfig()?.forms?.publicSubmissionPath ?? '/api/cms/public/forms';
    return {
        id: form.id,
        name: form.name,
        slug: form.slug,
        description: form.description || undefined,
        fields: publicFields,
        submitPath: `${basePath.replace(/\/$/, '')}/${form.slug}/submit`,
        successMessage: form.successMessage,
        redirectUrl: form.redirectUrl,
        spam: {
            honeypotFieldKey: form.spamSettings?.honeypotFieldKey ?? fields.find((f) => f.type === 'honeypot')?.key,
            minSubmitSeconds: form.spamSettings?.minSubmitSeconds,
        },
        captcha: options.captcha && options.captcha.provider !== 'none' ? options.captcha : undefined,
        schemaVersionId,
    };
}
/** Strip server-side-only validation hints (custom validator names) before exposing rules. */
function sanitizePublicRules(rules) {
    const { customValidator: _drop, ...rest } = rules;
    return rest;
}
export { resolveFields as resolveSubmissionFields };
//# sourceMappingURL=submission.js.map