/**
 * Tracked-generation context + run queue.
 *
 * The SSE lifecycle (AbortController, event handlers, accumulated result)
 * lives in a React context mounted at the App level. That way navigating
 * between pages — e.g. from Text→Video's "Start Process" to the Processes
 * tab — doesn't unmount the component that owns the stream and doesn't
 * orphan the in-flight request.
 *
 * A FIFO **queue** sits on top of the single-run executor. Wizards call
 * `generate(...)`; if nothing is running it kicks off immediately, otherwise
 * the job is enqueued and the provider auto-dequeues when the current run
 * reaches a terminal state. This gives users "submit more work while the
 * first run is executing" without any extra UI on the wizards.
 */
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react'
import type { ReactNode } from 'react'
import { useGenerate } from './useGenerate'
import type { GenerationState } from './useGenerate'
import { useRuns } from '../store/runs'
import type { Run, RunTool } from '../store/runs'
import { useToast } from '../store/toast'
import type { BackendRunDetail, GenerateSettings, PendingClientQueueItem } from '../api/types'
import { api } from '../api/client'
import type { ReplacementTargets } from '../lib/processEditHandoff'
import { useSettings } from '../store/settings'

type PendingMeta = Omit<Run, 'id' | 'status' | 'startedAt'>

export type QueueItemKind = 'text' | 'html' | 'image'

export interface QueueItem {
  id: string
  tool: RunTool
  kind: QueueItemKind
  inputPreview: string
  inputText?: string
  queuedAt: number
  settings?: GenerateSettings
  // payload variants — only one of these is populated per item
  text?: string
  html?: string
  formData?: FormData
  files?: File[]
  replaceTargets?: ReplacementTargets
}

export interface EnqueueResult {
  /** Stable queue id — clients can show this in UI or pass to `cancelQueued`. */
  queueId: string
  /** True when this item started executing immediately (queue was empty). */
  startedImmediately: boolean
  /**
   * When set, the new submission was a client-side duplicate of an already
   * queued or currently-running item; `queueId` points at that existing
   * item (so callers can still navigate to it) and no new work was
   * enqueued. Wizards show a toast and jump to Processes instead of
   * spawning a second identical run.
   */
  duplicateOf?: 'active' | 'queued'
}

interface TrackedGenerationContextValue {
  state: GenerationState
  queue: QueueItem[]
  /**
   * True when the queue stopped auto-dispatching after a backend
   * rejection. `resumeQueue()` (or any new `enqueueX` call) clears it.
   */
  paused: boolean
  /** Reason the queue is currently paused, when applicable. */
  pausedReason: 'in_flight' | 'duplicate' | 'unknown' | null
  queueModeNotice: string | null
  dismissQueueModeNotice: () => void
  cancel: (options?: {
    mode?: 'now' | 'after_html' | 'after_screenshots' | 'after_pptx' | 'after_video'
    delete_outputs?: boolean
  }) => void
  cancelQueued: (queueId: string) => void
  pauseQueue: () => void
  /** Manually resume auto-dispatch after a 409-induced pause. */
  resumeQueue: () => void
  reorderQueued: (queueId: string, targetQueueId: string) => void
  updateQueued: (queueId: string, patch: Partial<Pick<QueueItem, 'text' | 'html' | 'settings'>>) => void
  reset: () => void
  enqueueText: (
    tool: RunTool,
    text: string,
    settings: GenerateSettings,
    options?: { replaceTargets?: ReplacementTargets },
  ) => EnqueueResult
  enqueueHtml: (
    tool: RunTool,
    html: string,
    settings: GenerateSettings,
    options?: { replaceTargets?: ReplacementTargets },
  ) => EnqueueResult
  enqueueImage: (
    tool: RunTool,
    formData: FormData,
    meta: { files: File[]; settings?: GenerateSettings },
  ) => EnqueueResult
}

const Ctx = createContext<TrackedGenerationContextValue | null>(null)

let queueCounter = 0
function nextQueueId(): string {
  queueCounter += 1
  return `queue-${Date.now().toString(36)}-${queueCounter}`
}

/**
 * Cheap deterministic fingerprint of a queue item's *meaningful* payload.
 * Used to short-circuit client-side duplicates BEFORE the POST reaches the
 * backend — the backend already blocks simultaneous duplicates via the
 * input_fingerprint check, but once the first run finishes the backend
 * happily accepts a second identical submission. A user who
 * re-opens the wizard and clicks Start again (or any transient form of
 * double-dispatch) would otherwise chain a second copy the moment the
 * first completes.
 *
 * djb2 on a normalized payload string. Only needs to be stable within a
 * single browser session, so we don't need crypto.
 */
function fingerprintItem(item: QueueItem): string {
  const settings = item.settings
    ? JSON.stringify(sortedEntries(item.settings as unknown as Record<string, unknown>))
    : ''
  let payload: string
  if (item.kind === 'text') {
    payload = `text|${item.tool}|${(item.text ?? '').trim()}|${settings}`
  } else if (item.kind === 'html') {
    payload = `html|${item.tool}|${(item.html ?? '').trim()}|${settings}`
  } else {
    const fileSig = (item.files ?? [])
      .map((f) => `${f.name}:${f.size}:${f.lastModified}`)
      .join(',')
    payload = `image|${item.tool}|${fileSig}|${settings}`
  }
  let hash = 5381
  for (let i = 0; i < payload.length; i += 1) {
    hash = ((hash << 5) + hash + payload.charCodeAt(i)) | 0
  }
  return hash.toString(36)
}

function sortedEntries(obj: Record<string, unknown>): Array<[string, unknown]> {
  return Object.keys(obj)
    .sort()
    .map((k) => [k, obj[k]] as [string, unknown])
}

function toPersistedQueueItem(item: QueueItem): PendingClientQueueItem | null {
  if (item.kind !== 'text' && item.kind !== 'html') return null
  if (item.tool !== 'text-to-video' && item.tool !== 'html-to-video') return null
  const payload = item.kind === 'text' ? item.text : item.html
  if (!payload?.trim()) return null
  return {
    id: item.id,
    tool: item.tool,
    kind: item.kind,
    inputPreview: item.inputPreview,
    inputText: item.inputText,
    queuedAt: item.queuedAt,
    settings: item.settings,
    text: item.kind === 'text' ? payload : undefined,
    html: item.kind === 'html' ? payload : undefined,
  }
}

function fromPersistedQueueItem(item: PendingClientQueueItem): QueueItem | null {
  const payload = item.kind === 'text' ? item.text : item.html
  if (!payload?.trim()) return null
  return {
    id: item.id || nextQueueId(),
    tool: item.tool,
    kind: item.kind,
    inputPreview: item.inputPreview || payload.slice(0, 200),
    inputText: item.inputText ?? payload,
    queuedAt: item.queuedAt || Date.now(),
    settings: item.settings,
    text: item.kind === 'text' ? payload : undefined,
    html: item.kind === 'html' ? payload : undefined,
  }
}

function etaFromBackendRun(run: BackendRunDetail['run']): number | undefined {
  const raw =
    run.settings?.estimated_total_seconds ??
    run.metrics?.estimated_total_seconds ??
    run.metrics?.eta_seconds
  const value = typeof raw === 'number' ? raw : Number(raw)
  return Number.isFinite(value) && value > 0 ? value : undefined
}

async function cleanupReplacementTargets(targets: ReplacementTargets): Promise<void> {
  const seen = new Set<string>()
  const deletions: Array<Promise<unknown>> = []
  const add = (type: Parameters<typeof api.deleteFile>[0], filename?: string) => {
    if (!filename) return
    const key = `${type}:${filename}`
    if (seen.has(key)) return
    seen.add(key)
    deletions.push(api.deleteFile(type, filename).catch(() => undefined))
  }
  add('html', targets.htmlFilename)
  for (const file of targets.screenshotFiles ?? []) add('screenshot', file)
  add('presentation', targets.presentationFile)
  add('video', targets.videoFile)
  await Promise.all(deletions)
}

export function TrackedGenerationProvider({ children }: { children: ReactNode }) {
  const gen = useGenerate()
  const runs = useRuns()
  const toast = useToast()
  const { settings: appSettings } = useSettings()
  const runIdRef = useRef<string | null>(null)
  const pendingRef = useRef<PendingMeta | null>(null)
  // Queue holds ONLY pending items (never the currently-executing one).
  // The active item is tracked separately via `activeQueueIdRef` /
  // `activeFingerprintRef`. Previously queue[0] was "the running one" and
  // the completion effect filtered it out by id — that created two racing
  // sources of truth (the ref claim in pushOrStart and the filter in the
  // terminal-state effect) and, together with React 19's concurrent
  // rendering, could let the same id get dispatched twice when the first
  // run finished. Keeping the active item out of the queue entirely
  // eliminates that class of races.
  const [queue, setQueue] = useState<QueueItem[]>([])
  const persistedQueueLoadedRef = useRef(false)
  const activeQueueIdRef = useRef<string | null>(null)
  // Fingerprint of the currently-executing item, set by `dispatch` and
  // cleared on terminal-state. Used by pushOrStart to client-side-dedupe
  // "user resubmits the same content" before we ever POST.
  const activeFingerprintRef = useRef<string | null>(null)
  const activeReplaceTargetsRef = useRef<ReplacementTargets | null>(null)
  // Guards against accidentally dispatching the same queue id twice
  // (StrictMode re-invoking effects, duplicate auto-dequeue firings, etc).
  const dispatchedIdsRef = useRef<Set<string>>(new Set())
  // Set to true in `dispatch()` once we've actually fired the underlying
  // generate call, cleared once the run terminates. Without this flag the
  // auto-dequeue effect can race with `pushOrStart` and pop the just-claimed
  // item before the worker even started.
  const activeStartedRef = useRef(false)
  // When the backend rejects the active run with 409 the next item in the
  // queue would 409 too — pause auto-dispatch and surface a banner so the
  // user can decide. Cleared on `resumeQueue()` or the next manual enqueue.
  const [paused, setPaused] = useState(false)
  const [pausedReason, setPausedReason] = useState<
    'in_flight' | 'duplicate' | 'unknown' | null
  >(null)
  const [queueModeNotice, setQueueModeNotice] = useState<string | null>(null)
  const previousConcurrentRef = useRef(appSettings.concurrentPipelineRuns)
  const trackedRunInProgress = runs.runs.some((run) => run.status === 'running')

  useEffect(() => {
    const previous = previousConcurrentRef.current
    if (previous !== appSettings.concurrentPipelineRuns) {
      setQueueModeNotice(
        appSettings.concurrentPipelineRuns
          ? 'Concurrency turned on. Pending Text -> Video jobs can start in parallel while screenshot and PowerPoint stages stay gated.'
          : 'Concurrency turned off. Pending jobs will stay in serial order and start one at a time.',
      )
      previousConcurrentRef.current = appSettings.concurrentPipelineRuns
    }
  }, [appSettings.concurrentPipelineRuns])

  useEffect(() => {
    let cancelled = false
    void api.getPendingClientQueue()
      .then((response) => {
        if (cancelled) return
        const restored = response.items
          .map(fromPersistedQueueItem)
          .filter((item): item is QueueItem => !!item)
        if (restored.length > 0) {
          setQueue((prev) => {
            const existing = new Set(prev.map((item) => item.id))
            return [...prev, ...restored.filter((item) => !existing.has(item.id))]
          })
        }
      })
      .catch(() => undefined)
      .finally(() => {
        if (!cancelled) persistedQueueLoadedRef.current = true
      })
    return () => {
      cancelled = true
    }
  }, [])

  useEffect(() => {
    if (!persistedQueueLoadedRef.current) return
    const persisted = queue
      .map(toPersistedQueueItem)
      .filter((item): item is PendingClientQueueItem => !!item)
    void api.savePendingClientQueue(persisted).catch(() => undefined)
  }, [queue])

  // Connect generation events → runs store. Lifts the "run row" into
  // existence as soon as the SSE stream reports `running`, finishes it on
  // terminal events. We also create the row on a *terminal* state when
  // `pendingRef` is still set — this catches the rare case where the POST
  // 409s before the SSE ever flips to running, so the run still appears
  // in Processes instead of vanishing silently.
  useEffect(() => {
    const s = gen.state

    if (s.status === 'running' && pendingRef.current && !runIdRef.current) {
      runIdRef.current = runs.start(pendingRef.current)
      pendingRef.current = null
      return
    }

    const terminal =
      s.status === 'success' || s.status === 'error' || s.status === 'cancelled'
    if (terminal && pendingRef.current && !runIdRef.current) {
      runIdRef.current = runs.start(pendingRef.current)
      pendingRef.current = null
    }

    if (!runIdRef.current) return

    if (s.status === 'running') {
      runs.update(runIdRef.current, {
        stage: s.stage,
        message: s.message,
        progress: s.progress,
        etaSeconds: s.etaSeconds,
        operationId: s.operationId,
      })
    }

    if (s.status === 'success') {
      const replacement = activeReplaceTargetsRef.current
      runs.finish(runIdRef.current, {
        status: 'success',
        htmlFilename: s.result?.html_filename,
        screenshotFiles: s.result?.screenshot_files,
        screenshotFolder: s.result?.screenshot_folder,
        presentationFile: s.result?.presentation_file,
        videoFile: s.result?.video_file,
        operationId: s.result?.operation_id ?? s.operationId,
        etaSeconds: s.etaSeconds,
      })
      if (replacement) {
        void cleanupReplacementTargets(replacement).then(() => {
          if (replacement.runId) runs.remove(replacement.runId)
        })
      }
      runIdRef.current = null
    } else if (s.status === 'error') {
      runs.finish(runIdRef.current, { status: 'error', error: s.error })
      runIdRef.current = null
    } else if (s.status === 'cancelled') {
      runs.finish(runIdRef.current, { status: 'cancelled' })
      runIdRef.current = null
    }
  }, [gen.state, runs])

  useEffect(() => {
    if (gen.state.status !== 'running') return
    let cancelled = false
    let wakeLock: { release: () => Promise<void> } | null = null

    const requestWakeLock = async () => {
      try {
        const nav = navigator as Navigator & {
          wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise<void> }> }
        }
        if (!nav.wakeLock) return
        wakeLock = await nav.wakeLock.request('screen')
        if (cancelled) {
          await wakeLock.release().catch(() => undefined)
          wakeLock = null
        }
      } catch {
        /* Best effort: browser/OS can deny wake lock. */
      }
    }

    void requestWakeLock()
    return () => {
      cancelled = true
      void wakeLock?.release().catch(() => undefined)
    }
  }, [gen.state.status])

  // Dispatcher — starts a queue item's generate call. Safe to call with
  // any item kind; chooses the right hook under the hood. Idempotent: if
  // called twice with the same item id we silently ignore the second call,
  // which defends against double-dispatch from StrictMode / re-render
  // races without changing steady-state behaviour.
  const dispatch = useCallback(
    (item: QueueItem) => {
      if (dispatchedIdsRef.current.has(item.id)) return
      dispatchedIdsRef.current.add(item.id)
      activeQueueIdRef.current = item.id
      activeFingerprintRef.current = fingerprintItem(item)
      activeReplaceTargetsRef.current = item.replaceTargets ?? null
      activeStartedRef.current = true
      if (item.kind === 'text' && item.text !== undefined && item.settings) {
        pendingRef.current = {
          tool: item.tool,
          inputPreview: item.inputPreview,
          inputText: item.inputText ?? item.text,
          settings: item.settings,
        }
        void gen.generate(item.text, item.settings)
      } else if (item.kind === 'html' && item.html !== undefined && item.settings) {
        pendingRef.current = {
          tool: item.tool,
          inputPreview: item.inputPreview,
          inputText: item.inputText ?? item.html,
          settings: item.settings,
        }
        void gen.generateFromHtml(item.html, item.settings)
      } else if (item.kind === 'image' && item.formData) {
        pendingRef.current = {
          tool: item.tool,
          inputPreview: item.inputPreview,
          inputText: item.inputText ?? item.inputPreview,
          inputFiles: item.files?.map((f) => f.name),
          settings: item.settings,
        }
        void gen.generateFromImage(item.formData)
      }
    },
    [gen],
  )

  const dispatchBackgroundText = useCallback(
    (item: QueueItem): EnqueueResult => {
      if (item.kind !== 'text' || !item.text || !item.settings) {
        return { queueId: item.id, startedImmediately: false }
      }
      if (dispatchedIdsRef.current.has(item.id)) {
        return { queueId: item.id, startedImmediately: false, duplicateOf: 'active' }
      }
      dispatchedIdsRef.current.add(item.id)
      const localRunId = runs.start({
        tool: item.tool,
        inputPreview: item.inputPreview,
        inputText: item.inputText ?? item.text,
        settings: item.settings,
      })
      const replacement = item.replaceTargets ?? null

      void (async () => {
        try {
          const started = await api.startTextToVideoRun(item.text!, item.settings!)
          runs.update(localRunId, {
            operationId: started.operation_id,
            etaSeconds: started.estimated_total_seconds,
          })
          let done = false
          while (!done) {
            await new Promise((resolve) => window.setTimeout(resolve, 1500))
            const detail = await api.getRun(started.run_id)
            const run = detail.run
            const status = String(run.status ?? '')
            if (status === 'queued' || status === 'running') {
          runs.update(localRunId, {
                stage: run.stage,
                message: run.message,
                progress: run.progress,
                etaSeconds: etaFromBackendRun(run),
                operationId: run.operation_id ?? started.operation_id,
              })
            }
            if (status === 'completed') {
              const outputs = run.outputs ?? {}
              runs.finish(localRunId, {
                status: 'success',
                htmlFilename: outputs.html_filename ?? outputs.html_file,
                screenshotFiles: outputs.screenshot_files,
                screenshotFolder: outputs.screenshot_folder,
                presentationFile: outputs.presentation_file ?? outputs.presentation_path,
                videoFile: outputs.video_file ?? outputs.video_path,
                operationId: run.operation_id ?? started.operation_id,
              })
              if (replacement) {
                await cleanupReplacementTargets(replacement)
                if (replacement.runId) runs.remove(replacement.runId)
              }
              done = true
            } else if (status === 'failed') {
              runs.finish(localRunId, {
                status: 'error',
                error: run.message ?? 'Process failed',
                operationId: run.operation_id ?? started.operation_id,
              })
              done = true
            } else if (status === 'cancelled') {
              runs.finish(localRunId, {
                status: 'cancelled',
                operationId: run.operation_id ?? started.operation_id,
              })
              done = true
            }
          }
        } catch (err) {
          runs.finish(localRunId, {
            status: 'error',
            error: err instanceof Error ? err.message : String(err),
          })
        }
      })()

      return { queueId: item.id, startedImmediately: true }
    },
    [runs],
  )

  const canRunInBackground = useCallback(
    (item: QueueItem): boolean =>
      appSettings.concurrentPipelineRuns &&
      item.kind === 'text' &&
      item.tool === 'text-to-video',
    [appSettings.concurrentPipelineRuns],
  )

  // Auto-dequeue: when the current run terminates, pop the next queue
  // item and kick it off. The short delay gives React a tick to render the
  // "success"/"error" state before we flip back to "running" for the next
  // item — otherwise the UI never flashes the completion state for a run
  // whose successor is queued behind it.
  //
  // Guards:
  //   1. `activeStartedRef` — we only act on terminal states that follow a
  //      run we actually dispatched. Without this, a fresh `pushOrStart`
  //      committed in the same tick as a previous run's terminal state
  //      can be clobbered here.
  //   2. Pause-on-rejection — when the run errored with a backend 409 we
  //      don't auto-fire the next item; doing so would cascade-fail every
  //      queued item with the same reason.
  //
  // Because `queue` now only contains pending items (the running one is
  // tracked separately via `activeQueueIdRef`), we no longer need to
  // filter the completed id out — `queue[0]` IS the next pending item.
  useEffect(() => {
    const s = gen.state
    if (s.status !== 'success' && s.status !== 'error' && s.status !== 'cancelled') {
      return
    }
    if (!activeStartedRef.current) return

    const rejected = s.status === 'error' && s.rejectedReason
    const rejectedReason = s.rejectedReason ?? null

    const timer = window.setTimeout(() => {
      activeStartedRef.current = false
      activeQueueIdRef.current = null
      activeFingerprintRef.current = null
      activeReplaceTargetsRef.current = null
      if (paused) return
      if (rejected) {
        setPaused(true)
        setPausedReason(rejectedReason ?? 'unknown')
        return
      }
      setQueue((prev) => {
        if (prev.length === 0) return prev
        const [next, ...rest] = prev
        activeQueueIdRef.current = next.id
        activeFingerprintRef.current = fingerprintItem(next)
        activeReplaceTargetsRef.current = next.replaceTargets ?? null
        window.setTimeout(() => dispatch(next), 0)
        return rest
      })
    }, 250)
    return () => window.clearTimeout(timer)
  }, [dispatch, gen.state, paused])

  // If a running backend job was restored from the process log, this hook can
  // be idle while the backend is still busy. Keep new submissions pending until
  // that tracked run finishes, then drain the queue.
  useEffect(() => {
    const idleNow =
      gen.state.status === 'idle' ||
      gen.state.status === 'success' ||
      gen.state.status === 'error' ||
      gen.state.status === 'cancelled'
    if (!idleNow || paused || activeStartedRef.current || activeQueueIdRef.current) return
    if (!appSettings.concurrentPipelineRuns && trackedRunInProgress) return

    setQueue((prev) => {
      if (prev.length === 0) return prev
      const [next, ...rest] = prev
      activeQueueIdRef.current = next.id
      activeFingerprintRef.current = fingerprintItem(next)
      activeReplaceTargetsRef.current = next.replaceTargets ?? null
      window.setTimeout(() => dispatch(next), 0)
      return rest
    })
  }, [
    appSettings.concurrentPipelineRuns,
    dispatch,
    gen.state.status,
    paused,
    queue.length,
    trackedRunInProgress,
  ])

  // When concurrency is enabled while a serial run is already active, promote
  // the existing pending text-to-video queue in FIFO order. New submissions
  // append behind these items and wait for this effect, so the last clicked
  // item cannot jump ahead of older queued work.
  useEffect(() => {
    if (!appSettings.concurrentPipelineRuns || paused || queue.length === 0) return
    setQueue((prev) => {
      const runnable: QueueItem[] = []
      let index = 0
      while (index < prev.length && canRunInBackground(prev[index])) {
        runnable.push(prev[index])
        index += 1
      }
      if (runnable.length === 0) return prev
      const rest = prev.slice(index)
      runnable.forEach((item, offset) => {
        window.setTimeout(() => dispatchBackgroundText(item), offset * 25)
      })
      return rest
    })
  }, [
    appSettings.concurrentPipelineRuns,
    canRunInBackground,
    dispatchBackgroundText,
    paused,
    queue.length,
  ])

  const pushOrStart = useCallback(
    (item: QueueItem): EnqueueResult => {
      // Any new manual submission resumes a paused queue — we assume the
      // user has waited out / handled whatever caused the previous 409.
      if (paused) {
        setPaused(false)
        setPausedReason(null)
      }

      // Client-side duplicate guard. Blocks the common "user resubmits the
      // same content while it's already running/queued" path so we don't
      // silently chain a second identical run the moment the first
      // completes. Match against both the active fingerprint and every
      // pending queue entry.
      const fp = fingerprintItem(item)
      if (activeFingerprintRef.current === fp && activeQueueIdRef.current) {
        toast.push({
          variant: 'info',
          title: 'Already running',
          message: 'This exact content is already being processed — opening its progress view.',
        })
        return {
          queueId: activeQueueIdRef.current,
          startedImmediately: false,
          duplicateOf: 'active',
        }
      }
      const existing = queue.find((q) => fingerprintItem(q) === fp)
      if (existing) {
        toast.push({
          variant: 'info',
          title: 'Already queued',
          message: 'This exact content is already queued — opening its entry in Processes.',
        })
        return {
          queueId: existing.id,
          startedImmediately: false,
          duplicateOf: 'queued',
        }
      }

      const idleNow =
        gen.state.status === 'idle' ||
        gen.state.status === 'success' ||
        gen.state.status === 'error' ||
        gen.state.status === 'cancelled'
      const backendSlotBusy = !appSettings.concurrentPipelineRuns && trackedRunInProgress
      if (canRunInBackground(item) && !idleNow) {
        if (queue.length === 0) {
          return dispatchBackgroundText(item)
        }
        setQueue((prev) => [...prev, item])
        return { queueId: item.id, startedImmediately: false }
      }
      if (idleNow && !activeQueueIdRef.current && !backendSlotBusy) {
        // Claim the active slot *synchronously* so a rapid second submission
        // in the same tick doesn't also see `activeQueueIdRef` as empty and
        // race to dispatch. The item is NOT appended to `queue` — queue is
        // for pending-only items now; the running one lives in the refs.
        activeQueueIdRef.current = item.id
        activeFingerprintRef.current = fp
        activeReplaceTargetsRef.current = item.replaceTargets ?? null
        window.setTimeout(() => dispatch(item), 0)
        return { queueId: item.id, startedImmediately: true }
      }
      setQueue((prev) => [...prev, item])
      return { queueId: item.id, startedImmediately: false }
    },
    [
      appSettings.concurrentPipelineRuns,
      canRunInBackground,
      dispatch,
      dispatchBackgroundText,
      gen.state.status,
      paused,
      queue,
      toast,
      trackedRunInProgress,
    ],
  )

  const enqueueText = useCallback(
    (
      tool: RunTool,
      text: string,
      settings: GenerateSettings,
      options?: { replaceTargets?: ReplacementTargets },
    ): EnqueueResult =>
      pushOrStart({
        id: nextQueueId(),
        tool,
        kind: 'text',
        inputPreview: text.slice(0, 200),
        inputText: text,
        queuedAt: Date.now(),
        text,
        settings,
        replaceTargets: options?.replaceTargets,
      }),
    [pushOrStart],
  )

  const enqueueHtml = useCallback(
    (
      tool: RunTool,
      html: string,
      settings: GenerateSettings,
      options?: { replaceTargets?: ReplacementTargets },
    ): EnqueueResult =>
      pushOrStart({
        id: nextQueueId(),
        tool,
        kind: 'html',
        inputPreview: html.slice(0, 200),
        inputText: html,
        queuedAt: Date.now(),
        html,
        settings,
        replaceTargets: options?.replaceTargets,
      }),
    [pushOrStart],
  )

  const enqueueImage = useCallback(
    (
      tool: RunTool,
      formData: FormData,
      meta: { files: File[]; settings?: GenerateSettings },
    ): EnqueueResult =>
      pushOrStart({
        id: nextQueueId(),
        tool,
        kind: 'image',
        inputPreview: meta.files.length ? meta.files.map((f) => f.name).join(', ') : '(image/pdf)',
        inputText: meta.files.length ? meta.files.map((f) => f.name).join('\n') : '(image/pdf)',
        queuedAt: Date.now(),
        formData,
        files: meta.files,
        settings: meta.settings,
      }),
    [pushOrStart],
  )

  const cancelQueued = useCallback((queueId: string) => {
    // `queue` only contains pending items now, so a plain filter is safe —
    // no risk of accidentally yanking the in-flight item out of under the
    // dispatcher.
    setQueue((prev) => prev.filter((q) => q.id !== queueId))
  }, [])

  const pauseQueue = useCallback(() => {
    setPaused(true)
    setPausedReason(null)
  }, [])

  const resumeQueue = useCallback(() => {
    setPaused(false)
    setPausedReason(null)
    // Pull the next queued item (if any) and fire it. We re-use the
    // dispatch path so all the pendingRef / runIdRef bookkeeping is
    // identical to a fresh submission.
    setQueue((prev) => {
      if (activeQueueIdRef.current) return prev
      if (gen.state.status === 'running') return prev
      if (!appSettings.concurrentPipelineRuns && trackedRunInProgress) return prev
      if (prev.length === 0) return prev
      const [next, ...rest] = prev
      activeQueueIdRef.current = next.id
      activeFingerprintRef.current = fingerprintItem(next)
      activeReplaceTargetsRef.current = next.replaceTargets ?? null
      window.setTimeout(() => dispatch(next), 0)
      return rest
    })
  }, [appSettings.concurrentPipelineRuns, dispatch, gen.state.status, trackedRunInProgress])

  const dismissQueueModeNotice = useCallback(() => {
    setQueueModeNotice(null)
  }, [])

  const reorderQueued = useCallback((queueId: string, targetQueueId: string) => {
    if (queueId === targetQueueId) return
    setQueue((prev) => {
      const from = prev.findIndex((q) => q.id === queueId)
      const to = prev.findIndex((q) => q.id === targetQueueId)
      if (from < 0 || to < 0) return prev
      const next = prev.slice()
      const [item] = next.splice(from, 1)
      next.splice(to, 0, item)
      return next
    })
  }, [])

  const updateQueued = useCallback(
    (queueId: string, patch: Partial<Pick<QueueItem, 'text' | 'html' | 'settings'>>) => {
      setQueue((prev) =>
        prev.map((item) => {
          if (item.id !== queueId) return item
          const text = patch.text ?? item.text
          const html = patch.html ?? item.html
          return {
            ...item,
            ...patch,
            inputText: item.kind === 'html' ? html ?? item.inputText : text ?? item.inputText,
            inputPreview:
              item.kind === 'html'
                ? (html ?? item.inputPreview).slice(0, 200)
                : (text ?? item.inputPreview).slice(0, 200),
          }
        }),
      )
    },
    [],
  )

  const value = useMemo<TrackedGenerationContextValue>(
    () => ({
      state: gen.state,
      queue,
      paused,
      pausedReason,
      queueModeNotice,
      dismissQueueModeNotice,
      cancel: gen.cancel,
      cancelQueued,
      pauseQueue,
      resumeQueue,
      reorderQueued,
      updateQueued,
      reset: gen.reset,
      enqueueText,
      enqueueHtml,
      enqueueImage,
    }),
    [
      gen.state,
      gen.cancel,
      gen.reset,
      queue,
      paused,
      pausedReason,
      queueModeNotice,
      dismissQueueModeNotice,
      cancelQueued,
      pauseQueue,
      resumeQueue,
      reorderQueued,
      updateQueued,
      enqueueText,
      enqueueHtml,
      enqueueImage,
    ],
  )

  return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}

// eslint-disable-next-line react-refresh/only-export-components
export function useTrackedGenerate(tool: RunTool) {
  const ctx = useContext(Ctx)
  if (!ctx) {
    throw new Error('useTrackedGenerate must be used inside <TrackedGenerationProvider>')
  }
  return useMemo(
    () => ({
      state: ctx.state,
      queue: ctx.queue,
      cancel: ctx.cancel,
      cancelQueued: ctx.cancelQueued,
      pauseQueue: ctx.pauseQueue,
      resumeQueue: ctx.resumeQueue,
      reorderQueued: ctx.reorderQueued,
      updateQueued: ctx.updateQueued,
      reset: ctx.reset,
      // Back-compat aliases so existing wizards keep working verbatim: all
      // three are now enqueues and return void (ignored by old callers).
      generate: (
        text: string,
        settings: GenerateSettings,
        options?: { replaceTargets?: ReplacementTargets },
      ) => ctx.enqueueText(tool, text, settings, options),
      generateFromHtml: (
        html: string,
        settings: GenerateSettings,
        options?: { replaceTargets?: ReplacementTargets },
      ) => ctx.enqueueHtml(tool, html, settings, options),
      generateFromImage: (
        fd: FormData,
        meta?: { files?: File[]; settings?: GenerateSettings },
      ) => ctx.enqueueImage(tool, fd, { files: meta?.files ?? [], settings: meta?.settings }),
    }),
    [ctx, tool],
  )
}

// eslint-disable-next-line react-refresh/only-export-components
export function useGenerationQueue() {
  const ctx = useContext(Ctx)
  if (!ctx) {
    throw new Error('useGenerationQueue must be used inside <TrackedGenerationProvider>')
  }
  return {
    queue: ctx.queue,
    cancelQueued: ctx.cancelQueued,
    cancel: ctx.cancel,
    pauseQueue: ctx.pauseQueue,
    state: ctx.state,
    paused: ctx.paused,
      pausedReason: ctx.pausedReason,
      queueModeNotice: ctx.queueModeNotice,
      dismissQueueModeNotice: ctx.dismissQueueModeNotice,
      resumeQueue: ctx.resumeQueue,
    reorderQueued: ctx.reorderQueued,
    updateQueued: ctx.updateQueued,
    enqueueText: ctx.enqueueText,
    enqueueHtml: ctx.enqueueHtml,
  }
}
