import type { DataDomain } from './DataDomain.js'
import { DomainAssociationKind } from '../models/kinds.js'
import { DomainElement, type DomainElementSchema } from './DomainElement.js'
import { nanoid } from '../nanoid.js'
import { type IThing, Thing } from '../models/Thing.js'
import { observed, retargetChange, toRaw } from '../decorators/observed.js'
import type { DomainEntity } from './DomainEntity.js'
import type { IApiAssociationShape, IApiPropertyShape } from '../amf/definitions/Shapes.js'
import type { AssociationBinding, AssociationBindings, AssociationWebBindings } from './Bindings.js'
import { DomainAttributeAttribute, DomainAttributeAttributes } from './DataFormat.js'
import type { AssociationTarget, DomainGraphEdge } from './types.js'
import { ShapeGenerator } from './amf/ShapeGenerator.js'
import { DataSemantics, isAssociationSemantic, type SemanticType, type AppliedDataSemantic } from './Semantics.js'

/**
 * Defines the behavior when a parent entity in an association is deleted.
 *
 * - `restrict`: Prevents the deletion of a parent entity if it has any associated child entities.
 *               The generated API should return a clear and specific error message (e.g., 409 Conflict).
 *   - _Example_: Do not allow a Department to be deleted if it still has Employees.
 * - `cascade`: Automatically deletes all associated child entities when the parent entity is deleted.
 *   - _Example_: Deleting a User will also delete all their associated Posts and Comments.
 * - `setNull`: Sets the foreign key of the associated child entities to NULL. This is only valid if the
 *   association property on the child entity is nullable.
 *   - _Example_: When a `Project` is deleted, the `project_id` on associated `Tasks` is set to NULL,
 *   making them unassigned but not deleting them.
 * - `doNothing`: No action is taken on the associated child entities when the parent entity is deleted.
 *   - _Example_: Deleting a `Category` does not affect associated `Products`, which remain in the database
 *               but may become orphaned.
 *               This is useful when the association is optional or when child entities should not be deleted
 *               or modified upon the deletion of a parent entity.
 */
export type OnDeleteRule = 'restrict' | 'cascade' | 'setNull' | 'doNothing'

export interface DomainAssociationSchema extends DomainElementSchema {
  kind: typeof DomainAssociationKind
  /**
   * The data association description.
   */
  info: IThing
  /**
   * Wether the data association is required.
   */
  required?: boolean
  /**
   * Whether the data association allows multiple items.
   */
  multiple?: boolean
  /**
   * 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[]
  /**
   * 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 semantics applied to this association.
   * This is a list of applied semantics that can be used to
   * describe the association in more detail.
   */
  semantics?: AppliedDataSemantic[]
  /**
   * Defines the behavior when a parent entity in an association is deleted.
   */
  onDelete?: OnDeleteRule
  /**
   * Whether the association is read-only.
   */
  readOnly?: boolean
}

/**
 * Represents an association between entities in a data domain.
 *
 * A `DomainAssociation` defines a relationship between a
 * `DomainEntity` and one or more other entities. It specifies
 * how entities are connected and can represent various types
 * of relationships, such as one-to-one, one-to-many, or
 * many-to-many.
 *
 * **Key Features:**
 *
 * -   **Target Entities:** Specifies the entities that are
 *     the target of the association.
 * -   **Cardinality:** Defines whether the association is
 *     single-valued or multi-valued (e.g., one-to-one vs.
 *     one-to-many).
 * -   **Required Status:** Indicates whether the association
 *     is mandatory or optional.
 * -   **Schema Customization:** Allows defining a custom
 *     schema for the association, overriding the default
 *     schema derived from the target entities.
 * -   **Bindings:** Supports defining how the association is
 *     represented in different formats (e.g., web APIs,
 *     protocol buffers).
 * -   **Foreign Namespaces:** Can target entities in other
 *     namespaces, enabling cross-namespace relationships.
 *
 * **Usage:**
 *
 * The preferred way to create a `DomainAssociation` is
 * through the `DomainEntity.addAssociation()` method.
 *
 * **Example:**
 *
 * ```typescript
 * const dataDomain = new DataDomain();
 * const userModel = dataDomain.addModel({ key: 'userModel' });
 * const userEntity = userModel.addEntity({ key: 'user' });
 * const addressEntity = userModel.addEntity({ key: 'address' });
 * // Add an association from User to Address
 * const addressAssociation = userEntity.addAssociation();
 * addressAssociation.addTarget(addressEntity);
 * ```
 */
export class DomainAssociation extends DomainElement {
  /**
   * The kind of the domain element.
   */
  override kind: typeof DomainAssociationKind

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

  /**
   * Wether the data association is required.
   */
  @observed() accessor required: boolean | undefined

  /**
   * Whether the data association allows multiple items.
   */
  @observed() accessor multiple: boolean | undefined

  /**
   * The schema allowing to translate the model into a
   * specific format (like JSON, RAML, XML, etc.)
   */
  @observed({ deep: true }) accessor schema: IApiAssociationShape | undefined

  /**
   * 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.
   */
  @observed({ deep: true }) accessor bindings: AssociationBinding[]

  /**
   * The key of the parent entity.
   */
  protected parent: string

  /**
   * 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.
   */
  @observed({ deep: true }) accessor targets: AssociationTarget[]

  /**
   * Semantics applied to this association.
   */
  @observed({ deep: true }) accessor semantics: AppliedDataSemantic[] = []

  /**
   * Defines the behavior when a parent entity in an association is deleted.
   */
  @observed() accessor onDelete: OnDeleteRule | undefined

  /**
   * Whether the association is read-only.
   */
  @observed() accessor readOnly: boolean | undefined

  /**
   * Creates a full data association schema with defaults.
   *
   * @param input The partial data association schema.
   * @returns The data association schema.
   */
  static createSchema(input: Partial<DomainAssociationSchema> = {}): DomainAssociationSchema {
    const { key = nanoid(), semantics = [] } = input
    const info = Thing.fromJSON(input.info, { name: 'new_association' }).toJSON()
    const result: DomainAssociationSchema = {
      kind: DomainAssociationKind,
      key,
      info,
    }
    if (Array.isArray(semantics)) {
      result.semantics = [...semantics]
    }
    if (input.onDelete) {
      result.onDelete = input.onDelete
    }
    if (typeof input.readOnly === 'boolean') {
      result.readOnly = input.readOnly
    }
    if (input.schema) {
      result.schema = structuredClone(input.schema)
    }
    if (typeof input.multiple === 'boolean') {
      result.multiple = input.multiple
    }
    if (typeof input.required === 'boolean') {
      result.required = input.required
    }
    if (Array.isArray(input.bindings)) {
      result.bindings = input.bindings.map((i) => structuredClone(i))
    }
    if (Array.isArray(input.targets)) {
      result.targets = input.targets.map((i) => ({ ...i }))
    }
    return result
  }

  /**
   * Creates a new data association instance.
   *
   * You probably want to use the `DomainEntity.addAssociation()` method instead.
   *
   * @param root A reference to the root DataDomain instance.
   * @param parent The key of the parent entity.
   * @param input The partial data association schema.
   */
  constructor(root: DataDomain, parent: string, input?: Partial<DomainAssociationSchema>) {
    const init = DomainAssociation.createSchema(input)
    super(root, init.key)
    this.parent = parent
    this.kind = DomainAssociationKind
    this.info = new Thing(init.info)
    if (init.schema) {
      this.schema = structuredClone(init.schema)
    }
    if (typeof init.multiple === 'boolean') {
      this.multiple = init.multiple
    }
    if (typeof init.required === 'boolean') {
      this.required = init.required
    }
    if (Array.isArray(init.bindings)) {
      this.bindings = init.bindings.map((i) => structuredClone(i))
    } else {
      this.bindings = []
    }
    if (Array.isArray(init.targets)) {
      this.targets = init.targets.map((i) => ({ ...i }))
    } else {
      this.targets = []
    }
    if (Array.isArray(init.semantics)) {
      this.semantics = init.semantics.map((item) => structuredClone(item))
    } else {
      this.semantics = []
    }
    if (init.onDelete) {
      this.onDelete = init.onDelete
    }
    if (typeof init.readOnly === 'boolean') {
      this.readOnly = init.readOnly
    }
  }

  /**
   * Generates a JSON representation of the association.
   *
   * @returns A plain object representation of the `DomainAssociation`.
   */
  toJSON(): DomainAssociationSchema {
    const result: DomainAssociationSchema = {
      kind: this.kind,
      key: this.key,
      info: this.info.toJSON(),
    }
    if (this.schema) {
      result.schema = structuredClone(toRaw(this, this.schema))
    }
    if (typeof this.multiple === 'boolean') {
      result.multiple = this.multiple
    }
    if (typeof this.required === 'boolean') {
      result.required = this.required
    }
    if (Array.isArray(this.bindings) && this.bindings.length) {
      result.bindings = toRaw(this, this.bindings)?.map((i) => structuredClone(i))
    }
    if (Array.isArray(this.targets) && this.targets.length) {
      result.targets = toRaw(this, this.targets)?.map((i) => ({ ...i }))
    }
    if (Array.isArray(this.semantics) && this.semantics.length) {
      result.semantics = toRaw(this, this.semantics)?.map((i) => structuredClone(i))
    }
    if (this.onDelete) {
      result.onDelete = this.onDelete
    }
    if (typeof this.readOnly === 'boolean' && this.readOnly) {
      result.readOnly = this.readOnly
    }
    return result
  }

  /**
   * Returns a parent data entity where this data property exist.
   *
   * @returns The parent `DomainEntity` or undefined if not found.
   */
  getParentInstance(): DomainEntity | undefined {
    return this.root.graph.node(this.parent) as DomainEntity | undefined
  }

  /**
   * Removes this property from the parent entity.
   *
   * This method removes the association from the parent
   * entity's list of associations.
   *
   * @example
   * ```typescript
   * association.remove();
   * ```
   */
  remove(): void {
    const parent = this.getParentInstance()
    parent?.removeAssociation(this.key)
  }

  /**
   * Creates the `schema` object if missing and returns it.
   *
   * @returns The schema object.
   * @example
   * ```typescript
   * const schema = association.ensureSchema();
   * ```
   */
  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.
   */
  static isValidAttribute(value: unknown): value is DomainAttributeAttribute {
    if (typeof value !== 'string') {
      return false
    }
    return DomainAttributeAttributes.includes(value as DomainAttributeAttribute)
  }

  /**
   * Creates if not existing and returns web bindings
   * definition.
   *
   * @returns The web binding definition
   * @example
   * ```typescript
   * const webBindings = association.getWebBinding();
   * webBindings.hidden = true;
   * ```
   */
  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)
      this.domain.notifyChange()
    }
    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
   * @example
   * ```typescript
   * const webBindings = association.readBinding('web');
   * if (webBindings) {
   *   // ...
   * }
   * ```
   */
  readBinding(type: 'web' | 'protobuf'): AssociationBindings | undefined {
    const item = this.bindings.find((i) => i.type === type) as AssociationBinding
    return item?.schema || undefined
  }

  /**
   * 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 `DomainEntity` instance to add.
   * @example
   * ```typescript
   * association.addTarget(addressEntity);
   * ```
   */
  addTarget(entity: DomainEntity): void

  /**
   * Adds a target entity to the association.
   *
   * @param entity - The `DomainEntity` 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.
   * @example
   * ```typescript
   * association.addTarget('address', 'foreignNamespace');
   * ```
   */
  addTarget(entity: string, namespace?: string): void

  /**
   * Adds a target entity to the association.
   *
   * @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 | DomainEntity, namespace?: string): void {
    let key: string
    if (typeof init === 'string') {
      key = init
    } else {
      key = init.key
      if (init.domain.key !== this.root.key) {
        namespace = init.domain.key
      }
    }
    if (this.targets.some((i) => i.key === key && i.domain === namespace)) {
      const message = `Target ${key} already exists.`
      throw new Error(message)
    }
    if (namespace && !this.root.dependencies.has(namespace)) {
      throw new Error(`Foreign namespace "${namespace}" does not exist.`)
    }
    const info: AssociationTarget = {
      key,
    }
    if (namespace) {
      info.domain = namespace
    }
    this.targets.push(info)
    const targetKey = namespace ? `${namespace}:${key}` : key
    const label: DomainGraphEdge = {
      type: 'association',
    }
    if (namespace) {
      label.domain = namespace
      label.foreign = true
    }
    // We need to associate the target with the association
    // in the graph. This way, when looking for associations
    // on a target entities we can easily locate the
    // association.
    this.root.graph.setEdge(this.key, targetKey, label)
    // this.root.notifyChange()
  }

  /**
   * Removes a target entity from the targets list.
   *
   * @param init The key of an entity, its instance, or
   *   schema.
   * @example
   * ```typescript
   * association.removeTarget(addressEntity);
   * ```
   */
  removeTarget(init: string | DomainEntity): 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)
      // this.root.notifyChange()
    }
    this.root.graph.removeEdge(this.key, key)
  }

  /**
   * Lists all target entities of this association.
   *
   * @returns A generator that yields each target
   *   `DomainEntity`.
   * @example
   * ```typescript
   * for (const target of association.listTargets()) {
   *   console.log(target.key);
   * }
   * ```
   */
  *listTargets(): Generator<DomainEntity> {
    for (const target of this.targets) {
      const { key, domain } = target
      const targetKey = domain ? `${domain}:${key}` : key
      const entity = this.root.graph.node(targetKey) as DomainEntity | undefined
      if (entity) {
        yield entity
      }
    }
  }

  /**
   * 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 ShapeGenerator()
    return serializer.associationProperty(this)
  }

  /**
   * Adds or updates a semantic to the association.
   * @param semantic The semantic to add to the association.
   * @throws Error if the semantic is not an association semantic.
   */
  addSemantic(semantic: AppliedDataSemantic): void {
    const sem = DataSemantics[semantic.id]
    if (!isAssociationSemantic(sem)) {
      throw new Error(`Invalid semantic type: ${semantic.id}. Expected an association semantic.`)
    }
    const index = this.semantics.findIndex((s) => s.id === semantic.id)
    if (index >= 0) {
      this.semantics[index] = semantic
    } else {
      this.semantics.push(semantic)
    }
    this.domain.notifyChange()
  }

  /**
   * Removes a semantic from the association.
   * @param semanticId The ID of the semantic to remove.
   */
  removeSemantic(semanticId: SemanticType): void {
    const index = this.semantics.findIndex((s) => s.id === semanticId)
    if (index >= 0) {
      this.semantics.splice(index, 1)
      this.domain.notifyChange()
    }
  }

  /**
   * Checks if the association has a specific semantic.
   * @param semanticId The ID of the semantic to check.
   * @returns True if the semantic is present, false otherwise.
   */
  hasSemantic(semanticId: SemanticType): boolean {
    return this.semantics.some((s) => s.id === semanticId)
  }

  /**
   * Creates a duplicate of this association.
   * It places the duplicate on the parent entity right after the original association.
   *
   * @returns A new `DomainAssociation` instance that is a duplicate of this one.
   */
  duplicate(): DomainAssociation {
    const parent = this.getParentInstance()
    if (!parent) {
      throw new Error(`Cannot duplicate association ${this.key} as it has no parent entity.`)
    }
    const originalIndex = parent.fields.findIndex((f) => f.key === this.key)
    if (originalIndex === -1) {
      throw new Error(`Cannot duplicate association ${this.key} as it does not exist on the parent entity fields list.`)
    }
    const baseName = this.info.name || 'field'
    const newName = parent.generateUniqueName(baseName)

    // Making a copy and restoring it through the `addAssociation()` method
    // scales better than copying it manually.
    const copy = this.toJSON() as Partial<DomainAssociationSchema>

    // Delete properties that should not be copied
    delete copy.key
    delete copy.targets

    // Set the new name for the duplicated association
    if (copy.info) {
      copy.info.name = newName
    } else {
      copy.info = { name: newName }
    }

    const result = parent.addAssociation({}, copy)

    // Copy the target entities
    for (const target of this.targets) {
      result.addTarget(target.key, target.domain)
    }

    // Move the duplicate to be right after the original
    const fromIndex = parent.fields.length - 1
    const duplicateField = parent.fields[fromIndex]
    parent.fields.splice(fromIndex, 1)
    parent.fields.splice(originalIndex + 1, 0, duplicateField)

    this.domain.notifyChange()
    return result
  }
}
