// frontend\src\components\ui\FinishedDownloads.tsx

'use client'

import * as React from 'react'
import { useMemo, useEffect, useCallback } from 'react'
import Card from './Card'
import type {
  ViewMode,
  SelectedItem,
  UndoAction,
  PersistedUndoState,
  RunFileMutationOptions,
  RecordJob,
  RecordJobMeta,
  StoredModelFlags,
  FinishedPostworkBadge,
  FinishedPostworkState,
  FinishedPostworkStepSummary,
  FinishedPostworkSummary,
  DoneSortMode,
} from '../../types'
import ButtonGroup from './ButtonGroup'
import {
  TableCellsIcon,
  RectangleStackIcon,
  Squares2X2Icon,
  AdjustmentsHorizontalIcon,
  CheckCircleIcon,
  ExclamationTriangleIcon,
  SparklesIcon,
  CheckIcon,
  XMarkIcon,
  ArchiveBoxIcon,
  TrashIcon,
} from '@heroicons/react/24/outline'
import { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom'
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
import FinishedDownloadsTableView from './FinishedDownloadsTableView'
import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView'
import Pagination from './Pagination'
import { applyInlineVideoPolicy } from './videoPolicy'
import TagBadge from './TagBadge'
import Button from './Button'
import { useNotify } from './notify'
import { isHotName, stripHotPrefix } from './hotName'
import LabeledSwitch from './LabeledSwitch'
import Switch from './Switch'
import LoadingSpinner from './LoadingSpinner'
import HoverPopover from './HoverPopover'
import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback'

type Props = {
  jobs: RecordJob[]
  doneJobs: RecordJob[]
  modelsByKey: Record<string, StoredModelFlags>
  blurPreviews?: boolean
  teaserPlayback?: TeaserPlaybackMode
  teaserAudio?: boolean
  pauseTeasers?: boolean
  onOpenPlayer: (job: RecordJob, startAtSec?: number) => void
  onDeleteJob?: (
    job: RecordJob
  ) => void | { undoToken?: string } | Promise<void | { undoToken?: string }>
  onToggleHot?: (
    job: RecordJob
  ) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
  onToggleFavorite?: (job: RecordJob) => void | Promise<void>
  onToggleLike?: (job: RecordJob) => void | Promise<void>
  onToggleWatch?: (job: RecordJob) => void | Promise<void>
  onKeepJob?: (job: RecordJob) => void | Promise<void>
  onSplitJob?: (job: RecordJob) => void | Promise<void>
  onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>
  doneTotal: number
  page: number
  pageSize: number
  onPageChange: (page: number) => void
  assetNonce?: number
  sortMode: DoneSortMode
  onSortModeChange: (m: DoneSortMode) => void
  loadMode?: 'paged' | 'all'
}

const norm = (p: string) => (p || '').replaceAll('\\', '/')

const baseName = (p: string) => {
  const n = norm(p)
  const parts = n.split('/')
  return parts[parts.length - 1] || ''
}

const keyFor = (j: RecordJob) => {
  // ✅ Primär: stabile Job-ID (bleibt gleich bei Rename/HOT)
  const id = (j as any)?.id
  if (id != null && String(id).trim() !== '') return String(id)

  // Fallback (sollte selten sein)
  const f = baseName(j.output || '')
  return f || String((j as any)?.output || '')
}

const isTrashOutput = (output?: string) => {
  const p = norm(String(output ?? ''))
  // match: ".../.trash/file.ext" oder "...\ .trash\file.ext"
  return p.includes('/.trash/') || p.endsWith('/.trash')
}

function formatDuration(ms: number): string {
  if (!Number.isFinite(ms) || ms <= 0) return '—'
  const totalSec = Math.floor(ms / 1000)
  const h = Math.floor(totalSec / 3600)
  const m = Math.floor((totalSec % 3600) / 60)
  const s = totalSec % 60
  if (h > 0) return `${h}h ${m}m`
  if (m > 0) return `${m}m ${s}s`
  return `${s}s`
}

function formatBytes(bytes?: number | null): string {
  if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
  let v = bytes
  let i = 0
  while (v >= 1024 && i < units.length - 1) {
    v /= 1024
    i++
  }
  const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
  return `${v.toFixed(digits)} ${units[i]}`
}

function useMediaQuery(query: string) {
  const [matches, setMatches] = React.useState(false)

  useEffect(() => {
    const mql = window.matchMedia(query)
    const onChange = () => setMatches(mql.matches)
    onChange()

    if (mql.addEventListener) mql.addEventListener('change', onChange)
    else mql.addListener(onChange)

    return () => {
      if (mql.removeEventListener) mql.removeEventListener('change', onChange)
      else mql.removeListener(onChange)
    }
  }, [query])

  return matches
}

function modelHrefFromFlags(modelKey: string, flags?: StoredModelFlags): string | undefined {
  const key = String(modelKey || '').trim()
  if (!key) return undefined

  const anyFlags = flags as any
  const host = String(anyFlags?.host ?? anyFlags?.inputHost ?? '').trim().toLowerCase()
  const input = String(anyFlags?.input ?? '').trim()

  if (/^https?:\/\//i.test(input)) return input

  if (host.includes('chaturbate')) {
    return `https://chaturbate.com/${encodeURIComponent(key)}/`
  }

  if (host.includes('myfreecams')) {
    return `https://www.myfreecams.com/#${encodeURIComponent(key)}`
  }

  return undefined
}

function enrichJobForDetails(job: RecordJob, modelsByKey: Record<string, StoredModelFlags>): RecordJob {
  const model = modelNameFromOutput(job.output)
  const modelKey = lower(model)
  const flags = modelsByKey[modelKey]

  const href = modelHrefFromFlags(model, flags)

  return {
    ...job,
    sourceUrl: job.sourceUrl || href,
  }
}

const modelNameFromOutput = (output?: string) => {
  const fileRaw = baseName(output || '')
  const file = stripHotPrefix(fileRaw)
  if (!file) return '—'

  const stem = file.replace(/\.[^.]+$/, '')
  const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
  if (m?.[1]) return m[1]

  const i = stem.lastIndexOf('_')
  return i > 0 ? stem.slice(0, i) : stem
}

const lower = (s: string) => (s || '').trim().toLowerCase()

// Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt)
const parseTags = (raw?: string): string[] => {
  const s = String(raw ?? '').trim()
  if (!s) return []
  const parts = s
    .split(/[\n,;|]+/g)
    .map((p) => p.trim())
    .filter(Boolean)

  // stable dedupe (case-insensitive), aber original casing behalten
  const seen = new Set<string>()
  const out: string[] = []
  for (const p of parts) {
    const k = p.toLowerCase()
    if (seen.has(k)) continue
    seen.add(k)
    out.push(p)
  }

  // wie in ModelsTab: alphabetisch (case-insensitive)
  out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
  return out
}

// liest “irgendein” Size-Feld (falls du eins hast) aus dem Job
const sizeBytesOf = (job: RecordJob): number | null => {
  const anyJob = job as any
  const v =
    anyJob.sizeBytes ??
    anyJob.fileSizeBytes ??
    anyJob.bytes ??
    anyJob.size ??
    null
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null
}

function sleep(ms: number) {
  return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
}

function errorTextOf(err: unknown): string {
  if (err instanceof Error) return err.message || String(err)
  return String(err ?? '')
}

function looksLikeFileInUseError(err: unknown): boolean {
  const s = errorTextOf(err).toLowerCase()
  return (
    s.includes('wird gerade verwendet') ||
    s.includes('wird gerade abgespielt') ||
    s.includes('sharing violation') ||
    s.includes('used by another process') ||
    s.includes('file in use') ||
    s.includes('409')
  )
}

async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) {
  const res = await fetch(input, init)
  if (!res.ok) {
    const text = await res.text().catch(() => '')
    const msg = (text || `HTTP ${res.status}`).trim()
    throw new Error(msg)
  }
  return res
}

function fileStemFromOutput(output?: string): string {
  const file = baseName(output || '')
  if (!file) return ''
  return stripHotPrefix(file.replace(/\.[^.]+$/, ''))
}

function getPostworkSummaryForOutput(
  output: string | undefined,
  summaries: Record<string, FinishedPostworkSummary>
): FinishedPostworkSummary | undefined {
  const fileRaw = baseName(output || '')
  if (!fileRaw) return undefined

  const filePlain = stripHotPrefix(fileRaw)

  return summaries[fileRaw] ?? summaries[filePlain] ?? undefined
}

function parseMetaObject(metaRaw: unknown): RecordJobMeta | null {
  if (!metaRaw) return null

  if (typeof metaRaw === 'string') {
    try {
      return JSON.parse(metaRaw) as RecordJobMeta
    } catch {
      return null
    }
  }

  return typeof metaRaw === 'object' ? (metaRaw as RecordJobMeta) : null
}

function isPlainObject(value: unknown): value is Record<string, any> {
  return !!value && typeof value === 'object' && !Array.isArray(value)
}

function mergeMetaPreferDone(
  existingMetaRaw: string | RecordJobMeta | undefined,
  incomingMetaRaw: string | RecordJobMeta | undefined
): string | RecordJobMeta | undefined {
  const existingMeta = parseMetaObject(existingMetaRaw)
  const incomingMeta = parseMetaObject(incomingMetaRaw)

  if (!existingMeta && !incomingMeta) return existingMetaRaw ?? incomingMetaRaw
  if (!existingMeta) return incomingMetaRaw
  if (!incomingMeta) return existingMetaRaw

  const merged: RecordJobMeta = {
    ...(incomingMeta as Record<string, any>),
    ...(existingMeta as Record<string, any>),
  } as RecordJobMeta

  const incomingPostwork = isPlainObject(incomingMeta.postwork) ? incomingMeta.postwork : {}
  const existingPostwork = isPlainObject(existingMeta.postwork) ? existingMeta.postwork : {}

  const incomingCompleted = isPlainObject(incomingPostwork.completed)
    ? incomingPostwork.completed
    : {}

  const existingCompleted = isPlainObject(existingPostwork.completed)
    ? existingPostwork.completed
    : {}

  merged.postwork = {
    ...incomingPostwork,
    ...existingPostwork,
    completed: {
      ...incomingCompleted,
      ...existingCompleted,
    },
    history:
      Array.isArray(existingPostwork.history) && existingPostwork.history.length > 0
        ? existingPostwork.history
        : Array.isArray(incomingPostwork.history)
          ? incomingPostwork.history
          : undefined,
  } as any

  const incomingPreview = isPlainObject(incomingMeta.preview) ? incomingMeta.preview : {}
  const existingPreview = isPlainObject(existingMeta.preview) ? existingMeta.preview : {}

  merged.preview = {
    ...incomingPreview,
    ...existingPreview,
    clips:
      Array.isArray(existingPreview.clips) && existingPreview.clips.length > 0
        ? existingPreview.clips
        : Array.isArray(incomingPreview.clips)
          ? incomingPreview.clips
          : undefined,
    thumb: {
      ...(isPlainObject(incomingPreview.thumb) ? incomingPreview.thumb : {}),
      ...(isPlainObject(existingPreview.thumb) ? existingPreview.thumb : {}),
    },
    sprite: {
      ...(isPlainObject(incomingPreview.sprite) ? incomingPreview.sprite : {}),
      ...(isPlainObject(existingPreview.sprite) ? existingPreview.sprite : {}),
    },
  } as any

  const incomingMedia = isPlainObject(incomingMeta.media) ? incomingMeta.media : {}
  const existingMedia = isPlainObject(existingMeta.media) ? existingMeta.media : {}

  merged.media = {
    ...incomingMedia,
    ...existingMedia,
    durationSeconds:
      typeof existingMedia.durationSeconds === 'number' && existingMedia.durationSeconds > 0
        ? existingMedia.durationSeconds
        : typeof incomingMedia.durationSeconds === 'number' && incomingMedia.durationSeconds > 0
          ? incomingMedia.durationSeconds
          : undefined,
    video: {
      ...(isPlainObject(incomingMedia.video) ? incomingMedia.video : {}),
      ...(isPlainObject(existingMedia.video) ? existingMedia.video : {}),
    },
  } as any

  const incomingAnalysis = isPlainObject(incomingMeta.analysis) ? incomingMeta.analysis : {}
  const existingAnalysis = isPlainObject(existingMeta.analysis) ? existingMeta.analysis : {}

  merged.analysis = {
    ...incomingAnalysis,
    ...existingAnalysis,
    ai:
      isPlainObject(existingAnalysis.ai) && Object.keys(existingAnalysis.ai).length > 0
        ? {
            ...(isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {}),
            ...(isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {}),
          }
        : isPlainObject(incomingAnalysis.ai)
          ? incomingAnalysis.ai
          : undefined,
  } as any

  return merged
}

function metaDurationSeconds(meta: any): number | undefined {
  const v =
    meta?.media?.durationSeconds ??
    meta?.file?.durationSeconds

  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
}

function postworkBadgeTone(summary?: FinishedPostworkSummary): string {
  const state = String(summary?.state ?? '').toLowerCase()
  const label = String(summary?.label ?? '').toLowerCase()
  const isPartialDone = state === 'done' && label.startsWith('teilweise fertig')

  if (isPartialDone) {
    return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
  }

  switch (state) {
    case 'running':
      return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30'
    case 'queued':
      return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30'
    case 'done':
      return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30'
    case 'error':
      return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30'
    default:
      return 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
  }
}

function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string {
  const p = String(phase ?? '').trim().toLowerCase()

  switch (`${queue}:${p}`) {
    case 'postwork:meta':
      return 'Meta'
    case 'postwork:teaser':
      return 'Teaser'
    case 'postwork:thumb':
      return 'Vorschaubild'
    case 'postwork:sprites':
      return 'Sprites'
    case 'enrich:analyze':
      return 'KI-Analyse'
    default:
      return p || (queue === 'enrich' ? 'KI-Analyse' : 'Meta')
  }
}

function shortRunningLabel(summary?: FinishedPostworkSummary): string {
  if (!summary) return 'Läuft'

  const runningStep = summary.steps.find((s) => s.state === 'running')
  if (runningStep) return runningStep.label

  const queuedStep = summary.steps.find((s) => s.state === 'queued')
  if (queuedStep) return queuedStep.label

  return 'Läuft'
}

function expectedStepsForBadges(): Array<{ key: string; queue: 'postwork' | 'enrich'; phase: string }> {
  return defaultPostworkStepDefs({
    includeAnalyze: true,
  })
}

function defaultPostworkStepDefs(opts?: { includeAnalyze?: boolean }): Array<{
  key: string
  queue: 'postwork' | 'enrich'
  phase: string
}> {
  const includeAnalyze = opts?.includeAnalyze ?? true

  const steps: Array<{
    key: string
    queue: 'postwork' | 'enrich'
    phase: string
  }> = [
    { key: 'postwork:meta', queue: 'postwork', phase: 'meta' },
    { key: 'postwork:thumb', queue: 'postwork', phase: 'thumb' },
    { key: 'postwork:teaser', queue: 'postwork', phase: 'teaser' },
    { key: 'postwork:sprites', queue: 'postwork', phase: 'sprites' },
  ]

  if (includeAnalyze) {
    steps.push({ key: 'enrich:analyze', queue: 'enrich', phase: 'analyze' })
  }

  return steps
}

function buildMissingPostworkSummary(
  file: string,
  assetId?: string,
  label = 'Kein Status'
): FinishedPostworkSummary | null {
  if (!file) return null

  const steps: FinishedPostworkStepSummary[] = defaultPostworkStepDefs({
    includeAnalyze: true,
  }).map((stepDef) => ({
    key: stepDef.key,
    queue: stepDef.queue,
    phase: stepDef.phase,
    label: labelForStep(stepDef.queue, stepDef.phase),
    state: 'missing',
    ts: 0,
  }))

  return {
    file,
    assetId,
    state: 'missing',
    label,
    steps,
    ts: 0,
  }
}

function hasNonEmptyObject(value: unknown): boolean {
  return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length > 0
}

function firstNonEmptyPath(...values: unknown[]): string {
  for (const v of values) {
    if (typeof v === 'string') {
      const s = v.trim()
      if (s) return s
    }
  }
  return ''
}

function readMetaVideoWidth(meta: any): number | undefined {
  const v =
    meta?.media?.video?.width ??
    meta?.media?.video?.w ??
    meta?.media?.width ??
    meta?.file?.video?.width ??
    meta?.file?.video?.w ??
    meta?.file?.width ??
    meta?.video?.width ??
    meta?.video?.w ??
    meta?.width

  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
}

function readMetaVideoHeight(meta: any): number | undefined {
  const v =
    meta?.media?.video?.height ??
    meta?.media?.video?.h ??
    meta?.media?.height ??
    meta?.file?.video?.height ??
    meta?.file?.video?.h ??
    meta?.file?.height ??
    meta?.video?.height ??
    meta?.video?.h ??
    meta?.height

  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
}

function readMetaDurationSeconds(meta: any): number | undefined {
  const v =
    meta?.media?.durationSeconds ??
    meta?.media?.durationSec ??
    meta?.media?.duration ??
    meta?.file?.durationSeconds ??
    meta?.file?.durationSec ??
    meta?.file?.duration ??
    meta?.durationSeconds ??
    meta?.durationSec ??
    meta?.duration

  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined
}

function readCompletedFlag(meta: any, ...keys: string[]): boolean {
  const completed = meta?.postwork?.completed

  for (const key of keys) {
    const v =
      completed?.[key] ??
      meta?.postwork?.[key] ??
      meta?.preview?.[key] ??
      meta?.[key]

    if (v === true) return true
  }

  return false
}

function hasDoneHistoryForPhase(
  meta: any,
  queue: 'postwork' | 'enrich',
  phase: string
): boolean {
  const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : []

  return rawHistory.some((entry: any) => {
    const queueRaw = String(entry?.queue ?? '').trim().toLowerCase()
    const phaseRaw = normalizeFinishedPhase(
      queueRaw === 'enrich' ? 'enrich' : 'postwork',
      String(entry?.phase ?? '').trim().toLowerCase()
    )
    const stateRaw = String(entry?.state ?? '').trim().toLowerCase()

    return queueRaw === queue && phaseRaw === phase && stateRaw === 'done'
  })
}

function hasNonEmptyArray(value: unknown): boolean {
  return Array.isArray(value) && value.length > 0
}

function buildCompletedPostworkSummaryFromMeta(
  file: string,
  assetId: string | undefined,
  meta: any
): FinishedPostworkSummary | null {
  if (!file || !meta || typeof meta !== 'object') return null

  const durationSeconds = readMetaDurationSeconds(meta)
  const videoWidth = readMetaVideoWidth(meta)
  const videoHeight = readMetaVideoHeight(meta)

  const thumbPath = firstNonEmptyPath(
    meta?.preview?.thumb?.path,
    meta?.preview?.thumb?.url,
    meta?.preview?.thumb?.src,
    meta?.preview?.thumbPath,
    meta?.previewThumb?.path,
    meta?.previewThumb?.url,
    meta?.previewThumbPath,
    meta?.thumbPath,
    meta?.thumbUrl,
    meta?.coverPath,
    meta?.coverUrl
  )

  const spritePath = firstNonEmptyPath(
    meta?.preview?.sprite?.path,
    meta?.preview?.sprite?.url,
    meta?.preview?.sprite?.src,
    meta?.preview?.spritePath,
    meta?.previewSprite?.path,
    meta?.previewSprite?.url,
    meta?.previewSpritePath,
    meta?.spritePath,
    meta?.spriteUrl
  )

  const clips =
    Array.isArray(meta?.preview?.clips) ? meta.preview.clips
    : Array.isArray(meta?.preview?.teaserClips) ? meta.preview.teaserClips
    : Array.isArray(meta?.preview?.segments) ? meta.preview.segments
    : Array.isArray(meta?.preview?.teaser?.clips) ? meta.preview.teaser.clips
    : Array.isArray(meta?.previewClips) ? meta.previewClips
    : Array.isArray(meta?.teaserClips) ? meta.teaserClips
    : []

  const teaserPath = firstNonEmptyPath(
    meta?.preview?.teaser?.path,
    meta?.preview?.teaser?.url,
    meta?.preview?.teaser?.src,
    meta?.preview?.teaserPath,
    meta?.previewTeaser?.path,
    meta?.previewTeaser?.url,
    meta?.previewTeaserPath,
    meta?.teaserPath,
    meta?.teaserUrl
  )

  const aiObject =
    meta?.analysis?.ai ??
    meta?.ai

  const hasAiHits =
    Array.isArray(aiObject?.hits) && aiObject.hits.length > 0

  const hasAiSegments =
    Array.isArray(aiObject?.segments) && aiObject.segments.length > 0

  const hasMeta =
    readCompletedFlag(meta, 'meta') ||
    hasDoneHistoryForPhase(meta, 'postwork', 'meta') ||
    typeof durationSeconds === 'number' ||
    (typeof videoWidth === 'number' && typeof videoHeight === 'number') ||
    (typeof meta?.media?.video?.resolution === 'string' && meta.media.video.resolution.trim() !== '') ||
    (typeof meta?.file?.video?.resolution === 'string' && meta.file.video.resolution.trim() !== '')

  const hasTeaser =
    readCompletedFlag(meta, 'teaser', 'assets') ||
    hasDoneHistoryForPhase(meta, 'postwork', 'teaser') ||
    teaserPath !== '' ||
    hasNonEmptyArray(clips)

  const hasThumb =
    readCompletedFlag(meta, 'thumb', 'thumbnail', 'thumbnails') ||
    hasDoneHistoryForPhase(meta, 'postwork', 'thumb') ||
    meta?.preview?.thumb?.exists === true ||
    thumbPath !== ''

  const hasSprites =
    readCompletedFlag(meta, 'sprites', 'sprite') ||
    hasDoneHistoryForPhase(meta, 'postwork', 'sprites') ||
    meta?.preview?.sprite?.exists === true ||
    spritePath !== '' ||
    (
      typeof meta?.preview?.sprite?.count === 'number' &&
      Number.isFinite(meta.preview.sprite.count) &&
      meta.preview.sprite.count > 0
    )

  const hasAiAnalysis =
    readCompletedFlag(meta, 'analyze', 'analysis', 'ai') ||
    hasDoneHistoryForPhase(meta, 'enrich', 'analyze') ||
    hasNonEmptyObject(aiObject) ||
    hasAiHits ||
    hasAiSegments

  const stepDefs = defaultPostworkStepDefs({
    includeAnalyze: true,
  })

  const steps: FinishedPostworkStepSummary[] = stepDefs.map((stepDef) => {
    let state: FinishedPostworkState = 'missing'

    if (stepDef.key === 'postwork:meta') {
      state = hasMeta ? 'done' : 'missing'
    } else if (stepDef.key === 'postwork:teaser') {
      state = hasTeaser ? 'done' : 'missing'
    } else if (stepDef.key === 'postwork:thumb') {
      state = hasThumb ? 'done' : 'missing'
    } else if (stepDef.key === 'postwork:sprites') {
      state = hasSprites ? 'done' : 'missing'
    } else if (stepDef.key === 'enrich:analyze') {
      state = hasAiAnalysis ? 'done' : 'missing'
    }

    return {
      key: stepDef.key,
      queue: stepDef.queue,
      phase: stepDef.phase,
      label: labelForStep(stepDef.queue, stepDef.phase),
      state,
      ts: Number(meta?.updatedAtUnix ?? 0),
    }
  })

  const doneCount = steps.filter((s) => s.state === 'done').length
  const allDone = steps.length > 0 && steps.every((s) => s.state === 'done')

  return {
    file,
    assetId,
    state: allDone ? 'done' : doneCount > 0 ? 'done' : 'missing',
    label: allDone
      ? 'Fertig'
      : doneCount > 0
        ? `Teilweise fertig (${doneCount}/${steps.length})`
        : 'Kein Status',
    steps,
    ts: Number(meta?.updatedAtUnix ?? 0),
  }
}

function buildPostworkSummaryFromMetaHistory(
  file: string,
  assetId: string | undefined,
  meta: any
): FinishedPostworkSummary | null {
  if (!file || !meta || typeof meta !== 'object') return null

  const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : []
  if (rawHistory.length === 0) return null

  const badges: FinishedPostworkBadge[] = rawHistory
    .map((entry: any): FinishedPostworkBadge | null => {
      const queueRaw = String(entry?.queue ?? '').trim().toLowerCase()
      const phase = normalizeFinishedPhase(
        (queueRaw === 'enrich' ? 'enrich' : 'postwork'),
        String(entry?.phase ?? '').trim().toLowerCase()
      )
      const state = String(entry?.state ?? '').trim().toLowerCase()

      if ((queueRaw !== 'postwork' && queueRaw !== 'enrich') || !phase) return null
      if (
        state !== 'queued' &&
        state !== 'running' &&
        state !== 'done' &&
        state !== 'error' &&
        state !== 'missing'
      ) {
        return null
      }

      return {
        file,
        assetId,
        queue: queueRaw as 'postwork' | 'enrich',
        phase,
        state: state as FinishedPostworkBadge['state'],
        label: typeof entry?.label === 'string' ? entry.label : undefined,
        position: typeof entry?.position === 'number' ? entry.position : undefined,
        waiting: typeof entry?.waiting === 'number' ? entry.waiting : undefined,
        running: typeof entry?.running === 'number' ? entry.running : undefined,
        maxParallel: typeof entry?.maxParallel === 'number' ? entry.maxParallel : undefined,
        ts: typeof entry?.ts === 'number' ? entry.ts : 0,
      }
    })
    .filter(Boolean) as FinishedPostworkBadge[]

  if (badges.length === 0) return null
  return summarizePostworkSteps(file, badges)
}

function isAggregateAssetsPhase(
  queue: 'postwork' | 'enrich',
  phase?: string
): boolean {
  return queue === 'enrich' && String(phase ?? '').trim().toLowerCase() === 'assets'
}

function normalizeFinishedPhase(
  queue: 'postwork' | 'enrich',
  phase?: string
): string {
  const p = String(phase ?? '').trim().toLowerCase()

  if (queue === 'enrich') {
    if (p === 'analyze') return 'analyze'
    return p || 'analyze'
  }

  switch (p) {
    case 'postwork':
    case 'probe':
    case 'remuxing':
    case 'moving':
      return 'meta'
    case 'assets':
      return 'teaser'
    case 'thumb':
      return 'thumb'
    case 'sprites':
      return 'sprites'
    default:
      return p || 'meta'
  }
}

function summarizePostworkSteps(
  file: string,
  badges: FinishedPostworkBadge[]
): FinishedPostworkSummary | null {
  if (!file) return null

  const realBadges = badges.filter((b) => b && b.state !== 'missing')
  if (realBadges.length === 0) return null

  const expectedSteps = expectedStepsForBadges()
  const byKey = new Map<string, FinishedPostworkStepSummary>()

  let aggregateAssetsBadge: FinishedPostworkBadge | null = null

  for (const badge of realBadges) {
    const queue = badge.queue
    const rawPhase = String(badge.phase ?? '').trim().toLowerCase()

    // ✅ Sammelstatus "enrich:assets" NICHT als eigene Extra-Zeile anzeigen
    // sondern später auf die bekannten Schritte verteilen.
    if (isAggregateAssetsPhase(queue, rawPhase)) {
      if (!aggregateAssetsBadge || (aggregateAssetsBadge.ts ?? 0) <= (badge.ts ?? 0)) {
        aggregateAssetsBadge = badge
      }
      continue
    }

    const phase = normalizeFinishedPhase(queue, rawPhase)
    const key = `${queue}:${phase}`

    const prev = byKey.get(key)
    if (prev && prev.ts > badge.ts) continue

    byKey.set(key, {
      key,
      queue,
      phase,
      label: labelForStep(queue, phase),
      state: badge.state,
      ts: badge.ts,
      position: badge.position,
      waiting: badge.waiting,
      running: badge.running,
      maxParallel: badge.maxParallel,
    })
  }

  // ✅ Erst alle erwarteten Schritte anlegen
  for (const stepDef of expectedSteps) {
    if (byKey.has(stepDef.key)) continue

    byKey.set(stepDef.key, {
      key: stepDef.key,
      queue: stepDef.queue,
      phase: stepDef.phase,
      label: labelForStep(stepDef.queue, stepDef.phase),
      state: 'missing',
      ts: 0,
    })
  }

  // ✅ Dann ggf. den Sammelstatus "assets" auf alle noch nicht fertigen Schritte abbilden
  if (aggregateAssetsBadge) {
    for (const stepDef of expectedSteps) {
      const cur = byKey.get(stepDef.key)
      if (!cur) continue

      // Bereits fertige Schritte beibehalten
      if (cur.state === 'done') continue

      byKey.set(stepDef.key, {
        ...cur,
        state: aggregateAssetsBadge.state,
        ts: Math.max(cur.ts || 0, aggregateAssetsBadge.ts || 0),
        position: aggregateAssetsBadge.position,
        waiting: aggregateAssetsBadge.waiting,
        running: aggregateAssetsBadge.running,
        maxParallel: aggregateAssetsBadge.maxParallel,
      })
    }
  }

  const order = expectedSteps.map((s) => s.key)

  const steps = Array.from(byKey.values()).sort((a, b) => {
    const ai = order.indexOf(a.key)
    const bi = order.indexOf(b.key)

    if (ai >= 0 && bi >= 0) return ai - bi
    if (ai >= 0) return -1
    if (bi >= 0) return 1
    return a.key.localeCompare(b.key)
  })

  if (steps.length === 0) return null

  const visibleSteps = steps.filter((s) => s.state !== 'missing')

  const hasError = visibleSteps.some((s) => s.state === 'error')
  const hasRunning = visibleSteps.some((s) => s.state === 'running')
  const hasQueued = visibleSteps.some((s) => s.state === 'queued')

  const doneCount = steps.filter((s) => s.state === 'done').length
  const allDone = steps.length > 0 && steps.every((s) => s.state === 'done')
  const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError

  const state: FinishedPostworkSummary['state'] =
    hasError ? 'error'
    : hasRunning ? 'running'
    : hasQueued ? 'queued'
    : allDone ? 'done'
    : partiallyDone ? 'done'
    : 'missing'

  const label =
    hasError
      ? 'Fehler'
      : hasRunning
        ? shortRunningLabel({ file, steps, state: 'running', label: '', ts: 0 })
        : hasQueued
          ? shortRunningLabel({ file, steps, state: 'queued', label: '', ts: 0 })
          : allDone
            ? 'Fertig'
            : partiallyDone
              ? `Teilweise fertig (${doneCount}/${steps.length})`
              : 'Kein Status'

  const ts = Math.max(0, ...steps.map((s) => s.ts || 0))

  return {
    file,
    assetId: realBadges.find((b) => b.assetId)?.assetId,
    state,
    label,
    steps,
    ts,
  }
}

function mergePostworkSummaries(
  historySummary: FinishedPostworkSummary | null | undefined,
  completedSummary: FinishedPostworkSummary | null | undefined,
  fallbackFile: string,
  fallbackAssetId?: string
): FinishedPostworkSummary | null {
  if (!historySummary && !completedSummary) return null
  if (!historySummary) return completedSummary ?? null
  if (!completedSummary) return historySummary

  const stepMap = new Map<string, FinishedPostworkStepSummary>()

  for (const step of completedSummary.steps) {
    stepMap.set(step.key, step)
  }

  for (const step of historySummary.steps) {
    const prev = stepMap.get(step.key)

    if (!prev) {
      stepMap.set(step.key, step)
      continue
    }

    // Live-/History-Zustände mit Aktivität/Fehler sollen sichtbar bleiben
    if (
      step.state === 'running' ||
      step.state === 'queued' ||
      step.state === 'error'
    ) {
      stepMap.set(step.key, step)
      continue
    }

    // "done" aus History ist ok, aber nur wenn Completed nicht schon auch done liefert.
    // Wenn Completed mehr Informationen hat, füllt es die restlichen Steps ohnehin.
    if (step.state === 'done' && prev.state === 'missing') {
      stepMap.set(step.key, step)
      continue
    }

    // missing aus History darf nie ein echtes Completed-Ergebnis überschreiben
  }

  const orderedSteps = defaultPostworkStepDefs({ includeAnalyze: true }).map((def) => {
    return (
      stepMap.get(def.key) ?? {
        key: def.key,
        queue: def.queue,
        phase: def.phase,
        label: labelForStep(def.queue, def.phase),
        state: 'missing' as FinishedPostworkState,
        ts: 0,
      }
    )
  })

  const visibleSteps = orderedSteps.filter((s) => s.state !== 'missing')
  const hasError = visibleSteps.some((s) => s.state === 'error')
  const hasRunning = visibleSteps.some((s) => s.state === 'running')
  const hasQueued = visibleSteps.some((s) => s.state === 'queued')

  const doneCount = orderedSteps.filter((s) => s.state === 'done').length
  const allDone = orderedSteps.length > 0 && orderedSteps.every((s) => s.state === 'done')
  const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError

  const state: FinishedPostworkSummary['state'] =
    hasError ? 'error'
    : hasRunning ? 'running'
    : hasQueued ? 'queued'
    : allDone ? 'done'
    : partiallyDone ? 'done'
    : 'missing'

  const label =
    hasError
      ? 'Fehler'
      : hasRunning
        ? shortRunningLabel({
            file: historySummary.file || completedSummary.file || fallbackFile,
            steps: orderedSteps,
            state: 'running',
            label: '',
            ts: 0,
          })
        : hasQueued
          ? shortRunningLabel({
              file: historySummary.file || completedSummary.file || fallbackFile,
              steps: orderedSteps,
              state: 'queued',
              label: '',
              ts: 0,
            })
          : allDone
            ? 'Fertig'
            : partiallyDone
              ? `Teilweise fertig (${doneCount}/${orderedSteps.length})`
              : 'Kein Status'

  const ts = Math.max(
    historySummary.ts || 0,
    completedSummary.ts || 0,
    ...orderedSteps.map((s) => s.ts || 0)
  )

  return {
    file: historySummary.file || completedSummary.file || fallbackFile,
    assetId: historySummary.assetId || completedSummary.assetId || fallbackAssetId,
    state,
    label,
    steps: orderedSteps,
    ts,
  }
}

export function renderFinishedPostworkBadge(
  summary?: FinishedPostworkSummary,
  opts?: {
    onClick?: () => void
    clickable?: boolean
    disablePopover?: boolean
  }
): React.ReactNode {
  if (!summary) return null

  const clickable = Boolean(opts?.clickable && opts?.onClick)

  const className = [
    'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1',
    postworkBadgeTone(summary),
    clickable ? 'cursor-pointer transition-opacity hover:opacity-80' : '',
  ].join(' ')

  const badgeNode = clickable ? (
    <button
      type="button"
      className={className}
      title={summary.label}
      onClick={(e) => {
        e.preventDefault()
        e.stopPropagation()
        opts?.onClick?.()
      }}
    >
      {summary.state === 'running' ? (
        <LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
      ) : summary.state === 'queued' ? (
        <SparklesIcon className="size-3.5 shrink-0" />
      ) : summary.state === 'done' ? (
        <CheckCircleIcon className="size-3.5 shrink-0" />
      ) : summary.state === 'error' ? (
        <ExclamationTriangleIcon className="size-3.5 shrink-0" />
      ) : summary.state === 'missing' ? (
        <span className="size-3.5 shrink-0 rounded-full border border-current opacity-70" />
      ) : null}

      <span className="truncate">{summary.label}</span>
    </button>
  ) : (
    <span className={className} title={summary.label}>
      {summary.state === 'running' ? (
        <LoadingSpinner size="sm" className="shrink-0" srLabel={summary.label} />
      ) : summary.state === 'queued' ? (
        <SparklesIcon className="size-3.5 shrink-0" />
      ) : summary.state === 'done' ? (
        <CheckCircleIcon className="size-3.5 shrink-0" />
      ) : summary.state === 'error' ? (
        <ExclamationTriangleIcon className="size-3.5 shrink-0" />
      ) : summary.state === 'missing' ? (
        <span className="size-3.5 shrink-0 rounded-full border border-current opacity-70" />
      ) : null}

      <span className="truncate">{summary.label}</span>
    </span>
  )

  if (opts?.disablePopover) {
    return badgeNode
  }

  return (
    <HoverPopover
      content={(open) =>
        open && (
          <div className="w-[280px] rounded-lg">
            <div className="px-3 py-2 border-b border-gray-200 dark:border-white/10">
              <div className="text-xs font-semibold text-gray-900 dark:text-white">
                {summary.state === 'missing' ? 'Postwork-Phasen' : 'Postwork-Status'}
              </div>
              <div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400 truncate">
                {summary.file}
              </div>
            </div>

            <div className="px-3 py-2 space-y-2">
              {summary.steps.map((step) => (
                <div key={step.key} className="flex items-center justify-between gap-3">
                  <div className="min-w-0 text-[12px] text-gray-800 dark:text-gray-200">
                    {step.label}
                  </div>

                  <div className="shrink-0 flex items-center gap-1.5 text-[11px]">
                    {step.state === 'running' ? (
                      <>
                        <LoadingSpinner size="sm" className="shrink-0" srLabel={step.label} />
                        <span className="text-indigo-700 dark:text-indigo-200">Läuft</span>
                      </>
                    ) : step.state === 'queued' ? (
                      <>
                        <SparklesIcon className="size-3.5 shrink-0 text-amber-600 dark:text-amber-300" />
                        <span className="text-amber-700 dark:text-amber-200">
                          {typeof step.position === 'number' && step.position > 0
                            ? `Wartet #${step.position}`
                            : 'Wartet'}
                        </span>
                      </>
                    ) : step.state === 'done' ? (
                      <>
                        <CheckCircleIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
                        <span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
                      </>
                    ) : step.state === 'error' ? (
                      <>
                        <ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
                        <span className="text-red-700 dark:text-red-200">Fehler</span>
                      </>
                    ) : step.state === 'missing' ? (
                      <>
                        <span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
                        <span className="text-gray-500 dark:text-gray-400">Kein Status</span>
                      </>
                    ) : (
                      <span className="text-gray-500 dark:text-gray-400">—</span>
                    )}
                  </div>
                </div>
              ))}
            </div>
          </div>
        )
      }
    >
      {badgeNode}
    </HoverPopover>
  )
}

export default function FinishedDownloads({ 
  jobs,
  doneJobs,
  blurPreviews,
  teaserPlayback,
  teaserAudio,
  pauseTeasers = false,
  onOpenPlayer,
  onDeleteJob,
  onToggleHot,
  onToggleFavorite,
  onToggleLike,
  onToggleWatch,
  onKeepJob,
  onSplitJob,
  onAddToDownloads,
  doneTotal,
  page,
  pageSize,
  onPageChange,
  assetNonce,
  sortMode,
  onSortModeChange,
  modelsByKey,
  loadMode = 'paged',
 }: Props) {
  
  const VIEW_KEY = 'finishedDownloads_view'
  const KEEP_KEY = 'finishedDownloads_includeKeep_v2'
  const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'

  const allMode = loadMode === 'all'

  const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'
  const teaserPlaybackMode: TeaserPlaybackMode = pauseTeasers ? 'still' : requestedTeaserPlaybackMode

  // Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> echter Hover
  // Mobile: "hover" wie "all" behandeln
  const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')

  const effectiveTeaserPlaybackMode: TeaserPlaybackMode =
    !canHover && teaserPlaybackMode === 'hover'
      ? 'all'
      : teaserPlaybackMode

  const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)')
  const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)')

  const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller
  
  const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = React.useState(false)

  const forcePreviewMuted =
    mobileNeedsTapForAudio && teaserAudio
      ? !mobileTeaserAudioUnlocked
      : false

  const notify = useNotify()

  const pointerPosRef = React.useRef<{ x: number; y: number } | null>(null)

  const teaserHostsRef = React.useRef<Map<string, HTMLElement>>(new Map())
  const [teaserKey, setTeaserKey] = React.useState<string | null>(null)
  const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)

  const teaserIORef = React.useRef<IntersectionObserver | null>(null)
  const elToKeyRef = React.useRef<WeakMap<Element, string>>(new WeakMap())

  // 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten
  const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
  const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())

  const [hiddenFiles, setHiddenFiles] = React.useState<Set<string>>(() => new Set())

  const [isLoading, setIsLoading] = React.useState(false)

  const refillInFlightRef = React.useRef(false)
  const refillQueuedWhileInFlightRef = React.useRef(false)

  // ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
  const refillSessionRef = React.useRef(0)

  const [postworkByFile, setPostworkByFile] = React.useState<Record<string, FinishedPostworkBadge[]>>({})
  
  const [undoing, setUndoing] = React.useState(false)

  const [selectedItems, setSelectedItems] = React.useState<Record<string, SelectedItem>>({})
  const [bulkBusy, setBulkBusy] = React.useState(false)

  // 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln
  const [renamedFiles, setRenamedFiles] = React.useState<Record<string, string>>({})

  // 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken"
  const [overrideDoneJobs, setOverrideDoneJobs] = React.useState<RecordJob[] | null>(null)
  const [overrideDoneTotal, setOverrideDoneTotal] = React.useState<number | null>(null)
  const [refillTick, setRefillTick] = React.useState(0)
  const refillTimerRef = React.useRef<number | null>(null)

  const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })

  const [view, setView] = React.useState<ViewMode>('table')
  const [includeKeep, setIncludeKeep] = React.useState(false)

  const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
  const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false)

  const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())

  // 🏷️ Tag-Filter (klickbare Tags wie in ModelsTab)
  const [tagFilter, setTagFilter] = React.useState<string[]>([])
  const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter])

  const teaserState = React.useMemo<FinishedDownloadsTeaserState>(
    () => ({
      mode: effectiveTeaserPlaybackMode,
      activeKey: teaserKey,
      hoverKey: hoverTeaserKey,
      audioEnabled: Boolean(teaserAudio),
    }),
    [effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio]
  )

  const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'

  const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems])

  const toggleSelected = useCallback((job: RecordJob) => {
    const k = keyFor(job)
    const file = baseName(job.output || '')
    if (!file) return

    setSelectedItems((prev) => {
      const next = { ...prev }
      if (next[k]) {
        delete next[k]
      } else {
        next[k] = { key: k, file }
      }
      return next
    })
  }, [keyFor])

  const clearSelection = useCallback(() => {
    setSelectedItems({})
  }, [])

  const selectPageRows = useCallback((rows: RecordJob[]) => {
    setSelectedItems((prev) => {
      const next = { ...prev }
      for (const j of rows) {
        const k = keyFor(j)
        const file = baseName(j.output || '')
        if (!file) continue
        next[k] = { key: k, file }
      }
      return next
    })
  }, [keyFor])

  const deselectPageRows = useCallback((rows: RecordJob[]) => {
    setSelectedItems((prev) => {
      const next = { ...prev }
      for (const j of rows) {
        delete next[keyFor(j)]
      }
      return next
    })
  }, [keyFor])

  const [lastAction, setLastAction] = React.useState<UndoAction | null>(() => {
    if (typeof window === 'undefined') return null
    try {
      const raw = localStorage.getItem(LAST_UNDO_KEY)
      if (!raw) return null

      const parsed = JSON.parse(raw) as PersistedUndoState
      if (!parsed || parsed.v !== 1 || !parsed.action) return null

      // optional TTL (z. B. 30 min), damit kein uraltes Undo angezeigt wird
      const ageMs = Date.now() - Number(parsed.ts || 0)
      if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) {
        localStorage.removeItem(LAST_UNDO_KEY)
        return null
      }

      return parsed.action
    } catch {
      return null
    }
  })
  
  const refreshHoverPreviewFromPointer = useCallback(() => {
    if (!canHover) return

    const pos = pointerPosRef.current
    if (!pos) {
      setHoverTeaserKey(null)
      return
    }

    const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null
    if (!el) {
      setHoverTeaserKey(null)
      return
    }

    const hoverHost = el.closest<HTMLElement>('[data-finished-hover-key]')
    const nextKey = hoverHost?.dataset.finishedHoverKey ?? null

    setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey))
  }, [canHover])

  useEffect(() => {
    if (!pauseTeasers) return

    setTeaserKey(null)
    setHoverTeaserKey(null)
    setInlinePlay(null)
  }, [pauseTeasers])

  useEffect(() => {
    let alive = true

    const loadInitialPostworkState = async () => {
      try {
        const res = await fetch('/api/tasks/status', { cache: 'no-store' })
        if (!res.ok) return

        const data = await res.json().catch(() => null)
        if (!alive || !data) return

        const regen = data?.regenerateAssets
        const file = String(regen?.file ?? '').trim()

        if (!file) return

        if (regen?.running) {
          setPostworkByFile((prev) => ({
            ...prev,
            [file]: [
              {
                file,
                assetId: fileStemFromOutput(file),
                queue: 'enrich',
                state: 'running',
                phase: 'assets',
                label: 'Vorschaubild',
                ts: Date.now(),
              },
            ],
          }))
          return
        }

        if (regen?.error) {
          setPostworkByFile((prev) => ({
            ...prev,
            [file]: [
              {
                file,
                assetId: fileStemFromOutput(file),
                queue: 'enrich',
                state: 'error',
                phase: 'assets',
                label: 'Fehler',
                ts: Date.now(),
              },
            ],
          }))
          return
        }

        const bulk = data?.generateAssets
        const bulkFile = String(bulk?.currentFile ?? '').trim()
        const bulkQueue =
          String(bulk?.currentQueue ?? '').trim().toLowerCase() === 'enrich'
            ? 'enrich'
            : 'postwork'
        const bulkPhase = String(bulk?.currentPhase ?? '').trim().toLowerCase()
        const bulkLabel = String(bulk?.currentLabel ?? '').trim()

        if (bulk?.running && bulkFile && bulkPhase) {
          setPostworkByFile((prev) => ({
            ...prev,
            [bulkFile]: [
              {
                file: bulkFile,
                assetId: fileStemFromOutput(bulkFile),
                queue: bulkQueue,
                state: 'running',
                phase: bulkPhase,
                label: bulkLabel || undefined,
                ts: Date.now(),
              },
            ],
          }))
        }

        // optional: wenn du "done" kurz nach Reload noch sichtbar haben willst
        if (regen?.text) {
          setPostworkByFile((prev) => ({
            ...prev,
            [file]: [
              {
                file,
                assetId: fileStemFromOutput(file),
                queue: 'enrich',
                state: 'done',
                phase: 'assets',
                label: 'Fertig',
                ts: Date.now(),
              },
            ],
          }))
        }
      } catch {
        // ignore
      }
    }

    void loadInitialPostworkState()

    return () => {
      alive = false
    }
  }, [])

  useEffect(() => {
    const onPointerMove = (e: PointerEvent) => {
      pointerPosRef.current = { x: e.clientX, y: e.clientY }
    }

    window.addEventListener('pointermove', onPointerMove, { passive: true })
    return () => window.removeEventListener('pointermove', onPointerMove)
  }, [])

  useEffect(() => {
    try {
      if (!lastAction) {
        localStorage.removeItem(LAST_UNDO_KEY)
        return
      }

      const payload: PersistedUndoState = {
        v: 1,
        action: lastAction,
        ts: Date.now(),
      }

      localStorage.setItem(LAST_UNDO_KEY, JSON.stringify(payload))
    } catch {
      // ignore
    }
  }, [lastAction])

  const emitCountHint = React.useCallback((delta: number) => {
    if (!delta || !Number.isFinite(delta)) return

    countHintRef.current.pending += delta

    // coalesce mehrere Aktionen sehr kurz hintereinander
    if (countHintRef.current.timer) return

    countHintRef.current.timer = window.setTimeout(() => {
      countHintRef.current.timer = 0
      const d = countHintRef.current.pending
      countHintRef.current.pending = 0
      if (!d) return

      window.dispatchEvent(
        new CustomEvent('finished-downloads:count-hint', { detail: { delta: d } })
      )
    }, 120)
  }, [])

  const modelTags = useMemo(() => {
    const tagsByModelKey: Record<string, string[]> = {}
    const tagSetByModelKey: Record<string, Set<string>> = {}

    for (const [k, flags] of Object.entries(modelsByKey ?? {})) {
      const key = lower(k)
      const arr = parseTags(flags?.tags)
      tagsByModelKey[key] = arr
      tagSetByModelKey[key] = new Set(arr.map(lower))
    }

    return { tagsByModelKey, tagSetByModelKey }
  }, [modelsByKey])

  const jobForDetails = useCallback(
    (job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey),
    [modelsByKey]
  )

  // 🔎 Suche (client-side, innerhalb der aktuell geladenen Seite)
  const [searchQuery, setSearchQuery] = React.useState<string>('')
  const deferredSearchQuery = React.useDeferredValue(searchQuery)

  const searchTokens = useMemo(
    () => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean),
    [deferredSearchQuery]
  )

  const clearSearch = useCallback(() => setSearchQuery(''), [])

  const toggleTagFilter = useCallback((tag: string) => {
    const k = lower(tag)
    setTagFilter((prev) => {
      const has = prev.some((t) => lower(t) === k)
      return has ? prev.filter((t) => lower(t) !== k) : [...prev, tag]
    })
  }, [])

  // ✅ Mobile/UX: globales "all=1" erst bei sinnvoller Suche triggern
  const searchActiveForGlobalFetch =
    activeTagSet.size > 0 ||
    searchTokens.some((t) => t.length >= 2)

  const globalFilterActive = searchActiveForGlobalFetch
  const effectiveAllMode = globalFilterActive || allMode

  const fetchAllDoneJobs = useCallback(
    async (signal?: AbortSignal) => {
      // ✅ Nur sichtbares Loading zeigen, wenn wir noch keine Override-Daten haben
      const shouldShowLoading = overrideDoneJobs == null
      if (shouldShowLoading) setIsLoading(true)
      try {
        const res = await fetch(
          `/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
          {
            cache: 'no-store' as any,
            signal,
          }
        )
        if (!res.ok) return

        const data = await res.json().catch(() => null)
        const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
        const count = Number(data?.count ?? data?.totalCount ?? items.length)

        setOverrideDoneJobs(items)
        setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
      } finally {
        if (shouldShowLoading) setIsLoading(false)
      }
    },
    [sortMode, includeKeep]
  )

  const clearTagFilter = useCallback(() => setTagFilter([]), [])

  useEffect(() => {
    // ✅ Wenn wir aus CategoriesTab kommen, liegt evtl. ein "pending" Filter in localStorage
    try {
      const raw = localStorage.getItem('finishedDownloads_pendingTags')
      if (!raw) return

      const arr = JSON.parse(raw)
      const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : []
      if (tags.length === 0) return

      flushSync(() => setTagFilter(tags))
      if (page !== 1) onPageChange(1)
    } catch {
      // ignore
    } finally {
      try {
        localStorage.removeItem('finishedDownloads_pendingTags')
      } catch {}
    }
  }, []) // nur einmal beim Mount

  const queueRefill = useCallback(() => {
    // ✅ Schon geplant? Dann nicht nochmal planen.
    if (refillTimerRef.current != null) return

    refillTimerRef.current = window.setTimeout(() => {
      refillTimerRef.current = null
      setRefillTick((n) => n + 1)
    }, 80)
  }, [page, pageSize, sortMode, includeKeep])

  useEffect(() => {
    if (!effectiveAllMode) return

    const ac = new AbortController()

    const t = window.setTimeout(() => {
      fetchAllDoneJobs(ac.signal).catch(() => {})
    }, 250)

    return () => {
      window.clearTimeout(t)
      ac.abort()
    }
  }, [effectiveAllMode, fetchAllDoneJobs])

  // ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt
  useEffect(() => {
    if (refillTick === 0) return

    const ac = new AbortController()
    let alive = true
    let finished = false // ✅ pro Effect-Instanz idempotent

    // ✅ eigene Session-ID für diese Effect-Instanz
    const mySession = ++refillSessionRef.current

    const finishRefill = () => {
      if (finished) return
      finished = true

      // ✅ Nur die AKTUELLE Session darf den globalen Refill-Status verändern
      if (refillSessionRef.current !== mySession) {
        return
      }

      refillInFlightRef.current = false

      if (refillQueuedWhileInFlightRef.current) {
        refillQueuedWhileInFlightRef.current = false
        queueRefill()
      }
    }

    // ✅ Refill läuft (nur wenn diese Session noch aktuell ist)
    if (refillSessionRef.current === mySession) {
      refillInFlightRef.current = true
    }

    // ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
    if (effectiveAllMode) {
      ;(async () => {
        try {
          // fetchAllDoneJobs setzt isLoading selbst
          await fetchAllDoneJobs(ac.signal)
          if (alive) {
            refillRetryRef.current = 0
          }
        } catch {
          // ignore (Abort/Netzwerk)
        } finally {
          if (alive) finishRefill()
        }
      })()

      return () => {
        alive = false
        ac.abort()
        finishRefill()
      }
    }

    // ✅ paged refill → hier Loading setzen
    setIsLoading(true)

    ;(async () => {
      try {
        // 1) Liste + optional count in EINEM Request holen
        const listRes = await fetch(
          `/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${
            includeKeep ? '&includeKeep=1' : ''
          }`,
          { cache: 'no-store' as any, signal: ac.signal }
        )

        if (!alive || ac.signal.aborted) return

        const okAll = listRes.ok

        if (listRes.ok) {
          const data = await listRes.json().catch(() => null)
          if (!alive || ac.signal.aborted) return

          const items = Array.isArray(data?.items)
            ? (data.items as RecordJob[])
            : Array.isArray(data)
              ? data
              : []

          setOverrideDoneJobs(items)

          // ✅ Count direkt aus /done lesen (withCount=1)
          const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
          const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0

          setOverrideDoneTotal(count)

          const totalPages = Math.max(1, Math.ceil(count / pageSize))
          if (page > totalPages) {
            onPageChange(totalPages)
            setOverrideDoneJobs(null)
            return
          }
        }

        if (okAll) {
          refillRetryRef.current = 0
        } else if (alive && !ac.signal.aborted && refillRetryRef.current < 2) {
          refillRetryRef.current += 1
          const retryNo = refillRetryRef.current

          window.setTimeout(() => {
            if (!ac.signal.aborted) {
              setRefillTick((n) => n + 1)
            }
          }, 400 * retryNo)
        }
      } catch {
        // Abort / Fehler ignorieren
      } finally {
        if (alive) {
          setIsLoading(false)
          finishRefill()
        }
      }
    })()

    return () => {
      alive = false
      ac.abort()
      finishRefill()
    }
  }, [
    refillTick,
    effectiveAllMode,
    fetchAllDoneJobs,
    page,
    pageSize,
    sortMode,
    includeKeep,
    onPageChange,
  ])

  useEffect(() => {
    // Override-Daten nur dann zurücksetzen, wenn wir wirklich wieder auf
    // die normalen /done-Props zurückfallen wollen.
    if (effectiveAllMode || includeKeep) return

    setOverrideDoneJobs(null)
    setOverrideDoneTotal(null)
  }, [page, pageSize, sortMode, includeKeep, effectiveAllMode])
  
  useEffect(() => {
    if (effectiveAllMode) return

    // Nur nötig, wenn wir NICHT auf die normalen Props zurückfallen.
    if (!includeKeep) return

    const ac = new AbortController()
    let alive = true

    setIsLoading(true)

    ;(async () => {
      try {
        const res = await fetch(
          `/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`,
          { cache: 'no-store' as any, signal: ac.signal }
        )
        if (!res.ok || !alive || ac.signal.aborted) return

        const data = await res.json().catch(() => null)
        if (!alive || ac.signal.aborted) return

        const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
        const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
        const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length

        setOverrideDoneJobs(items)
        setOverrideDoneTotal(count)
      } finally {
        if (alive) setIsLoading(false)
      }
    })()

    return () => {
      alive = false
      ac.abort()
    }
  }, [effectiveAllMode, includeKeep, page, pageSize, sortMode])

  useEffect(() => {
    try {
      const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
      if (saved === 'table' || saved === 'cards' || saved === 'gallery') {
        setView(saved)
      } else {
        // Default: Mobile -> Cards, sonst Tabelle
        setView(window.matchMedia('(max-width: 639px)').matches ? 'cards' : 'table')
      }
    } catch {
      setView('table')
    }
  }, [])

  useEffect(() => {
    try {
      localStorage.setItem(VIEW_KEY, view)
    } catch {}
  }, [view])

    useEffect(() => {
    try {
      const raw = localStorage.getItem(KEEP_KEY)
      setIncludeKeep(raw === '1' || raw === 'true' || raw === 'yes')
    } catch {
      setIncludeKeep(false)
    }
  }, [])

  useEffect(() => {
    try {
      localStorage.setItem(KEEP_KEY, includeKeep ? '1' : '0')
    } catch {}
  }, [includeKeep])

  useEffect(() => {
    try {
      const raw = localStorage.getItem(MOBILE_OPTS_KEY)
      setMobileOptionsOpen(raw === '1' || raw === 'true' || raw === 'yes')
    } catch {
      setMobileOptionsOpen(false)
    }
  }, [])

  useEffect(() => {
    try {
      localStorage.setItem(MOBILE_OPTS_KEY, mobileOptionsOpen ? '1' : '0')
    } catch {}
  }, [mobileOptionsOpen])

  // 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
  const [durations, setDurations] = React.useState<Record<string, number>>({})

  // ✅ Perf: durations gesammelt flushen (verhindert viele Re-renders beim initialen Preview-Mount)
  const durationsRef = React.useRef<Record<string, number>>({})
  const durationsFlushTimerRef = React.useRef<number | null>(null)

  // 🔹 hier sammeln wir die Videoauflösung pro Job/Datei
  const [resolutions, setResolutions] = React.useState<Record<string, { w: number; h: number }>>({})

  // ✅ Perf: resolutions gesammelt flushen (wie durations)
  const resolutionsRef = React.useRef<Record<string, { w: number; h: number }>>({})
  const resolutionsFlushTimerRef = React.useRef<number | null>(null)

  const refillRetryRef = React.useRef(0)

  React.useEffect(() => {
    resolutionsRef.current = resolutions
  }, [resolutions])

  const flushResolutionsSoon = React.useCallback(() => {
    if (resolutionsFlushTimerRef.current != null) return
    resolutionsFlushTimerRef.current = window.setTimeout(() => {
      resolutionsFlushTimerRef.current = null
      setResolutions({ ...resolutionsRef.current })
    }, 200)
  }, [])

  React.useEffect(() => {
    return () => {
      if (resolutionsFlushTimerRef.current != null) {
        window.clearTimeout(resolutionsFlushTimerRef.current)
        resolutionsFlushTimerRef.current = null
      }
    }
  }, [])

  React.useEffect(() => {
    durationsRef.current = durations
  }, [durations])

  const flushDurationsSoon = React.useCallback(() => {
    if (durationsFlushTimerRef.current != null) return
    durationsFlushTimerRef.current = window.setTimeout(() => {
      durationsFlushTimerRef.current = null
      // neue Objekt-Referenz, damit React aktualisiert
      setDurations({ ...durationsRef.current })
    }, 200)
  }, [])

  React.useEffect(() => {
    return () => {
      if (durationsFlushTimerRef.current != null) {
        window.clearTimeout(durationsFlushTimerRef.current)
        durationsFlushTimerRef.current = null
      }
    }
  }, [])

  const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null)

  const previewMuted = !Boolean(teaserAudio)

  const tryAutoplayInline = useCallback((domId: string) => {
    const host = document.getElementById(domId)
    const v = host?.querySelector('video') as HTMLVideoElement | null
    if (!v) return false

    applyInlineVideoPolicy(v, { muted: previewMuted })

    const p = v.play?.()
    if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
    return true
  }, [previewMuted])

  const startInline = useCallback((key: string) => {
    setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
  }, [])

  const startInlineAt = useCallback((key: string, seconds: number, domId: string) => {
    const safeSeconds = Number.isFinite(seconds) && seconds > 0 ? seconds : 0

    // Inline-Preview aktivieren / remount erzwingen
    setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))

    // Nach dem Rendern das Video suchen, seeken und autoplay versuchen
    const trySeekAndPlay = (retriesLeft: number) => {
      const host = document.getElementById(domId)
      const v = host?.querySelector('video') as HTMLVideoElement | null

      if (!v) {
        if (retriesLeft > 0) {
          requestAnimationFrame(() => trySeekAndPlay(retriesLeft - 1))
        }
        return
      }

      applyInlineVideoPolicy(v, { muted: previewMuted })

      const applySeek = () => {
        try {
          const dur = Number(v.duration)
          const maxSeek =
            Number.isFinite(dur) && dur > 0
              ? Math.max(0, dur - 0.05)
              : safeSeconds

          v.currentTime = Math.max(0, Math.min(safeSeconds, maxSeek))
        } catch {
          // ignore
        }

        const p = v.play?.()
        if (p && typeof (p as any).catch === 'function') {
          ;(p as Promise<void>).catch(() => {})
        }
      }

      // Wenn Metadaten schon da sind -> direkt seeken
      if (v.readyState >= 1) {
        applySeek()
        return
      }

      // Sonst warten bis metadata da sind
      const onLoadedMetadata = () => {
        v.removeEventListener('loadedmetadata', onLoadedMetadata)
        applySeek()
      }

      v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true })

      // zusätzlich sofort play versuchen (hilft manchmal)
      const p = v.play?.()
      if (p && typeof (p as any).catch === 'function') {
        ;(p as Promise<void>).catch(() => {})
      }
    }

    requestAnimationFrame(() => trySeekAndPlay(8))
  }, [previewMuted])

  const openPlayer = useCallback((job: RecordJob) => {
    setInlinePlay(null)
    onOpenPlayer(job)
  }, [onOpenPlayer])

  const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
    const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
    setInlinePlay(null)
    onOpenPlayer(job, s)
  }, [onOpenPlayer])

  const handleScrubberClickIndex = useCallback(
    (job: RecordJob, segmentIndex: number, segmentCount: number) => {
      const idx = Number.isFinite(segmentIndex) ? Math.floor(segmentIndex) : 0
      const count = Number.isFinite(segmentCount) ? Math.floor(segmentCount) : 0

      if (count <= 0) {
        // Fallback: Player normal öffnen
        openPlayer(job)
        return
      }

      // Dauer bevorzugt aus Preview-Metadaten, sonst aus Job
      const k = keyFor(job)
      const durationSec =
        durations[k] ??
        ((job as any)?.meta?.file?.durationSeconds as number | undefined) ??
        0

      if (!Number.isFinite(durationSec) || durationSec <= 0) {
        // Wenn keine Dauer bekannt ist: trotzdem öffnen (ohne Timestamp)
        openPlayer(job)
        return
      }

      // Segment-Index -> Startsekunde
      // Beispiel: 10 Segmente, Klick auf Index 0..9
      const clampedIdx = Math.max(0, Math.min(idx, count - 1))
      const secPerSegment = durationSec / count
      const startAtSec = clampedIdx * secPerSegment

      openPlayerAt(job, startAtSec)
    },
    [durations, keyFor, openPlayer, openPlayerAt]
  )

  const markDeleting = useCallback((key: string, value: boolean) => {
    setDeletingKeys((prev) => {
      const next = new Set(prev)
      if (value) next.add(key)
      else next.delete(key)
      return next
    })
  }, [])

  const markDeleted = useCallback((key: string) => {
    setDeletedKeys((prev) => {
      const next = new Set(prev)
      next.add(key)
      return next
    })
  }, [])

  const [keepingKeys, setKeepingKeys] = React.useState<Set<string>>(() => new Set())

  const markKeeping = useCallback((key: string, value: boolean) => {
    setKeepingKeys((prev) => {
      const next = new Set(prev)
      if (value) next.add(key)
      else next.delete(key)
      return next
    })
  }, [])

  // neben deletedKeys / deletingKeys
  const [removingKeys, setRemovingKeys] = React.useState<Set<string>>(() => new Set())

  // ⏱️ Timer pro Key, damit wir Optimistik bei Fehler sauber zurückrollen können
  const removeTimersRef = React.useRef<Map<string, number>>(new Map())

  useEffect(() => {
    return () => {
      for (const t of removeTimersRef.current.values()) {
        window.clearTimeout(t)
      }
      removeTimersRef.current.clear()
    }
  }, [])

  const markRemoving = useCallback((key: string, value: boolean) => {
    setRemovingKeys((prev) => {
      const next = new Set(prev)
      if (value) next.add(key)
      else next.delete(key)
      return next
    })
  }, [])

  const cancelRemoveTimer = useCallback((key: string) => {
    const t = removeTimersRef.current.get(key)
    if (t != null) {
      window.clearTimeout(t)
      removeTimersRef.current.delete(key)
    }
  }, [])

  const restoreRow = useCallback(
    (key: string, file?: string) => {
      cancelRemoveTimer(key)

      setDeletedKeys((prev) => {
        const next = new Set(prev)
        next.delete(key)
        return next
      })

      setRemovingKeys((prev) => {
        const next = new Set(prev)
        next.delete(key)
        return next
      })

      setDeletingKeys((prev) => {
        const next = new Set(prev)
        next.delete(key)
        return next
      })

      setKeepingKeys((prev) => {
        const next = new Set(prev)
        next.delete(key)
        return next
      })

      if (file) {
        setHiddenFiles((prev) => {
          const next = new Set(prev)
          next.delete(file)
          return next
        })
      }
    },
    [cancelRemoveTimer]
  )

  const animateRemove = useCallback(
    (key: string, file?: string) => {
      if (file) {
        setHiddenFiles((prev) => {
          const next = new Set(prev)
          next.add(file)
          return next
        })
      }

      markRemoving(key, true)
      cancelRemoveTimer(key)

      const t = window.setTimeout(() => {
        removeTimersRef.current.delete(key)

        markDeleted(key)
        markRemoving(key, false)

        requestAnimationFrame(() => {
          requestAnimationFrame(() => {
            refreshHoverPreviewFromPointer()
          })
        })
      }, 320)

      removeTimersRef.current.set(key, t)
    },
    [markDeleted, markRemoving, cancelRemoveTimer, refreshHoverPreviewFromPointer]
  )

  const releasePlayingFile = useCallback(
    async (file: string, opts?: { close?: boolean }) => {
      // 1) App-/Overlay-Player freigeben
      window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
      if (opts?.close) {
        window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
      }

      // 2) Einmal auf den nächsten Frame warten (React/DOM cleanup)
      await new Promise<void>((r) => requestAnimationFrame(() => r()))

      // 3) Nochmals release senden (hilft bei race zwischen close/unmount)
      window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))

      // 4) Windows/Filesystem braucht manchmal einen Moment bis Handles wirklich frei sind
      await new Promise((r) => window.setTimeout(r, 260))
    },
    []
  )

  const withFileReleaseRetry = useCallback(
    async <T,>(
      file: string,
      run: () => Promise<T>,
      opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
    ): Promise<T> => {
      const attempts = Math.max(1, opts?.attempts ?? 4)
      const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)

      let lastErr: unknown

      for (let attempt = 1; attempt <= attempts; attempt++) {
        try {
          // vor JEDEM Versuch freigeben (nicht nur einmal)
          await releasePlayingFile(file, { close: opts?.close ?? true })

          // kurzer Tick extra (DOM/video cleanup, OS handle release)
          if (attempt > 1) {
            await sleep(baseDelayMs * attempt)
          } else {
            await sleep(80)
          }

          return await run()
        } catch (e) {
          lastErr = e

          // nur bei "Datei in Verwendung" retryen
          if (!looksLikeFileInUseError(e) || attempt >= attempts) {
            throw e
          }

          // nächster Versuch
          continue
        }
      }

      throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler'))
    },
    [releasePlayingFile]
  )

  const runFileMutation = useCallback(
    async <T,>(opts: RunFileMutationOptions<T>): Promise<{ ok: boolean; result?: T }> => {
      const {
        file,
        rowKey,
        setBusy,
        isBusyNow,
        optimisticRemove,
        alreadyRemoved,
        run,
        onSuccess,
        onError,
        labels,
      } = opts

      if (!file) {
        notify.error(labels.invalidTitle, labels.invalidBody)
        return { ok: false }
      }

      if (isBusyNow?.()) return { ok: false }

      setBusy?.(true)

      try {
        if (optimisticRemove && !alreadyRemoved) {
          animateRemove(rowKey)
        }

        const result = await run()
        await onSuccess?.(result)

        return { ok: true, result }
      } catch (e: any) {
        // Optimistik zurückrollen
        if (optimisticRemove) {
          restoreRow(rowKey, file)
        }

        await onError?.(e)

        if (looksLikeFileInUseError(e)) {
          notify.error(labels.inUseTitle, `${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.`)
        } else {
          const suffix = e?.message ? ` — ${String(e.message)}` : ''
          notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`)
        }

        return { ok: false }
      } finally {
        setBusy?.(false)
      }
    },
    [notify, animateRemove, restoreRow]
  )

  const deleteVideo = useCallback(
    async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise<boolean> => {
      const file = baseName(job.output || '')
      const key = keyFor(job)

      const res = await runFileMutation({
        kind: 'delete',
        job,
        file,
        rowKey: key,
        setBusy: (v) => markDeleting(key, v),
        isBusyNow: () => deletingKeys.has(key),
        optimisticRemove: true,
        alreadyRemoved: opts?.alreadyRemoved,
        labels: {
          invalidTitle: 'Löschen nicht möglich',
          invalidBody: 'Kein Dateiname gefunden – kann nicht löschen.',
          inUseTitle: 'Löschen fehlgeschlagen',
          failTitle: 'Löschen fehlgeschlagen',
          failPrefix: file,
        },
        run: async () => {
          if (onDeleteJob) {
            return await withFileReleaseRetry(
              file,
              async () => await onDeleteJob(job),
              { close: true, attempts: 4, baseDelayMs: 220 }
            )
          }

          const r = await withFileReleaseRetry(
            file,
            async () =>
              await fetchWithTextError(`/api/record/delete?file=${encodeURIComponent(file)}`, {
                method: 'POST',
              }),
            { close: true, attempts: 4, baseDelayMs: 220 }
          )

          return (await r.json().catch(() => null)) as any
        },
        onSuccess: async (result: any) => {
          // Fall 1: externer Handler (App) liefert { undoToken }
          if (onDeleteJob) {
            const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
            if (undoToken) {
              setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
            } else {
              setLastAction(null)
            }
            return
          }

          // Fall 2: lokaler API-Call (liefert from + undoToken)
          const from = (result?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
          const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''

          if (undoToken) {
            setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
          } else {
            setLastAction(null)
          }

          queueRefill()
          emitCountHint(-1)
        },
      })

      return res.ok
    },
    [
      baseName,
      keyFor,
      deletingKeys,
      markDeleting,
      onDeleteJob,
      withFileReleaseRetry,
      runFileMutation,
      queueRefill,
      emitCountHint,
    ]
  )

  const keepVideo = useCallback(
    async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => {
      const file = baseName(job.output || '')
      const key = keyFor(job)

      const res = await runFileMutation({
        kind: 'keep',
        job,
        file,
        rowKey: key,
        setBusy: (v) => markKeeping(key, v),
        isBusyNow: () => keepingKeys.has(key) || deletingKeys.has(key),
        optimisticRemove: true,
        alreadyRemoved: opts?.alreadyRemoved,
        labels: {
          invalidTitle: 'Keep nicht möglich',
          invalidBody: 'Kein Dateiname gefunden – kann nicht behalten.',
          inUseTitle: 'Keep fehlgeschlagen',
          failTitle: 'Keep fehlgeschlagen',
          failPrefix: file,
        },
        run: async () => {
          if (onKeepJob) {
            return await withFileReleaseRetry(
              file,
              async () => await onKeepJob(job),
              { close: true, attempts: 4, baseDelayMs: 220 }
            )
          }

          const r = await withFileReleaseRetry(
            file,
            async () =>
              await fetchWithTextError(`/api/record/keep?file=${encodeURIComponent(file)}`, {
                method: 'POST',
              }),
            { close: true, attempts: 4, baseDelayMs: 220 }
          )
          return (await r.json().catch(() => null)) as any
        },
        onSuccess: async (data: any) => {
          const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file

          setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })

          queueRefill()
          emitCountHint(includeKeep ? 0 : -1)
        },
      })

      return res.ok
    },
    [
      baseName,
      keyFor,
      markKeeping,
      keepingKeys,
      deletingKeys,
      withFileReleaseRetry,
      runFileMutation,
      queueRefill,
      emitCountHint,
      includeKeep,
    ]
  )

  const applyRename = useCallback((oldFile: string, newFile: string) => {
    if (!oldFile || !newFile || oldFile === newFile) return
    setRenamedFiles((prev) => {
      const next: Record<string, string> = { ...prev }
      for (const [k, v] of Object.entries(next)) {
        if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k]
      }
      next[oldFile] = newFile
      return next
    })
    setSelectedItems((prev) => {
      let changed = false
      const next: Record<string, SelectedItem> = {}

      for (const [k, item] of Object.entries(prev)) {
        if (item.file === oldFile) {
          next[k] = { ...item, file: newFile }
          changed = true
        } else {
          next[k] = item
        }
      }

      return changed ? next : prev
    })
  }, [])

  const clearRenamePair = useCallback((a: string, b: string) => {
    if (!a && !b) return
    setRenamedFiles((prev) => {
      const next: Record<string, string> = { ...prev }
      for (const [k, v] of Object.entries(next)) {
        if (k === a || k === b || v === a || v === b) delete next[k]
      }
      return next
    })
  }, [])

  const undoLastAction = useCallback(async () => {
    if (!lastAction || undoing) return
    setUndoing(true)

    const unhideRow = (keyOrToken: string, file?: string) => {
      setDeletedKeys((prev) => {
        const next = new Set(prev)
        next.delete(keyOrToken)
        return next
      })
      setDeletingKeys((prev) => {
        const next = new Set(prev)
        next.delete(keyOrToken)
        return next
      })
      setKeepingKeys((prev) => {
        const next = new Set(prev)
        next.delete(keyOrToken)
        return next
      })
      setRemovingKeys((prev) => {
        const next = new Set(prev)
        next.delete(keyOrToken)
        return next
      })

      if (file) {
        setHiddenFiles((prev) => {
          const next = new Set(prev)
          next.delete(file)
          return next
        })
      }
    }

    try {
      if (lastAction.kind === 'delete') {
        const res = await fetch(
          `/api/record/restore?token=${encodeURIComponent(lastAction.undoToken)}`,
          { method: 'POST' }
        )
        if (!res.ok) {
          const text = await res.text().catch(() => '')
          throw new Error(text || `HTTP ${res.status}`)
        }
        const data = await res.json().catch(() => null) as any
        const restoredFile = String(data?.restoredFile || lastAction.originalFile)

        if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile)
        // Fallbacks (falls alte lastAction ohne rowKey existiert)
        unhideRow(lastAction.originalFile, lastAction.originalFile)
        unhideRow(restoredFile, restoredFile)

        const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1
        emitCountHint(visibleDelta)
        queueRefill()
        setLastAction(null)
        return
      }

      if (lastAction.kind === 'keep') {
        const res = await fetch(
          `/api/record/unkeep?file=${encodeURIComponent(lastAction.keptFile)}`,
          { method: 'POST' }
        )
        if (!res.ok) {
          const text = await res.text().catch(() => '')
          throw new Error(text || `HTTP ${res.status}`)
        }
        const data = await res.json().catch(() => null) as any
        const restoredFile = String(data?.newFile || lastAction.originalFile)

        if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile)
        // Fallbacks (für ältere Actions / Sonderfälle)
        unhideRow(lastAction.originalFile, lastAction.originalFile)
        unhideRow(restoredFile, restoredFile)

        emitCountHint(+1)
        queueRefill()
        setLastAction(null)
        return
      }

      if (lastAction.kind === 'hot') {
        const res = await fetch(
          `/api/record/toggle-hot?file=${encodeURIComponent(lastAction.currentFile)}`,
          { method: 'POST' }
        )
        if (!res.ok) {
          const text = await res.text().catch(() => '')
          throw new Error(text || `HTTP ${res.status}`)
        }

        const data = (await res.json().catch(() => null)) as any
        const oldFile = String(data?.oldFile || lastAction.currentFile)
        const newFile = String(data?.newFile || '')

        if (newFile) {
          applyRename(oldFile, newFile)
        }

        queueRefill()
        setLastAction(null)
        return
      }
    } catch (e: any) {
      notify.error('Undo fehlgeschlagen', String(e?.message || e))
    } finally {
      setUndoing(false)
    }
  }, [
    lastAction,
    undoing,
    notify,
    queueRefill,
    includeKeep,
    emitCountHint,
    applyRename,
  ])

  const [hotBusyKeys, setHotBusyKeys] = React.useState<Set<string>>(() => new Set())

  const markHotBusy = useCallback((key: string, value: boolean) => {
    setHotBusyKeys((prev) => {
      const next = new Set(prev)
      if (value) next.add(key)
      else next.delete(key)
      return next
    })
  }, [])

  const toggleHotVideo = useCallback(
    async (job: RecordJob): Promise<void> => {
      const currentFile = baseName(job.output || '')
      const key = keyFor(job)

      const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
      const oldFile = currentFile
      const optimisticNew = toggledName(oldFile)

      await runFileMutation({
        kind: 'rename',
        job,
        file: currentFile,
        rowKey: key,
        setBusy: (v) => markHotBusy(key, v),
        isBusyNow: () => hotBusyKeys.has(key),
        optimisticRemove: false,
        labels: {
          invalidTitle: 'HOT nicht möglich',
          invalidBody: 'Kein Dateiname gefunden – kann nicht HOT togglen.',
          inUseTitle: 'HOT umbenennen fehlgeschlagen',
          failTitle: 'HOT umbenennen fehlgeschlagen',
          failPrefix: oldFile,
        },
        run: async () => {
          // Optimistik sofort anwenden
          applyRename(oldFile, optimisticNew)

          if (onToggleHot) {
            const r = await onToggleHot(job)
            return r as any
          }

          const r = await withFileReleaseRetry(
            oldFile,
            async () =>
              await fetchWithTextError(
                `/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
                { method: 'POST' }
              ),
            { close: true, attempts: 4, baseDelayMs: 220 }
          )

          return (await r.json().catch(() => null)) as any
        },
        onSuccess: async (data: any) => {
          const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
          const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew

          if (apiOld && apiNew && apiOld !== apiNew) {
            applyRename(apiOld, apiNew)
          }

          setLastAction({ kind: 'hot', currentFile: apiNew })

          if (!onToggleHot || sortMode === 'file_asc' || sortMode === 'file_desc') {
            queueRefill()
          }
        },
        onError: async () => {
          // Rename-Optimistik rollback
          clearRenamePair(oldFile, optimisticNew)
          setLastAction(null)
        },
      })
    },
    [
      baseName,
      keyFor,
      hotBusyKeys,
      markHotBusy,
      runFileMutation,
      applyRename,
      clearRenamePair,
      onToggleHot,
      withFileReleaseRetry,
      queueRefill,
      sortMode,
    ]
  )

  const regenerateAssetsForFile = useCallback(async (file: string) => {
    const cleanFile = String(file || '').trim()
    if (!cleanFile) return false

    setPostworkByFile((prev) => ({
      ...prev,
      [cleanFile]: [
        {
          file: cleanFile,
          assetId: fileStemFromOutput(cleanFile),
          queue: 'enrich',
          state: 'running',
          phase: 'assets',
          label: 'Wird verarbeitet…',
          ts: Date.now(),
        },
      ],
    }))

    try {
      const res = await fetch('/api/record/regenerate-assets', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ file: cleanFile }),
      })

      if (!res.ok) {
        const text = await res.text().catch(() => '')
        throw new Error(text || `HTTP ${res.status}`)
      }

      return true
    } catch (e: any) {
      setPostworkByFile((prev) => ({
        ...prev,
        [cleanFile]: [
          {
            file: cleanFile,
            assetId: fileStemFromOutput(cleanFile),
            queue: 'enrich',
            state: 'error',
            phase: 'assets',
            label: 'Fehler',
            ts: Date.now(),
          },
        ],
      }))

      notify.error(
        'Assets neu generieren fehlgeschlagen',
        e?.message ? String(e.message) : String(e)
      )
      return false
    }
  }, [notify])

  const applyRenamedOutput = useCallback(
    (job: RecordJob): RecordJob => {
      const out = norm(job.output || '')
      const file = baseName(out)
      const override = renamedFiles[file]
      if (!override) return job

      const idx = out.lastIndexOf('/')
      const dir = idx >= 0 ? out.slice(0, idx + 1) : ''
      return { ...job, output: dir + override }
    },
    [renamedFiles]
  )

  const doneJobsPage = overrideDoneJobs ?? doneJobs
  const doneTotalPage = overrideDoneTotal ?? doneTotal

  const rows = useMemo(() => {
    const map = new Map<string, RecordJob>()
    const doneBaseKeys = new Set<string>()

    // Basis: Files aus dem Done-Ordner
    for (const j of doneJobsPage) {
      const jj = applyRenamedOutput(j)
      const k = keyFor(jj)

      map.set(k, jj)
      doneBaseKeys.add(k)
    }

    // Jobs aus /list drübermergen, damit aktuelle Live-Felder
    // (phase, postWork, progress, duration, size, etc.) sichtbar bleiben.
    for (const j of jobs) {
      const jj = applyRenamedOutput(j)
      const k = keyFor(jj)

      if (map.has(k)) {
        const existing = map.get(k)!

        map.set(k, {
          ...existing,
          ...jj,
          meta: mergeMetaPreferDone((existing as any)?.meta, (jj as any)?.meta),
        })
        continue
      }

      // Fallback: gleicher Dateiname, aber evtl. anderer key/ID
      const file = baseName(jj.output || '')
      if (!file) continue

      for (const [existingKey, existingJob] of map.entries()) {
        if (baseName(existingJob.output || '') === file) {
          map.set(existingKey, {
            ...existingJob,
            ...jj,
            meta: mergeMetaPreferDone((existingJob as any)?.meta, (jj as any)?.meta),
          })
          break
        }
      }
    }

    const list = Array.from(map.entries())
      .map(([k, j]) => ({ k, j }))
      .filter(({ k, j }) => {
        const file = baseName(j.output || '')
        if (deletedKeys.has(k)) return false
        if (file && hiddenFiles.has(file)) return false

        // .trash niemals anzeigen
        if (isTrashOutput(j.output)) return false

        const anyJ = j as any
        const status = String(anyJ?.status ?? '').trim().toLowerCase()
        const endedAt = anyJ?.endedAt

        // ✅ Wichtig:
        // Sobald die Datei aus dem Done-Ordner kommt, soll sie im Finished-Tab bleiben –
        // auch wenn per SSE/Job-Merge noch phase/postWork=running/queued gesetzt ist.
        if (doneBaseKeys.has(k)) return true

        // Fallback: terminale Jobs ebenfalls anzeigen
        if (
          status === 'finished' ||
          status === 'failed' ||
          status === 'stopped'
        ) {
          return true
        }

        // Optionaler Fallback für bereits beendete Jobs, falls Status mal verzögert ist
        if (endedAt) return true

        return false
      })
      .map(({ j }) => j)

    return list
  }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutput, keyFor, baseName])

  

  const effectivePostworkSummaryByFile = useMemo<Record<string, FinishedPostworkSummary>>(() => {
    const next: Record<string, FinishedPostworkSummary> = {}

    // 1) Erst Live-Zustände sammeln
    for (const [file, badges] of Object.entries(postworkByFile)) {
      const summary = summarizePostworkSteps(file, badges)
      if (summary) {
        next[file] = summary
      }
    }

    // 2) Dann für jede sichtbare Datei Meta auswerten und ggf. Live-Zustand überschreiben
    for (const job of rows) {
      const fileRaw = baseName(job.output || '')
      const filePlain = stripHotPrefix(fileRaw)

      if (!fileRaw) continue

      const rawMeta = (job as any)?.meta
      const meta = parseMetaObject(rawMeta)
      const assetId = fileStemFromOutput(job.output)

      const summaryFromHistory = buildPostworkSummaryFromMetaHistory(fileRaw, assetId, meta)
      const summaryFromCompleted =
        buildCompletedPostworkSummaryFromMeta(fileRaw, assetId, meta)

      const mergedMetaSummary =
        mergePostworkSummaries(
          summaryFromHistory,
          summaryFromCompleted,
          fileRaw,
          assetId
        )

      const metaSummary =
        mergedMetaSummary ??
        buildMissingPostworkSummary(fileRaw, assetId, 'Kein Status')

      const liveSummary = next[fileRaw] ?? next[filePlain]

      const summary =
        mergePostworkSummaries(
          liveSummary ?? null,
          metaSummary ?? null,
          fileRaw,
          assetId
        ) ?? metaSummary ?? liveSummary ?? null

      if (summary) {
        next[fileRaw] = summary
        if (filePlain && filePlain !== fileRaw) {
          next[filePlain] = summary
        }
      }
    }

    return next
  }, [postworkByFile, rows])

  const viewRows = rows

  const fileToKeyRef = React.useRef<Map<string, string>>(new Map())

  useEffect(() => {
    const m = new Map<string, string>()
    for (const j of viewRows) {
      const f = baseName(j.output || '')
      if (!f) continue
      m.set(f, keyFor(j))
    }
    fileToKeyRef.current = m
  }, [viewRows])

  useEffect(() => {
    const onPostwork = (ev: Event) => {
      const detail = (ev as CustomEvent<FinishedPostworkBadge>).detail
      const file = String(detail?.file ?? '').trim()
      if (!file) return

      setPostworkByFile((prev) => {
        const next = { ...prev }

        if (detail.state === 'missing') {
          const cur = Array.isArray(prev[file]) ? prev[file] : []
          const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}`
          const filtered = cur.filter((item) => {
            const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}`
            return itemKey !== missingKey
          })

          if (filtered.length === 0) {
            delete next[file]
          } else {
            next[file] = filtered
          }

          return next
        }

        const incoming: FinishedPostworkBadge = {
          ...detail,
          label:
            String(detail?.label ?? '').trim() === file
              ? undefined
              : detail?.label,
        }

        const cur = Array.isArray(prev[file]) ? prev[file] : []
        const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}`

        let replaced = false
        const merged = cur.map((item) => {
          const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}`
          if (itemKey !== key) return item
          replaced = true
          return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming
        })

        next[file] = replaced ? merged : [...merged, incoming]
        return next
      })
    }

    window.addEventListener('finished-downloads:postwork', onPostwork as EventListener)
    return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener)
  }, [])

  useEffect(() => {
    const onExternalDelete = (ev: Event) => {
      const detail = (ev as CustomEvent<{ file: string; phase: 'start'|'success'|'error' }>).detail
      if (!detail?.file) return

      const key = fileToKeyRef.current.get(detail.file) || detail.file

      if (detail.phase === 'start') {
        markDeleting(key, true)

        // ✅ Optimistik: überall gleich -> animiert raus
        animateRemove(key, detail.file)

        return
      }

      if (detail.phase === 'error') {
        // ✅ alles zurückrollen -> wieder sichtbar
        restoreRow(key, detail.file)

        // ✅ Swipe zurück (nur Cards relevant, schadet sonst aber nicht)
        swipeRefs.current.get(key)?.reset()
        return
      }

      if (detail.phase === 'success') {
        markDeleting(key, false)

        // ✅ Nach erfolgreichem externen Delete aktuelle Seite nachziehen
        if (refillInFlightRef.current) {
          refillQueuedWhileInFlightRef.current = true
        } else {
          queueRefill()
        }

        return
      }
    }

    window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
    return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
  }, [animateRemove, markDeleting, queueRefill, restoreRow])

  useEffect(() => {
    const onReload = () => {
      // Nur reagieren, wenn FinishedDownloads wirklich sichtbar/mounted ist
      if (document.hidden) return

      if (refillInFlightRef.current) {
        if (!refillQueuedWhileInFlightRef.current) {
          refillQueuedWhileInFlightRef.current = true
        }
        return
      }

      if (refillTimerRef.current != null) return

      queueRefill()
    }

    window.addEventListener('finished-downloads:reload', onReload as EventListener)
    return () => window.removeEventListener('finished-downloads:reload', onReload as EventListener)
  }, [queueRefill])

  useEffect(() => {
    const onExternalRename = (ev: Event) => {
      const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail
      const oldFile = String(detail?.oldFile ?? '').trim()
      const newFile = String(detail?.newFile ?? '').trim()
      if (!oldFile || !newFile || oldFile === newFile) return

      // ✅ nutzt eure bestehende Logik inkl. Aufräumen + durations-move
      applyRename(oldFile, newFile)
    }

    window.addEventListener('finished-downloads:rename', onExternalRename as EventListener)
    return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener)
  }, [applyRename])

  const visibleRows = useMemo(() => {
    const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))

    // 🔎 Suche (AND über Tokens)
    const searched = searchTokens.length
      ? base.filter((j) => {
          const file = baseName(j.output || '')
          const model = modelNameFromOutput(j.output)
          const modelKey = lower(model)
          const tags = modelTags.tagsByModelKey[modelKey] ?? []

          const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' '))

          for (const t of searchTokens) {
            if (!hay.includes(t)) return false
          }
          return true
        })
      : base

    if (activeTagSet.size === 0) return searched

    // AND-Filter: alle ausgewählten Tags müssen vorhanden sein
    return searched.filter((j) => {
      const modelKey = lower(modelNameFromOutput(j.output))

      const have = modelTags.tagSetByModelKey[modelKey]
      if (!have || have.size === 0) return false

      for (const t of activeTagSet) {
        if (!have.has(t)) return false
      }
      return true
    })
  }, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens])

  const selectedFiles = useMemo(() => {
    return Object.values(selectedItems)
      .map((x) => String(x.file || '').trim())
      .filter(Boolean)
  }, [selectedItems])

  const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage

  const pageRows = useMemo(() => {
    if (!effectiveAllMode) return visibleRows
    const start = (page - 1) * pageSize
    const end = start + pageSize
    return visibleRows.slice(Math.max(0, start), Math.max(0, end))
  }, [effectiveAllMode, visibleRows, page, pageSize])

  const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
  const emptyByFilter = globalFilterActive && visibleRows.length === 0

  // Optional zusätzlich: wenn allMode und wirklich gar nix da:
  const emptyAll = allMode && visibleRows.length === 0

  const showLoadingCard = isLoading && (emptyFolder || emptyAll || emptyByFilter)

  useEffect(() => {
    if (!globalFilterActive) return
    const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize))
    if (page > totalPages) onPageChange(totalPages)
  }, [globalFilterActive, visibleRows.length, page, pageSize, onPageChange])

  // 🖱️ Desktop: Teaser nur bei Hover (Settings: 'hover' = Standard). Mobile: weiterhin Viewport-Fokus (Effect darunter)
  useEffect(() => {
    const active =
      !canHover &&
      effectiveTeaserPlaybackMode !== 'still' &&
      (view === 'cards' || view === 'gallery' || view === 'table')

    if (!active) {
      setTeaserKey(null)
      teaserIORef.current?.disconnect()
      teaserIORef.current = null
      return
    }

    // Cards: Inline-Player soll “sticky” bleiben
    if (view === 'cards' && inlinePlay?.key) {
      setTeaserKey(inlinePlay.key)
      return
    }

    teaserIORef.current?.disconnect()

    const io = new IntersectionObserver(
      (entries) => {
        let bestKey: string | null = null
        let bestRatio = 0

        for (const ent of entries) {
          if (!ent.isIntersecting) continue
          const k = elToKeyRef.current.get(ent.target)
          if (!k) continue

          if (ent.intersectionRatio > bestRatio) {
            bestRatio = ent.intersectionRatio
            bestKey = k
          }
        }

        if (bestKey) {
          setTeaserKey((prev) => (prev === bestKey ? prev : bestKey))
        }
      },
      {
        // eher “welche Card ist wirklich sichtbar”
        threshold: [0, 0.15, 0.3, 0.5, 0.7, 0.9],
        rootMargin: '0px',
      }
    )

    teaserIORef.current = io

    for (const [k, el] of teaserHostsRef.current) {
      elToKeyRef.current.set(el, k)
      io.observe(el)
    }

    return () => {
      io.disconnect()
      if (teaserIORef.current === io) teaserIORef.current = null
    }
  }, [view, effectiveTeaserPlaybackMode, canHover, inlinePlay?.key])

  // 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
  const runtimeOf = (job: RecordJob): string => {
    const k = keyFor(job)

    // ✅ 1) echte Videodauer (Preview-Metadaten oder Backend durationSeconds)
    const meta = parseMetaObject((job as any)?.meta)
    const sec = durations[k] ?? metaDurationSeconds(meta)
    
    if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) {
      return formatDuration(sec * 1000)
    }

    // 2) Fallback
    const start = Date.parse(String(job.startedAt || ''))
    const end = Date.parse(String(job.endedAt || ''))
    if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
      return formatDuration(end - start)
    }

    return '—'
  }

  const registerTeaserHost = useCallback(
    (key: string) => (el: HTMLDivElement | null) => {
      const prev = teaserHostsRef.current.get(key)
      if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev)

      if (el) {
        teaserHostsRef.current.set(key, el)
        elToKeyRef.current.set(el, key)
        teaserIORef.current?.observe(el)
      } else {
        if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev)
        teaserHostsRef.current.delete(key)
      }
    },
    []
  )

  // Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind
  const handleDuration = useCallback((job: RecordJob, seconds: number) => {
    if (!Number.isFinite(seconds) || seconds <= 0) return
    const k = keyFor(job)

    const old = durationsRef.current[k]
    if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) return

    durationsRef.current = { ...durationsRef.current, [k]: seconds }
    flushDurationsSoon()
  }, [flushDurationsSoon])

  const handleResolution = useCallback((job: RecordJob, w: number, h: number) => {
    if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return

    const k = keyFor(job)
    const prev = resolutionsRef.current[k]
    if (prev && prev.w === w && prev.h === h) return

    resolutionsRef.current = { ...resolutionsRef.current, [k]: { w, h } }
    flushResolutionsSoon()
  }, [flushResolutionsSoon])

  const bulkDeleteSelected = useCallback(async () => {
    if (bulkBusy) return
    if (selectedFiles.length === 0) return

    const ok = window.confirm(`${selectedFiles.length} Downloads löschen?`)
    if (!ok) return

    setBulkBusy(true)
    try {
      const res = await fetch('/api/record/delete-many', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ files: selectedFiles }),
      })

      if (!res.ok) {
        const text = await res.text().catch(() => '')
        throw new Error(text || `HTTP ${res.status}`)
      }

      const data = await res.json().catch(() => null)
      const results = Array.isArray(data?.results) ? data.results : []

      for (const item of results) {
        if (!item?.ok) continue

        const file = String(item.file || '').trim()
        if (!file) continue

        const key =
          Object.values(selectedItems).find((x) => x.file === file)?.key ||
          fileToKeyRef.current.get(file) ||
          file

        animateRemove(key, file)
      }

      const deletedCount = Number(data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0)

      clearSelection()
      queueRefill()
      emitCountHint(-deletedCount)

      if (deletedCount > 0) {
        notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`)
      }
    } catch (e: any) {
      notify.error('Bulk-Löschen fehlgeschlagen', String(e?.message || e))
    } finally {
      setBulkBusy(false)
    }
  }, [
    bulkBusy,
    selectedFiles,
    selectedItems,
    animateRemove,
    clearSelection,
    queueRefill,
    emitCountHint,
    notify,
  ])

  const bulkKeepSelected = useCallback(async () => {
    if (bulkBusy) return
    if (selectedFiles.length === 0) return

    const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`)
    if (!ok) return

    setBulkBusy(true)
    try {
      const res = await fetch('/api/record/keep-many', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ files: selectedFiles }),
      })

      if (!res.ok) {
        const text = await res.text().catch(() => '')
        throw new Error(text || `HTTP ${res.status}`)
      }

      const data = await res.json().catch(() => null)
      const results = Array.isArray(data?.results) ? data.results : []

      for (const item of results) {
        if (!item?.ok) continue

        const file = String(item.file || '').trim()
        if (!file) continue

        const key =
          Object.values(selectedItems).find((x) => x.file === file)?.key ||
          fileToKeyRef.current.get(file) ||
          file

        animateRemove(key, file)
      }

      const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0)

      clearSelection()
      queueRefill()
      emitCountHint(includeKeep ? 0 : -keptCount)

      if (keptCount > 0) {
        notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`)
      }
    } catch (e: any) {
      notify.error('Bulk-Keep fehlgeschlagen', String(e?.message || e))
    } finally {
      setBulkBusy(false)
    }
  }, [
    bulkBusy,
    selectedFiles,
    selectedItems,
    animateRemove,
    clearSelection,
    queueRefill,
    emitCountHint,
    includeKeep,
    notify,
  ])

  // ✅ Hooks immer zuerst – unabhängig von rows
  const isSmall = useMediaQuery('(max-width: 639px)')

  // ✅ Mobile-Offsets für Cards-Ansicht (zentral steuerbar)
  const cardsMobileOffsetTopClass = 'mt-10'
  const cardsMobileOffsetBottomClass = 'mb-2' // bei Bedarf z. B. 'mb-4'

  useEffect(() => {
    if (!isSmall || view !== 'cards') return
    if (effectiveTeaserPlaybackMode === 'still') {
      setTeaserKey(null)
      return
    }

    const top = pageRows[0]
    if (!top) {
      setTeaserKey(null)
      return
    }

    const topKey = keyFor(top)
    setTeaserKey((prev) => (prev === topKey ? prev : topKey))
  }, [isSmall, view, pageRows, keyFor, effectiveTeaserPlaybackMode])

  useEffect(() => {
    if (!isSmall) {
      // dein Cleanup (z.B. swipeRefs reset) wie gehabt
      swipeRefs.current = new Map()
    }
  }, [isSmall])

  useEffect(() => {
    // Nicht während eines Seitenwechsels / Refills zurückspringen.
    if (isLoading) return

    if (emptyFolder && page !== 1) onPageChange(1)
  }, [emptyFolder, isLoading, page, onPageChange])

  const selectedCount = selectedFiles.length
  const allPageSelected =
    pageRows.length > 0 &&
    pageRows.every((j) => selectedKeys.has(keyFor(j)))

  useEffect(() => {
    if (selectedCount === 0 && selectionActionsOpen) {
      setSelectionActionsOpen(false)
    }
  }, [selectedCount, selectionActionsOpen])

  return (
    <>
      {/* Toolbar (sticky) */}
      <div className="sticky top-[56px] z-20">
        <div
          className="
            rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
            backdrop-blur supports-[backdrop-filter]:bg-white/60
            dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
          "
        >
          {/* Desktop */}
          <div className="hidden sm:block px-3 py-2.5">
            {/* Zeile 1: Titel */}
            <div className="flex items-center gap-2 pb-2">
              <div className="min-w-0 flex flex-1 items-center gap-2">
                <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
                  Abgeschlossene Downloads
                </div>
                <span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
                  {totalItemsForPagination}
                </span>
              </div>

              <div className="flex h-8 w-8 shrink-0 items-center justify-center">
                <LoadingSpinner
                  size="lg"
                  className={[
                    'text-indigo-500 transition-opacity duration-150',
                    isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none',
                  ].join(' ')}
                  srLabel="Lade Downloads…"
                />
              </div>
            </div>

            {/* Zeile 2: Controls */}
            <div className="flex items-center gap-3">
              <div className="min-w-0 flex flex-1 items-center gap-2">
                <input
                  value={searchQuery}
                  onChange={(e) => setSearchQuery(e.target.value)}
                  placeholder="Suchen…"
                  className="
                    h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
                    focus:outline-none focus:ring-2 focus:ring-indigo-500
                    dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
                  "
                />
                {(searchQuery || '').trim() !== '' ? (
                  <Button size="sm" variant="soft" onClick={clearSearch}>
                    Leeren
                  </Button>
                ) : null}
              </div>

              {selectedCount > 0 ? (
                <Button
                  size="md"
                  variant="soft"
                  disabled={bulkBusy}
                  onClick={() => setSelectionActionsOpen((v) => !v)}
                  title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahl-Aktionen anzeigen'}
                  className={[
                    'shrink-0 font-semibold',
                    selectionActionsOpen
                      ? 'ring-2 ring-indigo-500/60'
                      : '',
                  ].join(' ')}
                >
                  {selectedCount} ausgewählt
                </Button>
              ) : null}

              <div className="ml-auto flex shrink-0 items-center gap-2">
                <div className="flex h-9 items-center gap-2 rounded-lg border border-gray-200/70 bg-white/60 px-3 dark:border-white/10 dark:bg-white/5">
                  <LabeledSwitch
                    label="Behaltene Downloads anzeigen"
                    checked={includeKeep}
                    onChange={(checked) => {
                      if (page !== 1) onPageChange(1)
                      setIncludeKeep(checked)
                    }}
                  />
                </div>

                {view !== 'table' ? (
                  <div>
                    <label className="sr-only" htmlFor="finished-sort">
                      Sortierung
                    </label>
                    <select
                      id="finished-sort"
                      value={sortMode}
                      onChange={(e) => {
                        const m = e.target.value as DoneSortMode
                        onSortModeChange(m)
                        if (page !== 1) onPageChange(1)
                      }}
                      className="
                        h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
                        dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
                      "
                    >
                      <option value="completed_desc">Fertiggestellt am ↓</option>
                      <option value="completed_asc">Fertiggestellt am ↑</option>
                      <option value="file_asc">Modelname A→Z</option>
                      <option value="file_desc">Modelname Z→A</option>
                      <option value="duration_desc">Dauer ↓</option>
                      <option value="duration_asc">Dauer ↑</option>
                      <option value="size_desc">Größe ↓</option>
                      <option value="size_asc">Größe ↑</option>
                    </select>
                  </div>
                ) : null}

                <Button
                  size="md"
                  variant="soft"
                  disabled={!lastAction || undoing}
                  onClick={undoLastAction}
                  title={
                    !lastAction
                      ? 'Keine Aktion zum Rückgängig machen'
                      : `Letzte Aktion rückgängig machen (${lastAction.kind})`
                  }
                >
                  Undo
                </Button>

                <ButtonGroup
                  value={view}
                  onChange={(id) => setView(id as ViewMode)}
                  size="md"
                  ariaLabel="Ansicht"
                  items={[
                    {
                      id: 'table',
                      icon: <TableCellsIcon className="size-5" />,
                      label: 'Tabelle',
                      srLabel: 'Tabelle',
                    },
                    {
                      id: 'cards',
                      icon: <RectangleStackIcon className="size-5" />,
                      label: 'Cards',
                      srLabel: 'Cards',
                    },
                    {
                      id: 'gallery',
                      icon: <Squares2X2Icon className="size-5" />,
                      label: 'Galerie',
                      srLabel: 'Galerie',
                    },
                  ]}
                />
              </div>
            </div>
          </div>

          {/* Mobile */}
          <div className="sm:hidden">
            <div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
              <div className="min-w-0 flex flex-1 items-center gap-2">
                <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
                  Abgeschlossene Downloads
                </div>
                <span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
                  {totalItemsForPagination}
                </span>
              </div>

              <div className="flex h-8 w-8 shrink-0 items-center justify-center">
                <LoadingSpinner
                  size="lg"
                  className={[
                    'text-indigo-500 transition-opacity duration-150',
                    isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none',
                  ].join(' ')}
                  srLabel="Lade Downloads…"
                />
              </div>
            </div>

            <div className="px-3 pt-1.5 pb-2">
              <div className="flex items-center justify-between gap-2">
                <Button
                  size="md"
                  variant="soft"
                  className="flex-1"
                  disabled={!lastAction || undoing}
                  onClick={undoLastAction}
                  title={
                    !lastAction
                      ? 'Keine Aktion zum Rückgängig machen'
                      : `Letzte Aktion rückgängig machen (${lastAction.kind})`
                  }
                >
                  Undo
                </Button>

                <div className="flex-1">
                  <ButtonGroup
                    value={view}
                    onChange={(id) => setView(id as ViewMode)}
                    size="sm"
                    ariaLabel="Ansicht"
                    items={[
                      {
                        id: 'table',
                        icon: <TableCellsIcon className="size-4" />,
                        label: undefined,
                        srLabel: 'Tabelle',
                      },
                      {
                        id: 'cards',
                        icon: <RectangleStackIcon className="size-4" />,
                        label: undefined,
                        srLabel: 'Cards',
                      },
                      {
                        id: 'gallery',
                        icon: <Squares2X2Icon className="size-4" />,
                        label: undefined,
                        srLabel: 'Galerie',
                      },
                    ]}
                  />
                </div>

                {selectedCount > 0 ? (
                  <Button
                    size="md"
                    variant="soft"
                    disabled={bulkBusy}
                    onClick={() => setSelectionActionsOpen((v) => !v)}
                    title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahl-Aktionen anzeigen'}
                    className={[
                      'shrink-0 whitespace-nowrap px-3 font-semibold',
                      selectionActionsOpen
                        ? 'ring-2 ring-indigo-500/60'
                        : '',
                    ].join(' ')}
                  >
                    {selectedCount} ausgewählt
                  </Button>
                ) : null}

                <button
                  type="button"
                  className="relative inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white shadow-sm
                    hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-white/10"
                  onClick={() => setMobileOptionsOpen((v) => !v)}
                  aria-expanded={mobileOptionsOpen}
                  aria-controls="finished-mobile-options"
                  aria-label="Filter & Optionen"
                >
                  <AdjustmentsHorizontalIcon className="size-5" />
                  {globalFilterActive || includeKeep ? (
                    <span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-indigo-500 ring-2 ring-white dark:ring-gray-950" />
                  ) : null}
                </button>
              </div>
            </div>
          </div>

          {/* Auswahl-Aktionen */}
          {selectedCount > 0 ? (
            <div
              className={[
                'overflow-hidden border-t border-gray-200/60 dark:border-white/10 transition-all duration-200 ease-in-out',
                selectionActionsOpen
                  ? 'max-h-72 opacity-100'
                  : 'max-h-0 opacity-0',
              ].join(' ')}
              aria-hidden={!selectionActionsOpen}
            >
              <div
                className={[
                  'px-3 py-3 transition-transform duration-200 ease-in-out',
                  selectionActionsOpen ? 'translate-y-0' : '-translate-y-1 pointer-events-none',
                ].join(' ')}
              >
                {/* Mobile */}
                <div className="sm:hidden">
                  {bulkBusy ? (
                    <div className="mb-2 flex items-center gap-1.5 text-[11px] text-gray-600 dark:text-gray-300">
                      <LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
                      <span>Wird verarbeitet…</span>
                    </div>
                  ) : null}

                  <div className="grid grid-cols-2 gap-2">
                    <Button
                      size="sm"
                      variant="soft"
                      color={allPageSelected ? 'amber' : 'indigo'}
                      leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
                      disabled={bulkBusy}
                      onClick={() => {
                        if (allPageSelected) deselectPageRows(pageRows)
                        else selectPageRows(pageRows)
                      }}
                      className="w-full justify-center"
                    >
                      {allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
                    </Button>

                    <Button
                      size="sm"
                      variant="secondary"
                      leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
                      disabled={bulkBusy}
                      onClick={clearSelection}
                      className="w-full justify-center"
                    >
                      Auswahl aufheben
                    </Button>

                    <Button
                      size="sm"
                      variant="soft"
                      color="emerald"
                      leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
                      disabled={bulkBusy}
                      onClick={() => void bulkKeepSelected()}
                      className="w-full justify-center"
                    >
                      Behalten
                    </Button>

                    <Button
                      size="sm"
                      variant="soft"
                      color="red"
                      leadingIcon={<TrashIcon className="size-4 shrink-0" />}
                      disabled={bulkBusy}
                      onClick={() => void bulkDeleteSelected()}
                      className="w-full justify-center"
                    >
                      Löschen
                    </Button>
                  </div>
                </div>

                {/* Desktop */}
                <div className="hidden sm:flex sm:flex-wrap sm:items-center sm:gap-2">
                  {bulkBusy ? (
                    <div className="mr-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
                      <LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
                      <span>Wird verarbeitet…</span>
                    </div>
                  ) : null}

                  <Button
                    size="sm"
                    variant="soft"
                    color={allPageSelected ? 'amber' : 'indigo'}
                    leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
                    disabled={bulkBusy}
                    onClick={() => {
                      if (allPageSelected) deselectPageRows(pageRows)
                      else selectPageRows(pageRows)
                    }}
                  >
                    {allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
                  </Button>

                  <Button
                    size="sm"
                    variant="secondary"
                    leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
                    disabled={bulkBusy}
                    onClick={clearSelection}
                  >
                    Auswahl aufheben
                  </Button>

                  <div className="h-5 w-px bg-gray-200 dark:bg-white/10" />

                  <Button
                    size="sm"
                    variant="soft"
                    color="emerald"
                    leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
                    disabled={bulkBusy}
                    onClick={() => void bulkKeepSelected()}
                  >
                    Behalten
                  </Button>

                  <Button
                    size="sm"
                    variant="soft"
                    color="red"
                    leadingIcon={<TrashIcon className="size-4 shrink-0" />}
                    disabled={bulkBusy}
                    onClick={() => void bulkDeleteSelected()}
                  >
                    Löschen
                  </Button>
                </div>
              </div>
            </div>
          ) : null}

          {/* Desktop: aktive Tag-Filter anzeigen */}
          {tagFilter.length > 0 ? (
            <div className="hidden sm:flex items-center gap-2 border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
              <span className="text-xs font-medium text-gray-600 dark:text-gray-300">
                Tag-Filter
              </span>

              <div className="flex flex-wrap items-center gap-1.5">
                {tagFilter.map((t) => (
                  <TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
                ))}

                <Button
                  className="
                    ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
                    text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
                  "
                  size="sm"
                  variant="soft"
                  onClick={clearTagFilter}
                >
                  Zurücksetzen
                </Button>
              </div>
            </div>
          ) : null}

          {/* Mobile Optionen */}
          <div
            id="finished-mobile-options"
            className={[
              'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
              mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
            ].join(' ')}
          >
            <div className="border-t border-gray-200/60 dark:border-white/10 p-3">
              <div className="space-y-2">
                <div className="rounded-lg border border-gray-200/70 bg-white/70 p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
                  <div className="flex items-center gap-2">
                    <input
                      value={searchQuery}
                      onChange={(e) => setSearchQuery(e.target.value)}
                      placeholder="Suchen…"
                      className="
                        h-10 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900
                        focus:outline-none focus:ring-2 focus:ring-indigo-500
                        dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
                      "
                    />
                    {(searchQuery || '').trim() !== '' ? (
                      <Button size="sm" variant="soft" onClick={clearSearch}>
                        Clear
                      </Button>
                    ) : null}
                  </div>
                </div>

                <div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
                  <div className="flex items-center justify-between gap-3">
                    <div className="min-w-0">
                      <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
                        Keep anzeigen
                      </div>
                      <div className="text-xs text-gray-600 dark:text-gray-300">
                        Behaltene Downloads in der Liste
                      </div>
                    </div>

                    <Switch
                      checked={includeKeep}
                      onChange={(checked) => {
                        if (page !== 1) onPageChange(1)
                        setIncludeKeep(checked)
                      }}
                      ariaLabel="Behaltene Downloads anzeigen"
                      size="default"
                    />
                  </div>
                </div>

                {view !== 'table' ? (
                  <div className="rounded-lg border border-gray-200/70 bg-white/70 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
                    <div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">
                      Sortierung
                    </div>
                    <select
                      id="finished-sort-mobile"
                      value={sortMode}
                      onChange={(e) => {
                        const m = e.target.value as DoneSortMode
                        onSortModeChange(m)
                        if (page !== 1) onPageChange(1)
                      }}
                      className="
                        w-full h-10 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
                        dark:border-white/10 dark:bg-gray-950/60 dark:text-gray-100 dark:[color-scheme:dark]
                      "
                    >
                      <option value="completed_desc">Fertiggestellt am ↓</option>
                      <option value="completed_asc">Fertiggestellt am ↑</option>
                      <option value="file_asc">Modelname A→Z</option>
                      <option value="file_desc">Modelname Z→A</option>
                      <option value="duration_desc">Dauer ↓</option>
                      <option value="duration_asc">Dauer ↑</option>
                      <option value="size_desc">Größe ↓</option>
                      <option value="size_asc">Größe ↑</option>
                    </select>
                  </div>
                ) : null}
              </div>
            </div>

            {tagFilter.length > 0 ? (
              <div className="border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
                <div className="flex flex-wrap items-center gap-2">
                  <span className="text-xs font-medium text-gray-600 dark:text-gray-300">
                    Tag-Filter
                  </span>

                  <div className="flex flex-wrap items-center gap-1.5">
                    {tagFilter.map((t) => (
                      <TagBadge key={t} tag={t} active={true} onClick={toggleTagFilter} />
                    ))}

                    <Button
                      className="
                        ml-1 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium
                        text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10
                      "
                      size="sm"
                      variant="soft"
                      onClick={clearTagFilter}
                    >
                      Zurücksetzen
                    </Button>
                  </div>
                </div>
              </div>
            ) : null}
          </div>
        </div>
      </div>

      {showLoadingCard ? (
        <Card grayBody>
          <div className="flex items-center justify-between gap-3">
            <div className="min-w-0">
              <div className="text-sm font-medium text-gray-900 dark:text-white">
                Lade Downloads…
              </div>
              <div className="text-xs text-gray-600 dark:text-gray-300">
                Bitte warte einen Moment.
              </div>
            </div>

            <LoadingSpinner
              size="lg"
              className="text-indigo-500"
              srLabel="Lade Downloads…"
            />
          </div>
        </Card>
      ) : (emptyFolder || emptyAll) ? (
        <Card grayBody>
          <div className="flex items-center gap-3">
            <div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
              <span className="text-lg">📁</span>
            </div>
            <div className="min-w-0">
              <div className="text-sm font-medium text-gray-900 dark:text-white">
                Keine abgeschlossenen Downloads
              </div>
              <div className="text-xs text-gray-600 dark:text-gray-300">
                Im Zielordner ist aktuell nichts vorhanden.
              </div>
            </div>
          </div>
        </Card>
      ) : emptyByFilter ? (
        <Card grayBody>
          <div className="text-sm text-gray-600 dark:text-gray-300">
            Keine Treffer für die aktuellen Filter.
          </div>

          {tagFilter.length > 0 || (searchQuery || '').trim() !== '' ? (
            <div className="mt-2 flex flex-wrap gap-3">
              {tagFilter.length > 0 ? (
                <button
                  type="button"
                  className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
                  onClick={clearTagFilter}
                >
                  Tag-Filter zurücksetzen
                </button>
              ) : null}

              {(searchQuery || '').trim() !== '' ? (
                <button
                  type="button"
                  className="text-sm font-medium text-gray-700 hover:underline dark:text-gray-200"
                  onClick={clearSearch}
                >
                  Suche zurücksetzen
                </button>
              ) : null}
            </div>
          ) : null}
        </Card>
      ) : (
        <div className="pb-24 sm:pb-20">
          {view === 'cards' && (
            <div className={isSmall ? `${cardsMobileOffsetTopClass} ${cardsMobileOffsetBottomClass}` : ''}>
              <FinishedDownloadsCardsView
                rows={pageRows}
                selectedKeys={selectedKeys}
                onToggleSelected={toggleSelected}
                onToggleSelectAllPage={(checked) => {
                  if (checked) selectPageRows(pageRows)
                  else deselectPageRows(pageRows)
                }}
                isSmall={isSmall}
                jobForDetails={jobForDetails}
                isLoading={isLoading}
                blurPreviews={blurPreviews}
                durations={durations}
                teaserState={teaserState}
                inlinePlay={inlinePlay}
                deletingKeys={deletingKeys}
                keepingKeys={keepingKeys}
                removingKeys={removingKeys}
                swipeRefs={swipeRefs}
                keyFor={keyFor}
                baseName={baseName}
                modelNameFromOutput={modelNameFromOutput}
                runtimeOf={runtimeOf}
                sizeBytesOf={sizeBytesOf}
                formatBytes={formatBytes}
                lower={lower}
                onOpenPlayer={onOpenPlayer}
                openPlayer={openPlayer}
                onOpenPlayerAt={openPlayerAt}
                handleScrubberClickIndex={handleScrubberClickIndex}
                startInlineAt={startInlineAt}
                startInline={startInline}
                tryAutoplayInline={tryAutoplayInline}
                registerTeaserHost={registerTeaserHost}
                handleDuration={handleDuration}
                deleteVideo={deleteVideo}
                keepVideo={keepVideo}
                releasePlayingFile={releasePlayingFile}
                modelsByKey={modelsByKey}
                onToggleHot={toggleHotVideo}
                onToggleFavorite={onToggleFavorite}
                onToggleLike={onToggleLike}
                onToggleWatch={onToggleWatch}
                onSplit={onSplitJob}
                onAddToDownloads={onAddToDownloads}
                activeTagSet={activeTagSet}
                onToggleTagFilter={toggleTagFilter}
                onHoverPreviewKeyChange={setHoverTeaserKey}
                assetNonce={assetNonce ?? 0}
                postworkByFile={effectivePostworkSummaryByFile}
                getPostworkSummaryForOutput={getPostworkSummaryForOutput}
                forcePreviewMuted={forcePreviewMuted}
                mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
                onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
                renderPostworkBadge={(badge) =>
                  renderFinishedPostworkBadge(badge, {
                    clickable: true,
                    disablePopover: isSmall,
                    onClick: () => {
                      if (!badge?.file) return
                      if (badge.state === 'running' || badge.state === 'queued') return
                      void regenerateAssetsForFile(badge.file)
                    },
                  })
                }
              />
            </div>
          )}

          {view === 'table' && (
            <FinishedDownloadsTableView
              rows={pageRows}
              isLoading={isLoading}
              selectedKeys={selectedKeys}
              onToggleSelected={toggleSelected}
              onToggleSelectAllPage={(checked) => {
                if (checked) selectPageRows(pageRows)
                else deselectPageRows(pageRows)
              }}
              jobForDetails={jobForDetails}
              keyFor={keyFor}
              baseName={baseName}
              lower={lower}
              modelNameFromOutput={modelNameFromOutput}
              runtimeOf={runtimeOf}
              sizeBytesOf={sizeBytesOf}
              formatBytes={formatBytes}
              resolutions={resolutions}
              durations={durations}
              canHover={canHover}
              teaserState={teaserState}
              setHoverTeaserKey={setHoverTeaserKey}
              registerTeaserHost={registerTeaserHost}
              handleDuration={handleDuration}
              handleResolution={handleResolution}
              blurPreviews={blurPreviews}
              assetNonce={assetNonce}
              deletingKeys={deletingKeys}
              keepingKeys={keepingKeys}
              removingKeys={removingKeys}
              modelsByKey={modelsByKey}
              activeTagSet={activeTagSet}
              onToggleTagFilter={toggleTagFilter}
              onOpenPlayer={onOpenPlayer}
              handleScrubberClickIndex={handleScrubberClickIndex}
              onSortModeChange={onSortModeChange}
              page={page}
              onPageChange={onPageChange}
              onToggleHot={toggleHotVideo}
              onToggleFavorite={onToggleFavorite}
              onToggleLike={onToggleLike}
              onToggleWatch={onToggleWatch}
              onSplit={onSplitJob}
              onAddToDownloads={onAddToDownloads}
              deleteVideo={deleteVideo}
              keepVideo={keepVideo}
              postworkByFile={effectivePostworkSummaryByFile}
              getPostworkSummaryForOutput={getPostworkSummaryForOutput}
              forcePreviewMuted={forcePreviewMuted}
              renderPostworkBadge={(badge) =>
                renderFinishedPostworkBadge(badge, {
                  clickable: true,
                  disablePopover: isSmall,
                  onClick: () => {
                    if (!badge?.file) return
                    if (badge.state === 'running' || badge.state === 'queued') return
                    void regenerateAssetsForFile(badge.file)
                  },
                })
              }
            />
          )}

          {view === 'gallery' && (
            <FinishedDownloadsGalleryView
              rows={pageRows}
              selectedKeys={selectedKeys}
              onToggleSelected={toggleSelected}
              onToggleSelectAllPage={(checked) => {
                if (checked) selectPageRows(pageRows)
                else deselectPageRows(pageRows)
              }}
              isLoading={isLoading}
              jobForDetails={jobForDetails}
              blurPreviews={blurPreviews}
              durations={durations}
              handleDuration={handleDuration}
              teaserState={teaserState}
              keyFor={keyFor}
              baseName={baseName}
              modelNameFromOutput={modelNameFromOutput}
              runtimeOf={runtimeOf}
              sizeBytesOf={sizeBytesOf}
              formatBytes={formatBytes}
              deletingKeys={deletingKeys}
              keepingKeys={keepingKeys}
              removingKeys={removingKeys}
              deletedKeys={deletedKeys}
              registerTeaserHost={registerTeaserHost}
              onOpenPlayer={onOpenPlayer}
              handleScrubberClickIndex={handleScrubberClickIndex}
              deleteVideo={deleteVideo}
              keepVideo={keepVideo}
              onToggleHot={toggleHotVideo}
              lower={lower}
              modelsByKey={modelsByKey}
              onToggleFavorite={onToggleFavorite}
              onToggleLike={onToggleLike}
              onToggleWatch={onToggleWatch}
              onSplit={onSplitJob}
              onAddToDownloads={onAddToDownloads}
              activeTagSet={activeTagSet}
              onToggleTagFilter={toggleTagFilter}
              onHoverPreviewKeyChange={setHoverTeaserKey}
              postworkByFile={effectivePostworkSummaryByFile}
              getPostworkSummaryForOutput={getPostworkSummaryForOutput}
              forcePreviewMuted={forcePreviewMuted}
              renderPostworkBadge={(badge) =>
                renderFinishedPostworkBadge(badge, {
                  clickable: true,
                  disablePopover: isSmall,
                  onClick: () => {
                    if (!badge?.file) return
                    if (badge.state === 'running' || badge.state === 'queued') return
                    void regenerateAssetsForFile(badge.file)
                  },
                })
              }
            />
          )}
        </div>
      )}

      <div className="fixed inset-x-0 bottom-4 z-[70] flex justify-center px-4 pointer-events-none">
        <div className="pointer-events-auto flex w-full max-w-fit flex-col items-center gap-3">
          <div className="shadow-2xl">
            <Pagination
              page={page}
              pageSize={pageSize}
              totalItems={totalItemsForPagination}
              onPageChange={(p) => {
                flushSync(() => {
                  setInlinePlay(null)
                  setTeaserKey(null)
                  setHoverTeaserKey(null)
                })
                window.scrollTo({ top: 0, behavior: 'auto' })
                onPageChange(p)
              }}
              showSummary={false}
              prevLabel="Zurück"
              nextLabel="Weiter"
              className="mt-0"
            />
          </div>
        </div>
      </div>
    </>
  )

}
