import { observed } from '../decorators/observed.js'
import { ExposedEntityKind } from '../models/kinds.js'
import { nanoid } from '../nanoid.js'
import type { ApiModel } from './ApiModel.js'
import { ensureLeadingSlash, joinPaths } from './helpers/endpointHelpers.js'
import type {
  AccessRule,
  ApiAction,
  AssociationTarget,
  ExposeOptions,
  ExposeParentRef,
  RateLimitingConfiguration,
  ExposedEntitySchema,
} from './types.js'

/**
 * A class that specializes in representing an exposed Data Entity within an API Model.
 *
 * @fires change - Emitted when the exposed entity has changed.
 */
export class ExposedEntity extends EventTarget {
  /**
   * The exposed entity kind recognizable by the ecosystem.
   */
  kind: typeof ExposedEntityKind

  /**
   * The unique key of the exposed entity.
   * This is a stable identifier that does not change across versions.
   */
  key: string

  /**
   * A pointer to a Data Entity from the Data Domain.
   */
  @observed() accessor entity: AssociationTarget

  /**
   * Indicates whether this exposure has a collection endpoint.
   * A collection endpoint is optional for nested exposures where the association is 1:1
   * and the schema is embedded directly under the parent resource.
   *
   * Note that this property is not observed for changes as it is immutable after creation.
   */
  hasCollection: boolean

  /**
   * Path to the collection endpoint for this exposure.
   * Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
   */
  @observed() accessor collectionPath: string | undefined

  /**
   * Path to the resource endpoint for this exposure.
   * Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
   */
  @observed() accessor resourcePath: string

  /**
   * Whether this exposure is a root exposure (top-level collection).
   * If this is set then the `parent` reference must be populated.
   *
   * Note that this property is not observed for changes as it is immutable after creation.
   */
  isRoot?: boolean

  /**
   * Parent reference when this exposure was created via following an association.
   *
   * Note that this property is not observed for changes as it is immutable after creation.
   */
  parent?: ExposeParentRef

  /**
   * Expose-time config used to create this exposure (persisted for auditing/UI).
   * This is only populated for the root exposure. All children exposures inherit this config.
   *
   * Note that this property is not observed for changes as it is immutable after creation.
   */
  exposeOptions?: ExposeOptions

  /**
   * The list of enabled API actions for this exposure (List/Read/Create/etc.)
   */
  @observed({ deep: true }) accessor actions: ApiAction[]

  /**
   * Optional array of access rules that define the access control policies for this exposure.
   */
  @observed({ deep: true }) accessor accessRule: AccessRule[] | undefined

  /**
   * Optional configuration for rate limiting for this exposure.
   */
  @observed({ deep: true }) accessor rateLimiting: RateLimitingConfiguration | undefined

  /**
   * When true, generation for this exposure hit configured limits
   *
   * Note that this property is not observed for changes as it is immutable after creation.
   */
  truncated?: boolean

  /**
   * 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
  /**
   * When the initializing flag is set to true,
   * the domain is not notified of changes.
   */
  #initializing = true
  /**
   * A reference to the parent API Model instance.
   */
  api: ApiModel

  static createSchema(input: Partial<ExposedEntitySchema> = {}): ExposedEntitySchema {
    const {
      key = nanoid(),
      entity = { key: '' },
      collectionPath,
      resourcePath = '/',
      hasCollection = true,
      isRoot,
      parent,
      exposeOptions,
      actions = [],
      accessRule,
      rateLimiting,
      truncated,
    } = input
    const result: ExposedEntitySchema = {
      kind: ExposedEntityKind,
      key,
      entity: { ...entity },
      hasCollection,
      resourcePath,
      actions: actions.map((a) => ({ ...a })),
    }
    if (collectionPath !== undefined) {
      result.collectionPath = collectionPath
    }
    if (isRoot !== undefined) {
      result.isRoot = isRoot
    }
    if (parent !== undefined) {
      result.parent = { ...parent }
    }
    if (exposeOptions !== undefined) {
      result.exposeOptions = { ...exposeOptions }
    }
    if (accessRule !== undefined) {
      result.accessRule = accessRule.map((ar) => ({ ...ar }))
    }
    if (rateLimiting !== undefined) {
      result.rateLimiting = { ...rateLimiting }
    }
    if (truncated !== undefined) {
      result.truncated = truncated
    }
    return result
  }

  constructor(model: ApiModel, state?: Partial<ExposedEntitySchema>) {
    super()
    this.api = model
    const init = ExposedEntity.createSchema(state)
    this.kind = init.kind
    this.key = init.key
    this.entity = init.entity
    this.hasCollection = init.hasCollection
    this.collectionPath = init.collectionPath
    this.resourcePath = init.resourcePath
    this.isRoot = init.isRoot
    this.parent = init.parent
    this.exposeOptions = init.exposeOptions
    this.actions = init.actions
    this.accessRule = init.accessRule
    this.rateLimiting = init.rateLimiting
    this.truncated = init.truncated
    this.#initializing = false
  }

  notifyChange() {
    if (this.#notifying || this.#initializing) {
      return
    }
    this.#notifying = true
    queueMicrotask(() => {
      this.#notifying = false
      const event = new Event('change')
      this.dispatchEvent(event)
    })
  }

  toJSON(): ExposedEntitySchema {
    const result: ExposedEntitySchema = {
      kind: this.kind,
      key: this.key,
      entity: { ...this.entity },
      resourcePath: this.resourcePath,
      actions: this.actions.map((a) => ({ ...a })),
      hasCollection: this.hasCollection,
    }
    if (this.collectionPath !== undefined) {
      result.collectionPath = this.collectionPath
    }
    if (this.isRoot !== undefined) {
      result.isRoot = this.isRoot
    }
    if (this.parent !== undefined) {
      result.parent = { ...this.parent }
    }
    if (this.exposeOptions !== undefined) {
      result.exposeOptions = { ...this.exposeOptions }
    }
    if (this.accessRule !== undefined) {
      result.accessRule = this.accessRule.map((ar) => ({ ...ar }))
    }
    if (this.rateLimiting !== undefined) {
      result.rateLimiting = { ...this.rateLimiting }
    }
    if (this.truncated !== undefined) {
      result.truncated = this.truncated
    }
    return result
  }

  /**
   * Sets a new collection path for this exposed entity.
   *
   * It:
   * - updates the collectionPath property
   * - updates the absoluteCollectionPath property accordingly
   * - updates the resourcePath accordingly.
   * @param path The new path to set.
   */
  setCollectionPath(path: string) {
    if (!this.hasCollection) {
      throw new Error(`Cannot set collection path on an exposure that does not have a collection`)
    }
    const cleaned = ensureLeadingSlash(path)
    // Ensure exactly one non-empty segment
    const segments = cleaned.split('/').filter(Boolean)
    if (segments.length !== 1) {
      throw new Error(`Collection path must contain exactly one segment. Received: "${path}"`)
    }
    const normalizedCollection = `/${segments[0]}`
    // Preserve current parameter name if present, otherwise default to {id}
    let param = '{id}'
    if (this.resourcePath) {
      const curSegments = this.resourcePath.split('/').filter(Boolean)
      const maybeParam = curSegments[1]
      if (maybeParam && /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(maybeParam)) {
        param = maybeParam
      }
    }
    const nextResource = `${normalizedCollection}/${param}`
    this.collectionPath = normalizedCollection
    this.resourcePath = nextResource
    // rely on ApiModel.exposes deep observation to notify on property sets
  }

  /**
   * Sets a new resource path for this exposed entity.
   *
   * Rules:
   * - Must start with '/'.
   * - If this exposure has a collection, the path must be exactly the collection path plus a single
   *   parameter segment (e.g. `/products/{productId}`) and only the parameter name may vary.
   * - If this exposure does NOT have a collection, the path can be any two segments (e.g. `/profile/{id}` or `/a/b`).
   */
  setResourcePath(path: string) {
    const cleaned = ensureLeadingSlash(path)
    const segments = cleaned.split('/').filter(Boolean)

    if (this.hasCollection) {
      if (!this.collectionPath) {
        throw new Error('Cannot set resource path: missing collection path for this exposure')
      }
      const colSegments = this.collectionPath.split('/').filter(Boolean)
      if (colSegments.length !== 1) {
        throw new Error(`Invalid stored collection path "${this.collectionPath}"`)
      }
      if (segments.length !== 2) {
        throw new Error(`Resource path must be exactly two segments (collection + parameter). Received: "${cleaned}"`)
      }
      const [s1, s2] = segments
      if (s1 !== colSegments[0]) {
        throw new Error(`Resource path must start with the collection segment "${colSegments[0]}". Received: "${s1}"`)
      }
      // s2 must be a parameter segment {name}
      if (!/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(s2)) {
        throw new Error(`The second segment must be a parameter in braces, e.g. {id}. Received: "${s2}"`)
      }
      if (this.resourcePath !== cleaned) {
        this.resourcePath = `/${s1}/${s2}`
      }
      return
    }

    // No collection: allow any two segments
    if (segments.length !== 2) {
      throw new Error(
        `Resource path must contain exactly two segments when no collection is present. Received: "${cleaned}"`
      )
    }
    if (this.resourcePath !== cleaned) {
      this.resourcePath = `/${segments[0]}/${segments[1]}`
    }
  }

  /**
   * Computes the absolute path for this exposure's resource endpoint by
   * walking up the exposure tree using `parent.key` until reaching a root exposure.
   * The absolute path is composed by concatenating each ancestor's resource path
   * with this exposure's resource path.
   */
  getAbsoluteResourcePath(): string {
    let absolute = ensureLeadingSlash(this.resourcePath)
    // Traverse parents, always joining with the parent's resource path
    let parentKey = this.parent?.key
    while (parentKey) {
      const parent = this.api.exposes.find((e) => e.key === parentKey)
      if (!parent) break
      const parentResource = ensureLeadingSlash(parent.resourcePath)
      absolute = joinPaths(parentResource, absolute)
      parentKey = parent.parent?.key
    }
    return absolute
  }

  /**
   * Computes the absolute path for this exposure's collection endpoint (if any)
   * by walking up the exposure tree using `parent.key` until reaching a root exposure.
   * The absolute path is composed by concatenating each ancestor's resource path
   * with this exposure's collection path.
   * Returns undefined if this exposure has no collection.
   */
  getAbsoluteCollectionPath(): string | undefined {
    if (!this.hasCollection || !this.collectionPath) return undefined
    let absolute = ensureLeadingSlash(this.collectionPath)
    // Traverse parents, always joining with the parent's resource path
    let parentKey = this.parent?.key
    while (parentKey) {
      const parent = this.api.exposes.find((e) => e.key === parentKey)
      if (!parent) break
      const parentResource = ensureLeadingSlash(parent.resourcePath)
      absolute = joinPaths(parentResource, absolute)
      parentKey = parent.parent?.key
    }
    return absolute
  }
}
