import { AmfShapeGenerator } from '../../amf/AmfShapeGenerator.js'
import { IApiAssociationShape, IApiPropertyShape } from '../../amf/definitions/Shapes.js'
import { nanoid } from 'nanoid'
import { IThing, Thing } from '../../models/Thing.js'
import { DataEntity, IDataEntity } from './DataEntity.js'
import { DataNamespace } from './DataNamespace.js'
import { type FieldValidationMessage, ValidationError } from '../../exceptions/validation_error.js'
import { DataAssociationKind } from '../../models/kinds.js'
import type { AssociationBinding, AssociationBindings, AssociationWebBindings } from '../Bindings.js'
import { DataAttributeAttributes, type DataAttributeAttribute } from '../DataFormat.js'

/**
 * Describes association target of an entity
 * @deprecated
 */
export interface AssociationTarget {
  /**
   * The key of the associated entity.
   */
  key: string
  /**
   * The key of the namespace to look for entities.
   * By default it is the current (root) namespace. When this is set the program
   * will look into `foreign` of the root namespace for the definition
   * of the entity.
   */
  namespace?: string
}

/**
 * Describes an association between entities. An association is another property of an entity.
 * The `name` is the name of the property and the value is the associated target or targets.
 *
 * An association can have multiple targets to allow describing the model as `allOf`, `anyOf` and `oneOf` schemas.
 * When the association has only one target, then generated schema cannot have union types.
 *
 * Depending on the schema translation an association can be embedded as a sub-object in the generated schema
 * or can be references via a primary key.
 * @deprecated
 */
export interface IDataAssociation {
  kind: typeof DataAssociationKind
  /**
   * The key of the association.
   */
  key: string
  /**
   * The data association description.
   */
  info: IThing
  /**
   * Wether the data association is required.
   */
  required?: boolean
  /**
   * Whether the data association allows multiple items.
   */
  multiple?: boolean
  /**
   * The list of keys associated with the entity through this association.
   * An association without a target is considered invalid and discarded when processing the values.
   * When multiple associations are set then we are dealing with an union.
   */
  targets?: AssociationTarget[]
  /**
   * The schema allowing to translate the model into a specific format (like JSON, RAML, XML, etc.)
   *
   * When this is defined then it is used as the schema. When this is not defined it uses
   * referenced entities schemas. Note, changes in the referenced entities may not be propagated
   * to schemas altered by the user.
   */
  schema?: IApiAssociationShape
  /**
   * The list of bindings for this property.
   *
   * A binding defines a translation from a data model to a specific format.
   * For example allows to define properties required to generate AMF shape and therefore RAML/OAS shapes for web APIs
   * or a protocol buffer schema.
   */
  bindings?: AssociationBinding[]
}

/**
 * Describes an association between entities. An association is a relationship defined on an entity
 * that links it to one or more other entities. The association has a `name` which is used as the
 * property name in the context of the parent entity, and it connects to a set of `targets`
 * representing the associated entities.
 *
 * An association can have multiple targets to allow describing the model as `allOf`, `anyOf`, or `oneOf`
 * schemas. When the association has only one target, then the generated schema cannot have union types.
 *
 * Depending on the schema translation, an association can be embedded as a sub-object in the generated
 * schema, or it can be referenced via a primary key.
 *
 * ## Key Concepts
 *
 * - **Targets**: An association's `targets` specify the entities that this association points to.
 *     Each target is defined by:
 *     -   `key`: The key of the target entity.
 *     -   `namespace` (optional): The key of the namespace where the target entity is defined.
 *         If not specified, it defaults to the current namespace.
 *
 * -   **Cardinality**:
 *     -   `multiple` (boolean): Indicates whether the association allows multiple target entities.
 *         If `true`, it's a to-many relationship; otherwise, it's a to-one relationship.
 *
 * -   **Schema**:
 *     -   `schema` (optional): A custom schema for the association, allowing manual definition of the
 *         association's structure in specific formats (e.g., JSON, RAML, XML).
 *         When provided, this schema overrides the automatically generated schema based on the
 *         target entities.
 *         -   **Important**: If a custom schema is defined and changes are made to the referenced
 *             entities, those changes **will not** be automatically propagated to the custom schema.
 *             This means the custom schema may become outdated and no longer accurately reflect the
 *             structure of the target entities.
 *
 * - **Hidden**:
 *  - `hidden`: (boolean) Defines if this association is a part of the schema or not.
 * @deprecated
 */
export class DataAssociation {
  kind = DataAssociationKind

  key = ''

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

  /**
   * Wether the data association is required.
   */
  required?: boolean

  /**
   * Whether the data association allows multiple items.
   */
  multiple?: boolean

  /**
   * The list of keys associated with the entity through this association.
   * An association without a target is considered invalid and discarded when processing the values.
   * When multiple associations are set then we are dealing with an union.
   */
  targets: AssociationTarget[] = []

  /**
   * The schema allowing to translate the model into a specific format (like JSON, RAML, XML, etc.)
   *
   * When this is defined then it is used as the schema. When this is not defined it uses
   * referenced entities schemas. Note, changes in the referenced entities may not be propagated
   * to schemas altered by the user.
   */
  schema?: IApiAssociationShape

  /**
   * The list of bindings for this property.
   *
   * A binding defines a translation from a data model to a specific format.
   * For example allows to define properties required to generate AMF shape and therefore RAML/OAS shapes for web APIs
   * or a protocol buffer schema.
   */
  bindings: AssociationBinding[] = []

  /**
   * Returns true when the association has 0 or 1 targets.
   */
  get isSingle(): boolean {
    return this.targets.length < 2
  }

  /**
   * Creates an instance of DataAssociation from a target entity key.
   * Note, this entity will have no name. The default name is used instead.
   *
   * @param root The namespace root.
   * @param target The target entity key.
   * @param namespace The optional key of the target association when different to the current one.
   * @returns A new `DataAssociation` instance.
   */
  static fromTarget(root: DataNamespace, target: string, namespace?: string): DataAssociation {
    const assoc = new DataAssociation(root)
    const info: AssociationTarget = {
      key: target,
    }
    if (namespace) {
      info.namespace = namespace
    }
    assoc.targets = [info]
    return assoc
  }

  /**
   * Creates an instance of DataAssociation from a name, without defining a target.
   *
   * @param root The namespace root.
   * @param name The name of the association.
   * @returns A new `DataAssociation` instance.
   */
  static fromName(root: DataNamespace, name: string): DataAssociation {
    const assoc = new DataAssociation(root)
    assoc.info = Thing.fromName(name)
    return assoc
  }

  /**
   * @param input The data association definition to restore.
   */
  constructor(
    public root: DataNamespace,
    input?: string | IDataAssociation
  ) {
    let init: IDataAssociation
    if (typeof input === 'string') {
      try {
        init = JSON.parse(input)
      } catch {
        throw new ValidationError([
          {
            field: '',
            message: 'Unable to parse the data association input as JSON.',
            rule: 'object_required',
          },
        ])
      }
    } else if (typeof input === 'object') {
      init = input
    } else {
      init = {
        kind: DataAssociationKind,
        key: nanoid(),
        info: Thing.fromName('Unnamed association').toJSON(),
      }
    }
    this.new(init)
  }

  /**
   * Initializes the association using provided data.
   *
   * @param init - The data association definition to restore.
   */
  new(init: IDataAssociation): this {
    DataAssociation.validate(init)
    const { info, key = nanoid(), kind = DataAssociationKind, schema, multiple, required, targets, bindings } = init
    this.kind = kind
    this.key = key
    if (info) {
      this.info = new Thing(info)
    } else {
      this.info = Thing.fromName('')
    }
    if (schema) {
      this.schema = structuredClone(schema)
    } else {
      this.schema = undefined
    }
    if (typeof multiple === 'boolean') {
      this.multiple = multiple
    } else {
      this.multiple = undefined
    }
    if (typeof required === 'boolean') {
      this.required = required
    } else {
      this.required = undefined
    }
    if (Array.isArray(targets)) {
      this.targets = targets.map((i) => ({ ...i }))
    } else {
      this.targets = []
    }
    if (Array.isArray(bindings)) {
      this.bindings = bindings.map((i) => structuredClone(i))
    } else {
      this.bindings = []
    }
    return this
  }

  /**
   * Validates if the input object is a valid `DataAssociation` definition.
   * Throws a `ValidationError` if the input is invalid.
   *
   * @param input The object to validate.
   * @throws {ValidationError} When the validation fails.
   * @deprecated
   */
  static validate(input: unknown): void {
    const typed = input as IDataAssociation
    const messages: FieldValidationMessage[] = []
    if (!typed) {
      messages.push({
        field: '',
        message: 'The input is required. None given',
        rule: 'required',
      })
      throw new ValidationError(messages)
    }
    if (typed.kind !== DataAssociationKind) {
      messages.push({
        field: 'kind',
        message: `The kind property must be ${DataAssociationKind}`,
        rule: 'invalid',
      })
    }
    if (typed.info && typeof typed.info !== 'object') {
      messages.push({
        field: 'info',
        message: 'The info property must be an object',
        rule: 'invalid',
      })
    }
    if (messages.length) {
      throw new ValidationError(messages)
    }
  }

  /**
   * Generates an object representing the association, suitable for serialization.
   *
   * @returns A plain object representation of the `DataAssociation`.
   */
  toJSON(): IDataAssociation {
    const result: IDataAssociation = {
      kind: DataAssociationKind,
      key: this.key,
      info: this.info.toJSON(),
    }
    if (this.schema) {
      result.schema = structuredClone(this.schema)
    }
    if (typeof this.multiple === 'boolean') {
      result.multiple = this.multiple
    }
    if (typeof this.required === 'boolean') {
      result.required = this.required
    }
    if (Array.isArray(this.targets) && this.targets.length) {
      result.targets = this.targets.map((i) => ({ ...i }))
    }
    if (Array.isArray(this.bindings) && this.bindings.length) {
      result.bindings = this.bindings.map((i) => structuredClone(i))
    }
    return result
  }

  /**
   * Retrieves the list of target entities.
   *
   * @returns An array of `DataEntity` instances representing the targets of this association.
   */
  getTargets(): DataEntity[] {
    const { targets } = this
    return this.root.findAssociatedEntities(targets).filter((i) => !!i) as DataEntity[]
  }

  /**
   * Removes self from the parent entity and the namespace definition.
   */
  remove(): void {
    const { root } = this
    const entity = root.definitions.entities.find((i) => i.associations.some((j) => j.key === this.key))
    if (entity) {
      const assocIndex = entity.associations.findIndex((i) => i.key === this.key)
      entity.associations.splice(assocIndex, 1)
    }
    const defIndex = this.root.definitions.associations.findIndex((i) => i.key === this.key)
    if (defIndex >= 0) {
      this.root.definitions.associations.splice(defIndex, 1)
    }
  }

  /**
   * Adds a target entity to the association.
   * If the entity comes from another namespace then it is set as a foreign entity.
   *
   * @param entity - The `DataEntity` instance to add.
   */
  addTarget(entity: DataEntity): void

  /**
   * Adds a target entity to the association.
   *
   * @param entity - The `IDataEntity` definition or just its key.
   * @param namespace - The key of the foreign namespace this entity belongs to.
   *      Do not set this value for local entities.
   */
  addTarget(entity: IDataEntity | string, namespace?: string): void

  /**
   * @param init The key of an entity, its instance, or schema.
   * @param namespace - The key of the foreign namespace this entity belongs to.
   *      Do not set this value for local entities.
   */
  addTarget(init: string | DataEntity | IDataEntity, namespace?: string): void {
    let key: string
    if (typeof init === 'string') {
      key = init
    } else {
      key = init.key
      const typed = init as IDataEntity & { root?: DataNamespace }
      if (!namespace && typed.root && typed.root !== this.root) {
        namespace = typed.root.key
      }
    }
    if (this.targets.some((i) => i.key === key)) {
      const message = `Target ${key} already exists.`
      throw new ValidationError(
        [
          {
            field: 'targets',
            message,
            rule: 'unique',
          },
        ],
        { message }
      )
    }
    if (namespace && namespace !== this.root.key) {
      const foreignNamespace = this.root.foreign.find((ns) => ns.key === namespace)
      if (!foreignNamespace) {
        throw new Error(`Foreign namespace "${namespace}" does not exist.`)
      }
    }
    const info: AssociationTarget = {
      key,
    }
    if (namespace) {
      info.namespace = namespace
    }
    this.targets.push(info)
  }

  /**
   * Removes a target entity from the targets list.
   *
   * @param init The key of an entity, its instance, or schema.
   */
  removeTarget(init: string | DataEntity | IDataEntity): void {
    let key: string
    if (typeof init === 'string') {
      key = init
    } else {
      key = init.key
    }
    const index = this.targets.findIndex((i) => i.key === key)
    if (index >= 0) {
      this.targets.splice(index, 1)
    }
  }

  /**
   * Creates a Property Shape of AMF.
   * The property itself is auto-generated. If the `schema` is defined then it is used
   * as the `range` of the property. Otherwise basic shape is generated for the range.
   *
   * This is a preferred way of reading the AMF shape as this synchronizes changed
   * data properties with the shape definition.
   *
   * @returns AMF property shape definition.
   */
  toApiShape(): IApiPropertyShape {
    const serializer = new AmfShapeGenerator()
    return serializer.associationProperty(this)
  }

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

  /**
   * Retrieves the parent entity of this association.
   *
   * @returns The `DataEntity` instance that contains this association.
   */
  getParentInstance(): DataEntity {
    // this is forced as an association is only created when an entity is created
    // so it has to be defined.
    return this.root.definitions.entities.find((i) => i.associations.some((a) => a.key === this.key)) as DataEntity
  }

  /**
   * Creates the `schema` object if missing and returns it.
   * @returns The schema object.
   */
  ensureSchema(): IApiAssociationShape {
    if (!this.schema) {
      this.schema = {}
    }
    return this.schema
  }

  /**
   * Checks whether the passed value is one of the supported data property attributes.
   * @param value The value to test
   * @returns True when the passed value is one of the supported data property attributes.
   * @deprecated
   */
  static isValidAttribute(value: unknown): value is DataAttributeAttribute {
    if (typeof value !== 'string') {
      return false
    }
    return DataAttributeAttributes.includes(value as DataAttributeAttribute)
  }

  /**
   * Creates if not existing and returns web bindings definition.
   * @returns The web binding definition
   */
  getWebBinding(): AssociationWebBindings {
    let object = this.bindings.find((i) => i.type === 'web') as AssociationBinding | undefined
    if (!object) {
      object = { type: 'web', schema: {} } as AssociationBinding
      this.bindings.push(object)
    }
    return object.schema as AssociationWebBindings
  }

  /**
   * Returns the schema value of the binding, if any was created.
   * @param type The type of the binding to read.
   * @returns The binding schema, if any
   */
  readBinding(type: 'web' | 'protobuf'): AssociationBindings | undefined {
    const item = this.bindings.find((i) => i.type === type) as AssociationBinding
    return item?.schema
  }

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