/* eslint-disable @typescript-eslint/no-this-alias */
import { IThing, Thing } from '../../models/Thing.js'
import { IDataAssociation, DataAssociation, AssociationTarget } from './DataAssociation.js'
import { IDataEntity, DataEntity } from './DataEntity.js'
import { IDataModel, DataModel } from './DataModel.js'
import { IDataProperty, DataProperty } from './DataProperty.js'
import {
  DataNamespaceKind,
  DataModelKind,
  DataEntityKind,
  DataPropertyKind,
  DataAssociationKind,
} from '../../models/kinds.js'
import { nanoid } from 'nanoid'
import type { DataDomainRemoveOptions } from '../types.js'

/**
 * @deprecated
 */
type ItemKind = typeof DataNamespaceKind | typeof DataModelKind

/**
 * @deprecated
 */
export interface DataItemAdaptingOptions {
  /**
   * The index at which to adapt the item.
   */
  index?: number
}
/**
 * @deprecated
 */
interface IDataDefinitions {
  models?: IDataModel[]
  entities?: IDataEntity[]
  properties?: IDataProperty[]
  associations?: IDataAssociation[]
  namespaces?: IDataNamespace[]
  tags?: string[]
  /**
   * The list of foreign namespaces associated with this namespace.
   */
  foreign?: ForeignNamespace[]
}

/**
 * @deprecated
 */
interface DataDefinitions {
  models: DataModel[]
  // @todo: This should be a map of entities with a key of the entity key.
  entities: DataEntity[]
  // @todo: This should be a map of properties with a key of the property key.
  properties: DataProperty[]
  // @todo: This should be a map of associations with a key of the association key.
  associations: DataAssociation[]
  namespaces: DataNamespace[]
  /**
   * Common for the entire root namespace tags.
   * These are kept separately so the UI can generate autocomplete for tags.
   */
  tags: string[]
  /**
   * The list of foreign namespaces associated with this namespace.
   */
  foreign?: ForeignNamespace[]
}

/**
 * Data definition for a foreign namespace.
 * Each foreign namespace is resolved to a specific version.
 * This makes sure that the local data are always referencing an existing
 * entity as breaking changes should be resolved when upgrading a version.
 * @deprecated
 */
interface ForeignNamespace {
  key: string
  version: string
}

/**
 * Data namespace is a logical description of the hierarchy in the data.
 * @deprecated
 */
export interface IDataNamespace {
  kind: typeof DataNamespaceKind
  /**
   * The key of the namespace.
   */
  key: string
  /**
   * The ordered list of items in this namespace.
   */
  items: IDataItem[]
  /**
   * The data namespace description.
   */
  info: IThing
  /**
   * The list of definitions used in the namespace.
   */
  definitions: IDataDefinitions
}

/**
 * Data item is a reference to an object in the top namespace definitions
 * to the namespace items.
 * @deprecated
 */
export interface IDataItem {
  /**
   * The kind of the item.
   */
  kind: ItemKind
  /**
   * The identifier in the `definitions` array of the namespace.
   */
  key: string
}

/**
 * @deprecated
 */
export class DataItem implements IDataItem {
  kind: ItemKind = DataModelKind

  key = ''

  /**
   * A reference to the top level namespace.
   */
  private root: DataNamespace

  static isDataItem(input: unknown): boolean {
    const typed = input as IDataItem
    if (!input || ![DataModelKind, DataNamespaceKind].includes(typed.kind)) {
      return false
    }
    return true
  }

  static dataNamespace(root: DataNamespace, key: string): DataItem {
    const item = new DataItem(root, {
      kind: DataNamespaceKind,
      key,
    })
    return item
  }

  static dataModel(root: DataNamespace, key: string): DataItem {
    const item = new DataItem(root, {
      kind: DataModelKind,
      key,
    })
    return item
  }

  /**
   * @param root The top-most data namespace.
   * @param input The project item definition used to restore the state.
   */
  constructor(root: DataNamespace, input: string | IDataItem) {
    this.root = root
    let init: IDataItem
    if (typeof input === 'string') {
      init = JSON.parse(input)
    } else if (typeof input === 'object') {
      init = input
    } else {
      throw new Error('Specify the type of the item.')
    }
    this.new(init)
  }

  new(init: IDataItem): void {
    if (!DataItem.isDataItem(init)) {
      throw new Error(`Not a data item.`)
    }
    const { kind, key } = init
    this.kind = kind
    this.key = key
  }

  toJSON(): IDataItem {
    const result: IDataItem = {
      kind: this.kind,
      key: this.key,
    }
    return result
  }

  getItem(): DataNamespace | DataModel | undefined {
    const { root, key, kind } = this
    const { definitions } = root
    if (kind === DataModelKind) {
      return definitions.models.find((i) => i.key === key)
    }
    if (kind === kind) {
      return definitions.namespaces.find((i) => i.key === key)
    }
  }
}

/**
 * Data namespace is a logical description of the hierarchy in the data.
 * @deprecated
 */
export class DataNamespace {
  kind = DataNamespaceKind

  key = ''

  /**
   * The ordered list of items in this namespace.
   */
  items: DataItem[] = []

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

  /**
   * When a namespace is a sub-namespace this is the reference to the
   * root namespace with all definitions.
   */
  root?: DataNamespace
  /**
   * The list of definitions used in the namespace.
   */
  definitions: DataDefinitions

  /**
   * The list of foreign namespaces.
   * This is used to find entities from foreign namespaces.
   * Values here are treated as read only (objects can be frozen).
   *
   * This should be set by the application and the namespace will do
   * nothing to populate this value.
   */
  foreign: DataNamespace[] = []

  /**
   * Creates a new data namespace from a name.
   * @param name The name to set.
   */
  static fromName(name: string, root?: DataNamespace): DataNamespace {
    const ns = new DataNamespace(undefined, root)
    const info = Thing.fromName(name)
    ns.info = info
    return ns
  }

  /**
   * @deprecated
   */
  static definitions(): DataDefinitions {
    return {
      models: [],
      associations: [],
      entities: [],
      properties: [],
      namespaces: [],
      tags: [],
    }
  }

  constructor(input?: string | IDataNamespace, root?: DataNamespace) {
    this.root = root
    this.definitions = {
      models: [],
      associations: [],
      entities: [],
      properties: [],
      namespaces: [],
      tags: [],
    }

    let init: IDataNamespace
    if (typeof input === 'string') {
      init = JSON.parse(input)
    } else if (typeof input === 'object') {
      init = input
      if (!init.kind) {
        init.kind = DataNamespaceKind
      }
    } else {
      init = {
        kind: DataNamespaceKind,
        key: nanoid(),
        definitions: {},
        items: [],
        info: Thing.fromName('').toJSON(),
      }
    }
    this.new(init)
  }

  new(init: IDataNamespace): void {
    if (!init || !init.definitions || !init.items) {
      throw new Error(`Not a data namespace.`)
    }
    const { key = nanoid(), definitions = {}, items, info } = init
    this.key = key
    if (info) {
      this.info = new Thing(info)
    } else {
      this.info = Thing.fromName('')
    }
    if (Array.isArray(items)) {
      this.items = items.map((i) => new DataItem(this, i))
    } else {
      this.items = []
    }
    if (Array.isArray(definitions.associations)) {
      this.definitions.associations = definitions.associations.map((i) => new DataAssociation(this, i))
    } else {
      this.definitions.associations = []
    }
    if (Array.isArray(definitions.properties)) {
      this.definitions.properties = definitions.properties.map((i) => new DataProperty(this, i))
    } else {
      this.definitions.properties = []
    }
    // note, entities must be restored after properties / associations
    if (Array.isArray(definitions.entities)) {
      this.definitions.entities = definitions.entities.map((i) => new DataEntity(this, i))
    } else {
      this.definitions.entities = []
    }
    // must be set after entities.
    if (Array.isArray(definitions.models)) {
      this.definitions.models = definitions.models.map((i) => new DataModel(this, i))
    } else {
      this.definitions.models = []
    }
    if (Array.isArray(definitions.namespaces)) {
      this.definitions.namespaces = definitions.namespaces.map((i) => new DataNamespace(i, this))
    } else {
      this.definitions.namespaces = []
    }
    if (Array.isArray(definitions.tags)) {
      this.definitions.tags = [...definitions.tags]
    } else {
      this.definitions.tags = []
    }
  }

  toJSON(): IDataNamespace {
    const result: IDataNamespace = {
      key: this.key,
      kind: DataNamespaceKind,
      info: this.info.toJSON(),
      items: this.items.map((i) => i.toJSON()),
      definitions: {},
    }
    const { associations, entities, models, namespaces, properties, tags } = this.definitions
    if (Array.isArray(associations) && associations.length) {
      result.definitions.associations = associations.map((i) => i.toJSON())
    }
    if (Array.isArray(entities) && entities.length) {
      result.definitions.entities = entities.map((i) => i.toJSON())
    }
    if (Array.isArray(models) && models.length) {
      result.definitions.models = models.map((i) => i.toJSON())
    }
    if (Array.isArray(namespaces) && namespaces.length) {
      result.definitions.namespaces = namespaces.map((i) => i.toJSON())
    }
    if (Array.isArray(properties) && properties.length) {
      result.definitions.properties = properties.map((i) => i.toJSON())
    }
    if (Array.isArray(tags) && tags.length) {
      result.definitions.tags = [...tags]
    }
    return result
  }

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

  /**
   * @returns The parent namespace of this namespace. It returns `undefined` when this is the root namespace.
   */
  getParentInstance(): DataNamespace | undefined {
    const { root, key } = this
    if (root) {
      return root.findParent(key)
    }
    // we are the root namespace.
    return undefined
  }

  /**
   * Lists namespaces that are in this namespace items.
   */
  listNamespaces(): DataNamespace[] {
    const result: DataNamespace[] = []
    const { items } = this
    const root = this.getRoot()
    const { namespaces } = root.definitions
    items.forEach((i) => {
      if (i.kind !== DataNamespaceKind) {
        return
      }
      const def = namespaces.find((j) => j.key === i.key)
      if (def) {
        result.push(def)
      }
    })
    return result
  }

  /**
   * Lists namespaces that are in this namespace items.
   */
  listDataModels(): DataModel[] {
    const result: DataModel[] = []
    const { items } = this
    const root = this.getRoot()
    const { models } = root.definitions
    items.forEach((i) => {
      if (i.kind !== DataModelKind) {
        return
      }
      const def = models.find((j) => j.key === i.key)
      if (def) {
        result.push(def)
      }
    })
    return result
  }

  /**
   * @returns The root of the namespaces tree. It might be the same object.
   */
  getRoot(): DataNamespace {
    if (this.root) {
      return this.root
    }
    return this as unknown as DataNamespace
  }

  /**
   * Removes self from the parent namespace with all data models.
   * This does noting for the root namespace.
   */
  remove(opts?: DataDomainRemoveOptions): void {
    const { root } = this
    if (!root) {
      throw new Error(`Unable to remove the root namespace this way.`)
    }
    const models = this.listDataModels()
    const children = this.listNamespaces()
    models.forEach((m) => m.remove(opts))
    children.forEach((c) => c.remove(opts))
    const index = root.definitions.namespaces.findIndex((i) => i.key === this.key)
    if (index >= 0) {
      root.definitions.namespaces.splice(index, 1)
    }
  }

  /**
   * Checks if this is the root namespace.
   * @returns True if this is the root namespace.
   */
  isRoot(): boolean {
    return this.root === undefined
  }

  /**
   * Finds a parent namespace for the given namespace.
   * @param key The namespace key to find the parent for.
   * @returns The parent namespace or undefined when the namespace does not exist. It may return the root namespace.
   */
  findParent(key: string): DataNamespace | undefined {
    const { definitions, items = [] } = this
    const rootIndex = items.findIndex((i) => i.key === key)
    if (rootIndex >= 0) {
      return this
    }
    const definition = definitions.namespaces.find((i) => i.items.some((item) => item.key === key))
    if (definition) {
      return definition
    }
    return undefined
  }

  /**
   * Adds a data namespace to the structure.
   * @param init The name of the namespace to add, namespace's schema, or instance.
   * @param parent The optional key of the parent namespace to add the new namespace to.
   */
  addNamespace(init: string | IDataNamespace | DataNamespace, parent?: string): DataNamespace {
    let root: DataNamespace
    if (parent) {
      const rootCandidate = this.findNamespace(parent)
      if (!rootCandidate) {
        throw new Error(`Unable to find the parent namespace ${parent}`)
      }
      root = rootCandidate
    } else {
      root = this
    }
    let definition: DataNamespace
    if (typeof init === 'string') {
      definition = DataNamespace.fromName(init, this.root || this)
    } else if (init instanceof DataNamespace) {
      definition = init
    } else {
      definition = new DataNamespace(init, this.root || this)
    }
    ;(this.root || this).definitions.namespaces.push(definition)
    const item = DataItem.dataNamespace(this.root || this, definition.key)
    if (!Array.isArray(root.items)) {
      root.items = []
    }
    root.items.push(item)
    return definition
  }

  /**
   * Adapts an existing namespace to this namespace.
   * This will remove the namespace from the parent namespace and add it to this one.
   *
   * @param ns The namespace to adapt.
   * @param opts Adapting options.
   */
  adaptNamespace(ns: DataNamespace, 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.items.length) {
        throw new Error(`The index ${opts.index} is not valid.`)
      }
    }
    if (ns === this) {
      throw new Error(`Unable to adapt a namespace that is self.`)
    }
    if (ns.root !== this.root) {
      throw new Error(`The namespace ${ns.key} is not in the same namespace as this data namespace.`)
    }
    if (ns.getParentInstance() === this) {
      throw new Error(`The namespace ${ns.key} is already adapted by this data namespace.`)
    }
    // Check for circular dependency
    let current: DataNamespace | undefined = this
    while (current) {
      if (current === ns) {
        throw new Error(`Unable to adapt namespace ${ns.key} under itself or one of its children.`)
      }
      current = current.getParentInstance()
    }
    const parent = ns.getParentInstance() || this.root
    if (!parent) {
      throw new Error(`The namespace ${ns.key} has no parent.`)
    }
    // it has a parent (above check) so it has the index.
    const index = parent.items.findIndex((i) => i.key === ns.key)
    const [item] = parent.items.splice(index, 1)
    if (opts.index !== undefined) {
      this.items.splice(opts.index, 0, item)
    } else {
      this.items.push(item)
    }
  }

  /**
   * Adapts an existing data model to this namespace.
   * This will remove the data model from the parent namespace and add it to this one.
   * @param model The data model to adapt.
   * @param opts The adapting options.
   */
  adaptDataModel(model: DataModel, 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.items.length) {
        throw new Error(`The index ${opts.index} is not valid.`)
      }
    }
    if (model.root !== this.root) {
      throw new Error(`The data model ${model.key} is not in the same namespace as this data namespace.`)
    }
    const parent = model.getParentInstance()
    if (!parent) {
      throw new Error(`The data model ${model.key} has no parent.`)
    }
    if (parent === this) {
      throw new Error(`The data model ${model.key} is already adapted by this data namespace.`)
    }
    // it has a parent (above check) so it has the index.
    const index = parent.items.findIndex((i) => i.key === model.key)
    const [item] = parent.items.splice(index, 1)
    if (opts.index !== undefined) {
      this.items.splice(opts.index, 0, item)
    } else {
      this.items.push(item)
    }
  }

  /**
   * Finds a namespace in the definitions.
   * @param key The key of the namespace to find.
   * @returns The namespace definition or undefined when not found.
   */
  findNamespace(key: string): DataNamespace | undefined {
    const { definitions } = this.root || this
    return definitions.namespaces.find((i) => i.key === key)
  }

  /**
   * Finds a namespace and calls the `remove()` on it.
   * @param key The key of the namespace to find.
   */
  removeNamespace(key: string, opts?: DataDomainRemoveOptions): void {
    const root = this.root || this
    if (root.key === key) {
      throw new Error(`Unable to remove the root namespace this way.`)
    }
    const { definitions } = root
    const ns = definitions.namespaces.find((i) => i.key === key)
    if (ns) {
      ns.remove(opts)
    }
  }

  /**
   * Adds a data model to a namespace.
   * @param init The name of the data model to add, data model's schema, or its instance.
   * @param parent The optional key of the parent namespace to add the new data model to.
   */
  addDataModel(init: string | IDataModel | DataModel, parent?: string): DataModel {
    let root: DataNamespace
    if (parent) {
      const rootCandidate = this.findNamespace(parent)
      if (!rootCandidate) {
        throw new Error(`Unable to find the parent namespace ${parent}`)
      }
      root = rootCandidate
    } else {
      root = this
    }
    let definition: DataModel
    if (typeof init === 'string') {
      definition = DataModel.fromName(this.root || this, init)
    } else if (init instanceof DataModel) {
      definition = init
    } else {
      definition = new DataModel(this.root || this, init)
    }
    ;(this.root || this).definitions.models.push(definition)
    const item = DataItem.dataModel(this.root || this, definition.key)
    if (!Array.isArray(root.items)) {
      root.items = []
    }
    root.items.push(item)
    return definition
  }

  /**
   * Finds a data model in the definitions.
   * @param key The key of the data model to find.
   * @returns The data model definition or undefined when not found.
   */
  findDataModel(key: string): DataModel | undefined {
    const { definitions } = this.root || this
    return definitions.models.find((i) => i.key === key)
  }

  /**
   * 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.
   * @param opts The options for the removal.
   * @throws Error when the kind is not known.
   */
  removeObject(key: string, kind: string, opts?: DataDomainRemoveOptions): void {
    switch (kind) {
      case DataNamespaceKind:
        this.findNamespace(key)?.remove(opts)
        break
      case DataModelKind:
        this.findDataModel(key)?.remove(opts)
        break
      case DataEntityKind:
        this.findEntity(key)?.remove(opts)
        break
      case DataPropertyKind:
        this.findProperty(key)?.remove()
        break
      case DataAssociationKind:
        this.findAssociation(key)?.remove()
        break
      default:
        throw new Error(`Unknown kind ${kind} for the object ${key}.`)
    }
  }

  /**
   * Finds a data model and calls the `remove()` on it.
   * @param key The key of the data model to find.
   */
  removeDataModel(key: string, opts?: DataDomainRemoveOptions): void {
    const { definitions } = this.root || this
    const model = definitions.models.find((i) => i.key === key)
    if (model) {
      model.remove(opts)
    }
  }

  /**
   * @returns The graph of associations where keys are the source
   * entities and the value is the list of all target entities.
   */
  associationGraph(): Record<string, string[]> {
    const graph: Record<string, string[]> = {}
    const { definitions } = this.root || this
    const { associations, entities } = definitions
    for (const assoc of associations) {
      if (!assoc.targets.length) {
        continue
      }
      const srcEntity = entities.find((i) => i.associations.some((a) => a === assoc))
      if (!srcEntity) {
        continue
      }
      if (!graph[srcEntity.key]) {
        graph[srcEntity.key] = []
      }
      const targetIds = assoc.targets.map((i) => i.key)
      graph[srcEntity.key].splice(0, 0, ...targetIds)
    }
    return graph
  }

  /**
   * Prints out all associations from one entity to another through all entities that may be in between.
   *
   * @param from The key of the from entity
   * @param to The key of the target entity
   * @param g The graph generated with `associationGraph()`
   * @param path The current list of entity ids. Do not set this, it is for the recursive processing of the graph.
   * @param visited The list of visited paths to avoid cycles.
   *  Do not set this, it is for the recursive processing of the graph.
   */
  *associationPath(
    from: string,
    to: string,
    g: Record<string, string[]>,
    path: string[] = [],
    visited = new Set<string>()
  ): Generator<string[]> {
    if (from === to) {
      yield path.concat(to)
      return
    }
    if (visited.has(from)) {
      // it's a cycle
      return
    }
    if (g[from]) {
      visited.add(from)
      path.push(from)

      for (const neighbor of g[from]) {
        yield* this.associationPath(neighbor, to, g, path, visited)
      }

      visited.delete(from)
      path.pop()
    }
  }

  /**
   * Scans all associations for foreign namespaces and returns
   * the list of all namespaces used in the association graph.
   * This will never add this namespace's key.
   *
   * This function should be used to read keys for all related
   * namespaces through associations.
   *
   * @returns All keys of foreign namespaces.
   */
  computeForeignNamespaceKeys(): string[] {
    const result: string[] = []
    const { associations = [] } = this.definitions
    associations.forEach((association) => {
      const { targets = [] } = association
      targets.forEach((target) => {
        const { namespace } = target
        if (!namespace || namespace === this.key) {
          return
        }
        result.push(namespace)
      })
    })
    return result
  }

  /**
   * Finds an entity in this namespace.
   * @param key The key of the entity to find.
   */
  findEntity(key: string): DataEntity | undefined {
    const { definitions } = this.root || this
    return definitions.entities.find((i) => i.key === key)
  }

  /**
   * Finds a property by its key.
   * @param key The key of the property to find
   * @returns The property or undefined if not found.
   */
  findProperty(key: string): DataProperty | undefined {
    const { definitions } = this.root || this
    return definitions.properties.find((i) => i.key === key)
  }

  /**
   * Finds an association by its key.
   * @param key The key of the property to find
   * @returns The property or undefined if not found.
   */
  findAssociation(key: string): DataAssociation | undefined {
    const { definitions } = this.root || this
    return definitions.associations.find((i) => i.key === key)
  }

  /**
   * Searches for entities for association targets.
   * This is a helper function to discover entities in the current and foreign namespaces.
   *
   * @param targets The list of targets
   * @returns A list of entities. An `undefined` is put at the index where an entity cannot be found
   */
  findAssociatedEntities(targets: AssociationTarget[]): (DataEntity | undefined)[] {
    const result: (DataEntity | undefined)[] = []
    targets.forEach((target) => {
      let ns: DataNamespace | undefined
      if (target.namespace) {
        ns = this.foreign.find((i) => i.key === target.namespace)
      } else {
        ns = this
      }
      if (!ns) {
        result.push(undefined)
        return
      }
      const entity = ns.findEntity(target.key)
      if (entity) {
        result.push(entity)
      } else {
        result.push(undefined)
      }
    })
    return result
  }

  /**
   * Finds an associated entity in the current or foreign namespace.
   * This is a helper function to discover entities in the current and foreign namespaces.
   *
   * @param key The key of the entity to find.
   * @param namespace The optional namespace to search in.
   * If not set, the current namespace is used.
   * This is used to find entities in foreign namespaces.
   * @returns The entity or undefined if not found.
   */
  findAssociatedEntity(key: string, namespace?: string): DataEntity | undefined {
    let ns: DataNamespace | undefined
    if (namespace) {
      ns = this.foreign.find((i) => i.key === namespace)
    } else {
      ns = this
    }
    if (!ns) {
      return undefined
    }
    return ns.findEntity(key)
  }

  addForeign(ns: DataNamespace): void {
    const exists = this.foreign.some((i) => i.key === ns.key)
    if (exists) {
      return
    }
    this.foreign.push(ns)
  }

  removeForeign(ns: DataNamespace): void {
    const index = this.foreign.findIndex((i) => i === ns)
    if (index >= 0) {
      this.foreign.splice(index, 1)
    }
  }

  hasForeignNamespace(key: string): boolean {
    return this.foreign.some((i) => i.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
    }
    return parent.isChildOf(key)
  }
}
