/**
 * @module db/errors
 * @description Error types for the 8.0 generational-MVCC Db API.
 *
 * Three failure modes have dedicated classes so callers can branch on them
 * with `instanceof` instead of string matching:
 *
 * - {@link GenerationConflictError} — the store's current generation differs
 *   from what the caller's operation requires: a `transact()`
 *   compare-and-swap (`ifAtGeneration`) observed a different generation than
 *   expected, or `db.persist()` was called on a view that history has moved
 *   past (snapshots capture current bytes, so persisting requires the view
 *   to still be the latest generation). The standard retry pattern is:
 *   re-read via `brain.now()`, re-derive the operation, and re-submit.
 * - {@link SpeculativeOverlayError} — an index-accelerated query (vector /
 *   hybrid / graph-traversal search, cursors, aggregation) or `persist()`
 *   was issued against a speculative `db.with()` overlay. Overlays are pure
 *   in-memory values whose entities carry no embeddings (`with()` never
 *   invokes the embedder), so a "full" index query over one would silently
 *   miss the overlay's own entities — an honest error beats silently-wrong
 *   results. Commit the operations with `brain.transact()` to get the full
 *   query surface. Historical (non-overlay) views do NOT throw this: they
 *   serve the full query surface via at-generation index materialization.
 * - {@link GenerationCompactedError} — `asOf()` asked for a generation whose
 *   immutable records were reclaimed by `compactHistory()`.
 *
 * All three are exported from the package root (`@soulcraft/brainy`).
 */

/**
 * @description Thrown when an operation requires the store to be at a
 * specific generation and it is not:
 *
 * - `brain.transact(ops, { ifAtGeneration })` — the whole-store
 *   compare-and-swap counterpart to the per-entity `ifRev` /
 *   `RevisionConflictError` pair: it guarantees that *nothing* was committed
 *   between the caller's read (`brain.now()`) and this transaction. The
 *   transaction is rejected before any record is staged — the store is
 *   untouched and the generation counter is unchanged.
 * - `db.persist(path)` — snapshots capture current bytes, so persisting
 *   requires the view to still be the store's **latest** generation. A view
 *   that history has moved past throws this error: pin with `brain.now()`
 *   and persist before further writes.
 *
 * @example
 * const db = brain.now()
 * try {
 *   await brain.transact(ops, { ifAtGeneration: db.generation })
 * } catch (err) {
 *   if (err instanceof GenerationConflictError) {
 *     // Someone committed since we pinned — re-read and retry.
 *     console.log(`expected ${err.expected}, store is at ${err.actual}`)
 *   }
 * }
 */
export class GenerationConflictError extends Error {
  /** The generation the operation required the store to be at. */
  public readonly expected: number
  /** The generation the store was actually at. */
  public readonly actual: number

  /**
   * @param expected - The required generation (`ifAtGeneration`, or the
   *   pinned generation of the view being persisted).
   * @param actual - The store's current generation.
   */
  constructor(expected: number, actual: number) {
    super(
      `Generation conflict: the operation requires the store at generation ${expected}, ` +
        `but it is at generation ${actual}. Another write committed in between. ` +
        `Re-read with brain.now(), rebuild the operation, and retry.`
    )
    this.name = 'GenerationConflictError'
    this.expected = expected
    this.actual = actual
  }
}

/**
 * @description Thrown when an operation on a speculative `db.with()` overlay
 * requires index machinery (vector / hybrid / graph-traversal search, cursor
 * pagination, aggregation) or durability (`persist()`).
 *
 * Why this single boundary exists: overlays are pure in-memory values —
 * `with()` never touches disk, the generation counter, or the embedder, so
 * overlay entities carry **no embeddings**. A "full" vector or hybrid query
 * over an overlay would silently exclude the overlay's own entities, and a
 * persisted overlay would be a store whose entities cannot be semantically
 * searched. An honest error beats silently-wrong results. Commit the
 * operations with `brain.transact()` to get the full query surface, or query
 * the overlay's base view (historical views serve the complete surface via
 * at-generation index materialization).
 *
 * `get()`, metadata-filter `find()`, and filter-based `related()` remain
 * fully supported on overlays.
 *
 * @example
 * const spec = await brain.now().with([{ op: 'add', type, data: 'draft' }])
 * await spec.find({ where: { draft: true } })  // ✅ metadata find — supported
 * await spec.find({ query: 'semantic query' }) // ❌ throws this error
 * await brain.transact([{ op: 'add', type, data: 'draft' }]) // → full surface
 */
export class SpeculativeOverlayError extends Error {
  /** The capability that is not supported on speculative overlays. */
  public readonly capability: string
  /** The overlay's pinned base generation. */
  public readonly generation: number

  /**
   * @param capability - Human-readable name of the unsupported capability
   *   (e.g. `'vector search'`, `'persist'`).
   * @param generation - The overlay's pinned base generation.
   */
  constructor(capability: string, generation: number) {
    super(
      `${capability} is not supported on a speculative with() overlay ` +
        `(pinned at base generation ${generation}). Overlays are pure in-memory ` +
        `values whose entities carry no embeddings, so index-accelerated reads ` +
        `over them would be silently incomplete. Supported on overlays: get(), ` +
        `metadata-filter find(), and filter-based related(). Commit the operations ` +
        `with brain.transact() for the full query surface, or query the base view.`
    )
    this.name = 'SpeculativeOverlayError'
    this.capability = capability
    this.generation = generation
  }
}

/**
 * @description Thrown by `brain.asOf(generationOrTimestamp)` when the
 * requested generation's history was reclaimed by `brain.compactHistory()`.
 * The store records the compaction horizon (the highest reclaimed
 * generation) in its manifest; any generation BELOW the horizon is
 * unreachable — its reads would need the reclaimed before-images. The
 * horizon itself stays reachable, resolved from the record-sets above it.
 *
 * To keep a generation readable forever, `persist()` it to a snapshot
 * directory before compacting — snapshots are self-contained and unaffected
 * by compaction of the source store.
 */
export class GenerationCompactedError extends Error {
  /** The generation that was requested. */
  public readonly requested: number
  /** The compaction horizon — generations < this value are unreachable. */
  public readonly horizon: number

  /**
   * @param requested - The generation the caller asked for.
   * @param horizon - The store's current compaction horizon.
   */
  constructor(requested: number, horizon: number) {
    super(
      `Generation ${requested} has been compacted away (compaction horizon: ${horizon}). ` +
        `Only generations at or above the horizon are reachable via asOf(). ` +
        `Use db.persist(path) before compactHistory() to keep a generation readable.`
    )
    this.name = 'GenerationCompactedError'
    this.requested = requested
    this.horizon = horizon
  }
}

/** One entity/relationship left in an unreconciled state by a failed rollback. */
export interface UnreconciledRecord {
  /** The entity or relationship id. */
  id: string
  /** `'noun'` (entity) or `'verb'` (relationship). */
  kind: 'noun' | 'verb'
  /**
   * `'orphan'` — the record is durably PRESENT but the transaction aborted
   * (an add whose delete-undo failed); `'loss'` — the record is durably GONE
   * or wrong when it should have been restored (a remove/update whose
   * restore-undo failed — actual data loss).
   */
  disposition: 'orphan' | 'loss'
}

/**
 * @description Thrown when a transaction's rollback could not be fully applied
 * and the resulting inconsistency cannot be safely adopted forward — i.e. a
 * multi-operation batch, or ANY case where a record was lost (a remove/update
 * whose restore-undo failed). The store is left in a known-inconsistent state:
 * the {@link records} name every entity/relationship whose canonical state no
 * longer matches what the aborted transaction should have produced.
 *
 * On throw, the brain enters **write-quarantine** — reads continue to work, but
 * further writes are refused until {@link } `repairIndex()` reconciles the
 * derived indexes against canonical storage and lifts the quarantine. This is
 * the honest, loud failure: a visible inconsistency the operator must repair,
 * never a silent partial write. The generation counter is NOT advanced.
 *
 * (A SINGLE-op add whose only damage is a durably-present orphan is NOT this
 * error: it is adopted forward as a committed generation and returned as a
 * degraded-but-successful write — the record the caller asked for exists.)
 *
 * @example
 * try {
 *   await brain.transact(ops)
 * } catch (err) {
 *   if (err instanceof StoreInconsistentError) {
 *     console.error('store inconsistent:', err.records) // ids + dispositions
 *     await brain.repairIndex() // reconcile + lift the write-quarantine
 *   }
 * }
 */
export class StoreInconsistentError extends Error {
  /** Every record left in an unreconciled state by the failed rollback. */
  public readonly records: UnreconciledRecord[]
  /** The original error that triggered the (then-failed) rollback. */
  public override readonly cause: Error

  /**
   * @param records - The unreconciled entities/relationships (ids + disposition).
   * @param cause - The error that triggered the rollback.
   */
  constructor(records: UnreconciledRecord[], cause: Error) {
    const orphans = records.filter((r) => r.disposition === 'orphan').length
    const losses = records.filter((r) => r.disposition === 'loss').length
    super(
      `Store left inconsistent by a failed transaction rollback: ` +
        `${records.length} record(s) could not be reconciled ` +
        `(${orphans} orphaned, ${losses} lost). ` +
        `The brain is now WRITE-QUARANTINED (reads still work) — run repairIndex() ` +
        `to reconcile the derived indexes against canonical storage and lift the ` +
        `quarantine. Ids: ${records.map((r) => `${r.id}:${r.disposition}`).join(', ')}. ` +
        `Cause: ${cause.message}`
    )
    this.name = 'StoreInconsistentError'
    this.records = records
    this.cause = cause
  }
}

/**
 * @description Thrown by a write when the store cannot make single-op
 * generation **history** durable: the asynchronous group-commit flush that
 * persists buffered before-images to disk has failed repeatedly (a real
 * storage fault — a full disk, an EIO, a permission loss — not a transient
 * blip). Rather than keep accepting writes whose history silently piles up in
 * memory and is never persisted — an invisible durability loss, and an
 * unbounded leak — the store LATCHES this failure and refuses further writes
 * until the pending tier drains.
 *
 * This is the loud, honest response to a broken history-durability path:
 * - Live canonical data is unaffected (single-op live bytes are written before
 *   the history flush; only the immutable before-image history is stuck).
 * - The background flush keeps retrying with backoff; when the underlying fault
 *   clears and a flush succeeds, the latch lifts and writes resume
 *   automatically. An explicit `flush()` also clears it on success.
 * - {@link cause} is the underlying storage error from the last failed flush.
 *
 * A caller seeing this should treat it exactly like a full disk: stop writing,
 * resolve the storage fault, and retry. It is NOT a data-corruption error — no
 * committed generation is lost — it is a refusal to *promise* durability the
 * store currently cannot deliver.
 *
 * @example
 * try {
 *   await brain.add({ ... })
 * } catch (err) {
 *   if (err instanceof PendingFlushDurabilityError) {
 *     // History can't be persisted right now (disk fault). Resolve storage,
 *     // then retry — the store self-heals once a flush succeeds.
 *     console.error('history durability stalled:', err.cause)
 *   }
 * }
 */
export class PendingFlushDurabilityError extends Error {
  /** The underlying storage error from the most recent failed flush. */
  public override readonly cause: Error
  /** How many consecutive flush attempts had failed when the latch tripped. */
  public readonly failedAttempts: number

  /**
   * @param cause - The storage error from the last failed pending-tier flush.
   * @param failedAttempts - Consecutive failed flush attempts at latch time.
   */
  constructor(cause: Error, failedAttempts: number) {
    super(
      `Write refused: single-op generation history could not be made durable ` +
        `after ${failedAttempts} consecutive flush attempts (${cause.message}). ` +
        `Live data is intact, but buffered history is not yet persisted — the ` +
        `store refuses further writes rather than silently accumulate ` +
        `un-durable history. Resolve the underlying storage fault; the store ` +
        `self-heals and resumes writes once a flush succeeds. Cause: ${cause.message}`
    )
    this.name = 'PendingFlushDurabilityError'
    this.cause = cause
    this.failedAttempts = failedAttempts
  }
}
