import { DomainProperty } from '../DomainProperty.js';
import { sanitizeInput, toDatabaseColumnName, toDatabaseTableName } from '../helpers/database.js';
import { nanoid } from '../../nanoid.js';
/**
 * Imports JSON Schema definitions into a DataDomain.
 *
 * This class parses a root JSON Schema file, bundles all its dependencies,
 * and translates the object definitions into DomainEntity instances within a specified DomainModel.
 */
export class JsonSchemaImporter {
    domain;
    model;
    refMap = new Map();
    messages = [];
    enums = new Map();
    constructor(domain) {
        this.domain = domain;
    }
    /**
     * Resets the internal state for a new import operation.
     */
    resetState(modelName) {
        this.model = this.domain.addModel({ info: { name: modelName } });
        this.refMap.clear();
        this.enums.clear();
        this.messages = [];
    }
    /**
     * Centralized reporting for warnings and info messages.
     */
    report(level, message, location) {
        this.messages.push({ level, message, location });
    }
    /**
     * Main import method: orchestrates schema bundling, entity creation, and population.
     */
    async import(schemas, modelName) {
        this.resetState(modelName);
        // Build a flat map of all definitions and $id-indexed schemas
        const definitionMap = new Map();
        const idMap = new Map();
        const processedSchemas = new Set(); // Track processed schema objects to avoid duplicates
        for (const schema of schemas) {
            // Add $id mapping if present
            if (schema.contents.$id) {
                idMap.set(schema.contents.$id, schema.contents);
                // Add the entire schema to the definitions maps as well as they can be referenced by other schemas.
                definitionMap.set(schema.contents.$id, schema.contents);
            }
            // Add the schema itself by its path
            definitionMap.set(schema.path, schema.contents);
            // Add all definitions inside this schema
            const definitions = schema.contents.definitions;
            const defs = schema.contents.$defs;
            if (definitions) {
                for (const [defName, defSchema] of Object.entries(definitions)) {
                    if (typeof defSchema === 'object') {
                        // Use a JSON pointer for the key - include both full path and short form
                        definitionMap.set(`${schema.path}#/definitions/${defName}`, defSchema);
                        definitionMap.set(`#/definitions/${defName}`, defSchema);
                    }
                }
            }
            if (defs) {
                for (const [defName, defSchema] of Object.entries(defs)) {
                    if (typeof defSchema === 'object') {
                        // Use a JSON pointer for the key - include both full path and short form
                        definitionMap.set(`${schema.path}#/$defs/${defName}`, defSchema);
                        definitionMap.set(`#/$defs/${defName}`, defSchema);
                    }
                }
            }
        }
        // == Pass 1: Create all entities ==
        for (const [key, schema] of definitionMap.entries()) {
            // Skip if this schema object has already been processed
            if (processedSchemas.has(schema)) {
                continue;
            }
            if (typeof schema === 'object' && schema.type === 'object') {
                this.createEntity(key, schema, key);
                processedSchemas.add(schema);
            }
            else if (this.isEnum(schema)) {
                this.enums.set(key, schema);
            }
            else {
                this.report('warning', `Skipping non-object schema at ${key}`, key);
            }
        }
        // Also create entities for all $id-indexed schemas if not already present
        for (const [id, schema] of idMap.entries()) {
            // Skip if this schema object has already been processed
            if (processedSchemas.has(schema)) {
                continue;
            }
            if (!this.refMap.has(id) && typeof schema === 'object' && schema.type === 'object') {
                this.createEntity(id, schema, id);
                processedSchemas.add(schema);
            }
            else if (this.isEnum(schema) && !this.enums.has(id)) {
                this.enums.set(id, schema);
            }
            else {
                this.report('warning', `Skipping non-object schema at ${id}`, id);
            }
        }
        // == Pass 2: Populate entities ==
        for (const [refPath, entityKey] of this.refMap.entries()) {
            const entity = this.domain.findEntity(entityKey);
            if (!entity) {
                this.report('warning', `Entity not found: ${entityKey}`, refPath);
                continue;
            }
            const schema = this.resolveSchemaRef(refPath, definitionMap, idMap);
            if (!schema) {
                this.report('warning', `Schema not found for ref: ${refPath}`, refPath);
                continue;
            }
            this.populateEntity(entity, schema);
        }
        return { model: this.model, messages: this.messages };
    }
    isEnum(schema) {
        if (schema.type !== 'string') {
            // I might be wrong about it, but I think enums are only for strings
            return false;
        }
        if (!Array.isArray(schema.oneOf)) {
            return false;
        }
        const notConst = schema.oneOf.find((s) => typeof s !== 'object' || !('const' in s));
        return !notConst; // If all schemas in oneOf have 'const', it's an enum
    }
    /**
     * Resolves a schema reference from the flat definition/id maps.
     * Supports #/definitions/..., $id, or direct path keys.
     */
    resolveSchemaRef(refPath, definitionMap, idMap) {
        // Try direct match
        if (definitionMap.has(refPath))
            return definitionMap.get(refPath);
        if (idMap.has(refPath))
            return idMap.get(refPath);
        // Try to resolve #/definitions/Name or #/$defs/Name from any schema
        if (refPath.startsWith('#/definitions/') || refPath.startsWith('#/$defs/')) {
            for (const [key, schema] of definitionMap.entries()) {
                if (key === refPath)
                    return schema;
            }
        }
        // Try to resolve by $id fragment
        if (refPath.startsWith('#')) {
            for (const [id, schema] of idMap.entries()) {
                if (id.endsWith(refPath))
                    return schema;
            }
        }
        return undefined;
    }
    /**
     * Helper for entity creation, can be extended for custom logic.
     */
    createEntity(name, schema, refPath) {
        let fixedName = schema.title || name;
        // Extract just the definition name from JSON pointers (both short and full paths)
        if (name.includes('#/definitions/')) {
            const parts = name.split('#/definitions/');
            fixedName = schema.title || parts[parts.length - 1];
        }
        else if (name.includes('#/$defs/')) {
            const parts = name.split('#/$defs/');
            fixedName = schema.title || parts[parts.length - 1];
        }
        const cleanName = toDatabaseTableName(fixedName, 'untitled_entity');
        const entity = this.model.addEntity({
            info: {
                name: cleanName,
                displayName: fixedName,
            },
        });
        if (fixedName !== cleanName) {
            entity.info.displayName = fixedName; // Keep original title for display
        }
        if (schema.description) {
            entity.info.description = schema.description;
        }
        this.refMap.set(refPath, entity.key);
        return entity;
    }
    /**
     * Generates a meaningful name for inline entities based on their parent context.
     */
    generateInlineEntityName(parentEntityKey, propName, schema) {
        // Try to use the schema title first
        if (schema.title) {
            return toDatabaseTableName(schema.title, 'untitled_entity');
        }
        // Find the parent entity to create a meaningful name
        const parentEntity = this.domain.findEntity(parentEntityKey);
        const parentName = parentEntity?.info.name || 'unknown';
        // Create a name like "User_Address" or "Order_ShippingInfo"
        const combinedName = `${parentName}_${propName}`;
        return toDatabaseTableName(combinedName, 'untitled_entity');
    }
    /**
     * Populates a DomainEntity with its properties, associations, and parent relationships.
     */
    populateEntity(entity, schema) {
        const properties = schema.properties || {};
        const required = new Set(schema.required || []);
        for (const [propName, propSchema] of Object.entries(properties)) {
            if (typeof propSchema === 'object') {
                this.createPropertyOrAssociation(entity, propName, propSchema, required.has(propName));
            }
        }
        // Handle 'allOf' for inheritance and composition.
        if (schema.allOf) {
            this.processAllOf(entity, schema.allOf);
        }
    }
    /**
     * Processes the `allOf` keyword, which can contain references (for inheritance)
     * or inline schemas (for composition/mixins).
     */
    processAllOf(entity, allOfSchemas) {
        allOfSchemas.forEach((subSchema, index) => {
            if (typeof subSchema !== 'object') {
                return;
            }
            // Case 1: It's a reference to another entity (standard inheritance).
            if (subSchema.$ref) {
                // Try to resolve the referenced entity by $ref (could be #/definitions/..., $id, or path)
                const parentKey = this.refMap.get(subSchema.$ref);
                if (parentKey) {
                    try {
                        entity.addParent(parentKey);
                    }
                    catch (e) {
                        this.report('warning', `Could not add parent ${parentKey} to ${entity.key}: ${e.message}`, entity.key);
                    }
                }
                else {
                    this.report('warning', `Could not resolve parent reference: ${subSchema.$ref}`, entity.key);
                }
                return;
            }
            // Case 2: It's an inline schema to be mixed in.
            // const mixinKey = `${entity.key}_mixin_${index}`
            const mixinName = subSchema.title || `${entity.info.name} Mixin ${index + 1}`;
            const mixinEntity = this.model.addEntity({
                // key: mixinKey,
                info: {
                    name: mixinName,
                    description: subSchema.description ||
                        `Auto-generated mixin entity from an 'allOf' clause in the schema for '${entity.info.name}'. This entity is not intended for direct use.`,
                },
            });
            this.populateEntity(mixinEntity, subSchema);
            try {
                entity.addParent(mixinEntity.key);
            }
            catch (e) {
                this.report('warning', `Could not add mixin parent ${mixinEntity.key} to ${entity.key}: ${e.message}`, entity.key);
            }
        });
    }
    /**
     * Recursively analyzes a schema definition to find all contained primitive types and `$ref`s.
     * It's used to understand the nature of a property (is it a primitive, an association, or a mix).
     */
    collectTypesAndRefs(schema, parentEntityKey, propName) {
        const collected = { refs: new Set(), types: new Set(), isArray: false };
        if (typeof schema !== 'object') {
            return collected;
        }
        if (schema.$ref) {
            collected.refs.add(schema.$ref);
            return collected;
        }
        if (schema.type) {
            const types = Array.isArray(schema.type) ? schema.type : [schema.type];
            for (const t of types) {
                if (t !== 'null')
                    collected.types.add(t);
            }
        }
        // Handle inline object types by creating entities immediately
        if (schema.type === 'object' && schema.properties && parentEntityKey && propName) {
            // Create an entity for this inline object immediately
            const inlineEntityName = this.generateInlineEntityName(parentEntityKey, propName, schema);
            const inlineEntity = this.createEntity(inlineEntityName, schema, inlineEntityName);
            // Populate it immediately since we have the schema
            this.populateEntity(inlineEntity, schema);
            // Add a reference to it so it gets treated as an association
            collected.refs.add(inlineEntityName);
            return collected;
        }
        if (schema.type === 'array') {
            collected.isArray = true;
            if (typeof schema.items === 'object') {
                // Recurse into items - pass along parent context for inline objects in arrays
                const itemInfo = this.collectTypesAndRefs(Array.isArray(schema.items) ? schema.items[0] : schema.items, parentEntityKey, propName ? `${propName}_item` : undefined);
                itemInfo.refs.forEach((r) => collected.refs.add(r));
                itemInfo.types.forEach((t) => collected.types.add(t));
            }
            return collected;
        }
        const choices = schema.anyOf || schema.oneOf;
        if (choices) {
            for (const choice of choices) {
                const choiceInfo = this.collectTypesAndRefs(choice, parentEntityKey);
                choiceInfo.refs.forEach((r) => collected.refs.add(r));
                choiceInfo.types.forEach((t) => collected.types.add(t));
                if (choiceInfo.isArray) {
                    collected.isArray = true;
                }
            }
        }
        if (schema.enum) {
            // If the schema has an enum, we treat it as a string type with specific values.
            collected.types.add('string');
            // consider the following schema: `"enum": ["one", "two", "three"]`
            const id = nanoid(8);
            this.enums.set(id, {
                type: 'string',
                oneOf: schema.enum.map((value) => ({
                    const: String(value), // Ensure enum values are strings
                })),
            });
            collected.refs.add(id); // Add the enum reference
        }
        return collected;
    }
    /**
     * Determines whether to create a DomainProperty or a DomainAssociation for a given schema property.
     */
    createPropertyOrAssociation(entity, propName, propSchema, isRequired) {
        // Analyze the property schema to understand its composition (primitives, refs, arrays).
        // Pass entity context to collectTypesAndRefs for inline object handling
        const analysis = this.collectTypesAndRefs(propSchema, entity.key, propName);
        // console.log(`Analyzing property '${propName}' in entity '${entity.info.name}':`, analysis, propSchema)
        // Case 1: If the property has `$ref` to an enum, we treat it as a DomainProperty.
        if (analysis.refs.size === 1 && this.enums.has([...analysis.refs][0])) {
            const prop = this.createEnumProperty(propName, propSchema, this.enums.get([...analysis.refs][0]), entity.info.name);
            if (prop) {
                prop.multiple = analysis.isArray;
                // console.log(`Adding enum property '${propName}' to entity '${entity.info.name}'`, prop.toJSON())
                entity.addProperty(prop.toJSON());
            }
            return;
        }
        // Case 2: If any `$ref` was found, we treat it as an Association.
        if (analysis.refs.size > 0) {
            const sanitizedName = toDatabaseColumnName(propName, 'untitled_association');
            const assoc = entity.addAssociation({}, { info: { name: sanitizedName } });
            if (sanitizedName !== propName) {
                assoc.info.displayName = propName; // Keep original name for display
            }
            if (propSchema.description) {
                assoc.info.description = propSchema.description;
            }
            assoc.required = isRequired;
            assoc.multiple = analysis.isArray;
            for (const ref of analysis.refs) {
                const targetKey = this.refMap.get(ref);
                if (targetKey) {
                    assoc.addTarget(targetKey);
                }
            }
            if (analysis.types.size > 0) {
                this.report('warning', `Property '${propName}' has a mix of reference and primitive types. Only the reference(s) will be used to create an association. The primitive types (${[
                    ...analysis.types,
                ].join(', ')}) are ignored.`, entity.key);
            }
            return;
        }
        // Case 3: No `$ref`s found, only primitive types. We treat it as a DomainProperty.
        if (analysis.types.size > 0) {
            // We need to create a temporary schema object to pass to createDomainProperty,
            // as it expects a single schema. We'll use the first detected type.
            const tempSchema = {
                ...propSchema,
                // Pass the collected types to be resolved by mapJsonTypeToDomainType
                type: [...analysis.types],
            };
            const prop = this.createDomainProperty(entity.key, propName, tempSchema, isRequired);
            if (prop) {
                prop.multiple = analysis.isArray;
                entity.addProperty(prop.toJSON());
            }
            return;
        }
    }
    /**
     * Creates and configures a DomainProperty based on a JSON schema property definition.
     */
    createDomainProperty(parentEntityKey, propName, propSchema, isRequired) {
        const typeInfo = this.mapJsonTypeToDomainType(propSchema.type, propSchema, `${parentEntityKey}.${propName}`);
        if (!typeInfo)
            return null;
        const sanitizedName = toDatabaseColumnName(propName, 'untitled_property');
        const propertyInit = {
            info: { name: sanitizedName, description: propSchema.description },
            type: typeInfo.type,
            required: isRequired,
        };
        // Use the constructor that doesn't add to the graph immediately.
        const prop = new DomainProperty(this.domain, parentEntityKey, propertyInit);
        if (sanitizedName !== propName) {
            prop.info.displayName = propName; // Keep original name for display
        }
        const schema = {};
        const webBindings = {};
        if (propSchema.pattern)
            schema.pattern = propSchema.pattern;
        if (propSchema.enum)
            schema.enum = propSchema.enum.map(String);
        if (propSchema.default !== undefined)
            schema.defaultValue = { type: 'literal', value: String(propSchema.default) };
        if (typeInfo.type === 'string') {
            if (typeof propSchema.minLength === 'number')
                schema.minimum = propSchema.minLength;
            if (typeof propSchema.maxLength === 'number')
                schema.maximum = propSchema.maxLength;
        }
        else if (typeInfo.type === 'number') {
            if (typeof propSchema.minimum === 'number')
                schema.minimum = propSchema.minimum;
            if (typeof propSchema.maximum === 'number')
                schema.maximum = propSchema.maximum;
            if (typeof propSchema.exclusiveMinimum === 'number') {
                schema.minimum = propSchema.exclusiveMinimum;
                schema.exclusiveMinimum = true;
            }
            if (typeof propSchema.exclusiveMaximum === 'number') {
                schema.maximum = propSchema.exclusiveMaximum;
                schema.exclusiveMaximum = true;
            }
            if (typeof propSchema.multipleOf === 'number')
                schema.multipleOf = propSchema.multipleOf;
            if (typeInfo.format) {
                webBindings.format = typeInfo.format;
            }
        }
        if (Object.keys(schema).length > 0) {
            prop.schema = schema;
        }
        if (Object.keys(webBindings).length > 0) {
            prop.bindings = [{ type: 'web', schema: webBindings }];
        }
        return prop;
    }
    /**
     * Maps a JSON Schema type to a DomainPropertyType.
     */
    mapJsonTypeToDomainType(jsonType, schema, location) {
        if (!jsonType) {
            return null;
        }
        if (Array.isArray(jsonType)) {
            const nonNullTypes = jsonType.filter((t) => t !== 'null');
            if (nonNullTypes.length === 0) {
                return null;
            }
            if (nonNullTypes.length === 2 && nonNullTypes.includes('array')) {
                const nonArrayType = nonNullTypes.filter((t) => t !== 'array')[0];
                return this.mapJsonTypeToDomainType(nonArrayType, schema, location);
            }
            if (nonNullTypes.length > 1) {
                this.report('warning', `Property has a union of primitive types: [${nonNullTypes.join(', ')}]. The importer will use the first type ('${nonNullTypes[0]}').`, location);
            }
            // Pick the first non-null type from the union.
            return this.mapJsonTypeToDomainType(nonNullTypes[0], schema, location);
        }
        if (jsonType === 'string') {
            // JSON Schema has the `format` property which can specify more about the string type.
            // We need to decide how to handle these formats.
            const choices = schema.anyOf || schema.oneOf;
            if (Array.isArray(choices)) {
                // If we have a list of choices, we need to pick one.
                const formats = [];
                for (const choice of choices) {
                    const typed = choice;
                    if (typed.format) {
                        formats.push(typed.format);
                    }
                }
                // we will list dates from the most specific to the least specific
                if (formats.includes('date-time')) {
                    return { type: 'datetime' };
                }
                if (formats.includes('date')) {
                    return { type: 'date' };
                }
                if (formats.includes('time')) {
                    return { type: 'time' };
                }
            }
            // If no specific format is found, we default to string.
            return { type: 'string' };
        }
        switch (jsonType) {
            case 'number':
                return { type: 'number', format: 'double' };
            case 'integer':
                return { type: 'number', format: 'int64' };
            case 'boolean':
                return { type: 'boolean' };
            // JSON Schema's 'object' and 'array' are handled as associations or multi-valued properties.
            // Other types like 'null' are handled by making the property not required.
            default:
                return null;
        }
    }
    createEnumProperty(propName, propSchema, enumSchema, parentEntityKey) {
        const description = propSchema.description ?? enumSchema.description;
        const typeInfo = this.mapJsonTypeToDomainType(enumSchema.type, enumSchema, `${parentEntityKey}.${propName}`);
        if (!typeInfo)
            return null;
        const sanitizedName = toDatabaseColumnName(propName);
        const propertyInit = {
            info: { name: sanitizedName },
            type: typeInfo.type,
            // required: isRequired,
            required: false,
        };
        // Use the constructor that doesn't add to the graph immediately.
        const prop = new DomainProperty(this.domain, parentEntityKey, propertyInit);
        if (sanitizedName !== propName) {
            prop.info.displayName = propName; // Keep original name for display
        }
        if (description) {
            prop.info.description = sanitizeInput(description);
        }
        const schema = {};
        const pattern = propSchema.pattern ?? enumSchema.pattern;
        if (pattern) {
            schema.pattern = pattern;
        }
        const defaultValue = propSchema.default ?? enumSchema.default;
        if (defaultValue !== undefined) {
            schema.defaultValue = { type: 'literal', value: String(defaultValue) };
        }
        if (typeInfo.type === 'string') {
            const minLength = propSchema.minLength ?? enumSchema.minLength;
            const maxLength = propSchema.maxLength ?? enumSchema.maxLength;
            if (minLength !== undefined)
                schema.minimum = minLength;
            if (maxLength !== undefined)
                schema.maximum = maxLength;
        }
        else if (typeInfo.type === 'number') {
            const minimum = propSchema.minimum ?? enumSchema.minimum;
            const maximum = propSchema.maximum ?? enumSchema.maximum;
            if (minimum !== undefined) {
                schema.minimum = minimum;
            }
            if (maximum !== undefined) {
                schema.maximum = maximum;
            }
            const exclusiveMinimum = propSchema.exclusiveMinimum ?? enumSchema.exclusiveMinimum;
            const exclusiveMaximum = propSchema.exclusiveMaximum ?? enumSchema.exclusiveMaximum;
            if (typeof exclusiveMinimum === 'number') {
                schema.minimum = exclusiveMinimum;
                schema.exclusiveMinimum = true;
            }
            if (typeof exclusiveMaximum === 'number') {
                schema.maximum = exclusiveMaximum;
                schema.exclusiveMaximum = true;
            }
            const multipleOf = propSchema.multipleOf ?? enumSchema.multipleOf;
            if (typeof multipleOf === 'number') {
                schema.multipleOf = multipleOf;
            }
        }
        if (enumSchema.oneOf && Array.isArray(enumSchema.oneOf)) {
            schema.enum = [];
            for (const item of enumSchema.oneOf) {
                if (typeof item !== 'object' || !('const' in item)) {
                    this.report('warning', `Invalid enum item in oneOf: ${JSON.stringify(item)}. Expected an object with 'const' property.`, `${parentEntityKey}.${propName}`);
                    continue;
                }
                if (typeof item.const !== 'string') {
                    this.report('warning', `Invalid enum const value: ${JSON.stringify(item.const)}. Expected a string.`, `${parentEntityKey}.${propName}`);
                    continue;
                }
                schema.enum.push(item.const);
            }
        }
        if (Object.keys(schema).length > 0) {
            // console.log(`Creating enum property '${propName}' with schema:`, schema)
            prop.schema = schema;
        }
        return prop;
    }
}
//# sourceMappingURL=JsonSchemaImporter.js.map