import {
  CollectionInErrorStateError,
  CollectionPreloadAbortedError,
  CollectionStateError,
  InvalidCollectionStatusTransitionError,
} from '../errors'
import {
  safeCancelIdleCallback,
  safeRequestIdleCallback,
} from '../utils/browser-polyfills'
import { runAllCallbacks } from '../utils/callbacks'
import { createDeferred } from '../deferred'
import { codedMessage, devBuild } from '../error-message'
import { isFailedStatus } from '../live-query-primitives.js'
import { CleanupQueue, cleanupNow } from './cleanup-queue'
import type { Deferred } from '../deferred'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type {
  CollectionConfig,
  CollectionErrorInfo,
  CollectionStatus,
  HealthProblem,
} from '../types'
import type { CollectionEventsManager } from './events'
import type { CollectionIndexesManager } from './indexes'
import type { CollectionChangesManager } from './changes'
import type { CollectionSyncManager } from './sync'
import type { CollectionStateManager } from './state'

/**
 * Floor applied to the GC delay of a collection that started syncing before
 * anything subscribed. Adapters build their live query while rendering and
 * subscribe when that render commits. This grace period reduces cleanup
 * during that gap; a later subscriber can still restart sync. Adapters pass
 * a near-zero `gcTime` to make teardown on unmount immediate. Does not apply
 * to the timer armed when the last subscriber leaves, which still honours
 * `gcTime` exactly.
 */
export const UNSUBSCRIBED_GC_FLOOR_MS = 50

/** A Collection's gcTime when its config names none (5 minutes). */
export const DEFAULT_GC_TIME_MS = 300000

export class CollectionLifecycleManager<
  TOutput extends object = Record<string, unknown>,
  TKey extends string | number = string | number,
  TSchema extends StandardSchemaV1 = StandardSchemaV1,
  TInput extends object = TOutput,
> {
  private config: CollectionConfig<TOutput, TKey, TSchema, any>
  private id: string
  private indexes!: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
  private events!: CollectionEventsManager
  private changes!: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
  private sync!: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
  private state!: CollectionStateManager<TOutput, TKey, TSchema, TInput>

  public status: CollectionStatus = `idle`
  public hasBeenReady = false
  public hasReceivedFirstCommit = false
  private readyFailureSink: ((failure: { error: unknown }) => void) | undefined
  public onFirstReadyCallbacks: Array<() => void> = []
  private idleCallbackId: number | null = null
  // After the first ready, the sync adapter's problem is health tree detail:
  // `failureReason` while it still tries, `stoppedError` once it called fail().
  // A live query's request failures are kept apart, so settling a request
  // cannot clear the sync adapter's problem. The failure reason lists every
  // reportError() of its sync run since it began.
  private failureReason: HealthProblem | null = null
  // A reload's new run reported the failure reason, so its completion keeps it.
  private failureReasonFromReload = false
  private stoppedError: HealthProblem | null = null
  // The cause of status `error` or `crashed`, kept so readers see the same
  // object until the sync run ends.
  private statusError: HealthProblem | null = null
  private requestErrors = new RequestErrors()
  // A reload's new run reported the request errors, so its completion keeps
  // them.
  private requestErrorsFromReload = false
  // fail() ends the sync adapter's reporting for its sync run; a reload or
  // cleanup starts a new one.
  private adapterStopped = false
  // A reload's new sync run, until it completes or fails.
  private reloadRun: Deferred<void> | undefined
  // A reload is moving the Collection out of `error`.
  private recovering = false
  private gcGraceUntil = 0
  private cleanupConfig: () => void
  private statusRevision = 0
  private cleaningUp = false
  private cleanupPromise: Promise<void> | null = null
  private readonly cleanupStartCallbacks = new Set<() => void>()

  /**
   * Creates a new CollectionLifecycleManager instance
   */
  constructor(
    config: CollectionConfig<TOutput, TKey, TSchema, any>,
    id: string,
    cleanupConfig: () => void = () => {},
  ) {
    this.config = config
    this.id = id
    this.cleanupConfig = cleanupConfig
  }

  setDeps(deps: {
    indexes: CollectionIndexesManager<TOutput, TKey, TSchema, TInput>
    events: CollectionEventsManager
    changes: CollectionChangesManager<TOutput, TKey, TSchema, TInput>
    sync: CollectionSyncManager<TOutput, TKey, TSchema, TInput>
    state: CollectionStateManager<TOutput, TKey, TSchema, TInput>
  }) {
    this.indexes = deps.indexes
    this.events = deps.events
    this.changes = deps.changes
    this.sync = deps.sync
    this.state = deps.state
  }

  /**
   * Validates state transitions to prevent invalid status changes
   */
  public validateStatusTransition(
    from: CollectionStatus,
    to: CollectionStatus,
  ): void {
    if (from === to) {
      // Allow same state transitions
      return
    }
    const validTransitions: Record<
      CollectionStatus,
      Array<CollectionStatus>
    > = {
      idle: [`loading`, `error`, `crashed`, `cleaned-up`],
      loading: [`ready`, `error`, `crashed`, `cleaned-up`],
      // After the first ready only a crash changes the status; a sync
      // problem is health tree detail.
      ready: [`cleaned-up`, `crashed`],
      // A failed first load is final for its sync run. Only a reload leaves
      // it for `ready` (see `recovering`).
      error: [`cleaned-up`, `idle`, `crashed`],
      // Nothing recovers a crash in place; only cleanup leaves it.
      crashed: [`cleaned-up`],
      'cleaned-up': [`loading`, `error`, `crashed`],
    }

    if (
      !validTransitions[from].includes(to) &&
      !(this.recovering && from === `error` && to === `ready`)
    ) {
      throw new InvalidCollectionStatusTransitionError(from, to, this.id)
    }
  }

  /**
   * Safely update the collection status with validation
   * @private
   */
  public setStatus(
    newStatus: CollectionStatus,
    allowReady: boolean = false,
    beforeEmit?: () => void,
  ): void {
    if (newStatus === `ready` && !allowReady) {
      // setStatus('ready') is an internal method that should not be called directly
      // Instead, use markReady to transition to ready triggering the necessary events
      // and side effects.
      throw new CollectionStateError(
        devBuild() && process.env.NODE_ENV !== `production`
          ? `You can't directly call "setStatus('ready'). You must use markReady instead.`
          : codedMessage(208),
      )
    }
    this.validateStatusTransition(this.status, newStatus)
    // Only an actual replacement run transfers later cleanup to a new run.
    // Validation, deferred requests and failed admission retain the old promise.
    if (newStatus === `loading`) this.cleanupPromise = null
    const revision = ++this.statusRevision
    const previousStatus = this.status
    this.status = newStatus
    beforeEmit?.()

    // Emit event
    const isCurrent = () => this.statusRevision === revision
    this.events.emitStatusChange(newStatus, previousStatus, isCurrent)
    // The status is part of the health tree. status:change fires first, and
    // the tree is already current when either event fires.
    if (isCurrent()) this.events.emitHealthChange()
  }

  /**
   * Validates that the collection is in a usable state for data operations
   * @private
   */
  public validateCollectionUsable(operation: string): void {
    switch (this.status) {
      case `error`:
      case `crashed`:
        throw new CollectionInErrorStateError(operation, this.id)
      case `cleaned-up`:
        // Automatically restart the collection when operations are called on cleaned-up collections
        this.sync.startSync()
        break
    }
  }

  /**
   * Mark the collection as ready for use
   * This is called by sync implementations to explicitly signal that the collection is ready,
   * providing a more intuitive alternative to using commits for readiness signaling
   * @private - Should only be called by sync implementations
   *
   * `beforeEffects` runs after the status reads `ready` and before status
   * listeners and ready callbacks, so their writes follow it.
   */
  public markReady(beforeEffects?: () => void): void {
    // A crash ended the sync run from core's side, and fail() from the
    // adapter's: after either, and once the run is ready, markReady() does
    // nothing.
    if (this.status === `crashed` || this.adapterStopped) return
    // A reload's new run is ready: the reload completes.
    if (this.reloadRun) {
      this.completeReload(beforeEffects)
      return
    }
    const failure = this.applyReadyTransition(beforeEffects)
    if (!failure) return
    if (this.readyFailureSink) this.readyFailureSink(failure)
    else throw failure.error
  }

  /**
   * @internal Hold ready-effect failures while a sync function runs, whether
   * it calls markReady or commits a truncate. The returned function stops
   * holding them and returns the first one.
   */
  public deferReadyFailures(): () => { error: unknown } | undefined {
    let first: { error: unknown } | undefined
    this.readyFailureSink = (failure) => {
      first ??= failure
    }
    return () => {
      this.readyFailureSink = undefined
      return first
    }
  }

  private applyReadyTransition(
    beforeEffects?: () => void,
  ): { error: unknown } | undefined {
    this.validateStatusTransition(this.status, `ready`)
    // A successful initial sync establishes a ready snapshot, as does a
    // reload of a failed first load.
    if (this.status === `loading` || this.status === `error`) {
      const readyRevision = this.statusRevision + 1
      this.setStatus(`ready`, true, beforeEffects)

      // A status listener can synchronously supersede this transition, even
      // when it restarts the Collection back to ready before returning.
      if (
        (this.status as CollectionStatus) !== `ready` ||
        this.statusRevision !== readyRevision
      ) {
        return undefined
      }

      const readyEffects: Array<() => void> = []

      // Call any registered first ready callbacks (only on first time becoming ready)
      if (!this.hasBeenReady) {
        this.hasBeenReady = true

        // Also mark as having received first commit for backwards compatibility
        this.hasReceivedFirstCommit = true

        readyEffects.push(...this.onFirstReadyCallbacks)
        this.onFirstReadyCallbacks = []
      }
      // Notify dependents when markReady is called, after status is set
      // This ensures live queries get notified when their dependencies become ready
      readyEffects.push(() => this.changes.emitEmptyReadyEvent())
      try {
        runAllCallbacks(readyEffects)
      } catch (error) {
        return { error }
      }
    }
    return undefined
  }

  /**
   * The sync adapter stopped. Before the first ready this fails the sync
   * run's first load; after it, the rows stay and the error shows until a
   * reload. A second fail() in the same sync run changes nothing: the adapter
   * already stopped, and the first error stays.
   */
  public fail(error: unknown): void {
    if (this.status === `crashed` || this.adapterStopped) return
    this.adapterStopped = true
    const reload = this.reloadRun
    this.reloadRun = undefined
    if (this.status === `ready`) {
      // The rows stay; a reload that failed shows its new error instead.
      this.failureReason = null
      this.stoppedError = problemOf([describeProblem(error)])
      this.events.emitHealthChange()
    } else if (reload && this.status === `error`) {
      // A reload of a failed first load keeps status `error` with the new
      // cause.
      this.statusError = problemOf([describeProblem(error)])
      this.events.emitHealthChange()
    } else {
      this.validateStatusTransition(this.status, `error`)
      this.statusError = problemOf([describeProblem(error)])
      this.setStatus(`error`)
    }
    reload?.reject(error)
  }

  /** Whether the current sync run's adapter called `fail()`. */
  public get stoppedThisRun(): boolean {
    return this.adapterStopped
  }

  /**
   * Whether the current sync run is in its first load: `loading`, or a reload
   * of a failed first load that is not ready yet. A failure now fails that
   * first load.
   */
  public get inFirstLoad(): boolean {
    return (
      this.status === `loading` || (this.reloading && this.status === `error`)
    )
  }

  /** Whether a reload's new sync run is in flight. */
  public get reloading(): boolean {
    return this.reloadRun !== undefined
  }

  /**
   * A reload starts a new sync run while everything visible stays: the rows,
   * the status, and the current problem. The node shows `reloading`.
   */
  public beginReload(): Promise<void> {
    if (this.reloadRun) return this.reloadRun.promise
    const run = createDeferred<void>()
    void run.promise.catch(() => undefined)
    this.reloadRun = run
    this.failureReasonFromReload = false
    this.requestErrorsFromReload = false
    this.adapterStopped = false
    this.events.emitHealthChange()
    return run.promise
  }

  /**
   * The reload's new run replaced the rows with a snapshot or a truncate, or
   * marked ready: the retired run's problems clear, the new run's stay, and a
   * failed first load becomes ready.
   */
  public completeReload(beforeEffects?: () => void): void {
    const run = this.reloadRun
    if (!run) return
    this.reloadRun = undefined
    if (!this.failureReasonFromReload) this.failureReason = null
    this.stoppedError = null
    if (!this.requestErrorsFromReload) this.requestErrors = new RequestErrors()
    if (this.status === `ready`) this.events.emitHealthChange()
    else {
      // A failed first load, or a reload during the first load, becomes
      // ready with the new run's rows.
      this.statusError = null
      this.recovering = true
      try {
        this.markReady(beforeEffects)
      } catch (error) {
        // A ready callback that throws reaches the adapter's markReady(); the
        // reload succeeded once the status is ready.
        if ((this.status as CollectionStatus) === `ready`) run.resolve()
        else run.reject(error)
        throw error
      } finally {
        this.recovering = false
      }
    }
    run.resolve()
  }

  /** A reload retired the sync run; subscriptions detach its demand. */
  public retireSubscriptionDemand(): void {
    for (const subscription of [...this.changes.changeSubscriptions])
      subscription.retireSyncRun()
  }

  /** The reload ended without a commit, for example by cleanup. */
  public abandonReload(error: unknown): void {
    if (!this.endReload(error)) return
    if (this.status !== `cleaned-up`) this.events.emitHealthChange()
  }

  private endReload(error: unknown): boolean {
    const run = this.reloadRun
    if (!run) return false
    this.reloadRun = undefined
    run.reject(error)
    return true
  }

  /**
   * Local storage or an internal invariant broke. The Collection can no
   * longer serve reads or accept writes, from any active status. Nothing
   * recovers a crash in place: only cleanup, which discards all state, leaves
   * it.
   */
  public crash(error?: unknown): void {
    if (this.status === `crashed`) return
    this.validateStatusTransition(this.status, `crashed`)
    // A crash ends a reload in flight with it; its status change announces
    // the tree.
    this.endReload(error)
    this.statusError = problemOf([describeProblem(error)])
    this.setStatus(`crashed`)
  }

  /** The Collection's own problems, as health tree detail. */
  public get problems(): {
    failureReason: HealthProblem | null
    error: HealthProblem | null
  } {
    const error = isFailedStatus(this.status)
      ? this.statusError
      : (this.stoppedError ?? this.requestErrors.problem)
    return { failureReason: error ? null : this.failureReason, error }
  }

  /** The cause of status `error` or `crashed`, as health tree detail. */
  public get statusProblem(): HealthProblem | null {
    return isFailedStatus(this.status) ? this.statusError : null
  }

  /** The sync adapter still tries after a problem. Status and rows do not change. */
  public reportError(error: unknown): void {
    if (this.status === `crashed`) return
    this.assertAdapterActive(`reportError`)
    this.assertReady(`reportError`)
    // A reload's new run that reports starts its own list: the retired run's
    // failures leave with that run.
    const kept =
      this.reloading && !this.failureReasonFromReload
        ? []
        : (this.failureReason?.errors ?? [])
    // One error value is one failure: one already listed is still open, so
    // it keeps its place and its `at`, and nothing changes.
    if (kept.some((failure) => failure.cause === error)) return
    this.failureReason = problemOf([...kept, describeProblem(error)])
    this.failureReasonFromReload = this.reloading
    this.events.emitHealthChange()
  }

  /**
   * The source synced successfully again. With no error it clears the
   * failure reason; with one it removes the failure whose cause is that
   * value, and the failure reason clears once none is left. With nothing to
   * remove it does nothing. `undefined` is no error: it clears every
   * failure, also one that `reportError(undefined)` listed.
   */
  public reportHealthy(error?: unknown): void {
    if (this.status === `crashed`) return
    this.assertAdapterActive(`reportHealthy`)
    this.assertReady(`reportHealthy`)
    const listed = this.failureReason?.errors ?? []
    const open =
      error === undefined
        ? []
        : listed.filter((failure) => failure.cause !== error)
    if (open.length === listed.length) return
    this.failureReason = open.length > 0 ? problemOf(open) : null
    this.events.emitHealthChange()
  }

  /**
   * @internal A live query's request failed after initial-query readiness,
   * with each of its failures in failure order.
   */
  public reportRequestError(key: string, errors: Array<unknown>): void {
    // A reload's new run that reports starts its own list: the retired run's
    // request errors leave with that run.
    if (this.reloading && !this.requestErrorsFromReload) {
      this.requestErrors = new RequestErrors()
      this.requestErrorsFromReload = true
    }
    if (this.requestErrors.fail(key, errors)) this.events.emitHealthChange()
  }

  /** @internal The failed request settled or was released. */
  public clearRequestError(key: string): void {
    if (this.requestErrors.settle(key)) this.events.emitHealthChange()
  }

  /** fail() ends the sync adapter's reporting for its sync run. */
  private assertAdapterActive(operation: string): void {
    if (this.adapterStopped) {
      throw new CollectionStateError(
        devBuild() && process.env.NODE_ENV !== `production`
          ? `${operation}() was called after fail() in the same sync run of Collection "${this.id}". fail() means the sync adapter stopped; a reload starts a new sync run.`
          : codedMessage(259, { operation, id: this.id }),
      )
    }
  }

  private assertReady(operation: string): void {
    if (this.status !== `ready`) {
      throw new CollectionStateError(
        devBuild() && process.env.NODE_ENV !== `production`
          ? `${operation}() reports on sync after the first ready, but Collection "${this.id}" is ${this.status}. Before the first ready, retry without reporting, or call fail() when the sync adapter stops.`
          : codedMessage(257, { operation, id: this.id, status: this.status }),
      )
    }
  }

  /** Return the cause of status `error` or `crashed` in this sync run, if any. */
  public getSyncError(): unknown {
    return this.statusError?.cause
  }

  /** Cleanup discards the current projection until a new sync run starts. */
  public hasStartedCleanup(): boolean {
    return this.cleaningUp || this.status === `cleaned-up`
  }

  public assertCanStartSync(): void {
    if (this.cleaningUp) {
      throw new CollectionStateError(
        devBuild() && process.env.NODE_ENV !== `production`
          ? `Cannot start collection "${this.id}" during cleanup. Restart after cleanup() completes.`
          : codedMessage(209, { id: this.id }),
      )
    }
  }

  public isCleaningUp(): boolean {
    return this.cleaningUp
  }

  /**
   * Observe the synchronous start of cleanup without treating it as terminal
   * resource settlement. Internal dependents use this to retire work before
   * an asynchronous adapter cleanup publishes `cleaned-up`.
   */
  public onCleanupStart(callback: () => void): () => void {
    this.cleanupStartCallbacks.add(callback)
    if (this.cleaningUp) {
      try {
        callback()
      } catch (error) {
        // Registration did not return its ownership handle. Do not retain an
        // observer that its caller has no way to unsubscribe.
        this.cleanupStartCallbacks.delete(callback)
        throw error
      }
    }
    return () => this.cleanupStartCallbacks.delete(callback)
  }

  /**
   * Start the garbage collection timer for a collection with no subscribers
   * Called when sync starts outside a subscription
   */
  public startGCTimerIfUnsubscribed(): void {
    this.gcGraceUntil = cleanupNow() + UNSUBSCRIBED_GC_FLOOR_MS
    this.startGCTimer(true)
  }

  // A persisting transaction holds the Collection: collecting it would drop
  // the rows the transaction shows and end the sync run its handler may wait on.
  private canGarbageCollect(): boolean {
    return (
      !this.cleaningUp &&
      this.changes.activeSubscribersCount === 0 &&
      !this.sync.hasPendingPreload &&
      !this.state.hasPersistingTransaction()
    )
  }

  /**
   * Renew inactive retention. Settlement keeps the remaining startup/preload
   * handoff; a real last unsubscribe clears it and uses gcTime exactly.
   */
  public startGCTimer(preserveHandoff = false): void {
    if (!preserveHandoff) this.gcGraceUntil = 0
    if (this.status === `idle` || this.status === `cleaned-up`) return
    this.cancelGCTimer()
    if (!this.canGarbageCollect()) return

    const gcTime = this.config.gcTime ?? DEFAULT_GC_TIME_MS

    // If gcTime is 0, negative, or non-finite (Infinity, -Infinity, NaN), GC is disabled.
    // Note: setTimeout with Infinity coerces to 0 via ToInt32, causing immediate GC,
    // so we must explicitly check for non-finite values here.
    if (gcTime <= 0 || !Number.isFinite(gcTime)) {
      return
    }

    CleanupQueue.getInstance().schedule(
      this,
      Math.max(gcTime, this.gcGraceUntil - cleanupNow()),
      () => {
        if (this.canGarbageCollect()) {
          // Schedule cleanup during idle time to avoid blocking the UI thread
          this.scheduleIdleCleanup()
        }
      },
    )
  }

  /**
   * Cancel the garbage collection timer
   * Called when the collection becomes active again
   */
  public cancelGCTimer(): void {
    CleanupQueue.getInstance().cancel(this)
    // Also cancel any pending idle cleanup
    if (this.idleCallbackId !== null) {
      safeCancelIdleCallback(this.idleCallbackId)
      this.idleCallbackId = null
    }
  }

  /**
   * Schedule cleanup to run during browser idle time
   * This prevents blocking the UI thread during cleanup operations
   */
  private scheduleIdleCleanup(): void {
    // Cancel any existing idle callback
    if (this.idleCallbackId !== null) {
      safeCancelIdleCallback(this.idleCallbackId)
    }

    // Schedule cleanup with a timeout of 1 second
    // This ensures cleanup happens even if the browser is busy
    this.idleCallbackId = safeRequestIdleCallback(
      (deadline) => {
        // Keep the callback ID only when cleanup schedules another idle period.
        if (this.canGarbageCollect()) {
          if (!(deadline.timeRemaining() > 0 || deadline.didTimeout)) {
            this.scheduleIdleCleanup()
            return
          }
          // Automatic GC has no caller to receive cleanup rejection. Core still
          // finalizes the lifecycle; explicit cleanup returns adapter diagnostics.
          void this.beginCleanup().catch(() => undefined)
        }
        this.idleCallbackId = null
      },
      { timeout: 1000 },
    )
  }

  private beginCleanup(): Promise<void> {
    if (this.cleanupPromise) return this.cleanupPromise

    const completion = createDeferred<void>()
    this.cleanupPromise = completion.promise
    this.cleaningUp = true
    const localFailures: Array<unknown> = []
    let synchronousSyncFailure: { error: unknown } | undefined
    let syncCleanupComplete = true

    const attempt = (callback: () => void): void => {
      try {
        callback()
      } catch (error) {
        localFailures.push(error)
      }
    }

    // Dependents must stop using the discarded sync run immediately, while
    // the public status and cleanup promise still wait for adapter settlement.
    for (const callback of [...this.cleanupStartCallbacks]) {
      attempt(callback)
    }

    const finish = (syncFailure?: { error: unknown }) => {
      // Sync cleanup either completes synchronously or calls this once after
      // its promise settles. Reentrant callers share cleanupPromise above.
      this.cleaningUp = false
      this.statusError = null
      attempt(() => this.setStatus(`cleaned-up`))

      // Active collection subscriptions still depend on lifecycle events.
      // Once the last subscriber leaves, its GC cleanup clears the handlers.
      if (this.changes.activeSubscribersCount === 0) {
        attempt(() => this.events.cleanup())
      }

      // Keep cleanup observably asynchronous even when every release is
      // synchronous. Existing callers may use this turn to let optimistic
      // settlement finish before starting the next operation.
      const failures = syncFailure
        ? [syncFailure.error, ...localFailures]
        : localFailures
      const failure =
        failures.length === 0
          ? undefined
          : {
              error:
                failures.length === 1
                  ? failures[0]
                  : new AggregateError(
                      failures,
                      devBuild() && process.env.NODE_ENV !== `production`
                        ? syncFailure
                          ? `Adapter cleanup and local teardown both failed`
                          : `Multiple local teardown steps failed`
                        : codedMessage(173, {
                            syncFailure: syncFailure !== undefined,
                          }),
                      { cause: failures[0] },
                    ),
            }
      void Promise.resolve()
        .then(() => Promise.resolve())
        .then(() => {
          if (this.cleanupPromise === completion.promise) {
            this.cleanupPromise = null
          }
          if (failure) completion.reject(failure.error)
          else completion.resolve()
        })
    }

    // Sync cleanup invalidates the sync run before invoking the adapter. The
    // remaining managers retire their local state synchronously while the
    // adapter's resource-release promise is pending.
    attempt(() => this.cleanupConfig())
    try {
      syncCleanupComplete = this.sync.cleanup(finish)
    } catch (error) {
      synchronousSyncFailure = { error }
    }

    attempt(() => this.state.cleanup())
    attempt(() => this.changes.cleanup())
    attempt(() => this.indexes.cleanup())
    this.hasBeenReady = false
    // Problems belong to the discarded sync run. The cause of a `crashed` or
    // `error` status stays while that status shows; finish() clears it.
    const before = this.problems
    const reloading = this.reloading
    this.adapterStopped = false
    this.failureReason = null
    this.stoppedError = null
    this.requestErrors = new RequestErrors()
    // An abandoned reload announces the tree; otherwise announce dropped
    // problems here, since the status changes only when cleanup settles.
    this.abandonReload(new CollectionPreloadAbortedError())
    const after = this.problems
    if (
      !reloading &&
      (before.error !== after.error ||
        before.failureReason !== after.failureReason)
    )
      this.events.emitHealthChange()
    // Cleanup is not readiness. Sync cleanup rejects pending preload callers;
    // first-ready listeners belong to the discarded run.
    this.onFirstReadyCallbacks = []

    if (syncCleanupComplete) finish(synchronousSyncFailure)
    return completion.promise
  }

  /**
   * Register a callback to be executed when the collection first becomes ready
   * Useful for preloading collections
   * @param callback Function to call when the collection first becomes ready
   */
  public onFirstReady(callback: () => void): () => void {
    // If already ready, call immediately
    if (this.hasBeenReady) {
      callback()
      return () => {}
    }

    this.onFirstReadyCallbacks.push(callback)
    return () => {
      const index = this.onFirstReadyCallbacks.indexOf(callback)
      if (index !== -1) {
        this.onFirstReadyCallbacks.splice(index, 1)
      }
    }
  }

  public cleanup(): Promise<void> {
    this.cancelGCTimer()

    // Return this exact retirement operation even if an event listener starts
    // a replacement run and cleanup while the first promise is still pending.
    return this.beginCleanup()
  }
}

/** @internal Requests that failed after initial-query readiness, by issuer. */
export class RequestErrors {
  // Each failed request's errors.
  private readonly failed = new Map<string, Array<unknown>>()
  // Each failure still failing, by its error, with how many failed requests
  // list it. Two requests that failed with one error share one failure, and
  // it keeps its place among the failures: insertion order is failure order.
  private readonly open = new Map<
    unknown,
    { failure: CollectionErrorInfo; requests: number }
  >()
  problem: HealthProblem | null = null

  /** Returns whether the problem changed. */
  fail(key: string, errors: Array<unknown>): boolean {
    // The request's failures count before its earlier ones leave, so a
    // failure it lists again stays open: it keeps its place and its `at`.
    let changed = false
    for (const error of errors) {
      const entry = this.open.get(error)
      if (entry) entry.requests++
      else {
        this.open.set(error, { failure: describeProblem(error), requests: 1 })
        changed = true
      }
    }
    changed = this.release(key) || changed
    this.failed.set(key, errors)
    return changed && this.show()
  }

  /** Returns whether the problem changed. */
  settle(key: string): boolean {
    return this.release(key) && this.show()
  }

  // Removes a request's failures; returns whether one stopped failing.
  private release(key: string): boolean {
    let changed = false
    for (const error of this.failed.get(key) ?? []) {
      const entry = this.open.get(error)!
      if (--entry.requests === 0) changed = this.open.delete(error)
    }
    this.failed.delete(key)
    return changed
  }

  // The newest failure still failing shows, and the problem lists every
  // failure still failing, oldest first. It is a new object only when that
  // list changed.
  private show(): true {
    const errors = Array.from(this.open.values(), ({ failure }) => failure)
    this.problem = errors.length > 0 ? problemOf(errors) : null
    return true
  }
}

/**
 * @internal A health problem: its newest failure's fields, with every
 * failure of its frame listed, oldest first.
 */
export function problemOf(
  errors: ReadonlyArray<CollectionErrorInfo>,
): HealthProblem {
  const newest = errors[errors.length - 1]!
  return withCause({ ...newest, errors }, newest.cause)
}

// The original value stays available but out of JSON.
function withCause<T extends object>(
  info: T,
  cause: unknown,
): T & { cause: unknown } {
  return Object.defineProperty(info, `cause`, {
    value: cause,
    enumerable: false,
  }) as T & { cause: unknown }
}

/** @internal Health tree detail for a problem value. */
export function describeProblem(error: unknown): CollectionErrorInfo {
  // Any value can be a problem. One that cannot describe itself, such as an
  // object without a prototype or a throwing getter, keeps what was read.
  let name = `Error`
  let message = `Collection is in an error state`
  let code: unknown
  try {
    if (error instanceof Error) {
      name = error.name
      message = error.message
    } else if (error !== undefined) message = String(error)
    code = (error as { code?: unknown } | null)?.code
  } catch {
    // The problem still shows, with the generic parts.
  }
  return withCause(
    {
      name,
      message,
      ...(typeof code === `string` ? { code } : {}),
      at: Date.now(),
    },
    error,
  )
}
