import { isDefined } from '@takeshape/util';
import camelCase from 'lodash/camelCase.js';
import find from 'lodash/find.js';
import uniq from 'lodash/uniq.js';
import { allBuiltInShapes } from "./builtin-schema.js";
import { followRef, getRefShapeName, getShapeByRef, shapeToRefString } from "./refs.js";
import { isModelShape, isObjectSchema, isPropertySchemaWithRelationship } from "./types/index.js";
import { isUnionSchema } from "./unions.js";
import { getShapeById } from "./util/shapes.js";
/**
 * Turn a LegacyRelationship into a partial PropertySchemaWithRelationship.
 */
export function getRelationshipSchema(projectSchema, relationship) {
    const propertySchema = {
        '@args': 'TSRelationshipArgs',
        '@resolver': {
            name: 'shapedb:getRelated',
            service: 'shapedb',
            options: {
                nullable: true
            }
        }
    };
    let isMultiple;
    let shapeRefs;
    if ('shapeIds' in relationship) {
        shapeRefs = relationship.shapeIds
            .map((shapeId) => {
            const shape = getShapeById(projectSchema, shapeId) ?? find(allBuiltInShapes, (shape) => shape.id === shapeId);
            return (shape && {
                '@ref': shapeToRefString(projectSchema, shape)
            });
        })
            .filter(isDefined);
        isMultiple = relationship.type === 'multiple';
    }
    else {
        shapeRefs = relationship.refs.map((refStr) => ({ '@ref': refStr }));
        isMultiple = relationship.isMultiple;
    }
    if (!shapeRefs.length) {
        throw new Error(`Invalid empty relationship ${JSON.stringify(relationship)}`);
    }
    const shapeRefsSchema = shapeRefs.length === 1 ? shapeRefs[0] : { oneOf: shapeRefs };
    if (isMultiple) {
        return {
            ...propertySchema,
            type: 'array',
            items: shapeRefsSchema,
            '@input': {
                type: 'array',
                items: {
                    '@ref': 'TSRelationship'
                }
            }
        };
    }
    return {
        ...propertySchema,
        ...shapeRefsSchema,
        '@input': {
            '@ref': 'TSRelationship'
        }
    };
}
/**
 * Get a list of refs given a property schema with a relationship resolver.
 */
export function getRelationshipShapeRefs(propertySchema) {
    let refs = [];
    const schema = propertySchema.items ?? propertySchema;
    if (schema.oneOf) {
        refs = schema.oneOf.map((schema) => schema['@ref']).filter(isDefined);
    }
    else if (schema['@ref']) {
        refs = [schema['@ref']];
    }
    return refs;
}
/**
 * Get a list of shapes given a property schema with a relationship resolver.
 */
export function getRelationshipShapes(shapes, refs) {
    const context = { shapes };
    return refs.map((ref) => getShapeByRef(context, ref)).filter(isDefined);
}
/**
 * Get a list of shape ids given a property schema with a relationship resolver.
 */
export function getRelationshipShapeIds(shapes, refs) {
    const context = { shapes };
    return refs.map((ref) => getShapeByRef(context, ref)?.id).filter(isDefined);
}
/**
 * Turn a Relationship into a LegacyRelationship object.
 */
export function getLegacyRelationship(projectSchema, relationship) {
    return {
        type: relationship.isMultiple ? 'multiple' : 'single',
        shapeIds: getRelationshipShapeIds(projectSchema.shapes, relationship.refs)
    };
}
/**
 * Get relationship details from a PropertySchema.
 */
export function getRelationship(propertySchema) {
    propertySchema = propertySchema['@output'] ?? propertySchema;
    if (!isPropertySchemaWithRelationship(propertySchema)) {
        return;
    }
    const isMultiple = propertySchema.type === 'array';
    const hasBackreference = Boolean(propertySchema['@backreference']?.enabled);
    const backreferenceName = propertySchema['@backreference']?.name;
    const refs = getRelationshipShapeRefs(propertySchema);
    return {
        isMultiple,
        refs,
        hasBackreference,
        backreferenceName
    };
}
function addRelationship(relationships, id, env) {
    relationships[id] ||= [];
    relationships[id].push(env);
}
export function findExistingRelationships(projectSchema, shapes) {
    const relationships = {};
    const shapeArray = Object.values(shapes);
    const shapesSeen = new Set();
    const shapeIds = new Set(shapeArray.map((shape) => shape.id));
    const findRelationships = (schema, path, parentShapeName) => {
        const relationship = getRelationship(schema);
        if (relationship) {
            const relatedShapeIds = getRelationshipShapeIds(shapes, relationship.refs);
            const relatedName = schema['@backreference']?.name;
            for (const shapeId of relatedShapeIds) {
                if (shapeIds.has(shapeId)) {
                    addRelationship(relationships, shapeId, {
                        relatedName,
                        path: path.slice(1),
                        shapeId: path[0],
                        hasBackreference: Boolean(schema['@backreference']?.enabled),
                        schema,
                        shapeName: parentShapeName
                    });
                }
            }
        }
        const shapeName = getRefShapeName(projectSchema, schema);
        if (shapeName && !shapes[shapeName]) {
            return;
        }
        const prop = shapeName ? followRef(projectSchema, schema) : schema;
        if (shapeName) {
            // Skip shapes we have already seen to avoid cycles
            if (shapesSeen.has(shapeName)) {
                return;
            }
            shapesSeen.add(shapeName);
        }
        if (!relationship) {
            if (prop.properties) {
                for (const name of Object.keys(prop.properties)) {
                    findRelationships(prop.properties[name], path.concat(name), shapeName ?? parentShapeName);
                }
            }
            else if (prop.items) {
                findRelationships(prop.items, path, shapeName ?? parentShapeName);
            }
            else if (isUnionSchema(prop)) {
                for (const child of prop.oneOf) {
                    findRelationships(child, path, shapeName ?? parentShapeName);
                }
            }
        }
    };
    for (const shape of shapeArray) {
        if (isModelShape(shape)) {
            const { schema, id, name } = shape;
            findRelationships(schema, [id], name);
        }
    }
    return relationships;
}
/**
 * Test whether two PropertySchemas have equal relationships
 */
export function isEqualRelationship(a, b) {
    const relationshipA = getRelationship(a);
    const relationshipB = getRelationship(b);
    // non-relationships are considered equal in that they are both equal that regard
    if (!relationshipA && !relationshipB) {
        return true;
    }
    const refsA = relationshipA?.refs;
    if (!refsA) {
        return false;
    }
    const refsB = relationshipB?.refs;
    if (!refsB || refsA.length !== refsB.length) {
        return false;
    }
    const refSet = new Set(refsA);
    return refsB.every((ref) => refSet.has(ref));
}
/**
 * Test whether an array of PropertySchemas have unequal relationships
 */
export function hasUnequalRelationships(relationships) {
    return Boolean(relationships.find((propertySchema, index, allRelationships) => {
        const nextPropertySchema = allRelationships[index + 1];
        return nextPropertySchema ? !isEqualRelationship(propertySchema, nextPropertySchema) : false;
    }));
}
function getRelatedShapeIds(relationships) {
    return uniq(relationships.map((rel) => (rel.hasBackreference ? rel.shapeId : undefined)).filter(isDefined));
}
function getShapes(projectSchema, shapeIds) {
    return shapeIds.map((shapeId) => getShapeById(projectSchema, shapeId)).filter(isDefined);
}
/**
 * Adds backreference fields to the schema.
 */
export function addRelatedFields(projectSchema, allRelationships) {
    for (const [shapeId, shapeRelationships] of Object.entries(allRelationships)) {
        const shape = getShapeById(projectSchema, shapeId);
        if (shape && isObjectSchema(shape.schema)) {
            const relatedShapeIds = getRelatedShapeIds(shapeRelationships);
            const relatedShapeRefs = getShapes(projectSchema, relatedShapeIds).map((shape) => shapeToRefString(projectSchema, shape));
            if (relatedShapeRefs.length) {
                let shapeRef;
                if (relatedShapeRefs.length === 1) {
                    // If only one back reference exists, _references is a regular shape
                    shapeRef = relatedShapeRefs[0];
                }
                else {
                    // If many back references exist, _references will be a union of referring shapes
                    const shapeName = `${shape.name}Reference`;
                    shapeRef = shapeName;
                    projectSchema.shapes[shapeName] = {
                        id: shapeName,
                        name: shapeName,
                        title: shapeName,
                        schema: {
                            oneOf: relatedShapeRefs.map((name) => ({
                                '@ref': name
                            }))
                        }
                    };
                }
                // _references, a list of all backreferences across all fields on this shape.
                // It lists all the items that have references to the current item.
                // It is for convenience.
                if (shapeRelationships.some((rel) => rel.hasBackreference)) {
                    shape.schema.properties._references = {
                        '@args': `TSListArgs<${shapeRef}>`,
                        '@ref': `PaginatedList<${shapeRef}>`,
                        '@resolver': {
                            name: 'shapedb:list',
                            service: 'shapedb',
                            args: {
                                ops: [
                                    { path: '$', mapping: '$args' },
                                    { path: 'baseWhere._references.eq', mapping: '$source._id' },
                                    { path: 'baseWhere._shapeId.in', value: relatedShapeIds }
                                ]
                            }
                        }
                    };
                }
            }
            // Create a schema for the backreference on the referred-to shape
            for (const relationship of shapeRelationships) {
                const relatedShape = getShapeById(projectSchema, relationship.shapeId);
                if (relatedShape && relationship.hasBackreference) {
                    const { relatedName } = relationship;
                    const relatedFieldName = relatedName ? relatedName : `${camelCase(relatedShape.name)}Set`;
                    const filterField = relatedName ? relationship.path.concat('_id').join('.') : '_references';
                    shape.schema.properties[relatedFieldName] = {
                        '@args': `TSListArgs<${relatedShape.name}>`,
                        '@ref': `PaginatedList<${relatedShape.name}>`,
                        '@resolver': {
                            name: 'shapedb:list',
                            service: 'shapedb',
                            args: {
                                ops: [
                                    { path: '$', mapping: '$args' },
                                    {
                                        path: `baseWhere.${filterField}.eq`,
                                        mapping: '$source._id'
                                    },
                                    { path: 'baseWhere._shapeId.eq', value: relatedShape.id }
                                ]
                            }
                        }
                    };
                }
            }
        }
    }
    return projectSchema;
}
