/**
 * @module events/changeFeed
 * @description The in-process change feed behind {@link Brainy.onChange} — the
 * authoritative "something committed" signal for every canonical mutation,
 * regardless of origin (direct API calls, batches, transactions, imports, the
 * VFS, or an accelerated native deployment: all of them funnel through the
 * same generation-store commit points this feed is emitted from).
 *
 * Design properties:
 * - **Post-commit only.** Events are enqueued after the commit succeeds, so an
 *   aborted commit (a losing `ifRev` CAS, a failed transaction) never emits.
 * - **Commit-ordered.** Events are enqueued in commit order and dispatched
 *   FIFO, so a subscriber observes mutations in the order they became durable.
 * - **Never blocks the write path.** Dispatch happens in a microtask after the
 *   committing call returns; a slow or throwing listener cannot delay or fail
 *   a write. Listener errors are isolated per listener and per event.
 * - **Zero cost when unused.** Callers consult {@link ChangeFeed.hasListeners}
 *   before constructing event payloads; with no subscribers the write path
 *   does no event work at all.
 * - **Fire-and-forget.** No acknowledgement, backpressure, or replay. Events
 *   carry the committed `generation`, so a consumer that needs catch-up
 *   semantics can pair the live feed with `transactionLog()` / `asOf()`.
 */

/** The post-commit view of an entity carried by entity change events. */
export interface ChangeEventEntity {
  /** The entity's canonical UUID. */
  id: string
  /** The entity's NounType string (e.g. `'person'`, `'document'`). */
  type: string
  /** The entity's subtype, when set. */
  subtype?: string
  /** The entity's custom (indexed) metadata fields. */
  metadata: Record<string, unknown>
  /** The writing service, when set. */
  service?: string
}

/** The post-commit view of a relationship carried by relation change events. */
export interface ChangeEventRelation {
  /** The relationship's canonical UUID. */
  id: string
  /** Source entity UUID. */
  from: string
  /** Target entity UUID. */
  to: string
  /** The relationship's VerbType string (e.g. `'contains'`). */
  type: string
  /** The relationship's custom metadata fields, when present. */
  metadata?: Record<string, unknown>
}

/**
 * One committed mutation, as delivered to {@link Brainy.onChange} listeners.
 *
 * Exactly one of `entity` / `relation` is populated for `kind: 'entity'` /
 * `kind: 'relation'` events. `kind: 'store'` events (`clear` / `restore`)
 * carry neither — they mean "the whole store changed; refetch what you care
 * about".
 *
 * For `op: 'remove'` / `op: 'unrelate'` the payload is the record's LAST
 * committed state (sourced from the commit's own before-image), so deletes are
 * fully described rather than id-only.
 */
export interface BrainyChangeEvent {
  /** What changed: one record, one relationship, or the whole store. */
  kind: 'entity' | 'relation' | 'store'
  /** The mutation, in Brainy's own API vocabulary. */
  op:
    | 'add'
    | 'update'
    | 'remove'
    | 'relate'
    | 'unrelate'
    | 'updateRelation'
    | 'clear'
    | 'restore'
  /** The mutated record's id (absent for store-level events). */
  id?: string
  /** Post-commit entity view (entity events only). */
  entity?: ChangeEventEntity
  /** Post-commit relation view (relation events only). */
  relation?: ChangeEventRelation
  /**
   * The committed generation this mutation belongs to (Model B: every write
   * is a generation; a transaction's items share one). Absent for store-level
   * events and init-time bootstrap writes, which are not generation-stamped.
   */
  generation?: number
  /** Commit timestamp (ms since epoch). */
  timestamp: number
}

/** Listener signature for {@link Brainy.onChange}. */
export type ChangeListener = (event: BrainyChangeEvent) => void

/**
 * A change event as constructed by a mutation method BEFORE its commit: the
 * commit seam stamps `generation`/`timestamp` after the write becomes
 * durable. An entity `remove` descriptor may omit `entity` — the seam fills
 * it from the commit's own before-image (the record's last committed state).
 */
export type PendingChangeEvent = Omit<BrainyChangeEvent, 'generation' | 'timestamp'>

/**
 * @description Listener registry + commit-ordered async dispatcher for
 * {@link BrainyChangeEvent}s. One instance per {@link Brainy}.
 */
export class ChangeFeed {
  private listeners = new Set<ChangeListener>()
  private queue: BrainyChangeEvent[] = []
  private draining = false

  /** Whether any listener is subscribed — the write path's zero-cost gate. */
  get hasListeners(): boolean {
    return this.listeners.size > 0
  }

  /**
   * @description Subscribe to committed mutations.
   * @param listener - Called once per committed mutation, in commit order.
   * @returns An unsubscribe function.
   */
  subscribe(listener: ChangeListener): () => void {
    this.listeners.add(listener)
    return () => {
      this.listeners.delete(listener)
    }
  }

  /**
   * @description Enqueue committed events and schedule dispatch. Call ONLY
   * after the commit has succeeded — this feed must never announce a write
   * that did not become durable. Safe to call with an empty array.
   * @param events - The committed mutations, in commit order.
   */
  emit(events: BrainyChangeEvent[]): void {
    if (events.length === 0 || this.listeners.size === 0) return
    this.queue.push(...events)
    if (!this.draining) {
      this.draining = true
      queueMicrotask(() => this.drain())
    }
  }

  /** Deliver everything queued, FIFO, isolating listener errors. */
  private drain(): void {
    try {
      while (this.queue.length > 0) {
        const event = this.queue.shift()!
        for (const listener of this.listeners) {
          try {
            listener(event)
          } catch (err) {
            // A subscriber's bug must never affect the write path or its
            // sibling subscribers.
            console.error('[Brainy] onChange listener threw:', err)
          }
        }
      }
    } finally {
      this.draining = false
    }
  }

  /** Drop all listeners (brain close/teardown). Queued events are discarded. */
  close(): void {
    this.listeners.clear()
    this.queue.length = 0
  }
}
