import { IThing, Thing } from '../../models/Thing.js'
import { nanoid } from 'nanoid'
import { DataEntity, IDataEntity } from './DataEntity.js'
import type { DataItemAdaptingOptions, DataNamespace } from './DataNamespace.js'
import { FileBreadcrumb } from '../../models/store/File.js'
import { DataModelKind } from '../../models/kinds.js'
import type { DataDomainRemoveOptions } from '../types.js'

/**
 * Data model creates a logical structure around data entities.
 * It groups entities that represents a whole schema, like a Product data model
 * can have entities that describe: the product entity, price history entity,
 * product location, etc.
 * @deprecated
 */
export interface IDataModel {
  kind: typeof DataModelKind
  /**
   * The key of the namespace.
   */
  key: string
  /**
   * The data model description.
   */
  info: IThing
  /**
   * The list of keys of entities that this data model contain.
   */
  entities?: string[]
}

/**
 * Data model creates a logical structure around data entities.
 * It groups entities that represents a whole schema, like a Product data model
 * can have entities that describe: the product entity, price history entity,
 * product location, etc.
 * @deprecated
 */
export class DataModel {
  kind = DataModelKind

  key = ''

  /**
   * The description of the data namespace.
   */
  info: Thing = Thing.fromName('')

  /**
   * The list of entities that this data model has.
   */
  entities: DataEntity[] = []

  static fromName(root: DataNamespace, name: string): DataModel {
    const entity = new DataModel(root)
    entity.info = Thing.fromName(name)
    return entity
  }

  /**
   * @param root the root namespace.
   * @param input The data model definition to restore.
   */
  constructor(
    public root: DataNamespace,
    input?: string | IDataModel
  ) {
    let init: IDataModel
    if (typeof input === 'string') {
      init = JSON.parse(input)
    } else if (typeof input === 'object') {
      init = input
    } else {
      init = {
        kind: DataModelKind,
        key: nanoid(),
        info: Thing.fromName('').toJSON(),
        entities: [],
      }
    }
    this.new(init)
  }

  new(init: IDataModel): void {
    if (!DataModel.isDataModel(init)) {
      throw new Error(`Not a data model.`)
    }
    const { info, key = nanoid(), kind = DataModelKind, entities } = init
    this.kind = kind
    this.key = key
    if (info) {
      this.info = new Thing(info)
    } else {
      this.info = Thing.fromName('')
    }

    this.entities = []
    if (Array.isArray(entities)) {
      entities.forEach((key) => {
        const value = this._readEntity(key)
        if (value) {
          this.entities.push(value)
        }
      })
    }
  }

  static isDataModel(input: unknown): boolean {
    const typed = input as IDataModel
    if (!input || typed.kind !== DataModelKind) {
      return false
    }
    return true
  }

  toJSON(): IDataModel {
    const result: IDataModel = {
      kind: DataModelKind,
      key: this.key,
      info: this.info.toJSON(),
    }
    if (Array.isArray(this.entities) && this.entities.length) {
      result.entities = this.entities.map((i) => i.key)
    }
    return result
  }

  protected _readEntity(key: string): DataEntity | undefined {
    return this.root.definitions.entities.find((i) => i.key === key)
  }

  /**
   * Removes self from the namespace with all entities.
   */
  remove(opts?: DataDomainRemoveOptions): void {
    const { key, entities, root } = this
    // remove children
    entities.forEach((e) => e.remove(opts))

    // remove self from the parent
    const parent = this.getParentInstance()
    if (parent) {
      const itemIndex = parent.items.findIndex((i) => i.key === key)
      if (itemIndex >= 0) {
        parent.items.splice(itemIndex, 1)
      }
    }
    // remove self from definitions
    const index = root.definitions.models.findIndex((i) => i.key === key)
    if (index >= 0) {
      root.definitions.models.splice(index, 1)
    }
  }

  /**
   * Adapts an entity to this data model.
   * The entity must already be added to another data model within this namespace for it to be
   * adapted to this data model.
   * This method is used to change the data model of an entity.
   * It will remove the entity from the previous data model and add it to this one.
   * @param entity The entity to adapt.
   */
  adaptEntity(entity: DataEntity, opts: DataItemAdaptingOptions = {}): void {
    if (opts.index !== undefined) {
      if (opts.index < 0) {
        throw new Error(`The index ${opts.index} cannot be below 0.`)
      }
      if (opts.index >= this.entities.length) {
        throw new Error(`The index ${opts.index} is not valid.`)
      }
    }
    if (entity.root !== this.root) {
      throw new Error(`The entity ${entity.key} is not in the same namespace as this data model.`)
    }
    const parent = entity.getParentInstance()
    if (!parent) {
      throw new Error(`The entity ${entity.key} has no parent.`)
    }
    if (parent.key === this.key) {
      throw new Error(`The entity ${entity.key} is already adapted by this data model.`)
    }
    // it has a parent (above check) so it has the index.
    const index = parent.entities.findIndex((i) => i.key === entity.key)
    parent.entities.splice(index, 1)
    if (opts.index !== undefined) {
      this.entities.splice(opts.index, 0, entity)
    } else {
      this.entities.push(entity)
    }
  }

  /**
   * Adds an entity to this data model.
   *
   * @param init The name of the entity to create, the instance of the entity or its schema
   * @returns A reference to the created entity.
   */
  addEntity(init: string | DataEntity | IDataEntity): DataEntity {
    let definition: DataEntity
    if (typeof init === 'string') {
      definition = DataEntity.fromName(this.root, init)
    } else if (init instanceof DataEntity) {
      definition = init
    } else {
      definition = new DataEntity(this.root, init)
    }
    this.root.definitions.entities.push(definition)
    this.entities.push(definition)
    return definition
  }

  /**
   * @deprecated Use the `getParentInstance()` method instead.
   */
  getParent(): DataNamespace | undefined {
    return this.getParentInstance()
  }

  /**
   * Returns the parent namespace where this data model exist.
   */
  getParentInstance(): DataNamespace | undefined {
    if (this.root.items.some((e) => e.key === this.key)) {
      return this.root
    }
    return this.root.definitions.namespaces.find((n) => n.items.some((e) => e.key === this.key))
  }

  /**
   * Creates breadcrumbs from this data model to the root namespace.
   */
  breadcrumbs(): FileBreadcrumb[] {
    const result: FileBreadcrumb[] = []
    result.push({
      key: this.key,
      name: this.info.getLabel('Unnamed data model'),
      kind: DataModelKind,
    })
    let parent = this.getParentInstance()
    while (parent && parent !== this.root) {
      result.push({
        key: parent.key,
        kind: parent.kind,
        name: parent.info.getLabel('Unnamed namespace'),
      })
      parent = parent.getParentInstance()
    }
    result.push({
      key: this.root.key,
      name: this.root.info.getLabel('Unnamed data domain'),
      kind: this.root.kind,
    })
    return result.reverse()
  }

  /**
   * Checks whether the data model is a child of the given namespace.
   * The relationship doesn't have to be direct, as long as the data model is in the hierarchy it will return true.
   *
   * @param key The key of the parent namespace to check.
   * @returns True if this data model 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
    }
    return parent.isChildOf(key)
  }
}
