// 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 { RecordJob } from '../../types'
import ButtonGroup from './ButtonGroup'
import {
  TableCellsIcon,
  RectangleStackIcon,
  Squares2X2Icon,
  AdjustmentsHorizontalIcon,
} 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'

type SortMode =
  | 'completed_desc'
  | 'completed_asc'
  | 'file_asc'
  | 'file_desc'
  | 'duration_desc'
  | 'duration_asc'
  | 'size_desc'
  | 'size_asc'

type TeaserPlaybackMode = 'still' | 'hover' | 'all'

type Props = {
  jobs: RecordJob[]
  doneJobs: RecordJob[]
  modelsByKey: Record<string, StoredModelFlags>
  blurPreviews?: boolean
  teaserPlayback?: TeaserPlaybackMode
  teaserAudio?: boolean
  onOpenPlayer: (job: RecordJob) => 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>
  doneTotal: number
  page: number
  pageSize: number
  onPageChange: (page: number) => void
  assetNonce?: number
  sortMode: SortMode
  onSortModeChange: (m: SortMode) => 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
}

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
}

type StoredModelFlags = {
  id: string
  modelKey: string
  favorite?: boolean
  liked?: boolean | null
  watching?: boolean | null
  tags?: string
}

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
}

export default function FinishedDownloads({ 
  jobs,
  doneJobs,
  blurPreviews,
  teaserPlayback,
  teaserAudio,
  onOpenPlayer,
  onDeleteJob,
  onToggleHot,
  onToggleFavorite,
  onToggleLike,
  onToggleWatch,
  doneTotal,
  page,
  pageSize,
  onPageChange,
  assetNonce,
  sortMode,
  onSortModeChange,
  modelsByKey,
  loadMode = 'paged',
 }: Props) {
  const allMode = loadMode === 'all'

  const teaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover'

  // Desktop vs Mobile: Desktop (hoverfähiger Pointer) -> Teaser nur bei Hover, Mobile -> im Viewport
  const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')

  const notify = useNotify()

  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 [isLoading, setIsLoading] = React.useState(false)

  const refillInFlightRef = React.useRef(false)

  type UndoAction =
  | { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
  | { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
  | { kind: 'hot'; currentFile: string }

  type PersistedUndoState = {
    v: 1
    action: UndoAction
    ts: number
  }

  const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1'

  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 [undoing, setUndoing] = React.useState(false)

  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])

  // 🔥 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 queueRefill = useCallback(() => {
    if (refillTimerRef.current) window.clearTimeout(refillTimerRef.current)
    // kurz debouncen, damit bei mehreren Aktionen nicht zig Fetches laufen
    refillTimerRef.current = window.setTimeout(() => setRefillTick((n) => n + 1), 80)
  }, [])

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

  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)
  }, [])

  type ViewMode = 'table' | 'cards' | 'gallery'
  const VIEW_KEY = 'finishedDownloads_view'
  const KEEP_KEY = 'finishedDownloads_includeKeep_v2'
  const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'

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

  const [mobileOptionsOpen, setMobileOptionsOpen] = 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 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])

  // 🔎 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]
    })
  }, [])

  const globalFilterActive = searchTokens.length > 0 || activeTagSet.size > 0
  const effectiveAllMode = globalFilterActive || allMode

  const fetchAllDoneJobs = useCallback(
    async (signal?: AbortSignal) => {
      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 {
        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

  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

    const finishRefill = () => {
      refillInFlightRef.current = false
    }

    // ✅ Refill läuft
    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 + count holen
        const [listRes, metaRes] = await Promise.all([
          fetch(
            `/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
              includeKeep ? '&includeKeep=1' : ''
            }`,
            { cache: 'no-store' as any, signal: ac.signal }
          ),
          fetch(
            `/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
            { cache: 'no-store' as any, signal: ac.signal }
          ),
        ])

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

        let okAll = listRes.ok && metaRes.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)
        }

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

          const countRaw = Number(meta?.count ?? 0)
          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(() => {
    if (effectiveAllMode) return
    setOverrideDoneJobs(null)
    setOverrideDoneTotal(null)
  }, [page, pageSize, sortMode, includeKeep, effectiveAllMode])

    useEffect(() => {
    if (!includeKeep) {
      // zurück auf "nur /done/" (Props)
      if (!globalFilterActive) {
        setOverrideDoneJobs(null)
        setOverrideDoneTotal(null)
      }
      return
    }

    // includeKeep = true:
    // - wenn Filter aktiv -> fetchAllDoneJobs macht das bereits (mit includeKeep)
    if (globalFilterActive) return

    const ac = new AbortController()

    ;(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) 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)
      } catch {}
    })()

    return () => ac.abort()
  }, [includeKeep, globalFilterActive, 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 openPlayer = useCallback((job: RecordJob) => {
    setInlinePlay(null)
    onOpenPlayer(job)
  }, [onOpenPlayer])

  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) => {
      // Timer stoppen (falls die "commit delete"-Phase noch aussteht)
      cancelRemoveTimer(key)

      // wieder sichtbar machen
      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
      })
    },
    [cancelRemoveTimer]
  )

  const animateRemove = useCallback(
    (key: string) => {
      markRemoving(key, true)
      cancelRemoveTimer(key)

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

        markDeleted(key)
        markRemoving(key, false)
      }, 320)

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

  const releasePlayingFile = useCallback(
    async (file: string, opts?: { close?: boolean }) => {
      window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
      if (opts?.close) {
        window.dispatchEvent(new CustomEvent('player:close', { detail: { file } }))
      }
      await new Promise((r) => window.setTimeout(r, 250))
    },
    []
  )

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

      if (!file) {
        notify.error('Löschen nicht möglich', 'Kein Dateiname gefunden – kann nicht löschen.')
        return false
      }
      if (deletingKeys.has(key)) return false

      markDeleting(key, true)
      try {
        await releasePlayingFile(file, { close: true })

        // ✅ Wenn App-Handler vorhanden: den benutzen
        // (WICHTIG für Undo: onDeleteJob sollte idealerweise {undoToken} zurückgeben)
        if (onDeleteJob) {
          const r = await onDeleteJob(job)
          const undoToken = (r as any)?.undoToken

          if (typeof undoToken === 'string' && undoToken) {
            setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
          } else {
            setLastAction(null)
            // optional: nicht als "error" melden, eher info/warn
            // notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.')
          }

          // ✅ OPTIMISTIK + Pagination refill + count hint
          animateRemove(key)
          queueRefill()
          emitCountHint(-1)
          // animateRemove queued already queueRefill(), aber extra ist ok:
          // queueRefill()

          return true
        }

        // Fallback: Backend direkt
        const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
        if (!res.ok) {
          const text = await res.text().catch(() => '')
          throw new Error(text || `HTTP ${res.status}`)
        }

        // ✅ Backend liefert undoToken (Trash)
        const data = (await res.json().catch(() => null)) as any
        const from = (data?.from === 'keep' ? 'keep' : 'done') as 'done' | 'keep'
        const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''

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

        animateRemove(key)
        queueRefill()

        // ✅ Tab-Count sofort korrigieren (App hört drauf)
        emitCountHint(-1)

        return true
      } catch (e: any) {
        // ✅ falls irgendwo (z.B. via External-Event) schon optimistisch entfernt wurde: zurückrollen
        restoreRow(key)

        notify.error('Löschen fehlgeschlagen: ', file)
        return false
      } finally {
        markDeleting(key, false)
      }
    },
    [
      deletingKeys,
      markDeleting,
      releasePlayingFile,
      onDeleteJob,
      animateRemove,
      notify,
      restoreRow,
      queueRefill,
      emitCountHint,
    ]
  )

  const keepVideo = useCallback(
    async (job: RecordJob) => {
      const file = baseName(job.output || '')
      const key = keyFor(job)

      if (!file) {
        notify.error('Keep nicht möglich', 'Kein Dateiname gefunden – kann nicht behalten.')
        return false
      }
      if (keepingKeys.has(key) || deletingKeys.has(key)) return false

      markKeeping(key, true)
      try {
        await releasePlayingFile(file, { close: true })

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

        // ✅ Backend liefert ggf. newFile (uniqueDestPath)
        const data = (await res.json().catch(() => null)) as any
        const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file

        // ✅ Undo-Info merken
        setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key })

        // ✅ aus UI entfernen (wie delete), aber "keep" ist kein delete -> trotzdem raus aus finished
        animateRemove(key)
        queueRefill()

        // ✅ Tab-Count sofort korrigieren (App hört drauf)
        emitCountHint(includeKeep ? 0 : -1)

        return true
      } catch (e: any) {
        notify.error('Keep fehlgeschlagen', file)
        return false
      } finally {
        markKeeping(key, false)
      }
    },
    [
      keepingKeys,
      deletingKeys,
      markKeeping,
      releasePlayingFile,
      animateRemove,
      notify,
      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
    })
  }, [])

  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 unhideByToken = (token: string) => {
      setDeletedKeys((prev) => {
        const next = new Set(prev)
        next.delete(token)
        return next
      })
      setDeletingKeys((prev) => {
        const next = new Set(prev)
        next.delete(token)
        return next
      })
      setKeepingKeys((prev) => {
        const next = new Set(prev)
        next.delete(token)
        return next
      })
      setRemovingKeys((prev) => {
        const next = new Set(prev)
        next.delete(token)
        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) unhideByToken(lastAction.rowKey)
        // Fallbacks (falls alte lastAction ohne rowKey existiert)
        unhideByToken(lastAction.originalFile)
        unhideByToken(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) unhideByToken(lastAction.rowKey)
        // Fallbacks (für ältere Actions / Sonderfälle)
        unhideByToken(lastAction.originalFile)
        unhideByToken(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 toggleHotVideo = useCallback(
    async (job: RecordJob) => {
      const currentFile = baseName(job.output || '')
      if (!currentFile) {
        notify.error('HOT nicht möglich', 'Kein Dateiname gefunden – kann nicht HOT togglen.')
        return
      }

      // genau "HOT " Prefix
      const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)

      // Server-Truth anwenden (inkl. duration-key move via applyRename)
      const applyServerTruth = (apiOld: string, apiNew: string) => {
        if (!apiOld || !apiNew || apiOld === apiNew) return
        applyRename(apiOld, apiNew)
      }

      const oldFile = currentFile
      const optimisticNew = toggledName(oldFile)

      // Optimistik sofort anwenden (UI snappy)
      applyRename(oldFile, optimisticNew)

      try {
        await releasePlayingFile(oldFile, { close: true })

        // ✅ 1) Wenn du einen externen Handler hast:
        //    -> ideal: er gibt {oldFile,newFile} zurück (optional)
        if (onToggleHot) {
          const r = (await onToggleHot(job)) as any

          // Wenn Handler Server-Truth liefert, übernehmen, sonst Optimistik behalten
          const apiOld = typeof r?.oldFile === 'string' ? r.oldFile : ''
          const apiNew = typeof r?.newFile === 'string' ? r.newFile : ''
          if (apiOld && apiNew) applyServerTruth(apiOld, apiNew)

          // ✅ Undo erst jetzt setzen (nach Erfolg)
          setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })

          if (sortMode === 'file_asc' || sortMode === 'file_desc') {
            queueRefill()
          }
          return
        }

        // ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!)
        const res = await fetch(
          `/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
          { 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 apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
        const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew

        // Server-Truth über Optimistik drüberziehen (falls der Server anders entschieden hat)
        if (apiOld !== apiNew) applyServerTruth(apiOld, apiNew)

        // ✅ Undo nach Erfolg
        setLastAction({ kind: 'hot', currentFile: apiNew })

        queueRefill()
      } catch (e: any) {
        // ❌ Rollback, weil Optimistik schon angewendet wurde
        clearRenamePair(oldFile, optimisticNew)

        // und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
        setLastAction(null)

        notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
      }
      },
    [notify, applyRename, clearRenamePair, releasePlayingFile, onToggleHot, queueRefill, sortMode]
  )

  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>()

    // Basis: Files aus dem Done-Ordner
    for (const j of doneJobsPage) {
      const jj = applyRenamedOutput(j)
      map.set(keyFor(jj), jj)
    }

    // Jobs aus /list drübermergen
    for (const j of jobs) {
      const jj = applyRenamedOutput(j)
      const k = keyFor(jj)
      if (map.has(k)) map.set(k, { ...map.get(k)!, ...jj })
    }

    const list = Array.from(map.values()).filter((j) => {
    if (deletedKeys.has(keyFor(j))) return false

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

    return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
  })

    return list
  }, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput])

  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 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)

        return
      }

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

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

      if (detail.phase === 'success') {
        // delete final bestätigt
        markDeleting(key, false)
        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 = () => {
      if (refillInFlightRef.current) return
      queueRefill()
    }

    window.addEventListener('finished-downloads:reload', onReload as any)
    return () => window.removeEventListener('finished-downloads:reload', onReload as any)
  }, [queueRefill /* oder fetchAllDoneJobs */])

  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 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 =
      teaserPlaybackMode === 'hover' &&
      !canHover &&
      (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, teaserPlaybackMode, 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 sec = durations[k] ?? (job as any)?.durationSeconds
    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 {
        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])

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

  useEffect(() => {
    if (!isSmall) return
    if (view !== 'cards') return

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

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

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

  useEffect(() => {
    if (emptyFolder && page !== 1) onPageChange(1)
  }, [emptyFolder, page, onPageChange])

  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
          "
        >
          {/* Header row */}
          <div className="flex items-center gap-3 p-3">
            {/* Left: Title + Count */}
            <div className="hidden sm:flex items-center gap-2 min-w-0">
              <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>

            {/* Mobile title (bleibt wie gehabt, aber kompakter) */}
            <div className="sm:hidden flex items-center gap-2 min-w-0 flex-1">
              <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>

            {/* Right: Controls */}
            <div className="flex items-center gap-2 ml-auto shrink-0">
              {isLoading ? (
                <LoadingSpinner size="lg" className="text-indigo-500" srLabel="Lade Downloads…" />
              ) : null}
              {/* Desktop: Suche soll den Platz füllen */}
              <div className="hidden sm:flex items-center gap-2 min-w-0 flex-1">
                <input
                  value={searchQuery}
                  onChange={(e) => setSearchQuery(e.target.value)}
                  placeholder="Suchen…"
                  className="
                    h-9 w-full max-w-[420px] 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>

              {/* Desktop: Keep Toggle */}
              <div className="hidden sm:block">
                <LabeledSwitch
                  label="Behaltene Downloads anzeigen"
                  checked={includeKeep}
                  onChange={(checked) => {
                    if (page !== 1) onPageChange(1)
                    setIncludeKeep(checked)
                    queueRefill()
                  }}
                />
              </div>

              {/* Desktop: Sort (nur wenn nicht Tabelle) */}
              {view !== 'table' && (
                <div className="hidden sm:block">
                  <label className="sr-only" htmlFor="finished-sort">
                    Sortierung
                  </label>
                  <select
                    id="finished-sort"
                    value={sortMode}
                    onChange={(e) => {
                      const m = e.target.value as SortMode
                      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>
              )}

              {/* Views */}

              <Button
                size={isSmall ? 'sm' : 'md'}
                variant="soft"
                className={isSmall ? 'h-9' : 'h-10'}
                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={isSmall ? 'sm' : 'md'}
                ariaLabel="Ansicht"
                items={[
                  { id: 'table', icon: <TableCellsIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Tabelle', srLabel: 'Tabelle' },
                  { id: 'cards', icon: <RectangleStackIcon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Cards', srLabel: 'Cards' },
                  { id: 'gallery', icon: <Squares2X2Icon className={isSmall ? 'size-4' : 'size-5'} />, label: isSmall ? undefined : 'Galerie', srLabel: 'Galerie' },
                ]}
              />

              {/* Mobile: Optionen Button (unverändert) */}
              <button
                type="button"
                className="sm:hidden relative inline-flex items-center justify-center rounded-md border border-gray-200 bg-white p-2 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" />
                {/* kleiner Punkt wenn Filter aktiv */}
                {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>

          {/* 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 (einklappbar): Suche + Keep + Sort */}
          <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(' ')}
          >
            {/* “Sheet”-Body */}
            <div className="border-t border-gray-200/60 dark:border-white/10 p-3">
              <div className="space-y-2">
                {/* Suche */}
                <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>

                {/* Keep – als Setting Row */}
                <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)
                        queueRefill()
                      }}
                      ariaLabel="Behaltene Downloads anzeigen"
                      size="default"
                    />
                  </div>
                </div>

                {/* Sort */}
                {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 SortMode
                        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>

            {/* Tag-Filter */}
            {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>
      ) : (
        <>
          {view === 'cards' && (
            <div className={isSmall ? 'mt-8' : ''}>
              <FinishedDownloadsCardsView
                rows={pageRows}
                isSmall={isSmall}
                isLoading={isLoading}
                blurPreviews={blurPreviews}
                durations={durations}
                teaserKey={teaserKey}
                teaserPlayback={teaserPlaybackMode}
                teaserAudio={teaserAudio}
                hoverTeaserKey={hoverTeaserKey}
                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}
                startInline={startInline}
                tryAutoplayInline={tryAutoplayInline}
                registerTeaserHost={registerTeaserHost}
                handleDuration={handleDuration}
                deleteVideo={deleteVideo}
                keepVideo={keepVideo}
                releasePlayingFile={releasePlayingFile}
                modelsByKey={modelsByKey}
                onToggleHot={toggleHotVideo}
                onToggleFavorite={onToggleFavorite}
                onToggleLike={onToggleLike}
                onToggleWatch={onToggleWatch}
                activeTagSet={activeTagSet}
                onToggleTagFilter={toggleTagFilter}
                onHoverPreviewKeyChange={setHoverTeaserKey}
                assetNonce={assetNonce ?? 0}
              />
            </div>
          )}

          {view === 'table' && (
            <FinishedDownloadsTableView
              rows={pageRows}
              isLoading={isLoading}
              keyFor={keyFor}
              baseName={baseName}
              lower={lower}
              modelNameFromOutput={modelNameFromOutput}
              runtimeOf={runtimeOf}
              sizeBytesOf={sizeBytesOf}
              formatBytes={formatBytes}
              resolutions={resolutions}
              durations={durations}
              canHover={canHover}
              teaserAudio={teaserAudio}
              hoverTeaserKey={hoverTeaserKey}
              setHoverTeaserKey={setHoverTeaserKey}
              teaserPlayback={teaserPlaybackMode}
              teaserKey={teaserKey}
              registerTeaserHost={registerTeaserHost}
              handleDuration={handleDuration}
              handleResolution={handleResolution}
              blurPreviews={blurPreviews}
              assetNonce={assetNonce}
              deletingKeys={deletingKeys}
              keepingKeys={keepingKeys}
              removingKeys={removingKeys}
              modelsByKey={modelsByKey}
              activeTagSet={activeTagSet}
              onToggleTagFilter={toggleTagFilter}
              onOpenPlayer={onOpenPlayer}
              onSortModeChange={onSortModeChange}
              page={page}
              onPageChange={onPageChange}
              onToggleHot={toggleHotVideo}
              onToggleFavorite={onToggleFavorite}
              onToggleLike={onToggleLike}
              onToggleWatch={onToggleWatch}
              deleteVideo={deleteVideo}
              keepVideo={keepVideo}
            />
          )}

          {view === 'gallery' && (
            <FinishedDownloadsGalleryView
              rows={pageRows}
              isLoading={isLoading}
              blurPreviews={blurPreviews}
              durations={durations}
              handleDuration={handleDuration}
              teaserKey={teaserKey}
              teaserPlayback={teaserPlaybackMode}
              teaserAudio={teaserAudio}
              hoverTeaserKey={hoverTeaserKey}
              keyFor={keyFor}
              baseName={baseName}
              modelNameFromOutput={modelNameFromOutput}
              runtimeOf={runtimeOf}
              sizeBytesOf={sizeBytesOf}
              formatBytes={formatBytes}
              deletingKeys={deletingKeys}
              keepingKeys={keepingKeys}
              removingKeys={removingKeys}
              deletedKeys={deletedKeys}
              registerTeaserHost={registerTeaserHost}
              onOpenPlayer={onOpenPlayer}
              deleteVideo={deleteVideo}
              keepVideo={keepVideo}
              onToggleHot={toggleHotVideo}
              lower={lower}
              modelsByKey={modelsByKey}
              onToggleFavorite={onToggleFavorite}
              onToggleLike={onToggleLike}
              onToggleWatch={onToggleWatch}
              activeTagSet={activeTagSet}
              onToggleTagFilter={toggleTagFilter}
              onHoverPreviewKeyChange={setHoverTeaserKey}
            />
          )}

          <Pagination
            page={page}
            pageSize={pageSize}
            totalItems={totalItemsForPagination}
            onPageChange={(p) => {
              flushSync(() => {
                setInlinePlay(null)
                setTeaserKey(null)
                setHoverTeaserKey(null)
              })

              for (const j of pageRows) {
                const f = baseName(j.output || '')
                if (!f) continue
                window.dispatchEvent(new CustomEvent('player:release', { detail: { file: f } }))
                window.dispatchEvent(new CustomEvent('player:close', { detail: { file: f } }))
              }

              window.scrollTo({ top: 0, behavior: 'auto' })
              onPageChange(p)
            }}
            showSummary={false}
            prevLabel="Zurück"
            nextLabel="Weiter"
            className="mt-4"
          />
        </>
      )}
    </>
  )

}
