/**
 * Server-side submission validation + normalization.
 *
 * This is the authoritative gate for incoming public form submissions: the
 * client-side form is convenience only and is assumed hostile. Every value is
 * validated against the field's declared type and `validationRules`, then split
 * into two views:
 *
 *   - `data`        — the sanitized *raw* values (HTML-stripped strings) that we
 *                     persist and render back in the admin inbox.
 *   - `normalized`  — type-coerced values (numbers as numbers, checkboxes as
 *                     booleans, dates as ISO strings) used for export/search.
 *
 * Keys that are not part of the schema are dropped entirely — a submitter
 * cannot smuggle arbitrary columns into the stored payload. Fields hidden by
 * conditional logic are skipped (not required, not stored).
 */
import { stripHtml } from '../security/sanitize.js';
// Conservative hard caps so a single field can't be used to balloon the row /
// exhaust memory even when the schema omits explicit length rules.
const MAX_TEXT_LEN = 5_000;
const MAX_TEXTAREA_LEN = 50_000;
const MAX_MULTISELECT_ITEMS = 200;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[+]?[0-9\s().-]{6,20}$/;
const NON_INPUT = new Set(['richtext', 'divider', 'honeypot']);
const TRUTHY = new Set(['true', 'on', '1', 'yes', 'checked']);
function asString(value) {
    if (value == null)
        return '';
    if (typeof value === 'string')
        return value;
    if (typeof value === 'number' || typeof value === 'boolean')
        return String(value);
    return '';
}
function coerceBoolean(value) {
    if (typeof value === 'boolean')
        return value;
    if (typeof value === 'number')
        return value === 1;
    return TRUTHY.has(asString(value).trim().toLowerCase());
}
function evalRule(rule, values) {
    const current = asString(values[rule.fieldKey]).toLowerCase().trim();
    const target = asString(rule.value).toLowerCase().trim();
    switch (rule.operator) {
        case 'equals':
            return current === target;
        case 'notEquals':
            return current !== target;
        case 'contains':
            return current.includes(target);
        case 'isEmpty':
            return current === '';
        case 'isNotEmpty':
            return current !== '';
        default:
            return true;
    }
}
/** A field is visible unless its `show` rules exist and not all of them pass. */
function isVisible(field, values) {
    const show = field.conditionalLogic?.show;
    if (!show || show.length === 0)
        return true;
    return show.every((rule) => evalRule(rule, values));
}
/** A field is conditionally required when its `require` rules all pass. */
function isConditionallyRequired(field, values) {
    const require = field.conditionalLogic?.require;
    if (!require || require.length === 0)
        return false;
    return require.every((rule) => evalRule(rule, values));
}
function isPresent(raw) {
    if (raw === undefined || raw === null)
        return false;
    if (typeof raw === 'string')
        return raw.trim() !== '';
    if (Array.isArray(raw))
        return raw.length > 0;
    return true;
}
export function validateAndNormalizeSubmission(fields, rawValues, options = {}) {
    const errors = [];
    const data = {};
    const normalized = {};
    const fileKeys = options.fileKeys ?? new Set();
    for (const field of fields) {
        if (NON_INPUT.has(field.type))
            continue;
        if (!isVisible(field, rawValues))
            continue;
        const key = field.key;
        const label = field.label || key;
        const rules = field.validationRules ?? {};
        const required = Boolean(field.required) ||
            Boolean(rules.required) ||
            isConditionallyRequired(field, rawValues);
        if (field.type === 'file') {
            if (required && !fileKeys.has(key)) {
                errors.push({ key, message: `${label} is required.` });
            }
            continue;
        }
        const raw = rawValues[key];
        if (!isPresent(raw)) {
            if (required)
                errors.push({ key, message: `${label} is required.` });
            continue;
        }
        switch (field.type) {
            case 'checkbox':
            case 'consent': {
                const bool = coerceBoolean(raw);
                if ((field.type === 'consent' || required) && !bool) {
                    errors.push({ key, message: `${label} must be accepted.` });
                }
                data[key] = bool;
                normalized[key] = bool;
                break;
            }
            case 'number': {
                const num = typeof raw === 'number' ? raw : Number(asString(raw).trim());
                if (!Number.isFinite(num)) {
                    errors.push({ key, message: `${label} must be a number.` });
                    break;
                }
                if (rules.min != null && num < rules.min) {
                    errors.push({ key, message: `${label} must be at least ${rules.min}.` });
                }
                if (rules.max != null && num > rules.max) {
                    errors.push({ key, message: `${label} must be at most ${rules.max}.` });
                }
                data[key] = num;
                normalized[key] = num;
                break;
            }
            case 'multiselect': {
                const list = Array.isArray(raw) ? raw : [raw];
                if (list.length > MAX_MULTISELECT_ITEMS) {
                    errors.push({ key, message: `${label} has too many selections.` });
                    break;
                }
                const cleaned = list.map((v) => stripHtml(asString(v)).trim()).filter((v) => v !== '');
                const allowed = new Set((field.options ?? []).map((o) => o.value));
                if (allowed.size > 0 && cleaned.some((v) => !allowed.has(v))) {
                    errors.push({ key, message: `${label} contains an invalid selection.` });
                }
                data[key] = cleaned;
                normalized[key] = cleaned;
                break;
            }
            case 'select':
            case 'radio': {
                const val = stripHtml(asString(raw)).trim();
                const allowed = new Set((field.options ?? []).map((o) => o.value));
                if (allowed.size > 0 && !allowed.has(val)) {
                    errors.push({ key, message: `${label} contains an invalid selection.` });
                }
                data[key] = val;
                normalized[key] = val;
                break;
            }
            case 'date': {
                const s = asString(raw).trim();
                const d = new Date(s);
                if (Number.isNaN(d.getTime())) {
                    errors.push({ key, message: `${label} is not a valid date.` });
                    data[key] = s;
                }
                else {
                    data[key] = s;
                    normalized[key] = d.toISOString();
                }
                break;
            }
            default: {
                // text, email, phone, url, textarea, hidden
                const s = stripHtml(asString(raw)).trim();
                const maxLen = rules.maxLength ?? (field.type === 'textarea' ? MAX_TEXTAREA_LEN : MAX_TEXT_LEN);
                if (s.length > maxLen) {
                    errors.push({ key, message: `${label} must be at most ${maxLen} characters.` });
                }
                if (rules.minLength != null && s.length < rules.minLength) {
                    errors.push({ key, message: `${label} must be at least ${rules.minLength} characters.` });
                }
                const format = rules.format ??
                    (field.type === 'email'
                        ? 'email'
                        : field.type === 'url'
                            ? 'url'
                            : field.type === 'phone'
                                ? 'phone'
                                : undefined);
                if (format === 'email' && !EMAIL_RE.test(s)) {
                    errors.push({ key, message: `${label} must be a valid email address.` });
                }
                else if (format === 'phone' && !PHONE_RE.test(s)) {
                    errors.push({ key, message: `${label} must be a valid phone number.` });
                }
                else if (format === 'url') {
                    if (!isHttpUrl(s)) {
                        errors.push({ key, message: `${label} must be a valid URL.` });
                    }
                }
                if (rules.pattern) {
                    try {
                        if (!new RegExp(rules.pattern).test(s)) {
                            errors.push({ key, message: `${label} is not in the expected format.` });
                        }
                    }
                    catch {
                        // An invalid pattern in the schema must never block a submission;
                        // schema validation already surfaces bad patterns to the editor.
                    }
                }
                data[key] = s;
                normalized[key] = s;
            }
        }
    }
    return { ok: errors.length === 0, errors, data, normalized };
}
function isHttpUrl(value) {
    try {
        const url = new URL(value);
        return url.protocol === 'http:' || url.protocol === 'https:';
    }
    catch {
        return false;
    }
}
//# sourceMappingURL=validation.js.map