import { IThing, Thing } from '../../models/Thing.js'
import { nanoid } from 'nanoid'
import { DataNamespace } from './DataNamespace.js'
import { IApiPropertyShape } from '../../amf/definitions/Shapes.js'
import { AmfShapeGenerator } from '../../amf/AmfShapeGenerator.js'
import { PropertyBinding, PropertyBindings, PropertyWebBindings } from '../Bindings.js'
import { DataPropertyKind } from '../../models/kinds.js'
import {
  type BinaryFormat,
  BinaryFormats,
  type DataPropertyAttribute,
  DataPropertyAttributes,
  type DataPropertyFormat,
  DataPropertyList,
  type DataPropertyType,
  DataPropertyTypes,
  type NumberFormat,
  NumberFormats,
} from '../DataFormat.js'
import type { DataEntity } from './DataEntity.js'

/**
 * Describes the default value set on a property schema.
 * @deprecated
 */
export interface SchemaDefaultValue {
  /**
   * The type of the default value.
   *
   * - `literal` The `value` should be used as-is.
   * - `function` The value represents the name of the function to be used.
   */
  type: 'literal' | 'function'
  /**
   * The actual value type depends on the `type`.
   * It is always a string and it has to be casted to the property's data type.
   */
  value: string
}

/**
 * A general schema definition for a property.
 * This is propagated to all bindings (when they support these properties).
 * @deprecated
 */
export interface DataPropertySchema {
  /**
   * The minimum value of the property.
   *
   * By default it is inclusive value. Use the `exclusiveMinimum` property to make it exclusive.
   *
   * Used with:
   * - string
   * - number
   * - date
   * - time
   * - datetime
   */
  minimum?: number
  /**
   * The maximum value of the property.
   *
   * By default it is inclusive value. Use the `exclusiveMaximum` property to make it exclusive.
   *
   * Used with:
   * - string
   * - number
   * - date
   * - time
   * - datetime
   */
  maximum?: number
  /**
   * When set, the minimum value is exclusive.
   *
   * - When `false`: value ≥ minimum.
   * - When `true`: value > minimum.
   */
  exclusiveMinimum?: boolean
  /**
   * When set, the exclusiveMaximum value is exclusive.
   *
   * - When `false`: value ≤ maximum.
   * - When `true`: value < minimum.
   */
  exclusiveMaximum?: boolean
  /**
   * The multiplier value for a number scalar.
   *
   * Examples:
   * - if `1` then every integer is allowed
   * - if `2` than every even number is allowed
   * - if `0.5` than every number dividable by `0.5` is allowed
   */
  multipleOf?: number
  /**
   * The enum values for the property.
   * They are always encoded as strings. The actual type is defined in the `dataType` property.
   */
  enum?: string[]
  /**
   * The default value for the property.
   * This is always encoded as a string. The actual type is defined in the `dataType` property.
   */
  defaultValue?: SchemaDefaultValue
  /**
   * The example values for the property.
   * They are always encoded as strings. The actual type is defined in the `dataType` property.
   */
  examples?: string[]
}

/**
 * @deprecated
 */
export interface IDataProperty {
  kind: typeof DataPropertyKind
  /**
   * The key of the namespace.
   */
  key: string
  /**
   * The data 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 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[]
  /**
   * For future use.
   *
   * The keys of the taxonomy items associated with the property.
   */
  taxonomy?: string[]
  /**
   * 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: DataPropertyType
  /**
   * The general schema definition of this property.
   * This is propagated to all bindings (when they support these properties).
   */
  schema?: DataPropertySchema
  /**
   * 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[]
}

/**
 * @deprecated
 */
export class DataProperty {
  kind = DataPropertyKind

  key = ''

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

  /**
   * 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 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.
   *
   * Note to implementations, use the `addTag()` method as it propagates the "tag" value in the namespace.
   */
  tags: string[] = []

  /**
   * Reserved for future use.
   *
   * The keys of the taxonomy items associated with the property.
   */
  taxonomy: string[] = []

  /**
   * 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: DataPropertyType = 'string'

  /**
   * The general schema definition of this property.
   * This is propagated to all bindings (when they support these properties).
   */
  schema?: DataPropertySchema
  /**
   * 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[] = []

  /**
   * @deprecated Use the direct import of types.
   */
  static get supportedTypes(): DataPropertyType[] {
    return [...DataPropertyTypes]
  }

  static fromName(root: DataNamespace, name: string): DataProperty {
    const property = new DataProperty(root)
    property.info = Thing.fromName(name)
    return property
  }

  static fromType(root: DataNamespace, type: DataPropertyType): DataProperty {
    const result = new DataProperty(root)
    result.type = type
    return result
  }

  /**
   * @param input The data property definition to restore.
   */
  constructor(
    public root: DataNamespace,
    input?: string | IDataProperty
  ) {
    let init: IDataProperty
    if (typeof input === 'string') {
      init = JSON.parse(input)
    } else if (typeof input === 'object') {
      init = input
    } else {
      init = {
        kind: DataPropertyKind,
        key: nanoid(),
        info: Thing.fromName('').toJSON(),
        type: 'string',
      }
    }
    this.new(init)
  }

  new(init: IDataProperty): void {
    if (!DataProperty.isDataProperty(init)) {
      throw new Error(`Not a data property.`)
    }
    const {
      info,
      key = nanoid(),
      kind = DataPropertyKind,
      multiple,
      required,
      type = DataPropertyList.string,
      index,
      primary,
      readOnly,
      writeOnly,
      tags,
      taxonomy,
      deprecated,
      schema,
      bindings,
    } = init
    this.kind = kind
    this.key = key
    this.type = type
    if (info) {
      this.info = new Thing(info)
    } else {
      this.info = Thing.fromName('')
    }
    if (typeof multiple === 'boolean') {
      this.multiple = multiple
    } else {
      this.multiple = undefined
    }
    if (typeof required === 'boolean') {
      this.required = required
    } else {
      this.required = undefined
    }
    if (typeof index === 'boolean') {
      this.index = index
    } else {
      this.index = undefined
    }
    if (typeof deprecated === 'boolean') {
      this.deprecated = deprecated
    } else {
      this.deprecated = undefined
    }
    if (typeof primary === 'boolean') {
      this.primary = primary
    } else {
      this.primary = undefined
    }
    if (typeof readOnly === 'boolean') {
      this.readOnly = readOnly
    } else {
      this.readOnly = undefined
    }
    if (typeof writeOnly === 'boolean') {
      this.writeOnly = writeOnly
    } else {
      this.writeOnly = undefined
    }
    if (Array.isArray(tags)) {
      this.tags = [...tags]
    } else {
      this.tags = []
    }
    if (Array.isArray(taxonomy)) {
      this.taxonomy = [...taxonomy]
    } else {
      this.taxonomy = []
    }
    if (schema) {
      this.schema = structuredClone(schema)
    } else {
      this.schema = undefined
    }
    if (Array.isArray(bindings)) {
      this.bindings = bindings.map((i) => structuredClone(i))
    } else {
      this.bindings = []
    }
  }

  static isDataProperty(input: unknown): boolean {
    const typed = input as IDataProperty
    if (!input || typed.kind !== DataPropertyKind) {
      return false
    }
    return true
  }

  toJSON(): IDataProperty {
    const result: IDataProperty = {
      kind: DataPropertyKind,
      info: this.info.toJSON(),
      key: this.key,
      type: this.type || DataPropertyList.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.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.taxonomy) && this.taxonomy.length) {
      result.taxonomy = [...this.taxonomy]
    }
    if (this.schema) {
      result.schema = structuredClone(this.schema)
    }
    if (Array.isArray(this.bindings) && this.bindings.length) {
      result.bindings = this.bindings.map((i) => structuredClone(i))
    }
    return result
  }

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

  /**
   * Adds a new tag to the property. It also populates the root namespace's tags when tag is new.
   *
   * Note, it does nothing when the tag is already defined.
   *
   * @param tag The tag to add.
   */
  addTag(tag: string): void {
    if (!tag) {
      return
    }
    const lower = tag.toLowerCase()
    const { tags } = this
    if (tags.some((t) => t.toLowerCase() === lower)) {
      return
    }
    tags.push(tag)
    const { definitions } = this.root
    if (!definitions.tags.some((t) => t.toLowerCase() === lower)) {
      definitions.tags.push(tag)
    }
  }

  /**
   * Removes a tag from the property. Unlike the `addTag()` this won't remove a `tag` from the root namespace.
   *
   * @param tag The tag to remove.
   */
  removeTag(tag: string): void {
    if (!tag) {
      return
    }
    const lower = tag.toLowerCase()
    const { tags } = this
    const index = tags.findIndex((t) => t.toLowerCase() === lower)
    if (index >= 0) {
      tags.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.property(this)
  }

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

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

  /**
   * 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 DataPropertyFormat {
    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(): DataPropertySchema {
    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
  }

  /**
   * Returns a parent data entity where this data property exist.
   */
  getParentInstance(): DataEntity | undefined {
    return this.root.definitions.entities.find((e) => e.properties.some((e) => e.key === this.key))
  }

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