import { formatShapeName, isDefined, pascalCase } from '@takeshape/util';
import md5 from 'blueimp-md5';
import isFunction from 'lodash/isFunction.js';
import uniqBy from 'lodash/uniqBy.js';
import unset from 'lodash/unset.js';
import { workflowsEnabled } from "../api-version.js";
import { allBuiltInShapes } from "../builtin-schema.js";
import { followRef, getRef, getRefOrItemsRef, hasRefProperty, hasResolvableRef, parseRef, refItemToNamespacedShapeName, refItemToShape } from "../refs.js";
import { getRelationship, getRelationshipShapes, isEqualRelationship } from "../relationships.js";
import { createSchemaPropertyList, createShape, getAllRefsInShapes } from "../schema-util.js";
import { isCachedShape, isObjectSchema, isPropertySchemaWithRelationship } from "../types/utils.js";
import { enumerateOneOfKeys, isUnionSchema } from "../unions.js";
export const MAX_RELATIONSHIP_DEPTH = 1;
export const exceededRelationshipDepth = (depth = 0) => depth >= MAX_RELATIONSHIP_DEPTH;
class ShapeCache {
    thunks = new Map();
    unresolved = new Set();
    has(shapeName) {
        return this.thunks.has(shapeName);
    }
    set(shapeName, thunk) {
        this.thunks.set(shapeName, thunk);
        if (isFunction(thunk)) {
            this.unresolved.add(shapeName);
        }
    }
    resolveAll() {
        while (this.unresolved.size) {
            const shapeName = this.unresolved.values().next().value;
            if (shapeName) {
                const thunk = this.thunks.get(shapeName);
                if (isFunction(thunk)) {
                    this.thunks.set(shapeName, thunk());
                    this.unresolved.delete(shapeName);
                }
            }
        }
        const result = {};
        for (const [shapeName, shape] of this.thunks) {
            result[shapeName] = shape;
        }
        return result;
    }
}
export const fieldTypeComparison = {
    id: { comparators: ['eq', 'in'], type: 'string' },
    boolean: { comparators: ['eq'], type: 'boolean' },
    string: {
        comparators: ['eq', 'in', 'match', 'match_advanced', 'match_phrase', 'match_phrase_advanced', 'regexp', 'wildcard'],
        type: 'string'
    },
    date: { comparators: ['eq', 'lt', 'lte', 'gt', 'gte'], type: 'string' },
    number: {
        comparators: ['eq', 'lt', 'lte', 'gt', 'gte', 'in'],
        type: 'number'
    },
    integer: {
        comparators: ['eq', 'lt', 'lte', 'gt', 'gte', 'in'],
        type: 'integer'
    },
    workflow: {
        comparators: ['eq', 'lt', 'lte', 'gt', 'gte', 'in'],
        type: 'string'
    },
    draftjs: { comparators: ['match', 'match_advanced', 'match_phrase', 'match_phrase_advanced'], type: 'string' },
    mdx: { comparators: ['match', 'match_advanced', 'match_phrase', 'match_phrase_advanced'], type: 'string' }
};
export const comparatorDescriptions = {
    eq: 'Exact match',
    gt: 'Greater than',
    gte: 'Greater than or equal',
    lt: 'Less than',
    lte: 'Less than or equal',
    in: 'Array of possible exact match values.',
    match: 'Full text searching with fuzzy matching.',
    match_advanced: 'Full text searching with advanced options like operator (and/or), fuzziness, minimum_should_match, analyzer, and zero_terms_query.',
    match_phrase: 'Full text searching requiring the exact phrase to appear in order.',
    match_phrase_advanced: 'Full text phrase matching with advanced options like slop (word distance), analyzer, and zero_terms_query.',
    regexp: 'Regular expression string matching. Use of * wildcards could degrade performance.',
    wildcard: 'Wildcard pattern matching with * (multiple characters) and ? (single character).'
};
export const booleanOperators = ['AND', 'OR', 'NOT'];
export const boolOpDescriptionMap = {
    AND: 'AND takes an array of conditions that must appear in the matching results. Nested boolean operators can be used to create complex filters.',
    OR: 'OR takes an array of conditions that should appear in the matching results. Nested boolean operators can be used to create complex filters.',
    NOT: 'NOT takes a single condition that must not appear in the matching results.'
};
function getConflictingProperties(projectSchema, shapes) {
    const conflicts = new Set();
    const allProps = {};
    for (const shape of Object.values(shapes)) {
        for (const [name, prop] of createSchemaPropertyList(projectSchema, shape).getNodes()) {
            if (name in allProps && (allProps[name].type !== prop.type || !isEqualRelationship(allProps[name], prop))) {
                conflicts.add(name);
            }
        }
        if (isObjectSchema(shape.schema)) {
            Object.assign(allProps, shape.schema.properties);
        }
    }
    return conflicts;
}
export function getFieldTypeComparison(fieldType) {
    const comparison = fieldTypeComparison[fieldType];
    if (!comparison) {
        return undefined;
    }
    const { comparators, type } = comparison;
    const result = {};
    for (const op of comparators) {
        const description = comparatorDescriptions[op];
        if (op === 'in') {
            result[op] = { type: 'array', items: { type }, description };
        }
        else if (op === 'wildcard') {
            result[op] = { '@ref': 'TSWhereWildcard', description };
        }
        else if (op === 'match_advanced') {
            result[op] = { '@ref': 'TSWhereMatchAdvanced', description };
        }
        else if (op === 'match_phrase_advanced') {
            result[op] = { '@ref': 'TSWhereMatchPhraseAdvanced', description };
        }
        else {
            result[op] = { description, type };
        }
    }
    return result;
}
function isId(name) {
    return name === '_id' || name === '_contentTypeId' || name === '_shapeId';
}
function isNonRelationshipRef(prop) {
    return Boolean(hasRefProperty(prop) && !getRelationship(prop));
}
function getPropType(projectSchema, name, prop) {
    if (prop['@workflow']) {
        return 'workflow';
    }
    if (prop['@tag'] === 'draftjs') {
        return 'draftjs';
    }
    if (prop['@tag'] === 'mdx') {
        return 'mdx';
    }
    if (prop.format === 'date-time') {
        return 'date';
    }
    if (isId(name)) {
        return 'id';
    }
    if (isNonRelationshipRef(prop)) {
        const followed = followRef(projectSchema, prop);
        if (followed.enum) {
            return 'string';
        }
        return 'object';
    }
    return prop.type;
}
function truncateNames(names) {
    // For unions of many types hash the names this reduces shape name size while still allowing reuse
    return names.length > 80 ? md5(names) : names;
}
/**
 * Get a formatted string of related shape names from a property schema with a
 * relationship resolver
 */
function getRelationshipShapeName(context, refs) {
    return refs
        .map((ref) => formatShapeName(parseRef(context, ref).shapeName))
        .filter(isDefined)
        .sort()
        .join('');
}
function joinShapeNames(shapes) {
    return shapes.sort().join('');
}
function getTypeName(name, prop, shapeName, context) {
    const { projectSchema, relationshipDepth, conflictingProperties } = context;
    let type = getPropType(projectSchema, name, prop);
    if (isId(name)) {
        return 'TSWhereID';
    }
    const relationship = getRelationship(prop);
    if (relationship) {
        const relationshipName = getRelationshipShapeName(context.projectSchema, relationship.refs);
        if (!relationshipName) {
            throw new Error(`Relationship property "${prop.title ?? ''}" is missing related shapes`);
        }
        const shapeNames = truncateNames(relationshipName);
        return `TSWhere${shapeNames}Relationship`;
    }
    if (isUnionSchema(prop)) {
        if (prop.oneOf.length > 1) {
            const shapeNames = truncateNames(joinShapeNames(enumerateOneOfKeys(projectSchema, prop.oneOf).map((child) => child.shapeName)));
            return `TSWhere${shapeNames}Union`;
        }
        type = 'object';
        prop = prop.oneOf[0];
    }
    if (type === 'object' || type === 'array') {
        const prefix = `TS${relationshipDepth >= MAX_RELATIONSHIP_DEPTH ? 'Shallow' : ''}Where`;
        const fieldName = `${conflictingProperties?.has(name) ? '_' : ''}${formatShapeName(name)}`;
        const refItem = getRefOrItemsRef(projectSchema, prop);
        const suffix = refItem ? refItemToNamespacedShapeName(projectSchema, refItem) : `${shapeName}${fieldName}`;
        return `${prefix}${suffix}`;
    }
    return `TSWhere${pascalCase(type)}`;
}
function skipField({ name, prop, workflowsEnabled, exceededRelationshipDepth, isIndexedShape }) {
    const isRelationship = isPropertySchemaWithRelationship(prop);
    return Boolean(prop['@sensitive'] ??
        (Boolean(prop['@resolver'] && !isIndexedShape && !isRelationship) ||
            Boolean(isRelationship && exceededRelationshipDepth) ||
            Boolean((name === '_enabled' || name === '_enabledAt') && workflowsEnabled)));
}
export function getFields(typeName, shapes, context) {
    const { relationshipDepth, booleanOperators, projectSchema } = context;
    const result = {};
    const tooDeep = exceededRelationshipDepth(relationshipDepth);
    const conflictingProperties = shapes.length ? getConflictingProperties(projectSchema, shapes) : undefined;
    const fieldContext = { ...context, conflictingProperties };
    for (const shape of shapes) {
        const nodes = createSchemaPropertyList(projectSchema, shape)
            .filterBy(([name, prop]) => !skipField({
            name,
            prop,
            workflowsEnabled: workflowsEnabled(projectSchema.apiVersion),
            isIndexedShape: isCachedShape(shape),
            exceededRelationshipDepth: exceededRelationshipDepth(relationshipDepth)
        }))
            .getNodes();
        for (const [name, property] of nodes) {
            Object.assign(result, getPropertyComparisonType(name, property, shape.name, fieldContext));
        }
    }
    return tooDeep || booleanOperators === false ? result : Object.assign(result, getBooleanOperatorTypes(typeName));
}
function getBooleanOperatorTypes(typeName) {
    const result = {};
    for (const op of booleanOperators) {
        const refSchema = { '@ref': `local:${typeName}` };
        result[op] = op === 'NOT' ? refSchema : { type: 'array', items: refSchema };
    }
    return result;
}
export function getPropertyComparisonType(name, prop, shapeName, context) {
    const { shapeCache, projectSchema, relationshipDepth, conflictingProperties } = context;
    if (skipField({
        name,
        prop,
        workflowsEnabled: workflowsEnabled(projectSchema.apiVersion),
        isIndexedShape: isCachedShape(projectSchema.shapes[shapeName]),
        exceededRelationshipDepth: exceededRelationshipDepth(relationshipDepth)
    })) {
        return undefined;
    }
    const fieldType = getPropType(projectSchema, name, prop) ?? 'string';
    const typeName = getTypeName(name, prop, shapeName, context);
    const relationship = getRelationship(prop);
    if (hasRefProperty(prop.items ?? prop)) {
        if (!hasResolvableRef(projectSchema, prop.items ?? prop)) {
            return undefined;
        }
        if (prop.items) {
            prop = { ...prop, items: followRef(projectSchema, prop.items) };
        }
        else {
            prop = followRef(projectSchema, prop);
        }
    }
    if (!shapeCache.has(typeName) && !projectSchema.shapes[typeName]) {
        shapeCache.set(typeName, () => {
            let props;
            const propOrItems = prop.items ?? prop;
            if (isId(name)) {
                props = getFieldTypeComparison('id');
            }
            else if (relationship) {
                const shapes = getRelationshipShapes(projectSchema.shapes, relationship.refs);
                props = getFields(typeName, shapes, {
                    ...context,
                    relationshipDepth: relationshipDepth + 1
                });
            }
            else if (isUnionSchema(propOrItems)) {
                const shapes = enumerateOneOfKeys(projectSchema, propOrItems.oneOf).map((option) => projectSchema.shapes[option.shapeName]);
                props = getFields(typeName, shapes, {
                    ...context,
                    booleanOperators: false
                });
            }
            else if (fieldType === 'object' || (fieldType === 'array' && prop.items?.type === 'object')) {
                const properties = prop.items?.properties ?? prop.properties;
                if (properties) {
                    props ??= {};
                    const fieldContext = {
                        ...context,
                        conflictingProperties: undefined // non-union objects have no conflicts
                    };
                    for (const propName of Object.keys(properties)) {
                        Object.assign(props, getPropertyComparisonType(propName, properties[propName], shapeName, fieldContext));
                    }
                }
            }
            else {
                // Treat non-object arrays as their base type
                let type;
                if (typeof prop.items?.type === 'string') {
                    type = prop.items.type;
                }
                else {
                    type = fieldType;
                }
                props = getFieldTypeComparison(type);
            }
            return createShape(typeName, {
                type: 'object',
                properties: props ?? {}
            });
        });
    }
    if (shapeCache.has(typeName) || projectSchema.shapes[typeName]) {
        const fieldName = `${conflictingProperties?.has(name) ? `${shapeName}_` : ''}${name}`;
        return {
            [fieldName]: {
                '@ref': `local:${typeName}`
            }
        };
    }
}
function findEmpty(shapes) {
    const empty = new Set();
    for (const [shapeName, shape] of Object.entries(shapes)) {
        if (isObjectSchema(shape.schema) && Object.keys(shape.schema.properties).length === 0) {
            empty.add(shapeName);
        }
    }
    return empty;
}
function getRefSummary(shapes) {
    const result = {};
    for (const ref of getAllRefsInShapes({ shapes, services: {} })) {
        const path = ref.path.slice(1, ref.path.length - 1);
        if (result[ref.shapeName]) {
            result[ref.shapeName].push(path);
        }
        else {
            result[ref.shapeName] = [path];
        }
    }
    return result;
}
function pruneEmpty(shapes) {
    let toPrune = findEmpty(shapes);
    if (toPrune.size) {
        const refSummary = getRefSummary(shapes);
        while (toPrune.size) {
            for (const shapeName of toPrune) {
                unset(shapes, shapeName);
                for (const path of refSummary[shapeName] ?? []) {
                    unset(shapes, path);
                }
            }
            toPrune = findEmpty(shapes);
        }
    }
    return shapes;
}
function getReferencedShape(context) {
    return (prop) => {
        const ref = getRef(context, prop);
        return ref && refItemToShape(context, ref);
    };
}
/**
 * Expand union shapes
 */
function expandUnionShapes(projectSchema, shapes) {
    return uniqBy(shapes.flatMap((shape) => isUnionSchema(shape.schema) ? shape.schema.oneOf.map(getReferencedShape(projectSchema)).filter(isDefined) : shape), 'name');
}
export function getWhereShape(projectSchema, selectedShapes) {
    const shapeName = selectedShapes.length === 1 ? `TSWhere${pascalCase(selectedShapes[0].name)}Input` : 'TSWhereInput';
    if (projectSchema.shapes[shapeName]) {
        return { shapeName, dependencies: {} };
    }
    const expandedShapes = expandUnionShapes(projectSchema, selectedShapes);
    const isManyContentTypes = expandedShapes.length > 1;
    const shapeCache = new ShapeCache();
    const fields = getFields(shapeName, expandedShapes, {
        projectSchema,
        shapeCache,
        relationshipDepth: 0
    });
    return {
        shapeName,
        dependencies: pruneEmpty({
            [shapeName]: createShape(shapeName, {
                type: 'object',
                properties: {
                    ...fields,
                    ...(isManyContentTypes && getBooleanOperatorTypes(shapeName))
                }
            }),
            ...shapeCache.resolveAll(),
            TSWhereWildcard: allBuiltInShapes.TSWhereWildcard,
            TSWhereMatchAdvanced: allBuiltInShapes.TSWhereMatchAdvanced,
            TSWhereMatchPhraseAdvanced: allBuiltInShapes.TSWhereMatchPhraseAdvanced
        })
    };
}
export function getWhereSearchArg(projectSchema, selectedShapes) {
    const { shapeName, dependencies } = getWhereShape(projectSchema, selectedShapes);
    const schema = {
        type: 'object',
        properties: {
            where: {
                '@ref': `local:${shapeName}`,
                description: 'The where clause uses the boolean AND, OR, and NOT parameters to construct complex filters based on the values of your fields. It applies an implicit AND to all the top-level keys. To avoid this, use a single OR or NOT key as the only top-level key.'
            },
            should: {
                '@ref': `local:${shapeName}`,
                description: 'The should clause is used for relevance scoring. Unlike "where" which filters in a non-scoring context, queries in "should" contribute to the relevance score. Use this with match_advanced and boost to influence result ranking. Sort by "_score" to see boosted results first.'
            }
        }
    };
    return { schema, dependencies };
}
