import { nanoid } from '../nanoid.js'
import { Graph } from '@api-client/graph/graph/Graph.js'
import {
  DomainAssociationKind,
  DataDomainKind,
  DomainEntityKind,
  DomainModelKind,
  DomainNamespaceKind,
  DomainPropertyKind,
} from '../models/kinds.js'
import type {
  AssociationAddOptions,
  DeserializationIssue,
  DeserializationMode,
  DomainGraphEdge,
  DomainGraphNodeType,
  SerializedGraph,
  DomainSearchCriteria,
  DomainSearchResult,
  SearchableNodeType,
} from './types.js'
import { type DomainNamespaceSchema, DomainNamespace, type NamespaceOrderedItem } from './DomainNamespace.js'
import { type DomainModelSchema, DomainModel } from './DomainModel.js'
import { type DomainEntitySchema, DomainEntity } from './DomainEntity.js'
import { DomainAssociation } from './DomainAssociation.js'
import { DomainProperty, DomainPropertySchema } from './DomainProperty.js'
import { type IThing, Thing } from '../models/Thing.js'
import { removeGraphNode } from './GraphUtils.js'
import { serialize, deserialize, mergeGraph, removeForeignGraph } from './DomainSerialization.js'
import { DependentModel, type DependentModelSchema, type DomainDependency } from './DependentModel.js'

export interface DataDomainSchema extends DependentModelSchema {
  info: IThing
  kind: typeof DataDomainKind
  key: string
  graph?: SerializedGraph
  /**
   * The ordered list of fields (namespace and models) in the schema.
   * These only keep references to define the order of these properties
   * in the schema as graph won't do it.
   */
  fields?: NamespaceOrderedItem[]
}

/**
 * Represents the root of a data domain model.
 *
 * The `DataDomain` class serves as the top-level container
 * for a collection of data models, namespaces, entities,
 * properties, and associations. It provides methods for
 * managing and manipulating these data elements, enabling
 * the creation of complex and interconnected data
 * structures.
 *
 * **Key Features:**
 *
 * -   **Root Container:** Holds all data elements within a domain.
 * -   **Graph-Based Structure:** Uses a graph to represent relationships between data elements.
 * -   **Namespace Management:** Supports creating and managing namespaces to organize data models.
 * -   **Data Model Management:** Supports creating and managing data models to group entities.
 * -   **Entity Management:** Supports creating and managing entities, which define the structure of data.
 * -   **Property Management:** Supports creating and managing properties, which define the data elements
 *     within entities.
 * -   **Association Management:** Supports creating and managing associations, which define relationships
 *     between entities.
 * -   **Foreign Domain Support:** Allows registering and integrating data from external domains.
 * -   **Change Notification:** Notifies listeners when changes occur within the data domain.
 *
 * **Usage:**
 *
 * 1.  Create an instance of the `DataDomain`.
 * 2.  Use methods like `addNamespace()`, `addModel()`,
 *     `addEntity()`, `addProperty()`, and `addAssociation()`
 *     to build the data domain structure.
 * 3.  Use methods like `findNamespace()`, `findModel()`,
 *     `findEntity()`, `findProperty()`, and
 *     `findAssociation()` to retrieve data elements.
 * 4.  Use methods like `listNamespaces()`, `listGraphNamespaces()`, `listModels()`,
 *     and `listEntities()` to iterate over collections of
 *     data elements.
 * 5.  Use `registerForeignDomain()` to integrate data from
 *     external domains.
 * 6.  Listen for the `change` event to be notified of
 *     changes within the data domain.
 *
 * **Example:**
 *
 * ```typescript
 * const dataDomain = new DataDomain();
 * const userNamespace = dataDomain.addNamespace({
 *   key: 'userNamespace',
 * });
 * const userModel = userNamespace.addModel({
 *   key: 'userModel',
 * });
 * const userEntity = userModel.addEntity({
 *   key: 'user',
 * });
 * const nameProperty = userEntity.addProperty({
 *   key: 'name',
 *   type: 'string',
 * });
 * ```
 *
 * @fires DataDomain#change {Event} - Fired when the data
 * domain changes.
 *
 * @todo: Implement a mechanism to move an entity to a new
 * parent model.
 */
export class DataDomain extends DependentModel {
  /**
   * The kind of the domain element.
   */
  kind: typeof DataDomainKind
  /**
   * The unique key of the domain element.
   */
  key: string

  /**
   * The graph used to store the data domain structure.
   */
  graph: Graph<unknown, DomainGraphNodeType, DomainGraphEdge>

  /**
   * The description of the domain property.
   */
  info: Thing

  /**
   * When the initializing flag is set to true,
   * the domain is not notified of changes.
   */
  #initializing = true

  /**
   * When the notifying flag is set to true,
   * the domain is pending a notification.
   * No other notifications will be sent until
   * the current notification is sent.
   */
  #notifying = false

  /**
   * This is to keep it consistent with the domain elements.
   */
  get domain(): DataDomain {
    return this
  }

  /**
   * The ordered list of fields (namespace and models) in the schema.
   * These only keep references to define the order of these properties
   * in the schema as graph won't do it.
   */
  accessor fields: NamespaceOrderedItem[]

  /**
   * The list of issues encountered during deserialization.
   * This is only populated when deserialization fails in lenient mode.
   */
  issues: DeserializationIssue[] = []

  static createSchema(input: Partial<DataDomainSchema> = {}): DataDomainSchema {
    const { key = nanoid(), fields } = input
    const info = Thing.fromJSON(input.info, { name: 'Unnamed domain' }).toJSON()
    const result: DataDomainSchema = {
      kind: DataDomainKind,
      key,
      info,
    }
    if (input.dependencyList) {
      result.dependencyList = structuredClone(input.dependencyList)
    }
    if (input.graph) {
      result.graph = input.graph
    }
    if (Array.isArray(fields)) {
      result.fields = [...fields]
    }
    return result
  }

  /**
   * Creates a new instance of the `DataDomain` class.
   *
   * When creating a new Data Domain arguments should not be set.
   * When restoring a Data Domain from a previous state, you should provide
   * the serialized graph state as well as the same list of dependencies
   * used when the graph was deserialized. Edges to missing dependency nodes
   * will be ignored.
   *
   * @param state The previously serialized state of the graph.
   * @param dependencies An array of foreign data domains to register with this domain.
   */
  constructor(state?: Partial<DataDomainSchema>, dependencies: DomainDependency[] = [], mode?: DeserializationMode) {
    const init = DataDomain.createSchema(state)
    const instances: DataDomain[] = []
    for (const dep of dependencies) {
      if (dep instanceof DataDomain) {
        instances.push(dep)
      } else if (typeof dep === 'object' && dep.kind === DataDomainKind && dep.key) {
        const domain = new DataDomain(dep)
        instances.push(domain)
      } else {
        throw new Error(`Invalid foreign domain dependency: ${dep}`)
      }
    }
    super(init.dependencyList, instances)
    this.kind = init.kind
    this.key = init.key
    this.info = new Thing(init.info)
    const result = deserialize(this, {
      json: init.graph,
      dependencies: instances,
      mode: mode || 'strict',
    })
    this.graph = result.graph
    if (result.issues.length > 0 && mode === 'lenient') {
      this.issues = result.issues
    }
    if (Array.isArray(init.fields)) {
      this.fields = [...init.fields]
    } else {
      this.fields = []
    }
    if (Array.isArray(init.dependencyList)) {
      this.dependencyList = [...init.dependencyList]
    } else {
      this.dependencyList = []
    }
    this.#initializing = false
    this.info.addEventListener('change', () => {
      this.notifyChange()
    })
  }

  /**
   * Serializes the DataDomain instance to a JSON object.
   * It does not serialize the foreign domain dependencies. The serialized nodes
   * are the ones that are local to this data domain.
   * @returns The serialized data domain ready for storage or transport.
   */
  toJSON(): DataDomainSchema {
    const result: DataDomainSchema = {
      info: this.info.toJSON(),
      kind: this.kind,
      key: this.key,
      graph: serialize(this.graph, this.key),
    }
    if (this.dependencyList.length > 0) {
      result.dependencyList = structuredClone(this.dependencyList)
    }
    if (Array.isArray(this.fields) && this.fields.length) {
      result.fields = [...this.fields]
    }
    return result
  }

  /**
   * This function is used internally by all domain elements to notify that something has changed.
   * Since we want to notify listeners after the operation commits, we use microtask
   * to ensure that the event is dispatched after the current operation.
   */
  notifyChange() {
    if (this.#notifying || this.#initializing) {
      return
    }
    this.#notifying = true
    queueMicrotask(() => {
      this.#notifying = false
      const event = new Event('change')
      this.dispatchEvent(event)
    })
  }

  private removeField(key: string): void {
    this.fields = this.fields.filter((item) => item.key !== key)
  }

  /**
   * Checks if this data domain has any fields (namespace and models).
   *
   * @returns True if the data domain has fields.
   * @example
   * ```typescript
   * if (domain.hasFields()) {
   *   // ...
   * }
   * ```
   */
  hasFields(): boolean {
    return this.fields.length > 0
  }

  /**
   * Lists all fields (namespace and models) of this namespace.
   *
   * @returns A generator that yields each `DomainAssociation` or `DomainProperty`.
   * @example
   * ```typescript
   * for (const field of namespace.listFields()) {
   *   console.log(field.key);
   * }
   * ```
   */
  *listFields(): Generator<DomainNamespace | DomainModel> {
    for (const { key } of this.fields) {
      const node = this.graph.node(key)
      if (!node) {
        continue
      }
      if (node.kind === DomainNamespaceKind || node.kind === DomainModelKind) {
        yield node
      }
    }
  }

  /**
   * Adds a new data domain instance to the graph.
   *
   * @param value The partial namespace schema. All missing
   *   values will be filled with default values.
   * @param parent The parent namespace key. If not provided,
   *   the new namespace will be added to the root of the
   *   graph.
   * @returns The created namespace instance.
   * @throws Error When the parent does not exist or is not
   *   a namespace.
   * @example
   * ```typescript
   * const newNamespace = dataDomain.addNamespace({
   *   key: 'newNamespace',
   * });
   * ```
   */
  addNamespace(value?: Partial<DomainNamespaceSchema>, parent?: string): DomainNamespace {
    if (parent) {
      if (!this.graph.hasNode(parent)) {
        throw new Error(`Parent namespace ${parent} does not exist`)
      }
      const instance = this.graph.node(parent) as DomainNamespace
      if (!instance || instance.kind !== DomainNamespaceKind) {
        throw new Error(`Parent namespace ${parent} is not a valid namespace`)
      }
      if (instance.domain.key !== this.key) {
        throw new Error(`Cannot add a namespace to a foreign domain`)
      }
      return instance.addNamespace(value)
    }
    const item = new DomainNamespace(this, value)
    this.graph.setNode(item.key, item)
    this.fields.push({
      type: 'namespace',
      key: item.key,
    })
    this.notifyChange()
    return item
  }

  /**
   * Removes a namespace from the graph.
   *
   * @param key The key of the namespace to remove.
   * @returns The current DataDomain instance.
   * @throws Error When the namespace does not exist.
   * @example
   * ```typescript
   * dataDomain.removeNamespace('userNamespace');
   * ```
   */
  removeNamespace(key: string): this {
    if (!this.graph.hasNode(key)) {
      throw new Error(`Namespace ${key} does not exist`)
    }
    const ns = this.graph.node(key) as DomainNamespace
    if (!ns || ns.kind !== DomainNamespaceKind) {
      throw new Error(`Namespace ${key} not found`)
    }
    if (ns.domain.key !== this.key) {
      throw new Error(`Cannot remove a namespace from a foreign domain`)
    }
    const parent = ns.getParentInstance()
    if (parent === this) {
      removeGraphNode(this.graph, key)
      this.removeField(key)
      this.notifyChange()
    } else {
      parent.removeNamespace(key)
    }
    return this
  }

  /**
   * Lists all namespaces of this data domain that are direct children of it.
   *
   * Note, it accounts for the order of the namespaces as defined in the `fields` array.
   *
   * @returns A generator that yields each `DomainNamespace`.
   * @example
   * ```typescript
   * for (const ns of domain.listNamespaces()) {
   *   console.log(ns.key);
   * }
   * ```
   */
  *listNamespaces(): Generator<DomainNamespace> {
    for (const { key, type } of this.fields) {
      if (type !== 'namespace') {
        continue
      }
      const node = this.graph.node(key)
      if (!node || node.kind !== DomainNamespaceKind) {
        continue
      }
      yield node
    }
  }

  /**
   * Lists all namespaces in the graph that are part of specified domain.
   *
   * @param namespace The foreign namespace to search in. When not found, it throws an error.
   * @returns A generator that yields each `DomainNamespace`.
   * @throws Error When the foreign domain is not found.
   * @example
   * ```typescript
   * for (const ns of dataDomain.listForeignNamespaces('foreignDomainKey')) {
   *   console.log(ns.key);
   * }
   * ```
   */
  *listForeignNamespaces(namespace: string): Generator<DomainNamespace> {
    const foreignDomain = this.dependencies.get(namespace)
    if (!foreignDomain) {
      throw new Error(`Foreign domain ${namespace} not found`)
    }
    for (const node of foreignDomain.listNamespaces()) {
      const key = `${namespace}:${node.key}`
      const value = this.graph.node(key)
      if (value) {
        yield node
      }
    }
  }

  /**
   * Checks if this data domain has any namespaces.
   *
   * @returns True if the data domain has namespaces.
   * @example
   * ```typescript
   * if (domain.hasNamespaces()) {
   *   // ...
   * }
   * ```
   */
  hasNamespaces(): boolean {
    return this.fields.some((item) => item.type === 'namespace')
  }

  /**
   * Lists all namespaces in the graph.
   *
   * @param parent The key of the parent namespace. If not
   *   provided, all root namespaces will be listed.
   * @returns A generator that yields each `DomainNamespace`.
   * @example
   * ```typescript
   * for (const ns of dataDomain.listGraphNamespaces()) {
   *   console.log(ns.key);
   * }
   * ```
   */
  *listGraphNamespaces(parent?: string): Generator<DomainNamespace> {
    for (const node of this.graph.children(parent)) {
      const value = this.graph.node(node) as DomainGraphNodeType
      if (value.kind === DomainNamespaceKind && value.domain.key === this.key) {
        yield value
      }
    }
  }

  /**
   * Builds a reference key for a domain object.
   *
   * If the namespace is provided and it is different from the domain key,
   * it will return the key in the format `namespace:key`. The foreign namespace
   * must be registered in the domain's dependencies otherwise an error will be thrown.
   *
   * @param key The key of the namespace or model.
   * @param namespace The namespace to search in. When different from the domain key, it looks for a foreign namespace.
   * @returns The reference key in the format `namespace:key` or just `key` if no namespace is provided.
   * @throws Error When the foreign domain is not found.
   * @example
   * ```typescript
   * const refKey = dataDomain.buildReferenceKey('userModel', 'domainKey');
   * ```
   */
  buildReferenceKey(key: string, namespace?: string): string {
    if (namespace && namespace !== this.key) {
      if (typeof namespace !== 'string' || namespace.length === 0) {
        throw new Error(`Invalid namespace key. Expected a string, got ${typeof namespace}`)
      }
      const foreignDomain = this.dependencies.get(namespace)
      if (!foreignDomain) {
        throw new Error(`Foreign domain ${namespace} not found`)
      }
      return `${namespace}:${key}`
    }
    return key
  }

  /**
   * Finds a namespace by its key.
   *
   * @param key The key of the namespace to find.
   * @param namespace The namespace to search in. When different from the domain key, it looks for a foreign namespace.
   * @returns The namespace instance or undefined if not
   *   found.
   * @example
   * ```typescript
   * const ns = dataDomain.findNamespace('userNamespace');
   * if (ns) {
   *   console.log(ns.key);
   * }
   * ```
   */
  findNamespace(key: string, namespace?: string): DomainNamespace | undefined {
    const result = this.graph.node(this.buildReferenceKey(key, namespace)) as DomainNamespace | undefined
    if (result && result.kind === DomainNamespaceKind) {
      return result
    }
  }

  /**
   * Moves a namespace to a new parent.
   *
   * @param key The key of the namespace to move.
   * @param parent The new parent namespace key. If
   *   undefined, the namespace will be moved to the root.
   * @returns The current DataDomain instance.
   * @throws Error When the namespace or parent does not
   *   exist, or when a namespace is moved to itself or
   *   its own child.
   * @example
   * ```typescript
   * dataDomain.moveNamespace('userNamespace', 'root');
   * ```
   */
  moveNamespace(key: string, parent?: string): this {
    if (!this.graph.hasNode(key)) {
      throw new Error(`Namespace ${key} does not exist`)
    }

    const namespace = this.findNamespace(key)
    if (!namespace) {
      throw new Error(`Namespace ${key} not found in the graph`)
    }

    if (parent) {
      const instance = this.findNamespace(parent)
      if (instance && instance.domain.key !== this.key) {
        throw new Error(`Cannot move a namespace to a foreign domain`)
      }
      if (!instance) {
        throw new Error(`Parent namespace ${parent} does not exist`)
      }
    }

    if (key === parent) {
      throw new Error(`Cannot move a namespace to itself`)
    }

    if (namespace.domain.key !== this.key) {
      throw new Error(`Cannot move a namespace from a foreign domain`)
    }
    let parentNamespace: DomainNamespace | undefined
    if (parent) {
      parentNamespace = this.findNamespace(parent)
      if (!parentNamespace) {
        throw new Error(`Parent namespace ${parent} not found`)
      }
      if (this.isChildOf(parent, key)) {
        throw new Error(`Cannot move a namespace to its own child`)
      }
    }

    // namespaces can only have one parent
    const currentParentKey = this.graph.parent(key)
    if (currentParentKey && !parent) {
      // The new parent namespace will detach the namespace when attaching to self.
      const currentParent = this.graph.node(currentParentKey) as DomainNamespace
      currentParent.detachNamespace(key)
    } else if (!currentParentKey) {
      // The namespace is a root namespace.
      this.removeField(key)
    }
    // Add to new parent
    if (parent && parentNamespace) {
      parentNamespace.attachNamespace(key)
    } else {
      // The namespace is a root namespace.
      this.fields.push({
        type: 'namespace',
        key,
      })
    }
    this.notifyChange()
    return this
  }

  /**
   * Checks if a namespace is a child of another namespace.
   * @param parentKey The key of the parent namespace.
   * @param childKey The key of the child namespace.
   * @returns True if the namespace is a child of another namespace.
   */
  private isChildOf(parentKey: string, childKey: string): boolean {
    const children = this.graph.children(childKey)
    for (const child of children) {
      if (child === parentKey) {
        return true
      }
      if (this.isChildOf(parentKey, child)) {
        return true
      }
    }
    return false
  }

  /**
   * Adds a data model to the graph.
   *
   * @param input The partial data model schema.
   * @param parent The parent namespace key. If not
   *   provided, the new data model will be added to the
   *   root of the graph.
   * @returns The created data model instance.
   * @throws Error When the parent does not exist or is not
   *   a namespace.
   * @example
   * ```typescript
   * const newModel = dataDomain.addModel({
   *   key: 'newModel',
   * });
   * ```
   */
  addModel(input?: Partial<DomainModelSchema>, parent?: string): DomainModel {
    if (parent) {
      if (!this.graph.hasNode(parent)) {
        throw new Error(`Parent ${parent} does not exist`)
      }
      const instance = this.findNamespace(parent)
      if (!instance) {
        throw new Error(`Parent namespace ${parent} is not a valid namespace`)
      }
      if (instance.domain.key !== this.key) {
        throw new Error(`Cannot add a model to a foreign domain`)
      }
      return instance.addModel(input)
    }
    const item = new DomainModel(this, input)
    this.graph.setNode(item.key, item)
    this.fields.push({
      type: 'model',
      key: item.key,
    })
    return item
  }

  /**
   * Removes a data model from the graph.
   *
   * @param key The key of the data model to remove.
   * @returns The current DataDomain instance.
   * @throws Error When the data model does not exist.
   * @example
   * ```typescript
   * dataDomain.removeModel('userModel');
   * ```
   */
  removeModel(key: string): this {
    if (!this.graph.hasNode(key)) {
      throw new Error(`Data model ${key} does not exist`)
    }
    const model = this.graph.node(key) as DomainModel
    if (!model || model.kind !== DomainModelKind) {
      throw new Error(`Data model ${key} not found`)
    }
    if (model.domain.key !== this.key) {
      throw new Error(`Cannot remove a model from a foreign domain`)
    }
    const parent = model.getParentInstance()
    if (parent === this) {
      removeGraphNode(this.graph, key)
      this.removeField(key)
      this.notifyChange()
    } else {
      parent.removeModel(key)
    }
    return this
  }

  /**
   * Lists all models of this data domain that are direct children of it.
   *
   * Note, it accounts for the order of the models as
   * defined in the `fields` array.
   *
   * @returns A generator that yields each `DomainModel`.
   * @example
   * ```typescript
   * for (const model of namespace.listModels()) {
   *   console.log(model.key);
   * }
   * ```
   */
  *listModels(): Generator<DomainModel> {
    for (const { key, type } of this.fields) {
      if (type !== 'model') {
        continue
      }
      const node = this.graph.node(key)
      if (!node || node.kind !== DomainModelKind) {
        continue
      }
      yield node
    }
  }

  /**
   * Lists all models in the graph that are part of specified domain.
   *
   * @param namespace The foreign namespace to search in. When not found, it throws an error.
   * @returns A generator that yields each `DomainModel`.
   * @throws Error When the foreign domain is not found.
   * @example
   * ```typescript
   * for (const model of dataDomain.listForeignModels('foreignDomainKey')) {
   *   console.log(model.key);
   * }
   * ```
   */
  *listForeignModels(namespace: string): Generator<DomainModel> {
    const foreignDomain = this.dependencies.get(namespace)
    if (!foreignDomain) {
      throw new Error(`Foreign domain ${namespace} not found`)
    }
    for (const node of foreignDomain.listModels()) {
      const key = `${namespace}:${node.key}`
      const value = this.graph.node(key)
      if (value) {
        yield node
      }
    }
  }

  /**
   * Checks if this data domain has any direct models.
   *
   * @returns True if the data domain has models.
   * @example
   * ```typescript
   * if (domain.hasModels()) {
   *   // ...
   * }
   * ```
   */
  hasModels(): boolean {
    return this.fields.some((item) => item.type === 'model')
  }

  /**
   * Lists all data models in the graph.
   *
   * @param parent The key of the parent namespace. If not
   *   provided, all root data models will be listed.
   * @returns A generator that yields each `DomainModel`.
   * @example
   * ```typescript
   * for (const model of dataDomain.listGraphModels()) {
   *   console.log(model.key);
   * }
   * ```
   */
  *listGraphModels(parent?: string): Generator<DomainModel> {
    for (const node of this.graph.children(parent)) {
      const value = this.graph.node(node) as DomainGraphNodeType
      if (value.kind === DomainModelKind && value.domain.key === this.key) {
        yield value
      }
    }
  }

  /**
   * Finds a data model by its key.
   *
   * @param key The key of the data model to find.
   * @param namespace The namespace to search in. When different from the domain key, it looks for a foreign entity.
   * @returns The data model instance or undefined if not
   *   found.
   * @example
   * ```typescript
   * const model = dataDomain.findModel('userModel');
   * if (model) {
   *   console.log(model.key);
   * }
   * ```
   */
  findModel(key: string, namespace?: string): DomainModel | undefined {
    const value = this.graph.node(this.buildReferenceKey(key, namespace)) as DomainModel | undefined
    if (value && value.kind === DomainModelKind) {
      return value
    }
    return undefined
  }

  /**
   * Moves a data model to a new parent.
   *
   * @param key The key of the data model to move.
   * @param parent The new parent namespace key. If
   *   undefined, the data model will be moved to the root.
   * @returns The current DataDomain instance.
   * @throws Error When the data model or parent does not
   *   exist, or when a data model is moved to a foreign
   *   domain.
   * @example
   * ```typescript
   * dataDomain.moveModel('userModel', 'newNamespace');
   * ```
   */
  moveModel(key: string, parent?: string): this {
    if (!this.graph.hasNode(key)) {
      throw new Error(`Data model ${key} does not exist`)
    }

    const model = this.findModel(key)
    if (!model) {
      throw new Error(`Data model ${key} not found in the graph`)
    }

    if (parent) {
      const instance = this.findNamespace(parent)
      if (instance && instance.domain.key !== this.key) {
        throw new Error(`Cannot move a model to a foreign domain`)
      }
      if (!instance) {
        throw new Error(`Parent namespace ${parent} does not exist`)
      }
    }

    if (model.domain.key !== this.key) {
      throw new Error(`Cannot move a data model to a foreign domain`)
    }

    // namespaces can only have one parent
    const currentParentKey = this.graph.parent(key)
    // Remove from current parent
    if (currentParentKey && !parent) {
      // The new parent namespace will detach the model when attaching to self.
      const currentParent = this.findNamespace(currentParentKey)
      if (!currentParent) {
        throw new Error(`The parent namespace ${currentParentKey} not found`)
      }
      currentParent.detachModel(key)
    } else if (!currentParentKey) {
      // The model is a root model.
      this.removeField(key)
    }

    // Add to new parent
    if (parent) {
      const parentNamespace = this.findNamespace(parent) as DomainNamespace
      parentNamespace.attachModel(key)
    } else {
      // The model is becoming a root model.
      this.fields.push({
        type: 'model',
        key,
      })
    }

    this.notifyChange()
    return this
  }

  /**
   * Adds an entity to a data model.
   *
   * @param input The partial entity schema.
   * @param parent The key of the parent data model.
   * @returns The created entity instance.
   * @throws {Error} When the parent does not exist or is not a data model.
   * @example
   * ```typescript
   * const userEntity = dataDomain.addEntity({
   *   key: 'user',
   * }, 'userModel');
   * ```
   */
  addEntity(parent: string, input?: Partial<DomainEntitySchema>): DomainEntity {
    if (!parent) {
      throw new Error(`An entity expects a DomainModel parent`)
    }
    if (!this.graph.hasNode(parent)) {
      throw new Error(`The parent ${parent} does not exist`)
    }
    const instance = this.findModel(parent)
    if (!instance) {
      throw new Error(`Parent model ${parent} is not a valid model`)
    }
    if (instance.domain.key !== this.key) {
      throw new Error(`Cannot add an entity to a foreign domain`)
    }
    return instance.addEntity(input)
  }

  /**
   * Removes an entity from the graph.
   *
   * @param key The key of the entity to remove.
   * @returns The current DataDomain instance.
   * @throws Error When the entity does not exist.
   * @example
   * ```typescript
   * dataDomain.removeEntity('user');
   * ```
   */
  removeEntity(key: string): this {
    if (!this.graph.hasNode(key)) {
      throw new Error(`Entity ${key} does not exist`)
    }
    const parentKey = this.graph.parent(key)
    if (!parentKey) {
      throw new Error(`Parent model not found for entity ${key}`)
    }
    const parent = this.findModel(parentKey)
    if (!parent) {
      throw new Error(`Parent model ${parentKey} not found`)
    }
    if (parent.domain.key !== this.key) {
      throw new Error(`Cannot remove an entity from a foreign domain`)
    }
    parent.removeEntity(key)
    return this
  }

  /**
   * Lists all entities in a data model, or, if the model key is not provided,
   * all entities in the domain.
   *
   * @param parent The key of the parent data model.
   * @returns A generator that yields each `DomainEntity`.
   * @example
   * ```typescript
   * for (const entity of dataDomain.listEntities('userModel')) {
   *   console.log(entity.key);
   * }
   * ```
   */
  *listEntities(parent?: string): Generator<DomainEntity> {
    const iterator = parent ? this.graph.children(parent) : this.graph.nodes()
    for (const node of iterator) {
      const value = this.graph.node(node) as DomainGraphNodeType
      if (value.kind === DomainEntityKind && value.domain.key === this.key) {
        yield value
      }
    }
  }

  /**
   * Lists all entities in the graph that are part of specified domain.
   *
   * @param namespace The foreign namespace to search in. When not found, it throws an error.
   * @returns A generator that yields each `DomainEntity`.
   * @throws Error When the foreign domain is not found.
   * @example
   * ```typescript
   * for (const model of dataDomain.listForeignEntities('foreignDomainKey')) {
   *   console.log(model.key);
   * }
   * ```
   */
  *listForeignEntities(namespace: string): Generator<DomainEntity> {
    const foreignDomain = this.dependencies.get(namespace)
    if (!foreignDomain) {
      throw new Error(`Foreign domain ${namespace} not found`)
    }
    for (const node of foreignDomain.listEntities()) {
      const key = `${namespace}:${node.key}`
      const value = this.graph.node(key)
      if (value) {
        yield node
      }
    }
  }

  /**
   * Lists all entities in the graph that are not part of this domain.
   */
  *listAllForeignEntities(): Generator<DomainEntity> {
    for (const node of this.graph.nodes()) {
      const value = this.graph.node(node) as DomainGraphNodeType
      if (value.kind === DomainEntityKind && value.domain.key !== this.key) {
        yield value
      }
    }
  }

  /**
   * Finds an entity by its key.
   *
   * @param key The key of the entity to find.
   * @param namespace The namespace to search in. When different from the domain key, it looks for a foreign entity.
   * @returns The entity instance or undefined if not found.
   * @example
   * ```typescript
   * const entity = dataDomain.findEntity('user');
   * if (entity) {
   *   console.log(entity.key);
   * }
   * ```
   */
  findEntity(key: string, namespace?: string): DomainEntity | undefined {
    const node = this.graph.node(this.buildReferenceKey(key, namespace)) as DomainEntity | undefined
    if (node && node.kind === DomainEntityKind) {
      return node
    }
    return undefined
  }

  /**
   * Moves an entity from one model to another.
   *
   * @param entityKey The key of the entity to move.
   * @param sourceModelKey The key of the source model.
   * @param targetModelKey The key of the target model.
   * @throws Error When the entity, source model, or target model does not exist.
   * @throws Error When the entity is not in the same domain.
   * @throws Error When the source and target models are the same.
   * @throws Error When moving to an unsupported object.
   */
  moveEntity(entityKey: string, sourceModelKey: string, targetModelKey: string): void {
    if (sourceModelKey === targetModelKey) {
      throw new Error(`Cannot move an entity to the same model`)
    }
    if (!this.graph.hasNode(entityKey)) {
      throw new Error(`Entity ${entityKey} does not exist`)
    }
    if (!this.graph.hasNode(sourceModelKey)) {
      throw new Error(`Source model ${sourceModelKey} does not exist`)
    }
    if (!this.graph.hasNode(targetModelKey)) {
      throw new Error(`Target model ${targetModelKey} does not exist`)
    }

    const entity = this.findEntity(entityKey)
    const sourceModel = this.findModel(sourceModelKey)
    const targetModel = this.findModel(targetModelKey)

    if (!entity || !sourceModel || !targetModel) {
      throw new Error(`Entity or models not found in the graph`)
    }

    if (entity.domain.key !== this.key) {
      // this also applied to the parent model.
      throw new Error(`Cannot move an entity from a foreign domain`)
    }
    if (targetModel.domain.key !== this.key) {
      throw new Error(`Cannot move an entity to a foreign domain`)
    }
    // The target model detaches the entity when attaching to self.
    targetModel.attachEntity(entityKey)
    this.notifyChange()
  }

  /**
   * Adds an association between two entities.
   *
   * This function is a shortcut that finds the entity and
   * calls the `addAssociation` method on it.
   *
   * @param source The key of the source entity.
   * @param init The association options.
   * @returns The created association.
   * @throws Error When the source entity does not exist.
   * @example
   * ```typescript
   * const addressAssociation = dataDomain.addAssociation(
   *   'user', { key: 'address' }
   * );
   * ```
   */
  addAssociation(source: string, init?: AssociationAddOptions): DomainAssociation {
    if (!this.graph.hasNode(source)) {
      throw new Error(`Source entity ${source} not found`)
    }
    const entity = this.findEntity(source)
    if (!entity) {
      throw new Error(`Source entity ${source} not found`)
    }
    if (entity.domain.key !== this.key) {
      throw new Error(`Cannot add an association to a foreign domain`)
    }
    return entity.addAssociation(init)
  }

  /**
   * Removes an association from the graph.
   *
   * @param key The key of the association to remove.
   * @returns The current DataDomain instance.
   * @throws Error When the association does not exist or
   *   when the parent entity is not found.
   * @example
   * ```typescript
   * dataDomain.removeAssociation('address');
   * ```
   */
  removeAssociation(key: string): this {
    if (!this.graph.hasNode(key)) {
      throw new Error(`Association ${key} does not exist`)
    }
    const instance = this.graph.node(key) as DomainAssociation
    if (!instance || instance.kind !== DomainAssociationKind) {
      throw new Error(`Association ${key} not found`)
    }
    const entity = instance.getParentInstance()
    if (!entity) {
      throw new Error(`Parent entity not found for association ${key}`)
    }
    if (entity.domain.key !== this.key) {
      throw new Error(`Cannot remove an association from a foreign domain`)
    }
    entity.removeAssociation(key)
    return this
  }

  /**
   * Finds an association by its key.
   *
   * @param key The key of the association to find.
   * @param namespace The namespace to search in. When different from the domain key, it looks for a foreign association
   * @returns The association instance or undefined if not
   *   found.
   * @example
   * ```typescript
   * const assoc = dataDomain.findAssociation('address');
   * if (assoc) {
   *   console.log(assoc.key);
   * }
   * ```
   */
  findAssociation(key: string, namespace?: string): DomainAssociation | undefined {
    const node = this.graph.node(this.buildReferenceKey(key, namespace)) as DomainAssociation | undefined
    if (node && node.kind === DomainAssociationKind) {
      return node
    }
    return undefined
  }

  /**
   * Adds a property to an entity.
   *
   * @param parent The key of the parent entity.
   * @param property The partial property schema.
   * @returns The created property instance.
   * @throws Error When the parent does not exist or is not
   *   an entity.
   * @example
   * ```typescript
   * const nameProperty = dataDomain.addProperty(
   *   'user', { key: 'name', type: 'string' }
   * );
   * ```
   */
  addProperty(parent: string, property?: Partial<DomainPropertySchema>): DomainProperty {
    if (!parent) {
      throw new Error(`A property expects a DomainEntity parent`)
    }
    if (!this.graph.hasNode(parent)) {
      throw new Error(`Parent entity ${parent} does not exist`)
    }
    const entity = this.findEntity(parent)
    if (!entity) {
      throw new Error(`Parent entity ${parent} not found`)
    }
    if (entity.domain.key !== this.key) {
      throw new Error(`Cannot add a property to a foreign domain`)
    }
    return entity.addProperty(property)
  }

  /**
   * Removes a property from the graph.
   *
   * @param key The key of the property to remove.
   * @returns The current DataDomain instance.
   * @throws Error When the property does not exist or when
   *   the parent entity is not found.
   * @example
   * ```typescript
   * dataDomain.removeProperty('name');
   * ```
   */
  removeProperty(key: string): this {
    if (!this.graph.hasNode(key)) {
      throw new Error(`Property ${key} does not exist`)
    }
    const instance = this.graph.node(key) as DomainProperty
    if (!instance || instance.kind !== DomainPropertyKind) {
      throw new Error(`Property ${key} not found`)
    }
    const entity = instance.getParentInstance()
    if (!entity) {
      throw new Error(`Parent entity not found for property ${key}`)
    }
    if (entity.domain.key !== this.key) {
      throw new Error(`Cannot remove a property from a foreign domain`)
    }
    entity.removeProperty(key)
    return this
  }

  /**
   * Finds a property by its key.
   *
   * @param key The key of the property to find.
   * @param namespace The namespace to search in. When different from the domain key, it looks for a foreign property
   * @returns The property instance or undefined if not
   *   found.
   * @example
   * ```typescript
   * const prop = dataDomain.findProperty('name');
   * if (prop) {
   *   console.log(prop.key);
   * }
   * ```
   */
  findProperty(key: string, namespace?: string): DomainProperty | undefined {
    const node = this.graph.node(this.buildReferenceKey(key, namespace)) as DomainProperty | undefined
    if (node && node.kind === DomainPropertyKind) {
      return node
    }
    return undefined
  }

  /**
   * Registers a foreign DataDomain.
   *
   * @param domain The foreign DataDomain instance.
   * @param key The unique identifier for the foreign
   *   domain.
   * @param version The version of the foreign domain.
   * @throws Error When a foreign domain with the same key is already registered.
   * @example
   * ```typescript
   * dataDomain.registerForeignDomain(
   *   foreignDomain, 'foreignDomain', '1.0.0'
   * );
   * ```
   */
  registerForeignDomain(domain: DataDomain): void {
    const { info, key } = domain
    if (!info.version) {
      // @TODO: make the data domain immutable after a version is set.
      // This will prevent the user from changing the version
      // after the domain is registered.
      throw new Error(`Foreign domain ${key} does not have a version set`)
    }
    if (this.dependencies.has(key)) {
      throw new Error(`Foreign domain with key ${key} already registered`)
    }
    this.dependencies.set(key, domain)
    this.dependencyList.push({ key, version: info.version })
    // Copy the relevant parts of the foreign domain's graph into the local graph.
    // this.copyForeignDomainGraph(domain)
    mergeGraph(this.graph, domain.graph, domain.key)
    this.notifyChange()
  }

  /**
   * Un-registers a foreign DataDomain. It removes all defined by the foreign domain
   * information from the graph.
   * @param key The key of the foreign domain to unregister.
   */
  unregisterForeignDomain(key: string): void {
    const foreignDomain = this.dependencies.get(key)
    if (!foreignDomain) {
      throw new Error(`Foreign domain with key ${key} not found`)
    }
    this.dependencies.delete(key)
    this.dependencyList = this.dependencyList.filter((dependency) => dependency.key !== key)
    // Remove the foreign domain's nodes from the local graph.
    removeForeignGraph(this.graph, foreignDomain.key)
    this.notifyChange()
  }

  /**
   * Finds an entity in a foreign domain.
   *
   * @param entityKey The key of the entity to find.
   * @param domainKey The key of the foreign domain.
   * @returns The entity instance or undefined if not
   *   found.
   * @example
   * ```typescript
   * const foreignUser = dataDomain.findForeignEntity(
   *   'user', 'foreignDomain'
   * );
   * if (foreignUser) {
   *   console.log(foreignUser.key);
   * }
   * ```
   * @deprecated Use `findEntity` with the `namespace` parameter instead.
   * This method is kept for backward compatibility but will be removed in the future.
   */
  findForeignEntity(entityKey: string, domainKey: string): DomainEntity | undefined {
    const foreignDomain = this.dependencies.get(domainKey)
    if (!foreignDomain) {
      return undefined
    }
    const foreignKey = `${domainKey}:${entityKey}`
    const node = this.graph.node(foreignKey)
    if (node && node.kind === DomainEntityKind) {
      return node as DomainEntity
    }
    return undefined
  }

  /**
   * A convenience function to remove an object from the
   * namespace.
   *
   * @param key The key of the object to remove.
   * @param kind The kind of the object to remove.
   * @throws Error when the kind is not known.
   * @example
   * ```typescript
   * dataDomain.removeDomainElement('user', DomainEntityKind);
   * ```
   */
  removeDomainElement(key: string, kind: string): void {
    switch (kind) {
      case DomainNamespaceKind:
        this.removeNamespace(key)
        break
      case DomainModelKind:
        this.removeModel(key)
        break
      case DomainEntityKind:
        this.removeEntity(key)
        break
      case DomainPropertyKind:
        this.removeProperty(key)
        break
      case DomainAssociationKind:
        this.removeAssociation(key)
        break
      default:
        throw new Error(`Unknown kind ${kind} for the object ${key}`)
    }
  }

  /**
   * Searches domain nodes by text content and/or node type.
   *
   * This function traverses the graph and filters nodes based on the provided search criteria.
   * It can search through name, displayName, and description fields of domain objects,
   * and optionally filter by specific node types.
   *
   * @param criteria Search criteria including query string/regex and filtering options.
   * @returns An array of search results containing matching nodes and metadata.
   *
   * @example
   * ```typescript
   * // Search for nodes containing "user" in any text field
   * const results = dataDomain.search({ query: 'user' });
   *
   * // Search for entities only
   * const entityResults = dataDomain.search({
   *   nodeTypes: [DomainEntityKind]
   * });
   *
   * // Search for "user" in entities and properties with case-sensitive matching
   * const specificResults = dataDomain.search({
   *   query: 'User',
   *   nodeTypes: [DomainEntityKind, DomainPropertyKind],
   *   caseSensitive: true
   * });
   *
   * // Search using regex pattern
   * const regexResults = dataDomain.search({
   *   query: /^user/i
   * });
   * ```
   */
  search(criteria: DomainSearchCriteria = {}): DomainSearchResult[] {
    const { query, nodeTypes, includeForeignDomains = false, caseSensitive = false } = criteria

    const results: DomainSearchResult[] = []

    // If no criteria provided, return all nodes
    const shouldReturnAll = !query && (!nodeTypes || nodeTypes.length === 0)

    // Helper function to check if a node matches the type filter
    const matchesNodeType = (nodeKind: string): boolean => {
      if (!nodeTypes || nodeTypes.length === 0) {
        return true
      }
      return nodeTypes.includes(nodeKind as SearchableNodeType)
    }

    // Helper function to check if text matches the query
    const matchesQuery = (text: string | undefined): boolean => {
      if (!text || !query) {
        return !query // If no query, consider it a match
      }

      if (query instanceof RegExp) {
        return query.test(text)
      }

      // String query
      const searchText = caseSensitive ? text : text.toLowerCase()
      const searchQuery = caseSensitive ? query : query.toLowerCase()
      return searchText.includes(searchQuery)
    }

    // Helper function to get matched fields for a node
    const getMatchedFields = (node: DomainGraphNodeType): ('name' | 'displayName' | 'description')[] => {
      if (!query) {
        return [] // No query means no specific field matching
      }

      const matched: ('name' | 'displayName' | 'description')[] = []
      const { info } = node

      if (matchesQuery(info.name)) {
        matched.push('name')
      }
      if (matchesQuery(info.displayName)) {
        matched.push('displayName')
      }
      if (matchesQuery(info.description)) {
        matched.push('description')
      }

      return matched
    }

    // Helper function to determine if a node key belongs to a foreign domain
    const isForeignNode = (nodeKey: string): boolean => {
      return nodeKey.includes(':')
    }

    // Iterate through all nodes in the graph
    for (const nodeKey of this.graph.nodes()) {
      const node = this.graph.node(nodeKey) as DomainGraphNodeType

      // Skip if node doesn't exist or doesn't have required properties
      if (!node || !node.info) {
        continue
      }

      const isForeign = isForeignNode(nodeKey)

      // Skip foreign nodes if not requested
      if (isForeign && !includeForeignDomains) {
        continue
      }

      // Check node type filter
      if (!matchesNodeType(node.kind)) {
        continue
      }

      // If returning all nodes (no specific criteria), add the node
      if (shouldReturnAll) {
        results.push({
          node,
          matchedFields: [],
          key: nodeKey,
          isForeign,
        })
        continue
      }

      // Check text query match
      const matchedFields = getMatchedFields(node)
      const hasTextMatch = !query || matchedFields.length > 0

      if (hasTextMatch) {
        results.push({
          node,
          matchedFields,
          key: nodeKey,
          isForeign,
        })
      }
    }

    return results
  }
}
