import { describe, expect, test } from '@jest/globals';
import type * as StackbitTypes from '@stackbit/types';
import { createContentSourceWithDrafts } from '../src';
import { createMockedContentSource, createMockInitOptions } from './test-utils/content-source-mock';

const models: StackbitTypes.Model[] = [
    {
        type: 'page',
        name: 'pageModel',
        actions: [
            {
                name: 'existing_action',
                type: 'document',
                run: async () => {}
            }
        ],
        fields: [
            {
                type: 'string',
                name: 'title'
            },
            {
                type: 'enum',
                name: 'style',
                options: ['option-a', 'option-b']
            }
        ]
    },
    {
        type: 'data',
        name: 'dataModel',
        fields: [
            {
                type: 'string',
                name: 'title'
            }
        ]
    },
    {
        type: 'object',
        name: 'objectModel',
        fields: [
            {
                type: 'string',
                name: 'title'
            }
        ]
    }
];

describe('test createContentSourceWithDrafts', () => {
    test('should override getSchema function and add "save_draft" and "cancel_draft" actions to existing actions of a page model', async () => {
        const contentSource = createMockedContentSource({
            getSchema: async () => {
                return {
                    models: models,
                    locales: [],
                    context: null
                };
            }
        });
        const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource });
        const schema = await contentSourceWithDrafts.getSchema();
        const pageModel = schema.models.find((model) => model.name === 'pageModel') as StackbitTypes.PageModel;
        expect(pageModel.actions).toEqual([
            {
                name: 'existing_action',
                type: 'document',
                run: expect.any(Function)
            },
            {
                name: 'save_draft',
                label: 'Save',
                icon: 'check',
                preferredStyle: 'button-primary',
                state: expect.any(Function),
                run: expect.any(Function)
            },
            {
                name: 'cancel_draft',
                label: 'Cancel',
                icon: 'xmark',
                preferredStyle: 'button-secondary',
                state: expect.any(Function),
                run: expect.any(Function)
            }
        ]);
    });

    test('should override getSchema function and add "save_draft" and "cancel_draft" actions to data model', async () => {
        const contentSource = createMockedContentSource({
            getSchema: async () => {
                return {
                    models: models,
                    locales: [],
                    context: null
                };
            }
        });
        const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource });
        const schema = await contentSourceWithDrafts.getSchema();
        const dataModel = schema.models.find((model) => model.name === 'dataModel') as StackbitTypes.DataModel;
        expect(dataModel.actions).toEqual([
            {
                name: 'save_draft',
                label: 'Save',
                icon: 'check',
                preferredStyle: 'button-primary',
                state: expect.any(Function),
                run: expect.any(Function)
            },
            {
                name: 'cancel_draft',
                label: 'Cancel',
                icon: 'xmark',
                preferredStyle: 'button-secondary',
                state: expect.any(Function),
                run: expect.any(Function)
            }
        ]);
    });

    test('no actions should be added to object models', async () => {
        const contentSource = createMockedContentSource({
            getSchema: async () => {
                return {
                    models: models,
                    locales: [],
                    context: null
                };
            }
        });
        const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource });
        const schema = await contentSourceWithDrafts.getSchema();
        const objectModel = schema.models.find((model) => model.name === 'objectModel') as StackbitTypes.ObjectModel;
        expect(objectModel).toEqual({
            type: 'object',
            name: 'objectModel',
            fields: [
                {
                    type: 'string',
                    name: 'title'
                }
            ]
        });
    });

    test('should concatenate update operations, and call updateDocument of the wrapped content source when "save_draft" is invoked', async () => {
        const contentSource = createMockedContentSource({
            getSchema: async () => {
                return {
                    models: models,
                    locales: [],
                    context: null
                };
            },
            updateDocument: async () => {}
        });
        // existing document
        const document: StackbitTypes.Document = {
            type: 'document',
            modelName: 'pageModel',
            id: 'doc-1',
            status: 'modified',
            manageUrl: '',
            createdAt: '2024-01-18T10:00:00.000Z',
            updatedAt: '2024-01-18T14:00:00.000Z',
            context: null,
            fields: {}
        };
        const initOptionsMock = createMockInitOptions();
        initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName));

        // Create content source with drafts wrapping the mocked content source
        const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource });
        await contentSourceWithDrafts.init(initOptionsMock);

        const schema = await contentSourceWithDrafts.getSchema();
        const pageModel = schema.models.find((model) => model.name === 'pageModel')! as StackbitTypes.PageModel;
        const saveDraftAction = pageModel.actions!.find((action) => action.name === 'save_draft')! as StackbitTypes.CustomActionDocument;

        // The "save" action state should be disabled because there are no cached changes.
        let saveActionState = await saveDraftAction.state!({
            document: document
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(saveActionState).toEqual('disabled');

        // Call updateDocument on the content source with drafts passing an
        // update operation with updates to the "title" field.
        initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document);
        const firstOperation: StackbitTypes.UpdateOperation = {
            opType: 'set',
            modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!,
            fieldPath: ['title'],
            field: {
                type: 'string',
                value: 'new title'
            }
        };
        await contentSourceWithDrafts.updateDocument({
            document,
            operations: [firstOperation]
        });

        // The content source with drafts should call the cache.updateContent
        // and pass the updated document.
        let expectedDocument: StackbitTypes.Document = {
            ...document,
            fields: {
                title: {
                    type: 'string',
                    value: 'new title'
                }
            }
        };
        expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1);
        expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, {
            documents: [expectedDocument]
        });

        // Call updateDocument on the content source with drafts again passing
        // an update operation with updates to the "style" field.
        // The document passed to updateDocument should include updates to the
        // previously updated "title" field.
        const secondOperation: StackbitTypes.UpdateOperation = {
            opType: 'set',
            modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'style')!,
            fieldPath: ['style'],
            field: {
                type: 'enum',
                value: 'option-b'
            }
        };
        await contentSourceWithDrafts.updateDocument({
            document: expectedDocument,
            operations: [secondOperation]
        });

        // The content source with drafts should call the cache.updateContent
        // again and pass the updated document with both fields.
        expectedDocument = {
            ...document,
            fields: {
                title: {
                    type: 'string',
                    value: 'new title'
                },
                style: {
                    type: 'enum',
                    value: 'option-b'
                }
            }
        };
        expect(initOptionsMock.cache.updateContent).toBeCalledTimes(2);
        expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(2, {
            documents: [expectedDocument]
        });

        // The updateDocument of the wrapped content source should not be called.
        expect(contentSource.updateDocument).not.toBeCalled();

        // The "save" action state should be enabled because there are cached changes.
        saveActionState = await saveDraftAction.state!({
            document: expectedDocument
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(saveActionState).toEqual('enabled');

        // Call "save" action run function.
        await saveDraftAction.run({
            document: expectedDocument
        } as StackbitTypes.CustomActionRunCommonOptions & StackbitTypes.CustomActionDocumentRunOptions);

        // The updateDocument of the wrapped content source should be called with
        // the cached operations and the original document.
        expect(contentSource.updateDocument).toBeCalledTimes(1);
        expect(contentSource.updateDocument).toHaveBeenNthCalledWith(1, {
            document: document,
            operations: [firstOperation, secondOperation]
        });

        // The "save" action state should be disabled again because all cached
        // changes were saved.
        saveActionState = await saveDraftAction.state!({
            document: expectedDocument
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(saveActionState).toEqual('disabled');
    });

    test('should concatenate update operations, and clear them when "cancel_draft" is invoked', async () => {
        const contentSource = createMockedContentSource({
            getSchema: async () => {
                return {
                    models: models,
                    locales: [],
                    context: null
                };
            }
        });
        // existing document
        const document: StackbitTypes.Document = {
            type: 'document',
            modelName: 'pageModel',
            id: 'doc-1',
            status: 'modified',
            manageUrl: '',
            createdAt: '2024-01-18T10:00:00.000Z',
            updatedAt: '2024-01-18T14:00:00.000Z',
            context: null,
            fields: {}
        };
        const initOptionsMock = createMockInitOptions();
        initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName));

        // Create content source with drafts wrapping the mocked content source
        const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource });
        await contentSourceWithDrafts.init(initOptionsMock);

        const schema = await contentSourceWithDrafts.getSchema();
        const pageModel = schema.models.find((model) => model.name === 'pageModel')! as StackbitTypes.PageModel;
        const cancelDraftAction = pageModel.actions!.find((action) => action.name === 'cancel_draft')! as StackbitTypes.CustomActionDocument;

        // The "cancel" action state should be disabled because there are no cached changes.
        let cancelActionState = await cancelDraftAction.state!({
            document: document
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(cancelActionState).toEqual('disabled');

        // Call updateDocument on the content source with drafts passing an
        // update operation with updates to the "title" field.
        initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document);
        await contentSourceWithDrafts.updateDocument({
            document,
            operations: [
                {
                    opType: 'set',
                    modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!,
                    fieldPath: ['title'],
                    field: {
                        type: 'string',
                        value: 'new title'
                    }
                }
            ]
        });

        // The content source with drafts should call the cache.updateContent
        // again and pass the updated document with both fields.
        const expectedDocument: StackbitTypes.Document = {
            ...document,
            fields: {
                title: {
                    type: 'string',
                    value: 'new title'
                }
            }
        };
        expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1);
        expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, {
            documents: [expectedDocument]
        });

        // The "cancel" action state should be enabled because there are cached changes.
        cancelActionState = await cancelDraftAction.state!({
            document: expectedDocument
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(cancelActionState).toEqual('enabled');

        // Call "cancel" action run function.
        await cancelDraftAction.run({
            document: expectedDocument
        } as StackbitTypes.CustomActionRunCommonOptions & StackbitTypes.CustomActionDocumentRunOptions);

        // The "cancel" action state should be disabled because we cancelled cached changes.
        cancelActionState = await cancelDraftAction.state!({
            document
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(cancelActionState).toEqual('disabled');

        // The updateDocument of the wrapped content source should not be called.
        expect(contentSource.updateDocument).not.toBeCalled();
    });

    test('should override getDocuments and update documents with cached operations when wrapped content source calls getDocuments', async () => {
        // existing document
        const document: StackbitTypes.Document = {
            type: 'document',
            modelName: 'pageModel',
            id: 'doc-1',
            status: 'modified',
            manageUrl: '',
            createdAt: '2024-01-18T10:00:00.000Z',
            updatedAt: '2024-01-18T14:00:00.000Z',
            context: null,
            fields: {}
        };
        const contentSource = createMockedContentSource({
            getSchema: async () => {
                return {
                    models: models,
                    locales: [],
                    context: null
                };
            },
            getDocuments: async () => {
                return [document];
            }
        });
        const initOptionsMock = createMockInitOptions();
        initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName));

        // Create content source with drafts wrapping the mocked content source
        const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource });
        await contentSourceWithDrafts.init(initOptionsMock);

        // Call updateDocument on the content source with drafts passing an
        // update operation with updates to the "title" field.
        initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document);
        const operation: StackbitTypes.UpdateOperation = {
            opType: 'set',
            modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!,
            fieldPath: ['title'],
            field: {
                type: 'string',
                value: 'new title'
            }
        };
        await contentSourceWithDrafts.updateDocument({
            document,
            operations: [operation]
        });

        // The content source with drafts should call the cache.updateContent
        // and pass the updated document with the updated "title" field.
        const expectedDocument: StackbitTypes.Document = {
            ...document,
            fields: {
                title: {
                    type: 'string',
                    value: 'new title'
                }
            }
        };
        expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1);
        expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, {
            documents: [expectedDocument]
        });

        // Stackbit calls getDocuments on the wrapped content source.
        const documents = await contentSourceWithDrafts!.getDocuments();

        // The wrapped content source getDocuments should be called once
        expect(contentSource.getDocuments).toHaveBeenCalledTimes(1);

        // The returned documents should include cached changes
        expect(documents).toEqual([expectedDocument]);
    });

    test('should override cache.updateContent and update documents with cached operations when wrapped content source calls cache.updateContent', async () => {
        let contentSourceCache: StackbitTypes.Cache;
        const contentSource = createMockedContentSource({
            init: async (options: StackbitTypes.InitOptions): Promise<void> => {
                contentSourceCache = options.cache;
            },
            getSchema: async () => {
                return {
                    models: models,
                    locales: [],
                    context: null
                };
            },
            updateDocument: async () => {}
        });
        // existing document
        const document: StackbitTypes.Document = {
            type: 'document',
            modelName: 'pageModel',
            id: 'doc-1',
            status: 'modified',
            manageUrl: '',
            createdAt: '2024-01-18T10:00:00.000Z',
            updatedAt: '2024-01-18T14:00:00.000Z',
            context: null,
            fields: {}
        };
        const initOptionsMock = createMockInitOptions();
        initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName));

        // Create content source with drafts wrapping the mocked content source
        const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource });
        await contentSourceWithDrafts.init(initOptionsMock);

        const schema = await contentSourceWithDrafts.getSchema();
        const pageModel = schema.models.find((model) => model.name === 'pageModel')! as StackbitTypes.PageModel;
        const saveDraftAction = pageModel.actions!.find((action) => action.name === 'save_draft')! as StackbitTypes.CustomActionDocument;

        // Call updateDocument on the content source with drafts passing an
        // update operation with updates to the "title" field.
        initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document);
        const operation: StackbitTypes.UpdateOperation = {
            opType: 'set',
            modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!,
            fieldPath: ['title'],
            field: {
                type: 'string',
                value: 'new title'
            }
        };
        await contentSourceWithDrafts.updateDocument({
            document,
            operations: [operation]
        });

        // The content source with drafts should call the cache.updateContent
        // and pass the updated document with the updated "title" field.
        let expectedDocument: StackbitTypes.Document = {
            ...document,
            fields: {
                title: {
                    type: 'string',
                    value: 'new title'
                }
            }
        };
        expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1);
        expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, {
            documents: [expectedDocument]
        });

        // The wrapped content source calls cache.updateContent updating a
        // different "style" field.
        await contentSourceCache!.updateContent({
            documents: [
                {
                    ...document,
                    fields: {
                        style: {
                            type: 'enum',
                            value: 'option-b'
                        }
                    }
                }
            ]
        });

        // The content source with drafts should pass the document with the
        // updated "style" field and also add the cached "title" field.
        expectedDocument = {
            ...document,
            fields: {
                title: {
                    type: 'string',
                    value: 'new title'
                },
                style: {
                    type: 'enum',
                    value: 'option-b'
                }
            }
        };
        expect(initOptionsMock.cache.updateContent).toBeCalledTimes(2);
        expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(2, {
            documents: [expectedDocument]
        });

        // The "save" action state should be enabled because there are cached changes
        let saveActionState = await saveDraftAction.state!({
            document: expectedDocument
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(saveActionState).toEqual('enabled');

        // Call "save" action run function
        await saveDraftAction.run({
            document: expectedDocument
        } as StackbitTypes.CustomActionRunCommonOptions & StackbitTypes.CustomActionDocumentRunOptions);

        // The updateDocument of the wrapped content source should be called with
        // the cached operations and the original document.
        expect(contentSource.updateDocument).toBeCalledTimes(1);
        expect(contentSource.updateDocument).toHaveBeenNthCalledWith(1, {
            document: document,
            operations: [operation]
        });

        // The "save" action state should be disabled because cached changes are saved
        saveActionState = await saveDraftAction.state!({
            document: expectedDocument
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(saveActionState).toEqual('disabled');
    });

    test('should override cache.updateContent and delete cached documents with cached operations when wrapped content source calls cache.updateContent with deleted documents', async () => {
        let contentSourceCache: StackbitTypes.Cache;
        const contentSource = createMockedContentSource({
            init: async (options: StackbitTypes.InitOptions): Promise<void> => {
                contentSourceCache = options.cache;
            },
            getSchema: async () => {
                return {
                    models: models,
                    locales: [],
                    context: null
                };
            }
        });
        // existing document
        const document: StackbitTypes.Document = {
            type: 'document',
            modelName: 'pageModel',
            id: 'doc-1',
            status: 'modified',
            manageUrl: '',
            createdAt: '2024-01-18T10:00:00.000Z',
            updatedAt: '2024-01-18T14:00:00.000Z',
            context: null,
            fields: {}
        };
        const initOptionsMock = createMockInitOptions();
        initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName));

        // Create content source with drafts wrapping the mocked content source
        const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource });
        await contentSourceWithDrafts.init(initOptionsMock);

        const schema = await contentSourceWithDrafts.getSchema();
        const pageModel = schema.models.find((model) => model.name === 'pageModel')! as StackbitTypes.PageModel;
        const saveDraftAction = pageModel.actions!.find((action) => action.name === 'save_draft')! as StackbitTypes.CustomActionDocument;

        // Call updateDocument on the content source with drafts passing an
        // update operation with updates to the "title" field.
        initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document);
        await contentSourceWithDrafts.updateDocument({
            document,
            operations: [
                {
                    opType: 'set',
                    modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!,
                    fieldPath: ['title'],
                    field: {
                        type: 'string',
                        value: 'new title'
                    }
                }
            ]
        });

        // The content source with drafts should call the cache.updateContent
        // and pass the updated document.
        const expectedDocument: StackbitTypes.Document = {
            ...document,
            fields: {
                title: {
                    type: 'string',
                    value: 'new title'
                }
            }
        };
        expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1);
        expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, {
            documents: [expectedDocument]
        });

        // The "save" action state should be enabled because there are cached changes.
        let saveActionState = await saveDraftAction.state!({
            document: expectedDocument
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(saveActionState).toEqual('enabled');

        // Wrapped content source calls updateContent with deleted document ID
        await contentSourceCache!.updateContent({
            deletedDocumentIds: [document.id]
        });

        // The content source with drafts should call cache.updateContent and
        // propagate the deleted document ID
        expect(initOptionsMock.cache.updateContent).toBeCalledTimes(2);
        expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(2, {
            deletedDocumentIds: [document.id]
        });

        // The "save" action state should be disabled because the document with
        // cached changes was deleted.
        saveActionState = await saveDraftAction.state!({
            document: expectedDocument
        } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions);
        expect(saveActionState).toEqual('disabled');
    });
});
