// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
'use client'

import * as React from 'react'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import {
  StarIcon as StarSolidIcon,
  HeartIcon as HeartSolidIcon,
  EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import RecordJobActions from './RecordJobActions'
import TagOverflowRow from './TagOverflowRow'
import { isHotName, stripHotPrefix } from './hotName'
import { formatResolution } from './formatters'
import PreviewScrubber from './PreviewScrubber'
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
import {
  shouldAnimateTeaser,
  shouldEnableTeaserAudio,
  shouldObserveTeasers,
} from './teaserPlayback'

type ModelFlags = {
  favorite?: boolean
  liked?: boolean | null
  watching?: boolean | null
  tags?: string

  // ✅ Optional für Model-Hover-Preview
  image?: string
  imageUrl?: string | null

  // ✅ Optional für stashapp-artigen Preview-Scrubber
  previewScrubberPath?: string
  previewScrubberCount?: number
}

type FinishedPostworkBadge = {
  file: string
  assetId?: string
  queue: 'postwork' | 'enrich'
  state: 'queued' | 'running' | 'done' | 'error' | 'missing'
  phase?: string
  label?: string
  position?: number
  waiting?: number
  running?: number
  maxParallel?: number
  ts: number
}

type Props = {
  rows: RecordJob[]
  isLoading?: boolean
  blurPreviews?: boolean
  durations: Record<string, number>
  postworkByFile: Record<string, FinishedPostworkBadge>
  teaserState: FinishedDownloadsTeaserState

  handleDuration: (job: RecordJob, seconds: number) => void

  handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
  jobForDetails: (job: RecordJob) => RecordJob

  keyFor: (j: RecordJob) => string
  baseName: (p: string) => string
  modelNameFromOutput: (output?: string) => string
  runtimeOf: (job: RecordJob) => string
  sizeBytesOf: (job: RecordJob) => number | null
  formatBytes: (bytes?: number | null) => string

  deletingKeys: Set<string>
  keepingKeys: Set<string>
  removingKeys: Set<string>
  deletedKeys: Set<string>

  registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void

  onOpenPlayer: (job: RecordJob) => void
  deleteVideo: (job: RecordJob) => Promise<boolean>
  keepVideo: (job: RecordJob) => Promise<boolean>

  lower: (s: string) => string

  modelsByKey: Record<string, ModelFlags>
  activeTagSet: Set<string>
  onHoverPreviewKeyChange?: (key: string | null) => void
  onToggleTagFilter: (tag: string) => void
  onToggleFavorite?: (job: RecordJob) => void | Promise<void>
  onToggleLike?: (job: RecordJob) => void | Promise<void>
  onToggleWatch?: (job: RecordJob) => void | Promise<void>
  onToggleHot: (job: RecordJob) => void | Promise<void>
  onSplit?: (job: RecordJob) => void | Promise<void>
  onAddToDownloads?: (job: RecordJob) => void | Promise<boolean>

  forcePreviewMuted?: boolean
  previewActivationNonce: number
}

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

function parseJobMeta(metaRaw: unknown): any | null {
  if (!metaRaw) return null
  if (typeof metaRaw === 'string') {
    try {
      return JSON.parse(metaRaw)
    } catch {
      return null
    }
  }
  if (typeof metaRaw === 'object') return metaRaw
  return null
}

function normalizeDurationSeconds(value: unknown): number | undefined {
  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
  // ms -> s Heuristik wie in FinishedVideoPreview
  return value > 24 * 60 * 60 ? value / 1000 : value
}

function clamp(n: number, min: number, max: number) {
  return Math.max(min, Math.min(max, n))
}

const DEFAULT_SPRITE_STEP_SECONDS = 5

function chooseSpriteGrid(count: number): [number, number] {
  if (count <= 1) return [1, 1]

  const targetRatio = 16 / 9
  let bestCols = 1
  let bestRows = count
  let bestWaste = Number.POSITIVE_INFINITY
  let bestRatioScore = Number.POSITIVE_INFINITY

  for (let c = 1; c <= count; c++) {
    const r = Math.max(1, Math.ceil(count / c))
    const waste = c * r - count
    const ratio = c / r
    const ratioScore = Math.abs(ratio - targetRatio)

    if (
      waste < bestWaste ||
      (waste === bestWaste && ratioScore < bestRatioScore) ||
      (waste === bestWaste && ratioScore === bestRatioScore && r < bestRows)
    ) {
      bestWaste = waste
      bestRatioScore = ratioScore
      bestCols = c
      bestRows = r
    }
  }

  return [bestCols, bestRows]
}

export default function FinishedDownloadsGalleryView({
  rows,
  isLoading,
  blurPreviews,
  durations,
  postworkByFile,
  teaserState,

  handleDuration,
  handleScrubberClickIndex,
  jobForDetails,
  keyFor,
  baseName,
  modelNameFromOutput,
  runtimeOf,
  sizeBytesOf,
  formatBytes,

  deletingKeys,
  keepingKeys,
  removingKeys,

  registerTeaserHost,

  onHoverPreviewKeyChange,
  onOpenPlayer,
  deleteVideo,
  keepVideo,
  onToggleHot,
  lower,
  modelsByKey,
  activeTagSet,
  onToggleTagFilter,
  onToggleFavorite,
  onToggleLike,
  onToggleWatch,
  onSplit,
  onAddToDownloads,
  forcePreviewMuted,
  previewActivationNonce,
}: Props) {
  // ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
  const observeTeasers = shouldObserveTeasers(teaserState.mode)
  


  // ✅ Wrapper: bei still unregistrieren / nicht registrieren
  const registerTeaserHostIfNeeded = React.useCallback(
    (key: string) => (el: HTMLDivElement | null) => {
      if (!observeTeasers) {
        registerTeaserHost(key)(null)
        return
      }
      registerTeaserHost(key)(el)
    },
    [registerTeaserHost, observeTeasers]
  )

  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)

    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)
    }
    return out
  }

  // ✅ Auflösung als {w,h} aus meta.json bevorzugen
  const resolutionObjOf = React.useCallback((j: RecordJob): { w: number; h: number } | null => {
    const w =
      (typeof (j as any)?.meta?.videoWidth === 'number' && Number.isFinite((j as any).meta.videoWidth)
        ? (j as any).meta.videoWidth
        : 0) ||
      (typeof (j as any)?.videoWidth === 'number' && Number.isFinite((j as any).videoWidth)
        ? (j as any).videoWidth
        : 0)
    const h =
      (typeof (j as any)?.meta?.videoHeight === 'number' && Number.isFinite((j as any).meta.videoHeight)
        ? (j as any).meta.videoHeight
        : 0) ||
      (typeof (j as any)?.videoHeight === 'number' && Number.isFinite((j as any).videoHeight)
        ? (j as any).videoHeight
        : 0)

    if (w > 0 && h > 0) return { w, h }
    return null
  }, [])

  // ✅ Modelbild-Preview (wird beim Hover auf Modelnamen im Thumb eingeblendet)
  const [hoveredModelPreviewKey, setHoveredModelPreviewKey] = React.useState<string | null>(null)

  // ✅ stashapp-artiger Hover-Scrubber-Zustand (pro Karte)
  const [scrubIndexByKey, setScrubIndexByKey] = React.useState<Record<string, number | undefined>>({})

  const [hoveredThumbKey, setHoveredThumbKey] = React.useState<string | null>(null)

  const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => {
    setScrubIndexByKey((prev) => {
      if (index === undefined) {
        if (!(key in prev)) return prev
        const next = { ...prev }
        delete next[key]
        return next
      }

      if (prev[key] === index) return prev
      return { ...prev, [key]: index }
    })
  }, [])

  const clearScrubIndex = React.useCallback((key: string) => {
    setScrubIndexForKey(key, undefined)
  }, [setScrubIndexForKey])

  return (
    <div className="relative">
      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
        {rows.map((j) => {
          const k = keyFor(j)

          const isPreviewActive =
            teaserState.activeKey === k || teaserState.hoverKey === k

          // Sound nur bei Hover auf genau diesem Teaser
          const allowSound = shouldEnableTeaserAudio({
            state: teaserState,
            itemKey: k,
          })
          const previewMuted = forcePreviewMuted ? true : !allowSound

          const model = modelNameFromOutput(j.output)
          const modelKey = lower(model)
          const flags = modelsByKey[modelKey]
          const isFav = Boolean(flags?.favorite)
          const isLiked = flags?.liked === true
          const isWatching = Boolean(flags?.watching)

          const tags = parseTags(flags?.tags)

          const fileRaw = baseName(j.output || '')
          const isHot = isHotName(fileRaw)
          const file = stripHotPrefix(fileRaw)

          const postworkBadge =
            postworkByFile[fileRaw] ??
            postworkByFile[stripHotPrefix(fileRaw)]
          const showPostworkBadge =
            postworkBadge &&
            (postworkBadge.state === 'queued' ||
              postworkBadge.state === 'running' ||
              postworkBadge.state === 'done' ||
              postworkBadge.state === 'error')

          const dur = runtimeOf(j)
          const size = formatBytes(sizeBytesOf(j))

          const resObj = resolutionObjOf(j)
          const resLabel = formatResolution(resObj)

          const isRemoving = removingKeys.has(k)
          const isDeleting = deletingKeys.has(k)
          const isKeeping = keepingKeys.has(k)
          const busy = isDeleting || isKeeping || isRemoving

          // ✅ Modelbild-Mapping (anpassen, falls deine API andere Feldnamen hat)
          const modelImageSrc = firstNonEmptyString(
            flags?.image,
            flags?.imageUrl,
            (j as any)?.meta?.modelImage,
            (j as any)?.meta?.modelImageUrl
          )

          // Preview-ID wie in FinishedVideoPreview ableiten (HOT entfernen + ext entfernen)
          const fileForPreviewId = stripHotPrefix(baseName(j.output || ''))
          const previewId = fileForPreviewId.replace(/\.[^.]+$/, '').trim()

          // meta robust lesen (Objekt oder JSON-String)
          const meta = parseJobMeta((j as any)?.meta)

          // ------------------------------------------------------------
          // ✅ STASHAPP-LIKE: Sprite-Preview (1 Bild + CSS background-position)
          // Erwartet z.B. meta.previewSprite = { path, count, cols, rows, stepSeconds }
          // ------------------------------------------------------------
          const spritePathRaw = firstNonEmptyString(
            meta?.previewSprite?.path,
            (meta as any)?.previewSpritePath,
            flags?.previewScrubberPath,
            previewId ? `/api/preview-sprite/${encodeURIComponent(previewId)}` : undefined
          )
          const spritePath = spritePathRaw ? spritePathRaw.replace(/\/+$/, '') : undefined

          const spriteStepSecondsRaw =
            meta?.previewSprite?.stepSeconds ?? (meta as any)?.previewSpriteStepSeconds

          const spriteStepSeconds =
            typeof spriteStepSecondsRaw === 'number' &&
            Number.isFinite(spriteStepSecondsRaw) &&
            spriteStepSecondsRaw > 0
              ? spriteStepSecondsRaw
              : DEFAULT_SPRITE_STEP_SECONDS

          // ✅ Fallback-Dauer (meta -> job -> durations cache)
          const durationForSprite =
            normalizeDurationSeconds(meta?.durationSeconds) ??
            normalizeDurationSeconds((j as any)?.durationSeconds) ??
            normalizeDurationSeconds(durations[k])

          // ✅ Count aus Meta/Flags ODER aus Dauer ableiten
          const inferredSpriteCountFromDuration =
            typeof durationForSprite === 'number' && durationForSprite > 0
              ? Math.max(1, Math.min(200, Math.floor(durationForSprite / spriteStepSeconds) + 1))
              : undefined

          const spriteCountRaw =
            meta?.previewSprite?.count ??
            (meta as any)?.previewSpriteCount ??
            flags?.previewScrubberCount ??
            inferredSpriteCountFromDuration

          const spriteColsRaw = meta?.previewSprite?.cols ?? (meta as any)?.previewSpriteCols
          const spriteRowsRaw = meta?.previewSprite?.rows ?? (meta as any)?.previewSpriteRows

          const spriteCount =
            typeof spriteCountRaw === 'number' && Number.isFinite(spriteCountRaw)
              ? Math.max(0, Math.floor(spriteCountRaw))
              : 0

          // ✅ Wenn cols/rows fehlen, aus Count ableiten (wie Backend)
          const [inferredCols, inferredRows] =
            spriteCount > 1 ? chooseSpriteGrid(spriteCount) : [0, 0]

          const spriteCols =
            typeof spriteColsRaw === 'number' && Number.isFinite(spriteColsRaw)
              ? Math.max(0, Math.floor(spriteColsRaw))
              : inferredCols

          const spriteRows =
            typeof spriteRowsRaw === 'number' && Number.isFinite(spriteRowsRaw)
              ? Math.max(0, Math.floor(spriteRowsRaw))
              : inferredRows

          // Optionaler Cache-Buster (wenn du sowas in meta hast)
          const spriteVersion =
            (typeof meta?.updatedAtUnix === 'number' && Number.isFinite(meta.updatedAtUnix)
              ? meta.updatedAtUnix
              : undefined) ??
            (typeof (meta as any)?.fileModUnix === 'number' && Number.isFinite((meta as any).fileModUnix)
              ? (meta as any).fileModUnix
              : undefined) ??
            0

          const spriteUrl =
            spritePath && spriteVersion
              ? `${spritePath}?v=${encodeURIComponent(String(spriteVersion))}`
              : spritePath || undefined

          const hasScrubberUi =
            Boolean(spriteUrl) &&
            spriteCount > 1

          const hasSpriteScrubber =
            hasScrubberUi &&
            spriteCols > 0 &&
            spriteRows > 0

          // Finales Scrubber-Setup
          const scrubberCount = hasScrubberUi ? spriteCount : 0
          const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
          const hasScrubber = hasScrubberUi

          const activeScrubIndex = scrubIndexByKey[k]

          const scrubProgressRatio =
            typeof activeScrubIndex === 'number' && scrubberCount > 1
              ? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1)
              : undefined

          // Sprite-Overlay-Frame (kein Request pro Move)
          const spriteFrameStyle: React.CSSProperties | undefined =
            hasSpriteScrubber && typeof activeScrubIndex === 'number'
              ? (() => {
                  const idx = clamp(activeScrubIndex, 0, Math.max(0, spriteCount - 1))
                  const col = idx % spriteCols
                  const row = Math.floor(idx / spriteCols)

                  const posX = spriteCols <= 1 ? 0 : (col / (spriteCols - 1)) * 100
                  const posY = spriteRows <= 1 ? 0 : (row / (spriteRows - 1)) * 100

                  return {
                    backgroundImage: `url("${spriteUrl}")`,
                    backgroundRepeat: 'no-repeat',
                    backgroundSize: `${spriteCols * 100}% ${spriteRows * 100}%`,
                    backgroundPosition: `${posX}% ${posY}%`,
                  }
                })()
              : undefined

          const showModelPreviewInThumb = hoveredModelPreviewKey === k && Boolean(modelImageSrc)
          const showScrubberSpriteInThumb = !showModelPreviewInThumb && Boolean(spriteFrameStyle)

          const hideTeaserUnderOverlay =
            showModelPreviewInThumb || showScrubberSpriteInThumb

          return (
            <div key={k} className="relative">
              <div
                role="button"
                tabIndex={0}
                className="group relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
                onClick={() => onOpenPlayer(j)}
                onKeyDown={(e) => {
                  if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
                }}
              >
                <div
                  className={[
                    'relative rounded-lg overflow-hidden outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
                    'bg-white dark:bg-gray-900/40',
                    'transition-[transform,opacity,box-shadow,background-color] duration-220 ease-out',
                    'will-change-[transform,opacity]',
                    !busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
                    busy && !isRemoving && 'pointer-events-none opacity-70',
                    isDeleting &&
                      'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
                    isKeeping &&
                      'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
                    isRemoving
                      ? 'pointer-events-none opacity-0 translate-y-2 scale-[0.98]'
                      : 'opacity-100 translate-y-0 scale-100',
                  ]
                    .filter(Boolean)
                    .join(' ')}
                >
                {/* Thumb */}
                <div
                  className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
                  ref={registerTeaserHostIfNeeded(k)}
                  onMouseEnter={() => {
                    setHoveredThumbKey(k)
                    onHoverPreviewKeyChange?.(k)
                  }}
                  onMouseLeave={() => {
                    setHoveredThumbKey((prev) => (prev === k ? null : prev))
                    onHoverPreviewKeyChange?.(null)
                    clearScrubIndex(k)
                    setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
                  }}
                >
                  {/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
                  <div className="absolute inset-0 overflow-hidden rounded-t-lg">
                    <div className="absolute inset-0">
                      <FinishedVideoPreview
                        key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
                        job={j}
                        getFileName={(p) => stripHotPrefix(baseName(p))}
                        durationSeconds={durations[k] ?? (j as any)?.durationSeconds}
                        onDuration={handleDuration}
                        variant="fill"
                        showPopover={false}
                        blur={blurPreviews}
                        animated={shouldAnimateTeaser({
                          state: teaserState,
                          itemKey: k,
                          disabled: hideTeaserUnderOverlay,
                        })}
                        animatedMode="teaser"
                        animatedTrigger="always"
                        muted={previewMuted}
                        popoverMuted={previewMuted}
                        scrubProgressRatio={scrubProgressRatio}
                        preferScrubProgress={typeof activeScrubIndex === 'number'}
                        forceActive={isPreviewActive}
                        activationNonce={isPreviewActive ? previewActivationNonce : 0}
                        teaserPreloadEnabled
                      />
                    </div>

                    {/* ✅ Sprite vorladen (einmal), damit erster Scrub-Move sofort sichtbar ist */}
                    {hasSpriteScrubber && spriteUrl ? (
                      <img
                        src={spriteUrl}
                        alt=""
                        className="hidden"
                        loading="lazy"
                        decoding="async"
                        aria-hidden="true"
                      />
                    ) : null}

                    {/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
                    {showScrubberSpriteInThumb && spriteFrameStyle ? (
                      <div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
                        <div
                          className="h-full w-full"
                          style={spriteFrameStyle}
                        />
                      </div>
                    ) : null}

                    {/* ✅ Modelbild-Preview Overlay (hover auf Modelname) */}
                    {showModelPreviewInThumb && modelImageSrc ? (
                      <div className="absolute inset-0 z-[6]">
                        <img
                          src={modelImageSrc}
                          alt={model ? `${model} preview` : 'Model preview'}
                          className="h-full w-full object-cover"
                          draggable={false}
                        />
                        <div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2 py-1.5">
                          <div className="text-[10px] font-semibold tracking-wide text-white/95">
                            MODEL PREVIEW
                          </div>
                        </div>
                      </div>
                    ) : null}

                    {/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
                    {hasScrubber && hoveredThumbKey === k ? (
                      <div
                        className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
                        onClick={(e) => e.stopPropagation()}
                        onMouseDown={(e) => e.stopPropagation()}
                      >
                        <PreviewScrubber
                          className="pointer-events-auto px-1"
                          imageCount={scrubberCount}
                          activeIndex={activeScrubIndex}
                          onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
                          onIndexClick={(index) => {
                            setScrubIndexForKey(k, index)
                            handleScrubberClickIndex(j, index, scrubberCount)
                          }}
                          stepSeconds={scrubberStepSeconds}
                        />
                      </div>
                    ) : null}

                    {/* Meta-Overlay im Video: unten rechts */}
                    <div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
                      <div
                        className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
                        title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]
                          .filter(Boolean)
                          .join(' • ')}
                      >
                        <span>{dur}</span>
                        {resLabel ? <span aria-hidden="true">•</span> : null}
                        {resLabel ? <span>{resLabel}</span> : null}
                        <span aria-hidden="true">•</span>
                        <span>{size}</span>
                      </div>
                    </div>
                  </div>
                </div>

              {/* Footer / Meta */}
              <div className="relative flex min-h-[118px] flex-col px-4 py-3 rounded-b-lg border-t border-black/5 dark:border-white/10 bg-white dark:bg-gray-900">
                {/* ✅ stashapp-like: Dateiname zuerst */}
                <div className="min-w-0">
                  <div className="mt-0.5 flex items-start gap-2 min-w-0">
                    {isHot ? (
                      <span className="shrink-0 self-start rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] leading-none font-semibold text-amber-800 dark:text-amber-300">
                        HOT
                      </span>
                    ) : null}

                    <span
                      className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"
                      title={stripHotPrefix(file) || '—'}
                    >
                      {stripHotPrefix(file) || '—'}
                    </span>
                  </div>
                </div>

                  {/* ✅ Model darunter + Postwork-Badge inline + rechts die Flags */}
                  <div className="mt-1 flex items-center justify-between gap-2">
                    <div className="min-w-0 flex items-center gap-2">
                      <span
                        className={[
                          'block min-w-0 truncate text-xs text-gray-500 dark:text-gray-400',
                          modelImageSrc ? 'cursor-zoom-in hover:text-gray-700 dark:hover:text-gray-200' : '',
                        ]
                          .filter(Boolean)
                          .join(' ')}
                        title={modelImageSrc ? `${model} (Hover: Model-Preview)` : model}
                        onMouseEnter={(e) => {
                          e.stopPropagation()
                          if (modelImageSrc) setHoveredModelPreviewKey(k)
                        }}
                        onMouseLeave={(e) => {
                          e.stopPropagation()
                          setHoveredModelPreviewKey((prev) => (prev === k ? null : prev))
                        }}
                        onMouseDown={(e) => e.stopPropagation()}
                        onClick={(e) => e.stopPropagation()}
                      >
                        {model || '—'}
                      </span>

                      {showPostworkBadge ? (
                        <span
                          className={[
                            'shrink-0 inline-flex max-w-[180px] items-center rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1',
                            postworkBadge.state === 'running'
                              ? 'bg-indigo-500/10 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-200 dark:ring-indigo-400/20'
                              : postworkBadge.state === 'queued'
                                ? 'bg-amber-500/10 text-amber-700 ring-amber-500/20 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/20'
                                : postworkBadge.state === 'done'
                                  ? 'bg-emerald-500/10 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/20'
                                  : 'bg-red-500/10 text-red-700 ring-red-500/20 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/20',
                          ].join(' ')}
                          title={
                            postworkBadge.label ||
                            (postworkBadge.state === 'running'
                              ? 'Nacharbeiten laufen'
                              : postworkBadge.state === 'queued'
                                ? 'Warte auf Nacharbeiten'
                                : postworkBadge.state === 'done'
                                  ? 'Nacharbeiten abgeschlossen'
                                  : 'Nacharbeiten fehlgeschlagen')
                          }
                        >
                          <span className="truncate">
                            {postworkBadge.label ||
                              (postworkBadge.state === 'running'
                                ? 'Nacharbeiten laufen…'
                                : postworkBadge.state === 'queued'
                                  ? 'Warte auf Nacharbeiten…'
                                  : postworkBadge.state === 'done'
                                    ? 'Nacharbeiten abgeschlossen'
                                    : 'Nacharbeiten fehlgeschlagen')}
                          </span>
                        </span>
                      ) : null}
                    </div>

                    <div className="shrink-0 flex items-center gap-1.5">
                      {isWatching ? <EyeSolidIcon className="size-4 text-sky-600 dark:text-sky-300" /> : null}
                      {isLiked ? <HeartSolidIcon className="size-4 text-rose-600 dark:text-rose-300" /> : null}
                      {isFav ? <StarSolidIcon className="size-4 text-amber-600 dark:text-amber-300" /> : null}
                    </div>
                  </div>

                  {/* Actions (wie CardView: im Footer statt im Video) */}
                  <div
                    className="mt-2 shrink-0"
                    onClick={(e) => e.stopPropagation()}
                    onMouseDown={(e) => e.stopPropagation()}
                  >
                    <div className="w-full">
                      <div className="w-full rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                        <RecordJobActions
                          job={jobForDetails(j)}
                          variant="table"
                          busy={busy}
                          collapseToMenu
                          compact={true}
                          isHot={isHot}
                          isFavorite={isFav}
                          isLiked={isLiked}
                          isWatching={isWatching}
                          onToggleWatch={onToggleWatch}
                          onToggleFavorite={onToggleFavorite}
                          onToggleLike={onToggleLike}
                          onToggleHot={onToggleHot}
                          onKeep={keepVideo}
                          onDelete={deleteVideo}
                          onSplit={onSplit}
                          onAddToDownloads={onAddToDownloads}
                          order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'split', 'details', 'add']}
                          className="w-full gap-1.5"
                        />
                      </div>
                    </div>
                  </div>

                  {/* Tags */}
                  <div className="mt-2" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
                    <TagOverflowRow
                      rowKey={k}
                      tags={tags}
                      activeTagSet={activeTagSet}
                      lower={lower}
                      onToggleTagFilter={onToggleTagFilter}
                    />
                  </div>
                </div>
              </div>
            </div>
          </div>
          )
      })}
      </div>

      {isLoading && rows.length === 0 ? (
        <div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
          <div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
            <div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
            <div className="text-sm font-medium text-gray-800 dark:text-gray-100">Lade…</div>
          </div>
        </div>
      ) : null}
    </div>
  )
}