import type { DataDomain } from './DataDomain.js'
import { DomainPropertyKind } 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 BinaryFormat,
  BinaryFormats,
  type DomainPropertyAttribute,
  DomainPropertyAttributes,
  type DomainPropertyFormat,
  DomainPropertyList,
  type DomainPropertyType,
  DomainPropertyTypes,
  type NumberFormat,
  NumberFormats,
} from './DataFormat.js'
import { ShapeGenerator } from './amf/ShapeGenerator.js'
import type { PropertyBinding, PropertyBindings, PropertyWebBindings } from './Bindings.js'
import type { ModelValidationOptions } from '../models/types.js'
import type { DomainEntity } from './DomainEntity.js'
import type { IApiPropertyShape } from '../amf/definitions/Shapes.js'
import type { PropertySchema } from './types.js'
import { DataSemantics, isPropertySemantic, type SemanticType, type AppliedDataSemantic } from './Semantics.js'
import { ApiSchemaGenerator } from '../amf/ApiSchemaGenerator.js'
import type { IShapeRenderOptions } from '../amf/shape/ShapeBase.js'

export interface DomainPropertySchema extends DomainElementSchema {
  kind: typeof DomainPropertyKind
  /**
   * The domain property description.
   */
  info: IThing
  /**
   * Wether the data property is required.
   */
  required?: boolean
  /**
   * Whether the data property allows multiple items.
   */
  multiple?: boolean
  /**
   * Whether this property describes a primary key of the entity.
   */
  primary?: boolean
  /**
   * Whether this property describes a unique property of the entity.
   * This is used to generate unique constraints in the database.
   */
  unique?: boolean
  /**
   * Whether this property describes an indexed property of the entity.
   */
  index?: boolean
  /**
   * Whether the property is read only in the schema.
   */
  readOnly?: boolean
  /**
   * Whether the property is write only in the schema.
   */
  writeOnly?: boolean
  /**
   * Whether this property is deprecated.
   */
  deprecated?: boolean
  /**
   * Optional general purpose tags for the UI.
   */
  tags?: string[]
  /**
   * The semantics applied to this property.
   * This is a list of applied semantics that can be used to
   * describe the property in more detail.
   */
  semantics?: AppliedDataSemantic[]
  /**
   * The data type for this property.
   * Note, not all schemas support the same type. For example, defining `sint32`
   * works for protocol buffers, but does not for JSON. In such case we use default
   * translation `sint32` -> `number`. Another way it to create
   * a derivative entity for specific schema to describe specific schema case.
   */
  type: DomainPropertyType
  /**
   * The general schema definition of this property.
   * This is propagated to all bindings (when they support these properties).
   */
  schema?: PropertySchema
  /**
   * 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?: PropertyBinding[]
}

/**
 * Represents a property within a data domain entity.
 *
 * A `DomainProperty` defines a single data element within a
 * `DomainEntity`. It specifies the data type, constraints,
 * and other metadata for a specific piece of information.
 *
 * **Key Features:**
 *
 * -   **Data Type:** Defines the type of data the property
 *     holds (e.g., string, number, boolean, date).
 * -   **Constraints:** Supports defining constraints such as
 *     minimum/maximum values, enum values, and default values.
 * -   **Bindings:** Allows defining how the property is
 *     represented in different formats (e.g., web APIs,
 *     protocol buffers).
 * -   **Metadata:** Supports metadata such as tags, semantics,
 *     read-only/write-only status, and deprecation.
 * -   **Schema:** Allows defining a general schema for the
 *     property, which is propagated to all bindings.
 *
 * **Usage:**
 *
 * Use the `DomainEntity.addProperty` method to add a new property to an
 * entity. The property can be created using the constructor, but it
 * won't be added to the graph until you call the `addProperty` method.
 *
 * **Example:**
 *
 * ```typescript
 * const entity = domainModel.addEntity({ key: 'user' });
 * const property = entity.addProperty({
 *   key: 'name',
 *   type: 'string',
 *   required: true,
 *   schema: {
 *     minimum: 3,
 *     maximum: 50,
 *   },
 * });
 * ```
 */
export class DomainProperty extends DomainElement {
  override kind: typeof DomainPropertyKind

  /**
   * The description of the domain property.
   */
  @retargetChange() accessor info: Thing

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

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

  /**
   * Whether this property describes a primary key of the entity.
   */
  @observed() accessor primary: boolean | undefined

  /**
   * Whether this property describes a unique property of the entity.
   * This is used to generate unique constraints in the database.
   */
  @observed() accessor unique: boolean | undefined

  /**
   * Whether this property describes an indexed property of the entity.
   */
  @observed() accessor index: boolean | undefined

  /**
   * Whether the property is read only in the schema.
   */
  @observed() accessor readOnly: boolean | undefined

  /**
   * Whether the property is write only in the schema.
   */
  @observed() accessor writeOnly: boolean | undefined

  /**
   * Whether this property is deprecated.
   */
  @observed() accessor deprecated: boolean | undefined

  /**
   * Optional general purpose tags for the UI.
   *
   * Note to implementations, use the `addTag()` method as it propagates the "tag" value in the namespace.
   * Also, the tags property is excluded from the observer properties.
   * Changes to this property will not notify the observers.
   */
  @observed({ deep: true }) accessor tags: string[] = []

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

  /**
   * The data type for this property.
   * Note, not all schemas support the same type. For example, defining `sint32`
   * works for protocol buffers but does not for JSON. In such case we use default
   * translation `sint32` -> `number`. Another way it to create
   * a derivative entity for specific schema to describe specific schema case.
   */
  @observed() accessor type: DomainPropertyType = 'string'

  /**
   * The general schema definition of this property.
   * This is propagated to all bindings (when they support these properties).
   */
  @observed({ deep: true }) accessor schema: PropertySchema | 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: PropertyBinding[] = []

  /**
   * Creates a full data model schema with defaults.
   *
   * @param input The partial data model schema.
   * @returns The data model schema.
   */
  static createSchema(input: Partial<DomainPropertySchema> = {}): DomainPropertySchema {
    const {
      key = nanoid(),
      multiple,
      required,
      type = DomainPropertyList.string,
      index,
      primary,
      unique,
      readOnly,
      writeOnly,
      tags,
      semantics,
      deprecated,
      schema,
      bindings,
    } = input
    if (typeof type === 'string') {
      if (!DomainPropertyTypes.includes(type as DomainPropertyType)) {
        throw new Error(`Invalid data property type ${type}`)
      }
    }
    const info = Thing.fromJSON(input.info, { name: 'new_property' }).toJSON()
    const result: DomainPropertySchema = {
      kind: DomainPropertyKind,
      key,
      info,
      type,
    }
    if (typeof multiple === 'boolean') {
      result.multiple = multiple
    }
    if (typeof required === 'boolean') {
      result.required = required
    }
    if (typeof index === 'boolean') {
      result.index = index
    }
    if (typeof primary === 'boolean') {
      result.primary = primary
    }
    if (typeof unique === 'boolean') {
      result.unique = unique
    }
    if (typeof readOnly === 'boolean') {
      result.readOnly = readOnly
    }
    if (typeof writeOnly === 'boolean') {
      result.writeOnly = writeOnly
    }
    if (typeof deprecated === 'boolean') {
      result.deprecated = deprecated
    }
    if (Array.isArray(tags)) {
      result.tags = [...tags]
    }
    if (Array.isArray(semantics)) {
      result.semantics = [...semantics]
    }
    if (schema) {
      result.schema = structuredClone(schema)
    }
    if (Array.isArray(bindings)) {
      result.bindings = bindings.map((i) => structuredClone(i))
    } else {
      result.bindings = []
    }
    return result
  }

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

  /**
   * Creates a new domain property instance.
   * It does not add it to the graph.
   * You need to call the `addProperty` method of the graph or a data model to add it.
   *
   * Domain objects constructors shouldn't be used directly. Use methods
   * provided on the DataDomain class to create new objects.
   *
   * @param root A reference to the root DataDomain instance.
   * @param parent The key of the parent entity.
   * @param input The partial domain property schema.
   * @returns Created domain property instance.
   */
  constructor(root: DataDomain, parent: string, input: Partial<DomainPropertySchema> = {}) {
    const init = DomainProperty.createSchema(input)
    super(root, init.key)
    this.parent = parent
    this.kind = DomainPropertyKind
    this.info = new Thing(init.info)
    this.type = init.type

    if (typeof init.multiple === 'boolean') {
      this.multiple = init.multiple
    } else {
      this.multiple = undefined
    }
    if (typeof init.required === 'boolean') {
      this.required = init.required
    } else {
      this.required = undefined
    }
    if (typeof init.index === 'boolean') {
      this.index = init.index
    } else {
      this.index = undefined
    }
    if (typeof init.deprecated === 'boolean') {
      this.deprecated = init.deprecated
    } else {
      this.deprecated = undefined
    }
    if (typeof init.primary === 'boolean') {
      this.primary = init.primary
    } else {
      this.primary = undefined
    }
    if (typeof init.unique === 'boolean') {
      this.unique = init.unique
    } else {
      this.unique = undefined
    }
    if (typeof init.readOnly === 'boolean') {
      this.readOnly = init.readOnly
    } else {
      this.readOnly = undefined
    }
    if (typeof init.writeOnly === 'boolean') {
      this.writeOnly = init.writeOnly
    } else {
      this.writeOnly = undefined
    }
    if (Array.isArray(init.tags)) {
      this.tags = [...init.tags]
    } else {
      this.tags = []
    }
    if (Array.isArray(init.semantics)) {
      this.semantics = [...init.semantics]
    } else {
      this.semantics = []
    }
    if (init.schema) {
      this.schema = structuredClone(init.schema)
    } else {
      this.schema = undefined
    }
    if (Array.isArray(init.bindings)) {
      this.bindings = init.bindings.map((i) => structuredClone(i))
    } else {
      this.bindings = []
    }
  }

  /**
   * Checks whether the input is a valid definition of a domain property.
   *
   * @param input The input to validate.
   * @param options Validation options.
   * @returns true when the passed input is a valid definition of a domain property.
   */
  static validate(input: unknown, options: ModelValidationOptions = {}): boolean {
    if (!input || (typeof input !== 'object' && input !== null)) {
      return false
    }
    const typed = input as DomainPropertySchema
    if (options.strict && typed.kind !== DomainPropertyKind) {
      return false
    }
    return true
  }

  toJSON(): DomainPropertySchema {
    const result: DomainPropertySchema = {
      kind: this.kind,
      key: this.key,
      info: this.info.toJSON(),
      type: this.type || DomainPropertyList.string,
    }
    if (typeof this.index === 'boolean') {
      result.index = this.index
    }
    if (typeof this.deprecated === 'boolean') {
      result.deprecated = this.deprecated
    }
    if (typeof this.primary === 'boolean') {
      result.primary = this.primary
    }
    if (typeof this.unique === 'boolean') {
      result.unique = this.unique
    }
    if (typeof this.multiple === 'boolean') {
      result.multiple = this.multiple
    }
    if (typeof this.required === 'boolean') {
      result.required = this.required
    }
    if (typeof this.readOnly === 'boolean') {
      result.readOnly = this.readOnly
    }
    if (typeof this.writeOnly === 'boolean') {
      result.writeOnly = this.writeOnly
    }
    if (Array.isArray(this.tags) && this.tags.length) {
      result.tags = [...this.tags]
    }
    if (Array.isArray(this.semantics) && this.semantics.length) {
      result.semantics = toRaw(this, this.semantics)?.map((i) => structuredClone(i))
    }
    if (this.schema) {
      result.schema = structuredClone(toRaw(this, this.schema))
    }
    if (Array.isArray(this.bindings) && this.bindings.length) {
      result.bindings = toRaw(this, this.bindings)?.map((i) => structuredClone(i))
    }
    return result
  }

  /**
   * Removes this property from the parent entity.
   * It calls the `removeProperty()` method of the parent entity.
   */
  remove(): void {
    const parent = this.getParentInstance()
    parent?.removeProperty(this.key)
  }

  /**
   * Returns a parent data entity where this data property exist.
   */
  getParentInstance(): DomainEntity | undefined {
    return this.root.graph.node(this.parent) as DomainEntity | undefined
  }

  /**
   * Checks whether the passed value is one of the supported data types.
   * @param value The value to test
   * @returns True when the passed value is one of the supported data types.
   */
  static isValidType(value: unknown): value is DomainPropertyType {
    if (typeof value !== 'string') {
      return false
    }
    return DomainPropertyTypes.includes(value as DomainPropertyType)
  }

  /**
   * 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 DomainPropertyAttribute {
    if (typeof value !== 'string') {
      return false
    }
    return DomainPropertyAttributes.includes(value as DomainPropertyAttribute)
  }

  /**
   * Checks whether the passed value of one of the supported `format` values.
   *
   * @param value The value to test
   * @returns `true` when the value is one of the supported `format` values.
   */
  static isValidFormat(value: unknown): value is DomainPropertyFormat {
    if (value === undefined) {
      // It is the valid value.
      return true
    }
    if (typeof value !== 'string') {
      return false
    }
    if (NumberFormats.includes(value as NumberFormat)) {
      return true
    }
    if (BinaryFormats.includes(value as BinaryFormat)) {
      return true
    }
    return false
  }

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

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

  /**
   * 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'): PropertyBindings | undefined {
    const item = this.bindings.find((i) => i.type === type) as PropertyBinding
    return item?.schema || undefined
  }

  /**
   * 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.property(this)
  }

  /**
   * Generates an example value for this property based on its schema and type.
   *
   * This method converts the property to an AMF shape and then generates
   * an example value using the ApiSchemaGenerator. The generated example
   * respects the property's type, constraints, and any defined schema.
   *
   * @param mime The mime type of the example (e.g., 'application/json', 'application/xml').
   * @param opts Optional configuration for example generation.
   * @returns The generated example value.
   * @example
   * ```typescript
   * const example = property.toExample('application/json');
   * console.log(example);
   * ```
   */
  toExample(mime: string, opts: IShapeRenderOptions = {}): string | number | boolean | null | undefined {
    // TODO: add support for semantic extensions and generate example values based on them.
    const shape = this.toApiShape()
    if (!shape.range) {
      return undefined
    }
    const generator = new ApiSchemaGenerator(mime, {
      renderExamples: opts.renderExamples ?? true,
      renderMocked: opts.renderMocked ?? true,
      renderOptional: opts.renderOptional ?? true,
      selectedUnions: opts.selectedUnions,
    })
    return generator.generate(shape)
  }

  /**
   * Adds or updates a semantic to the property.
   * @param semantic The semantic to add to the property.
   * @throws Error if the semantic is not an property semantic.
   */
  addSemantic(semantic: AppliedDataSemantic): void {
    const sem = DataSemantics[semantic.id]
    if (!isPropertySemantic(sem)) {
      throw new Error(`Invalid semantic type: ${semantic.id}. Expected a property 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 property.
   * @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 property 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 domain property on the parent entity.
   * It places the duplicate on the parent entity right after the original property.
   */
  duplicate(): DomainProperty {
    const parent = this.getParentInstance()
    if (!parent) {
      throw new Error(`Cannot duplicate property ${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 property ${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 `addProperty()` method
    // scales better than copying it manually.
    const copy = this.toJSON() as Partial<DomainPropertySchema>

    // Delete properties that should not be copied
    delete copy.key
    delete copy.primary // Don't duplicate the primary key

    // Set the new name for the duplicated property
    if (copy.info) {
      copy.info.name = newName
    } else {
      copy.info = { name: newName }
    }
    const result = parent.addProperty(copy)

    // 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
  }
}
