import { assert, deepClone, isDefined, rebaseArray, rebaseObject } from '@takeshape/util';
import assignWith from 'lodash/assignWith.js';
import difference from 'lodash/difference.js';
import intersection from 'lodash/intersection.js';
import isEqual from 'lodash/isEqual.js';
import last from 'lodash/last.js';
import omit from 'lodash/omit.js';
import pick from 'lodash/pick.js';
import pickBy from 'lodash/pickBy.js';
import set from 'lodash/set.js';
import union from 'lodash/union.js';
import unset from 'lodash/unset.js';
import { isExtendsSchema, isObjectSchema, isRefSchema } from "../types/index.js";
export function omitObjectSchema(schema, names) {
    const { properties, required, ...rest } = schema;
    const filteredSchema = {
        ...rest,
        properties: omit(properties, names)
    };
    if (required) {
        filteredSchema.required = difference(required, names);
    }
    return filteredSchema;
}
export function pickObjectSchema(schema, names) {
    const { properties, required, ...rest } = schema;
    const filteredSchema = {
        ...rest,
        properties: pick(properties, names)
    };
    if (required) {
        filteredSchema.required = intersection(required, names);
    }
    return filteredSchema;
}
export function mergeObjectSchemas(...args) {
    return args.filter(isDefined).reduce((parent, schemas) => mergeSchemaProperties(parent, schemas), {
        type: 'object',
        properties: {}
    });
}
export function mergeDeepObjectSchemas(...args) {
    return args.filter(isDefined).reduce((parent, schemas) => mergeSchemaProperties(parent, schemas, true), {
        type: 'object',
        properties: {}
    });
}
export function mergeSchemaProperties(parent, overrides, deep = false) {
    const result = {
        ...parent,
        properties: deep
            ? assignWith({}, parent.properties, overrides.properties, (objValue, srcValue) => {
                // merge objects if possible
                if (objValue !== undefined && srcValue !== undefined) {
                    // skip merging if they are equal this avoids allOf with duplicate refs
                    if (isEqual(objValue, srcValue)) {
                        return objValue;
                    }
                    if (isObjectSchema(objValue) && isObjectSchema(srcValue)) {
                        return mergeObjectSchemas(objValue, srcValue);
                    }
                    if ((isObjectSchema(objValue) || isRefSchema(objValue)) &&
                        (isObjectSchema(srcValue) || isRefSchema(srcValue))) {
                        return { allOf: [objValue, srcValue] };
                    }
                }
            })
            : { ...parent.properties, ...overrides.properties }
    };
    const required = union(parent.required, overrides.required);
    if (required.length) {
        result.required = required;
    }
    return result;
}
export function rebaseSchemaProperties(base, parent, head) {
    const { properties: toProperties, required: toRequired, ...toOther } = base;
    const { properties: baseProperties, required: baseRequired, ...baseOther } = parent;
    const { properties: fromProperties, required: fromRequired, ...fromOther } = head;
    const newSchema = {
        ...rebaseObject(toOther, baseOther, fromOther),
        type: 'object',
        properties: rebaseObject(toProperties, baseProperties, fromProperties)
    };
    const required = rebaseArray(toRequired ?? [], baseRequired ?? [], fromRequired ?? []);
    if (required.length) {
        newSchema.required = required;
    }
    return newSchema;
}
export function mergeFormProperties(parent, overrides) {
    const result = {
        ...parent,
        properties: {
            ...parent.properties,
            ...overrides.properties
        }
    };
    const order = union(parent.order, overrides.order);
    if (order.length) {
        result.order = order;
    }
    return result;
}
export function rebaseFormProperties(base, parent, head) {
    const { properties: toProperties, order: toOrder, ...toOther } = base;
    const { properties: baseProperties, order: baseOrder, ...baseOther } = parent;
    const { properties: fromProperties, order: fromOrder, ...fromOther } = head;
    const newForm = {
        ...rebaseObject(toOther, baseOther, fromOther),
        properties: rebaseObject(toProperties, baseProperties, fromProperties)
    };
    const order = rebaseArray(toOrder ?? [], baseOrder ?? [], fromOrder ?? []);
    if (order.length) {
        newForm.order = order;
    }
    return newForm;
}
export function mergeFormConfigs(...configs) {
    return configs.reduce(mergeFormProperties, { properties: {}, order: [] });
}
const mergeableSections = ['shapes', 'mutations', 'queries', 'forms', 'services'];
const isMergeableSection = (str) => {
    return mergeableSections.includes(str);
};
const pickUnmergeable = (projectSchema) => {
    return pickBy(projectSchema, (_value, key) => !isMergeableSection(key));
};
function compareSection(section, original, updated) {
    const changes = [];
    const originalKeys = Object.keys(original);
    const updatedKeys = Object.keys(updated);
    for (const removedKey of difference(originalKeys, updatedKeys)) {
        changes.push({ path: [section, removedKey], section, op: 'remove' });
    }
    for (const [key, value] of Object.entries(updated)) {
        if (!original[key]) {
            changes.push({ path: [section, key], section, op: 'add', value });
        }
        else if (!isEqual(original[key], updated[key])) {
            changes.push({ path: [section, key], section, op: 'update', value });
        }
    }
    return changes;
}
function compareSections(parent, branch) {
    return mergeableSections.flatMap((section) => (parent[section] ?? branch[section]) ? compareSection(section, parent[section] ?? {}, branch[section] ?? {}) : []);
}
function findChangedShape(schema, change) {
    return schema.shapes[change.path[1]];
}
function getChangedShape(schema, change) {
    const shape = findChangedShape(schema, change);
    assert(shape, 'Expected shape not found');
    return shape;
}
function rebaseShapeProperties(toShape, fromShape, baseShape) {
    return baseShape
        ? rebaseObject(omit(toShape, 'schema'), omit(baseShape, 'schema'), omit(fromShape, 'schema'))
        : { ...toShape, ...fromShape };
}
function attemptToMerge(base, parent, head, change) {
    const toShape = getChangedShape(base, change);
    const fromShape = getChangedShape(head, change);
    const toSchema = toShape.schema;
    const fromSchema = fromShape.schema;
    if (isEqual(toSchema, fromSchema)) {
        const baseShape = findChangedShape(parent, change);
        return {
            ...rebaseShapeProperties(toShape, fromShape, baseShape),
            schema: toSchema
        };
    }
    if (isObjectSchema(toSchema) && isObjectSchema(fromSchema)) {
        const baseShape = findChangedShape(parent, change);
        const baseSchema = baseShape?.schema;
        return {
            ...rebaseShapeProperties(toShape, fromShape, baseShape),
            schema: change.op === 'update' && baseSchema && isObjectSchema(baseSchema)
                ? rebaseSchemaProperties(toSchema, baseSchema, fromSchema)
                : mergeSchemaProperties(toSchema, fromSchema)
        };
    }
    if (isExtendsSchema(toSchema) &&
        isExtendsSchema(fromSchema) &&
        toSchema.extends.length === fromSchema.extends.length) {
        const fromExtends = fromSchema.extends;
        const toObj = last(toSchema.extends);
        const fromObj = last(fromSchema.extends);
        if (toObj &&
            isObjectSchema(toObj) &&
            fromObj &&
            isObjectSchema(fromObj) &&
            toSchema.extends.slice(0, toSchema.extends.length - 1).every((item, i) => isEqual(item, fromExtends[i]))) {
            let mergedObjSchema;
            const baseShape = findChangedShape(parent, change);
            const baseSchema = baseShape?.schema;
            if (change.op === 'update' && baseSchema && isExtendsSchema(baseSchema)) {
                const baseObj = last(baseSchema.extends);
                if (baseObj && isObjectSchema(baseObj)) {
                    mergedObjSchema = rebaseSchemaProperties(toObj, baseObj, fromObj);
                }
            }
            mergedObjSchema ||= mergeSchemaProperties(toObj, fromObj);
            return {
                ...rebaseShapeProperties(toShape, fromShape, baseShape),
                schema: {
                    extends: [...toSchema.extends.slice(0, toSchema.extends.length - 1), mergedObjSchema]
                }
            };
        }
    }
}
function applyChange(base, parent, head, change) {
    if (change.op === 'remove') {
        unset(base, change.path);
    }
    else if (change.op === 'add') {
        set(base, change.path, change.value);
    }
    else if (change.op === 'update') {
        if (change.section === 'shapes') {
            const mergedShape = attemptToMerge(base, parent, head, change);
            if (mergedShape) {
                set(base, change.path, mergedShape);
                return;
            }
        }
        else if (change.section === 'forms') {
            const shapeName = change.path[1];
            const toForm = base.forms?.[shapeName]?.default;
            const fromForm = head.forms?.[shapeName]?.default;
            if (toForm && fromForm) {
                const baseForm = parent.forms?.[shapeName]?.default;
                set(base, change.path, {
                    default: baseForm ? rebaseFormProperties(toForm, baseForm, fromForm) : mergeFormConfigs(toForm, fromForm)
                });
                return;
            }
        }
        set(base, change.path, change.value);
    }
}
export function mergeProjectSchemas(base, parent, head) {
    const changesTo = compareSections(parent, base);
    const changesFrom = compareSections(parent, head);
    const mergedSchema = deepClone(base);
    for (let change of changesFrom) {
        const potentialConflict = changesTo.find((changeTo) => isEqual(changeTo.path, change.path));
        if (potentialConflict) {
            if (potentialConflict.op === 'remove' && change.op !== 'remove') {
                return {
                    error: `Attempting to ${change.op} ${change.path.join('.')} which has already been removed`,
                    mergedSchema: undefined
                };
            }
            if ((potentialConflict.op === 'add' && change.op === 'add') ||
                (potentialConflict.op === 'update' && change.op === 'update')) {
                if (change.section === 'shapes') {
                    const mergedValue = attemptToMerge(base, parent, head, change);
                    if (!mergedValue) {
                        return {
                            error: `Unable to merge ${change.path.join('.')} schemas conflict`,
                            mergedSchema: undefined
                        };
                    }
                    change = { ...change, op: 'add', value: mergedValue };
                }
                else if (change.section === 'forms') {
                    change = { ...change, op: 'update' };
                }
            }
        }
        applyChange(mergedSchema, parent, head, change);
    }
    Object.assign(mergedSchema, rebaseObject(pickUnmergeable(base), pickUnmergeable(parent), pickUnmergeable(head)));
    return { error: undefined, mergedSchema };
}
