import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createDocument, updateDocument, deleteDocument, getDocument, listDocuments, duplicateDocument, getGlobal, updateGlobal, } from '../../actions.js';
import { setActuateConfig } from '../../config/runtime.js';
function createMockDB() {
    const docs = new Map();
    const versions = [];
    return {
        document: {
            create: vi.fn(async ({ data }) => {
                const doc = { id: `doc-${Date.now()}`, ...data, deletedAt: null };
                docs.set(doc.id, doc);
                return doc;
            }),
            findFirst: vi.fn(async ({ where }) => {
                for (const doc of docs.values()) {
                    if (where.id && doc.id !== where.id)
                        continue;
                    if (where.collection && doc.collection !== where.collection)
                        continue;
                    if (where.deletedAt === null && doc.deletedAt !== null)
                        continue;
                    return doc;
                }
                return null;
            }),
            findMany: vi.fn(async ({ where }) => {
                const results = [];
                for (const doc of docs.values()) {
                    if (where?.collection && doc.collection !== where.collection)
                        continue;
                    if (where?.deletedAt === null && doc.deletedAt !== null)
                        continue;
                    if (where?.slug !== undefined && doc.slug !== where.slug)
                        continue;
                    results.push(doc);
                }
                return results;
            }),
            count: vi.fn(async ({ where } = {}) => {
                let n = 0;
                for (const doc of docs.values()) {
                    if (where?.collection && doc.collection !== where.collection)
                        continue;
                    if (where?.deletedAt === null && doc.deletedAt !== null)
                        continue;
                    if (where?.slug !== undefined && doc.slug !== where.slug)
                        continue;
                    n++;
                }
                return n;
            }),
            update: vi.fn(async ({ where, data: updateData }) => {
                const doc = docs.get(where.id);
                if (!doc)
                    throw new Error('Not found');
                Object.assign(doc, updateData);
                return doc;
            }),
        },
        version: {
            create: vi.fn(async ({ data }) => {
                const v = { id: `ver-${versions.length}`, ...data, createdAt: new Date() };
                versions.push(v);
                return v;
            }),
        },
        $transaction: vi.fn(async (fn) => fn({
            document: {
                create: vi.fn(async ({ data }) => {
                    const doc = {
                        id: `doc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
                        ...data,
                        deletedAt: null,
                    };
                    docs.set(doc.id, doc);
                    return doc;
                }),
                update: vi.fn(async ({ where, data: updateData }) => {
                    const doc = docs.get(where.id);
                    if (!doc)
                        throw new Error('Not found');
                    Object.assign(doc, updateData);
                    return doc;
                }),
            },
            version: {
                create: vi.fn(async ({ data }) => {
                    const v = { id: `ver-${versions.length}`, ...data, createdAt: new Date() };
                    versions.push(v);
                    return v;
                }),
            },
        })),
        _docs: docs,
        _versions: versions,
    };
}
function createCtx(db) {
    return { userId: 'user-1', role: 'ADMIN', db };
}
describe('Document CRUD actions', () => {
    let db;
    let ctx;
    beforeEach(() => {
        db = createMockDB();
        ctx = createCtx(db);
    });
    it('createDocument creates a document and version', async () => {
        const doc = await createDocument('pages', { title: 'Test Page', slug: 'test-page' }, ctx);
        expect(doc).toHaveProperty('id');
        expect(doc.collection).toBe('pages');
        expect(doc.status).toBe('DRAFT');
    });
    it('createDocument honors a requested PUBLISHED status and stamps publishedAt', async () => {
        // Regression: createDocument used to hard-code status: 'DRAFT', silently
        // dropping a requested `status: 'PUBLISHED'`, so an agent/SDK could never
        // create-and-publish in one call. EDITOR/ADMIN may publish on create.
        const doc = await createDocument('pages', { title: 'Live Page', slug: 'live', status: 'PUBLISHED' }, ctx);
        expect(doc.status).toBe('PUBLISHED');
        expect(doc.publishedAt).toBeInstanceOf(Date);
    });
    it('createDocument defaults to DRAFT with a null publishedAt when no status is requested', async () => {
        const doc = await createDocument('pages', { title: 'Draft Page', slug: 'draft' }, ctx);
        expect(doc.status).toBe('DRAFT');
        expect(doc.publishedAt).toBeNull();
    });
    it('createDocument hoists a lifecycle status to the column and strips it from `data`', async () => {
        // Regression: the lifecycle status belongs on the document column, never in
        // `data`. A lifecycle-valued `status` must not be persisted inside the JSON
        // (it confuses the admin UI), but it must still set the column.
        const doc = await createDocument('pages', { title: 'Live Page', slug: 'live2', status: 'PUBLISHED' }, ctx);
        expect(doc.status).toBe('PUBLISHED');
        const stored = db._docs.get(doc.id);
        expect(stored.data.status).toBeUndefined();
        expect(stored.data.title).toBe('Live Page');
    });
    it('createDocument compiles a page-builder layout into canonical sections on save (ADR 0002)', async () => {
        // The visual page builder sends a `layout` tree. We store it as editor state
        // (`data._layout`) AND compile it to the canonical `data.sections` so every
        // reader (SDK, MCP, search, /resolve) sees one contract — not just /resolve.
        const layout = {
            type: 'page',
            children: [
                {
                    type: 'section',
                    settings: { htmlId: 'top' },
                    children: [
                        {
                            type: 'block',
                            settings: { blockType: 'hero' },
                            data: { title: 'Welcome', subtitle: 'Hi there' },
                        },
                    ],
                },
            ],
        };
        const doc = await createDocument('pages', { title: 'Built Page', slug: 'built', layout }, ctx);
        const stored = db._docs.get(doc.id);
        const data = stored.data;
        // _layout is retained as editor round-trip state.
        expect(data._layout).toBeDefined();
        // …and canonical sections are derived from it.
        const sections = data.sections;
        expect(Array.isArray(sections)).toBe(true);
        expect(sections[0].sectionType).toBe('hero');
        expect(sections[0].content.heading).toBe('Welcome');
        expect(sections[0].settings.anchorId).toBe('top');
    });
    it('createDocument leaves section-authored pages (no layout) untouched', async () => {
        // A page authored via the flat editor / MCP section tools carries no
        // `_layout`; its hand-authored sections must be stored verbatim.
        const sections = [{ id: 's1', sectionType: 'text', content: { body: 'Hand-written' } }];
        const doc = await createDocument('pages', { title: 'Flat', slug: 'flat', sections }, ctx);
        const stored = db._docs.get(doc.id);
        const data = stored.data;
        expect(data._layout).toBeUndefined();
        expect(data.sections[0].content.body).toBe('Hand-written');
    });
    it('createDocument tolerates an empty/invalid layout (derives empty sections, never throws)', async () => {
        const doc = await createDocument('pages', { title: 'Empty', slug: 'empty', layout: { type: 'page', children: [] } }, ctx);
        const stored = db._docs.get(doc.id);
        const data = stored.data;
        expect(data.sections).toEqual([]);
    });
    it('createDocument rejects publishing for non-publish roles', async () => {
        const authorCtx = { ...ctx, role: 'AUTHOR' };
        await expect(createDocument('pages', { title: 'Nope', slug: 'nope', status: 'PUBLISHED' }, authorCtx)).rejects.toThrow(/permission|publish/i);
    });
    it('createDocument rejects statuses other than DRAFT/PUBLISHED (scheduling goes through update)', async () => {
        await expect(createDocument('pages', { title: 'Sched', slug: 'sched', status: 'SCHEDULED' }, ctx)).rejects.toThrow(/status "SCHEDULED"/);
    });
    it('createDocument treats a non-lifecycle `status` field as data (does not hijack the column)', async () => {
        // Regression: collections like Forms store their own `status` field
        // ('draft' | 'active' | 'archived') inside `data`. createDocument used to
        // mistake that for the document lifecycle status and throw, breaking form
        // creation entirely. A non-DocumentStatus value must be left in data and
        // the column must default to DRAFT.
        const doc = await createDocument('forms', { name: 'Contact', status: 'active' }, ctx);
        expect(doc.status).toBe('DRAFT');
        expect(doc.data.status).toBe('active');
    });
    it('updateDocument treats a non-lifecycle `status` field as data (does not hijack the column)', async () => {
        const created = await createDocument('forms', { name: 'Contact', status: 'draft' }, ctx);
        const docId = created.id;
        db.document.findFirst.mockResolvedValueOnce({
            id: docId,
            collection: 'forms',
            status: 'DRAFT',
            data: { name: 'Contact', status: 'draft' },
            deletedAt: null,
        });
        const updated = await updateDocument('forms', docId, { status: 'active' }, ctx);
        // The lifecycle column is untouched; the form's own status persists in data.
        expect(updated.status).toBe('DRAFT');
        expect(updated.data.status).toBe('active');
    });
    it('updateDocument strips a polluted lifecycle `status` key out of stored data', async () => {
        // A migration/import wrote `data.status: 'PUBLISHED'` (the lifecycle status
        // belongs on the column). Any subsequent edit must clean it out of the JSON
        // so the admin UI stops seeing a phantom status field.
        const created = await createDocument('pages', { title: 'Polluted', slug: 'polluted' }, ctx);
        const docId = created.id;
        db.document.findFirst.mockResolvedValueOnce({
            id: docId,
            collection: 'pages',
            status: 'PUBLISHED',
            data: { title: 'Polluted', slug: 'polluted', status: 'PUBLISHED' },
            deletedAt: null,
        });
        const updated = await updateDocument('pages', docId, { title: 'Clean' }, ctx);
        expect(updated.data.status).toBeUndefined();
        expect(updated.data.title).toBe('Clean');
    });
    it('updateDocument merges data instead of replacing', async () => {
        const doc = await createDocument('pages', { title: 'Page', body: 'Original body', slug: 'page' }, ctx);
        const docId = doc.id;
        db.document.findFirst.mockResolvedValueOnce({
            id: docId,
            collection: 'pages',
            data: { title: 'Page', body: 'Original body', slug: 'page' },
            deletedAt: null,
        });
        const updated = await updateDocument('pages', docId, { title: 'Updated Title' }, ctx);
        expect(updated.data).toBeDefined();
        const updatedData = updated.data;
        expect(updatedData.title).toBe('Updated Title');
        expect(updatedData.body).toBe('Original body');
    });
    it('deleteDocument creates a version record before soft-deleting', async () => {
        const doc = await createDocument('pages', { title: 'Delete Me' }, ctx);
        const docId = doc.id;
        db.document.findFirst.mockResolvedValueOnce({
            id: docId,
            collection: 'pages',
            data: { title: 'Delete Me' },
            deletedAt: null,
        });
        await deleteDocument('pages', docId, ctx);
        const txFn = db.$transaction.mock.calls[db.$transaction.mock.calls.length - 1][0];
        expect(db.$transaction).toHaveBeenCalled();
    });
    it('deleteDocument throws for non-existent document', async () => {
        db.document.findFirst.mockResolvedValueOnce(null);
        await expect(deleteDocument('pages', 'nonexistent', ctx)).rejects.toThrow('not found');
    });
    it('getDocument returns null for non-existent document', async () => {
        db.document.findFirst.mockResolvedValueOnce(null);
        const result = await getDocument('pages', 'nonexistent', ctx);
        expect(result).toBeNull();
    });
    it('listDocuments paginates results', async () => {
        const result = await listDocuments({ collection: 'pages', page: 1, pageSize: 10 }, ctx);
        expect(result).toHaveProperty('docs');
        expect(result).toHaveProperty('total');
        expect(result).toHaveProperty('page');
        expect(result).toHaveProperty('pageSize');
        expect(result).toHaveProperty('totalPages');
    });
    it('listDocuments filters by exact slug (powers MCP relationship resolver, WS-C C2)', async () => {
        // `@@unique([collection, slug])` guarantees at most one match, so a slug
        // filter is the canonical slug→id lookup. Verify the filter is applied
        // and is collection-scoped (a same-slug doc in another collection is
        // not returned).
        await createDocument('services', { title: 'House Cleaning', slug: 'house-cleaning' }, ctx);
        await createDocument('services', { title: 'Office Cleaning', slug: 'office-cleaning' }, ctx);
        await createDocument('pages', { title: 'House Cleaning Page', slug: 'house-cleaning' }, ctx);
        const result = await listDocuments({ collection: 'services', slug: 'house-cleaning' }, ctx);
        expect(result.docs).toHaveLength(1);
        expect(result.total).toBe(1);
        expect(result.docs[0].slug).toBe('house-cleaning');
        expect(result.docs[0].collection).toBe('services');
    });
    it('getGlobal returns the global data rather than the raw document row', async () => {
        db.document.findFirst.mockResolvedValueOnce({
            id: 'global-1',
            collection: 'site-settings',
            data: { siteName: 'MaidPro', phone: '555-0100' },
            deletedAt: null,
        });
        await expect(getGlobal('site-settings', ctx)).resolves.toEqual({
            siteName: 'MaidPro',
            phone: '555-0100',
        });
    });
    it('duplicateDocument creates a copy with (Copy) suffix', async () => {
        const original = await createDocument('pages', { title: 'Original', slug: 'original' }, ctx);
        const originalId = original.id;
        db.document.findFirst.mockResolvedValueOnce({
            id: originalId,
            collection: 'pages',
            data: { title: 'Original', slug: 'original' },
            deletedAt: null,
            locale: null,
            siteId: null,
            templateId: null,
        });
        const copy = await duplicateDocument('pages', originalId, ctx);
        expect(copy.status).toBe('DRAFT');
    });
});
describe('cache.onInvalidate dispatch (Phase 5.5 #1)', () => {
    let db;
    let ctx;
    let onInvalidate;
    beforeEach(() => {
        db = createMockDB();
        ctx = createCtx(db);
        onInvalidate = vi.fn(async () => undefined);
        // The runtime config global is read by actions.ts via getActuateConfig();
        // wire just the cache.onInvalidate hook for these cases.
        setActuateConfig({ cache: { onInvalidate } });
    });
    afterEach(() => {
        // Clear the runtime config between cases so an earlier test's hook
        // can't bleed into a later test that expects "no hook".
        setActuateConfig({});
    });
    it('fires onInvalidate with collection + doc tags after createDocument', async () => {
        const doc = await createDocument('posts', { title: 'Hello', slug: 'hello' }, ctx);
        const id = doc.id;
        expect(onInvalidate).toHaveBeenCalledTimes(1);
        expect(new Set(onInvalidate.mock.calls[0][0])).toEqual(new Set(['actuate', 'actuate:collection:posts', `actuate:doc:${id}`]));
    });
    it('fires onInvalidate after updateDocument with the doc id from the update target', async () => {
        const created = await createDocument('posts', { title: 'Initial', slug: 'init' }, ctx);
        const docId = created.id;
        onInvalidate.mockClear();
        db.document.findFirst.mockResolvedValueOnce({
            id: docId,
            collection: 'posts',
            data: { title: 'Initial', slug: 'init' },
            deletedAt: null,
        });
        await updateDocument('posts', docId, { title: 'Updated' }, ctx);
        expect(onInvalidate).toHaveBeenCalledTimes(1);
        expect(new Set(onInvalidate.mock.calls[0][0])).toEqual(new Set(['actuate', 'actuate:collection:posts', `actuate:doc:${docId}`]));
    });
    it('fires onInvalidate after deleteDocument so CDN tiers drop the cached row', async () => {
        const created = await createDocument('posts', { title: 'Doomed' }, ctx);
        const docId = created.id;
        onInvalidate.mockClear();
        db.document.findFirst.mockResolvedValueOnce({
            id: docId,
            collection: 'posts',
            data: { title: 'Doomed' },
            deletedAt: null,
        });
        await deleteDocument('posts', docId, ctx);
        expect(onInvalidate).toHaveBeenCalledTimes(1);
        expect(new Set(onInvalidate.mock.calls[0][0])).toEqual(new Set(['actuate', 'actuate:collection:posts', `actuate:doc:${docId}`]));
    });
    it('fires onInvalidate with the global tag set after updateGlobal', async () => {
        // updateGlobal upserts — with no existing doc it falls through to the
        // create branch. Either branch must invalidate.
        await updateGlobal('site-settings', { siteName: 'X' }, ctx);
        expect(onInvalidate).toHaveBeenCalledTimes(1);
        expect(new Set(onInvalidate.mock.calls[0][0])).toEqual(new Set(['actuate', 'actuate:global:site-settings']));
    });
    it('does not throw when onInvalidate itself fails — write must still succeed', async () => {
        // Cache invalidation is best-effort. A broken hook (e.g. a transient
        // failure calling revalidateTag) must never roll back the underlying
        // write or surface to the caller as an error.
        onInvalidate.mockImplementation(() => {
            throw new Error('CDN unreachable');
        });
        await expect(createDocument('posts', { title: 'Resilient' }, ctx)).resolves.toMatchObject({
            collection: 'posts',
        });
        expect(onInvalidate).toHaveBeenCalledTimes(1);
    });
});
//# sourceMappingURL=document-crud.test.js.map