import { type AssociationBindings, type PropertyWebBindings } from '../Bindings.js'
import type { DomainAssociation } from '../DomainAssociation.js'
import type { DomainProperty } from '../DomainProperty.js'
import type { DomainEntity } from '../DomainEntity.js'
import { IAmfExampleTypes, modelTypeToAmfDataType } from '../../amf/AmfTypes.js'
import { AmfNamespace } from '../../amf/definitions/Namespace.js'
import {
  anyShape,
  arrayShape,
  fileShape,
  IApiAnyShape,
  IApiArrayShape,
  IApiDataExample,
  IApiFileShape,
  IApiNodeShape,
  IApiPropertyShape,
  IApiRecursiveShape,
  IApiScalarShape,
  IApiShape,
  IApiUnionShape,
  IShapeUnion,
  nodeShape,
  propertyShape,
  recursiveShape,
  scalarShape,
  unionShape,
} from '../../amf/definitions/Shapes.js'
import { AmfDataNode } from '../../amf/models/AmfDataNode.js'
import { DataValueGenerator } from '../../amf/DataValueGenerator.js'
import { nanoid } from '../../nanoid.js'
import { DomainEntityKind, DomainPropertyKind } from '../../models/kinds.js'
import type { PropertySchema } from '../types.js'

const UNION_TYPE_ANY_OF = 'anyOf'
const UNION_TYPE_ALL_OF = 'allOf'
const UNION_TYPE_ONE_OF = 'oneOf'
const UNION_TYPE_NOT = 'not'
const DATA_TYPE_STRING = 'string'
const DATA_TYPE_BINARY = 'binary'

/**
 * Serializes Data Domain elements (DomainEntity, DomainProperty, DomainAssociation)
 * to AMF shapes.
 *
 * This class provides a mechanism to translate a data domain model, defined using
 * custom classes like `DomainEntity`, `DomainProperty`, and `DomainAssociation`,
 * into AMF (API Modeling Framework) shapes. AMF shapes are a standardized way
 * to represent API structures and data models, enabling interoperability and tooling.
 *
 * This is particularly useful when you have a custom data model and need to
 * generate API definitions (e.g., RAML or OAS) from it.
 *
 * **Key Features:**
 *
 * - **Data Domain to AMF Translation:** Converts data domain elements into
 *   their corresponding AMF shape representations.
 * - **Recursive Shape Handling:**  Detects and handles recursive relationships
 *   within the data model to prevent infinite loops during shape generation.
 * - **Union Type Support:**  Supports the creation of union types in AMF
 *   to represent associations that can point to multiple different entity types.
 * - **Binding Integration:**  Leverages binding information (e.g., web bindings)
 *   to customize the generated AMF shapes based on the target API format.
 * - **Example and Default Value Generation:**  Integrates with a `DataValueGenerator`
 *   to automatically generate example values and default values for properties
 *   within the AMF shapes.
 *
 * **Usage:**
 *
 * 1. Create an instance of the `ShapeGenerator`.
 * 2. Call the appropriate method (e.g., `entity()`, `property()`, `associationShape()`)
 *    to generate the AMF shape for a specific data domain element.
 * 3. The returned AMF shape can then be used with other AMF tools to generate
 *    API definitions or perform other API-related tasks.
 */
export class ShapeGenerator {
  #valueGenerator?: DataValueGenerator

  /**
   * Used with data domain value generation for entity properties.
   * Lazily loaded class only when functions are used.
   * This needs to be a class instances because some functions are incremental
   */
  get valueGenerator(): DataValueGenerator {
    // we need a new instance of the generator for each shape generation
    // as the generator is stateful and we need to reset it for each shape.
    if (!this.#valueGenerator) {
      this.#valueGenerator = new DataValueGenerator()
    }
    return this.#valueGenerator
  }

  /**
   * Serializes a `DomainEntity` to an AMF node shape.
   *
   * This method is the entry point for converting a `DomainEntity` into its
   * AMF representation. It handles recursive relationships, property serialization,
   * and inheritance from parent entities.
   *
   * @param input The `DomainEntity` to serialize.
   * @param visited A `Set` of keys of already generated entities. This prevents
   *                recursive shape generation and avoids infinite loops.  Defaults to a new empty `Set`.
   * @returns An `IApiNodeShape` or `IApiRecursiveShape` representing the entity in AMF.
   *          Returns a `IApiRecursiveShape` if a recursive loop is detected.
   * @example
   * ```typescript
   * const generator = new ShapeGenerator();
   * const amfShape = generator.entity(myDomainEntity);
   * ```
   */
  entity(input: DomainEntity, visited = new Set<string>()): IApiNodeShape | IApiRecursiveShape {
    if (visited.has(input.key)) {
      // create a recursive shape.
      return this.createRecursiveShape(input)
    }
    visited.add(input.key)
    const result = nodeShape(input.key)
    result.id = input.key
    this.updateBaseProperties(input, result)
    result.properties = []
    for (const item of input.listProperties()) {
      // we check whether the property is hidden.
      // This is not happening when calling the `property()` because this method
      // always returns the AMF shape.
      const wb = item.readBinding('web') as PropertyWebBindings | undefined
      if (wb && wb.hidden) {
        continue
      }
      const shape = this.property(item)
      result.properties.push(shape)
    }
    for (const assoc of input.listAssociations()) {
      const wb = assoc.readBinding('web') as AssociationBindings | undefined
      if (wb && wb.hidden) {
        continue
      }
      const prop = this.associationProperty(assoc, visited)
      result.properties.push(prop)
    }
    for (const parent of input.listParents()) {
      const shape = this.entity(parent, visited)
      result.inherits.push(shape)
    }
    return result
  }

  /**
   * Serializes a `DomainProperty` to an AMF property shape.
   *
   * This method converts a `DomainProperty` into an `IApiPropertyShape`,
   * defining its characteristics such as data type, required status, and
   * range (the shape of the property's value).
   *
   * @param input The `DomainProperty` to serialize.
   * @returns An `IApiPropertyShape` representing the property in AMF.
   * @example
   * ```typescript
   * const generator = new ShapeGenerator();
   * const amfShape = generator.property(myDomainProperty);
   * ```
   */
  property(input: DomainProperty): IApiPropertyShape {
    const { required, key } = input
    const result = propertyShape(key)
    result.path = `${AmfNamespace.aml.vocabularies.data.key}${input.info.name}`
    if (required) {
      result.minCount = 1
    }
    result.range = this.getRange(input)

    // for example, Example generator needs to know the name of the property
    // as it does not look into the "range" object.
    this.updateBaseProperties(input, result)

    // sync the name of the property shape with the range, in case it was changed by the bindings
    if (result.range.name) {
      result.name = result.range.name
    }
    return result
  }

  /**
   * Serializes a `DomainAssociation` to an AMF property shape, handling the
   * association's target entity or entities.
   *
   * This method is similar to `property()`, but it specifically handles
   * associations between entities. It determines the range of the property
   * based on the association's target(s) and generates the appropriate
   * AMF shape (e.g., a scalar shape for a linked schema, a union shape for
   * multiple targets).
   *
   * @param input The `DomainAssociation` to serialize.
   * @param visited A `Set` of keys of already generated entities. This prevents
   *                recursive shape generation and avoids infinite loops. Defaults to a new empty `Set`.
   * @returns An `IApiPropertyShape` representing the association in AMF.
   * @example
   * ```typescript
   * const generator = new ShapeGenerator();
   * const amfShape = generator.associationProperty(myDomainAssociation);
   * ```
   */
  associationProperty(input: DomainAssociation, visited: Set<string> = new Set<string>()): IApiPropertyShape {
    const { required, key } = input
    const result = propertyShape(key)
    result.path = `${AmfNamespace.aml.vocabularies.data.key}${input.info.name}`
    if (required) {
      result.minCount = 1
    }
    result.range = this.associationShape(input, visited)

    this.updateBaseProperties(input, result)
    return result
  }

  /**
   * Generates a shape for an association. Most likely you want to use the `associationProperty()` method instead.
   *
   * @param input The data association instance.
   * @param visited A `Set` of keys of already generated entities. This prevents
   *                recursive shape generation and avoids infinite loops. Defaults to a new empty `Set`.
   * @returns The range value for the PropertyShape.
   */
  associationShape(input: DomainAssociation, visited: Set<string> = new Set<string>()): IShapeUnion | undefined {
    const schema = input.schema
    if (schema && schema.linked) {
      return this.createLinkedShape(input)
    }
    const items = this.associationUnion(input, visited)
    if (!items) {
      return
    }
    if (Array.isArray(items)) {
      return this.createUnionShape(input, items)
    }
    const unionType = (schema && schema.unionType) || UNION_TYPE_ANY_OF
    if (unionType === UNION_TYPE_NOT) {
      // Need to investigate more about "not" union type.
      // AMF supports it, but I am not sure how to handle it.
      const wrapper = anyShape(input.key)
      wrapper.id = `not-shape-${input.key}`
      wrapper.not = items
      return wrapper
    }
    if (input.multiple) {
      return this.refactorShapeToArray(input.key, items)
    }
    return items
  }

  /**
   * @param input The data association instance.
   * @returns An AMF API scalar shape with the link to the schema.
   */
  protected createLinkedShape(input: DomainAssociation): IApiScalarShape {
    // This is a link to the schema. In an API that would be the id
    // of a resource to request the data from.
    const range = scalarShape(input.key)
    range.id = `link-${input.key}`
    range.dataType = modelTypeToAmfDataType(DATA_TYPE_STRING)
    return range
  }

  protected createUnionShape(input: DomainAssociation, items: IShapeUnion[]): IApiUnionShape | IApiArrayShape {
    const range = unionShape(input.key)
    this.updateBaseProperties(input, range)
    range.anyOf = []
    const unionType = (input.schema && input.schema.unionType) || UNION_TYPE_ANY_OF
    if (unionType === UNION_TYPE_ANY_OF) {
      range.anyOf = items
    } else if (unionType === UNION_TYPE_ALL_OF) {
      range.and = items
    } else if (unionType === UNION_TYPE_ONE_OF) {
      range.xone = items
    } else {
      // the "not" union type only supports a single schema.
      // I am not sure how this should be handled. Will take the first one
      // for now.
      range.not = items[0]
    }
    if (input.multiple) {
      return this.refactorShapeToArray(input.key, range)
    }
    return range
  }

  /**
   * Generates a shape list for an union. Most likely you want to use the `associationProperty()` method instead.
   *
   * @param input The data association instance.
   * @returns The range value for the PropertyShape.
   */
  associationUnion(
    input: DomainAssociation,
    visited: Set<string> = new Set<string>()
  ): IShapeUnion | IShapeUnion[] | undefined {
    const result: IShapeUnion[] = []
    for (const item of input.listTargets()) {
      result.push(this.entity(item, visited))
    }
    if (!result.length) {
      return undefined
    }
    if (result.length > 1) {
      return result
    }
    return result[0]
  }

  /**
   * The DomainProperty may have both the `schema` and the `bindings`. For AMF shape we read `schema` for
   * default value, examples, and enum values. We also look for the `web`
   * bindings for more detailed definition of a shape.
   *
   * @param input
   */
  protected getRange(input: DomainProperty): IApiArrayShape | IApiFileShape | IApiScalarShape {
    const bindings = input.readBinding('web') as PropertyWebBindings | undefined
    let schema: PropertySchema | undefined
    if (input.schema) {
      schema = input.schema
    }
    const { multiple, type } = input
    if (multiple) {
      return this.createArrayShape(input, schema, bindings)
    }
    if (type === DATA_TYPE_BINARY) {
      return this.createFileShape(input, bindings)
    }
    return this.createScalarShape(input, schema, bindings)
  }

  /**
   * Normally this would be part of generating a scalar schema but the the property is an array this
   * is generated on the array and not on the range.
   *
   * @param result The scalar or array shape.
   * @param schema The property schema
   * @param type The data type of the parent property as set on the `range`
   * @param isArray Whether the DomainProperty is multiple
   */
  protected setShapeSchema(
    input: DomainProperty,
    result: IApiAnyShape,
    schema: PropertySchema,
    type: string,
    isArray?: boolean
  ): void {
    if (schema.defaultValue) {
      const { type: dfFormat } = schema.defaultValue
      const { value } = schema.defaultValue
      if (dfFormat === 'function') {
        const tmp = this.valueGenerator.generate(value, input.type)
        if (tmp !== DataValueGenerator.noValue) {
          const dt = AmfDataNode.scalar(tmp as string, dfFormat)
          result.defaultValue = dt.toJSON()
        }
      } else {
        const dt = AmfDataNode.scalar(value, type)
        result.defaultValue = dt.toJSON()
      }
    }
    if (Array.isArray(schema.enum)) {
      result.values = schema.enum.map((i) => AmfDataNode.scalar(i, type).toJSON())
    }
    if (Array.isArray(schema.examples)) {
      if (isArray) {
        result.examples = this.createArrayExamples(schema.examples, type)
      } else {
        result.examples = this.createExamples(schema.examples, type)
      }
    }
  }

  protected createArrayShape(
    input: DomainProperty,
    schema?: PropertySchema,
    bindings?: PropertyWebBindings
  ): IApiArrayShape {
    const result = arrayShape(input.key)
    const { type } = input
    if (type === DATA_TYPE_BINARY) {
      // we do not pass schema to the range generator as we set schema's properties on the array shape.
      result.items = this.createFileShape(input, bindings)
    } else {
      result.items = this.createScalarShape(input, undefined, bindings)
    }
    if (schema) {
      const type = (result.items as IApiScalarShape).dataType as string
      this.setShapeSchema(input, result, schema, type, input.multiple)
    }
    return result
  }

  protected createScalarShape(
    input: DomainProperty,
    schema?: PropertySchema,
    bindings?: PropertyWebBindings
  ): IApiScalarShape {
    const result = scalarShape(input.key)
    this.updateBaseProperties(input, result)
    this.setScalarCommonProperties(result, input, bindings)
    if (!result.dataType) {
      result.dataType = modelTypeToAmfDataType(input.type, bindings)
    }
    if (schema) {
      this.setShapeSchema(input, result, schema, result.dataType as string, input.multiple)
    }
    return result
  }

  protected createExamples(examples: string[], type: string): IApiDataExample[] {
    const result: IApiDataExample[] = []
    for (const current of examples) {
      const item: IApiDataExample = {
        id: nanoid(),
        customDomainProperties: [],
        strict: true,
        types: IAmfExampleTypes,
        structuredValue: AmfDataNode.scalar(current, type).toJSON(),
      }
      result.push(item)
    }
    return result
  }

  protected createArrayExamples(examples: string[], type: string): IApiDataExample[] {
    const item: IApiDataExample = {
      id: nanoid(),
      customDomainProperties: [],
      strict: true,
      types: IAmfExampleTypes,
    }
    const value = new AmfDataNode('array')
    for (const item of examples) {
      const member = AmfDataNode.scalar(item, type)
      value.addMember(member)
    }
    item.structuredValue = value.toJSON()
    return [item]
  }

  protected createFileShape(input: DomainProperty, bindings?: PropertyWebBindings): IApiFileShape {
    const result = fileShape(input.key)
    this.updateBaseProperties(input, result)
    if (bindings) {
      if (Array.isArray(bindings.fileTypes)) {
        result.fileTypes = bindings.fileTypes
      }
      this.setScalarCommonProperties(result, input, bindings)
      if (bindings.format === 'base64') {
        result.format = AmfNamespace.w3.xmlSchema.base64Binary
      }
    }
    return result
  }

  protected setScalarCommonProperties(
    result: IApiFileShape | IApiScalarShape,
    input: DomainProperty,
    bindings?: PropertyWebBindings
  ): void {
    if (bindings?.name) {
      result.name = bindings.name
    }
    if (bindings?.xml) {
      result.xmlSerialization = bindings.xml
    }
    const { schema, type } = input
    if (schema) {
      if (schema.pattern) {
        result.pattern = schema.pattern
      }
      if (typeof schema.multipleOf === 'number') {
        result.multipleOf = schema.multipleOf
      }
      if (typeof schema.minimum === 'number') {
        if (type === DATA_TYPE_STRING) {
          result.minLength = schema.minimum
        } else {
          result.minimum = schema.minimum
        }
      }
      if (typeof schema.maximum === 'number') {
        if (type === DATA_TYPE_STRING) {
          result.maxLength = schema.maximum
        } else {
          result.maximum = schema.maximum
        }
      }
      if (typeof schema.exclusiveMinimum === 'boolean') {
        result.exclusiveMinimum = schema.exclusiveMinimum
      }
      if (typeof schema.exclusiveMaximum === 'boolean') {
        result.exclusiveMaximum = schema.exclusiveMaximum
      }
    }
    if (bindings?.format) {
      switch (bindings.format) {
        case 'base64':
          result.format = AmfNamespace.w3.xmlSchema.base64Binary
          break
        case 'double':
          result.format = AmfNamespace.w3.xmlSchema.double
          break
        case 'float':
          result.format = AmfNamespace.w3.xmlSchema.float
          break
        case 'int32':
          result.format = AmfNamespace.w3.xmlSchema.integer
          break
        case 'int64':
          result.format = AmfNamespace.w3.xmlSchema.integer
          break
      }
      // result.format = bindings.format
    }
    if (typeof input.readOnly === 'boolean') {
      result.readOnly = input.readOnly
    }
    if (typeof input.writeOnly === 'boolean') {
      result.writeOnly = input.writeOnly
    }
  }

  protected updateBaseProperties(input: DomainProperty | DomainAssociation | DomainEntity, target: IApiShape): void {
    target.name = input.info.name
    target.displayName = input.info.displayName
    target.description = input.info.description
    if (input.kind === DomainPropertyKind) {
      target.deprecated = (input as DomainProperty).deprecated
    } else if (input.kind === DomainEntityKind) {
      target.deprecated = (input as DomainEntity).deprecated
    }
  }

  protected createRecursiveShape(input: DomainEntity): IApiRecursiveShape {
    return recursiveShape(input.key, input.key)
  }

  /**
   * Translates the shape to an array shape. This happens when data model property
   * is changed from 'multiple' to not-multiple and back.
   *
   * @param id The key of the parameter or an association
   * @param shape The shape to wrap as an array.
   * @returns Array shape.
   */
  protected refactorShapeToArray(id: string, shape: IShapeUnion): IApiArrayShape {
    const result = arrayShape(id)
    result.items = shape
    return result
  }
}
