import type { DataDomain } from './DataDomain.js'
import { DomainModelKind, DomainNamespaceKind } from '../models/kinds.js'
import { DomainElement, DomainElementSchema } from './DomainElement.js'
import { nanoid } from '../nanoid.js'
import { IThing, Thing } from '../models/Thing.js'
import { observed, retargetChange } from '../decorators/observed.js'
import { DomainModel, DomainModelSchema } from './DomainModel.js'
import { RemoveNamespaceException } from '../exceptions/remove_namespace_exception.js'
import { RemoveModelException } from '../exceptions/remove_model_exception.js'
import { removeGraphNode } from './GraphUtils.js'
import { DetachException } from '../exceptions/detach_exception.js'
import { AttachException } from '../exceptions/attach_exception.js'

export interface NamespaceOrderedItem {
  /**
   * The type of the ordered item.
   */
  type: 'namespace' | 'model'

  /**
   * The key of the ordered item.
   */
  key: string
}

export interface DomainNamespaceSchema extends DomainElementSchema {
  kind: typeof DomainNamespaceKind
  /**
   * The data namespace description.
   */
  info: IThing
  /**
   * 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[]

  /**
   * Whether this namespace is deprecated.
   */
  deprecated?: boolean
}

/**
 * The domain namespace is a way to create a logical structure around data models.
 * It groups data models that represent a whole schema, like a Product data namespace
 * can have data models that describe: the product data model, price history data model,
 * product location, etc.
 *
 * It is optional to have a namespace, but it is recommended to have one for complex
 * data domains.
 *
 * A data domain can be a child of another data domain. It can also contain multiple
 * data domains and data models.
 */
export class DomainNamespace extends DomainElement {
  override kind: typeof DomainNamespaceKind

  /**
   * The description of the domain namespace.
   */
  @retargetChange() accessor info: Thing

  /**
   * 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[]

  /**
   * Whether this namespace is deprecated.
   */
  @observed() accessor deprecated: boolean | undefined

  /**
   * Creates a full namespace schema with defaults.
   * @param input The partial namespace schema.
   * @returns The namespace schema.
   */
  static createSchema(input: Partial<DomainNamespaceSchema> = {}): DomainNamespaceSchema {
    const { key = nanoid(), fields, deprecated } = input
    const info = Thing.fromJSON(input.info, { name: 'New namespace' }).toJSON()
    const result: DomainNamespaceSchema = {
      kind: DomainNamespaceKind,
      key,
      info,
    }
    if (Array.isArray(fields)) {
      result.fields = [...fields]
    }
    if (typeof deprecated === 'boolean') {
      result.deprecated = deprecated
    }
    return result
  }

  /**
   * Creates a new namespace instance.
   * It does not add it to the graph.
   * You need to call the `addNamespace` method of the graph to add it.
   *
   * @param root A reference to the root DataDomain instance.
   * @param input The partial namespace schema.
   * @returns Created namespace instance.
   */
  constructor(root: DataDomain, input?: Partial<DomainNamespaceSchema>) {
    const init = DomainNamespace.createSchema(input)
    super(root, init.key)
    this.kind = DomainNamespaceKind
    this.info = new Thing(init.info)
    if (Array.isArray(init.fields)) {
      this.fields = [...init.fields]
    } else {
      this.fields = []
    }
    if (typeof init.deprecated === 'boolean') {
      this.deprecated = init.deprecated
    } else {
      this.deprecated = undefined
    }
  }

  toJSON(): DomainNamespaceSchema {
    const result: DomainNamespaceSchema = {
      kind: this.kind,
      key: this.key,
      info: this.info.toJSON(),
    }
    if (Array.isArray(this.fields) && this.fields.length) {
      result.fields = [...this.fields]
    }
    if (typeof this.deprecated === 'boolean') {
      result.deprecated = this.deprecated
    }
    return result
  }

  /**
   * Adds a child namespace to the current namespace.
   *
   * @param value The partial namespace schema.
   * @returns The created namespace instance.
   * @example
   * ```typescript
   * const property = namespace.addNamespace({
   *   key: 'e-commerce'
   * })
   * ```
   */
  addNamespace(value?: Partial<DomainNamespaceSchema>): DomainNamespace {
    if (value && value.key && this.root.graph.hasNode(value.key)) {
      throw new Error(`Trying to add a namespace to ${this.key}, but it already exists`)
    }
    const instance = new DomainNamespace(this.root, value)
    this.root.graph.setNode(instance.key, instance)
    this.root.graph.setParent(instance.key, this.key)
    this.fields.push({
      type: 'namespace',
      key: instance.key,
    })
    this.root.notifyChange()
    return instance
  }

  /**
   * Removes a namespace from the current namespace.
   *
   * @param key The key of the namespace to remove.
   * @throws {RemoveNamespaceException} If the namespace does not exist.
   * @example
   * ```typescript
   * namespace.removeNamespace('e-commerce')
   * ```
   */
  removeNamespace(key: string): this {
    if (!this.root.graph.hasNode(key)) {
      throw new RemoveNamespaceException(`Trying to remove the ${key} namespace, but it doesn't exist`)
    }
    const node = this.root.graph.node(key)
    if (!node || node.kind !== DomainNamespaceKind) {
      throw new RemoveNamespaceException(`Trying to remove the ${key} namespace, but it is not a namespace`)
    }
    removeGraphNode(this.root.graph, key)
    this.removeField(key)
    this.root.notifyChange()
    return this
  }

  /**
   * Attaches a namespace to the current namespace. It changes the parent of the namespace
   * to the current namespace in the graph.
   *
   * The attached namespace must be already a child of another namespace.
   *
   * @param key The key of the namespace to attach.
   * @throws {Error} If the namespace does not exist.
   * @example
   * ```typescript
   * namespace.attachNamespace('e-commerce')
   * ```
   */
  attachNamespace(key: string): this {
    if (!this.root.graph.hasNode(key)) {
      throw new Error(`Trying to attach the ${key} namespace, but it doesn't exist`)
    }
    const node = this.root.graph.node(key)
    if (!node || node.kind !== DomainNamespaceKind) {
      throw new Error(`Trying to attach the ${key} namespace, but it is not a namespace`)
    }
    if (node.domain.key !== this.root.key) {
      throw new Error(`Trying to attach the ${key} namespace, but it is not in the same domain`)
    }
    const parent = node.getParentInstance()
    if (parent === this) {
      throw new AttachException(`Trying to attach the ${key} entity, but it is already a child of this model`)
    }
    if (this.root !== parent) {
      ;(parent as DomainNamespace).detachNamespace(key)
    }
    this.fields.push({
      type: 'namespace',
      key: key,
    })
    this.root.graph.setParent(key, this.key)
    this.root.notifyChange()
    return this
  }

  /**
   * Detaches a namespace from the current namespace. It changes the parent of the namespace
   * to the root graph.
   * Unlike remove, it doesn't remove the namespace from the graph nor notifies the change.
   * @param key The key of the namespace to detach.
   * @throws {RemoveNamespaceException} If the namespace does not exist.
   */
  detachNamespace(key: string): this {
    const index = this.fields.findIndex((item) => item.key === key)
    if (index === -1) {
      throw new RemoveNamespaceException(`Trying to detach the ${key} namespace, but it doesn't exist`)
    }
    const node = this.root.graph.node(key)
    if (!node || node.kind !== DomainNamespaceKind) {
      throw new RemoveNamespaceException(`Trying to detach the ${key} namespace, but it is not a namespace`)
    }
    this.fields.splice(index, 1)
    this.root.graph.setParent(key)
    return this
  }

  /**
   * Lists all namespaces of this namespaces.
   *
   * 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 namespace.listNamespaces()) {
   *   console.log(ns.key);
   * }
   * ```
   */
  *listNamespaces(): Generator<DomainNamespace> {
    for (const { key, type } of this.fields) {
      if (type !== 'namespace') {
        continue
      }
      const node = this.root.graph.node(key)
      if (!node || node.kind !== DomainNamespaceKind) {
        continue
      }
      yield node
    }
  }

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

  /**
   * Adds a data model to the current namespace.
   * @param value The partial data model schema.
   * @returns The created data model instance.
   * @example
   * ```typescript
   * const property = namespace.addModel({
   *   key: 'inventory'
   * })
   * ```
   */
  addModel(input?: Partial<DomainModelSchema>): DomainModel {
    if (input && input.key && this.root.graph.hasNode(input.key)) {
      throw new Error(`Trying to add a model to ${this.key}, but it already exists`)
    }
    const item = new DomainModel(this.root, input)
    this.root.graph.setNode(item.key, item)
    this.root.graph.setParent(item.key, this.key)
    this.fields.push({
      type: 'model',
      key: item.key,
    })
    this.root.notifyChange()
    return item
  }

  /**
   * Removes a data model from the current namespace.
   *
   * @param key The key of the data model to remove.
   * @throws {RemoveModelException} If the data model does not exist.
   * @example
   * ```typescript
   * namespace.removeModel('inventory')
   * ```
   */
  removeModel(key: string): this {
    if (!this.root.graph.hasNode(key)) {
      throw new RemoveModelException(`Trying to remove the ${key} model, but it doesn't exist`)
    }
    const node = this.root.graph.node(key)
    if (!node || node.kind !== DomainModelKind) {
      throw new RemoveModelException(`Trying to remove the ${key} model, but it is not a model`)
    }
    removeGraphNode(this.root.graph, key)
    this.removeField(key)
    this.root.notifyChange()
    return this
  }

  /**
   * Attaches a model to the current namespace and sets its parent to the current namespace.
   *
   * The attached model is already a child of another namespace.
   * This method only adds this model to its internal list of fields.
   *
   * Unlike `addModel`, it doesn't create a new model or add it to the graph.
   * It only adds the model to the list of fields.
   *
   * @param key The key of the model to attach.
   * @throws {AttachException} If the model does not exist.
   * @throws {AttachException} If the model is not a model.
   * @throws {AttachException} If the model is not in the same domain.
   * @throws {AttachException} If the model is already a child of this namespace.
   * @example
   * ```typescript
   * namespace.attachModel('inventory')
   * ```
   */
  attachModel(key: string): this {
    if (!this.root.graph.hasNode(key)) {
      throw new AttachException(`Trying to attach the ${key} model, but it doesn't exist`)
    }
    const node = this.root.graph.node(key)
    if (!node || node.kind !== DomainModelKind) {
      throw new AttachException(`Trying to attach the ${key} model, but it is not a model`)
    }
    if (node.domain.key !== this.root.key) {
      throw new AttachException(`Trying to attach the ${key} model, but it is not in the same domain`)
    }
    const parent = node.getParentInstance()
    if (parent === this) {
      throw new AttachException(`Trying to attach the ${key} model, but it is already a child of this namespace`)
    }
    if (this.root !== parent) {
      ;(parent as DomainNamespace).detachModel(key)
    }
    this.fields.push({
      type: 'model',
      key: key,
    })
    this.root.graph.setParent(key, this.key)
    this.root.notifyChange()
    return this
  }

  /**
   * Detaches a model from the current namespace and sets its parent to the root graph.
   * Unlike remove, it doesn't remove the model from the graph nor notifies the change.
   * @param key The key of the model to detach.
   * @throws {DetachException} If the model does not exist.
   * @throws {DetachException} If the model is not a model.
   * @throws {DetachException} If the model is not a child of this namespace.
   */
  detachModel(key: string): this {
    const index = this.fields.findIndex((item) => item.key === key)
    if (index === -1) {
      throw new DetachException(`Trying to detach the ${key} model, but it doesn't exist`)
    }
    const node = this.root.graph.node(key)
    if (!node || node.kind !== DomainModelKind) {
      throw new DetachException(`Trying to detach the ${key} model, but it is not a model`)
    }
    if (node.getParentInstance() !== this) {
      throw new DetachException(`Trying to detach the ${key} model, but it is not a child of this namespace`)
    }
    this.fields.splice(index, 1)
    this.root.graph.setParent(key)
    return this
  }

  /**
   * Lists all models of this namespaces.
   *
   * 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 ns of namespace.listModels()) {
   *   console.log(ns.key);
   * }
   * ```
   */
  *listModels(): Generator<DomainModel> {
    for (const { key, type } of this.fields) {
      if (type !== 'model') {
        continue
      }
      const node = this.root.graph.node(key)
      if (!node || node.kind !== DomainModelKind) {
        continue
      }
      yield node
    }
  }

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

  /**
   * Returns a parent data namespace or the data domain where this data namespace exist.
   */
  getParentInstance(): DomainNamespace | DataDomain {
    let parentKey: string | undefined
    for (const parent of this.root.graph.parents(this.key)) {
      parentKey = parent
      break
    }
    if (!parentKey) {
      return this.root
    }
    return this.root.graph.node(parentKey) as DomainNamespace
  }

  /**
   * 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.root.graph.node(key)
      if (!node) {
        continue
      }
      if (node.kind === DomainNamespaceKind || node.kind === DomainModelKind) {
        yield node
      }
    }
  }

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

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

  /**
   * Checks whether the namespace is a child of the given namespace.
   * The relationship doesn't have to be direct, as long as the namespace is in the hierarchy it will return true.
   *
   * @param key The key of the parent namespace to check.
   * @returns True if this namespace is a child of the given namespace.
   */
  isChildOf(key: string): boolean {
    if (this.key === key) {
      return false
    }
    const parent = this.getParentInstance()
    if (!parent) {
      return false
    }
    if (parent.key === key) {
      return true
    }
    if (parent === this.root) {
      return false
    }
    return (parent as DomainNamespace).isChildOf(key)
  }
}
