import type * as StackbitTypes from '@stackbit/types';
import { updateDocumentWithOperations } from './update-document-operation';

/**
 * The `createContentSourceWithDrafts()` function enhances the provided content
 * source with "Content Drafts" capabilities, enabling editors to modify multiple
 * content fields on a page and then save all pending changes into the CMS in a
 * single action. This reduces the number of content writes in the CMS.
 * For instance, when using this with `GitContentSource`, all pending changes are
 * saved in a single commit.
 *
 * To retrieve draft content in the web framework and reflect changes in Create
 * before they are saved to the CMS (e.g., Git), run the web framework with the
 * `STACKBIT_API_KEY` environment variable set to the value of the `localId`
 * variable printed when running `stackbit dev` locally. Then, pass the
 * `STACKBIT_API_KEY` to the `getCSIDocuments()` method from the `@stackbit/utils`
 * package to retrieve the draft content from the Create server.
 *
 * ```javascript
 * const result = await getCSIDocuments({
 *     stackbitApiKey: process.env.STACKBIT_API_KEY
 * });
 * ```
 *
 * You can also pass additional data to filter out specific documents by ID:
 *
 * ```
 * {"documentSpecs": [{srcType: "git", srcProjectId: "...", "srcDocumentId": "..."}]}
 * ```
 *
 * For example, after running `stackbit dev`, the command will print:
 *
 * ```
 * info: localId: ab1...9yz
 * ```
 *
 * Use the `localId` value to set the `STACKBIT_API_KEY` environment variable
 * when running your web framework:
 *
 * ```
 * STACKBIT_API_KEY=ab1...9yz npm run dev
 * ```
 *
 * Then, use the `STACKBIT_API_KEY` to fetch documents with pending "draft"
 * changes and render them on a page.
 *
 * When the project runs in Create Cloud, a different `STACKBIT_API_KEY` will be
 * generated and injected into the environment variables.
 */
export function createContentSourceWithDrafts<ContentSourceType extends StackbitTypes.ContentSourceInterface<any, any, any, any, any>>(options: {
    contentSource: ContentSourceType;
}): ContentSourceType {
    const contentSource = options.contentSource;
    const contentSourceWithDrafts = new ContentSourceWithDrafts({ contentSource });

    // Return proxied content source and override some of its methods.
    return new Proxy(contentSource, {
        get(target: ContentSourceType, prop: keyof StackbitTypes.ContentSourceInterface): any {
            switch (prop) {
                case 'init':
                    return contentSourceWithDrafts.init;
                case 'getSchema':
                    return contentSourceWithDrafts.getSchema;
                case 'getDocuments':
                    return contentSourceWithDrafts.getDocuments;
                case 'updateDocument':
                    return contentSourceWithDrafts.updateDocument;
                default:
                    return target[prop];
            }
        }
    });
}

type DraftDocumentMap = Record<
    string,
    {
        origDocument: StackbitTypes.Document;
        operations: StackbitTypes.UpdateOperation[];
        saving: boolean;
    }
>;

class ContentSourceWithDrafts<ContentSourceType extends StackbitTypes.ContentSourceInterface>
    implements Pick<StackbitTypes.ContentSourceInterface, 'init' | 'getSchema' | 'getDocuments' | 'updateDocument'>
{
    private contentSource: ContentSourceType;
    private cache!: StackbitTypes.Cache;
    private draftsByDocumentId: DraftDocumentMap;

    constructor(options: { contentSource: ContentSourceType }) {
        this.contentSource = options.contentSource;
        this.draftsByDocumentId = {};
    }

    /**
     * Override the `cache.updateContent` function passed to the content source.
     * This override should ensure that when `cache.updateContent` called by the
     * wrapped content source, it updates the documents passed in
     * `contentChanges.documents` with cached operations, and removes the
     * document specified in `contentChanges.deletedDocumentIds` from the drafts
     * map.
     */
    init = async (options: StackbitTypes.InitOptions<unknown, unknown, unknown, unknown>): Promise<void> => {
        this.cache = options.cache;
        const newOptions = {
            ...options,
            cache: {
                ...options.cache,
                getDocuments: () => {
                    // Documents in the cache may contain unsaved changes.
                    // Map the documents with unsaved changes to the original documents.
                    const documents = options.cache.getDocuments();
                    return documents.map((document) => {
                        const draft = this.draftsByDocumentId[document.id];
                        if (draft) {
                            return draft.origDocument;
                        }
                        return document;
                    });
                },
                getDocumentById: (documentId: string) => {
                    // Documents in the cache may contain unsaved changes.
                    // Return the original document if the document in cache has unsaved changes.
                    const draft = this.draftsByDocumentId[documentId];
                    if (draft) {
                        return draft.origDocument;
                    }
                    return options.cache.getDocumentById(documentId);
                },
                updateContent: async (contentChanges: StackbitTypes.ContentChanges) => {
                    if (Array.isArray(contentChanges.deletedDocumentIds)) {
                        for (const deletedDocumentId of contentChanges.deletedDocumentIds) {
                            delete this.draftsByDocumentId[deletedDocumentId];
                        }
                    }
                    // When the wrapped content source calls cache.updateContent(),
                    // apply the unsaved operations for documents with unsaved content.
                    if (Array.isArray(contentChanges.documents)) {
                        contentChanges = {
                            ...contentChanges,
                            documents: contentChanges.documents.map((document) => {
                                const draft = this.draftsByDocumentId[document.id];
                                if (!draft || draft.saving) {
                                    return document;
                                }
                                return updateDocumentWithOperations({
                                    document,
                                    operations: draft.operations,
                                    getModelByName: this.cache!.getModelByName
                                });
                            })
                        };
                    }
                    await options.cache.updateContent(contentChanges);
                }
            }
        };
        return this.contentSource.init(newOptions);
    };

    /**
     * Override the schema returned by the content source, by adding "Save" and
     * "Cancel" actions to every "data" and "page" model.
     */
    getSchema = async (): ReturnType<StackbitTypes.ContentSourceInterface['getSchema']> => {
        const schema = await this.contentSource.getSchema();
        return {
            ...schema,
            models: schema.models.map((model) => {
                if (model.type === 'data' || model.type === 'page') {
                    const { actions = [], ...restModel } = model;
                    model = {
                        ...restModel,
                        actions: actions.concat(
                            createSaveAction({
                                draftsByDocumentId: this.draftsByDocumentId,
                                contentSource: this.contentSource
                            }),
                            createCancelAction({
                                draftsByDocumentId: this.draftsByDocumentId,
                                cache: this.cache
                            })
                        )
                    };
                }
                return model;
            })
        };
    };

    /**
     * When Stackbit calls `getDocuments` to reload all content source documents,
     * update documents that have drafts with cached operations.
     */
    getDocuments = async (
        options: Parameters<StackbitTypes.ContentSourceInterface['getDocuments']>[0]
    ): ReturnType<StackbitTypes.ContentSourceInterface['getDocuments']> => {
        const getDocumentsResult = await this.contentSource.getDocuments(options);
        const isArray = Array.isArray(getDocumentsResult);
        const documents = isArray ? getDocumentsResult : getDocumentsResult.documents;
        const documentsWithDrafts = documents.map((document) => {
            const draft = this.draftsByDocumentId[document.id];
            if (!draft) {
                return document;
            }
            return updateDocumentWithOperations({
                document,
                operations: draft.operations,
                getModelByName: this.cache!.getModelByName
            });
        });
        return isArray
            ? documentsWithDrafts
            : {
                  ...getDocumentsResult,
                  documents: documentsWithDrafts
              };
    };

    /**
     * When updating a document, accumulate the update operations and store
     * them in `draftsByDocumentId` instead of calling the `updateDocument`
     * method of the wrapped content source.
     */
    updateDocument = async ({
        document,
        operations
    }: Parameters<StackbitTypes.ContentSourceInterface['updateDocument']>[0]): ReturnType<StackbitTypes.ContentSourceInterface['updateDocument']> => {
        let draft = this.draftsByDocumentId[document.id];
        if (!draft) {
            draft = {
                origDocument: this.cache!.getDocumentById(document.id)!,
                operations: [],
                saving: false
            };
        }
        draft.operations = draft.operations.concat(operations);
        const updatedDocument = updateDocumentWithOperations({
            document,
            operations,
            getModelByName: this.cache!.getModelByName
        });
        this.draftsByDocumentId[document.id] = draft;
        await this.cache!.updateContent({
            documents: [updatedDocument]
        }).catch(() => {
            // TODO: log error
        });
    };
}

/**
 * Create a custom action that invokes the `updateDocument` method on the
 * wrapped content source with the cached update operations for a specific
 * document.
 */
function createSaveAction({
    draftsByDocumentId,
    contentSource
}: {
    draftsByDocumentId: DraftDocumentMap;
    contentSource: StackbitTypes.ContentSourceInterface;
}): StackbitTypes.CustomActionDocument {
    return {
        name: 'save_draft',
        label: 'Save',
        icon: 'check',
        preferredStyle: 'button-primary',
        state: async ({ document }) => {
            return draftsByDocumentId[document.id] ? 'enabled' : 'disabled';
        },
        run: async ({ document, currentUser }) => {
            const draft = draftsByDocumentId[document.id];
            if (!draft) {
                return;
            }
            try {
                // Update documents in content-source (CMS)
                // Mark the draft as being saved, in case `updateDocument()`
                // calls `cache.updateContent()` after updating the document
                // with operations, and so the overriden cache won't re-apply
                // the same operations again.
                draft.saving = true;
                await contentSource.updateDocument({
                    document: draft.origDocument,
                    operations: draft.operations,
                    userContext: currentUser
                });
                delete draftsByDocumentId[document.id];
            } catch (error) {
                // If the update was unsuccessful, don't delete the draft and
                // revert `saving` back to false, so the user won't lose their
                // unsaved changes.
                draft.saving = false;
                throw error;
            }
        }
    };
}

/**
 * Create a custom action that discards any cached operations for a specific document.
 */
function createCancelAction({
    draftsByDocumentId,
    cache
}: {
    draftsByDocumentId: DraftDocumentMap;
    cache: StackbitTypes.Cache;
}): StackbitTypes.CustomActionDocument {
    return {
        name: 'cancel_draft',
        label: 'Cancel',
        icon: 'xmark',
        preferredStyle: 'button-secondary',
        state: async ({ document }) => {
            return draftsByDocumentId[document.id] ? 'enabled' : 'disabled';
        },
        run: async ({ document, currentUser }) => {
            const draft = draftsByDocumentId[document.id];
            if (draft) {
                delete draftsByDocumentId[document.id];
                // update documents in content-store
                await cache!.updateContent({
                    documents: [draft.origDocument]
                });
            }
        }
    };
}
