import { addAll, deepClone, mapSet } from '@takeshape/util';
import get from 'lodash/get.js';
import isObject from 'lodash/isObject.js';
import pick from 'lodash/pick.js';
import set from 'lodash/set.js';
import { getImplementationShapeNameMap, isInterfaceShape, pruneUnusedInterfaces } from "./interfaces.js";
import { createGetNamespace, dereferenceObjectSchema, getAllPropertyRefs, getRef, getRefShapeName, isValidRefItem, propertyRefItemToPath, refExpressionToNamespacedName, refItemToNamespacedShapeName } from "./refs.js";
import { getAllNamespaceShapes, getAllRefsInQuery, getArgsReference, visitSchemaProperties } from "./schema-util.js";
import { isExtendsSchema, isObjectSchema, isProjectSchemaWithServices } from "./types/index.js";
function getGraphQLServiceShapeMap(layers) {
    return Object.assign({}, ...Object.values(layers).map((layer) => layer.schema?.shapes ?? {}));
}
function schemaExtendsShape(context, shapeName, schema) {
    return (isExtendsSchema(schema) &&
        schema.extends.some((item) => getRefShapeName(context, item) === shapeName));
}
function getMissingPropertyRefs(projectSchema, getNamespace) {
    const propertyRefs = getAllPropertyRefs(projectSchema);
    return propertyRefs.filter((ref) => {
        const path = propertyRefItemToPath(getNamespace, ref);
        return !get(projectSchema, path);
    });
}
function refToQuery(serviceSchemas, ref) {
    const serviceSchema = serviceSchemas[ref.layerId]?.schema;
    if (serviceSchema) {
        if (ref.shapeName === 'Query' || ref.shapeName === 'Mutation') {
            const operation = ref.shapeName === 'Query' ? 'queries' : 'mutations';
            return serviceSchema[operation][ref.propertyName];
        }
    }
}
function getShapeRefs(services, serviceSchemas, propertyRefs) {
    const results = [];
    for (const ref of propertyRefs) {
        const query = refToQuery(serviceSchemas, ref);
        const layerSchema = serviceSchemas[ref.layerId]?.schema;
        if (query && layerSchema) {
            const layerWithService = {
                ...layerSchema,
                services: pick(services, [ref.layerId]) // include own service so refs have isValidService: true
            };
            results.push(...getAllRefsInQuery(layerWithService, [], query));
        }
    }
    return results;
}
function interfacesToShapeNames(projectSchema, shapeRefArray) {
    return shapeRefArray.map((shapeRef) => refExpressionToNamespacedName(projectSchema, shapeRef));
}
/**
 * Add all referenced shape names to the refSet meant to be used as a callback to visitSchemaProperties
 */
export function collectReferencedShapeNames(context, shapeNames) {
    const addRef = (refItem) => {
        shapeNames.add(refItemToNamespacedShapeName(context, refItem));
    };
    const collect = (schema) => {
        const args = schema['@args'];
        if (args && isObject(args) && isObjectSchema(args)) {
            Object.values(args.properties).forEach(collect);
        }
        const argsRefItem = getArgsReference(context, schema);
        if (argsRefItem) {
            addRef(argsRefItem);
        }
        const refItem = getRef(context, schema);
        if (refItem) {
            addRef(refItem);
        }
    };
    return collect;
}
/**
 * Returns a schema with all of its dependencies resolved.
 * SHOULD only return the minimum set of remote dependencies required to enable
 * the local shape definitions.
 */
export function resolveSchemaShapeDependencies(projectSchema, serviceLayers) {
    const newSchema = deepClone(projectSchema);
    if (isProjectSchemaWithServices(projectSchema)) {
        const newShapeMap = {};
        const requiredShapeNames = new Set(getAllNamespaceShapes(projectSchema));
        const getNamespace = createGetNamespace(projectSchema);
        const missingPropertyRefs = getMissingPropertyRefs(projectSchema, getNamespace);
        if (requiredShapeNames.size || missingPropertyRefs.length) {
            const remoteShapeMap = getGraphQLServiceShapeMap(serviceLayers);
            // Add shape references from queries
            addAll(requiredShapeNames, getShapeRefs(projectSchema.services, serviceLayers, missingPropertyRefs)
                .filter(isValidRefItem(projectSchema))
                .map((item) => refItemToNamespacedShapeName(projectSchema, item)));
            // Add referenced queries and mutations
            for (const ref of missingPropertyRefs) {
                const query = refToQuery(serviceLayers, ref);
                if (query) {
                    set(newSchema, propertyRefItemToPath(getNamespace, ref), query);
                }
            }
            // Gather all the remote dependencies that our local schema requires.
            // If the required shape exists in the remote shape map, visit the
            // properties on the remote shape's schema and ensure they are required
            // as well.
            const collectDependencies = (shapeNames) => {
                for (const name of shapeNames) {
                    const localShape = newSchema.shapes[name];
                    const remoteShape = remoteShapeMap[name];
                    // Don't check for dependencies if the shape is already in our schema unless it extends a remote shape
                    // getNamespacedShapeNameList already collected those dependencies
                    if ((!localShape || (localShape && schemaExtendsShape(projectSchema, name, localShape.schema))) &&
                        remoteShape) {
                        visitSchemaProperties(remoteShape.schema, [remoteShape.name, 'schema'], collectReferencedShapeNames({ services: newSchema.services }, shapeNames));
                    }
                }
            };
            collectDependencies(requiredShapeNames);
            // Collect interfaces that requiredShapeNames implement
            const interfaceNames = new Set();
            const requiredShapes = mapSet(requiredShapeNames, (shapeName) => newSchema.shapes[shapeName] ?? remoteShapeMap[shapeName]);
            for (const shape of requiredShapes) {
                if (shape?.interfaces) {
                    addAll(interfaceNames, interfacesToShapeNames(projectSchema, shape.interfaces));
                }
            }
            // Collect implementations for interfaces in requiredShapeNames
            const implementationNames = new Set();
            const implementations = getImplementationShapeNameMap({
                ...newSchema.shapes,
                ...remoteShapeMap
            });
            for (const shape of requiredShapes) {
                if (isInterfaceShape(shape) && implementations[shape.name]) {
                    addAll(implementationNames, implementations[shape.name]);
                }
            }
            // Collect the dependencies for the interfaces and implementations
            collectDependencies(interfaceNames);
            collectDependencies(implementationNames);
            addAll(requiredShapeNames, interfaceNames);
            addAll(requiredShapeNames, implementationNames);
            // Populate a ShapeMap with the remote shapes we've gathered
            // A combined context which prefers remote shapes for use when merging/inlining
            const mergingContext = {
                services: newSchema.services,
                shapes: { ...newSchema.shapes, ...remoteShapeMap }
            };
            for (const name of requiredShapeNames) {
                const localShape = newSchema.shapes[name];
                const remoteShape = remoteShapeMap[name];
                if (localShape && remoteShape && schemaExtendsShape(mergingContext, name, localShape.schema)) {
                    // Merge and inline combined shape when a local shape extends a remote shape
                    newShapeMap[name] = {
                        ...localShape,
                        schema: dereferenceObjectSchema(mergingContext, newSchema.shapes[name].schema)
                    };
                }
                else if (localShape ?? remoteShape) {
                    newShapeMap[name] = localShape ?? remoteShape;
                }
            }
        }
        // Assign remote shapes to the new schema, mutating the new schema
        Object.assign(newSchema.shapes, pruneUnusedInterfaces(newSchema, newShapeMap));
    }
    return newSchema;
}
