import { afterEach, describe, expect, it } from 'vitest';
import { SECTION_TYPES, SECTION_TYPE_LIST, getSectionType, getSectionTypesForScope, getAllSectionTypes, isKnownSectionType, registerSectionTypes, resetCustomSectionTypes, resolveCustomSectionTypes, resolveRepeaterField, isRepeaterField, blankRepeaterRow, coerceSectionsWithReport, UnknownSectionTypeError, createSection, buildSection, addSection, removeSection, duplicateSection, toggleSectionVisibility, setSectionVisibility, moveSection, reorderSections, updateSectionContent, updateSectionSettings, updateSectionMeta, coerceSections, validateSectionContent, defaultPostHeader, coercePostHeader, computeReadingTime, createPostTemplate, coercePostTemplate, } from '../index.js';
const ALL_IDS = [
    'hero',
    'text',
    'feature',
    'stats',
    'cta',
    'form',
    'article-body',
    'quote',
    'author-bio',
    'related-posts',
    'gallery',
];
describe('section registry', () => {
    it('lists every renderable type with a scope, defaults + fields', () => {
        const ids = new Set(ALL_IDS);
        expect(SECTION_TYPE_LIST).toHaveLength(ALL_IDS.length);
        for (const def of SECTION_TYPE_LIST) {
            expect(ids.has(def.id)).toBe(true);
            expect(def.fields.length).toBeGreaterThan(0);
            expect(def.defaultContent).toBeTruthy();
            expect(def.scope.length).toBeGreaterThan(0);
        }
    });
    it('getSectionType / isKnownSectionType reflect the registry', () => {
        expect(getSectionType('hero')).toBe(SECTION_TYPES.hero);
        expect(getSectionType('nope')).toBeUndefined();
        expect(isKnownSectionType('cta')).toBe(true);
        expect(isKnownSectionType('article-body')).toBe(true);
        expect(isKnownSectionType('nope')).toBe(false);
    });
});
describe('getSectionTypesForScope', () => {
    it('page scope excludes post-only types', () => {
        const pageIds = getSectionTypesForScope('page').map((d) => d.id);
        expect(pageIds).toEqual(['hero', 'text', 'feature', 'stats', 'cta', 'form']);
        expect(pageIds).not.toContain('article-body');
    });
    it('post scope includes shared + post-only types', () => {
        const postIds = getSectionTypesForScope('post').map((d) => d.id);
        // shared types are usable on both surfaces
        expect(postIds).toContain('hero');
        // post-only types appear
        for (const id of ['article-body', 'quote', 'author-bio', 'related-posts', 'gallery']) {
            expect(postIds).toContain(id);
        }
    });
    it('every type is reachable from at least one scope', () => {
        const reachable = new Set([
            ...getSectionTypesForScope('page').map((d) => d.id),
            ...getSectionTypesForScope('post').map((d) => d.id),
        ]);
        for (const id of ALL_IDS)
            expect(reachable.has(id)).toBe(true);
    });
});
describe('createSection / buildSection', () => {
    it('seeds registry defaults with a unique id and deep-cloned content', () => {
        const a = createSection('stats');
        const b = createSection('stats');
        expect(a.id).not.toBe(b.id);
        expect(a.sectionType).toBe('stats');
        expect(a.name).toBe(SECTION_TYPES.stats.name);
        a.content.stats.push({ value: 'x' });
        expect(b.content.stats.length).toBe(SECTION_TYPES.stats.defaultContent.stats.length);
    });
    it('buildSection merges overrides over the defaults', () => {
        const s = buildSection('hero', {
            name: 'Custom hero',
            content: { heading: 'Override' },
            settings: { paddingY: 'sm' },
        });
        expect(s.name).toBe('Custom hero');
        expect(s.content.heading).toBe('Override');
        // untouched default keys survive the merge
        expect(s.content.primaryButtonLabel).toBe(SECTION_TYPES.hero.defaultContent.primaryButtonLabel);
        expect(s.settings.paddingY).toBe('sm');
    });
});
describe('array helpers', () => {
    const s1 = createSection('hero');
    const s2 = createSection('text');
    const s3 = createSection('cta');
    const list = [s1, s2, s3];
    it('addSection appends by default and inserts at an index', () => {
        const extra = createSection('stats');
        expect(addSection(list, extra).map((s) => s.id)).toEqual([s1.id, s2.id, s3.id, extra.id]);
        expect(addSection(list, extra, 1).map((s) => s.id)).toEqual([s1.id, extra.id, s2.id, s3.id]);
    });
    it('removeSection / moveSection / reorderSections', () => {
        expect(removeSection(list, s2.id).map((s) => s.id)).toEqual([s1.id, s3.id]);
        expect(moveSection(list, s1.id, 99).map((s) => s.id)).toEqual([s2.id, s3.id, s1.id]);
        expect(reorderSections(list, [s3.id]).map((s) => s.id)).toEqual([s3.id, s1.id, s2.id]);
    });
    it('duplicateSection inserts a new-id copy right after the original', () => {
        const out = duplicateSection(list, s2.id);
        expect(out).toHaveLength(4);
        expect(out[2].id).not.toBe(s2.id);
        expect(out[2].name).toBe(`${s2.name} (Copy)`);
    });
    it('visibility + patch helpers are immutable', () => {
        expect(toggleSectionVisibility(list, s1.id).find((s) => s.id === s1.id).visible).toBe(false);
        expect(setSectionVisibility(list, s1.id, false).find((s) => s.id === s1.id).visible).toBe(false);
        expect(updateSectionContent(list, s1.id, { heading: 'New' }).find((s) => s.id === s1.id).content
            .heading).toBe('New');
        expect(list.find((s) => s.id === s1.id).content.heading).not.toBe('New');
        expect(updateSectionSettings(list, s1.id, { paddingY: 'sm' }).find((s) => s.id === s1.id).settings
            .paddingY).toBe('sm');
        expect(updateSectionMeta(list, s1.id, { name: 'Renamed' }).find((s) => s.id === s1.id).name).toBe('Renamed');
    });
});
describe('coerceSections', () => {
    it('drops unknown types and backfills ids/names/visibility', () => {
        const out = coerceSections([
            { sectionType: 'hero', content: { heading: 'Hi' } },
            { sectionType: 'ghost' }, // unknown — dropped
            { sectionType: 'text', visible: false },
            'garbage',
            null,
        ]);
        expect(out).toHaveLength(2);
        expect(out[0].sectionType).toBe('hero');
        expect(out[0].id).toBeTruthy();
        expect(out[0].name).toBe(SECTION_TYPES.hero.name);
        expect(out[1].sectionType).toBe('text');
        expect(out[1].visible).toBe(false);
    });
    it('returns an empty array for non-array input', () => {
        expect(coerceSections(undefined)).toEqual([]);
        expect(coerceSections({})).toEqual([]);
    });
    it('preserves unknown types as unmanaged, verbatim and in order, when asked', () => {
        const out = coerceSections([
            { sectionType: 'hero', content: { heading: 'Hi' } },
            { sectionType: 'opal-hero', content: { title: 'Custom', cta: '/x' }, settings: { foo: 1 } },
            { sectionType: 'text' },
        ], { preserveUnknown: true });
        expect(out.map((s) => s.sectionType)).toEqual(['hero', 'opal-hero', 'text']);
        const unmanaged = out[1];
        expect(unmanaged.unmanaged).toBe(true);
        // content + settings are kept verbatim so the externally-managed section
        // round-trips without loss.
        expect(unmanaged.content).toEqual({ title: 'Custom', cta: '/x' });
        expect(unmanaged.settings).toEqual({ foo: 1 });
        expect(unmanaged.name).toBe('opal-hero');
        // known types are never flagged unmanaged
        expect(out[0].unmanaged).toBeUndefined();
    });
    it('still drops unknown types by default (no preserveUnknown)', () => {
        const out = coerceSections([{ sectionType: 'opal-hero' }, { sectionType: 'hero' }]);
        expect(out.map((s) => s.sectionType)).toEqual(['hero']);
    });
    it('never marks an entry with a blank sectionType as unmanaged', () => {
        const out = coerceSections([{ content: { x: 1 } }, { sectionType: '' }], {
            preserveUnknown: true,
        });
        expect(out).toEqual([]);
    });
});
describe('custom (config-declared) section types', () => {
    afterEach(() => resetCustomSectionTypes());
    const pricing = {
        id: 'pricing',
        name: 'Pricing Table',
        scope: ['page'],
        fields: [{ key: 'heading', label: 'Heading', type: 'text', required: true }],
        defaultContent: { heading: 'Plans' },
    };
    it('registers + resolves a custom type and makes it createable', () => {
        expect(isKnownSectionType('pricing')).toBe(false);
        registerSectionTypes([{ ...pricing }]);
        expect(isKnownSectionType('pricing')).toBe(true);
        expect(getSectionType('pricing')?.name).toBe('Pricing Table');
        // defaults are backfilled
        expect(getSectionType('pricing')?.icon).toBeTruthy();
        const created = createSection('pricing');
        expect(created.sectionType).toBe('pricing');
        expect(created.content).toEqual({ heading: 'Plans' });
    });
    it('appears in the Add Section gallery for its scope only', () => {
        registerSectionTypes([{ ...pricing }]);
        expect(getSectionTypesForScope('page').map((d) => d.id)).toContain('pricing');
        expect(getSectionTypesForScope('post').map((d) => d.id)).not.toContain('pricing');
    });
    it('a custom type is no longer unmanaged when coerced', () => {
        registerSectionTypes([{ ...pricing }]);
        const out = coerceSections([{ sectionType: 'pricing', content: { heading: 'X' } }], {
            preserveUnknown: true,
        });
        expect(out).toHaveLength(1);
        expect(out[0].unmanaged).toBeUndefined();
    });
    it('validates a custom type against its declared fields', () => {
        registerSectionTypes([{ ...pricing }]);
        expect(validateSectionContent('pricing', { heading: '' }).valid).toBe(false);
        expect(validateSectionContent('pricing', { heading: 'Plans' }).valid).toBe(true);
    });
    it('never lets a custom type shadow a built-in, and skips invalid input', () => {
        registerSectionTypes([
            { id: 'hero', name: 'Evil Hero', scope: ['page'], fields: [] },
            { id: '', name: 'Blank', scope: ['page'], fields: [] },
        ]);
        expect(getSectionType('hero')).toBe(SECTION_TYPES.hero);
        expect(getAllSectionTypes().filter((d) => d.id === '')).toHaveLength(0);
    });
    it('resetCustomSectionTypes clears registrations', () => {
        registerSectionTypes([{ ...pricing }]);
        resetCustomSectionTypes();
        expect(isKnownSectionType('pricing')).toBe(false);
    });
});
describe('validateSectionContent', () => {
    it('flags an unknown section type as a hard error', () => {
        const r = validateSectionContent('ghost', {});
        expect(r.valid).toBe(false);
        expect(r.errors[0].message).toMatch(/Unknown section type/);
    });
    it('flags missing required fields', () => {
        const r = validateSectionContent('hero', { heading: '' });
        expect(r.valid).toBe(false);
        expect(r.errors.some((e) => e.field === 'heading')).toBe(true);
    });
    it('flags invalid button URLs and missing image alt text', () => {
        const r = validateSectionContent('feature', {
            heading: 'Has heading',
            buttonLabel: 'Learn more',
            buttonUrl: 'not a url',
            imageUrl: 'https://x/img.png',
            imageAlt: '',
        });
        expect(r.valid).toBe(false);
        expect(r.errors.some((e) => e.message.includes('not a valid URL'))).toBe(true);
        expect(r.errors.some((e) => e.field === 'imageAlt')).toBe(true);
    });
    it('warns when a URL is set without its button label', () => {
        const r = validateSectionContent('hero', {
            heading: 'Title',
            primaryButtonUrl: '/go',
            primaryButtonLabel: '',
        });
        expect(r.valid).toBe(true);
        expect(r.warnings.some((w) => w.field === 'primaryButtonLabel')).toBe(true);
    });
    it('passes a well-formed section', () => {
        const r = validateSectionContent('hero', createSection('hero').content);
        expect(r.valid).toBe(true);
        expect(r.errors).toEqual([]);
    });
    it('validates post-scoped types: quote requires its quote field', () => {
        expect(validateSectionContent('quote', { quote: '' }).valid).toBe(false);
        expect(validateSectionContent('quote', createSection('quote').content).valid).toBe(true);
    });
    it('warns when a gallery image is missing alt text', () => {
        const r = validateSectionContent('gallery', {
            images: [{ url: 'https://x/a.png', alt: '' }],
        });
        expect(r.valid).toBe(true);
        // The gallery field validates through the generic repeater path, so the
        // warning is scoped to the specific row + sub-field.
        expect(r.warnings.some((w) => /^images\[\d+\]\.alt$/.test(w.field ?? ''))).toBe(true);
    });
    it('accepts a default article-body section', () => {
        const r = validateSectionContent('article-body', createSection('article-body').content);
        expect(r.valid).toBe(true);
    });
});
describe('post template helpers', () => {
    it('defaultPostHeader is a full centered config', () => {
        const h = defaultPostHeader();
        expect(h.layout).toBe('centered');
        expect(h.showFeaturedImage).toBe(true);
    });
    it('coercePostHeader fills missing keys and rejects bad layout', () => {
        const h = coercePostHeader({ layout: 'sideways', showAuthor: false });
        expect(h.layout).toBe('centered'); // invalid → default
        expect(h.showAuthor).toBe(false); // explicit override kept
        expect(h.showDate).toBe(true); // missing → default
    });
    it('createPostTemplate seeds a usable default template', () => {
        const t = createPostTemplate('blog');
        expect(t.postType).toBe('blog');
        expect(t.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts']);
        expect(t.sections[0].id).not.toBe(t.sections[1].id);
    });
    it('coercePostTemplate normalizes header + drops unknown sections', () => {
        const t = coercePostTemplate('news', {
            header: { layout: 'overlay' },
            sections: [{ sectionType: 'quote', content: { quote: 'Hi' } }, { sectionType: 'ghost' }],
        });
        expect(t.header.layout).toBe('overlay');
        expect(t.sections).toHaveLength(1);
        expect(t.sections[0].sectionType).toBe('quote');
    });
});
describe('resolveCustomSectionTypes (sections.types vs customTypes)', () => {
    const a = { id: 'a', name: 'A', scope: ['page'], fields: [] };
    const b = { id: 'b', name: 'B', scope: ['page'], fields: [] };
    it('returns undefined when no sections config is given', () => {
        expect(resolveCustomSectionTypes(undefined)).toBeUndefined();
    });
    it('prefers the canonical `types` key', () => {
        expect(resolveCustomSectionTypes({ types: [a] })).toEqual([a]);
    });
    it('falls back to the deprecated `customTypes` alias', () => {
        expect(resolveCustomSectionTypes({ customTypes: [b] })).toEqual([b]);
    });
    it('lets `types` win outright when both are set', () => {
        expect(resolveCustomSectionTypes({ types: [a], customTypes: [b] })).toEqual([a]);
    });
});
describe('coerceSections onUnknown strategy', () => {
    afterEach(() => resetCustomSectionTypes());
    it('reports dropped + preserved sections via coerceSectionsWithReport', () => {
        const report = coerceSectionsWithReport([{ sectionType: 'hero' }, { sectionType: 'ghost' }, { sectionType: 'opal-x' }], { onUnknown: 'drop' });
        expect(report.sections.map((s) => s.sectionType)).toEqual(['hero']);
        expect(report.dropped.map((d) => d.sectionType)).toEqual(['ghost', 'opal-x']);
        expect(report.dropped[0].index).toBe(1);
        expect(report.preserved).toEqual([]);
    });
    it('onUnknown: "preserve" keeps unknowns as unmanaged and records them', () => {
        const report = coerceSectionsWithReport([{ sectionType: 'opal-x', content: { a: 1 } }], {
            onUnknown: 'preserve',
        });
        expect(report.sections[0].unmanaged).toBe(true);
        expect(report.preserved.map((p) => p.sectionType)).toEqual(['opal-x']);
        expect(report.dropped).toEqual([]);
    });
    it('onUnknown: "error" throws UnknownSectionTypeError listing the offenders', () => {
        expect(() => coerceSections([{ sectionType: 'hero' }, { sectionType: 'ghost' }, { sectionType: 'opal-x' }], {
            onUnknown: 'error',
        })).toThrow(UnknownSectionTypeError);
        try {
            coerceSections([{ sectionType: 'ghost' }, { sectionType: 'ghost' }], { onUnknown: 'error' });
        }
        catch (err) {
            expect(err).toBeInstanceOf(UnknownSectionTypeError);
            // de-duplicated
            expect(err.sectionTypes).toEqual(['ghost']);
        }
    });
    it('does not throw in error mode when every type is known', () => {
        expect(() => coerceSections([{ sectionType: 'hero' }], { onUnknown: 'error' })).not.toThrow();
    });
    it('invokes onDrop for each dropped section (drop mode)', () => {
        const dropped = [];
        coerceSections([{ sectionType: 'ghost' }, { sectionType: 'opal-x' }], {
            onUnknown: 'drop',
            onDrop: (ref) => dropped.push(ref.sectionType),
        });
        expect(dropped).toEqual(['ghost', 'opal-x']);
    });
    it('the deprecated preserveUnknown flag still maps to preserve', () => {
        const out = coerceSections([{ sectionType: 'opal-x' }], { preserveUnknown: true });
        expect(out[0].unmanaged).toBe(true);
    });
});
describe('repeater fields', () => {
    afterEach(() => resetCustomSectionTypes());
    it('resolveRepeaterField handles the generic type plus stats/gallery presets', () => {
        expect(isRepeaterField({ key: 'x', label: 'X', type: 'text' })).toBe(false);
        expect(resolveRepeaterField({ key: 's', label: 'S', type: 'stats' })?.rowLabel).toBe('Stat');
        expect(resolveRepeaterField({ key: 'g', label: 'G', type: 'gallery' })?.rowLabel).toBe('Image');
        const generic = resolveRepeaterField({
            key: 'plans',
            label: 'Plans',
            type: 'repeater',
            rowLabel: 'Plan',
            fields: [{ key: 'name', label: 'Name', type: 'text' }],
        });
        expect(generic?.rowLabel).toBe('Plan');
        expect(generic?.fields.map((f) => f.key)).toEqual(['name']);
    });
    it('blankRepeaterRow seeds empty values per sub-field', () => {
        expect(blankRepeaterRow([
            { key: 'name', label: 'Name', type: 'text' },
            { key: 'featured', label: 'Featured', type: 'boolean' },
        ])).toEqual({ name: '', featured: false });
    });
    it('validates required sub-fields and the row cap on a generic repeater', () => {
        registerSectionTypes([
            {
                id: 'pricing',
                name: 'Pricing',
                scope: ['page'],
                fields: [
                    {
                        key: 'plans',
                        label: 'Plans',
                        type: 'repeater',
                        rowLabel: 'Plan',
                        maxRows: 2,
                        fields: [{ key: 'name', label: 'Plan name', type: 'text', required: true }],
                    },
                ],
            },
        ]);
        // a row missing its required sub-field is a scoped error
        const missing = validateSectionContent('pricing', { plans: [{ name: '' }] });
        expect(missing.valid).toBe(false);
        expect(missing.errors.some((e) => e.field === 'plans[0].name')).toBe(true);
        // exceeding maxRows is an error
        const tooMany = validateSectionContent('pricing', {
            plans: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
        });
        expect(tooMany.valid).toBe(false);
        expect(tooMany.errors.some((e) => e.field === 'plans')).toBe(true);
        // a well-formed repeater passes
        expect(validateSectionContent('pricing', { plans: [{ name: 'A' }] }).valid).toBe(true);
    });
});
describe('computeReadingTime', () => {
    it('returns at least 1 minute for empty or missing content', () => {
        expect(computeReadingTime(undefined)).toBe(1);
        expect(computeReadingTime(null)).toBe(1);
        expect(computeReadingTime('')).toBe(1);
        expect(computeReadingTime('   ')).toBe(1);
        expect(computeReadingTime('<p></p>')).toBe(1);
    });
    it('strips HTML tags before counting words', () => {
        const html = `<p>${Array.from({ length: 200 }, () => 'word').join(' ')}</p>`;
        // 200 words / 200 wpm = 1 minute (the tags must not inflate the count)
        expect(computeReadingTime(html)).toBe(1);
    });
    it('rounds to the nearest minute at 200 wpm', () => {
        expect(computeReadingTime(Array.from({ length: 400 }, () => 'w').join(' '))).toBe(2);
        expect(computeReadingTime(Array.from({ length: 500 }, () => 'w').join(' '))).toBe(3);
    });
});
//# sourceMappingURL=sections.test.js.map