import _ from 'lodash';
import { getLocalizedFieldForLocale } from '@stackbit/types';
import type {
    Model,
    Field,
    FieldListItems,
    FieldListProps,
    FieldObjectProps,
    FieldPath,
    Document,
    DocumentWithSource,
    DocumentField,
    DocumentFieldNonLocalized,
    DocumentListFieldItems
} from '@stackbit/types';
import { getDocumentFieldValue } from './document-utils';

/**
 * Converts a FieldPath into a string.
 * Puts complex strings inside single quotes '',
 * and uses square brackets [] for number keys.
 */
export function fieldPathToString(fieldPath: FieldPath) {
    return _.reduce(
        fieldPath,
        (accumulator, fieldName, index) => {
            if (_.isString(fieldName) && /\W/.test(fieldName)) {
                // field name is a string with non-alphanumeric character
                accumulator += `['${fieldName}']`;
            } else if (_.isNumber(fieldName)) {
                accumulator += `[${fieldName}]`;
            } else {
                if (index > 0) {
                    accumulator += '.';
                }
                accumulator += fieldName;
            }
            return accumulator;
        },
        ''
    );
}

/**
 * Converts string into FieldPath
 *
 * ```js
 * stringToFieldPath("sections[0].button['button-label']")
 * =>
 * ["sections", 0, "button", "button-label"]
 * ```
 */
export function stringToFieldPath(fieldPath: string): FieldPath {
    const re = /\.|\[([^\]]+)\]\.?/g;
    const result = [];
    let match: RegExpExecArray | null = null;
    let prevLastIndex = 0;
    while ((match = re.exec(fieldPath)) !== null) {
        if (prevLastIndex < match.index) {
            result.push(fieldPath.substring(prevLastIndex, match.index));
        }
        if (match[1]) {
            // matched [...]
            if (/^\d+$/.test(match[1])) {
                result.push(parseInt(match[1]));
            } else {
                // remove quotes inside brackets
                // "hero['field-name'][2]" => ["hero", "field-name", 2]
                result.push(match[1].replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1'));
            }
        }
        prevLastIndex = re.lastIndex;
    }
    if (prevLastIndex < fieldPath.length) {
        result.push(fieldPath.substring(prevLastIndex));
    }
    return result;
}

export function getModelFieldAtFieldPath({
    document,
    fieldPath,
    modelMap,
    locale
}: {
    document: Document;
    fieldPath: (string | number)[];
    modelMap: Record<string, Model>;
    locale?: string;
}): Field | FieldListItems {
    if (_.isEmpty(fieldPath)) {
        throw new Error('the fieldPath can not be empty');
    }

    const model = modelMap[document.modelName];
    if (!model) {
        throw new Error(`model with name '${document.modelName}' of a document '${document.id}' not found`);
    }

    function getField(docField: DocumentField, modelField: Field | FieldListItems, fieldPath: (string | number)[]): Field | FieldListItems {
        const fieldName = _.head(fieldPath);
        if (typeof fieldName === 'undefined') {
            throw new Error('the first fieldPath item must be string');
        }
        const childFieldPath = _.tail(fieldPath);
        let childDocField: DocumentField | undefined;
        let childModelField: Field | undefined;
        switch (docField.type) {
            case 'object': {
                const localizedObjectField = getLocalizedFieldForLocale(docField, locale);
                if (!localizedObjectField) {
                    throw new Error(`locale for field was not found`);
                }
                childDocField = localizedObjectField.fields[fieldName];
                childModelField = _.find((modelField as FieldObjectProps).fields, (field) => field.name === fieldName);
                if (!childDocField || !childModelField) {
                    throw new Error(`field ${fieldName} doesn't exist`);
                }
                if (childFieldPath.length === 0) {
                    return childModelField;
                }
                return getField(childDocField, childModelField, childFieldPath);
            }
            case 'model': {
                const localizedModelField = getLocalizedFieldForLocale(docField, locale);
                if (!localizedModelField) {
                    throw new Error(`locale for field was not found`);
                }
                const modelName = localizedModelField.modelName;
                const childModel = modelMap[modelName];
                if (!childModel) {
                    throw new Error(`model ${modelName} doesn't exist`);
                }
                childModelField = _.find(childModel.fields, (field) => field.name === fieldName);
                childDocField = localizedModelField.fields![fieldName];
                if (!childDocField || !childModelField) {
                    throw new Error(`field ${fieldName} doesn't exist`);
                }
                if (childFieldPath.length === 0) {
                    return childModelField;
                }
                return getField(childDocField, childModelField!, childFieldPath);
            }
            case 'list': {
                const localizedListField = getLocalizedFieldForLocale(docField, locale);
                if (!localizedListField) {
                    throw new Error(`locale for field was not found`);
                }
                const listItem = localizedListField.items && localizedListField.items[fieldName as number];
                const listItemsModel = (modelField as FieldListProps).items;
                if (!listItem || !listItemsModel) {
                    throw new Error(`field ${fieldName} doesn't exist`);
                }
                if (childFieldPath.length === 0) {
                    return listItemsModel;
                }
                if (!Array.isArray(listItemsModel)) {
                    return getField(listItem, listItemsModel, childFieldPath);
                } else {
                    const fieldListItems = (listItemsModel as FieldListItems[]).find((listItemsModel) => listItemsModel.type === listItem.type);
                    if (!fieldListItems) {
                        throw new Error('cannot find matching field model');
                    }
                    return getField(listItem, fieldListItems, childFieldPath);
                }
            }
            default:
                if (!_.isEmpty(childFieldPath)) {
                    throw new Error('illegal fieldPath');
                }
                return modelField;
        }
    }

    const fieldName = _.head(fieldPath);
    const childFieldPath = _.tail(fieldPath);

    if (typeof fieldName !== 'string') {
        throw new Error('the first fieldPath item must be string');
    }

    const childDocField = document.fields[fieldName];
    const childModelField = _.find(model.fields, { name: fieldName });

    if (!childDocField || !childModelField) {
        throw new Error(`field ${fieldName} doesn't exist`);
    }

    if (childFieldPath.length === 0) {
        return childModelField;
    }

    return getField(childDocField, childModelField, childFieldPath);
}

export type GetDocumentFieldAtFieldPathOptions = {
    document: Document;
    fieldPath: FieldPath;
    locale?: string;
    isFullFieldPath?: boolean;
    returnUndefined?: boolean;
};
export type GetDocumentFieldAtFieldPathOptionsThrow = GetDocumentFieldAtFieldPathOptions & {
    returnUndefined?: false;
};
export type GetDocumentFieldAtFieldPathOptionsUndefined = GetDocumentFieldAtFieldPathOptions & {
    returnUndefined: true;
};

/**
 * This function receives a `document` and returns DocumentFieldNonLocalized at
 * the specified `fieldPath` while resolving any localized fields with the
 * specified `locale`.
 *
 * ```ts
 * getDocumentFieldAtFieldPath({
 *   document,
 *   locale,
 *   fieldPath: ['sections', 1, 'title']
 * })
 * ```
 *
 * For improved localization support, use the getModelAndDocumentFieldForLocalizedFieldPath
 * method instead.
 *
 * The `isFullFieldPath` flag specifies if the `fieldPath` includes container
 * specifiers such as "fields" and "items":
 *
 * ```ts
 * getDocumentFieldAtFieldPath({
 *   document,
 *   isFullFieldPath: false,
 *   fieldPath: ['sections', 1, 'title']
 * })
 * getDocumentFieldAtFieldPath({
 *   document,
 *   isFullFieldPath: true,
 *   fieldPath: ['fields', 'sections', 'items', 1, 'fields', 'title']
 * })
 * ```
 *
 * By default, if the document field at the specified `fieldPath` not found, this
 * function will throw exception. Setting `returnUndefined` to true will allow
 * the function to return `undefined` if the document field is not found.
 */
export function getDocumentFieldAtFieldPath(options: GetDocumentFieldAtFieldPathOptionsThrow): DocumentFieldNonLocalized;
export function getDocumentFieldAtFieldPath(options: GetDocumentFieldAtFieldPathOptionsUndefined): DocumentFieldNonLocalized | undefined;
export function getDocumentFieldAtFieldPath({
    document,
    fieldPath,
    locale,
    isFullFieldPath,
    returnUndefined
}: GetDocumentFieldAtFieldPathOptions): DocumentFieldNonLocalized | undefined {
    function throwOrReturnUndefined(errorMessage: string) {
        if (returnUndefined) {
            return undefined;
        } else {
            throw new Error(errorMessage);
        }
    }

    const origFieldPath = fieldPath;
    const origFieldPathStr = fieldPath.join('.');

    if (isFullFieldPath) {
        if (_.head(fieldPath) !== 'fields') {
            throw new Error(`Illegal fieldPath '${origFieldPathStr}'. The fieldPath must start with "fields" specifier when 'isFullFieldPath' is set.`);
        }
        fieldPath = _.tail(fieldPath);
    }

    if (_.isEmpty(fieldPath)) {
        throw new Error(`Illegal fieldPath '${origFieldPathStr}'. The fieldPath cannot be empty`);
    }

    const docId = document.id;
    const modelName = document.modelName;

    function getPrefixOf(fieldPath: FieldPath, include = 0) {
        return origFieldPath.slice(0, origFieldPath.length - fieldPath.length + include).join('.');
    }

    function getField(docField: DocumentField | DocumentListFieldItems, fieldPath: FieldPath): DocumentFieldNonLocalized | undefined {
        const localizedField = getLocalizedFieldForLocale(docField, locale);
        if (!localizedField) {
            return throwOrReturnUndefined(
                `Illegal fieldPath '${origFieldPathStr}'. The value of a field at path '${getPrefixOf(fieldPath)}' ` +
                    `of a document '${docId}' (model: '${modelName}') for the '${locale}' locale is undefined.`
            );
        }

        // if no more items in fieldPath return the found document and model fields
        if (fieldPath.length === 0) {
            return localizedField;
        }

        switch (localizedField.type) {
            case 'object':
            case 'model': {
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'fields') {
                        throw new Error(
                            `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                                `is an object field and must be followed by the "fields" specifier when 'isFullFieldPath' is set.`
                        );
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const fieldName = _.head(fieldPath);
                if (typeof fieldName === 'undefined') {
                    throw new Error(
                        `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                            `of a document '${docId}' (model: '${modelName}') is an object field and must be followed by a field name.`
                    );
                }
                fieldPath = _.tail(fieldPath);
                const childDocField = localizedField.fields[fieldName];
                if (!childDocField) {
                    return throwOrReturnUndefined(
                        `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                            `of a document '${docId}' (model: '${modelName}') points to a non existing field.`
                    );
                }
                return getField(childDocField, fieldPath);
            }
            case 'list': {
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'items') {
                        throw new Error(
                            `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                                `is a list field and must be followed by the "items" specifier when 'isFullFieldPath' is set.`
                        );
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const itemIndex = _.head(fieldPath) as number;
                if (typeof itemIndex === 'undefined') {
                    throw new Error(
                        `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                            `of a document '${docId}' (model: '${modelName}') is a list field and must be followed by a list item index.`
                    );
                }
                fieldPath = _.tail(fieldPath);
                const listItem = localizedField.items && localizedField.items[itemIndex];
                if (!listItem) {
                    return throwOrReturnUndefined(
                        `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                            `of a document '${docId}' (model: '${modelName}') points to a non existing list item.`
                    );
                }
                return getField(listItem, fieldPath);
            }
            default: {
                throw new Error(
                    `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                        `is a primitive field of type '${docField.type}' and cannot be followed by another field path.`
                );
            }
        }
    }

    const fieldName = _.head(fieldPath);
    const restFieldPath = _.tail(fieldPath);

    if (typeof fieldName !== 'string') {
        return throwOrReturnUndefined('the first fieldPath item must be string');
    }

    const childDocField: DocumentField | undefined = document.fields[fieldName];
    if (!childDocField) {
        return throwOrReturnUndefined(`document '${docId}' of type '${modelName}' doesn't have a field named '${fieldName}'`);
    }

    return getField(childDocField, restFieldPath);
}

/**
 * This function receives a `document` and a `modelMap` and returns an object
 * with DocumentFieldNonLocalized and a model Field at the specified `fieldPath`
 * while resolving any localized fields with the specified `locale`.
 *
 * @example
 * getDocumentAndModelFieldAtFieldPath({
 *   document,
 *   locale,
 *   modelMap,
 *   fieldPath: ['sections', 1, 'title']
 * })
 *
 * For improved localization support, use the getModelAndDocumentFieldForLocalizedFieldPath
 * method instead.
 *
 * The `isFullFieldPath` flag specifies if the `fieldPath` includes container
 * specifiers such as "fields" and "items".
 *
 * @example
 * isFullFieldPath: false => fieldPath: ['sections', 1, 'title']
 * isFullFieldPath: true => fieldPath: ['fields', 'sections', 'items', 1, 'fields', 'title']
 */
export function getDocumentAndModelFieldAtFieldPath({
    document,
    fieldPath,
    modelMap,
    locale,
    isFullFieldPath
}: {
    document: Document;
    fieldPath: (string | number)[];
    modelMap: Record<string, Model>;
    locale?: string;
    isFullFieldPath?: boolean;
}): { modelField: Field | FieldListItems; documentField: DocumentFieldNonLocalized } {
    if (isFullFieldPath) {
        if (_.head(fieldPath) !== 'fields') {
            throw new Error('fieldPath must start with "fields" specifier');
        }
        fieldPath = _.tail(fieldPath);
    }

    if (_.isEmpty(fieldPath)) {
        throw new Error('the fieldPath cannot be empty');
    }

    const docId = document.id;
    const docModelName = document.modelName;
    const origFieldPath = fieldPath;
    const origFieldPathStr = fieldPath.join('.');

    let parentModel: Model = modelMap[docModelName]!;
    let fieldPathFromParentModel = fieldPath;

    if (!parentModel) {
        throw new Error(`model with name '${docModelName}' of a document '${docId}' not found`);
    }

    function getPrefixOf(fieldPath: (string | number)[], include = 0) {
        return origFieldPath.slice(0, origFieldPath.length - fieldPath.length + include).join('.');
    }

    function getModelPrefixOf(fieldPath: (string | number)[], include = 0) {
        return fieldPathFromParentModel.slice(0, fieldPathFromParentModel.length - fieldPath.length + include).join('.');
    }

    function getField(
        docField: DocumentField | DocumentListFieldItems,
        modelField: Field | FieldListItems,
        fieldPath: (string | number)[]
    ): { modelField: Field | FieldListItems; documentField: DocumentFieldNonLocalized } {
        const localizedField = getLocalizedFieldForLocale(docField, locale);
        if (!localizedField) {
            throw new Error(
                `the value of a field at fieldPath '${getPrefixOf(fieldPath)}' is undefined ` +
                    `for the document '${docId}' of type '${docModelName}' for the '${locale}' locale.`
            );
        }

        // if no more items in fieldPath return the found document and model fields
        if (fieldPath.length === 0) {
            return {
                modelField: modelField,
                documentField: localizedField
            };
        }

        switch (localizedField.type) {
            case 'object': {
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'fields') {
                        throw new Error(`the fieldPath '${getPrefixOf(fieldPath, 1)}' must contain "fields" specifier before a field's name`);
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const fieldName = _.head(fieldPath);
                if (typeof fieldName === 'undefined') {
                    throw new Error(
                        `the fieldPath '${getPrefixOf(fieldPath, 1)}' of a document '${docId}' of type '${docModelName}' must point to a field name`
                    );
                }
                fieldPath = _.tail(fieldPath);
                const childDocField = localizedField.fields[fieldName];
                if (!childDocField) {
                    throw new Error(
                        `the fieldPath '${getPrefixOf(fieldPath)}' points to a non existing field in a document '${docId}' of type '${docModelName}'`
                    );
                }
                if (modelField.type !== 'object') {
                    throw new Error(
                        `model field of type '${modelField.type}' of model '${parentModel.name}' at field path '${getModelPrefixOf(fieldPath)}' ` +
                            `doesn't match document field of type 'object' of document '${docId}' at field path '${getPrefixOf(fieldPath)}'`
                    );
                }
                const childModelField = _.find(modelField.fields, (field) => field.name === fieldName);
                if (!childModelField) {
                    throw new Error(`model '${parentModel.name}' doesn't have a field at path: '${getModelPrefixOf(fieldPath)}'`);
                }
                return getField(childDocField, childModelField, fieldPath);
            }
            case 'model': {
                fieldPathFromParentModel = fieldPath;
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'fields') {
                        throw new Error(`the fieldPath '${getPrefixOf(fieldPath, 1)}' must contain "fields" specifier before a field's name`);
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const fieldName = _.head(fieldPath);
                if (typeof fieldName === 'undefined') {
                    throw new Error(
                        `the fieldPath '${getPrefixOf(fieldPath, 1)}' of a document '${docId}' of type '${docModelName}' must point to a field name`
                    );
                }
                fieldPath = _.tail(fieldPath);
                const childDocField = localizedField.fields[fieldName];
                if (!childDocField) {
                    throw new Error(
                        `the fieldPath '${getPrefixOf(fieldPath)}' points to a non existing field in a document '${docId}' of type '${docModelName}'`
                    );
                }
                const modelName = localizedField.modelName;
                const childModel = modelMap[modelName];
                if (!childModel) {
                    throw new Error(
                        `the "model" field at path '${getPrefixOf(fieldPath)}' of a document '${docId}' of type '${docModelName}' ` +
                            `contains an object with type of non existing model '${modelName}'`
                    );
                }
                parentModel = childModel;
                const childModelField = _.find(childModel.fields, (field) => field.name === fieldName);
                if (!childModelField) {
                    throw new Error(`model '${childModel.name}' doesn't have a field at path: '${getModelPrefixOf(fieldPath)}'`);
                }
                return getField(childDocField, childModelField, fieldPath);
            }
            case 'list': {
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'items') {
                        throw new Error(`the fieldPath '${getPrefixOf(fieldPath, 1)}' must contain "items" specifier before a list item index`);
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const itemIndex = _.head(fieldPath) as number;
                if (typeof itemIndex === 'undefined') {
                    throw new Error(
                        `the fieldPath '${getPrefixOf(fieldPath, 1)}' of a document '${docId}' of type '${docModelName}' must point to a list item index`
                    );
                }
                fieldPath = _.tail(fieldPath);
                const listItem = localizedField.items && localizedField.items[itemIndex];
                if (!listItem) {
                    throw new Error(
                        `the fieldPath '${getPrefixOf(fieldPath)}' points to a non existing list item in a document '${docId}' of type '${docModelName}'`
                    );
                }
                if (modelField.type !== 'list') {
                    throw new Error(
                        `model field of type '${modelField.type}' of model '${parentModel.name}' at field path '${getModelPrefixOf(fieldPath)}' ` +
                            `doesn't match document field of type 'list' of document '${docId}' at field path '${getPrefixOf(fieldPath)}'`
                    );
                }
                const listItemsModel = modelField.items;
                if (!Array.isArray(listItemsModel)) {
                    return getField(listItem, listItemsModel, fieldPath);
                } else {
                    const fieldListItems = (listItemsModel as FieldListItems[]).find((listItemsModel) => listItemsModel.type === listItem.type);
                    if (!fieldListItems) {
                        throw new Error(
                            `cannot find list item model for list item of type '${listItem.type}' for model '${parentModel.name}' ` +
                                `at field path '${getModelPrefixOf(fieldPath)}'`
                        );
                    }
                    return getField(listItem, fieldListItems, fieldPath);
                }
            }
            default: {
                const primitiveFieldName = origFieldPath[origFieldPath.length - fieldPath.length - 1];
                throw new Error(
                    `the fieldPath '${origFieldPathStr}' is illegal, a primitive field '${primitiveFieldName}' of type '${localizedField.type}' cannot be followed by additional field paths`
                );
            }
        }
    }

    const fieldName = _.head(fieldPath);
    const restFieldPath = _.tail(fieldPath);

    if (typeof fieldName !== 'string') {
        throw new Error('the first fieldPath item must be string');
    }

    const childDocField: DocumentField | undefined = document.fields[fieldName];
    if (!childDocField) {
        throw new Error(`document '${docId}' of type '${docModelName}' doesn't have a field named '${fieldName}'`);
    }

    const childModelField: Field | undefined = _.find(parentModel.fields, { name: fieldName });
    if (!childModelField) {
        throw new Error(`model '${docModelName}' doesn't have a field named '${fieldName}'`);
    }

    return getField(childDocField, childModelField, restFieldPath);
}

/**
 * This function receives a `document` and a `modelMap` and returns an object
 * with DocumentField and a model Field at the specified `fieldPath`.
 *
 * If some fields along the fieldPath are localized, the `fieldPath` must
 * contain the locale of the field under the "locales" property. The locales
 * along the field path don't have to be the same.
 *
 * @example
 * fieldPath: ['fields', 'button', 'locales', 'en', 'fields', 'title', 'locales', 'es']
 *
 * If the provided `fieldPath` points to a list item, the returned model field
 * and document field will belong to a list item. In this case, the model field
 * will contain only field-specific properties and the document field will be
 * localized.
 *
 * @example
 * fieldPath: ['fields', 'buttons', 'items', 2]
 *
 * The `isFullFieldPath` flag specifies if the `fieldPath` includes container
 * specifiers such as "fields" and "items".
 *
 * @example
 * isFullFieldPath: false => fieldPath: ['sections', 1, 'title', 'es']
 * isFullFieldPath: true => fieldPath: ['fields', 'sections', 'items', 1, 'fields', 'title', 'locales', 'es']
 */
export function getModelAndDocumentFieldForLocalizedFieldPath({
    document,
    fieldPath,
    modelMap,
    isFullFieldPath
}: {
    document: Document;
    fieldPath: (string | number)[];
    modelMap: Record<string, Model>;
    isFullFieldPath?: boolean;
}): { modelField: Field | FieldListItems; documentField?: DocumentField | DocumentListFieldItems } {
    if (isFullFieldPath) {
        if (_.head(fieldPath) !== 'fields') {
            throw new Error('fieldPath must start with "fields" specifier');
        }
        fieldPath = _.tail(fieldPath);
    }
    if (_.isEmpty(fieldPath)) {
        throw new Error('the fieldPath cannot be empty');
    }

    const docId = document.id;
    const origModelName = document.modelName;
    const origFieldPath = fieldPath;
    const origFieldPathStr = fieldPath.join('.');
    const model = modelMap[origModelName];
    if (!model) {
        throw new Error(`model with name '${origModelName}' of a document '${docId}' not found`);
    }

    let parentModel = model;
    let fieldPathFromParentModel = fieldPath;

    function getPrefixOf(fieldPath: (string | number)[], include = 0) {
        return origFieldPath.slice(0, origFieldPath.length - fieldPath.length + include).join('.');
    }
    function getModelPrefixOf(fieldPath: (string | number)[], include = 0) {
        return fieldPathFromParentModel.slice(0, fieldPathFromParentModel.length - fieldPath.length + include).join('.');
    }

    function getField(
        docField: DocumentField | DocumentListFieldItems,
        modelField: Field | FieldListItems,
        fieldPath: (string | number)[]
    ): { modelField: Field | FieldListItems; documentField?: DocumentField | DocumentListFieldItems } {
        // if no more items in fieldPath return the found document and model fields
        if (fieldPath.length === 0) {
            return {
                modelField: modelField,
                documentField: docField
            };
        }

        if (docField.localized) {
            if (isFullFieldPath) {
                if (_.head(fieldPath) !== 'locales') {
                    throw new Error(`fieldPath '${origFieldPath.join('.')}' must contain "locales" specifier for localized field`);
                }
                fieldPath = _.tail(fieldPath);
            }
            const locale = _.head(fieldPath);
            if (typeof locale !== 'string') {
                throw new Error(`the locale specifier must be string in fieldPath '${origFieldPath.join('.')}'`);
            }
            fieldPath = _.tail(fieldPath);
            const localizedDocField = getLocalizedFieldForLocale(docField, locale);
            if (!localizedDocField) {
                if (!fieldPath.length) {
                    // field locale is not set
                    return { modelField, documentField: undefined };
                }
                throw new Error(`fieldPath '${origFieldPath.join('.')}' doesn't specify the locale`);
            }
            docField = localizedDocField;
        }

        switch (docField.type) {
            case 'object': {
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'fields') {
                        throw new Error(`fieldPath '${getPrefixOf(fieldPath, 1)}' must contain "fields" specifier after an "object" field`);
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const fieldName = _.head(fieldPath);
                if (typeof fieldName === 'undefined') {
                    throw new Error(`fieldPath '${getPrefixOf(fieldPath, 1)}' must specify a field name for an "object" field`);
                }
                fieldPath = _.tail(fieldPath);

                const childDocField = docField.fields[fieldName];
                if (!childDocField) {
                    throw new Error(`document '${docId}' of type '${origModelName}' doesn't have a field at path: '${getPrefixOf(fieldPath)}'`);
                }
                if (modelField.type !== 'object') {
                    throw new Error(
                        `model field of type '${modelField.type}' of model '${parentModel.name}' at field path '${getModelPrefixOf(fieldPath)}' ` +
                            `doesn't match document field of type 'object' of document '${docId}' at field path '${getPrefixOf(fieldPath)}'`
                    );
                }
                const childModelField = _.find(modelField.fields, (field) => field.name === fieldName);
                if (!childModelField) {
                    throw new Error(`model '${parentModel.name}' doesn't have a field at path: '${getModelPrefixOf(fieldPath)}'`);
                }
                return getField(childDocField, childModelField, fieldPath);
            }
            case 'model': {
                fieldPathFromParentModel = fieldPath;
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'fields') {
                        throw new Error(`fieldPath '${getPrefixOf(fieldPath, 1)}' must contain "fields" specifier after a "model" field`);
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const fieldName = _.head(fieldPath);
                if (typeof fieldName === 'undefined') {
                    throw new Error(`fieldPath '${getPrefixOf(fieldPath, 1)}' must specify a field name for an "model" field`);
                }
                fieldPath = _.tail(fieldPath);

                const modelName = docField.modelName;
                const childModel = modelMap[modelName];
                if (!childModel) {
                    throw new Error(
                        `a "model" field of a document '${docId}' at path '${getPrefixOf(fieldPath)}' ` +
                            `contains an object with non existing modelName '${modelName}'`
                    );
                }
                parentModel = childModel;
                const childDocField = docField.fields[fieldName];
                if (!childDocField) {
                    throw new Error(`document '${docId}' of type '${origModelName}' doesn't have a field at path: '${getPrefixOf(fieldPath)}'`);
                }
                const childModelField = _.find(childModel.fields, (field) => field.name === fieldName);
                if (!childModelField) {
                    throw new Error(`model '${childModel.name}' doesn't have a field at path: '${getModelPrefixOf(fieldPath)}'`);
                }
                return getField(childDocField, childModelField, fieldPath);
            }
            case 'list': {
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'items') {
                        throw new Error(`fieldPath '${getPrefixOf(fieldPath, 1)}' must contain "items" specifier after a "list" field`);
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const itemIndex = _.head(fieldPath) as number;
                if (typeof itemIndex === 'undefined') {
                    throw new Error(`fieldPath '${getPrefixOf(fieldPath, 1)}' must specify a list item index for a "list" field`);
                }
                fieldPath = _.tail(fieldPath);

                const listItem = docField.items && docField.items[itemIndex as number];
                if (!listItem) {
                    throw new Error(`document '${docId}' of type '${origModelName}' doesn't have a list item at path: '${getPrefixOf(fieldPath)}'`);
                }
                if (modelField.type !== 'list') {
                    throw new Error(
                        `model field type '${modelField.type}' of a model '${parentModel.name}' at field path '${getModelPrefixOf(fieldPath)}' ` +
                            `doesn't match document field of type 'list' of document '${docId}' at field path '${getPrefixOf(fieldPath)}'`
                    );
                }
                const listItemsModel = modelField.items;
                if (!Array.isArray(listItemsModel)) {
                    return getField(listItem, listItemsModel, fieldPath);
                } else {
                    const fieldListItems = (listItemsModel as FieldListItems[]).find((listItemsModel) => listItemsModel.type === listItem.type);
                    if (!fieldListItems) {
                        throw new Error(
                            `cannot find list item model for document list item of type '${listItem.type}' for model '${parentModel.name}' ` +
                                `at field path '${getModelPrefixOf(fieldPath, 1)}'`
                        );
                    }
                    return getField(listItem, fieldListItems, fieldPath);
                }
            }
            default:
                if (!_.isEmpty(fieldPath)) {
                    throw new Error(
                        `illegal fieldPath, a primitive field of type '${docField.type}' was found in the middle of the fieldPath '${origFieldPathStr}'`
                    );
                }
                return {
                    modelField,
                    documentField: docField
                };
        }
    }

    const fieldName = _.head(fieldPath);
    const childFieldPath = _.tail(fieldPath);

    if (typeof fieldName !== 'string') {
        throw new Error('the first fieldPath item must be string');
    }

    const childDocField: DocumentField | undefined = document.fields[fieldName];
    const childModelField: Field | undefined = _.find(model.fields, { name: fieldName });

    if (!childDocField) {
        throw new Error(`document '${docId}' of type '${model.name}' doesn't have a field named '${fieldName}'`);
    }

    if (!childModelField) {
        throw new Error(`model '${model.name}' doesn't have a field named '${fieldName}'`);
    }

    return getField(childDocField, childModelField, childFieldPath);
}

/**
 * This function returns the value of a field in the specified `document` at the
 * specified `fieldPath`.
 *
 * ```ts
 * getDocumentFieldValueAtFieldPath({
 *   document,
 *   fieldPath: 'sections[1].title'
 * })
 * ```
 *
 * The `fieldPath` can also contain locale specifiers for localized fields.
 *
 * ```ts
 * getDocumentFieldValueAtFieldPath({
 *   document,
 *   fieldPath: 'sections[1].title.es'
 * })
 * ```
 *
 * Note: getting a value of a localized field without using a locale specifier
 * will throw an exception.
 *
 * The `fieldPath` can be a string following the standard JavaScript notation
 * for getting nested properties and array values, or a {@link FieldPath}.
 *
 * If the document field at the specified `fieldPath` not found, undefined is returned.
 *
 * @param document The parent document
 * @param fieldPath The field path to the target field
 * @param getDocumentById A function
 * @param isFullFieldPath
 */
export function getDocumentFieldValueAtFieldPath({
    document,
    fieldPath,
    getDocumentById,
    isFullFieldPath
}: {
    document: DocumentWithSource;
    fieldPath: FieldPath | string;
    getDocumentById?: (options: { id: string; srcType?: string; srcProjectId?: string }) => DocumentWithSource | undefined;
    isFullFieldPath?: boolean;
}): unknown {
    if (typeof fieldPath === 'string') {
        fieldPath = stringToFieldPath(fieldPath);
    }

    const origFieldPath = fieldPath;
    const origFieldPathStr = fieldPath.join('.');

    if (isFullFieldPath) {
        if (_.head(fieldPath) !== 'fields') {
            throw new Error(`Illegal fieldPath '${origFieldPathStr}'. The fieldPath must start with "fields" specifier when 'isFullFieldPath' is set.`);
        }
        fieldPath = _.tail(fieldPath);
    }
    if (_.isEmpty(fieldPath)) {
        throw new Error(`Illegal fieldPath '${origFieldPathStr}'. The fieldPath cannot be empty`);
    }

    function getPrefixOf(fieldPath: FieldPath, include = 0) {
        return origFieldPath.slice(0, origFieldPath.length - fieldPath.length + include).join('.');
    }

    function getField(docField: DocumentField | DocumentListFieldItems, fieldPath: FieldPath): unknown {
        if (docField.localized) {
            if (isFullFieldPath) {
                if (_.head(fieldPath) !== 'locales') {
                    throw new Error(
                        `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                            `is a localized field and must be followed by the "locales" specifier when 'isFullFieldPath' is set.`
                    );
                }
                fieldPath = _.tail(fieldPath);
            }
            const locale = _.head(fieldPath);
            if (typeof locale !== 'string') {
                throw new Error(
                    `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                        `is a localized field and must be followed by a locale specifier.`
                );
            }
            fieldPath = _.tail(fieldPath);
            const localizedDocField = getLocalizedFieldForLocale(docField, locale);
            if (!localizedDocField) {
                return undefined;
            }
            docField = localizedDocField;
        }

        // if no more items in fieldPath return the found document fields
        if (fieldPath.length === 0) {
            return getDocumentFieldValue({ documentField: docField });
        }

        switch (docField.type) {
            case 'object':
            case 'model': {
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'fields') {
                        throw new Error(
                            `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                                `is a '${docField.type}' field and must be followed by the "fields" specifier when 'isFullFieldPath' is set.`
                        );
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const fieldName = _.head(fieldPath);
                if (typeof fieldName === 'undefined') {
                    throw new Error(
                        `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                            `is an '${docField.type}' field and must be followed by a field name.`
                    );
                }
                fieldPath = _.tail(fieldPath);
                const childDocField = docField.fields[fieldName];
                if (!childDocField) {
                    return undefined;
                }
                return getField(childDocField, fieldPath);
            }
            case 'reference': {
                if (docField.refType !== 'document') {
                    return undefined;
                }
                if (!getDocumentById) {
                    throw new Error(
                        `Can't get the field value for a field at the fieldPath '${origFieldPathStr}'. ` +
                            `The getDocumentById() function was not provided, ` +
                            `and the field at path '${getPrefixOf(fieldPath)}' is a reference field.`
                    );
                }
                const refDocument = getDocumentById({
                    id: docField.refId,
                    srcType: document.srcType,
                    srcProjectId: document.srcProjectId
                });
                if (!refDocument) {
                    return undefined;
                }
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'fields') {
                        throw new Error(
                            `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                                `is a reference field and must be followed by the "fields" specifier when 'isFullFieldPath' is set.`
                        );
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const fieldName = _.head(fieldPath);
                if (typeof fieldName === 'undefined') {
                    throw new Error(
                        `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                            `is a reference field and must be followed by a field name.`
                    );
                }
                fieldPath = _.tail(fieldPath);
                const childDocField = refDocument.fields[fieldName];
                if (!childDocField) {
                    return undefined;
                }
                return getField(childDocField, fieldPath);
            }
            case 'list': {
                if (isFullFieldPath) {
                    if (_.head(fieldPath) !== 'items') {
                        throw new Error(
                            `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                                `is a list field and must be followed by the "items" specifier when 'isFullFieldPath' is set.`
                        );
                    }
                    fieldPath = _.tail(fieldPath);
                }
                const itemIndex = _.head(fieldPath) as number;
                if (typeof itemIndex === 'undefined' || (typeof itemIndex !== 'number' && !/\d+/.test(itemIndex))) {
                    throw new Error(
                        `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                            `is a list field and must be followed by a list item index.`
                    );
                }
                fieldPath = _.tail(fieldPath);
                const listItem = docField.items && docField.items[itemIndex];
                if (!listItem) {
                    return;
                }
                return getField(listItem, fieldPath);
            }
            default: {
                throw new Error(
                    `Illegal fieldPath '${origFieldPathStr}'. The field at path '${getPrefixOf(fieldPath)}' ` +
                        `is a primitive field of type '${docField.type}' and cannot be followed by another field path.`
                );
            }
        }
    }

    const fieldName = _.head(fieldPath);
    const childFieldPath = _.tail(fieldPath);

    if (typeof fieldName !== 'string') {
        throw new Error(`Illegal fieldPath '${origFieldPathStr}'. The first fieldPath item must be a string specifying a document's field name.`);
    }

    const childDocField: DocumentField | undefined = document.fields[fieldName];
    if (!childDocField) {
        return undefined;
    }

    return getField(childDocField, childFieldPath);
}
