import _, { PropertyPath } from 'lodash';

/**
 * Gets the value at the first path of object having non undefined value.
 * If all paths resolve to undefined values, the defaultValue is returned.
 *
 * @param object
 * @param paths
 * @param defaultValue
 */
export function getFirst(object: any, paths: PropertyPath, defaultValue?: any): any {
    const result = _(object).at(paths).reject(_.isUndefined).first();
    return _.isUndefined(result) ? defaultValue : result;
}

/**
 * Appends the `value` to the end of the array located at the specified `path` in
 * the provided `object`. If array at the specified `path` doesn't exist, the
 * function creates a new array with a single `value` item.
 *
 * @param object
 * @param path
 * @param value
 */
export function append(object: any, path: PropertyPath, value: any): void {
    if (!_.has(object, path)) {
        _.set(object, path, []);
    }
    _.get(object, path).push(value);
}

/**
 * Prepends the `value` to the beginning of the array located at the specified
 * `path` in the provided `object`. If array at the specified `path` doesn't
 * exist, the function creates a new array with a single `value` item.
 *
 * @param object
 * @param path
 * @param value
 */
export function prepend(object: any, path: PropertyPath, value: any): void {
    if (!_.has(object, path)) {
        _.set(object, path, []);
    }
    _.get(object, path).unshift(value);
}

/**
 * Concatenates the `value` with an array located at the specified `path` in the
 * provided `object`. If array at the specified `path` doesn't exist, the
 * function creates a new array and concatenates it with `value`.
 *
 * @param object
 * @param path
 * @param value
 */
export function concat(object: any, path: PropertyPath, value: any) {
    if (!_.has(object, path)) {
        _.set(object, path, []);
    }
    const result = _.get(object, path).concat(value);
    _.set(object, path, result);
}

/**
 * Copies the value from the `sourceObject` at the specified `sourcePath` to
 * the `targetObject` at the specified `targetPath`, optionally transforming the
 * value using the `transform` function.
 *
 * If `sourcePath` resolves to `undefined`, this method does nothing.
 *
 * @param sourceObject
 * @param sourcePath
 * @param targetObject
 * @param targetPath
 * @param transform
 */
export function copy(sourceObject: any, sourcePath: PropertyPath, targetObject: any, targetPath: PropertyPath, transform?: (value: any) => any) {
    if (_.has(sourceObject, sourcePath)) {
        let value = _.get(sourceObject, sourcePath);
        if (transform) {
            value = transform(value);
        }
        _.set(targetObject, targetPath, value);
    }
}

/**
 * Copies the value from the `sourceObject` at the specified `sourcePath` to
 * the `targetObject` at the specified `targetPath`.
 *
 * If `targetPath` resolves to `undefined`, this method does nothing.
 * If `sourcePath` resolves to `undefined`, this method does nothing.
 *
 * Optionally transform the value using the `transform` function.
 *
 * @param sourceObject
 * @param sourcePath
 * @param targetObject
 * @param targetPath
 * @param transform
 */
export function copyIfNotSet(sourceObject: any, sourcePath: PropertyPath, targetObject: any, targetPath: PropertyPath, transform?: (value: any) => any) {
    if (!_.has(targetObject, targetPath)) {
        copy(sourceObject, sourcePath, targetObject, targetPath, transform);
    }
}

/**
 * Renames `oldPath` to a `newPath`.
 *
 * @param object
 * @param oldPath
 * @param newPath
 */
export function rename(object: any, oldPath: PropertyPath, newPath: PropertyPath) {
    if (_.has(object, oldPath)) {
        _.set(object, newPath, _.get(object, oldPath));
        oldPath = _.toPath(oldPath);
        if (oldPath.length > 1) {
            object = _.get(object, _.initial(oldPath));
        }
        const lastKey = _.last(oldPath);
        if (lastKey) {
            delete object[lastKey];
        }
    }
}

/**
 * Removed all null and undefined properties from the passed object
 * @param object
 */
export function omitByNil<T extends Record<string, any>>(object: T): T {
    return _.omitBy(object, _.isNil) as T;
}

export function omitByUndefined<T extends Record<string, any>>(object: T): T {
    return _.omitBy(object, _.isUndefined) as T;
}

export function undefinedIfEmpty<T extends Record<string, any> | any[]>(value: T): T | undefined {
    return _.isEmpty(value) ? undefined : value;
}

export type KeyPath = (string | number)[];

/**
 * Creates an object with the same keys as `object` and values generated by
 * recursively running each own enumerable string keyed property of `object` through
 * `iteratee`.
 *
 * @param {any} object
 * @param {Function} iteratee
 * @param [options]
 * @param {boolean} [options.context] The value of `this` provided for the call to `iteratee`. Default: undefined
 * @param {boolean} [options.iterateCollections] Should the `iteratee` be called for collections. Default: true
 * @param {boolean} [options.iteratePrimitives] Should the `iteratee` be called for primitives. Default: true
 * @param {boolean} [options.includeKeyPath] Should the `iteratee` be called with `keyPath` parameter. Default: true
 */
export function deepMap<T>(
    object: T,
    iteratee: (value: any, object: T) => any,
    options: { context?: any; iterateCollections?: boolean; iteratePrimitives?: boolean; includeKeyPath: false }
): any;
export function deepMap<T>(
    object: T,
    iteratee: (value: any, keyPath: KeyPath, mappedValueStack: any[], object: T) => any,
    options?: { context?: any; iterateCollections?: boolean; iteratePrimitives?: boolean; includeKeyPath?: true }
): any;
export function deepMap(
    object: any,
    iteratee: (...args: any[]) => any,
    options?: { context?: any; iterateCollections?: boolean; iteratePrimitives?: boolean; includeKeyPath?: boolean }
): any {
    const context = _.get(options, 'context');
    const iterateCollections = _.get(options, 'iterateCollections', true);
    const iteratePrimitives = _.get(options, 'iteratePrimitives', true);
    const includeKeyPath = _.get(options, 'includeKeyPath', true);

    function _mapDeep(value: any, keyPath: KeyPath | null, mappedValueStack: any[] | null) {
        const invokeIteratee = _.isPlainObject(value) || _.isArray(value) ? iterateCollections : iteratePrimitives;
        if (invokeIteratee) {
            value =
                options?.includeKeyPath === false ? iteratee.call(context, value, object) : iteratee.call(context, value, keyPath, mappedValueStack, object);
        }
        const childrenIterator = (val: any, key: string | number) => {
            if (includeKeyPath) {
                return _mapDeep(val, _.concat(keyPath!, key), _.concat(mappedValueStack!, value));
            }
            return _mapDeep(val, null, null);
        };
        if (_.isPlainObject(value)) {
            value = _.mapValues(value, childrenIterator);
        } else if (Array.isArray(value)) {
            value = _.map(value, childrenIterator);
        }
        return value;
    }

    return _mapDeep(object, [], []);
}

export async function asyncMapDeep(
    value: any,
    iteratee: (options: { value: any; keyPath: (string | number)[]; stack: any[]; skipNested: () => void }) => Promise<any>,
    options: { postOrder?: boolean; iterateCollections?: boolean; iteratePrimitives?: boolean; iterateScalars?: boolean; context?: any } = {}
) {
    const context = _.get(options, 'context');
    const iterateCollections = _.get(options, 'iterateCollections', true);
    const iteratePrimitives = _.get(options, 'iteratePrimitives', _.get(options, 'iterateScalars', true));
    const postOrder = _.get(options, 'postOrder', false);

    async function _mapDeep(value: any, keyPath: (string | number)[], stack: any[]) {
        const invokeIteratee = _.isPlainObject(value) || _.isArray(value) ? iterateCollections : iteratePrimitives;
        let shouldSkipNested = false;
        if (invokeIteratee && !postOrder) {
            value = await iteratee.call(context, {
                value,
                keyPath,
                stack,
                skipNested: () => {
                    shouldSkipNested = true;
                }
            });
        }
        // check if we should stop handling current branch
        if (shouldSkipNested) {
            return value;
        }
        if (_.isPlainObject(value)) {
            const mappedObject: Record<string, any> = {};
            const keys = Object.keys(value);
            const values = await Promise.all(
                _.map(keys, (key) => {
                    return _mapDeep(value[key], _.concat(keyPath, key), _.concat(stack, value));
                })
            );
            keys.forEach((key, i) => (mappedObject[key] = values[i]));
            value = mappedObject;
        } else if (_.isArray(value)) {
            value = await Promise.all(
                _.map(value, (val, key) => {
                    return _mapDeep(val, _.concat(keyPath, key), _.concat(stack, [value]));
                })
            );
        }
        if (invokeIteratee && postOrder) {
            value = await iteratee.call(context, {
                value,
                keyPath,
                stack,
                skipNested: () => {}
            });
        }
        return value;
    }

    return _mapDeep(value, [], []);
}
