import _ from 'lodash';
import { v4 as uuid } from 'uuid';
import tinycolor from 'tinycolor2';
import type { PatchOperations, SanityDocument } from '@sanity/client';
import type * as StackbitTypes from '@stackbit/types';
import type { ModelContext, ModelWithContext } from './sanity-schema-converter';
import { getItemTypeForListItem, isLocalizedModelField, getSanityAliasFieldType, resolvedFieldType } from './utils';
import type { GetModelByName } from './sanity-document-converter';

export function convertUpdateOperation({
    operation,
    ...rest
}: {
    operation: StackbitTypes.UpdateOperation;
    sanityDocument: SanityDocument;
    getModelByName: GetModelByName;
    model: ModelWithContext;
}): PatchOperations {
    switch (operation.opType) {
        case 'set':
            return Operations.set({ operation, ...rest });
        case 'unset':
            return Operations.unset({ operation, ...rest });
        case 'insert':
            return Operations.insert({ operation, ...rest });
        case 'remove':
            return Operations.remove({ operation, ...rest });
        case 'reorder':
            return Operations.reorder({ operation, ...rest });
    }
}

export const Operations: {
    [Type in StackbitTypes.UpdateOperation as Type['opType']]: ({
        sanityDocument,
        operation,
        getModelByName
    }: {
        sanityDocument: SanityDocument;
        operation: Type;
        getModelByName: GetModelByName;
        model: StackbitTypes.Model<ModelContext>;
    }) => PatchOperations;
} = {
    set: ({ operation, sanityDocument, getModelByName, model }) => {
        const { field, fieldPath, modelField, locale } = operation;
        const { patchFieldPath, modelFieldPath, localizedLeaf, isInList } = getPatchPathAndModelFieldPaths({
            fieldPath,
            sanityDocument,
            getModelByName,
            addValueToI18NLeafs: true,
            allowUndefinedI18NLeafArrays: true,
            locale
        });
        let value = mapUpdateOperationFieldToSanityValue({
            updateOperationField: field,
            getModelByName,
            modelField,
            rootModel: model,
            modelFieldPath,
            locale,
            isInList
        });
        if (isLocalizedModelField(modelField) && localizedLeaf !== 'value') {
            value = localizedValue({
                value,
                model,
                modelFieldPath,
                locale
            });
            if (localizedLeaf === 'newItem') {
                return {
                    insert: {
                        after: `${patchFieldPath}[-1]`,
                        items: [value]
                    }
                };
            } else if (localizedLeaf === 'undefinedArray') {
                return { set: { [patchFieldPath]: [value] } };
            }
        }
        return { set: { [patchFieldPath]: value } };
    },
    unset: ({ operation, sanityDocument, getModelByName }) => {
        const { fieldPath, locale } = operation;
        const { patchFieldPath } = getPatchPathAndModelFieldPaths({
            fieldPath,
            sanityDocument,
            getModelByName,
            locale
        });
        return { unset: [patchFieldPath] };
    },
    insert: ({ operation, sanityDocument, getModelByName, model }) => {
        const { item, fieldPath, modelField, index, locale } = operation;
        const listItemModelField = (modelField as StackbitTypes.FieldList).items ?? { type: 'string' };
        const { patchFieldPath, modelFieldPath, currentValue, localizedLeaf } = getPatchPathAndModelFieldPaths({
            fieldPath,
            sanityDocument,
            getModelByName,
            addValueToI18NLeafs: true,
            allowUndefinedI18NLeafArrays: true,
            locale
        });
        let value = mapUpdateOperationFieldToSanityValue({
            updateOperationField: item,
            getModelByName,
            modelField: listItemModelField,
            rootModel: model,
            modelFieldPath: modelFieldPath.concat('items'),
            locale,
            isInList: true
        });
        // In the case of a localized array field, the field will contain an
        // array of objects with localized arrays:
        // [
        //   {
        //     _key: 'en',
        //     _type: 'internationalizedArrayLocalizedArray',
        //     value: ['en value 1', 'en value 2', 'en value 3']
        //   },
        //   {
        //     _key: 'es',
        //     _type: 'internationalizedArrayLocalizedArray',
        //     value: ['es value 1', 'es value 2', 'es value 3']
        //   }
        // ]
        if (isLocalizedModelField(modelField) && localizedLeaf !== 'value') {
            // When there is no array for a given locale, create a new localized
            // array with a single value and insert it into the localized array:
            // {
            //   _key: 'es',
            //   _type: 'internationalizedArrayLocalizedArray',
            //   value: [value]
            // }
            value = localizedValue({
                value: [value],
                model,
                modelFieldPath,
                locale
            });
            if (localizedLeaf === 'newItem') {
                return {
                    insert: {
                        after: `${patchFieldPath}[-1]`,
                        items: [value]
                    }
                };
            } else if (localizedLeaf === 'undefinedArray') {
                return { set: { [patchFieldPath]: [value] } };
            }
        }
        if (!currentValue) {
            return { set: { [patchFieldPath]: [value] } };
        } else if (_.isNil(index) || index >= currentValue.length) {
            return {
                insert: {
                    after: `${patchFieldPath}[-1]`,
                    items: [value]
                }
            };
        }
        return {
            insert: {
                before: `${patchFieldPath}[${index}]`,
                items: [value]
            }
        };
    },
    remove: ({ sanityDocument, operation, getModelByName }) => {
        const { fieldPath, index, locale } = operation;
        const { patchFieldPath } = getPatchPathAndModelFieldPaths({
            fieldPath: fieldPath.concat(index),
            sanityDocument,
            getModelByName,
            locale
        });
        return {
            unset: [patchFieldPath]
        };
    },
    reorder: ({ sanityDocument, operation, getModelByName }) => {
        const { fieldPath, order, locale } = operation;
        const { patchFieldPath, currentValue } = getPatchPathAndModelFieldPaths({
            fieldPath,
            sanityDocument,
            getModelByName,
            addValueToI18NLeafs: true,
            locale
        });
        const newEntryArr = order.map((newIndex) => currentValue[newIndex]);
        return { set: { [patchFieldPath]: newEntryArr } };
    }
};

export function mapUpdateOperationFieldToSanityValue({
    updateOperationField,
    getModelByName,
    modelField,
    rootModel,
    modelFieldPath,
    locale,
    isInList
}: {
    updateOperationField: StackbitTypes.UpdateOperationField;
    getModelByName: GetModelByName;
    modelField: StackbitTypes.FieldSpecificProps;
    rootModel: StackbitTypes.Model<ModelContext>;
    modelFieldPath: string[];
    locale: string | undefined;
    isInList?: boolean;
}): any {
    switch (updateOperationField.type) {
        case 'string':
        case 'url':
        case 'text':
        case 'markdown':
        case 'html':
        case 'boolean':
        case 'date':
        case 'datetime':
        case 'enum':
        case 'style':
        case 'json':
        case 'richText':
        case 'file': {
            return updateOperationField.value;
        }
        case 'number': {
            return Number(updateOperationField.value);
        }
        case 'slug': {
            return {
                _type: getSanityAliasFieldType({
                    resolvedType: 'slug',
                    model: rootModel,
                    modelFieldPath
                }),
                current: updateOperationField.value
            };
        }
        case 'color': {
            const color = tinycolor(updateOperationField.value);
            return {
                _type: getSanityAliasFieldType({
                    resolvedType: 'color',
                    model: rootModel,
                    modelFieldPath
                }),
                hex: color.toHexString(),
                alpha: color.getAlpha(),
                hsl: {
                    _type: 'hslaColor',
                    ...color.toHsl()
                },
                hsv: {
                    _type: 'hsvaColor',
                    ...color.toHsv()
                },
                rgb: {
                    _type: 'rgbaColor',
                    ...color.toRgb()
                }
            };
        }
        case 'image': {
            const value = updateOperationField?.value;
            if (modelField.type === 'image') {
                if (modelField.source === 'cloudinary' || modelField.source === 'aprimo') {
                    const type = modelField.source === 'cloudinary' ? 'cloudinary.asset' : 'aprimo.cdnasset';
                    return addKeyIfInList(
                        {
                            _type: type,
                            ...value
                        },
                        isInList
                    );
                } else if (modelField.source === 'bynder') {
                    let imageValue = value;
                    if (imageValue?.__typename) {
                        imageValue = _.omitBy(
                            {
                                id: value.id,
                                name: value.name,
                                databaseId: value.databaseId,
                                type: value.type,
                                previewUrl: value.type === 'VIDEO' ? value.previewUrls[0] : value.files.webImage.url,
                                previewImg: value.files.webImage.url,
                                datUrl: value.files.transformBaseUrl?.url,
                                videoUrl: value.type === 'VIDEO' ? value.files.original?.url : null,
                                description: value.description,
                                aspectRatio: value.height / value.width
                            },
                            _.isUndefined
                        );
                    }
                    return addKeyIfInList(
                        {
                            _type: 'bynder.asset',
                            ...imageValue
                        },
                        isInList
                    );
                }
            }
            // TODO: there is a bug right now because documentField is inferred from the model which is an "image", not reference
            return addKeyIfInList(linkForAssetId(value), isInList);
        }
        case 'object': {
            if (modelField.type !== 'object') {
                throw new Error(`Operation field type 'object' does not match model field type '${modelField.type}'.`);
            }
            // Sanity array fields may consist of anonymous 'object' with names.
            // When creating such objects, the '_type' should be set to their
            // name to identify them among other 'object' types.
            const fieldAlias = rootModel.context?.fieldAliasMap?.[modelFieldPath.join('.')] ?? [];
            const typeName = fieldAlias?.find((alias) => alias.resolvedTypeName === 'object')?.origTypeName;
            const object = addKeyIfInList(
                {
                    ...(typeName ? { _type: typeName } : null)
                },
                isInList
            );
            return _.reduce(
                updateOperationField.fields,
                (result, childUpdateOperationField, fieldName) => {
                    const childModelField = _.find(modelField.fields, (field) => field.name === fieldName);
                    if (!childModelField) {
                        throw new Error(
                            `No model field found for field '${fieldName}' in model '${rootModel.name}' at field path ${modelFieldPath.join('.')}.`
                        );
                    }
                    const childModelFieldPath = modelFieldPath.concat(fieldName);
                    const value = mapUpdateOperationFieldToSanityValue({
                        updateOperationField: childUpdateOperationField,
                        getModelByName,
                        modelField: childModelField,
                        rootModel,
                        modelFieldPath: childModelFieldPath,
                        locale
                    });
                    if (isLocalizedModelField(childModelField)) {
                        _.set(result, fieldName, [
                            localizedValue({
                                value,
                                model: rootModel,
                                modelFieldPath: childModelFieldPath,
                                locale
                            })
                        ]);
                    } else {
                        _.set(result, fieldName, value);
                    }
                    return result;
                },
                object
            );
        }
        case 'model': {
            if (modelField.type !== 'model') {
                throw new Error(`Operation field type 'model' does not match model field type '${modelField.type}'.`);
            }
            const modelName = updateOperationField.modelName;
            const childModel = getModelByName(modelName);
            if (!childModel) {
                throw new Error(`No model '${modelName}' was found for field at '${modelFieldPath.join('.')}' in model '${rootModel.name}'.`);
            }
            const object = addKeyIfInList(
                {
                    _type: getSanityAliasFieldType({
                        resolvedType: modelName,
                        model: rootModel,
                        modelFieldPath
                    })
                },
                isInList
            );
            return _.reduce(
                updateOperationField.fields,
                (result, childUpdateOperationField, fieldName) => {
                    const childModelField = _.find(childModel?.fields, (field) => field.name === fieldName);
                    if (!childModelField) {
                        throw new Error(`No model field found for field '${fieldName}' in model '${childModel.name}'.`);
                    }
                    const childModelFieldPath = [fieldName];
                    const value = mapUpdateOperationFieldToSanityValue({
                        updateOperationField: childUpdateOperationField,
                        getModelByName,
                        modelField: childModelField,
                        rootModel: childModel,
                        modelFieldPath: childModelFieldPath,
                        locale
                    });
                    if (isLocalizedModelField(childModelField)) {
                        _.set(result, fieldName, [
                            localizedValue({
                                value,
                                model: rootModel,
                                modelFieldPath: childModelFieldPath,
                                locale
                            })
                        ]);
                    } else {
                        _.set(result, fieldName, value);
                    }
                    return result;
                },
                object
            );
        }
        case 'reference': {
            const value =
                updateOperationField.refType === 'document'
                    ? {
                          _ref: updateOperationField.refId,
                          _type: getSanityAliasFieldType({
                              resolvedType: 'reference',
                              model: rootModel,
                              modelFieldPath
                          }),
                          // TODO: this is a bug!
                          //  The _weak is not always `true`. Lookup the referenced document
                          //  by id, and the original model field's `weak` value.
                          //  If the referenced document was published (status === 'published'
                          //  or status === 'modified'), then the _weak value should be set to
                          //  the `weak` value defined in Sanity model field's.
                          //  Otherwise, if the document was not published (status === 'added'),
                          //  then the _weak value should be set to `true` and the
                          //  _strengthenOnPublish.weak should be set to the `weak` value
                          //  defined in the model field's.
                          _weak: true,
                          // TODO: Lookup the referenced document by id, and the original model
                          //  field's `weak` value. If the referenced document was never published
                          //  (status === 'added'), then add the _strengthenOnPublish object and
                          //  set its `weak` property to the model field's `weak` value.
                          //  When publishing objects with reference fields having the
                          //  _strengthenOnPublish object, update the field's _weak with that of
                          //  _strengthenOnPublish.weak.
                          //  For more info: https://www.sanity.io/blog/obvious-features-aren-t-obviously-made#2c38c9f38060
                          _strengthenOnPublish: {
                              //   type: <type for referenced item>,
                              //   weak: <the _weak value of the original field>
                          }
                      }
                    : linkForAssetId(updateOperationField.refId);
            return addKeyIfInList(value, isInList);
        }
        case 'cross-reference': {
            throw new Error('Sanity crossDatasetReference fields not supported.');
        }
        case 'list': {
            if (modelField.type !== 'list') {
                throw new Error(`Operation field type 'list' does not match model field type '${modelField.type}'.`);
            }
            return updateOperationField.items.map((item, index) => {
                let listItemModelField = modelField.items;
                if (_.isArray(modelField.items)) {
                    const itemModel = (modelField.items as StackbitTypes.FieldListItems[]).find((listItemsModel) => listItemsModel.type === item.type);
                    if (!itemModel) {
                        throw new Error(
                            `No list item model found for item type '${item.type}' in model '${rootModel.name}' at field path ${modelFieldPath.join('.')}.`
                        );
                    }
                    listItemModelField = itemModel;
                }
                return mapUpdateOperationFieldToSanityValue({
                    updateOperationField: item,
                    getModelByName,
                    modelField: listItemModelField,
                    rootModel,
                    modelFieldPath: modelFieldPath.concat('items'),
                    locale,
                    isInList: true
                });
            });
        }
        default: {
            const _exhaustiveCheck: never = updateOperationField;
            return _exhaustiveCheck;
        }
    }
}

function addKeyIfInList(object: Record<string, any>, isInList?: boolean) {
    if (isInList) {
        _.set(object, '_key', uuid());
    }
    return object;
}

/**
 * In Sanity, a localized field is represented by an array of objects containing the localized field values.
 * Each object has three properties:
 * - `_key` holds the field's locale
 * - `_type` holds the type of the localized value
 * - `value` holds the localized value.
 *
 * Note: the `_type` is not the regular type such as `string` or `object`,
 * but a special localized type generated by the Sanity Internationalized Array plugin.
 * The map between the localized fields and these types is stored in the model's context.
 * title: [
 *   {
 *     _key: 'us',
 *     _type: 'internationalizedArrayStringValue',
 *     value: 'hello',
 *   }, {
 *     _key: 'es',
 *     _type: 'internationalizedArrayStringValue',
 *     value: 'hola',
 *   }
 * ]
 */
export function localizedValue({ value, model, modelFieldPath, locale }: { value: any; model: ModelWithContext; modelFieldPath: string[]; locale?: string }) {
    const localizedFieldModelNameMap = model.context?.localizedFieldsModelMap?.[modelFieldPath.join('.')];
    if (!localizedFieldModelNameMap) {
        throw Error(`Internationalized array model for localized field at path ${modelFieldPath.join('.')} of model ${model.name} not found.`);
    }
    if (!locale) {
        throw Error(`No locale provided for localized field at path ${modelFieldPath.join('.')} of model ${model.name}.`);
    }
    return {
        _key: locale,
        _type: localizedFieldModelNameMap.arrayValueModelName,
        value: value
    };
}

function linkForAssetId(assetId?: string): any {
    return {
        _type: 'image',
        asset: {
            _ref: assetId,
            _type: 'reference'
        }
    };
}

/**
 * Receives a `fieldPath` of a target field in a `sanityDocument` and returns
 * an object with following properties:
 *
 * - `model`: The closest ancestor model of the target field.
 * - `modelFieldPath`: The field path of the target field from the `model`.
 *     The model's field path doesn't identify a specific document field,
 *     therefore it does not include list indexes.
 * - `patchFieldPath`: A Sanity specific field path for patching fields.
 *     For array items, the field path will include [_key="..."] when possible.
 *     For localized fields, which represented by arrays in Sanity, the path
 *     will point to the localized array item using the `_key`:
 *     `sections[_key=="es"].value[3].title`
 * - `localizedLeaf`: A string specifying the leaf value in the `patchFieldPath`
 *     when the target field is localized:
 *       - `value`: The `patchFieldPath` points to the localized value:
 *           `sections[_key=="es"].value
 *       - `newItem`: The `patchFieldPath` points to the existing localized array,
 *           which does not include an item for the provided locale.
 *           Returned only when `allowUndefinedI18NLeafArrays` set to `true`.
 *       - `undefinedArray`: The `patchFieldPath` points to undefined localized array.
 *           Returned only when `allowUndefinedI18NLeafArrays` set to `true`.
 * - `currentValue`: The value of the target field. If the target field is
 *      localized but doesn't have a value for the provided locale,
 *      the `currentValue` will be undefined.
 */
function getPatchPathAndModelFieldPaths({
    fieldPath,
    sanityDocument,
    getModelByName,
    addValueToI18NLeafs,
    allowUndefinedI18NLeafArrays,
    locale
}: {
    fieldPath: StackbitTypes.FieldPath;
    sanityDocument: SanityDocument;
    getModelByName: (modelName: string) => ModelWithContext | undefined;
    addValueToI18NLeafs?: boolean;
    allowUndefinedI18NLeafArrays?: boolean;
    locale?: string;
}): {
    model: ModelWithContext;
    modelFieldPath: string[];
    patchFieldPath: string;
    localizedLeaf?: 'value' | 'newItem' | 'undefinedArray';
    currentValue: any;
    isInList: boolean;
} {
    function iterateObject({
        object,
        model,
        rootModel,
        modelFieldPath,
        fieldPath,
        first = false
    }: {
        object: Record<string, any>;
        model: StackbitTypes.Model | StackbitTypes.FieldObjectProps;
        rootModel: ModelWithContext;
        modelFieldPath: string[];
        fieldPath: StackbitTypes.FieldPath;
        first?: boolean;
    }): {
        model: ModelWithContext;
        modelFieldPath: string[];
        patchFieldPath: string;
        localizedLeaf?: 'value' | 'newItem' | 'undefinedArray';
        currentValue: any;
        isInList: boolean;
    } {
        const [fieldName, ...fieldPathTail] = fieldPath as [string, ...StackbitTypes.FieldPath];
        if (typeof fieldName === 'undefined') {
            throw new Error('The fieldPath cannot be empty.');
        }

        const modelField = (model.fields ?? []).find((field) => field.name === fieldName);
        if (!modelField) {
            throw new Error(`Model field for field '${fieldName}' not found.`);
        }

        let patchFieldPath = '';
        if (/\W/.test(fieldName)) {
            // field name is a string with non-alphanumeric characters
            patchFieldPath += `['${fieldName}']`;
        } else {
            if (!first) {
                patchFieldPath += '.';
            }
            patchFieldPath += fieldName;
        }

        let value = object[fieldName];
        let localizedLeaf: 'value' | 'newItem' | 'undefinedArray' | undefined;
        if (modelField?.localized) {
            if (Array.isArray(value)) {
                const localizedItem = value.find((item) => item._key === locale);
                if (localizedItem) {
                    patchFieldPath += `[_key=="${locale}"]`;
                    if (fieldPathTail.length === 0) {
                        localizedLeaf = 'value';
                        if (addValueToI18NLeafs) {
                            patchFieldPath += '.value';
                        }
                    } else {
                        patchFieldPath += '.value';
                    }
                    value = localizedItem.value;
                } else if (fieldPathTail.length === 0 && allowUndefinedI18NLeafArrays) {
                    localizedLeaf = 'newItem';
                    value = undefined;
                } else {
                    throw new Error(`The localized field '${fieldName}' has no value for locale '${locale}'.`);
                }
            } else if (fieldPathTail.length === 0 && allowUndefinedI18NLeafArrays) {
                localizedLeaf = 'undefinedArray';
                value = undefined;
            } else {
                throw new Error(`The localized field '${fieldName}' has no localization array defined for locale '${locale}'.`);
            }
        }

        const result = iterateField({
            value,
            modelField,
            fieldPath: fieldPathTail,
            rootModel,
            modelFieldPath: modelFieldPath.concat(fieldName),
            isInList: false
        });

        return {
            model: result.model,
            modelFieldPath: result.modelFieldPath,
            patchFieldPath: patchFieldPath + result.patchFieldPath,
            localizedLeaf: localizedLeaf ?? result.localizedLeaf,
            currentValue: result.currentValue,
            isInList: result.isInList
        };
    }

    function iterateField({
        value,
        modelField,
        fieldPath,
        rootModel,
        modelFieldPath,
        isInList
    }: {
        value: any;
        modelField: StackbitTypes.FieldSpecificProps;
        fieldPath: StackbitTypes.FieldPath;
        rootModel: ModelWithContext;
        modelFieldPath: string[];
        isInList: boolean;
    }): {
        model: ModelWithContext;
        modelFieldPath: string[];
        patchFieldPath: string;
        localizedLeaf?: 'value' | 'newItem' | 'undefinedArray';
        currentValue: any;
        isInList: boolean;
    } {
        if (fieldPath.length === 0) {
            return {
                patchFieldPath: '',
                currentValue: value,
                model: rootModel,
                modelFieldPath,
                isInList: isInList
            };
        }
        if (typeof value === 'undefined') {
            throw new Error(`Field path has more items [${fieldPath.join('.')}], but value is undefined.`);
        }
        if (modelField?.type === 'object') {
            return iterateObject({
                object: value,
                model: modelField,
                rootModel,
                modelFieldPath,
                fieldPath
            });
        } else if (modelField?.type === 'model') {
            const modelName = resolvedFieldType({
                sanityFieldType: value._type,
                model: rootModel,
                modelFieldPath
            });
            const model = getModelByName(modelName);
            if (!model) {
                throw new Error(`Model '${modelName}' not found.`);
            }
            return iterateObject({
                object: value,
                model,
                rootModel: model,
                modelFieldPath: [],
                fieldPath
            });
        } else if (modelField?.type === 'list') {
            const [itemIndex, ...fieldPathTail] = fieldPath as [number, ...StackbitTypes.FieldPath];
            const listItem = value[itemIndex];
            const itemModel = getItemTypeForListItem(listItem, modelField);
            if (!itemModel) {
                throw new Error('Could not resolve type of a list item.');
            }
            const result = iterateField({
                value: listItem,
                modelField: itemModel,
                rootModel,
                modelFieldPath: modelFieldPath.concat('items'),
                fieldPath: fieldPathTail,
                isInList: true
            });

            // try to use Sanity _key as explicit accessor
            const key = listItem?._key;
            const patchFieldPath = key ? `[_key=="${key}"]` : `[${Number(itemIndex)}]`;

            return {
                // model fieldPath doesn't include array indexes.
                // return fieldPath with field names only, no list indexes
                model: result.model,
                modelFieldPath: result.modelFieldPath,
                // fieldPath for Sanity patches should always include array indexes.
                patchFieldPath: patchFieldPath + result.patchFieldPath,
                currentValue: result.currentValue,
                isInList: result.isInList
            };
        } else {
            throw new Error(`Field path has more items [${fieldPath.join('.')}], but no objects/arrays left to iterate.`);
        }
    }

    const modelName = sanityDocument._type;
    const model = getModelByName(modelName);
    if (!model) {
        throw new Error(`Model '${modelName}' not found.`);
    }
    return iterateObject({
        object: sanityDocument,
        model,
        rootModel: model,
        modelFieldPath: [],
        fieldPath,
        first: true
    });
}
