/**
 * Pure helpers over `PageSection[]`.
 *
 * Every function is side-effect free and returns a NEW array, so the admin
 * editor state, the REST section endpoints, the MCP tools, and the unit
 * tests all share ONE implementation. No DOM, React, or Node APIs here —
 * keep this module isomorphic so it runs in the browser and on the server.
 */
import { getSectionType } from './registry.js';
// ─── ID generation ───────────────────────────────────────────────────
let idCounter = 0;
/** Generate a unique section id (UUID when available, monotonic fallback). */
export function generateSectionId() {
    if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
        return crypto.randomUUID();
    }
    idCounter += 1;
    return `sec_${Date.now().toString(36)}_${idCounter}`;
}
// ─── Section factory + array helpers ─────────────────────────────────
/** Create a fresh section of the given type, seeded with registry defaults. */
export function createSection(type) {
    const def = getSectionType(type);
    if (!def) {
        throw new Error(`createSection: unknown section type "${type}"`);
    }
    return {
        id: generateSectionId(),
        sectionType: type,
        name: def.name,
        visible: true,
        // deep clone so two sections never share content/settings references
        content: structuredClone(def.defaultContent),
        settings: structuredClone(def.defaultSettings),
    };
}
/** Insert a section at `index` (defaults to the end). Returns a new array. */
export function addSection(sections, section, index) {
    const next = sections.slice();
    const at = index === undefined || index < 0 || index > next.length ? next.length : index;
    next.splice(at, 0, section);
    return next;
}
export function removeSection(sections, id) {
    return sections.filter((s) => s.id !== id);
}
/** Duplicate a section (new id, "(Copy)" suffix), inserted right after the original. */
export function duplicateSection(sections, id) {
    const idx = sections.findIndex((s) => s.id === id);
    if (idx === -1)
        return sections;
    const original = sections[idx];
    const copy = {
        ...original,
        id: generateSectionId(),
        name: `${original.name} (Copy)`,
        content: structuredClone(original.content),
        settings: structuredClone(original.settings),
    };
    return addSection(sections, copy, idx + 1);
}
export function toggleSectionVisibility(sections, id) {
    return sections.map((s) => (s.id === id ? { ...s, visible: !s.visible } : s));
}
/** Explicitly set a section's visibility (used by the API toggle endpoint). */
export function setSectionVisibility(sections, id, visible) {
    return sections.map((s) => (s.id === id ? { ...s, visible } : s));
}
/** Move the section with `id` to a new index (clamped). Returns a new array. */
export function moveSection(sections, id, toIndex) {
    const from = sections.findIndex((s) => s.id === id);
    if (from === -1)
        return sections;
    const next = sections.slice();
    const [moved] = next.splice(from, 1);
    const clamped = Math.max(0, Math.min(toIndex, next.length));
    next.splice(clamped, 0, moved);
    return next;
}
/** Reorder to an explicit id order (used by drag-and-drop drop handlers). */
export function reorderSections(sections, orderedIds) {
    const byId = new Map(sections.map((s) => [s.id, s]));
    const ordered = orderedIds.map((id) => byId.get(id)).filter((s) => Boolean(s));
    // append any sections missing from orderedIds (defensive) to avoid data loss
    for (const s of sections)
        if (!orderedIds.includes(s.id))
            ordered.push(s);
    return ordered;
}
export function updateSectionContent(sections, id, patch) {
    return sections.map((s) => (s.id === id ? { ...s, content: { ...s.content, ...patch } } : s));
}
export function updateSectionSettings(sections, id, patch) {
    return sections.map((s) => (s.id === id ? { ...s, settings: { ...s.settings, ...patch } } : s));
}
export function updateSectionMeta(sections, id, patch) {
    return sections.map((s) => (s.id === id ? { ...s, ...patch } : s));
}
/**
 * Thrown by {@link coerceSections} / {@link coerceSectionsWithReport} when
 * `onUnknown: 'error'` and the input contains unregistered section types.
 * Use it to block a publish/import rather than silently dropping content.
 */
export class UnknownSectionTypeError extends Error {
    sectionTypes;
    constructor(sectionTypes) {
        super(`Unknown section type(s): ${sectionTypes.join(', ')}. ` +
            'Register them via `sections.types` in your config, or remove them.');
        this.name = 'UnknownSectionTypeError';
        this.sectionTypes = sectionTypes;
    }
}
/** Resolve the effective unknown-type strategy, honoring the legacy flag. */
function resolveUnknownStrategy(opts) {
    if (opts.onUnknown)
        return opts.onUnknown;
    if (opts.preserveUnknown)
        return 'preserve';
    return 'drop';
}
function isProd() {
    // Works in both bundled browser builds and Node; defaults to dev when unset.
    return typeof process !== 'undefined' && process.env?.NODE_ENV === 'production';
}
/**
 * Normalize arbitrary JSON into a clean `PageSection[]` and report what
 * happened to entries with an unknown `sectionType`. See
 * {@link CoerceSectionsOptions} for the unknown-type strategy.
 *
 * Used when reading `data.sections` from the DB and when accepting an AI/agent
 * payload that may be partially shaped.
 */
export function coerceSectionsWithReport(raw, opts = {}) {
    const strategy = resolveUnknownStrategy(opts);
    const out = [];
    const dropped = [];
    const preserved = [];
    if (!Array.isArray(raw))
        return { sections: out, dropped, preserved };
    raw.forEach((item, index) => {
        if (!item || typeof item !== 'object')
            return;
        const s = item;
        const type = String(s.sectionType ?? '');
        const content = s.content && typeof s.content === 'object' ? s.content : {};
        const settings = s.settings && typeof s.settings === 'object' ? s.settings : {};
        const id = typeof s.id === 'string' && s.id ? s.id : generateSectionId();
        const internalLabel = typeof s.internalLabel === 'string' ? s.internalLabel : undefined;
        const def = getSectionType(type);
        if (!def) {
            // A blank type is never recoverable — always drop it (it can't render or
            // be re-registered), regardless of strategy.
            if (!type) {
                dropped.push({ sectionType: type, index });
                return;
            }
            if (strategy === 'preserve') {
                preserved.push({ sectionType: type, index });
                out.push({
                    id,
                    sectionType: type,
                    name: typeof s.name === 'string' && s.name ? s.name : type,
                    internalLabel,
                    visible: s.visible !== false,
                    content,
                    settings,
                    unmanaged: true,
                });
                return;
            }
            // 'drop' and 'error' both record the offender; 'error' throws below.
            dropped.push({ sectionType: type, index });
            if (strategy === 'drop')
                opts.onDrop?.({ sectionType: type, index });
            return;
        }
        out.push({
            id,
            sectionType: type,
            name: typeof s.name === 'string' ? s.name : def.name,
            internalLabel,
            visible: s.visible !== false,
            content,
            settings,
        });
    });
    if (strategy === 'error') {
        const unknownTypes = dropped.filter((d) => d.sectionType).map((d) => d.sectionType);
        if (unknownTypes.length > 0)
            throw new UnknownSectionTypeError([...new Set(unknownTypes)]);
    }
    // Dev-only safety net for the common "forgot to register the type" mistake.
    // Suppressed when the caller passed `onDrop` (it owns the signal) or in prod.
    if (strategy === 'drop' && !opts.onDrop && !isProd()) {
        const named = dropped.filter((d) => d.sectionType).map((d) => d.sectionType);
        if (named.length > 0) {
            console.warn(`[actuate] coerceSections dropped ${named.length} section(s) with unknown type(s): ` +
                `${[...new Set(named)].join(', ')}. Register them via \`sections.types\` or pass ` +
                '`{ onUnknown: "preserve" }` to keep them.');
        }
    }
    return { sections: out, dropped, preserved };
}
/**
 * Normalize arbitrary JSON into a clean `PageSection[]`.
 *
 * Thin wrapper over {@link coerceSectionsWithReport} that returns just the
 * sections. Backfills missing ids/names and coerces visibility; unknown types
 * are dropped by default (with a dev warning) — pass `{ onUnknown: 'preserve' }`
 * at a persistence boundary to keep them as `unmanaged`, or `{ onUnknown:
 * 'error' }` to block.
 */
export function coerceSections(raw, opts = {}) {
    return coerceSectionsWithReport(raw, opts).sections;
}
/**
 * Build a fully-formed section from a (possibly partial) AI/agent payload:
 * start from the registry defaults for `type`, then shallow-merge the
 * provided `content` / `settings` and apply an optional display name.
 * The returned section always has a fresh id + valid shape.
 */
export function buildSection(type, input = {}) {
    const base = createSection(type);
    return {
        ...base,
        name: input.name && input.name.trim() ? input.name : base.name,
        ...(input.internalLabel !== undefined ? { internalLabel: input.internalLabel } : {}),
        visible: input.visible !== false,
        content: { ...base.content, ...(input.content ?? {}) },
        settings: { ...base.settings, ...(input.settings ?? {}) },
    };
}
// ─── Post templates ──────────────────────────────────────────────────
/**
 * Estimate reading time in whole minutes from a body of text. HTML tags are
 * stripped first (post bodies are stored as sanitized HTML) and the word
 * count is divided by an average adult reading speed of 200 wpm. Always
 * returns at least 1 so the header never shows "0 min read".
 *
 * Shared by the admin post header preview and the public site header so both
 * surfaces show the same figure.
 */
export function computeReadingTime(text) {
    if (!text)
        return 1;
    const plain = text.replace(/<[^>]+>/g, ' ');
    const words = plain.trim().split(/\s+/).filter(Boolean).length;
    if (words === 0)
        return 1;
    return Math.max(1, Math.round(words / 200));
}
/** Sensible default header layout for a brand-new post type template. */
export function defaultPostHeader() {
    return {
        layout: 'centered',
        showFeaturedImage: true,
        showExcerpt: true,
        showAuthor: true,
        showDate: true,
        showCategory: true,
        showReadingTime: false,
    };
}
/**
 * Normalize arbitrary JSON into a clean {@link PostHeaderConfig}, falling
 * back to {@link defaultPostHeader} for any missing/invalid keys. Used when
 * reading a `post-templates` document or accepting an agent payload.
 */
export function coercePostHeader(raw) {
    const base = defaultPostHeader();
    if (!raw || typeof raw !== 'object')
        return base;
    const r = raw;
    const layout = r.layout === 'centered' || r.layout === 'left' || r.layout === 'overlay'
        ? r.layout
        : base.layout;
    const bool = (key) => typeof r[key] === 'boolean' ? r[key] : base[key];
    return {
        layout,
        showFeaturedImage: bool('showFeaturedImage'),
        showExcerpt: bool('showExcerpt'),
        showAuthor: bool('showAuthor'),
        showDate: bool('showDate'),
        showCategory: bool('showCategory'),
        showReadingTime: bool('showReadingTime'),
    };
}
/**
 * Build a default template for a post type: a centered header plus an
 * article body and a related-posts block — a usable starting point an
 * author or AI agent can then alter.
 */
export function createPostTemplate(postType) {
    return {
        postType,
        header: defaultPostHeader(),
        sections: [createSection('article-body'), createSection('related-posts')],
    };
}
/** Normalize a raw `post-templates` document's data into a {@link PostTemplate}. */
export function coercePostTemplate(postType, raw) {
    const data = raw && typeof raw === 'object' ? raw : {};
    return {
        postType,
        header: coercePostHeader(data.header),
        sections: coerceSections(data.sections),
    };
}
//# sourceMappingURL=helpers.js.map