import { extractPlainText, hashContent } from './content/index.js';
import { applyFieldAccess } from './security/access.js';
import { sanitizeDocumentHtml, sanitizeLayoutTree } from './security/sanitize-document.js';
import { layoutTreeToSections } from './sections/bridge.js';
import { stripInternalDataKeys } from './security/internal-keys.js';
import { getActuateConfig } from './config/runtime.js';
import { prepareContentStructuredData, syncSchemaTypeFields, applyStructuredDataFieldsToData, isPublicContentCollection, } from './seo/schema-sync.js';
import { validateRelations } from './fields/relations.js';
import { invalidationTagsForDocumentWrite, invalidationTagsForGlobalWrite } from './cache/http.js';
import { notifySitemapOnPublish } from './seo/sitemap-notify.js';
import { resolveDocumentUrl } from './seo/page-meta.js';
import { normalizeRedirectPath } from './redirects/graph.js';
import { planSlugChangeRedirects, isEmptyPlan } from './redirects/auto.js';
import { enforcePublishValidation } from './validation/publish.js';
import { getSeoOverrides, putSeoOverrides, resolveSeoModuleSettings, } from './seo/config-store.js';
/**
 * Widen a typed DB row to the `Record<string, unknown>` shape the public
 * action results and plugin hooks use. Rows are plain objects; this is a
 * type-level conversion only.
 */
function asDocRecord(doc) {
    return doc;
}
function prisma(ctx) {
    return ctx.db;
}
/**
 * Fire-and-forget webhook dispatch. Errors are logged but never thrown — the
 * caller is expected to have already committed the underlying database change.
 *
 * MUST be called AFTER the surrounding transaction commits — never inside a
 * `db.$transaction` callback. A rolled-back transaction must not dispatch
 * webhooks for a doc that no longer exists.
 */
async function fireWebhook(event, data) {
    try {
        const { dispatch } = await import('./webhooks/index.js');
        await dispatch(event, data);
    }
    catch (err) {
        if (process.env.NODE_ENV !== 'test') {
            console.error(`[actuate] Webhook dispatch failed for ${event}:`, err instanceof Error ? err.message : err);
        }
    }
}
async function firePluginHooks(event, doc, ctx) {
    try {
        const config = getActuateConfig();
        const hooks = (config?._pluginHooks ?? []);
        const matching = hooks.filter((h) => h?.event === event);
        for (const hook of matching) {
            try {
                await hook.handler(doc);
            }
            catch (err) {
                console.error(`[actuate] Plugin hook "${event}" error:`, err);
            }
        }
        if (doc.data && typeof doc.data === 'object') {
            const docData = doc.data;
            const hasAiUpdates = docData._aiScore !== undefined || docData._embedding !== undefined;
            if (hasAiUpdates && doc.id) {
                const db = prisma(ctx);
                await db.document
                    .update({
                    where: { id: doc.id },
                    data: { data: docData },
                })
                    .catch((err) => {
                    console.error(`[actuate] Failed to persist plugin hook data:`, err);
                });
            }
        }
    }
    catch (err) {
        console.error(`[actuate] Plugin hooks dispatch failed:`, err);
    }
}
/**
 * Dispatch `cache.onInvalidate` for the given tag set, swallowing any
 * failure. Cache invalidation is best-effort — the underlying DB write
 * has already committed, so a webhook revalidation failure must NOT
 * surface to the caller (otherwise we'd appear to have failed a write
 * that actually succeeded).
 */
async function fireCacheInvalidate(tags) {
    const onInvalidate = getActuateConfig()?.cache?.onInvalidate;
    if (!onInvalidate || tags.length === 0)
        return;
    try {
        await onInvalidate(tags);
    }
    catch (err) {
        if (process.env.NODE_ENV !== 'test') {
            console.error('[actuate] cache.onInvalidate threw — cache may be stale until TTL:', err instanceof Error ? err.message : err);
        }
    }
}
async function flushEffects(effects, ctx) {
    for (const effect of effects) {
        if (effect.kind === 'webhook') {
            await fireWebhook(effect.event, effect.payload);
        }
        else if (effect.kind === 'plugin-hook') {
            await firePluginHooks(effect.event, effect.doc, ctx);
        }
        else if (effect.kind === 'cache-invalidate') {
            await fireCacheInvalidate(effect.tags);
        }
        else if (effect.kind === 'auto-redirect') {
            await fireAutoRedirect(ctx, effect.oldPath, effect.newPath);
        }
        else {
            await fireSitemapNotify(ctx, effect.changed);
        }
    }
}
/**
 * Persist the redirect mutations for a slug/path change. Reads the current
 * rule set, plans loop-free 301s via {@link planSlugChangeRedirects}, and
 * applies the plan in one transaction. Never throws — the content write has
 * already committed, so a redirect failure must not surface to the caller.
 */
async function fireAutoRedirect(ctx, oldPath, newPath) {
    try {
        const db = prisma(ctx);
        const rows = await db.redirect.findMany({
            select: { id: true, source: true, destination: true, enabled: true },
        });
        const existing = rows.map((r) => ({
            id: r.id,
            from: r.source,
            to: r.destination,
            enabled: r.enabled,
        }));
        const plan = planSlugChangeRedirects({ oldPath, newPath, existing });
        if (isEmptyPlan(plan))
            return;
        await db.$transaction(async (tx) => {
            for (const id of plan.deleteIds) {
                await tx.redirect.delete({ where: { id } });
            }
            for (const rp of plan.repoint) {
                await tx.redirect.update({ where: { id: rp.id }, data: { destination: rp.destination } });
            }
            if (plan.upsert) {
                await tx.redirect.upsert({
                    where: { source: plan.upsert.source },
                    create: {
                        source: plan.upsert.source,
                        destination: plan.upsert.destination,
                        statusCode: 301,
                        source_kind: 'slug-change',
                        createdById: ctx.userId || undefined,
                    },
                    update: {
                        destination: plan.upsert.destination,
                        statusCode: 301,
                        enabled: true,
                        source_kind: 'slug-change',
                    },
                });
            }
        });
    }
    catch (err) {
        if (process.env.NODE_ENV !== 'test') {
            console.error('[actuate] auto-301 failed — old URL may 404 until a redirect is added manually:', err instanceof Error ? err.message : err);
        }
    }
}
/** Sitemap cache tag — invalidated on any public-content change. */
const SITEMAP_CACHE_TAG = 'sitemap';
/**
 * Roles permitted to publish content. AUTHOR (and lower) may save drafts but
 * never transition a document into PUBLISHED — on either the create or the
 * update path. Finer-grained control belongs in a dedicated publish endpoint
 * with its own access check.
 */
const PUBLISH_ROLES = new Set(['ADMIN', 'EDITOR']);
/**
 * The `DocumentStatus` enum values (must mirror prisma/schema.prisma). The
 * document lifecycle column is overloaded with the data key `status`, but many
 * collections legitimately use their OWN `status` field with non-lifecycle
 * values — forms, for instance, store `status: 'draft' | 'active' | 'archived'`
 * inside `data`. We must only hoist a `data.status` onto the document column
 * when it is an actual lifecycle status; any other string is domain data and is
 * left untouched in `data`. Without this guard, creating/updating such a
 * document throws (invalid create status) or writes an invalid enum value.
 */
const DOCUMENT_STATUSES = new Set([
    'DRAFT',
    'PUBLISHED',
    'ARCHIVED',
    'SCHEDULED',
    'SCHEDULED_UNPUBLISH',
]);
/**
 * Read the requested document lifecycle status from a data payload, ignoring
 * collection-defined `status` fields whose value is not a `DocumentStatus`.
 */
function readLifecycleStatus(data) {
    const raw = typeof data.status === 'string' ? data.status : undefined;
    return raw && DOCUMENT_STATUSES.has(raw) ? raw : undefined;
}
/**
 * Best-effort sitemap regeneration + (production-gated) search-engine ping
 * fired after public content changes. Reads the shared SEO module settings so
 * the toggles in Settings > SEO / SEO Technical govern behaviour. Never throws
 * — the content write has already committed.
 */
async function fireSitemapNotify(ctx, changed) {
    try {
        const overrides = await getSeoOverrides(ctx.db);
        const settings = resolveSeoModuleSettings(overrides);
        if (!settings.sitemapEnabled || !settings.autoRegenerateSitemapOnPublish)
            return;
        // Master switch: when the public site is opted out of indexing, refresh the
        // (now-empty) sitemap cache but never ping search engines or submit to
        // IndexNow — we must not solicit crawling of a site the operator deindexed.
        if (settings.indexEntireSite === false) {
            await fireCacheInvalidate([SITEMAP_CACHE_TAG]);
            return;
        }
        const cfg = getActuateConfig();
        const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ??
            cfg?.seo?.siteUrl ??
            null;
        // Resolve the specific changed URL for IndexNow (URL-based instant
        // indexing). Uses the SAME resolver as the sitemap + canonical tag so the
        // submitted URL is always the page's real, canonical address.
        const indexNowKey = process.env.INDEXNOW_KEY?.trim() || null;
        let changedUrls = [];
        let indexNowKeyLocation = null;
        if (siteUrl && indexNowKey && changed) {
            const base = siteUrl.replace(/\/+$/, '');
            indexNowKeyLocation = `${base}/api/cms/indexnow-key.txt`;
            const collectionDef = cfg?.collections?.[changed.collection];
            changedUrls = [
                resolveDocumentUrl({
                    siteUrl: base,
                    urlPrefix: collectionDef?.urlPrefix,
                    slug: changed.slug,
                    data: changed.path ? { path: changed.path } : {},
                }),
            ];
        }
        const result = await notifySitemapOnPublish({
            settings,
            siteUrl,
            lastGeneratedAt: settings.sitemapLastGeneratedAt,
            changedUrls,
            indexNowKey,
            indexNowKeyLocation,
        });
        // Cheap + un-debounced: refresh the sitemap cache on every change so the
        // next request rebuilds with the latest public content.
        if (result.invalidateCache) {
            await fireCacheInvalidate([SITEMAP_CACHE_TAG]);
        }
        // Expensive + debounced: only persist the regeneration timestamp once per
        // window so bursts of publishes don't hammer the single `__seo_config__`
        // row. The Settings/Technical UI reads this for "last generated".
        if (result.regenerated) {
            const next = {
                ...(overrides ?? {}),
                module: {
                    ...(overrides?.module ?? {}),
                    sitemapLastGeneratedAt: new Date().toISOString(),
                },
            };
            await putSeoOverrides(ctx.db, next, ctx.userId);
        }
    }
    catch (err) {
        if (process.env.NODE_ENV !== 'test') {
            console.error('[actuate] sitemap notify failed — sitemap may be stale until next write:', err instanceof Error ? err.message : err);
        }
    }
}
function getCollectionFields(collection) {
    const config = getActuateConfig()?.collections?.[collection];
    return config?.fields ?? null;
}
async function checkCollectionAccess(collection, operation, ctx, doc) {
    const collectionConfig = getActuateConfig()?.collections?.[collection];
    const accessFn = collectionConfig?.access?.[operation];
    if (!accessFn)
        return;
    const user = ctx.userId ? { id: ctx.userId, role: ctx.role } : null;
    const allowed = await accessFn({ user, doc });
    if (!allowed) {
        throw new Error(`Access denied: cannot ${operation} in collection "${collection}"`);
    }
}
/**
 * Public wrapper around the internal per-collection access gate. Lets API
 * handlers that don't go through the standard CRUD actions (e.g. the read-only
 * SEO analysis endpoints) still enforce a collection's `access[operation]`
 * rule. Throws `Access denied: …` when the rule rejects the user.
 */
export async function assertCollectionAccess(collection, operation, ctx, doc) {
    await checkCollectionAccess(collection, operation, ctx, doc);
}
/**
 * Sanitize every author-HTML sink in a document payload before storage:
 * top-level + nested rich-text fields and the canonical `sections[]` rich-text
 * slots (see {@link sanitizeDocumentHtml}). The page-builder `_layout` tree is
 * sanitized separately at the call site because it is split into an envelope
 * before this runs.
 */
function sanitizeDocumentData(data, collection) {
    return sanitizeDocumentHtml(data, collection);
}
async function computeTextFields(data) {
    const serialized = JSON.stringify(data);
    const plainText = extractPlainText(serialized);
    const contentHash = await hashContent(serialized);
    return { plainText, contentHash };
}
/**
 * Build a {@link RelationLookup} backed by the active Prisma client. The
 * lookup batches by collection and filters out soft-deleted docs so a
 * relation that points at a tombstoned record is treated as broken.
 *
 * Exported so the API layer (resolve, list, get) can reuse it without
 * pulling in the entire actions module.
 */
export function buildRelationLookup(db) {
    return {
        fetchByIds: async (collections, ids) => {
            const result = new Map();
            if (ids.length === 0 || collections.length === 0)
                return result;
            const rows = await db.document.findMany({
                where: {
                    id: { in: ids },
                    collection: { in: collections },
                    deletedAt: null,
                },
                select: {
                    id: true,
                    collection: true,
                    title: true,
                    slug: true,
                    status: true,
                    data: true,
                },
            });
            for (const row of rows) {
                result.set(row.id, {
                    id: row.id,
                    collection: row.collection,
                    title: row.title,
                    slug: row.slug,
                    status: row.status,
                    data: row.data && typeof row.data === 'object' ? row.data : null,
                });
            }
            return result;
        },
    };
}
/** Create a new document in a collection. */
export async function createDocument(collection, data, ctx) {
    await checkCollectionAccess(collection, 'create', ctx);
    const db = prisma(ctx);
    const user = { id: ctx.userId, role: ctx.role };
    // Strip top-level page builder envelope (`layout` / `pageSettings`) and
    // merge it back into `data` as `_layout` / `_pageSettings` so the storage
    // shape is single-sourced. Both wire shapes are accepted for backwards
    // compatibility — top-level wins when both are present.
    const { envelope, rest } = extractPageBuilderEnvelope(data);
    const fields = getCollectionFields(collection);
    const accessFiltered = fields ? await applyFieldAccess('write', fields, rest, user) : rest;
    const sanitizedData = sanitizeDocumentData(accessFiltered, collection);
    const filteredData = lowerPageBuilderEnvelope(sanitizedData, envelope);
    // Honor a requested status on create so an agent/SDK can create-and-publish
    // in one call. The lifecycle status is a top-level document column, NEVER a
    // `data` key — read it first, then strip a lifecycle-valued `status` from
    // `data` so it can't pollute the stored JSON. (Migration/import scripts that
    // wrote `data.status` confused the admin UI.) Domain `status` fields whose
    // value is not a lifecycle status — e.g. forms' draft/active/archived — are
    // left untouched.
    const requestedStatus = readLifecycleStatus(filteredData);
    if (requestedStatus !== undefined) {
        delete filteredData.status;
    }
    // Verify any relation fields point at real, non-deleted documents BEFORE
    // we open the transaction. Doing this inside the txn would block the
    // write while we run extra SELECTs, so we accept a tiny race here (a
    // referenced doc could be deleted between validation and create) in
    // exchange for a much shorter critical section.
    if (fields) {
        await validateRelations(fields, filteredData, buildRelationLookup(db));
    }
    const { plainText, contentHash } = await computeTextFields(filteredData);
    // Only DRAFT and PUBLISHED are valid at create time — scheduling/archiving
    // requires the dedicated fields the update path manages (scheduledAt, etc.),
    // so route those through update.
    if (requestedStatus && requestedStatus !== 'DRAFT' && requestedStatus !== 'PUBLISHED') {
        throw new Error(`Cannot create a document with status "${requestedStatus}". Create it as DRAFT or PUBLISHED, then change status via update.`);
    }
    const publishingNow = requestedStatus === 'PUBLISHED';
    if (publishingNow && !PUBLISH_ROLES.has(ctx.role)) {
        throw new Error('Insufficient permissions to publish — only EDITOR or ADMIN may publish.');
    }
    const status = publishingNow ? 'PUBLISHED' : 'DRAFT';
    // Publish gate: enforce hard content invariants server-side so the SDK/MCP/
    // API can't publish a broken page past the admin's checks. Only fires for
    // publicly-routed content (page/post collections) on a publish. Throws in
    // `block` mode (the default).
    if (publishingNow) {
        enforcePublishValidation({
            collectionDef: getActuateConfig()?.collections?.[collection],
            data: filteredData,
            config: getActuateConfig(),
        });
    }
    const collectionDef = getActuateConfig()?.collections?.[collection];
    const { data: dataForStore } = prepareContentStructuredData(collection, filteredData, {
        collectionDef,
    });
    const pendingEffects = [];
    const doc = await db.$transaction(async (tx) => {
        const created = await tx.document.create({
            data: {
                collection,
                title: typeof dataForStore.title === 'string' ? dataForStore.title : null,
                slug: typeof dataForStore.slug === 'string' ? dataForStore.slug : null,
                data: dataForStore,
                status,
                locale: ctx.locale ?? null,
                createdById: ctx.userId,
                updatedById: ctx.userId,
                publishedAt: publishingNow ? new Date() : null,
                plainText,
                contentHash,
            },
        });
        if (publishingNow && collectionDef) {
            const publishedPrep = prepareContentStructuredData(collection, dataForStore, {
                collectionDef,
                config: getActuateConfig(),
                publishing: true,
                doc: {
                    id: created.id,
                    slug: created.slug,
                    publishedAt: created.publishedAt,
                    updatedAt: created.updatedAt,
                },
            });
            if (publishedPrep.structuredData) {
                await tx.document.update({
                    where: { id: created.id },
                    data: { structuredData: publishedPrep.structuredData },
                });
                created.structuredData = publishedPrep.structuredData;
            }
        }
        await tx.version.create({
            data: {
                documentId: created.id,
                data: dataForStore,
                changedById: ctx.userId,
                changeType: 'CREATE',
            },
        });
        pendingEffects.push({
            kind: 'webhook',
            event: 'document.created',
            payload: {
                id: created.id,
                collection,
                title: created.title,
                slug: created.slug,
                status: created.status,
            },
        });
        // A document created directly as PUBLISHED also emits a publish event so
        // downstream subscribers (and the sitemap) react the same as a draft→publish
        // transition through update.
        if (publishingNow) {
            pendingEffects.push({
                kind: 'webhook',
                event: 'document.published',
                payload: {
                    id: created.id,
                    collection,
                    title: created.title,
                    slug: created.slug,
                    status: created.status,
                },
            });
            pendingEffects.push({
                kind: 'sitemap-notify',
                reason: 'publish',
                changed: {
                    collection,
                    slug: created.slug,
                    path: created.data?.path ?? null,
                },
            });
        }
        pendingEffects.push({ kind: 'plugin-hook', event: 'afterCreate', doc: asDocRecord(created) });
        pendingEffects.push({
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, created.id),
        });
        return created;
    });
    await flushEffects(pendingEffects, ctx);
    return doc;
}
/** Update an existing document. Merges incoming data with existing to prevent field loss. */
export async function updateDocument(collection, id, data, ctx) {
    await checkCollectionAccess(collection, 'update', ctx);
    const db = prisma(ctx);
    const user = { id: ctx.userId, role: ctx.role };
    const existing = await db.document.findFirst({
        where: { id, collection, deletedAt: null },
    });
    if (!existing) {
        throw new Error(`Document "${id}" not found in collection "${collection}"`);
    }
    const existingData = existing.data && typeof existing.data === 'object'
        ? existing.data
        : {};
    // Strip top-level page builder envelope before merge so we don't end up
    // with a duplicate `layout` user field shadowing `_layout`. See
    // {@link extractPageBuilderEnvelope} for the wire-vs-storage explanation.
    const { envelope, rest } = extractPageBuilderEnvelope(data);
    const mergedData = { ...existingData, ...rest };
    const fields = getCollectionFields(collection);
    const accessFiltered = fields
        ? await applyFieldAccess('write', fields, mergedData, user)
        : mergedData;
    const sanitizedData = sanitizeDocumentData(accessFiltered, collection);
    const filteredData = lowerPageBuilderEnvelope(sanitizedData, envelope);
    // The lifecycle status is a top-level document column, NEVER a `data` key.
    // Read it first, then strip a lifecycle-valued `status` from `data` so it
    // can't pollute the stored JSON — and so an edit of a doc whose `data` was
    // polluted by a migration/import script cleans it up. Domain `status` fields
    // whose value is not a lifecycle status (e.g. forms' draft/active/archived)
    // are left untouched.
    const requestedStatus = readLifecycleStatus(filteredData);
    if (requestedStatus !== undefined) {
        delete filteredData.status;
    }
    // Only re-validate relations whose value actually changed — most updates
    // are partial (status flip, slug rename) and re-running every lookup on
    // every save is wasteful. We compare the incoming `rest` against the
    // existing data and only validate fields the caller touched.
    if (fields) {
        const touchedFields = {};
        const touchedData = {};
        for (const [key, def] of Object.entries(fields)) {
            if (def.type !== 'relationship' && def.type !== 'array')
                continue;
            if (Object.prototype.hasOwnProperty.call(rest, key)) {
                touchedFields[key] = def;
                touchedData[key] = filteredData[key];
            }
        }
        if (Object.keys(touchedFields).length > 0) {
            await validateRelations(touchedFields, touchedData, buildRelationLookup(db));
        }
    }
    const { plainText, contentHash } = await computeTextFields(filteredData);
    // Only EDITOR+ can change status to PUBLISHED. Authors can save drafts but
    // can never publish through the document update path. (Use a dedicated
    // publish endpoint with its own access check for finer-grained control.)
    let status = requestedStatus;
    if (status === 'PUBLISHED' && existing.status !== 'PUBLISHED' && !PUBLISH_ROLES.has(ctx.role)) {
        throw new Error('Insufficient permissions to publish — only EDITOR or ADMIN may publish.');
    }
    // Preserve the original publishedAt when the status was already PUBLISHED;
    // only stamp publishedAt on the transition into PUBLISHED.
    const isPublishingNow = status === 'PUBLISHED' && existing.status !== 'PUBLISHED';
    // Publish gate: a document that will be published (newly or by staying
    // published through this edit) must satisfy the hard content invariants.
    // Enforced server-side so partial API edits can't leave a live page broken.
    const willBePublished = (status ?? existing.status) === 'PUBLISHED';
    if (willBePublished) {
        enforcePublishValidation({
            collectionDef: getActuateConfig()?.collections?.[collection],
            data: filteredData,
            config: getActuateConfig(),
        });
    }
    const collectionDef = getActuateConfig()?.collections?.[collection];
    const existingStructured = existing.structuredData && typeof existing.structuredData === 'object'
        ? existing.structuredData
        : null;
    const seoPrep = prepareContentStructuredData(collection, filteredData, {
        collectionDef,
        existingStructuredData: existingStructured,
    });
    const dataForStore = seoPrep.data;
    // Capture side-effects to fire AFTER the transaction commits so we never
    // dispatch a webhook for a rolled-back write. Reuses the module-level
    // `PendingEffect` type so newer effect kinds (e.g. cache-invalidate)
    // are accepted here automatically.
    const pendingEffects = [];
    const updated = await db.$transaction(async (tx) => {
        let structuredDataColumn;
        if (willBePublished && collectionDef) {
            const publishedPrep = prepareContentStructuredData(collection, dataForStore, {
                collectionDef,
                config: getActuateConfig(),
                existingStructuredData: existingStructured,
                publishing: true,
                doc: {
                    id,
                    slug: typeof dataForStore.slug === 'string' ? dataForStore.slug : existing.slug,
                    publishedAt: isPublishingNow ? new Date() : existing.publishedAt,
                    updatedAt: new Date(),
                },
            });
            structuredDataColumn = publishedPrep.structuredData ?? undefined;
        }
        const result = await tx.document.update({
            where: { id },
            data: {
                title: typeof dataForStore.title === 'string' ? dataForStore.title : undefined,
                slug: typeof dataForStore.slug === 'string' ? dataForStore.slug : undefined,
                data: dataForStore,
                updatedById: ctx.userId,
                plainText,
                contentHash,
                ...(status ? { status } : {}),
                ...(isPublishingNow ? { publishedAt: new Date() } : {}),
                ...(structuredDataColumn ? { structuredData: structuredDataColumn } : {}),
            },
        });
        await tx.version.create({
            data: {
                documentId: id,
                data: dataForStore,
                changedById: ctx.userId,
                changeType: 'UPDATE',
            },
        });
        const webhookEvent = isPublishingNow ? 'document.published' : 'document.updated';
        pendingEffects.push({
            kind: 'webhook',
            event: webhookEvent,
            payload: {
                id,
                collection,
                title: result.title,
                slug: result.slug,
                status: status ?? existing.status,
            },
        });
        pendingEffects.push({ kind: 'plugin-hook', event: 'afterUpdate', doc: asDocRecord(result) });
        pendingEffects.push({
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, id),
        });
        // Sitemap upkeep: a change is sitemap-relevant when the document enters,
        // stays in, or leaves the PUBLISHED state. Pure DRAFT→DRAFT edits never
        // touch the public sitemap, so don't enqueue (avoids needless pings).
        const wasPublished = existing.status === 'PUBLISHED';
        const isPublished = (status ?? existing.status) === 'PUBLISHED';
        const changedRef = {
            collection,
            slug: result.slug,
            path: result.data?.path ?? null,
        };
        if (isPublishingNow) {
            pendingEffects.push({ kind: 'sitemap-notify', reason: 'publish', changed: changedRef });
        }
        else if (wasPublished && !isPublished) {
            pendingEffects.push({ kind: 'sitemap-notify', reason: 'unpublish', changed: changedRef });
        }
        else if (wasPublished && isPublished) {
            pendingEffects.push({ kind: 'sitemap-notify', reason: 'update', changed: changedRef });
        }
        // Auto-301: when a document that was AND stays published changes its
        // resolved URL, the old URL is already indexed/linked — keep it alive.
        // Only on published→published; a draft's slug was never publicly reachable,
        // and an unpublish leaves nothing to redirect to.
        if (wasPublished && isPublished) {
            const urlPrefix = getActuateConfig()?.collections?.[collection]?.urlPrefix;
            const oldPath = resolveDocumentUrl({
                siteUrl: '',
                urlPrefix,
                slug: existing.slug,
                data: existingData,
            });
            const newPath = resolveDocumentUrl({
                siteUrl: '',
                urlPrefix,
                slug: result.slug,
                data: result.data,
            });
            if (normalizeRedirectPath(oldPath) !== normalizeRedirectPath(newPath)) {
                pendingEffects.push({ kind: 'auto-redirect', oldPath, newPath });
            }
        }
        return result;
    });
    await flushEffects(pendingEffects, ctx);
    return updated;
}
/**
 * Update SEO metadata on a document through the data layer.
 *
 * SEO fields (`canonical`, `og*`, `focusKeyword`, `structuredData`, `noindex`,
 * …) are a cross-cutting convention that collections are not required to
 * declare as fields. Unlike {@link updateDocument} this therefore does NOT run
 * field-access stripping — that strips any key without a matching field
 * definition and would silently drop these undeclared SEO keys. It still:
 *   - enforces the collection's `update` access rule (no cross-collection edits),
 *   - snapshots a `Version` row (audit trail),
 *   - recomputes `plainText`/`contentHash` (search index stays consistent),
 *   - fires the `afterUpdate` + cache-invalidate effects.
 *
 * `seoFields` MUST be pre-validated and length-bounded by the caller (the API
 * layer enforces type + length); this function trusts the shapes it receives.
 */
export async function updateDocumentSeoFields(collection, id, seoFields, ctx) {
    await checkCollectionAccess(collection, 'update', ctx);
    const db = prisma(ctx);
    const existing = await db.document.findFirst({
        where: { id, collection, deletedAt: null },
    });
    if (!existing) {
        throw new Error(`Document "${id}" not found in collection "${collection}"`);
    }
    const existingData = existing.data && typeof existing.data === 'object'
        ? existing.data
        : {};
    const collectionDef = getActuateConfig()?.collections?.[collection];
    const existingStructured = existing.structuredData && typeof existing.structuredData === 'object'
        ? existing.structuredData
        : null;
    let nextData = { ...existingData, ...seoFields };
    if ('structuredDataType' in seoFields || 'schemaType' in seoFields) {
        nextData = syncSchemaTypeFields(nextData);
    }
    if (isPublicContentCollection(collectionDef)) {
        nextData = applyStructuredDataFieldsToData(collection, nextData, collectionDef, existingStructured);
    }
    const { plainText, contentHash } = await computeTextFields(nextData);
    const pendingEffects = [];
    const updated = await db.$transaction(async (tx) => {
        const result = await tx.document.update({
            where: { id },
            data: { data: nextData, updatedById: ctx.userId, plainText, contentHash },
        });
        await tx.version.create({
            data: {
                documentId: id,
                data: nextData,
                changedById: ctx.userId,
                changeType: 'UPDATE',
            },
        });
        pendingEffects.push({ kind: 'plugin-hook', event: 'afterUpdate', doc: asDocRecord(result) });
        pendingEffects.push({
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, id),
        });
        // SEO field edits (e.g. toggling `noindex`, changing canonical) change
        // sitemap inclusion for already-public docs, so refresh the sitemap.
        if (existing.status === 'PUBLISHED') {
            pendingEffects.push({
                kind: 'sitemap-notify',
                reason: 'update',
                changed: {
                    collection,
                    slug: result.slug,
                    path: result.data?.path ?? null,
                },
            });
        }
        return result;
    });
    await flushEffects(pendingEffects, ctx);
    return updated;
}
/** Soft-delete a document. Creates a version snapshot before deletion. */
export async function deleteDocument(collection, id, ctx) {
    await checkCollectionAccess(collection, 'delete', ctx);
    const db = prisma(ctx);
    const existing = await db.document.findFirst({
        where: { id, collection, deletedAt: null },
    });
    if (!existing) {
        throw new Error(`Document "${id}" not found in collection "${collection}"`);
    }
    await db.$transaction(async (tx) => {
        await tx.version.create({
            data: {
                documentId: id,
                data: existing.data ?? {},
                changedById: ctx.userId,
                changeType: 'DELETE',
            },
        });
        await tx.document.update({
            where: { id },
            data: { deletedAt: new Date() },
        });
    });
    const deleteEffects = [
        {
            kind: 'webhook',
            event: 'document.deleted',
            payload: { id, collection, title: existing.title, slug: existing.slug },
        },
        {
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, id),
        },
    ];
    // Removing public content changes the sitemap; drafts were never in it.
    // Submitting the now-gone URL to IndexNow prompts a recrawl → 404 → deindex.
    if (existing.status === 'PUBLISHED') {
        deleteEffects.push({
            kind: 'sitemap-notify',
            reason: 'delete',
            changed: {
                collection,
                slug: existing.slug,
                path: existing.data?.path ?? null,
            },
        });
    }
    await flushEffects(deleteEffects, ctx);
}
function stripInternalKeys(data) {
    return stripInternalDataKeys(data);
}
/**
 * Page Builder envelope: the admin UI sends `layout` and `pageSettings` at
 * the top level of the document, but the database stores them inside
 * `data._layout` and `data._pageSettings` so they don't collide with
 * user-defined fields. These two helpers normalise between the wire shape
 * and the storage shape so both forms are accepted on input and the wire
 * shape is consistent on output.
 *
 * Wire shape:
 *   { id, title, slug, status, data: {...}, layout?, pageSettings? }
 *
 * Storage shape:
 *   data = { ...userFields, _layout?, _pageSettings? }
 */
function liftPageBuilderEnvelope(input) {
    if (!input.data || typeof input.data !== 'object')
        return input;
    const data = input.data;
    const out = { ...input };
    if (data._layout !== undefined) {
        out.layout = data._layout;
    }
    if (data._pageSettings !== undefined) {
        out.pageSettings = data._pageSettings;
    }
    return out;
}
function lowerPageBuilderEnvelope(data, envelope) {
    const out = { ...data };
    if (envelope.layout !== undefined) {
        // SECURITY: the page-builder tree carries author HTML in its rich-text
        // block bodies and is merged in AFTER field-level sanitization, so it must
        // be sanitized here too (see sanitize-document.ts).
        const sanitizedLayout = sanitizeLayoutTree(envelope.layout);
        out._layout = sanitizedLayout;
        // ADR 0002: the page builder's `_layout` tree is editor state, not a second
        // public contract. Compile it to the canonical `sections[]` on save so every
        // reader (SDK, MCP, search, content-graph, /resolve) sees one contract —
        // instead of deriving it lazily on each /resolve read. The page is then
        // "builder-owned": these derived sections are overwritten on the next builder
        // save (the flat Page Editor / section tools treat such a doc as read-only).
        // Derived from the already-sanitized tree, so the section HTML is safe.
        out.sections = layoutTreeToSections(sanitizedLayout);
    }
    if (envelope.pageSettings !== undefined) {
        out._pageSettings = envelope.pageSettings;
    }
    return out;
}
/** Pull `layout` / `pageSettings` from a write payload, returning the
 * envelope and the cleaned `data` body separately. Callers can then merge
 * the envelope back into `data` with {@link lowerPageBuilderEnvelope}. */
function extractPageBuilderEnvelope(input) {
    const { layout, pageSettings, ...rest } = input;
    const envelope = {};
    if (layout !== undefined)
        envelope.layout = layout;
    if (pageSettings !== undefined)
        envelope.pageSettings = pageSettings;
    return { envelope, rest };
}
/** Retrieve a single document by ID. */
export async function getDocument(collection, id, ctx) {
    const db = prisma(ctx);
    const doc = await db.document.findFirst({
        where: { id, collection, deletedAt: null },
    });
    if (!doc)
        return null;
    if (doc.data && typeof doc.data === 'object') {
        const rawData = doc.data;
        let data = stripInternalKeys(rawData);
        const fields = getCollectionFields(collection);
        if (fields) {
            const user = { id: ctx.userId, role: ctx.role };
            data = await applyFieldAccess('read', fields, data, user);
        }
        // Lift _layout / _pageSettings to the wire envelope so the admin UI
        // and consumers see a single canonical shape: page builder data lives
        // at the top level of the document, not nested inside `data`.
        const out = { ...doc, data };
        if (rawData._layout !== undefined)
            out.layout = rawData._layout;
        if (rawData._pageSettings !== undefined)
            out.pageSettings = rawData._pageSettings;
        return out;
    }
    return asDocRecord(doc);
}
/** List documents with pagination, sorting, and filtering. */
export async function listDocuments(options, ctx) {
    const db = prisma(ctx);
    const page = options.page ?? 1;
    const pageSize = options.pageSize ?? 20;
    const skip = (page - 1) * pageSize;
    const where = {
        collection: options.collection,
        deletedAt: null,
    };
    if (options.status) {
        where.status = options.status;
    }
    if (options.slug) {
        where.slug = options.slug;
    }
    if (options.locale) {
        where.locale = options.locale;
    }
    if (options.folderId === 'none') {
        where.folderId = null;
    }
    else if (options.folderId) {
        where.folderId = options.folderId;
    }
    const ALLOWED_FILTERS = new Set([
        'status',
        'locale',
        'collection',
        'siteId',
        'templateId',
        'folderId',
    ]);
    if (options.filters) {
        for (const [key, value] of Object.entries(options.filters)) {
            if (ALLOWED_FILTERS.has(key) && typeof value !== 'object') {
                where[key] = value;
            }
        }
    }
    if (options.dataFilters && options.dataFilters.length > 0) {
        where.AND = options.dataFilters.map((f) => f.kind === 'array'
            ? {
                // JSONB containment matches a subset object inside any array
                // element, so `[{ tag: "AI" }]` matches rows with extra keys too.
                // Legacy sites store plain strings — accept either shape.
                OR: [
                    { data: { path: [f.field], array_contains: [{ tag: f.value }] } },
                    { data: { path: [f.field], array_contains: [f.value] } },
                ],
            }
            : { data: { path: [f.field], equals: f.value } });
    }
    const ALLOWED_SORT = new Set(['createdAt', 'updatedAt', 'publishedAt', 'status', 'collection']);
    const orderBy = {};
    const sortField = ALLOWED_SORT.has(options.sort ?? '') ? options.sort : 'createdAt';
    orderBy[sortField] = options.order ?? 'desc';
    const [rawDocs, total] = await Promise.all([
        db.document.findMany({
            where,
            skip,
            take: pageSize,
            orderBy,
        }),
        db.document.count({ where }),
    ]);
    const fields = getCollectionFields(options.collection);
    const user = { id: ctx.userId, role: ctx.role };
    const docs = await Promise.all(rawDocs.map(async (doc) => {
        if (doc.data && typeof doc.data === 'object') {
            const rawData = doc.data;
            let data = stripInternalKeys(rawData);
            if (fields) {
                data = await applyFieldAccess('read', fields, data, user);
            }
            // See `getDocument` — page builder envelope must be top-level on
            // the wire. Without this, list views can't show titles/slugs that
            // the page builder UI stores under `_pageSettings`.
            const out = { ...doc, data };
            if (rawData._layout !== undefined)
                out.layout = rawData._layout;
            if (rawData._pageSettings !== undefined)
                out.pageSettings = rawData._pageSettings;
            return out;
        }
        return asDocRecord(doc);
    }));
    return {
        docs,
        total,
        page,
        pageSize,
        totalPages: Math.ceil(total / pageSize),
    };
}
/** Duplicate a document, resetting status to DRAFT and appending "(Copy)" to the title. */
export async function duplicateDocument(collection, sourceId, ctx) {
    const db = prisma(ctx);
    const source = await db.document.findFirst({
        where: { id: sourceId, collection, deletedAt: null },
    });
    if (!source) {
        throw new Error(`Source document "${sourceId}" not found in collection "${collection}"`);
    }
    const clonedData = { ...source.data };
    if (typeof clonedData.title === 'string') {
        clonedData.title += ' (Copy)';
    }
    else if (typeof clonedData.name === 'string') {
        clonedData.name += ' (Copy)';
    }
    if (typeof clonedData.slug === 'string') {
        clonedData.slug += '-copy';
    }
    const { plainText, contentHash } = await computeTextFields(clonedData);
    const pendingEffects = [];
    const doc = await db.$transaction(async (tx) => {
        const created = await tx.document.create({
            data: {
                collection,
                data: clonedData,
                status: 'DRAFT',
                locale: source.locale,
                createdById: ctx.userId,
                updatedById: ctx.userId,
                plainText,
                contentHash,
                siteId: source.siteId,
                templateId: source.templateId,
            },
        });
        await tx.version.create({
            data: {
                documentId: created.id,
                data: clonedData,
                changedById: ctx.userId,
                changeType: 'CREATE',
            },
        });
        pendingEffects.push({
            kind: 'webhook',
            event: 'document.created',
            payload: { id: created.id, collection, title: created.title, slug: created.slug },
        });
        pendingEffects.push({ kind: 'plugin-hook', event: 'afterCreate', doc: asDocRecord(created) });
        pendingEffects.push({
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, created.id),
        });
        return created;
    });
    await flushEffects(pendingEffects, ctx);
    return doc;
}
/** Retrieve a global's data. Globals are stored as single-row collections keyed by slug. */
export async function getGlobal(slug, ctx) {
    const db = prisma(ctx);
    const doc = await db.document.findFirst({
        where: { collection: slug, deletedAt: null },
    });
    if (!doc)
        return null;
    return doc.data && typeof doc.data === 'object' ? doc.data : {};
}
/** Update a global's data. Creates the document if it doesn't exist (upsert). */
export async function updateGlobal(slug, data, ctx) {
    const db = prisma(ctx);
    const { plainText, contentHash } = await computeTextFields(data);
    const existing = await db.document.findFirst({
        where: { collection: slug, deletedAt: null },
    });
    const result = existing
        ? await db.$transaction(async (tx) => {
            const updated = await tx.document.update({
                where: { id: existing.id },
                data: {
                    data,
                    updatedById: ctx.userId,
                    plainText,
                    contentHash,
                },
            });
            await tx.version.create({
                data: {
                    documentId: existing.id,
                    data,
                    changedById: ctx.userId,
                    changeType: 'UPDATE',
                },
            });
            return updated;
        })
        : await db.$transaction(async (tx) => {
            const created = await tx.document.create({
                data: {
                    collection: slug,
                    data,
                    status: 'DRAFT',
                    createdById: ctx.userId,
                    updatedById: ctx.userId,
                    plainText,
                    contentHash,
                },
            });
            await tx.version.create({
                data: {
                    documentId: created.id,
                    data,
                    changedById: ctx.userId,
                    changeType: 'CREATE',
                },
            });
            return created;
        });
    await flushEffects([{ kind: 'cache-invalidate', tags: invalidationTagsForGlobalWrite(slug) }], ctx);
    return result;
}
//# sourceMappingURL=actions.js.map