import {create} from 'zustand'

import {createCoalescer} from '@/lib/coalesce'
import {cleanError} from '@/lib/errors'
import {onActive} from '@/lib/network'
import {
  deadEntries,
  enqueue,
  lastInScope,
  nextWakeAt,
  pendingCount,
  redrive,
  remove,
  scopeMatches,
  type Outbox,
  type OutboxEntry,
  type RetryPolicy,
} from '@/lib/outbox'
import {clearStagedImages} from '@/lib/outbox-image'
import {createOutboxRunner} from '@/lib/outbox-runner'
import {type Parse} from '@/lib/snapshot-cache'
import {snapshot} from '@/lib/snapshots'
import {registerReset} from '@/lib/store-reset'
import {uuid} from '@/lib/uuid'

// the offline-write runtime: a persisted at-least-once queue that replays
// mutations when the network allows. the queue's shape and every ordering /
// backoff / dead-letter rule live in the pure @/lib/outbox (unit-tested); this
// module owns only the effects — persistence, the network handlers, the wall
// clock and the replay triggers

// a handler performs the real mutation for one kind: resolve = the server
// durably confirmed (ack + drop); throw = failed (classified + retried or
// dead-lettered). rollback undoes the optimistic effect when a dead entry is
// discarded, so a create that can never sync leaves nothing behind.
// createsScope marks a kind that creates the entity its scope names: every
// later entry in the scope targets that entity, so discarding the create
// cascades — the whole chain rolls back and drops
type Handler = {
  run: (payload: unknown) => Promise<void>
  rollback?: (payload: unknown) => void
  createsScope?: boolean
}

const handlers = new Map<string, Handler>()

// drain entries persisted from a prior session once their handler exists.
// deferred + coalesced: stores register at module eval, before their siblings
// have registered their kinds — flushing inline would race those entries into
// the no-handler park. one microtask flush after the batch settles drains all
let bootstrapFlushQueued = false

export const registerOutboxHandler = (
  kind: string,
  handler: Handler,
): void => {
  handlers.set(kind, handler)
  if (bootstrapFlushQueued) return
  if (!useOutbox.getState().queue.some((e) => e.kind === kind)) return
  bootstrapFlushQueued = true
  queueMicrotask(() => {
    bootstrapFlushQueued = false
    void flushOutbox()
  })
}

// co-locate a kind's payload type with its handler and return the matching
// typed enqueuer, so an enqueue site and its replay body can never drift. the
// registry itself stays payload-opaque; this is the one place that bridges it
export const defineMutation = <P>(
  kind: string,
  handler: {
    run: (payload: P) => Promise<void>
    rollback?: (payload: P) => void
    createsScope?: boolean
  },
): ((input: {
  scope: string
  payload: P
  label?: string
  id?: string
}) => void) => {
  registerOutboxHandler(kind, handler as Handler)
  return (input) => enqueueMutation({kind, ...input})
}

// retry ~8 times over a jittered 1s→20s backoff before a *server* failure is
// declared terminal; offline failures ignore the cap and wait for reconnect
const POLICY: RetryPolicy = {
  maxAttempts: 8,
  base: 1000,
  cap: 20_000,
  floor: 1000,
  random: Math.random,
}

// cap concurrent replays so reconnecting with a deep queue doesn't fire every
// scope's request at once (a thundering herd on the api + the device radio).
// ~matches a browser's per-host connection limit — enough parallelism, bounded
const FLUSH_CONCURRENCY = 6

// server-derived refreshes a handler wants after it syncs (the feed, planned
// crumbs, a trail detail). handlers register them by key via afterFlush instead
// of firing inline, so a bulk drain runs each once when the queue settles
// rather than once per entry. only call afterFlush from within a handler run —
// the runner flushes these when the drain completes
const refreshes = createCoalescer()
export const afterFlush = refreshes.schedule

// envelope-only validation: id/kind/scope/bookkeeping must be well-formed, but
// the kind-specific payload is opaque here and validated by its handler's rpc
const parseOutbox: Parse<Outbox> = (raw) => {
  if (!Array.isArray(raw)) return undefined
  const entries: Outbox = []
  for (const item of raw) {
    if (typeof item !== 'object' || item === null) return undefined
    const {id, kind, scope, attempts, nextAttemptAt, createdAt} =
      item as Record<string, unknown>
    if (
      typeof id !== 'string' ||
      typeof kind !== 'string' ||
      typeof scope !== 'string' ||
      typeof attempts !== 'number' ||
      typeof nextAttemptAt !== 'number' ||
      typeof createdAt !== 'number'
    )
      return undefined
    entries.push(item as OutboxEntry)
  }
  return entries
}

const outboxSnap = snapshot('outbox', 1, parseOutbox)

// the queue survives restarts: a mutation enqueued offline replays on the next
// launch that finds a network, so closing the app never loses a write
const useOutbox = create<{queue: Outbox}>(() => ({
  queue: outboxSnap.read() ?? [],
}))

useOutbox.subscribe((state, prev) => {
  if (state.queue !== prev.queue) outboxSnap.write(state.queue)
})

// unsynced writes belong to the signed-in account; wipe them on sign-out /
// account switch / 401 like every other cache, so account A's queued mutations
// never replay into account B's session
registerReset(() => {
  useOutbox.setState({queue: []})
  outboxSnap.clear()
  // the queue is gone, so no entry references a staged image anymore
  clearStagedImages()
})

const setQueue = (map: (queue: Outbox) => Outbox): void =>
  useOutbox.setState((state) => ({queue: map(state.queue)}))

let timer: ReturnType<typeof setTimeout> | undefined

// wake exactly once, at the soonest backoff gate, so a retry fires without the
// user having to touch anything (a foreground/reconnect trigger arrives sooner
// in practice; this is the fallback that makes offline retries self-driving).
// a function declaration so the runner's onSettle can reference it above its
// own const — both only run after this module has finished initializing
function scheduleWake(): void {
  if (timer) clearTimeout(timer)
  timer = undefined
  const wake = nextWakeAt(useOutbox.getState().queue)
  if (wake === undefined) return
  timer = setTimeout(
    () => void runner.flush(),
    Math.max(0, wake - Date.now()),
  )
}

// wire the pure drain engine (@/lib/outbox-runner) to the real effects: the
// zustand queue, the wall clock, cleanError, and — once a drain settles — the
// coalesced refreshes plus the next backoff wake
const runner = createOutboxRunner({
  getQueue: () => useOutbox.getState().queue,
  setQueue,
  getHandler: (kind) => handlers.get(kind),
  now: Date.now,
  policy: POLICY,
  concurrency: FLUSH_CONCURRENCY,
  describeError: cleanError,
  onSettle: () => {
    refreshes.flush()
    scheduleWake()
  },
})

export const flushOutbox = (): Promise<void> => runner.flush()

// queue a mutation and try to flush it now. the caller has already applied the
// optimistic effect; this returns immediately so the ui never waits on the
// network. scope serializes a chain (all of one crumb's edits), the id is the
// idempotency key a replayed create dedupes on
export const enqueueMutation = (input: {
  kind: string
  scope: string
  payload: unknown
  label?: string
  id?: string
}): void => {
  setQueue((queue) =>
    enqueue(queue, {
      id: input.id ?? uuid(),
      kind: input.kind,
      scope: input.scope,
      payload: input.payload,
      label: input.label,
      attempts: 0,
      nextAttemptAt: 0,
      createdAt: Date.now(),
    }),
  )
  void flushOutbox()
}

// retry a dead-lettered mutation from scratch (manual redrive)
export const redriveMutation = (id: string): void => {
  setQueue((queue) => redrive(queue, id))
  void flushOutbox()
}

// redrive every dead entry, then flush — the "retry all / sync now" action.
// one setQueue commit (one store notification, one persisted write), one flush
export const redriveAll = (): void => {
  setQueue((queue) =>
    deadEntries(queue).reduce((acc, e) => redrive(acc, e.id), queue),
  )
  void flushOutbox()
}

// give up on a dead-lettered mutation: undo its optimistic effect, then drop
// it. discarding a create moots everything queued behind it in the scope —
// those writes target an entity that will never exist — so the whole chain
// rolls back (newest optimistic effect first) and drops with it
export const discardMutation = (id: string): void => {
  const queue = useOutbox.getState().queue
  const entry = queue.find((candidate) => candidate.id === id)
  if (!entry) return
  const targets = handlers.get(entry.kind)?.createsScope
    ? queue.filter((e) => e.scope === entry.scope).reverse()
    : [entry]
  for (const target of targets)
    handlers.get(target.kind)?.rollback?.(target.payload)
  setQueue((q) => targets.reduce((acc, t) => remove(acc, t.id), q))
}

// an entity deleted before its queued create ever synced: nothing exists
// server-side, so drop the whole queued chain (create entry id = entity id,
// discarding a create cascades its scope) instead of replaying a pointless
// create → delete round trip. true when a queued create was found and dropped
export const discardQueuedCreate = (
  kind: string,
  id: string,
): boolean => {
  if (!hasQueuedMutation(kind, id)) return false
  discardMutation(id)
  return true
}

// scope-level redrive/discard for the per-entity failed affordance: act on
// every dead entry of one aggregate (a crumb) without the ui tracking ids.
// segment-matched so composite scopes (a reaction's 'trailId:crumbId') are
// reachable from either entity's affordance
export const redriveScope = (id: string): void => {
  setQueue((queue) =>
    deadEntries(queue)
      .filter((e) => scopeMatches(e.scope, id))
      .reduce((acc, e) => redrive(acc, e.id), queue),
  )
  void flushOutbox()
}

export const discardScope = (id: string): void => {
  for (const entry of getFailedMutations())
    if (scopeMatches(entry.scope, id)) discardMutation(entry.id)
}

// for an acking handler: whether to repaint the server copy into local state.
// while the scope still holds queued followers their optimistic effects are
// fresher than this response — the chain's last ack reconciles (the running
// entry is still queued during its own run, so "last" means "only")
export const shouldReconcile = (scope: string): boolean =>
  lastInScope(useOutbox.getState().queue, scope)

// replay on reconnect and on foreground — the moments connectivity most likely
// just returned. requests always attempt regardless; these only make a queued
// write sync sooner than its next backoff gate
onActive(() => void flushOutbox())

// drop every queued mutation of one kind in a scope without rollback — for a
// store replacing a superseded write (an older queued handle change) with a
// newer one whose optimistic effect already covers it. returns the oldest
// dropped entry so the caller can carry over its payload (the original
// pre-change value a rollback chain reverts to)
export const supersedeMutation = (
  kind: string,
  scope: string,
): OutboxEntry | undefined => {
  const entries = useOutbox
    .getState()
    .queue.filter((e) => e.kind === kind && e.scope === scope)
  if (entries.length)
    setQueue((queue) =>
      entries.reduce((acc, e) => remove(acc, e.id), queue),
    )
  return entries[0]
}

// whether discarding an entity's failed chain removes the entity itself:
// true when the dead chain includes the entity's own create (discarding a
// create cascades its scope) — the ui leaves the entity's screen only then
export const discardRemovesEntity = (id: string): boolean =>
  useOutbox
    .getState()
    .queue.some(
      (e) =>
        e.dead &&
        scopeMatches(e.scope, id) &&
        !!handlers.get(e.kind)?.createsScope,
    )

// same question for a single mutation by its entry id
export const mutationRemovesEntity = (id: string): boolean => {
  const entry = useOutbox.getState().queue.find((e) => e.id === id)
  return !!entry && !!handlers.get(entry.kind)?.createsScope
}

// whether a write of this kind is still queued for the scope — lets a store
// special-case a mutation chained behind its entity's unsynced create
export const hasQueuedMutation = (
  kind: string,
  scope: string,
): boolean =>
  useOutbox
    .getState()
    .queue.some((e) => e.kind === kind && e.scope === scope)

// how long a write must stay in flight before any sync indicator surfaces it,
// so a fast round-trip never flashes "Syncing…" (failed/offline show at once)
export const SYNC_STATUS_DELAY_MS = 500

// per-entity sync state for an inline affordance: a card shows a spinner while
// pending, a retry affordance when failed, nothing once synced
export type SyncState = 'synced' | 'pending' | 'failed'

export const useSyncState = (id: string): SyncState =>
  useOutbox((state) => {
    let pending = false
    for (const entry of state.queue)
      if (scopeMatches(entry.scope, id)) {
        if (entry.dead) return 'failed'
        pending = true
      }
    return pending ? 'pending' : 'synced'
  })

// sync state of a single mutation by its id — for a row that maps one-to-one
// to an entry (a dm bubble, whose message id is its entry id) rather than to a
// whole scope. a scope may hold several entries; this tracks exactly one
export const useEntrySyncState = (id: string): SyncState =>
  useOutbox((state) => {
    const entry = state.queue.find((e) => e.id === id)
    return !entry ? 'synced' : entry.dead ? 'failed' : 'pending'
  })

export const usePendingCount = (): number =>
  useOutbox((state) => pendingCount(state.queue))

export const useFailedCount = (): number =>
  useOutbox((state) => deadEntries(state.queue).length)

export const getFailedMutations = (): Outbox =>
  deadEntries(useOutbox.getState().queue)

// the whole queue for the pending-changes surface (stable ref — the array only
// changes when the queue does)
export const useOutboxEntries = (): Outbox =>
  useOutbox((state) => state.queue)
