import { Thing } from '../models/Thing.js';
import v4 from '../lib/uuid.js';
import { DataProperty } from './DataProperty.js';
import { DataAssociation } from './DataAssociation.js';
import { DataEntityKind } from '../models/kinds.js';
import { AmfShapeGenerator } from '../amf/AmfShapeGenerator.js';
import { ApiSchemaGenerator } from '../amf/ApiSchemaGenerator.js';
import { RemovePropertyException } from '../exceptions/remove_property_exception.js';
import { RemoveAssociationException } from '../exceptions/remove_association_exception.js';
import { ValidationError } from '../exceptions/validation_error.js';
import { RemoveEntityException } from '../exceptions/remove_entity_exception.js';
import { ForeignAssociationException } from '../exceptions/foreign_association_exception.js';
/**
 * Data entity is the smallest description of a data in the system
 * It contains properties and associations. At least one entity describe a data model.
 */
export class DataEntity {
    root;
    kind = DataEntityKind;
    key = '';
    /**
     * The description of the data namespace.
     */
    info = Thing.fromName('');
    /**
     * Optional general purpose tags for the UI.
     */
    tags = [];
    /**
     * Reserved for future use.
     *
     * The keys of the taxonomy items associated with the entity.
     */
    taxonomy = [];
    /**
     * The list of keys of properties that belong to this entity.
     */
    properties = [];
    /**
     * The list of keys of associations that belong to this entity.
     */
    associations = [];
    /**
     * The ordered list of fields (properties and associations) in the schema.
     * These only keep references to the properties and associations.
     * The order of the fields is important as it defines the order of the
     * properties in the schema.
     */
    fields = [];
    /**
     * The list of keys of entities that are parents to this entity.
     *
     * This potentially may cause a conflict when two parents declare the same
     * property. In such situation this entity should define own property
     * with the same name to shadow parent property. When the property is
     * not shadowed this may cause unexpected results as the processing could result
     * with inconsistent definition of a schema because the last read property wins.
     */
    parents = [];
    /**
     * Whether this entity is deprecated.
     */
    deprecated;
    static fromName(root, name) {
        const entity = new DataEntity(root);
        entity.info = Thing.fromName(name);
        return entity;
    }
    /**
     * @param input The data entity definition to restore.
     */
    constructor(root, input) {
        this.root = root;
        let init;
        if (typeof input === 'string') {
            init = JSON.parse(input);
        }
        else if (typeof input === 'object') {
            init = input;
        }
        else {
            init = {
                kind: DataEntityKind,
                key: v4(),
                info: Thing.fromName('').toJSON(),
            };
        }
        this.new(init);
    }
    new(init) {
        DataEntity.validate(init);
        const { info, key = v4(), kind = DataEntityKind, tags, taxonomy, parents, properties, associations, fields, deprecated, } = init;
        this.kind = kind;
        this.key = key;
        if (info) {
            this.info = new Thing(info);
        }
        else {
            this.info = Thing.fromName('');
        }
        if (Array.isArray(tags)) {
            this.tags = [...tags];
        }
        else {
            this.tags = [];
        }
        if (Array.isArray(taxonomy)) {
            this.taxonomy = [...taxonomy];
        }
        else {
            this.taxonomy = [];
        }
        if (Array.isArray(parents)) {
            this.parents = [...parents];
        }
        else {
            this.parents = [];
        }
        this.properties = [];
        if (Array.isArray(properties)) {
            properties.forEach((key) => {
                const value = this._readProperty(key);
                if (value) {
                    this.properties.push(value);
                }
            });
        }
        this.associations = [];
        if (Array.isArray(associations)) {
            associations.forEach((key) => {
                const value = this._readAssociation(key);
                if (value) {
                    this.associations.push(value);
                }
            });
        }
        if (Array.isArray(fields)) {
            this.fields = [...fields];
        }
        else {
            this.fields = this.createOrderedFields();
        }
        if (typeof deprecated === 'boolean') {
            this.deprecated = deprecated;
        }
        else {
            this.deprecated = undefined;
        }
    }
    createOrderedFields() {
        const result = [];
        this.properties.forEach((i) => {
            result.push({
                type: 'property',
                key: i.key,
            });
        });
        this.associations.forEach((i) => {
            result.push({
                type: 'association',
                key: i.key,
            });
        });
        return result;
    }
    static validate(input) {
        const typed = input;
        const messages = [];
        if (!typed) {
            messages.push({
                field: '',
                message: 'The input is required. None given',
                rule: 'required',
            });
            throw new ValidationError(messages);
        }
        if (typed.kind !== DataEntityKind) {
            messages.push({
                field: 'kind',
                message: `The kind property must be ${DataEntityKind}`,
                rule: 'invalid',
            });
        }
        if (messages.length) {
            throw new ValidationError(messages, { message: 'Invalid data entity definition.' });
        }
    }
    toJSON() {
        const result = {
            kind: DataEntityKind,
            key: this.key,
            info: this.info.toJSON(),
        };
        if (Array.isArray(this.tags) && this.tags.length) {
            result.tags = [...this.tags];
        }
        if (Array.isArray(this.taxonomy) && this.taxonomy.length) {
            result.taxonomy = [...this.taxonomy];
        }
        if (Array.isArray(this.parents) && this.parents.length) {
            result.parents = [...this.parents];
        }
        if (Array.isArray(this.properties) && this.properties.length) {
            result.properties = this.properties.map((i) => i.key);
        }
        if (Array.isArray(this.associations) && this.associations.length) {
            result.associations = this.associations.map((i) => i.key);
        }
        if (Array.isArray(this.fields) && this.fields.length) {
            result.fields = [...this.fields];
        }
        if (Array.isArray(this.taxonomy) && this.taxonomy.length) {
            result.taxonomy = [...this.taxonomy];
        }
        if (Array.isArray(this.tags) && this.tags.length) {
            result.tags = [...this.tags];
        }
        if (typeof this.deprecated === 'boolean') {
            result.deprecated = this.deprecated;
        }
        return result;
    }
    _readAssociation(key) {
        return this.root.definitions.associations.find((i) => i.key === key);
    }
    _readProperty(key) {
        return this.root.definitions.properties.find((i) => i.key === key);
    }
    /**
     * Creates a property with a passed type.
     * @param type The type of the property
     * @returns The created property
     */
    addTypedProperty(type, name) {
        const property = DataProperty.fromType(this.root, type);
        if (name) {
            property.info.name = name;
        }
        this.root.definitions.properties.push(property);
        this.properties.push(property);
        this.fields.push({ type: 'property', key: property.key });
        return property;
    }
    /**
     * Creates a property with a passed type.
     * @param name The name of the property.
     * @returns The created property
     */
    addNamedProperty(name) {
        const property = DataProperty.fromName(this.root, name);
        this.root.definitions.properties.push(property);
        this.properties.push(property);
        this.fields.push({ type: 'property', key: property.key });
        return property;
    }
    /**
     * Removes the property from the entity and namespace definitions.
     * @param key The key of the property to remove.
     */
    removeProperty(key) {
        const thisIndex = this.properties.findIndex((i) => i.key === key);
        if (thisIndex < 0) {
            throw new RemovePropertyException(`Trying to remove the ${key} property, but it doesn't exist.`);
        }
        const propertyToRemove = this.properties[thisIndex];
        this.properties.splice(thisIndex, 1);
        const defIndex = this.root.definitions.properties.findIndex((i) => i.key === key);
        if (defIndex >= 0) {
            this.root.definitions.properties.splice(defIndex, 1);
        }
        this.removeField(key);
        propertyToRemove.remove();
    }
    removeField(key) {
        this.fields = this.fields.filter((item) => item.key !== key);
    }
    /**
     * Lists all properties for this Entity.
     * @returns The list of properties that belong to this entity.
     */
    listProperties() {
        return [...this.properties];
    }
    /**
     * Lists all associations for this Entity.
     * @returns The list of associations that belong to this entity.
     */
    listAssociations() {
        return [...this.associations];
    }
    /**
     * Generates an ordered list of fields (properties and associations) for this entity.
     *
     * @returns The list of fields (properties and associations) that belong to this entity.
     */
    listFields() {
        const result = [];
        this.fields.forEach((i) => {
            if (i.type === 'property') {
                const property = this.properties.find((p) => p.key === i.key);
                if (property) {
                    result.push(property);
                }
            }
            else if (i.type === 'association') {
                const association = this.associations.find((a) => a.key === i.key);
                if (association) {
                    result.push(association);
                }
            }
        });
        return result;
    }
    /**
     * Moves the field to a new index.
     * @param key The key of the field to move.
     * @param toIndex The new index to which move the field to.
     */
    moveField(key, toIndex) {
        const fromIndex = this.fields.findIndex((i) => i.key === key);
        if (fromIndex < 0) {
            throw new ValidationError([{ field: 'fields', message: `Field ${key} not found.`, rule: 'not-found' }]);
        }
        if (toIndex < 0 || toIndex >= this.fields.length) {
            throw new ValidationError([{ field: 'fields', message: `Invalid index ${toIndex}.`, rule: 'invalid' }]);
        }
        const [item] = this.fields.splice(fromIndex, 1);
        this.fields.splice(toIndex, 0, item);
    }
    /**
     * Creates an association for a given name, adds it to definitions, and returns it.
     * @param name The name of the association
     * @returns The created association
     */
    addNamedAssociation(name) {
        const result = DataAssociation.fromName(this.root, name);
        this.root.definitions.associations.push(result);
        this.associations.push(result);
        this.fields.push({ type: 'association', key: result.key });
        return result;
    }
    /**
     * Creates an association for a given target, adds it to definitions, and returns it.
     *
     * @param target The target entity key of the association
     * @param namespace The target entity namespace when different to the current namespace.
     * @param name Optional name.
     * @returns The created association
     */
    addTargetAssociation(target, name) {
        let key;
        if (typeof target === 'string') {
            key = target;
        }
        else {
            key = target.key;
        }
        const result = DataAssociation.fromTarget(this.root, key);
        if (name) {
            result.info.name = name;
        }
        this.root.definitions.associations.push(result);
        this.associations.push(result);
        this.fields.push({ type: 'association', key: result.key });
        return result;
    }
    /**
     * Creates an association for a given target that is in another namespace
     * @param target The target entity key of the association
     * @param namespace The target entity namespace when different to the current namespace.
     * @param name Optional name.
     * @returns The created association
     */
    addForeignAssociation(target, namespace, name) {
        let key;
        if (typeof target === 'string') {
            key = target;
        }
        else {
            key = target.key;
        }
        if (!this.root.hasForeignNamespace(namespace)) {
            throw new ForeignAssociationException(`Trying to add a foreign association but the foreign namespace is not defined.`);
        }
        const result = DataAssociation.fromTarget(this.root, key, namespace);
        if (name) {
            result.info.name = name;
        }
        this.root.definitions.associations.push(result);
        this.associations.push(result);
        this.fields.push({ type: 'association', key: result.key });
        return result;
    }
    /**
     * Removes an association from the entity and, namespace definitions.
     * @param key The key of the association to remove.
     */
    removeAssociation(key) {
        const thisIndex = this.associations.findIndex((i) => i.key === key);
        if (thisIndex < 0) {
            // return
            throw new RemoveAssociationException(`Trying to remove the ${key} association, but it doesn't exist.`);
        }
        const associationToRemove = this.associations[thisIndex];
        this.associations.splice(thisIndex, 1);
        const defIndex = this.root.definitions.associations.findIndex((i) => i.key === key);
        if (defIndex >= 0) {
            this.root.definitions.associations.splice(defIndex, 1);
        }
        this.removeField(key);
        associationToRemove.remove();
    }
    /**
     * Reads the list of parents for the entity, inside the root namespace. The computed list contains the list of all
     * parents in the inheritance chain in no particular order.
     * @param recursive Whether to include parent parents as well.
     */
    getComputedParents(recursive) {
        const { entities } = this.root.definitions;
        let result = [];
        this.parents.forEach((key) => {
            const parent = entities.find((i) => i.key === key);
            if (parent) {
                result.push(parent);
                if (recursive) {
                    result = result.concat(parent.getComputedParents());
                }
            }
        });
        return result;
    }
    /**
     * Computes list of all children, inside the root namespace, that extends this entity.
     * The children are not ordered.
     */
    getComputedChildren() {
        const { entities } = this.root.definitions;
        return entities.filter((i) => i.parents.includes(this.key));
    }
    /**
     * Computes a list of entities that are associated with the current entity.
     */
    getComputedAssociations() {
        const { associations } = this;
        const result = [];
        associations.forEach((assoc) => {
            if (!assoc.targets.length) {
                return;
            }
            const entities = this.root.findAssociatedEntities(assoc.targets);
            entities.forEach((entity) => {
                if (entity) {
                    result.push(entity);
                }
            });
        });
        return result;
    }
    /**
     * Removes self from the namespace with all properties and attributes.
     */
    remove() {
        const { key, properties, associations, root } = this;
        // check if some entity has reference to this entity as a target
        const associationsToEntity = this.root.definitions.associations.filter((a) => a.targets.some((t) => t.key === this.key));
        if (associationsToEntity.length > 0) {
            const entitiesNames = associationsToEntity.reduce((acc, association) => {
                const entity = root.definitions.entities.find((e) => e.associations.includes(association));
                if (entity) {
                    acc.push(entity.info.name);
                }
                return acc;
            }, []);
            throw new RemoveEntityException(`Cannot remove entity ${this.info.name} because it is used as a target in associations in entities: ${entitiesNames.join(', ')}.`);
        }
        // remove own stuff
        properties.forEach((p) => this.removeProperty(p.key));
        associations.forEach((a) => this.removeAssociation(a.key));
        // remove from the root
        const index = root.definitions.entities.findIndex((i) => i.key === key);
        if (index >= 0) {
            root.definitions.entities.splice(index, 1);
        }
        // remove from the parent
        const model = this.getParent();
        if (model) {
            const entityIndex = model.entities.findIndex((e) => e === this);
            model.entities.splice(entityIndex, 1);
        }
    }
    /**
     * Returns a parent data model where this entity exist.
     */
    getParent() {
        return this.root.definitions.models.find((m) => m.entities.some((e) => e === this));
    }
    /**
     * Adds a parent reference to this entity.
     * This will not add the parent to the namespace. It only adds a reference to the parent.
     * @param key The key of the parent entity to add.
     * @returns `this` for chaining.
     */
    addParent(key) {
        // Prevent adding self as parent
        if (key === this.key) {
            const message = 'Entity cannot be a parent of itself.';
            throw new ValidationError([
                {
                    field: 'parents',
                    message,
                    rule: 'circular',
                },
            ], { message });
        }
        // check if parent exists
        const parent = this.root.definitions.entities.find((i) => i.key === key);
        if (!parent) {
            const message = `Entity with key "${key}" not found.`;
            throw new ValidationError([
                {
                    field: 'parents',
                    message,
                    rule: 'not-exists',
                },
            ], { message });
        }
        // Check for circular inheritance
        if (this.hasCircularParent(key)) {
            const message = `Circular inheritance detected. Cannot add ${key} as a parent.`;
            throw new ValidationError([
                {
                    field: 'parents',
                    message,
                    rule: 'circular',
                },
            ], { message });
        }
        const has = this.parents.some((i) => i === key);
        if (has) {
            const message = `Parent ${key} already exists.`;
            throw new ValidationError([
                {
                    field: 'parents',
                    message,
                    rule: 'exists',
                },
            ], { message });
        }
        this.parents.push(key);
        return this;
    }
    /**
     * Checks if the entity has a circular parent relationship when attempting to add a new parent.
     * @param key The key of the parent being added.
     * @returns true if adding this parent would create a circular relationship.
     */
    hasCircularParent(key) {
        const { entities } = this.root.definitions;
        // Check if new parent is one of the children.
        const children = this.getComputedChildren();
        if (children.some((c) => c.key === key)) {
            return true;
        }
        // check if this entity is in parents chain.
        let current = entities.find((e) => e.key === key);
        while (current) {
            if (current.key === this.key) {
                return true;
            }
            // for (const parentKey of current.parents) {
            //   current = parentKey ? entities.find((e) => e.key === parentKey) : undefined
            // }
            const parentKey = current.parents[0]; // Assuming single inheritance
            current = parentKey ? entities.find((e) => e.key === parentKey) : undefined;
        }
        return false;
    }
    /**
     * Tests whether one entity is associated with another.
     *
     * @param entity1 The source entity
     * @param entity2 The target entity
     * @returns true when there's any path from one entity to another.
     */
    static isAssociated(entity1, entity2) {
        return entity1.isAssociated(entity2.key);
    }
    /**
     * Tests whether this entity is somehow associated with another entity.
     * @param target The key of the target entity to test for association with.
     * @returns true if this entity has any association to the `target` entity.
     */
    isAssociated(target) {
        const it = this.associationPath(target);
        const path = it.next().value;
        return !!path;
    }
    /**
     * Prints out all associations from one entity to another through all entities that may be in between.
     * @param toEntity The key to the target entity
     * @yields The path containing keys of entities from this entity to the `toEntity`
     * (inclusive) and all entities in between.
     */
    *associationPath(toEntity) {
        const graph = this.root.associationGraph();
        for (const path of this.root.associationPath(this.key, toEntity, graph)) {
            yield path;
        }
    }
    /**
     * Returns a list of entities that are associated with this entity as a target.
     *
     * This method identifies entities that have an association where this entity is a target.
     * In other words, it finds entities that "point to" this entity through an association.
     *
     * For example, consider the following relationships:
     *
     * ```plain
     * A -> B  (A has an association that targets B)
     * C -> B  (C has an association that targets B)
     * B -> D  (B has an association that targets D)
     *
     * Calling `getRelatedEntities()` on B would return [A, C]
     * Calling `getRelatedEntities()` on D would return [B]
     * Calling `getRelatedEntities()` on A would return []
     * ```
     *
     * Note, this method only returns entities that are directly associated with this entity as a target.
     * It does not traverse the association graph to find indirectly related entities.
     *
     * @returns An array of `DataEntity` instances that have an association targeting this entity.
     */
    getRelatedEntities() {
        const { key, root } = this;
        const result = DataEntity.getRelatedEntities(root, key);
        root.foreign.forEach((ns) => {
            const other = DataEntity.getRelatedEntities(ns, key);
            other.forEach((entity) => {
                if (!result.includes(entity)) {
                    result.push(entity);
                }
            });
        });
        return result;
    }
    static getRelatedEntities(namespace, entityKey) {
        const result = [];
        const inverse = namespace.definitions.associations.filter((i) => i.targets.some((j) => j.key === entityKey));
        inverse.forEach((assoc) => {
            const entity = namespace.definitions.entities.find((e) => e.associations.includes(assoc));
            if (entity && !result.includes(entity)) {
                result.push(entity);
            }
        });
        return result;
    }
    /**
     * Creates breadcrumbs from this entity to the root namespace.
     */
    breadcrumbs() {
        const result = [];
        result.push({
            key: this.key,
            name: this.info.name || 'Unnamed entity',
            kind: DataEntityKind,
        });
        const model = this.getParent();
        if (model) {
            result.push({
                key: model.key,
                kind: model.kind,
                name: model.info.name || 'Unnamed data model',
            });
            let parent = model.getParent();
            while (parent && parent !== this.root) {
                result.push({
                    key: parent.key,
                    kind: parent.kind,
                    name: parent.info.name || 'Unnamed namespace',
                });
                parent = parent.getParent();
            }
        }
        result.push({
            key: this.root.key,
            name: this.root.info.name || 'Unnamed namespace',
            kind: this.root.kind,
        });
        return result.reverse();
    }
    /**
     * Adds a new tag to the property. It also populates the root namespace's tags when tag is new.
     *
     * Note, it does nothing when the tag is already defined.
     *
     * @param tag The tag to add.
     */
    addTag(tag) {
        if (!tag) {
            return;
        }
        const lower = tag.toLowerCase();
        const { tags } = this;
        if (tags.some((t) => t.toLowerCase() === lower)) {
            return;
        }
        tags.push(tag);
        const { definitions } = this.root;
        if (!definitions.tags.some((t) => t.toLowerCase() === lower)) {
            definitions.tags.push(tag);
        }
    }
    /**
     * Removes a tag from the property. Unlike the `addTag()` this won't remove a `tag` from the root namespace.
     *
     * @param tag The tag to remove.
     */
    removeTag(tag) {
        if (!tag) {
            return;
        }
        const lower = tag.toLowerCase();
        const { tags } = this;
        const index = tags.findIndex((t) => t.toLowerCase() === lower);
        if (index >= 0) {
            tags.splice(index, 1);
        }
    }
    /**
     * Creates a Shape of AMF.
     * The property itself is auto-generated. If the `schema` is defined then it is used
     * as the `range` of the property. Otherwise basic shape is generated for the range.
     *
     * This is a preferred way of reading the AMF shape as this synchronizes changed
     * data properties with the shape definition.
     *
     * @returns AMF property shape definition.
     */
    toApiShape() {
        const serializer = new AmfShapeGenerator();
        return serializer.entity(this);
    }
    /**
     * Reads the schema of the Entity and generates an example for it.
     */
    toExample(mime, opts = {}) {
        const shape = this.toApiShape();
        const generator = new ApiSchemaGenerator(mime, {
            renderExamples: typeof opts.renderExamples === 'boolean' ? opts.renderExamples : true,
            renderMocked: typeof opts.renderMocked === 'boolean' ? opts.renderMocked : true,
            renderOptional: typeof opts.renderOptional === 'boolean' ? opts.renderOptional : true,
            selectedUnions: opts.selectedUnions,
        });
        return generator.generate(shape);
    }
    hasClosedCycle(rootEntity, targetEntity) {
        if (targetEntity === this.key) {
            // self association
            return true;
        }
        const g = this.root.associationGraph();
        const selfPaths = [];
        const targetPaths = [];
        for (const path of this.root.associationPath(rootEntity, this.key, g)) {
            selfPaths.push(path);
        }
        for (const path of this.root.associationPath(targetEntity, this.key, g)) {
            targetPaths.push(path);
        }
        const checker = (arr, target) => target.every((v) => arr.includes(v));
        for (const sp of selfPaths) {
            for (const tp of targetPaths) {
                const result = checker(sp, tp);
                if (result) {
                    return result;
                }
            }
        }
        return false;
    }
}
//# sourceMappingURL=DataEntity.js.map