import {ApiError, isNetworkError} from '@/lib/errors'

// a durable at-least-once mutation queue: the one primitive behind offline
// writes. pure and time-injected like snapshot-cache — the runner owns i/o,
// timers and the network; this owns only the queue's shape and its transitions,
// so every ordering, backoff and dead-letter rule is unit-testable in isolation

export type OutboxEntry = {
  id: string // client-minted uuid; doubles as the mutation's idempotency key
  kind: string // handler selector, eg 'crumb.create'
  // aggregate-root id: entries sharing a scope replay strictly in fifo (a
  // crumb's create must land before its edit); different scopes replay in
  // parallel. the tanstack scope model — no dependency graph needed
  scope: string
  payload: unknown // handler input, already json-safe for persistence
  // a human summary for the pending-changes surface, eg the crumb's name
  label?: string
  attempts: number
  nextAttemptAt: number // epoch ms backoff gate; 0 = eligible immediately
  createdAt: number // fifo tiebreak within a scope
  lastError?: string
  // dead-lettered: a terminal failure (4xx) or the retry cap. skipped by replay
  // and kept for a visible "failed changes" surface with manual redrive
  dead?: boolean
}

export type Outbox = OutboxEntry[]

export const enqueue = (
  queue: Outbox,
  entry: OutboxEntry,
): Outbox => [...queue, entry]

// ack: the server durably confirmed, so the entry is gone for good
export const remove = (queue: Outbox, id: string): Outbox =>
  queue.filter((e) => e.id !== id)

const patch = (
  queue: Outbox,
  id: string,
  fn: (entry: OutboxEntry) => OutboxEntry,
): Outbox => queue.map((e) => (e.id === id ? fn(e) : e))

// a retryable failure: bump the attempt count and gate the next try on backoff
export const retryLater = (
  queue: Outbox,
  id: string,
  nextAttemptAt: number,
  error: string,
): Outbox =>
  patch(queue, id, (e) => ({
    ...e,
    attempts: e.attempts + 1,
    nextAttemptAt,
    lastError: error,
  }))

// gate an entry without burning an attempt — for a failure that isn't the
// mutation's own doing (its handler isn't registered yet), so waiting never
// eats into the retry budget
export const park = (
  queue: Outbox,
  id: string,
  nextAttemptAt: number,
  error: string,
): Outbox =>
  patch(queue, id, (e) => ({...e, nextAttemptAt, lastError: error}))

// a terminal failure: park it for manual redrive, never resend automatically
export const deadLetter = (
  queue: Outbox,
  id: string,
  error: string,
): Outbox =>
  patch(queue, id, (e) => ({...e, dead: true, lastError: error}))

// clear the dead flag and backoff so replay retries it from scratch
export const redrive = (queue: Outbox, id: string): Outbox =>
  patch(queue, id, (e) => ({
    ...e,
    dead: false,
    attempts: 0,
    nextAttemptAt: 0,
    lastError: undefined,
  }))

// the fifo head (oldest) of each scope: the only entry per scope eligible to
// run, so a scope replays strictly in order
const scopeHeads = (queue: Outbox): OutboxEntry[] => {
  const heads = new Map<string, OutboxEntry>()
  for (const entry of queue) {
    const head = heads.get(entry.scope)
    if (!head || entry.createdAt < head.createdAt)
      heads.set(entry.scope, entry)
  }
  return [...heads.values()]
}

// the runnable heads: a dead or backed-off head yields nothing for its scope —
// its followers wait, preserving create-before-edit
export const readyHeads = (
  queue: Outbox,
  now: number,
): OutboxEntry[] =>
  scopeHeads(queue).filter((e) => !e.dead && e.nextAttemptAt <= now)

// full-jitter exponential backoff (aws): random(0, min(cap, base·2^attempts)).
// jitter desynchronizes the reconnect stampede; the cap bounds the tail
export const backoffDelay = (
  attempts: number,
  {
    base,
    cap,
    random,
  }: {base: number; cap: number; random: () => number},
): number =>
  Math.round(random() * Math.min(cap, base * 2 ** attempts))

// why an attempt failed, coarse enough to drive the retry rule:
// - offline: the request never reached a server (no verdict)
// - server: a 5xx/408/429 — reached the server, which failed transiently
// - terminal: a 4xx the client owns (validation, forbidden) — resending won't help
export type FailureClass = 'offline' | 'server' | 'terminal'

// classify the error a handler threw. a coded http response is a verdict: a
// 4xx is the server refusing (terminal), a 5xx/408/429 is transient (retry,
// dead-letter past the cap). a network failure got no verdict, so it is never
// the mutation's fault: treat it as offline and keep retrying. any other throw
// (a programming error, a bad payload, an fs failure) would recur identically
// on every replay — classing it offline would retry it forever and wedge its
// scope's fifo head, so it counts toward the attempt cap like a server error
export const classifyFailure = (err: unknown): FailureClass => {
  if (err instanceof ApiError)
    return err.status >= 500 ||
      err.status === 408 ||
      err.status === 429
      ? 'server'
      : 'terminal'
  return isNetworkError(err) ? 'offline' : 'server'
}

export type RetryPolicy = {
  maxAttempts: number
  base: number
  cap: number
  floor: number
  random: () => number
}

// the transition after a failed attempt. offline never dead-letters — being
// offline is not the mutation's fault, so it waits (capped-backoff) for the
// link to return; a transient server error dead-letters only past the attempt
// cap; a terminal client error dead-letters at once. the floor keeps a
// zero-jitter draw from spinning the runner in a hot retry loop
export const nextAttempt = (
  entry: OutboxEntry,
  failure: FailureClass,
  now: number,
  {maxAttempts, base, cap, floor, random}: RetryPolicy,
): {retry: number} | {dead: true} => {
  if (failure === 'terminal') return {dead: true}
  if (failure === 'server' && entry.attempts + 1 >= maxAttempts)
    return {dead: true}
  return {
    retry:
      now +
      Math.max(
        floor,
        backoffDelay(entry.attempts, {base, cap, random}),
      ),
  }
}

// the earliest backoff gate among live scope heads: when the runner should
// next wake if nothing else triggers it. only heads are runnable, so a
// follower's gate (often 0) must never schedule a wake — behind a backed-off
// head it would spin the runner in a zero-delay loop. a dead head contributes
// nothing (its followers wait on a manual redrive, which flushes explicitly);
// undefined when no live head remains
export const nextWakeAt = (queue: Outbox): number | undefined => {
  let soonest: number | undefined
  for (const entry of scopeHeads(queue))
    if (
      !entry.dead &&
      (soonest === undefined || entry.nextAttemptAt < soonest)
    )
      soonest = entry.nextAttemptAt
  return soonest
}

// whether the scope's currently-draining entry is its last: only the chain's
// last ack should reconcile the server copy into local state — an earlier ack
// (a create) carries a copy older than later queued entries' optimistic
// effects (a rename, a pin, an image) and would visibly revert them
export const lastInScope = (
  queue: Outbox,
  scope: string,
): boolean => {
  let n = 0
  for (const entry of queue)
    if (entry.scope === scope && ++n > 1) return false
  return true
}

// a composite scope ('trailId:crumbId' for per-target reactions) belongs to
// each of its segment entities, so both the trail's and the crumb's inline
// affordances (badge, retry, discard) see its entries
export const scopeMatches = (scope: string, id: string): boolean =>
  scope === id || scope.split(':').includes(id)

// entries parked for manual redrive — the "failed changes" surface
export const deadEntries = (queue: Outbox): Outbox =>
  queue.filter((e) => e.dead)

// live (not-yet-synced) entries — drives the pending indicator
export const pendingCount = (queue: Outbox): number =>
  queue.reduce((n, e) => (e.dead ? n : n + 1), 0)
