import { nanoid } from '../nanoid.js'
import { ApiModelKind, DataDomainKind, ExposedEntityKind } from '../models/kinds.js'
import { type IThing, Thing } from '../models/Thing.js'
import type {
  AccessRule,
  AssociationTarget,
  AuthenticationConfiguration,
  AuthorizationConfiguration,
  ExposedEntitySchema,
  RateLimitingConfiguration,
  RolesBasedAccessControl,
  SessionConfiguration,
  UsernamePasswordConfiguration,
  ExposeOptions,
} from './types.js'
import { DataDomain } from './DataDomain.js'
import { DependentModel, type DependentModelSchema, type DomainDependency } from './DependentModel.js'
import { observed, toRaw } from '../decorators/observed.js'
import pluralize from '@jarrodek/pluralize'
import { createDomainKey } from './helpers/keying.js'
import { ExposedEntity } from './ExposedEntity.js'

/**
 * Contact information for the exposed API.
 */
export interface ApiContact {
  /**
   * The identifying name of the contact person/organization.
   */
  name?: string
  /**
   * The URL pointing to the contact information. MUST be in the format of a URL.
   */
  url?: string
  /**
   * The email address of the contact person/organization. MUST be in the format of an email address.
   */
  email?: string
}

/**
 * License information for the exposed API.
 */
export interface ApiLicense {
  /**
   * The license name used for the API. It is recommended to be an SPDX license identifier.
   */
  name: string
  /**
   * A URL to the license used for the API. MUST be in the format of a URL.
   */
  url?: string
}

export interface ApiModelSchema extends DependentModelSchema {
  /**
   * The data domain kind recognizable by the ecosystem.
   */
  kind: typeof ApiModelKind
  /**
   * The unique key of the API model schema.
   * This is a stable identifier that does not change across versions.
   */
  key: string
  /**
   * Contains the name, display name, description, and the version of the API model schema.
   */
  info: IThing

  /**
   * The designated Data Entity that represents a "User".
   * This entity should be marked with the "User" semantic in the Data Modeler.
   *
   * This property is required to publish the API.
   */
  user?: AssociationTarget

  /**
   * Configuration for how users prove their identity.
   * The API model is invalid if this is not set.
   */
  authentication?: AuthenticationConfiguration

  /**
   * Configuration for what authenticated users are allowed to do.
   * The API model is invalid if this is not set.
   */
  authorization?: AuthorizationConfiguration

  /**
   * Configuration for the transport and payload of the user session.
   * The API model is invalid if this is not set.
   */
  session?: SessionConfiguration
  /**
   * The specific subset of Data Entities to be exposed by this API.
   * These are the entities that are included in the data domain schema.
   */
  exposes: ExposedEntitySchema[]

  /**
   * Optional array of access rules that define the access control policies
   * for the API. These rules are used to enforce security and permissions
   * on the exposed entities.
   *
   * These rules apply to all exposed entities and actions. An API action
   * can declare its own access rules, which will override these.
   */
  accessRule?: AccessRule[]
  /**
   * Optional configuration for API-wide rate limiting and throttling.
   * Defines rules to protect the API from overuse.
   */
  rateLimiting?: RateLimitingConfiguration
  /**
   * A URL to the Terms of Service for the API.
   */
  termsOfService?: string
  /**
   * The contact information for the exposed API.
   */
  contact?: ApiContact
  /**
   * The license information for the API.
   */
  license?: ApiLicense
}

export class ApiModel extends DependentModel {
  /**
   * The data domain kind recognizable by the ecosystem.
   */
  kind: typeof ApiModelKind
  /**
   * The unique key of the data domain schema.
   * This is a stable identifier that does not change across versions.
   */
  key: string

  /**
   * The description of the domain property.
   */
  info: Thing
  /**
   * The designated Data Entity that represents a "User".
   * This entity must be marked with the "User" semantic in the Data Modeler.
   *
   * This property is required to publish the API.
   */
  user?: AssociationTarget

  /**
   * Configuration for how users prove their identity.
   * The API model is invalid if this is not set.
   */
  authentication?: AuthenticationConfiguration

  /**
   * Configuration for what authenticated users are allowed to do.
   * The API model is invalid if this is not set.
   */
  authorization?: AuthorizationConfiguration

  /**
   * Configuration for the transport and payload of the user session.
   * The API model is invalid if this is not set.
   */
  session?: SessionConfiguration
  /**
   * The specific subset of Data Entities to be exposed by this API.
   * These are the entities that are included in the data domain schema.
   */
  @observed({ deep: true }) accessor exposes: ExposedEntity[]
  /**
   * Optional array of access rules that define the access control policies
   * for the API. These rules are used to enforce security and permissions
   * on the exposed entities.
   *
   * These rules apply to all exposed entities and actions. An API action
   * can declare its own access rules, which will override these.
   */
  accessRule?: AccessRule[]
  /**
   * Optional configuration for API-wide rate limiting and throttling.
   * Defines rules to protect the API from overuse.
   */
  rateLimiting?: RateLimitingConfiguration
  /**
   * A URL to the Terms of Service for the API.
   */
  @observed() accessor termsOfService: string | undefined
  /**
   * The contact information for the exposed API.
   */
  @observed({ deep: true }) accessor contact: ApiContact | undefined
  /**
   * The license information for the API.
   */
  @observed({ deep: true }) accessor license: ApiLicense | undefined

  /**
   * When the initializing flag is set to true,
   * the domain is not notified of changes.
   */
  #initializing = true

  /**
   * When the notifying flag is set to true,
   * the domain is pending a notification.
   * No other notifications will be sent until
   * the current notification is sent.
   */
  #notifying = false

  /**
   * A convenience getter that returns the DataDomain associated with this API model.
   * Since the API model can have only one DataDomain,
   * this getter returns the first dependency in the list.
   *
   * The parent interface `DependentModel` allows for multiple dependencies,
   * to unify the dependency management across different models.
   */
  get domain(): DataDomain | undefined {
    if (this.dependencyList.length === 0) {
      return undefined
    }
    const domain = this.dependencyList[0]
    return this.dependencies.get(domain.key)
  }

  static createSchema(input: Partial<ApiModelSchema> = {}): ApiModelSchema {
    const { key = nanoid(), exposes = [] } = input
    const info = Thing.fromJSON(input.info, { name: 'Unnamed API' }).toJSON()
    const result: ApiModelSchema = {
      kind: ApiModelKind,
      key,
      info,
      exposes,
    }
    if (input.user) {
      result.user = { ...input.user }
    }
    if (input.dependencyList) {
      result.dependencyList = structuredClone(input.dependencyList)
    }
    if (input.authentication) {
      result.authentication = input.authentication
    }
    if (input.authorization) {
      result.authorization = input.authorization
    }
    if (input.session) {
      result.session = input.session
    }
    if (input.accessRule) {
      result.accessRule = input.accessRule
    }
    if (input.rateLimiting) {
      result.rateLimiting = input.rateLimiting
    }
    if (input.termsOfService) {
      result.termsOfService = input.termsOfService
    }
    if (input.contact) {
      result.contact = structuredClone(input.contact)
    }
    if (input.license) {
      result.license = structuredClone(input.license)
    }
    return result
  }

  constructor(state?: Partial<ApiModelSchema>, domain?: DomainDependency) {
    const init = ApiModel.createSchema(state)
    const instances: DataDomain[] = []
    if (domain instanceof DataDomain) {
      instances.push(domain)
    } else if (typeof domain === 'object' && domain.kind === DataDomainKind) {
      instances.push(new DataDomain(domain))
    } else if (domain) {
      throw new Error(`Invalid domain provided. Expected a DataDomain instance or schema.`)
    }
    super(init.dependencyList, instances)
    this.kind = init.kind
    this.key = init.key
    this.info = new Thing(init.info)
    this.user = init.user
    if (init.authentication) {
      this.authentication = structuredClone(init.authentication)
    }
    if (init.authorization) {
      this.authorization = structuredClone(init.authorization)
    }
    if (init.session) {
      this.session = structuredClone(init.session)
    }
    if (Array.isArray(init.exposes)) {
      this.exposes = init.exposes.map((e) => new ExposedEntity(this, e))
    } else {
      this.exposes = []
    }
    if (init.accessRule) {
      this.accessRule = structuredClone(init.accessRule)
    }
    if (init.rateLimiting) {
      this.rateLimiting = structuredClone(init.rateLimiting)
    }
    if (init.termsOfService) {
      this.termsOfService = init.termsOfService
    }
    if (init.contact) {
      this.contact = init.contact
    }
    if (init.license) {
      this.license = init.license
    }
    this.#initializing = false
    this.info.addEventListener('change', () => {
      this.notifyChange()
    })
  }

  toJSON(): ApiModelSchema {
    const result: ApiModelSchema = {
      kind: this.kind,
      key: this.key,
      info: this.info.toJSON(),
      exposes: this.exposes.map((e) => e.toJSON()),
    }
    if (this.user) {
      result.user = { ...this.user }
    }
    if (this.dependencyList.length > 0) {
      result.dependencyList = structuredClone(this.dependencyList)
    }
    if (this.authentication) {
      result.authentication = structuredClone(this.authentication)
    }
    if (this.authorization) {
      result.authorization = structuredClone(this.authorization)
    }
    if (this.session) {
      result.session = structuredClone(this.session)
    }
    if (this.accessRule) {
      result.accessRule = structuredClone(this.accessRule)
    }
    if (this.rateLimiting) {
      result.rateLimiting = structuredClone(this.rateLimiting)
    }
    if (this.termsOfService) {
      result.termsOfService = this.termsOfService
    }
    if (this.contact) {
      result.contact = structuredClone(toRaw(this, this.contact))
    }
    if (this.license) {
      result.license = structuredClone(toRaw(this, this.license))
    }
    return result
  }

  /**
   * This function is used internally by all domain elements to notify that something has changed.
   * Since we want to notify listeners after the operation commits, we use microtask
   * to ensure that the event is dispatched after the current operation.
   */
  notifyChange() {
    if (this.#notifying || this.#initializing) {
      return
    }
    this.#notifying = true
    queueMicrotask(() => {
      this.#notifying = false
      const event = new Event('change')
      this.dispatchEvent(event)
    })
  }

  /**
   * Exposes a new entity in the API model.
   * If the entity already exists, it returns the existing one.
   * @param entity The entity key and domain to expose.
   * @returns The exposed entity.
   */
  exposeEntity(entity: AssociationTarget, options?: ExposeOptions): ExposedEntity {
    const domain = this.domain
    if (!domain) {
      throw new Error(`No domain attached to API model`)
    }
    // checks whether the entity is already exposed as a root exposure.
    const existing = this.exposes.find(
      (e) => e.isRoot && e.entity.key === entity.key && e.entity.domain === entity.domain
    )
    if (existing) {
      // quietly return the existing exposure
      // TBD: should we throw an error here?
      return existing
    }
    const domainEntity = domain.findEntity(entity.key, entity.domain)
    if (!domainEntity) {
      throw new Error(`Entity not found in domain: ${entity.key}`)
    }
    const name = domainEntity.info.name || ''
    const segment = pluralize(name.toLocaleLowerCase())
    const relativeCollectionPath = `/${segment}`
    const relativeResourcePath = `/${segment}/{id}`
    const newEntity: ExposedEntitySchema = {
      kind: ExposedEntityKind,
      key: nanoid(),
      entity: { ...entity },
      actions: [],
      isRoot: true,
      collectionPath: relativeCollectionPath,
      resourcePath: relativeResourcePath,
      hasCollection: true,
    }
    if (options) {
      newEntity.exposeOptions = { ...options }
    }
    const created = new ExposedEntity(this, newEntity)
    this.exposes.push(created)

    // Follow associations if requested
    if (options?.followAssociations) {
      if (options?.maxDepth === undefined || options.maxDepth > 0) {
        this.followEntityAssociations(newEntity, options)
      }
    }
    this.notifyChange()
    return created
  }

  /**
   * Follows associations for a newly exposed entity if configured to do so.
   * This creates nested exposures based on the entity's associations.
   *
   * @param parentExposure The root exposure to follow associations from
   * @param options The expose options containing follow configuration
   */
  private followEntityAssociations(parentExposure: ExposedEntitySchema, options: ExposeOptions): void {
    const domain = this.domain
    if (!domain) {
      return
    }
    const maxDepth = options.maxDepth ?? 6
    const visited = new Set<string>()
    // Add parent entity's key to the visited set so we won't skip it when traversing
    // associations.
    visited.add(createDomainKey(parentExposure.entity))

    const follow = (currentEntity: AssociationTarget, parentKey: string, depth: number) => {
      // Find the domain entity
      const domainEntity = domain.findEntity(currentEntity.key, currentEntity.domain)
      if (!domainEntity) return

      // Iterate through associations
      for (const association of domainEntity.listAssociations()) {
        for (const target of association.targets) {
          // Skip self-referencing associations
          if (target.key === currentEntity.key && target.domain === currentEntity.domain) {
            continue
          }

          // Create unique identifier for circular detection
          const visitKey = createDomainKey(target)
          if (visited.has(visitKey)) {
            continue // Skip circular references
          }
          visited.add(visitKey)

          // Check if this nested exposure already exists
          const existingNested = this.exposes.find(
            (e) =>
              !e.isRoot &&
              e.entity.key === target.key &&
              e.entity.domain === target.domain &&
              e.parent?.key === parentKey
          )

          if (existingNested) {
            continue // Already exposed under this parent
          }

          // Find the target domain entity for path generation
          const targetDomainEntity = domain.findEntity(target.key, target.domain)
          if (!targetDomainEntity) continue

          const name = association.info.name || ''
          const segment = pluralize(name.toLocaleLowerCase())
          const isCollection = association.multiple !== false
          const relativeCollectionPath = isCollection ? `/${segment}` : undefined
          const relativeResourcePath = isCollection ? `/${segment}/{id}` : `/${segment}`
          // Create nested exposure
          const nestedExposure: ExposedEntitySchema = {
            kind: ExposedEntityKind,
            key: nanoid(),
            entity: { ...target },
            actions: [],
            isRoot: false,
            collectionPath: relativeCollectionPath,
            resourcePath: relativeResourcePath,
            hasCollection: isCollection,
            parent: {
              key: parentKey,
              association: {
                key: association.key,
                domain: currentEntity.domain,
              },
              depth: depth + 1,
            },
          }

          this.exposes.push(new ExposedEntity(this, nestedExposure))
          if (depth + 1 >= maxDepth) {
            nestedExposure.truncated = true
          } else {
            // Recursively follow associations
            follow(target, nestedExposure.key, depth + 1)
          }
        }
      }
    }

    // Start following from the root exposure
    follow(parentExposure.entity, parentExposure.key, 0)
  }

  /**
   * Removes an exposed entity from the API model.
   * @param entity The entity to remove.
   */
  removeEntity(entity: AssociationTarget): void {
    const current = this.exposes.find((e) => e.entity.key === entity.key && e.entity.domain === entity.domain)
    if (!current) {
      return
    }
    this.removeWithChildren(current.key)
    this.notifyChange()
  }

  private removeWithChildren(key: string): void {
    const index = this.exposes.findIndex((e) => e.key === key)
    if (index < 0) {
      return
    }
    // Remove the parent itself
    this.exposes.splice(index, 1)
    // Remove all children recursively
    const removeChildren = (parentKey: string) => {
      // Find all exposures whose parent.key matches parentKey
      const children = this.exposes.filter((e) => e.parent?.key === parentKey)
      for (const child of children) {
        removeChildren(child.key)
        const childIndex = this.exposes.findIndex((e) => e.key === child.key)
        if (childIndex >= 0) {
          this.exposes.splice(childIndex, 1)
        }
      }
    }
    // Then also remove children
    removeChildren(key)
    this.notifyChange()
  }

  /**
   * Returns the exposed entity by its key.
   * @param entityKey The key of the entity to find.
   * @returns The exposed entity or undefined if not found.
   */
  getExposedEntity(entity: AssociationTarget): ExposedEntity | undefined {
    return this.exposes.find((e) => e.entity.key === entity.key && e.entity.domain === entity.domain)
  }

  /**
   * Clears the API model for a new entity change.
   * This method resets the dependencies, exposes, user,
   * authentication, authorization, and session properties.
   */
  cleanForEntityChange(): void {
    this.dependencies.clear()
    this.dependencyList = []
    this.exposes = []
    this.user = undefined
    if (this.session) {
      this.session.properties = []
    }
    if (this.authentication && this.authentication.strategy === 'UsernamePassword') {
      const typed = this.authentication as UsernamePasswordConfiguration
      typed.passwordKey = undefined
    }
    if (this.authorization && this.authorization.strategy == 'RBAC') {
      const typed = this.authorization as RolesBasedAccessControl
      typed.roleKey = ''
    }
  }

  /**
   * Attaches a DataDomain to this API model.
   * This method clears any existing dependencies and sets the new domain.
   *
   * @param domain The DataDomain to attach to this API model.
   * @throws Error if the domain does not have a version set in its info.
   */
  attachDataDomain(domain: DataDomain): void {
    if (!domain.info.version) {
      throw new Error(`Cannot attach DataDomain without a version. Please set the version in the domain info.`)
    }
    this.cleanForEntityChange()
    this.dependencies.set(domain.key, domain)
    this.dependencyList = [{ key: domain.key, version: domain.info.version }]
    this.notifyChange()
  }
}
