/**
 * Forms service — definitions, schema versioning, and per-form settings.
 *
 * Form *definitions* are stored as documents in the `forms` collection, so we
 * reuse the standard data-layer actions (`createDocument` / `updateDocument` /
 * `deleteDocument`) to inherit version history, cache invalidation, and plugin
 * hooks. Field *schema* is additionally snapshotted into `FormSchemaVersion`
 * rows so historical submissions always render against the schema they were
 * captured with.
 *
 * Submissions/entries live in the dedicated `FormSubmission` table and are
 * handled in `./entries.ts`.
 */
import { getDB } from '../db.js';
import { createDocument, updateDocument, deleteDocument } from '../actions.js';
import { logEvent } from '../security/audit.js';
import { getActuateConfig } from '../config/runtime.js';
import { INPUT_FIELD_TYPES, NON_INPUT_FIELD_TYPES, } from './types.js';
export const FORMS_COLLECTION = 'forms';
const ALL_FIELD_TYPES = [...INPUT_FIELD_TYPES, ...NON_INPUT_FIELD_TYPES];
const DEFAULT_MAX_FIELDS = 60;
const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
function rawDb() {
    return getDB();
}
function toISO(value) {
    if (!value)
        return undefined;
    if (value instanceof Date)
        return value.toISOString();
    if (typeof value === 'string')
        return value;
    return undefined;
}
function normalizeStatus(value) {
    return value === 'active' || value === 'archived' ? value : 'draft';
}
function asRecord(value) {
    return value && typeof value === 'object' && !Array.isArray(value)
        ? value
        : {};
}
// ─── Parsing ──────────────────────────────────────────────────────────────
/** Parse a raw `forms` document row into the typed `FormDefinition` shape. */
export function parseFormDefinition(doc) {
    const data = asRecord(doc?.data);
    const notif = asRecord(data.notificationSettings);
    const fields = Array.isArray(data.fields) ? data.fields : [];
    return {
        id: String(doc.id),
        name: String(data.name ?? doc.title ?? 'Untitled form'),
        slug: String(data.slug ?? doc.slug ?? ''),
        description: typeof data.description === 'string' ? data.description : '',
        status: normalizeStatus(data.status),
        activeSchemaVersionId: data.activeSchemaVersionId ?? null,
        fields,
        notificationSettings: data.notificationSettings
            ? data.notificationSettings
            : undefined,
        embedSettings: data.embedSettings ? data.embedSettings : undefined,
        spamSettings: data.spamSettings ? data.spamSettings : undefined,
        successMessage: typeof data.successMessage === 'string' ? data.successMessage : undefined,
        redirectUrl: typeof data.redirectUrl === 'string' ? data.redirectUrl : undefined,
        notifyEnabled: Boolean(notif.enabled ?? data.notifyEnabled ?? false),
        createdBy: doc.createdById ? String(doc.createdById) : undefined,
        createdAt: toISO(doc.createdAt),
        updatedAt: toISO(doc.updatedAt),
        archivedAt: data.archivedAt ?? null,
    };
}
/**
 * Validate a set of fields against config limits and structural rules. This is
 * the single gate used by both the admin "Save Schema" flow and form creation.
 */
export function validateFormSchema(fields) {
    const errors = [];
    if (!Array.isArray(fields)) {
        return { valid: false, errors: ['Fields must be an array'] };
    }
    const cfg = getActuateConfig()?.forms;
    const allowed = cfg?.allowedFieldTypes ?? ALL_FIELD_TYPES;
    const maxFields = cfg?.maxFieldsPerForm ?? DEFAULT_MAX_FIELDS;
    if (fields.length > maxFields) {
        errors.push(`Too many fields (max ${maxFields}).`);
    }
    const seenKeys = new Set();
    for (const raw of fields) {
        const key = raw?.key;
        if (!key || !KEY_PATTERN.test(key)) {
            errors.push(`Invalid field key "${key ?? ''}". Use letters, numbers, and underscores.`);
        }
        else if (seenKeys.has(key)) {
            errors.push(`Duplicate field key "${key}".`);
        }
        if (key)
            seenKeys.add(key);
        if (!raw?.label || typeof raw.label !== 'string') {
            errors.push(`Field "${key ?? ''}" is missing a label.`);
        }
        if (!allowed.includes(raw?.type)) {
            errors.push(`Field type "${raw?.type ?? ''}" is not allowed.`);
        }
        if ((raw?.type === 'select' || raw?.type === 'multiselect' || raw?.type === 'radio') &&
            (!Array.isArray(raw.options) || raw.options.length === 0)) {
            errors.push(`Field "${key ?? ''}" of type ${raw.type} requires at least one option.`);
        }
    }
    return { valid: errors.length === 0, errors };
}
/** Normalize incoming field objects: assign ids and sortOrder, coerce required. */
function normalizeFields(fields) {
    return fields.map((f, i) => ({
        ...f,
        id: f.id || `fld_${Math.random().toString(36).slice(2, 10)}`,
        required: Boolean(f.required),
        sortOrder: typeof f.sortOrder === 'number' ? f.sortOrder : i,
    }));
}
// ─── Schema versions ─────────────────────────────────────────────────────
function parseSchemaVersion(row) {
    return {
        id: String(row.id),
        formId: String(row.formId),
        version: Number(row.version),
        fields: Array.isArray(row.fields) ? row.fields : [],
        notes: row.notes ?? null,
        createdById: row.createdById ?? null,
        createdAt: toISO(row.createdAt) ?? new Date().toISOString(),
    };
}
async function createSchemaVersionRow(formId, fields, createdById, notes) {
    const db = rawDb();
    const latest = await db.formSchemaVersion.findFirst({
        where: { formId },
        orderBy: { version: 'desc' },
        select: { version: true },
    });
    const nextVersion = (latest?.version ?? 0) + 1;
    const row = await db.formSchemaVersion.create({
        data: {
            formId,
            version: nextVersion,
            fields,
            notes: notes ?? null,
            createdById: createdById ?? null,
        },
    });
    return parseSchemaVersion(row);
}
export async function listSchemaVersions(formId) {
    const rows = await rawDb().formSchemaVersion.findMany({
        where: { formId },
        orderBy: { version: 'desc' },
    });
    return rows.map(parseSchemaVersion);
}
export async function getSchemaVersion(versionId) {
    const row = await rawDb().formSchemaVersion.findUnique({ where: { id: versionId } });
    return row ? parseSchemaVersion(row) : null;
}
/** Compute per-form entry counters for the supplied form ids in three grouped queries. */
async function computeFormCounters(formIds) {
    const out = new Map();
    for (const id of formIds)
        out.set(id, { total: 0, unread: 0, starred: 0, lastEntryAt: null });
    if (formIds.length === 0)
        return out;
    const db = rawDb();
    const where = { formId: { in: formIds }, spamStatus: { not: 'spam' } };
    const [totals, unreads, starreds] = await Promise.all([
        db.formSubmission.groupBy({
            by: ['formId'],
            where,
            _count: { _all: true },
            _max: { submittedAt: true },
        }),
        db.formSubmission.groupBy({
            by: ['formId'],
            where: { ...where, unread: true, archived: false },
            _count: { _all: true },
        }),
        db.formSubmission.groupBy({
            by: ['formId'],
            where: { ...where, starred: true },
            _count: { _all: true },
        }),
    ]);
    for (const g of totals) {
        const c = out.get(g.formId);
        if (c) {
            c.total = g._count._all;
            c.lastEntryAt = g._max?.submittedAt ? new Date(g._max.submittedAt).toISOString() : null;
        }
    }
    for (const g of unreads) {
        const c = out.get(g.formId);
        if (c)
            c.unread = g._count._all;
    }
    for (const g of starreds) {
        const c = out.get(g.formId);
        if (c)
            c.starred = g._count._all;
    }
    return out;
}
// ─── Forms CRUD ──────────────────────────────────────────────────────────
function matchesSearch(form, term) {
    const haystack = [
        form.name,
        form.slug,
        form.description ?? '',
        form.status,
        ...(form.fields ?? []).flatMap((f) => [f.label, f.key, f.type]),
        ...(form.notificationSettings?.recipients ?? []),
        ...(form.embedSettings?.allowedDomains ?? []),
    ]
        .join(' ')
        .toLowerCase();
    return haystack.includes(term.toLowerCase());
}
export async function listForms(params = {}) {
    const db = rawDb();
    const rows = await db.document.findMany({
        where: { collection: FORMS_COLLECTION, deletedAt: null },
        orderBy: { createdAt: 'desc' },
    });
    let forms = rows.map((r) => parseFormDefinition(r));
    const counters = await computeFormCounters(forms.map((f) => f.id));
    forms = forms.map((f) => {
        const c = counters.get(f.id);
        return {
            ...f,
            totalEntries: c?.total ?? 0,
            unreadEntries: c?.unread ?? 0,
            starredEntries: c?.starred ?? 0,
            lastEntryAt: c?.lastEntryAt ?? null,
        };
    });
    if (params.status)
        forms = forms.filter((f) => f.status === params.status);
    if (typeof params.notifyEnabled === 'boolean') {
        forms = forms.filter((f) => Boolean(f.notifyEnabled) === params.notifyEnabled);
    }
    if (params.search?.trim()) {
        const term = params.search.trim();
        forms = forms.filter((f) => matchesSearch(f, term));
    }
    const sortBy = params.sortBy ?? 'createdAt';
    const dir = params.sortDirection === 'asc' ? 1 : -1;
    forms.sort((a, b) => {
        const av = a[sortBy];
        const bv = b[sortBy];
        if (av == null && bv == null)
            return 0;
        if (av == null)
            return 1;
        if (bv == null)
            return -1;
        return av > bv ? dir : av < bv ? -dir : 0;
    });
    const total = forms.length;
    const page = Math.max(1, params.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 50));
    const start = (page - 1) * pageSize;
    return { forms: forms.slice(start, start + pageSize), total, page, pageSize };
}
export async function getFormById(id) {
    const row = await rawDb().document.findFirst({
        where: { id, collection: FORMS_COLLECTION, deletedAt: null },
    });
    if (!row)
        return null;
    const form = parseFormDefinition(row);
    const counters = await computeFormCounters([id]);
    const c = counters.get(id);
    return {
        ...form,
        totalEntries: c?.total ?? 0,
        unreadEntries: c?.unread ?? 0,
        starredEntries: c?.starred ?? 0,
        lastEntryAt: c?.lastEntryAt ?? null,
    };
}
export async function getFormBySlug(slug) {
    const row = await rawDb().document.findFirst({
        where: { collection: FORMS_COLLECTION, slug, deletedAt: null },
    });
    return row ? parseFormDefinition(row) : null;
}
async function slugExists(slug, excludeId) {
    const row = await rawDb().document.findFirst({
        where: {
            collection: FORMS_COLLECTION,
            slug,
            deletedAt: null,
            ...(excludeId ? { id: { not: excludeId } } : {}),
        },
        select: { id: true },
    });
    return Boolean(row);
}
function slugify(input) {
    return input
        .toLowerCase()
        .trim()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '')
        .slice(0, 64);
}
export async function createForm(ctx, input) {
    const fields = normalizeFields(input.fields ?? []);
    const validation = validateFormSchema(fields);
    if (!validation.valid) {
        throw new FormValidationError(validation.errors);
    }
    let slug = input.slug?.trim() ? slugify(input.slug) : slugify(input.name);
    if (!slug)
        slug = `form-${Date.now()}`;
    if (await slugExists(slug)) {
        throw new FormConflictError(`A form with slug "${slug}" already exists.`);
    }
    const data = {
        name: input.name,
        slug,
        description: input.description ?? '',
        status: normalizeStatus(input.status),
        fields,
        notificationSettings: input.notificationSettings ?? { enabled: false, recipients: [] },
        embedSettings: input.embedSettings ?? {},
        spamSettings: input.spamSettings ?? {},
        successMessage: input.successMessage ?? getActuateConfig()?.forms?.defaultSuccessMessage,
        redirectUrl: input.redirectUrl,
    };
    const doc = (await createDocument(FORMS_COLLECTION, data, ctx));
    const version = await createSchemaVersionRow(doc.id, fields, ctx.userId, 'Initial schema');
    await rawDb().document.update({
        where: { id: doc.id },
        data: { data: { ...asRecord(doc.data), activeSchemaVersionId: version.id } },
    });
    await logEvent({
        event: 'form.created',
        userId: ctx.userId,
        details: { formId: doc.id, name: input.name, slug },
    });
    const created = await getFormById(doc.id);
    if (!created)
        throw new Error('Form created but could not be reloaded');
    return created;
}
export async function updateForm(ctx, id, patch) {
    const existing = await getFormById(id);
    if (!existing)
        throw new FormNotFoundError(id);
    const data = {};
    if (patch.name !== undefined)
        data.name = patch.name;
    if (patch.description !== undefined)
        data.description = patch.description;
    if (patch.successMessage !== undefined)
        data.successMessage = patch.successMessage;
    if (patch.redirectUrl !== undefined)
        data.redirectUrl = patch.redirectUrl;
    if (patch.spamSettings !== undefined)
        data.spamSettings = patch.spamSettings;
    if (patch.embedSettings !== undefined)
        data.embedSettings = patch.embedSettings;
    let statusChanged = false;
    if (patch.status !== undefined && patch.status !== existing.status) {
        statusChanged = true;
        data.status = patch.status;
        data.archivedAt = patch.status === 'archived' ? new Date().toISOString() : null;
    }
    await updateDocument(FORMS_COLLECTION, id, data, ctx);
    await logEvent({
        event: statusChanged
            ? patch.status === 'archived'
                ? 'form.archived'
                : existing.status === 'archived'
                    ? 'form.restored'
                    : 'form.updated'
            : 'form.updated',
        userId: ctx.userId,
        details: { formId: id, ...(statusChanged ? { status: patch.status } : {}) },
    });
    const updated = await getFormById(id);
    if (!updated)
        throw new FormNotFoundError(id);
    return updated;
}
export async function archiveForm(ctx, id) {
    return updateForm(ctx, id, { status: 'archived' });
}
export async function restoreForm(ctx, id) {
    return updateForm(ctx, id, { status: 'draft' });
}
export async function duplicateForm(ctx, id) {
    const source = await getFormById(id);
    if (!source)
        throw new FormNotFoundError(id);
    let slug = slugify(`${source.slug || source.name}-copy`);
    let n = 2;
    while (await slugExists(slug)) {
        slug = slugify(`${source.slug || source.name}-copy-${n++}`);
    }
    return createForm(ctx, {
        name: `${source.name} (copy)`,
        slug,
        description: source.description,
        status: 'draft',
        fields: source.fields,
        notificationSettings: source.notificationSettings,
        embedSettings: {},
        spamSettings: source.spamSettings,
        successMessage: source.successMessage,
        redirectUrl: source.redirectUrl,
    });
}
export async function deleteForm(ctx, id, options = {}) {
    const existing = await getFormById(id);
    if (!existing)
        throw new FormNotFoundError(id);
    const submissionCount = await rawDb().formSubmission.count({ where: { formId: id } });
    if (submissionCount > 0 && !options.force) {
        throw new FormHasSubmissionsError(submissionCount);
    }
    await deleteDocument(FORMS_COLLECTION, id, ctx);
    await logEvent({
        event: 'form.deleted',
        userId: ctx.userId,
        details: { formId: id, submissionCount, forced: Boolean(options.force) },
    });
}
// ─── Schema save ───────────────────────────────────────────────────────
export async function saveFormSchema(ctx, id, fields, notes) {
    const existing = await getFormById(id);
    if (!existing)
        throw new FormNotFoundError(id);
    const normalized = normalizeFields(fields);
    const validation = validateFormSchema(normalized);
    if (!validation.valid)
        throw new FormValidationError(validation.errors);
    const version = await createSchemaVersionRow(id, normalized, ctx.userId, notes);
    await updateDocument(FORMS_COLLECTION, id, { fields: normalized, activeSchemaVersionId: version.id }, ctx);
    await logEvent({
        event: 'form.schema.updated',
        userId: ctx.userId,
        details: { formId: id, version: version.version, fieldCount: normalized.length },
    });
    const form = await getFormById(id);
    if (!form)
        throw new FormNotFoundError(id);
    return { form, version };
}
export async function getFormSchema(id) {
    const form = await getFormById(id);
    if (!form)
        throw new FormNotFoundError(id);
    const versions = await listSchemaVersions(id);
    return { fields: form.fields, activeVersionId: form.activeSchemaVersionId ?? null, versions };
}
// ─── Settings ──────────────────────────────────────────────────────────
const DEFAULT_NOTIFICATION_SETTINGS = {
    enabled: false,
    recipients: [],
    includeFields: true,
    excludeSensitiveFields: false,
    autoresponderEnabled: false,
    webhookEnabled: false,
};
export async function getNotificationSettings(id) {
    const form = await getFormById(id);
    if (!form)
        throw new FormNotFoundError(id);
    return { ...DEFAULT_NOTIFICATION_SETTINGS, ...(form.notificationSettings ?? {}) };
}
export async function updateNotificationSettings(ctx, id, patch) {
    const current = await getNotificationSettings(id);
    const next = { ...current, ...patch };
    await updateDocument(FORMS_COLLECTION, id, { notificationSettings: next, notifyEnabled: next.enabled }, ctx);
    await logEvent({
        event: 'form.notifications.changed',
        userId: ctx.userId,
        details: { formId: id, enabled: next.enabled },
    });
    return next;
}
/** Toggle the notify flag (maps to the Notify switch in the forms table). */
export async function setNotifyEnabled(ctx, id, enabled) {
    return updateNotificationSettings(ctx, id, { enabled });
}
export async function getEmbedSettings(id) {
    const form = await getFormById(id);
    if (!form)
        throw new FormNotFoundError(id);
    return form.embedSettings ?? {};
}
export async function updateEmbedSettings(ctx, id, patch) {
    const current = await getEmbedSettings(id);
    const next = { ...current, ...patch };
    await updateDocument(FORMS_COLLECTION, id, { embedSettings: next }, ctx);
    await logEvent({
        event: 'form.updated',
        userId: ctx.userId,
        details: { formId: id, embed: true },
    });
    return next;
}
// ─── Stats + sidebar ─────────────────────────────────────────────────────
export async function getFormStats() {
    const db = rawDb();
    const forms = (await db.document.findMany({
        where: { collection: FORMS_COLLECTION, deletedAt: null },
        select: { data: true },
    })).map((r) => normalizeStatus(asRecord(r.data).status));
    const [totalEntries, unreadEntries] = await Promise.all([
        db.formSubmission.count({ where: { spamStatus: { not: 'spam' } } }),
        db.formSubmission.count({
            where: { unread: true, archived: false, spamStatus: { not: 'spam' } },
        }),
    ]);
    return {
        totalForms: forms.length,
        activeForms: forms.filter((s) => s === 'active').length,
        totalEntries,
        unreadEntries,
    };
}
/**
 * Unread counts for the sidebar. Only active, non-hidden forms contribute to
 * the parent badge; archived/draft forms are excluded so the rail reflects
 * live, publicly-collecting forms.
 */
export async function getFormsSidebarCounts() {
    const db = rawDb();
    const rows = await db.document.findMany({
        where: { collection: FORMS_COLLECTION, deletedAt: null },
        orderBy: { createdAt: 'asc' },
    });
    const forms = rows.map(parseFormDefinition).filter((f) => f.status === 'active');
    const counters = await computeFormCounters(forms.map((f) => f.id));
    return forms.map((f) => ({
        formId: f.id,
        name: f.name,
        slug: f.slug,
        status: f.status,
        unread: counters.get(f.id)?.unread ?? 0,
    }));
}
// ─── Errors ──────────────────────────────────────────────────────────────
export class FormNotFoundError extends Error {
    constructor(id) {
        super(`Form "${id}" not found`);
        this.name = 'FormNotFoundError';
    }
}
export class FormValidationError extends Error {
    errors;
    constructor(errors) {
        super(`Invalid form schema: ${errors.join('; ')}`);
        this.name = 'FormValidationError';
        this.errors = errors;
    }
}
export class FormConflictError extends Error {
    constructor(message) {
        super(message);
        this.name = 'FormConflictError';
    }
}
export class FormHasSubmissionsError extends Error {
    submissionCount;
    constructor(submissionCount) {
        super(`Form has ${submissionCount} submission(s); deletion requires force or export first.`);
        this.name = 'FormHasSubmissionsError';
        this.submissionCount = submissionCount;
    }
}
//# sourceMappingURL=service.js.map