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

import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import Button from './Button'
import LoadingSpinner from './LoadingSpinner'
import { formatDuration } from './formatters'
import {
  ArrowPathIcon,
  ArrowsPointingInIcon,
  ArrowsPointingOutIcon,
  BoltIcon,
  CheckIcon,
  ForwardIcon,
  InboxArrowDownIcon,
  PauseIcon,
  RectangleGroupIcon,
  UserGroupIcon,
  UserPlusIcon,
  TrashIcon,
  VideoCameraIcon,
  XCircleIcon,
} from '@heroicons/react/20/solid'
import { getSegmentLabelItem } from './Icons'
import Modal from './Modal'
import { useNotify } from './notify'
import { createPortal } from 'react-dom'
import TrainingFeedbackHistoryModal from './TrainingFeedbackHistoryModal'
import type { RecorderSettingsState } from '../../types'

const bodyFrontSvgUrl = new URL('../../assets/body_front.svg', import.meta.url).href


type DrawingTrainingBox = TrainingBox & {
  startX: number
  startY: number
}

type ScoredLabel = {
  label: string
  score: number
}

type TrainingDetectorStatus = {
  trainCount: number
  valCount: number
  positiveTrainCount: number
  positiveValCount: number
  requiredTrain: number
  requiredVal: number
  datasetReady: boolean
  dataReady: boolean
  modelExists: boolean
  modelPath?: string
  trainedModelExists?: boolean
  trainedModelPath?: string
  source?: string
}

type TrainingPoseStatus = {
  trainCount: number
  valCount: number
  requiredTrain: number
  requiredVal: number
  datasetReady: boolean
  dataReady: boolean
  modelExists: boolean
  modelPath?: string
  trainedModelExists?: boolean
  trainedModelPath?: string
  source?: string
}

type TrainingVideoMAEStatus = {
  eligibleCount: number
  trainCount: number
  valCount: number
  requiredTrain: number
  requiredVal: number
  datasetReady: boolean
  dataReady: boolean
  modelExists: boolean
  modelPath?: string
  trainedModelExists?: boolean
  trainedModelPath?: string
  source?: string
}

type TrainingStatus = {
  feedbackCount: number
  requiredCount: number
  canTrain: boolean
  training?: TrainingJobStatus
  detector?: TrainingDetectorStatus
  pose?: TrainingPoseStatus
  videoMAE?: TrainingVideoMAEStatus
}

type TrainingJobStatus = {
  running: boolean
  progress: number
  step: string
  message?: string
  error?: string
  startedAt?: string
  finishedAt?: string
  durationMs?: number
  stage?: string
  stageStartedAt?: string
  stageProgress?: number
  epoch?: number
  epochs?: number
  previewUrl?: string
  paused?: boolean
  pauseReason?: string
  cpuPercent?: number
  temperatureC?: number
  map50?: number
  map5095?: number
  accuracy?: number
  loss?: number
}

type TrainingPrediction = {
  modelAvailable: boolean
  source?: string

  sexPosition: string
  sexPositionScore: number
  peoplePresent: ScoredLabel[]
  bodyPartsPresent: ScoredLabel[]
  objectsPresent: ScoredLabel[]
  clothingPresent: ScoredLabel[]
  boxes?: TrainingBox[]
  persons?: TrainingPosePerson[]
}

type TrainingSample = {
  sampleId: string
  frameUrl: string
  sourceFile: string
  sourcePath?: string
  sourceSizeBytes?: number
  second: number
  createdAt: string
  uncertaintyScore?: number
  prediction: TrainingPrediction
}

type TrainingLabels = {
  people: string[]
  sexPositions: string[]
  bodyParts: string[]
  objects: string[]
  clothing: string[]
}

type CorrectionState = {
  sexPosition: string
  peoplePresent: string[]
  bodyPartsPresent: string[]
  objectsPresent: string[]
  clothingPresent: string[]
  boxes: TrainingBox[]
  posePersons?: TrainingPosePerson[]
}

type QueuedTrainingSample = {
  sample: TrainingSample
  correction?: CorrectionState
  manualCorrection?: boolean
}

type TrainingBox = {
  label: string
  score?: number
  x: number
  y: number
  w: number
  h: number
}

type TrainingKeypoint = {
  name: string
  x: number
  y: number
  conf: number
}

type TrainingPosePerson = {
  label?: string
  score: number
  box: TrainingBox
  keypoints: TrainingKeypoint[]
  quality?: number
  visibleKeypoints?: number
  reliable?: boolean
}

type BoxInteraction =
  | {
      type: 'move'
      index: number
      startX: number
      startY: number
      original: TrainingBox
    }
  | {
      type: 'resize'
      index: number
      handle: 'nw' | 'ne' | 'sw' | 'se'
      startX: number
      startY: number
      original: TrainingBox
    }

type PoseInteraction = {
  type: 'keypoint'
  personIndex: number
  keypointIndex: number
}

type PoseKeypointPlacement = {
  personIndex: number
  keypointName: string
}

type MagnifierState = {
  visible: boolean
  clientX: number
  clientY: number
  imageX: number
  imageY: number
}

type PendingTrainingVideoImport = {
  jobId?: string
  output: string
  sourceFile?: string
  count?: number
}

type TrainingConfidence = {
  score: number
  level: 'none' | 'low' | 'mid' | 'high'
  label: string
}

type TrainingLabelStat = {
  label: string
  count: number
  confidence?: TrainingConfidence
}

type TrainingModelInfo = {
  trainedAt?: string
  trainedAtMs?: number
  epochs?: number
  trainSamples?: number
  valSamples?: number
  imgsz?: number
  device?: string
  map50?: number
  map5095?: number
}

type TrainingHistoryEntry = {
  trainedAt?: string
  trainedAtMs?: number
  target?: string
  status?: string
  durationMs?: number
  epochs?: number
  trainSamples?: number
  valSamples?: number
  imgsz?: number
  device?: string
  map50?: number
  map5095?: number
  performanceMode?: string
  cpuCoreCount?: number
  cpuThreads?: number
  workers?: number
  yoloBatchSize?: number
  lowPriority?: boolean
  autoPauseEnabled?: boolean
  autoPauseCpuPercent?: number
  autoPauseTemperatureC?: number
}

type TrainingStats = {
  feedbackCount: number
  acceptedCount: number
  correctedCount: number
  negativeCount: number
  sampleCount: number
  boxCount: number
  modelAvailable: boolean
  modelInfo?: TrainingModelInfo
  detectorModelAvailable?: boolean
  detectorModelInfo?: TrainingModelInfo
  poseModelAvailable?: boolean
  poseModelInfo?: TrainingModelInfo
  videoMAEModelAvailable?: boolean
  videoMAEModelInfo?: TrainingModelInfo
  confidence?: TrainingConfidence
  labels: {
    people: TrainingLabelStat[]
    sexPositions: TrainingLabelStat[]
    bodyParts: TrainingLabelStat[]
    objects: TrainingLabelStat[]
    clothing: TrainingLabelStat[]
  }
}

type TrainingAnnotation = {
  sampleId: string
  frameUrl: string
  sourceFile: string
  sourcePath?: string
  sourceSizeBytes?: number
  second: number
  createdAt: string
  answeredAt: string
  prediction: TrainingPrediction
  accepted: boolean
  negative?: boolean
  correction?: CorrectionState
  notes?: string
}

type TrainingFeedbackListResponse = {
  ok: boolean
  items: TrainingAnnotation[]
  total: number
  limit: number
  offset: number
  hasMore: boolean
}

type TrainingSampleMode = 'random' | 'uncertain'
type TrainingTargetKey = 'detector' | 'pose' | 'videomae'
type PoseKeypointMapView = 'body' | 'head'
type TrainingStartMode = 'full' | 'custom'
type TrainingStartOptions = {
  mode: TrainingStartMode
  targets?: TrainingTargetKey[]
}
type TrainingEstimateMode = 'auto' | 'eco' | 'balanced' | 'performance' | 'custom'
type TrainingEstimateRuntime = {
  mode: TrainingEstimateMode
  modeLabel: string
  threadsLabel: string
  workers: number
  yoloBatchLabel: string
  lowPriority: boolean
  autoPause: boolean
  factor: number
  yoloFactor: number
}
type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative'

const POSE_KEYPOINT_MIN_CONFIDENCE = 0.15
const POSE_PERSON_MIN_SCORE = 0.30
const POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE = 0.20
const POSE_RELIABLE_MIN_SCORE = 0.30
const POSE_RELIABLE_MIN_KEYPOINTS = 6
const POSE_RELIABLE_MIN_QUALITY = 0.45
const POSE_UNRELIABLE_COLOR = '#94a3b8'
const POSE_PERSON_COLORS = ['#38bdf8', '#a78bfa', '#34d399', '#f59e0b', '#fb7185']
const POSE_SKELETON_EDGES: Array<readonly [string, string]> = [
  ['left_ear', 'left_eye'],
  ['left_eye', 'nose'],
  ['nose', 'right_eye'],
  ['right_eye', 'right_ear'],
  ['left_shoulder', 'right_shoulder'],
  ['left_shoulder', 'left_elbow'],
  ['left_elbow', 'left_wrist'],
  ['right_shoulder', 'right_elbow'],
  ['right_elbow', 'right_wrist'],
  ['left_shoulder', 'left_hip'],
  ['right_shoulder', 'right_hip'],
  ['left_hip', 'right_hip'],
  ['left_hip', 'left_knee'],
  ['left_knee', 'left_ankle'],
  ['right_hip', 'right_knee'],
  ['right_knee', 'right_ankle'],
]

const POSE_KEYPOINT_LABELS: Record<string, string> = {
  nose: 'Nase',
  left_eye: 'L Auge',
  right_eye: 'R Auge',
  left_ear: 'L Ohr',
  right_ear: 'R Ohr',
  left_shoulder: 'L Schulter',
  right_shoulder: 'R Schulter',
  left_elbow: 'L Ellbogen',
  right_elbow: 'R Ellbogen',
  left_wrist: 'L Hand',
  right_wrist: 'R Hand',
  left_hip: 'L Huefte',
  right_hip: 'R Huefte',
  left_knee: 'L Knie',
  right_knee: 'R Knie',
  left_ankle: 'L Fuss',
  right_ankle: 'R Fuss',
}

const POSE_KEYPOINT_ORDER = [
  'nose',
  'left_eye',
  'right_eye',
  'left_ear',
  'right_ear',
  'left_shoulder',
  'right_shoulder',
  'left_elbow',
  'right_elbow',
  'left_wrist',
  'right_wrist',
  'left_hip',
  'right_hip',
  'left_knee',
  'right_knee',
  'left_ankle',
  'right_ankle',
] as const

const POSE_KEYPOINT_OPTIONS = POSE_KEYPOINT_ORDER.map((key) => [key, POSE_KEYPOINT_LABELS[key]] as const)
const POSE_FACE_KEYPOINT_NAME_SET = new Set([
  'nose',
  'left_eye',
  'right_eye',
  'left_ear',
  'right_ear',
])

function poseFaceKeypointLabelPlacement(keypointName: string) {
  switch (poseKeypointId(keypointName)) {
    case 'right_ear':
      return {
        labelToLeft: true,
        transform: 'translate(calc(-100% - 10px), -50%)',
        zIndex: 390,
      }
    case 'right_eye':
      return {
        labelToLeft: true,
        transform: 'translate(calc(-100% - 8px), -135%)',
        zIndex: 392,
      }
    case 'left_eye':
      return {
        labelToLeft: false,
        transform: 'translate(8px, -135%)',
        zIndex: 392,
      }
    case 'left_ear':
      return {
        labelToLeft: false,
        transform: 'translate(10px, -50%)',
        zIndex: 390,
      }
    case 'nose':
      return {
        labelToLeft: false,
        transform: 'translate(-50%, 10px)',
        zIndex: 394,
      }
    default:
      return null
  }
}

type PoseKeypointShape = {
  keypointName: keyof typeof POSE_KEYPOINT_LABELS
  d: string
  center: {
    x: number
    y: number
  }
}

const POSE_BODY_MAP_VIEW_BOX = '0 0 537 1386'
const POSE_BODY_MAP_ASPECT_RATIO = '537 / 1386'
const POSE_BODY_MAP_ASPECT_RATIO_VALUE = 537 / 1386
const POSE_HEAD_MAP_VIEW_BOX = '172.5 0 185 477'
const POSE_HEAD_MAP_ASPECT_RATIO = POSE_BODY_MAP_ASPECT_RATIO
const POSE_KEYPOINT_MAP_ZOOM_DURATION_MS = 400
const POSE_FACE_IMAGE_ZOOM_DURATION_MS = 650

function poseKeypointMapViewBox(view: PoseKeypointMapView) {
  return view === 'head' ? POSE_HEAD_MAP_VIEW_BOX : POSE_BODY_MAP_VIEW_BOX
}

function parsePoseMapViewBox(viewBox: string): [number, number, number, number] {
  const values = viewBox.split(/\s+/).map((value) => Number(value))

  return [
    Number.isFinite(values[0]) ? values[0] : 0,
    Number.isFinite(values[1]) ? values[1] : 0,
    Number.isFinite(values[2]) ? values[2] : 1,
    Number.isFinite(values[3]) ? values[3] : 1,
  ]
}

function formatPoseMapViewBox(values: [number, number, number, number]) {
  return values.map((value) => Number(value.toFixed(3))).join(' ')
}

function easePoseMapZoom(progress: number) {
  return progress < 0.5
    ? 4 * progress * progress * progress
    : 1 - Math.pow(-2 * progress + 2, 3) / 2
}

function interpolatePoseMapViewBox(from: string, to: string, progress: number) {
  const start = parsePoseMapViewBox(from)
  const end = parsePoseMapViewBox(to)
  const eased = easePoseMapZoom(Math.max(0, Math.min(1, progress)))

  return formatPoseMapViewBox([
    start[0] + (end[0] - start[0]) * eased,
    start[1] + (end[1] - start[1]) * eased,
    start[2] + (end[2] - start[2]) * eased,
    start[3] + (end[3] - start[3]) * eased,
  ])
}

const POSE_HEAD_SHAPE = {
  d: 'M252.24.38c22.82-.29,49.74,12.66,54,31,1.8,7.77-2.25,14.71,1,19l24,20c24.63,34.89,19.47,85.38-6,114l-8,19-15,17-2,10c-37.09,39.53-85.31-10.89-92-46-17.31-1.44-14.13-17.89-19-32-9.11-26.39-2.85-62.04,12-82,7.56-10.17,25.79-13.99,29-28-10.65-25.65,5.43-31.91,22-42Z',
  center: { x: 260, y: 128 },
}

const POSE_BODY_SILHOUETTE_SHAPE = {
  d: 'M228.17,226.46c9.86,2.25,19.05,8.64,26,14,21.09-.75,30.2-5.19,42-14l2,1,2,39c17.4,30.36,95.95,20.16,110,54,17.09,41.15-.67,121.25,7,171,5.26,34.15,16.21,54.17,23,85l14,105c9.03,37.99,66.17,66.65,74,106h-4c-5.94-2.49-8.47-5.5-13-9-3.03,2.69-1.52,3.01-7,4l-4-3c-2.26-14.07-17-33.28-33-28,5.34,9.78,14.64,15.86,16,29-10.6,6.63-15.15-8.33-20-14-9.68-11.31-26.15-19.14-30-36l-1-33-45-107c-14.24-36.26-19.89-82.94-20-132l-5,4c-6.73,24.5-20.65,62.24-14,91,13.42,58.06,59.07,109.01,45,196-13.09,80.98-35.72,147.59-49,224-7.78,44.76,9.2,86.8,2,131-7.72,47.41-16.38,86.91-26,128-5.67,24.22-9.97,63.94-8,89,1.47,18.68,24.25,43.35,14,61-13.49,2.22-33.17,4.78-48,2-13.83-11.36-2.13-27.9-6-49-6.84-37.25,12.3-66.02,6-107l-3-66c-3.28-21.08-6.86-52.3-3-77l4-41-10-44,4-95-1-76c3.6-34.34,5.4-68.37,11-96h-1l-30,1c3.03,18.81,5.71,39.34,9,61v122l4,80-10,47,4,43c5.09,33.08-.46,82.98-5,113-5.85,38.71,3,77.04,8,106l-3,37c-.58,18.68,2.11,30.56-6,42-13.1-.03-40.18-.91-51-8-2.91-19.32,12.04-34.88,16-52,1.11-4.81.34-48.15-1-56-10.21-59.61-22.61-107.33-33-170-7.04-42.45,10.26-88.28,2-133l-43-176-5-47c-13.76-83.21,31.05-132.8,44-190,4.49-19.84-7.14-85.32-17-93v2l-2,35c-4.3,25.96-5.88,51.76-13,75-16.33,40-32.67,80-49,120l-3,40c-3.35,10.16-38.6,49.47-50,50v-2l-1-1c2.5-12.5,11.59-19,17-29-27.53,3.35-31.62,32.36-43,32l-2-3c-4.46,4.97-7.22,7.97-17,8,6.45-24.77,33.79-51.67,50-69,7.65-8.18,20.5-15.08,24-27,5.33-40.66,10.67-81.34,16-122,6.48-27.54,16.92-44.95,22-76,.67-40,1.33-80,2-120,0-18.13-1.35-37.56,4-51,9.09-22.83,23.12-18.83,49-26,15.32-4.24,52.29-17.52,59-31l3-39Z',
}

const POSE_FACE_KEYPOINT_SHAPES: PoseKeypointShape[] = [
  {
    keypointName: 'left_ear',
    d: 'M333.4,141.79c6.55,2.02,7.24,6.5,10,12-4.25,12.13-7.47,21.63-15,30-4.53-.86-2.98.12-5-3-5.47-13.4,6.64-29.01,10-39Z',
    center: { x: 333, y: 162 },
  },
  {
    keypointName: 'left_eye',
    d: 'M275.17,161.23c0-9.1,2.33-11.16,6-16,13.79-3.91,20.05-5.92,34,0-.06,6.18.06,5.55,1,11-13.55,8.16-24.12,5.61-41,5Z',
    center: { x: 296, y: 153 },
  },
  {
    keypointName: 'nose',
    d: 'M255.72,141.52c9.21.01,16.7,3.13,19.65,10,3.56,9.05,4.51,27.57,2.81,37-7.05,3.67-18.89,3.38-27.13,1l-.94-3,3.74-44,1.87-1Z',
    center: { x: 265, y: 168 },
  },
  {
    keypointName: 'right_eye',
    d: 'M227.67,140.54c11.65-.29,15.55,2.71,22,7l3,11-2,2c-21.58-1.84-23.28.92-38-7v-3c5.11-5.09,7.9-6.27,15-10Z',
    center: { x: 233, y: 151 },
  },
  {
    keypointName: 'right_ear',
    d: 'M192.31,141.85h2c9.16,8.73,13.18,22.95,13,41l-7,1c-9.02-8.69-14.48-24.7-11-40l3-2Z',
    center: { x: 198, y: 163 },
  },
]

const POSE_BODY_KEYPOINT_SHAPES: PoseKeypointShape[] = [
  {
    keypointName: 'left_shoulder',
    d: 'M366.78,294.65c49.96-1.22,59.69,45.87,51,89-19.24,2.93-31.75-2.97-45-7-7.81-2.38-13.74.22-23-3-16.83-5.86-27.17-26-32-44-1.35-5.05-4.26-17.79-1-24,5.82-11.1,40.46-3.09,50-11Z',
    center: { x: 367, y: 339 },
  },
  {
    keypointName: 'right_shoulder',
    d: 'M151.37,294.09c15.59-.17,27.96,3.03,43,6,35.95,46.17-27.37,89.99-74,75-3.06.74-4.51.27-7-2-9.69-48.02,7-65.94,38-79Z',
    center: { x: 154, y: 335 },
  },
  {
    keypointName: 'left_elbow',
    d: 'M385.9,497.92l34,3c1.83,22.73,11.96,34.76,13,55l-2,1c-16.93,4.42-34.01,5.16-53,2-3.9-15.42-10.79-41.98-6-58l14-3Z',
    center: { x: 402, y: 528 },
  },
  {
    keypointName: 'right_elbow',
    d: 'M112.08,499.2c19.98-.26,31.68,1.45,45,5,6.4,18.88-4.46,48.43-8,66h-5c-11.54,1.91-39.73-14.5-46-23,1-6.62,10.3-43.27,14-48Z',
    center: { x: 128, y: 535 },
  },
  {
    keypointName: 'left_wrist',
    d: 'M442.19,698.5l17,1c18.5,11.77,65.86,67.9,71,91l-1,1c-10.64-1.08-11.24-6-18-10-1.03,5.43-.3,4.34-6,5l-5-4c-3.16-18.61-13.25-23.4-30-28,3.14,12.71,15.88,16.27,14,31-9.71,2.2-45.85-38.29-50-48-4.38-10.26-1.43-24.8-1-36h2l7-3Z',
    center: { x: 482, y: 745 },
  },
  {
    keypointName: 'right_wrist',
    d: 'M-.9,790.21c1.52-19.66,59.95-87.7,75-97,7.7-3.41,17.22,1.04,23,3l1,2c.79,43.23-12.04,47.2-31,68-6.74,7.4-8.48,13.96-18,19l-6-3v-1c2.62-12.82,12.3-18.1,17-29l-27,14c-5.09,6.8-3.71,14.23-11,19-7.41-2.6-7.5-3.82-13,4l-2,1H-.9Z',
    center: { x: 49, y: 742 },
  },
  {
    keypointName: 'left_hip',
    d: 'M361.83,617.76l16,2c13.34,42.76,19.07,80.94,18,137l-2,1c-26.41,5.52-56.7-24.7-61-45-2.39-11.27,2.04-29.55,3-40,1.59-17.35-3.1-31.43,7-45,4.37-5.87,12.1-6.54,19-10Z',
    center: { x: 365, y: 690 },
  },
  {
    keypointName: 'right_hip',
    d: 'M149.97,622.82c34.97-.84,40.11,21.46,46,49,.64,3.01-2,10-2,10,5.65,7.66,4.26,13.83,4,24v24c-8.03,22.25-40.04,38.2-64,33-7.58-53.57,5.98-100.8,16-140Z',
    center: { x: 166, y: 693 },
  },
  {
    keypointName: 'left_knee',
    d: 'M288.62,972.45c20.28-.46,45.13-2.06,57,6,.27,19.74,5.1,54.5-2,70-23.77.1-48.94,7.97-68,3-.05-25.22-19.01-51.23-7-77,6.75.33,16.29.85,20-2Z',
    center: { x: 311, y: 1012 },
  },
  {
    keypointName: 'right_knee',
    d: 'M184.44,972.61c22.02-.27,61.46-6.01,79,2,1.23,18.05-3.49,72.92-10,86-23.35.49-55.39-1.68-69-12-.19-25.65-5.78-61.25,0-76Z',
    center: { x: 223, y: 1017 },
  },
  {
    keypointName: 'left_ankle',
    d: 'M276.45,1281.69c14.15-.65,32.9.36,38,9l1,1-1,33c2.99,15.43,19.56,34.85,16,52l-1,1-5,7c-11.19.32-41.42,3.97-48-2-11.12-8.97-9-88.28,0-101Z',
    center: { x: 301, y: 1335 },
  },
  {
    keypointName: 'right_ankle',
    d: 'M217.81,1285.3c11.67.33,23.33.67,35,1,12.72,9.37,7.65,87.45-1,99-20.11.44-40.62-.06-53-8l-1-2c.32-21.95,15.9-34.18,18-54,1.3-12.3-6.69-25.48,2-36Z',
    center: { x: 233, y: 1337 },
  },
]

function poseKeypointId(name?: string | null) {
  return String(name ?? '').trim().toLowerCase()
}

function poseKeypointLabel(name?: string | null) {
  const key = poseKeypointId(name)
  return POSE_KEYPOINT_LABELS[key] || key.replace(/_/g, ' ') || 'Punkt'
}

function poseKeypointMarkerClassName(present: boolean, pending: boolean, highlighted = false) {
  if (pending) {
    return 'fill-amber-500 stroke-white/90 dark:stroke-gray-950/90'
  }

  if (highlighted) {
    return 'fill-cyan-500 stroke-white/95 dark:stroke-gray-950/90'
  }

  if (present) {
    return 'fill-blue-500 stroke-white/90 dark:stroke-gray-950/90'
  }

  return 'fill-rose-500 stroke-white/90 dark:stroke-gray-950/90'
}

function isPoseKeypointVisible(point?: TrainingKeypoint | null): point is TrainingKeypoint {
  if (!point) return false

  const x = Number(point.x)
  const y = Number(point.y)
  const conf = Number(point.conf)

  return (
    Number.isFinite(x) &&
    Number.isFinite(y) &&
    Number.isFinite(conf) &&
    conf >= POSE_KEYPOINT_MIN_CONFIDENCE
  )
}

type PoseFaceZoomRegion = {
  centerX: number
  centerY: number
  width: number
  height: number
}

type NormalizedImageRect = {
  x: number
  y: number
  w: number
  h: number
}

type PoseFaceImageCamera = {
  scale: number
  translateX: number
  translateY: number
  centerX: number
  centerY: number
  viewportWidth: number
  viewportHeight: number
  visibleRegion: NormalizedImageRect
}

type PoseFaceImagePanGesture = {
  pointerId: number
  source: 'image' | 'preview'
  startClientX: number
  startClientY: number
  startCenterX: number
  startCenterY: number
  contentWidth: number
  contentHeight: number
  scale: number
  moved: boolean
}

function poseFaceImageFullCamera(): PoseFaceImageCamera {
  return {
    scale: 1,
    translateX: 0,
    translateY: 0,
    centerX: 0.5,
    centerY: 0.5,
    viewportWidth: 1,
    viewportHeight: 1,
    visibleRegion: {
      x: 0,
      y: 0,
      w: 1,
      h: 1,
    },
  }
}

function interpolatePoseFaceImageCamera(
  from: PoseFaceImageCamera,
  to: PoseFaceImageCamera,
  progress: number
): PoseFaceImageCamera {
  const eased = easePoseMapZoom(Math.max(0, Math.min(1, progress)))
  const lerp = (a: number, b: number) => a + (b - a) * eased
  const scale = lerp(from.scale, to.scale)
  const translateX = lerp(from.translateX, to.translateX)
  const translateY = lerp(from.translateY, to.translateY)
  const centerX = lerp(from.centerX, to.centerX)
  const centerY = lerp(from.centerY, to.centerY)
  const viewportWidth = Math.min(1, Math.max(0.015, lerp(from.viewportWidth, to.viewportWidth)))
  const viewportHeight = Math.min(1, Math.max(0.015, lerp(from.viewportHeight, to.viewportHeight)))

  return {
    scale,
    translateX,
    translateY,
    centerX,
    centerY,
    viewportWidth,
    viewportHeight,
    visibleRegion: {
      x: Math.max(0, Math.min(1 - viewportWidth, centerX - viewportWidth / 2)),
      y: Math.max(0, Math.min(1 - viewportHeight, centerY - viewportHeight / 2)),
      w: viewportWidth,
      h: viewportHeight,
    },
  }
}

function normalizedZoomRegion(
  centerX: number,
  centerY: number,
  width: number,
  height: number
): PoseFaceZoomRegion {
  const w = Math.max(0.08, Math.min(1, Number(width) || 0))
  const h = Math.max(0.08, Math.min(1, Number(height) || 0))
  const cx = clamp01(centerX)
  const cy = clamp01(centerY)
  const left = Math.max(0, Math.min(1 - w, cx - w / 2))
  const top = Math.max(0, Math.min(1 - h, cy - h / 2))

  return {
    centerX: left + w / 2,
    centerY: top + h / 2,
    width: w,
    height: h,
  }
}

function clampPoseFaceCameraCenter(
  centerX: number,
  centerY: number,
  viewportWidth: number,
  viewportHeight: number
) {
  const w = Math.min(1, Math.max(0, Number(viewportWidth) || 0))
  const h = Math.min(1, Math.max(0, Number(viewportHeight) || 0))
  const minX = w >= 1 ? 0.5 : w / 2
  const maxX = w >= 1 ? 0.5 : 1 - w / 2
  const minY = h >= 1 ? 0.5 : h / 2
  const maxY = h >= 1 ? 0.5 : 1 - h / 2

  return {
    x: Math.max(minX, Math.min(maxX, clamp01(centerX))),
    y: Math.max(minY, Math.min(maxY, clamp01(centerY))),
  }
}

function posePersonFaceZoomRegion(person?: TrainingPosePerson | null): PoseFaceZoomRegion | null {
  if (!person) return null

  const facePoints = (person.keypoints ?? []).filter(
    (point) =>
      POSE_FACE_KEYPOINT_NAME_SET.has(poseKeypointId(point.name)) &&
      isPoseKeypointVisible(point)
  )

  if (facePoints.length > 0) {
    const xs = facePoints.map((point) => clamp01(Number(point.x)))
    const ys = facePoints.map((point) => clamp01(Number(point.y)))
    const minX = Math.min(...xs)
    const maxX = Math.max(...xs)
    const minY = Math.min(...ys)
    const maxY = Math.max(...ys)
    const rawW = Math.max(0.035, maxX - minX)
    const rawH = Math.max(0.035, maxY - minY)
    const pad = Math.max(0.045, Math.max(rawW, rawH) * 1.35)

    return normalizedZoomRegion(
      (minX + maxX) / 2,
      (minY + maxY) / 2,
      Math.max(0.14, rawW + pad * 2),
      Math.max(0.16, rawH + pad * 2)
    )
  }

  if (hasVisiblePoseBox(person)) {
    const box = normalizeBox(person.box)

    return normalizedZoomRegion(
      box.x + box.w / 2,
      box.y + box.h * 0.18,
      Math.max(0.16, box.w * 0.62),
      Math.max(0.18, box.h * 0.28)
    )
  }

  return null
}

function isPoseReliableKeypoint(point?: TrainingKeypoint | null): point is TrainingKeypoint {
  if (!point) return false

  const x = Number(point.x)
  const y = Number(point.y)
  const conf = Number(point.conf)

  return (
    Number.isFinite(x) &&
    Number.isFinite(y) &&
    Number.isFinite(conf) &&
    x >= 0 &&
    x <= 1 &&
    y >= 0 &&
    y <= 1 &&
    conf >= POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE
  )
}

function posePersonVisibleKeypoints(person: TrainingPosePerson) {
  const direct = Number(person.visibleKeypoints)
  if (Number.isFinite(direct) && direct >= 0) {
    return Math.floor(direct)
  }

  return (person.keypoints ?? []).filter(isPoseReliableKeypoint).length
}

function posePersonQuality(person: TrainingPosePerson) {
  const direct = Number(person.quality)
  if (Number.isFinite(direct) && direct >= 0) {
    return clamp01(direct)
  }

  const reliableKeypoints = (person.keypoints ?? []).filter(isPoseReliableKeypoint)
  if (reliableKeypoints.length === 0) return 0

  const totalConfidence = reliableKeypoints.reduce(
    (sum, point) => sum + clamp01(Number(point.conf)),
    0
  )
  const coverage = clamp01(reliableKeypoints.length / 17)
  const averageConfidence = clamp01(totalConfidence / reliableKeypoints.length)

  return clamp01(coverage * 0.45 + averageConfidence * 0.55)
}

function isPosePersonReliable(person: TrainingPosePerson) {
  if (typeof person.reliable === 'boolean') {
    return person.reliable
  }

  return (
    clamp01(Number(person.score)) >= POSE_RELIABLE_MIN_SCORE &&
    posePersonVisibleKeypoints(person) >= POSE_RELIABLE_MIN_KEYPOINTS &&
    posePersonQuality(person) >= POSE_RELIABLE_MIN_QUALITY
  )
}

function hasVisiblePoseBox(person: TrainingPosePerson) {
  const box = person.box
  if (!box) return false

  const w = Number(box.w)
  const h = Number(box.h)

  return (
    clamp01(Number(person.score)) >= POSE_PERSON_MIN_SCORE &&
    Number.isFinite(w) &&
    Number.isFinite(h) &&
    w > 0 &&
    h > 0
  )
}

function poseCoordPx(value: number, size: number) {
  return clamp01(Number(value)) * Math.max(0, Number(size) || 0)
}

function backendText(data: any, fallback: string) {
  return String(
    data?.message ||
    data?.error ||
    data?.detail ||
    fallback
  ).trim()
}

function trainingDurationMs(job?: TrainingJobStatus | null) {
  const direct = Number(job?.durationMs)

  if (Number.isFinite(direct) && direct > 0) {
    return direct
  }

  const started = job?.startedAt ? Date.parse(job.startedAt) : NaN
  const finished = job?.finishedAt ? Date.parse(job.finishedAt) : NaN

  if (!Number.isFinite(started) || !Number.isFinite(finished)) {
    return 0
  }

  return Math.max(0, finished - started)
}

function roundedTrainingEstimateMs(ms: number) {
  const safe = Number(ms)
  if (!Number.isFinite(safe) || safe <= 0) return 0

  const step =
    safe >= 60 * 60 * 1000
      ? 5 * 60 * 1000
      : safe >= 10 * 60 * 1000
        ? 60 * 1000
        : safe >= 2 * 60 * 1000
          ? 30 * 1000
          : 10 * 1000

  return Math.max(step, Math.round(safe / step) * step)
}

function formatApproxTrainingDuration(ms: number) {
  const rounded = roundedTrainingEstimateMs(ms)
  if (rounded <= 0) return 'ca. —'
  return `ca. ${formatDuration(rounded)}`
}

function trainingHistoryDurationFloorMs(entries: TrainingHistoryEntry[]) {
  const durations = (entries ?? [])
    .filter((entry) => !String(entry.target ?? '').trim())
    .map((entry) => Number(entry.durationMs ?? 0))
    .filter((duration) => Number.isFinite(duration) && duration > 60 * 1000)
    .slice(0, 5)
    .sort((a, b) => a - b)

  if (durations.length === 0) return 0

  return durations[Math.floor(durations.length / 2)]
}

function trainingHistoryTarget(value?: string | null): TrainingTargetKey | '' {
  switch (String(value ?? '').trim().toLowerCase()) {
    case 'detector':
    case 'yolo':
    case 'yolo26':
    case 'box':
    case 'boxes':
      return 'detector'
    case 'pose':
    case 'yolo26_pose':
      return 'pose'
    case 'videomae':
    case 'video_mae':
    case 'scene':
    case 'clip':
      return 'videomae'
    default:
      return ''
  }
}

function isTrainingTargetKey(value: unknown): value is TrainingTargetKey {
  switch (String(value ?? '').trim().toLowerCase()) {
    case 'detector':
    case 'pose':
    case 'videomae':
      return true
    default:
      return false
  }
}

function trainingTargetFromStageText(
  stage?: string | null,
  step?: string | null
): TrainingTargetKey | '' {
  const direct = trainingHistoryTarget(stage)
  if (direct) return direct

  const text = `${stage ?? ''} ${step ?? ''}`.toLowerCase()

  if (text.includes('videomae') || text.includes('clip')) return 'videomae'
  if (text.includes('pose')) return 'pose'
  if (
    text.includes('detector') ||
    text.includes('object') ||
    text.includes('yolo26') ||
    text.includes('yolo')
  ) {
    return 'detector'
  }

  return ''
}

function trainingTargetProgressWindow(target: TrainingTargetKey | '') {
  switch (target) {
    case 'detector':
      return { start: 15, end: 58 }
    case 'pose':
      return { start: 62, end: 82 }
    case 'videomae':
      return { start: 84, end: 98 }
    default:
      return { start: 0, end: 100 }
  }
}

function combineTrainingEtaMs(stageRemainingMs: number, epochRemainingMs: number) {
  const stage = Number(stageRemainingMs)
  const epoch = Number(epochRemainingMs)
  const hasStage = Number.isFinite(stage) && stage > 0
  const hasEpoch = Number.isFinite(epoch) && epoch > 0

  if (!hasStage && !hasEpoch) return 0
  if (!hasStage) return epoch
  if (!hasEpoch) return stage

  const boundedStage = Math.max(epoch * 0.35, Math.min(epoch * 1.65, stage))
  return epoch * 0.75 + boundedStage * 0.25
}

function trainingLocalProgressFromJob(
  job: TrainingJobStatus | null | undefined,
  target: TrainingTargetKey | ''
) {
  if (!job || !target) return null

  const stageTarget = trainingTargetFromStageText(job.stage, job.step)
  if (stageTarget !== target) return null

  const value = Number(job.stageProgress)
  if (!Number.isFinite(value)) return null

  return clamp01(value <= 1 ? value : value / 100)
}

function trainingCompletedEpochUnits(
  epoch: number,
  epochs: number,
  localProgress?: number | null
) {
  if (!Number.isFinite(epoch) || !Number.isFinite(epochs) || epoch <= 0 || epochs <= 0) {
    return 0
  }

  const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch)))
  const progress = Number(localProgress)

  if (Number.isFinite(progress) && progress > 0) {
    const inferred = ((clamp01(progress) - 0.04) / 0.90) * epochs
    if (Number.isFinite(inferred) && inferred > 0) {
      return clampTrainingEstimate(inferred, Math.max(0, safeEpoch - 1), epochs)
    }
  }

  return Math.max(0.15, Math.min(epochs, safeEpoch - 0.5))
}

function normalizeTrainingEstimateMode(value?: string | null): TrainingEstimateMode {
  switch (String(value ?? '').trim().toLowerCase()) {
    case 'eco':
    case 'schonend':
    case 'schonmodus':
    case 'powersave':
    case 'power-save':
    case 'power_save':
      return 'eco'
    case 'balanced':
    case 'ausgewogen':
    case 'normal':
      return 'balanced'
    case 'performance':
    case 'leistung':
    case 'fast':
    case 'schnell':
      return 'performance'
    case 'custom':
    case 'manual':
    case 'manuell':
      return 'custom'
    default:
      return 'auto'
  }
}

function trainingEstimateModeLabel(mode: TrainingEstimateMode) {
  switch (mode) {
    case 'eco':
      return 'Schonmodus'
    case 'balanced':
      return 'Ausgewogen'
    case 'performance':
      return 'Leistung'
    case 'custom':
      return 'Manuell'
    default:
      return 'Auto'
  }
}

function clampTrainingEstimate(value: number, minValue: number, maxValue: number) {
  if (!Number.isFinite(value)) return minValue
  return Math.max(minValue, Math.min(maxValue, value))
}

function trainingEstimateInt(value: unknown, fallback: number, minValue: number, maxValue: number) {
  const raw = Math.floor(Number(value))
  const safe = Number.isFinite(raw) ? raw : fallback
  return Math.max(minValue, Math.min(maxValue, safe))
}

function trainingEstimateRuntimeFromSettings(settings?: RecorderSettingsState | null): TrainingEstimateRuntime {
  const mode = normalizeTrainingEstimateMode(
    settings?.trainingEffectiveMode ?? settings?.trainingPerformanceMode
  )
  const cpuCores = trainingEstimateInt(settings?.trainingCpuCoreCount, 0, 0, 256)
  const rawThreads = trainingEstimateInt(
    settings?.trainingEffectiveCpuThreads ?? settings?.trainingCpuThreads,
    0,
    0,
    256
  )
  const workers = trainingEstimateInt(
    settings?.trainingEffectiveWorkers ?? settings?.trainingWorkers,
    1,
    0,
    32
  )
  const rawBatch = trainingEstimateInt(
    settings?.trainingEffectiveYoloBatchSize ?? settings?.trainingYoloBatchSize,
    0,
    0,
    64
  )
  const lowPriority = Boolean(
    settings?.trainingEffectiveLowPriority ?? settings?.trainingLowPriority
  )
  const autoPause = Boolean(settings?.trainingAutoPauseEnabled)
  const autoThreads = cpuCores > 0
    ? Math.max(1, Math.min(16, Math.round(cpuCores * 0.75)))
    : 4
  const effectiveThreads = rawThreads > 0 ? rawThreads : autoThreads
  const effectiveBatch = rawBatch > 0 ? rawBatch : 2

  const modeFactor =
    mode === 'eco'
      ? 1.45
      : mode === 'balanced'
        ? 1.15
        : mode === 'performance'
          ? 0.92
          : mode === 'custom'
            ? 1
            : 1.08
  const threadFactor =
    effectiveThreads <= 1
      ? 1.7
      : effectiveThreads === 2
        ? 1.35
        : effectiveThreads === 3
          ? 1.18
          : effectiveThreads >= 12
            ? 0.86
            : effectiveThreads >= 8
              ? 0.9
              : effectiveThreads >= 6
                ? 0.96
                : 1.04
  const workerFactor =
    workers <= 0
      ? 1.2
      : workers === 1
        ? 1.08
        : workers >= 6
          ? 0.98
          : 1
  const batchFactor =
    effectiveBatch <= 1
      ? 1.25
      : effectiveBatch === 2
        ? 1.08
        : effectiveBatch >= 12
          ? 0.88
          : effectiveBatch >= 8
            ? 0.92
            : effectiveBatch >= 4
              ? 0.98
              : 1
  const lowPriorityFactor = lowPriority ? 1.12 : 1
  const autoPauseFactor = autoPause ? 1.12 : 1
  const corePenalty =
    cpuCores > 0 && effectiveThreads > cpuCores
      ? 1 + Math.min(0.3, (effectiveThreads - cpuCores) * 0.04)
      : 1
  const factor = modeFactor * threadFactor * workerFactor * lowPriorityFactor * autoPauseFactor * corePenalty
  const yoloFactor = factor * batchFactor

  return {
    mode,
    modeLabel: trainingEstimateModeLabel(mode),
    threadsLabel: rawThreads > 0 ? String(rawThreads) : 'Auto',
    workers,
    yoloBatchLabel: rawBatch > 0 ? String(rawBatch) : 'Auto',
    lowPriority,
    autoPause,
    factor: Number.isFinite(factor) && factor > 0 ? factor : 1,
    yoloFactor: Number.isFinite(yoloFactor) && yoloFactor > 0 ? yoloFactor : 1,
  }
}

function trainingEstimateRuntimeText(runtime: TrainingEstimateRuntime) {
  const priority = runtime.lowPriority ? ' · niedrige Priorität' : ''
  const autoPause = runtime.autoPause ? ' · Auto-Pause' : ''
  return `${runtime.modeLabel} · ${runtime.threadsLabel} Threads · ${runtime.workers} Worker · Batch ${runtime.yoloBatchLabel}${priority}${autoPause}`
}

function trainingEstimateRuntimeFromHistory(entry: TrainingHistoryEntry) {
  return trainingEstimateRuntimeFromSettings({
    trainingPerformanceMode: entry.performanceMode,
    trainingEffectiveMode: entry.performanceMode,
    trainingCpuCoreCount: entry.cpuCoreCount,
    trainingEffectiveCpuThreads: entry.cpuThreads,
    trainingEffectiveWorkers: entry.workers,
    trainingEffectiveYoloBatchSize: entry.yoloBatchSize,
    trainingEffectiveLowPriority: entry.lowPriority,
    trainingAutoPauseEnabled: entry.autoPauseEnabled,
  } as RecorderSettingsState)
}

function trainingRuntimeFactorForTarget(runtime: TrainingEstimateRuntime, target: TrainingTargetKey) {
  return target === 'videomae' ? runtime.factor : runtime.yoloFactor
}

function trainingHasTargetHistory(entries: TrainingHistoryEntry[], target: TrainingTargetKey) {
  return (entries ?? []).some((entry) => {
    const duration = Number(entry.durationMs ?? 0)
    if (!Number.isFinite(duration) || duration <= 60 * 1000) return false
    const status = String(entry.status ?? 'trained').trim().toLowerCase()
    if (status && status !== 'trained') return false
    return trainingHistoryTarget(entry.target) === target
  })
}

function trainingFineTuneEstimateFactor(
  target: TrainingTargetKey,
  trainedModelExists: boolean,
  hasTargetHistory: boolean
) {
  if (!trainedModelExists) return 1

  if (hasTargetHistory) {
    return 0.92
  }

  return target === 'videomae' ? 0.85 : 0.75
}

function estimateTrainingHistoryDurationMs(
  entries: TrainingHistoryEntry[],
  target: TrainingTargetKey,
  trainCount: number,
  valCount: number,
  eligibleCount: number,
  runtime: TrainingEstimateRuntime,
  epochs: number
) {
  const currentSamples = Math.max(
    1,
    Math.max(0, Number(trainCount) || 0) +
      Math.max(0, Number(valCount) || 0),
    target === 'videomae' ? Math.max(0, Number(eligibleCount) || 0) : 0
  )
  const currentEpochs = trainingEstimateInt(
    target === 'videomae' ? epochs || 8 : epochs,
    target === 'videomae' ? 8 : 60,
    1,
    target === 'videomae' ? 200 : 300
  )
  const currentRuntimeFactor = trainingRuntimeFactorForTarget(runtime, target)
  const estimates = (entries ?? [])
    .filter((entry) => {
      const duration = Number(entry.durationMs ?? 0)
      if (!Number.isFinite(duration) || duration <= 60 * 1000) return false
      const status = String(entry.status ?? 'trained').trim().toLowerCase()
      if (status && status !== 'trained') return false

      return trainingHistoryTarget(entry.target) === target
    })
    .slice(0, 8)
    .map((entry) => {
      const duration = Number(entry.durationMs ?? 0)
      const historySamples = Math.max(
        1,
        Math.max(0, Number(entry.trainSamples ?? 0)) +
          Math.max(0, Number(entry.valSamples ?? 0))
      )
      const historyEpochs = trainingEstimateInt(
        entry.epochs,
        target === 'videomae' ? 8 : 60,
        1,
        target === 'videomae' ? 200 : 300
      )
      const historyRuntime = trainingEstimateRuntimeFromHistory(entry)
      const historyRuntimeFactor = trainingRuntimeFactorForTarget(historyRuntime, target)
      const sampleFactor = clampTrainingEstimate(
        Math.pow(currentSamples / historySamples, 0.85),
        0.55,
        2.8
      )
      const epochFactor = clampTrainingEstimate(currentEpochs / historyEpochs, 0.35, 4)
      const runtimeFactor = historyRuntimeFactor > 0
        ? clampTrainingEstimate(currentRuntimeFactor / historyRuntimeFactor, 0.45, 2.4)
        : 1

      return duration * sampleFactor * epochFactor * runtimeFactor
    })
    .filter((value) => Number.isFinite(value) && value > 0)
    .sort((a, b) => a - b)

  if (estimates.length === 0) return 0

  return estimates[Math.floor(estimates.length / 2)]
}

function estimateTrainingDurationMs(
  target: TrainingTargetKey,
  trainCount: number,
  valCount: number,
  eligibleCount = 0,
  historyEstimateMs = 0,
  runtimeFactor = 1,
  detectorEpochs = 60,
  fineTuneFactor = 1
) {
  const samples = Math.max(
    0,
    Number.isFinite(trainCount) ? trainCount : 0,
  ) + Math.max(
    0,
    Number.isFinite(valCount) ? valCount : 0,
  )
  const eligible = Math.max(0, Number.isFinite(eligibleCount) ? eligibleCount : 0)
  const historyEstimate = Number.isFinite(historyEstimateMs)
    ? Math.max(0, historyEstimateMs)
    : 0
  const factor = Number.isFinite(runtimeFactor) && runtimeFactor > 0
    ? runtimeFactor
    : 1
  const fineTune = Number.isFinite(fineTuneFactor) && fineTuneFactor > 0
    ? clampTrainingEstimate(fineTuneFactor, 0.5, 1)
    : 1
  const epochs = trainingEstimateInt(detectorEpochs, 60, 1, 300)
  const yoloEpochFactor = Math.max(0.5, epochs / 60)

  switch (target) {
    case 'detector': {
      const base = Math.max(
        12 * 60 * 1000,
        (10 * 60 * 1000 + samples * 8500) * yoloEpochFactor
      )
      return Math.max(base * factor * fineTune, historyEstimate)
    }
    case 'pose': {
      const base = Math.max(
        14 * 60 * 1000,
        (12 * 60 * 1000 + samples * 9500) * yoloEpochFactor
      )
      return Math.max(base * factor * fineTune, historyEstimate)
    }
    case 'videomae': {
      const base = Math.max(
        25 * 60 * 1000,
        18 * 60 * 1000 + Math.max(samples, eligible) * 16000
      )
      return Math.max(base * factor * fineTune, historyEstimate)
    }
    default:
      return 0
  }
}

function countPercent(count: number, total: number) {
  if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) return '0%'
  return `${Math.round((count / total) * 100)}%`
}

function confidencePercent(confidence?: TrainingConfidence | null) {
  const score = Number(confidence?.score)
  if (!Number.isFinite(score)) return '0%'
  return `${Math.round(clamp01(score) * 100)}%`
}

function confidenceLabel(confidence?: TrainingConfidence | null) {
  return confidence?.label || 'Keine'
}

function formatModelTrainedAt(info?: TrainingModelInfo): string {
  if (!info) return ''

  const ms = Number(info.trainedAtMs)
  const date =
    Number.isFinite(ms) && ms > 0
      ? new Date(ms)
      : info.trainedAt
        ? new Date(info.trainedAt)
        : null

  if (!date || Number.isNaN(date.getTime())) return ''

  return date.toLocaleString('de-DE', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  })
}

function formatMapPercent(value?: number | null): string {
  const n = Number(value)
  if (!Number.isFinite(n) || n <= 0) return ''
  return `${(n * 100).toFixed(1)}%`
}

function formatTrainingModelDetails(info?: TrainingModelInfo): string {
  if (!info) return ''

  const parts: string[] = []
  if (Number(info.epochs) > 0) parts.push(`${info.epochs} Epochen`)
  if (Number(info.trainSamples) > 0) parts.push(`${info.trainSamples} Train`)
  if (Number(info.valSamples) > 0) parts.push(`${info.valSamples} Val`)
  if (String(info.device || '').trim()) parts.push(String(info.device).trim())

  return parts.join(' | ')
}

function parseTrainingModelInfo(value: unknown): TrainingModelInfo | undefined {
  if (!value || typeof value !== 'object') return undefined

  const raw = value as Record<string, unknown>

  return {
    trainedAt: typeof raw.trainedAt === 'string' ? raw.trainedAt : undefined,
    trainedAtMs: Number(raw.trainedAtMs ?? 0),
    epochs: Number(raw.epochs ?? 0),
    trainSamples: Number(raw.trainSamples ?? 0),
    valSamples: Number(raw.valSamples ?? 0),
    imgsz: Number(raw.imgsz ?? 0),
    device: typeof raw.device === 'string' ? raw.device : undefined,
    map50: Number(raw.map50 ?? 0),
    map5095: Number(raw.map5095 ?? 0),
  }
}

function formatHistoryDate(entry: TrainingHistoryEntry): string {
  const ms = Number(entry.trainedAtMs)
  const date =
    Number.isFinite(ms) && ms > 0
      ? new Date(ms)
      : entry.trainedAt
        ? new Date(entry.trainedAt)
        : null

  if (!date || Number.isNaN(date.getTime())) return '—'

  return date.toLocaleString('de-DE', {
    day: '2-digit',
    month: '2-digit',
    year: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  })
}

function confidencePillClass(confidence?: TrainingConfidence | null) {
  switch (confidence?.level) {
    case 'high':
      return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
    case 'mid':
      return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
    case 'low':
      return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
    default:
      return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
  }
}

function averageCategoryConfidence(values: TrainingLabelStat[]) {
  const scores = values
    .map((item) => Number(item.confidence?.score))
    .filter((score) => Number.isFinite(score))

  if (scores.length === 0) {
    return undefined
  }

  const avg = scores.reduce((sum, score) => sum + clamp01(score), 0) / scores.length

  return confidenceFromScore(avg)
}

function confidenceFromScore(score: number): TrainingConfidence {
  const safeScore = clamp01(Number(score))

  if (safeScore >= 0.75) {
    return {
      score: safeScore,
      level: 'high',
      label: 'Hoch',
    }
  }

  if (safeScore >= 0.45) {
    return {
      score: safeScore,
      level: 'mid',
      label: 'Mittel',
    }
  }

  if (safeScore > 0) {
    return {
      score: safeScore,
      level: 'low',
      label: 'Niedrig',
    }
  }

  return {
    score: 0,
    level: 'none',
    label: 'Keine',
  }
}

const NO_SEX_POSITION_LABEL = 'keine'
const NO_SEX_POSITION_ALIASES = new Set([
  '',
  NO_SEX_POSITION_LABEL,
])

function normalizeSexPositionValue(value?: string | null) {
  const clean = String(value ?? '').trim()
  return NO_SEX_POSITION_ALIASES.has(clean.toLowerCase())
    ? NO_SEX_POSITION_LABEL
    : clean
}

function isNoSexPositionValue(value?: string | null) {
  return NO_SEX_POSITION_ALIASES.has(String(value ?? '').trim().toLowerCase())
}

function normalizeSexPositionValues(values?: string[]) {
  return uniqStrings(
    (values ?? []).map((value) => normalizeSexPositionValue(value))
  )
}

function currentAnalysisConfidence(prediction?: TrainingPrediction | null): TrainingConfidence {
  if (!prediction?.modelAvailable) {
    return confidenceFromScore(0)
  }

  const scores: number[] = []

  const addScore = (value: unknown) => {
    const n = Number(value)
    if (!Number.isFinite(n)) return
    if (n <= 0) return

    scores.push(clamp01(n))
  }

  // Pose-Modell: Sexposition als Sample-Label.
  if (
    prediction.sexPosition &&
    !isNoSexPositionValue(prediction.sexPosition)
  ) {
    addScore(prediction.sexPositionScore)
  }

  // Detector: sichtbare Boxen sind der wichtigste Signalträger.
  for (const box of prediction.boxes ?? []) {
    addScore(box.score)
  }

  // Fallback, falls Labels Scores haben, aber keine Boxen vorhanden sind.
  if ((prediction.boxes ?? []).length === 0) {
    for (const item of prediction.bodyPartsPresent ?? []) {
      addScore(item.score)
    }

    for (const item of prediction.objectsPresent ?? []) {
      addScore(item.score)
    }

    for (const item of prediction.clothingPresent ?? []) {
      addScore(item.score)
    }
  }

  if (scores.length === 0) {
    return confidenceFromScore(0)
  }

  const avg = scores.reduce((sum, score) => sum + score, 0) / scores.length

  return confidenceFromScore(avg)
}

const emptyLabels: TrainingLabels = {
  people: [],
  sexPositions: [NO_SEX_POSITION_LABEL],
  bodyParts: [],
  objects: [],
  clothing: [],
}

function percent(v: number) {
  if (!Number.isFinite(v)) return '—'
  return `${Math.round(v * 100)}%`
}

function scoreLevel(score?: number | null): 'none' | 'low' | 'mid' | 'high' {
  const n = Number(score)

  if (!Number.isFinite(n)) return 'none'
  if (n < 0.5) return 'low'
  if (n < 0.75) return 'mid'
  return 'high'
}

function scoreBorderClass(score?: number | null, opts?: { draft?: boolean }) {
  if (opts?.draft) return 'border-amber-400'

  switch (scoreLevel(score)) {
    case 'low':
      return 'border-red-500'
    case 'mid':
      return 'border-yellow-400'
    case 'high':
      return 'border-emerald-400'
    default:
      return 'border-gray-300'
  }
}

function scoreRingClass(score?: number | null, opts?: { draft?: boolean }) {
  if (opts?.draft) return 'ring-amber-500'

  switch (scoreLevel(score)) {
    case 'low':
      return 'ring-red-500'
    case 'mid':
      return 'ring-yellow-400'
    case 'high':
      return 'ring-emerald-500'
    default:
      return 'ring-gray-400'
  }
}

function scoreDetectionPillClass(score?: number | null) {
  switch (scoreLevel(score)) {
    case 'low':
      return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
    case 'mid':
      return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
    case 'high':
      return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
    default:
      return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
  }
}

function detectorBoxAppearance(label: string) {
  const clean = String(label || '').trim()

  if (clean === 'person_female' || clean === 'female_person') {
    return {
      activeSurface:
        'dark:bg-pink-500/10 dark:shadow-[0_0_0_1px_rgba(244,114,182,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
      idleHover: 'dark:hover:bg-pink-500/[0.05]',
      line: 'bg-pink-400',
      lineHover: 'dark:group-hover:bg-pink-400/70',
      iconActive:
        'dark:bg-pink-500/15 dark:text-pink-100 dark:ring-pink-400/25',
      iconIdle:
        'dark:text-pink-200/80 dark:group-hover:bg-pink-500/10 dark:group-hover:text-pink-100 dark:group-hover:ring-pink-400/20',
      selectedText: 'dark:text-pink-300',
    }
  }

  if (clean === 'person_male' || clean === 'male_person') {
    return {
      activeSurface:
        'dark:bg-sky-500/10 dark:shadow-[0_0_0_1px_rgba(56,189,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
      idleHover: 'dark:hover:bg-sky-500/[0.05]',
      line: 'bg-sky-400',
      lineHover: 'dark:group-hover:bg-sky-400/70',
      iconActive:
        'dark:bg-sky-500/15 dark:text-sky-100 dark:ring-sky-400/25',
      iconIdle:
        'dark:text-sky-200/80 dark:group-hover:bg-sky-500/10 dark:group-hover:text-sky-100 dark:group-hover:ring-sky-400/20',
      selectedText: 'dark:text-sky-300',
    }
  }

  return {
    activeSurface:
      'dark:bg-indigo-500/10 dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
    idleHover: 'dark:hover:bg-indigo-500/[0.05]',
    line: 'bg-indigo-400',
    lineHover: 'dark:group-hover:bg-indigo-400/70',
    iconActive:
      'dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/25',
    iconIdle:
      'dark:text-indigo-200/80 dark:group-hover:bg-indigo-500/10 dark:group-hover:text-indigo-100 dark:group-hover:ring-indigo-400/20',
    selectedText: 'dark:text-indigo-300',
  }
}

function normalizeMovedBox(box: TrainingBox): TrainingBox {
  const w = clamp01(box.w)
  const h = clamp01(box.h)

  return {
    ...box,
    x: Math.max(0, Math.min(1 - w, Number(box.x) || 0)),
    y: Math.max(0, Math.min(1 - h, Number(box.y) || 0)),
    w,
    h,
  }
}

function clampPercent(v: number) {
  if (!Number.isFinite(v)) return 0
  return Math.max(0, Math.min(100, v))
}

function toggleArrayValue(arr: string[], value: string) {
  return arr.includes(value)
    ? arr.filter((x) => x !== value)
    : [...arr, value]
}

function clamp01(v: number) {
  if (!Number.isFinite(v)) return 0
  return Math.max(0, Math.min(1, v))
}

function snap01(v: number, epsilon = 0.006) {
  const n = clamp01(v)

  if (n <= epsilon) return 0
  if (n >= 1 - epsilon) return 1

  return n
}

function normalizeBox(box: TrainingBox): TrainingBox {
  const x = clamp01(box.x)
  const y = clamp01(box.y)
  const w = clamp01(Math.min(box.w, 1 - x))
  const h = clamp01(Math.min(box.h, 1 - y))

  return {
    ...box,
    x,
    y,
    w,
    h,
  }
}

function boxGeometryChanged(a: TrainingBox, b: TrainingBox) {
  const epsilon = 0.0005

  return (
    Math.abs(a.x - b.x) > epsilon ||
    Math.abs(a.y - b.y) > epsilon ||
    Math.abs(a.w - b.w) > epsilon ||
    Math.abs(a.h - b.h) > epsilon
  )
}

function boxVisualStateChanged(a: TrainingBox, b: TrainingBox) {
  return (
    a.label !== b.label ||
    !Object.is(a.score, b.score) ||
    boxGeometryChanged(a, b)
  )
}

function markBoxCorrected(box: TrainingBox): TrainingBox {
  const { score: _oldScore, ...boxWithoutScore } = box
  return boxWithoutScore
}

function uniqStrings(values: string[]) {
  const seen = new Set<string>()
  const out: string[] = []

  for (const value of values) {
    const clean = String(value || '').trim()
    if (!clean || seen.has(clean)) continue
    seen.add(clean)
    out.push(clean)
  }

  return out
}

function peopleLabelsFromBoxes(boxes: TrainingBox[], labels: TrainingLabels) {
  return uniqStrings(
    boxes
      .map((box) => String(box.label || '').trim())
      .filter((label) => labels.people.includes(label))
  )
}

function normalizePosePerson(person: TrainingPosePerson): TrainingPosePerson | null {
  const box = normalizeBox(person.box)
  if (!box) return null

  const keypoints = (person.keypoints ?? [])
    .map((point) => ({
      name: poseKeypointId(point.name),
      x: clamp01(Number(point.x)),
      y: clamp01(Number(point.y)),
      conf: clamp01(Number(point.conf ?? 1)),
    }))
    .filter((point) => point.name)

  return {
    label: String(person.label || '').trim() || 'person',
    score: clamp01(Number(person.score ?? 1)),
    box,
    keypoints,
    quality: typeof person.quality === 'number' ? clamp01(Number(person.quality)) : undefined,
    visibleKeypoints:
      typeof person.visibleKeypoints === 'number'
        ? Math.max(0, Math.round(Number(person.visibleKeypoints)))
        : undefined,
    reliable: typeof person.reliable === 'boolean' ? person.reliable : undefined,
  }
}

function normalizePosePersons(persons: TrainingPosePerson[] | undefined): TrainingPosePerson[] {
  return (persons ?? [])
    .map(normalizePosePerson)
    .filter((person): person is TrainingPosePerson => Boolean(person))
}

function clonePosePersons(persons: TrainingPosePerson[] | undefined): TrainingPosePerson[] {
  return normalizePosePersons(persons).map((person) => ({
    ...person,
    box: { ...person.box },
    keypoints: person.keypoints.map((point) => ({ ...point })),
  }))
}

function createEmptyPosePerson(): TrainingPosePerson {
  return updatePosePersonQuality({
    label: 'person',
    score: 1,
    box: {
      label: 'person',
      score: 1,
      x: 0.32,
      y: 0.06,
      w: 0.36,
      h: 0.88,
    },
    keypoints: [],
  })
}

function poseBoxFromKeypoints(person: TrainingPosePerson): TrainingBox {
  const points = (person.keypoints ?? []).filter((point) =>
    Number.isFinite(point.x) &&
    Number.isFinite(point.y) &&
    clamp01(Number(point.conf ?? 1)) >= POSE_KEYPOINT_MIN_CONFIDENCE
  )

  if (points.length === 0) {
    return normalizeBox(person.box) ?? person.box
  }

  const margin = 0.025
  const minX = Math.min(...points.map((point) => clamp01(Number(point.x))))
  const minY = Math.min(...points.map((point) => clamp01(Number(point.y))))
  const maxX = Math.max(...points.map((point) => clamp01(Number(point.x))))
  const maxY = Math.max(...points.map((point) => clamp01(Number(point.y))))
  const left = clamp01(minX - margin)
  const top = clamp01(minY - margin)
  const right = clamp01(maxX + margin)
  const bottom = clamp01(maxY + margin)

  return normalizeBox({
    label: person.box?.label || person.label || 'person',
    score: person.box?.score,
    x: left,
    y: top,
    w: right - left,
    h: bottom - top,
  }) ?? person.box
}

function updatePosePersonQuality(person: TrainingPosePerson): TrainingPosePerson {
  const visibleKeypoints = posePersonVisibleKeypoints(person)
  const quality = posePersonQuality(person)

  return {
    ...person,
    visibleKeypoints,
    quality,
    reliable:
      clamp01(Number(person.score ?? 0)) >= POSE_RELIABLE_MIN_SCORE &&
      visibleKeypoints >= POSE_RELIABLE_MIN_KEYPOINTS &&
      quality >= POSE_RELIABLE_MIN_QUALITY,
  }
}

function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
  const p = sample?.prediction

  const boxes = (p?.boxes ?? [])
    .map((box) => ({
      label: String(box.label || '').trim(),
      score: box.score,
      x: clamp01(Number(box.x)),
      y: clamp01(Number(box.y)),
      w: clamp01(Number(box.w)),
      h: clamp01(Number(box.h)),
    }))
    .filter((box) => box.label && box.w > 0 && box.h > 0)

  return {
    sexPosition: normalizeSexPositionValue(p?.sexPosition),
    peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label),
    bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
    objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
    clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
    boxes,
    posePersons: clonePosePersons(p?.persons),
  }
}

function cloneCorrectionState(value: CorrectionState): CorrectionState {
  return {
    sexPosition: value.sexPosition,
    peoplePresent: [...value.peoplePresent],
    bodyPartsPresent: [...value.bodyPartsPresent],
    objectsPresent: [...value.objectsPresent],
    clothingPresent: [...value.clothingPresent],
    boxes: (value.boxes ?? []).map((box) => ({ ...box })),
    posePersons: clonePosePersons(value.posePersons),
  }
}

function correctionHasTrainablePositionOrBoxes(value: CorrectionState) {
  return (
    (value.sexPosition && !isNoSexPositionValue(value.sexPosition)) ||
    (value.boxes ?? []).some((box) => {
      const normalized = normalizeBox(box)
      return Boolean(normalized.label && normalized.w > 0 && normalized.h > 0)
    })
  )
}

function applyBoxLabelToCorrection(
  state: CorrectionState,
  label: string,
  labels: TrainingLabels
): CorrectionState {
  const clean = String(label || '').trim()
  if (!clean) return state

  if (labels.people.includes(clean)) {
    return {
      ...state,
      peoplePresent: state.peoplePresent.includes(clean)
        ? state.peoplePresent
        : [...state.peoplePresent, clean],
    }
  }

  if (labels.bodyParts.includes(clean)) {
    return {
      ...state,
      bodyPartsPresent: state.bodyPartsPresent.includes(clean)
        ? state.bodyPartsPresent
        : [...state.bodyPartsPresent, clean],
    }
  }

  if (labels.objects.includes(clean)) {
    return {
      ...state,
      objectsPresent: state.objectsPresent.includes(clean)
        ? state.objectsPresent
        : [...state.objectsPresent, clean],
    }
  }

  if (labels.clothing.includes(clean)) {
    return {
      ...state,
      clothingPresent: state.clothingPresent.includes(clean)
        ? state.clothingPresent
        : [...state.clothingPresent, clean],
    }
  }

  return state
}

function removeBoxLabelFromCorrection(
  state: CorrectionState,
  label: string,
  labels: TrainingLabels
): CorrectionState {
  const clean = String(label || '').trim()
  if (!clean) return state

  const remainingBoxesWithSameLabel = (state.boxes ?? []).some(
    (box) => String(box.label || '').trim() === clean
  )

  // Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert.
  if (remainingBoxesWithSameLabel) return state

  if (labels.people.includes(clean)) {
    return {
      ...state,
      peoplePresent: state.peoplePresent.filter((x) => x !== clean),
    }
  }

  if (labels.bodyParts.includes(clean)) {
    return {
      ...state,
      bodyPartsPresent: state.bodyPartsPresent.filter((x) => x !== clean),
    }
  }

  if (labels.objects.includes(clean)) {
    return {
      ...state,
      objectsPresent: state.objectsPresent.filter((x) => x !== clean),
    }
  }

  if (labels.clothing.includes(clean)) {
    return {
      ...state,
      clothingPresent: state.clothingPresent.filter((x) => x !== clean),
    }
  }

  return state
}

function removeBoxFromCorrection(
  state: CorrectionState,
  index: number,
  labels: TrainingLabels
): CorrectionState {
  const boxes = state.boxes ?? []
  const removed = boxes[index]

  if (!removed) return state

  const removedLabel = String(removed.label || '').trim()

  const next: CorrectionState = {
    ...state,
    boxes: boxes.filter((_, i) => i !== index),
  }

  return removeBoxLabelFromCorrection(next, removedLabel, labels)
}

function changeBoxLabelInCorrection(
  state: CorrectionState,
  index: number,
  nextLabel: string,
  labels: TrainingLabels
): CorrectionState {
  const boxes = state.boxes ?? []
  const currentBox = boxes[index]
  const cleanNextLabel = String(nextLabel || '').trim()

  if (!currentBox || !cleanNextLabel) return state

  const oldLabel = String(currentBox.label || '').trim()
  if (oldLabel === cleanNextLabel) return state

  let next: CorrectionState = {
    ...state,
    boxes: boxes.map((box, i) => {
      if (i !== index) return box

      const { score: _oldScore, ...boxWithoutScore } = box

      return {
        ...boxWithoutScore,
        label: cleanNextLabel,
      }
    }),
  }

  next = removeBoxLabelFromCorrection(next, oldLabel, labels)
  next = applyBoxLabelToCorrection(next, cleanNextLabel, labels)

  return next
}

function sortLabelList(values?: string[], opts?: { keepNoPositionFirst?: boolean }) {
  const list = [...(values ?? [])].sort((a, b) =>
    a.localeCompare(b, undefined, { sensitivity: 'base' })
  )

  if (!opts?.keepNoPositionFirst) return list

  return [
    ...list.filter((x) => isNoSexPositionValue(x)),
    ...list.filter((x) => !isNoSexPositionValue(x)),
  ]
}

function sortTrainingLabels(input: Partial<TrainingLabels> | null | undefined): TrainingLabels {
  return {
    people: sortLabelList(input?.people),
    sexPositions: sortLabelList(
      normalizeSexPositionValues(input?.sexPositions),
      { keepNoPositionFirst: true }
    ),
    bodyParts: sortLabelList(input?.bodyParts),
    objects: sortLabelList(input?.objects),
    clothing: sortLabelList(input?.clothing),
  }
}

function TrainingStageOverlay(props: {
  mode: 'training' | 'analysis' | 'saving'
  title?: string
  text?: string
  sourceFile?: string
  frameLabel?: string
  statusText?: string
  progress?: number
  backgroundUrl?: string
  visible?: boolean
  instantBackground?: boolean
}) {
  const progress = clampPercent(props.progress ?? 0)
  const isTraining = props.mode === 'training'
  const isSaving = props.mode === 'saving'
  const visible = props.visible ?? true

  const [displayedBackgroundUrl, setDisplayedBackgroundUrl] = useState('')
  const [incomingBackgroundUrl, setIncomingBackgroundUrl] = useState('')
  const [incomingBackgroundVisible, setIncomingBackgroundVisible] = useState(false)
  const latestBackgroundUrlRef = useRef('')
  const backgroundFadeTimerRef = useRef<number | null>(null)
  const backgroundFadeMs = props.instantBackground ? 200 : 500

  const clearBackgroundFadeTimer = useCallback(() => {
    if (backgroundFadeTimerRef.current === null) return

    window.clearTimeout(backgroundFadeTimerRef.current)
    backgroundFadeTimerRef.current = null
  }, [])

  useEffect(() => {
    latestBackgroundUrlRef.current = props.backgroundUrl || ''
  }, [props.backgroundUrl])

  useEffect(() => {
    return () => clearBackgroundFadeTimer()
  }, [clearBackgroundFadeTimer])

  useEffect(() => {
    const nextUrl = props.backgroundUrl || ''

    if (!nextUrl) {
      clearBackgroundFadeTimer()
      setDisplayedBackgroundUrl('')
      setIncomingBackgroundUrl('')
      setIncomingBackgroundVisible(false)
      return
    }

    if (!displayedBackgroundUrl) {
      clearBackgroundFadeTimer()
      setDisplayedBackgroundUrl(nextUrl)
      setIncomingBackgroundUrl('')
      setIncomingBackgroundVisible(false)
      return
    }

    if (nextUrl === displayedBackgroundUrl || nextUrl === incomingBackgroundUrl) {
      return
    }

    clearBackgroundFadeTimer()
    setIncomingBackgroundUrl(nextUrl)
    setIncomingBackgroundVisible(false)
  }, [
    clearBackgroundFadeTimer,
    displayedBackgroundUrl,
    incomingBackgroundUrl,
    props.backgroundUrl,
  ])

  const finishIncomingBackground = useCallback((loadedUrl: string) => {
    if (!loadedUrl || latestBackgroundUrlRef.current !== loadedUrl) return

    setIncomingBackgroundVisible(true)
    clearBackgroundFadeTimer()

    backgroundFadeTimerRef.current = window.setTimeout(() => {
      backgroundFadeTimerRef.current = null

      if (latestBackgroundUrlRef.current !== loadedUrl) return

      setDisplayedBackgroundUrl(loadedUrl)
      setIncomingBackgroundUrl((current) => (
        current === loadedUrl ? '' : current
      ))
      setIncomingBackgroundVisible(false)
    }, backgroundFadeMs)
  }, [backgroundFadeMs, clearBackgroundFadeTimer])

  const hasBackground = Boolean(displayedBackgroundUrl || incomingBackgroundUrl)
  const backgroundTransitionClass = props.instantBackground
    ? 'transition-opacity duration-200 ease-out will-change-opacity motion-reduce:transition-none'
    : 'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none'

  const title = props.title || (
    isTraining
      ? 'Training läuft…'
      : isSaving
        ? 'Speichert…'
        : 'Analyse läuft…'
  )
  const fallbackText = isTraining
    ? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'
    : isSaving
      ? 'Feedback wird gespeichert. Bitte warten.'
      : 'Bild wird erstellt und analysiert. Bitte warten.'
  const sourceFile = String(props.sourceFile || '').trim()
  const frameLabel = String(props.frameLabel || '').trim()
  const statusText = String(props.statusText || props.text || fallbackText).trim()
  const hasStructuredDetails = Boolean(sourceFile || frameLabel)
  const primaryText = hasStructuredDetails ? statusText : title
  const secondaryText = hasStructuredDetails ? sourceFile : statusText

  return (
    <div
      className={[
        'absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black px-3 text-center text-white',
        'transition-opacity duration-300 ease-out will-change-opacity motion-reduce:transition-none',
        visible ? 'opacity-100' : 'pointer-events-none opacity-0',
      ].join(' ')}
    >
      <div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2">
        {hasBackground ? (
          <>
            {displayedBackgroundUrl ? (
              <img
                src={displayedBackgroundUrl}
                alt=""
                aria-hidden="true"
                draggable={false}
                decoding="async"
                loading="eager"
                className="absolute inset-0 z-0 h-full w-full object-contain opacity-80 blur-[1px]"
              />
            ) : null}

            {incomingBackgroundUrl ? (
              <img
                src={incomingBackgroundUrl}
                alt=""
                aria-hidden="true"
                draggable={false}
                decoding="async"
                loading="eager"
                onLoad={() => finishIncomingBackground(incomingBackgroundUrl)}
                onError={() => {
                  if (latestBackgroundUrlRef.current !== incomingBackgroundUrl) return

                  setIncomingBackgroundUrl('')
                  setIncomingBackgroundVisible(false)
                }}
                className={[
                  'absolute inset-0 z-[1] h-full w-full object-contain blur-[1px]',
                  backgroundTransitionClass,
                  incomingBackgroundVisible ? 'opacity-80' : 'opacity-0',
                ].join(' ')}
              />
            ) : null}
          </>
        ) : null}

        <div
          className={[
            'absolute inset-0 z-[1] rounded-md',
            hasBackground
              ? 'bg-black/30 backdrop-blur-[4px] shadow-[inset_0_0_48px_18px_rgba(0,0,0,0.55)]'
              : 'bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]',
          ].join(' ')}
        />
      </div>

      <div className="relative z-10 flex w-[min(90%,22rem)] flex-col items-center justify-center text-center text-white">
        <LoadingSpinner
          size="md"
          className="text-white drop-shadow-[0_1px_5px_rgba(0,0,0,0.85)]"
          srLabel={title}
        />

        <div className="mt-2 max-w-full truncate text-sm font-semibold leading-5 text-white drop-shadow-[0_1px_5px_rgba(0,0,0,0.9)]">
          {primaryText}
        </div>

        {secondaryText ? (
          <div
            className="mt-1 flex max-w-full items-center justify-center gap-1.5"
            title={secondaryText}
          >
            <div className="min-w-0 truncate text-[11px] font-semibold leading-4 text-white/75 drop-shadow-[0_1px_4px_rgba(0,0,0,0.9)]">
              {secondaryText}
            </div>

            {frameLabel ? (
              <div className="shrink-0 rounded-full bg-emerald-500/28 px-2 py-0.5 text-[10px] font-bold leading-3 text-emerald-50 shadow-md ring-1 ring-emerald-200/25 backdrop-blur-sm">
                {frameLabel}
              </div>
            ) : null}
          </div>
        ) : frameLabel ? (
          <div className="mt-1 flex max-w-full items-center justify-center gap-1.5">
            <div className="min-w-0 truncate text-[11px] font-semibold leading-4 text-white/75 drop-shadow-[0_1px_4px_rgba(0,0,0,0.9)]">
              Frame
            </div>

            <div className="shrink-0 rounded-full bg-emerald-500/28 px-2 py-0.5 text-[10px] font-bold leading-3 text-emerald-50 shadow-md ring-1 ring-emerald-200/25 backdrop-blur-sm">
              {frameLabel}
            </div>
          </div>
        ) : null}

        <div className="mt-3 flex w-52 max-w-[82%] items-center gap-2">
          <div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-white/25 shadow-[0_1px_6px_rgba(0,0,0,0.45)]">
            <div
              className={[
                'h-full rounded-full transition-all duration-500',
                isTraining ? 'bg-indigo-400' : 'bg-emerald-400',
              ].join(' ')}
              style={{ width: `${progress}%` }}
            />
          </div>

          <div className="w-9 shrink-0 text-right text-[11px] font-semibold tabular-nums text-white/75 drop-shadow-[0_1px_4px_rgba(0,0,0,0.85)]">
            {Math.round(progress)}%
          </div>
        </div>
      </div>
    </div>
  )
}

function compactTrainingSourceFile(sourceFile: string) {
  let cleanSourceFile = String(sourceFile || '').trim()
  let frameLabel = ''

  const sourceFrameMatch = cleanSourceFile.match(/^(.*?)\s*\((\d+)\s*\/\s*(\d+)\)\s*$/)

  if (sourceFrameMatch) {
    cleanSourceFile = sourceFrameMatch[1].trim()
    frameLabel = `${sourceFrameMatch[2]} / ${sourceFrameMatch[3]}`
  }

  return {
    sourceFile: cleanSourceFile,
    frameLabel,
  }
}

function withTrainingFrameLabels(samples: TrainingSample[]) {
  if (samples.length <= 1) return samples

  return samples.map((sample, index) => {
    const sourceDetails = compactTrainingSourceFile(sample.sourceFile)
    const sourceFile = sourceDetails.sourceFile || String(sample.sourceFile || '').trim()

    if (!sourceFile || sourceDetails.frameLabel) {
      return sample
    }

    return {
      ...sample,
      sourceFile: `${sourceFile} (${index + 1} / ${samples.length})`,
    }
  })
}

function formatTrainingStageStatus(value: string) {
  const text = String(value || '').trim()

  if (!text) return text

  return text.charAt(0).toLocaleUpperCase('de-DE') + text.slice(1)
}

function compactTrainingStageDetails(sourceFile: string, stepText: string) {
  const sourceDetails = compactTrainingSourceFile(sourceFile)
  let cleanSourceFile = sourceDetails.sourceFile
  let frameLabel = sourceDetails.frameLabel
  let statusText = String(stepText || '').trim()

  const stepFrameMatch = statusText.match(/^Frame\s+(\d+)\s*\/\s*(\d+)\s+(.+)$/i)

  if (stepFrameMatch) {
    if (!frameLabel) {
      frameLabel = `${stepFrameMatch[1]} / ${stepFrameMatch[2]}`
    }

    statusText = stepFrameMatch[3].trim()
  }

  return {
    sourceFile: cleanSourceFile,
    frameLabel,
    statusText: formatTrainingStageStatus(statusText || 'Bild wird geladen…'),
  }
}

function labelTileClass(active: boolean) {
  return [
    'group flex min-h-[58px] w-full flex-col items-center justify-center gap-1 rounded-xl px-2 py-1.5 text-center text-[10px] font-semibold leading-tight ring-1 transition sm:min-h-[74px] sm:py-2',
    'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900',
    active
      ? [
          'bg-indigo-100 text-indigo-900 ring-2 ring-indigo-500 shadow-sm',
          'hover:bg-indigo-200',
          'dark:bg-indigo-500/30 dark:text-indigo-50 dark:ring-indigo-300/70',
          'dark:hover:bg-indigo-500/40',
        ].join(' ')
      : [
          'bg-white text-gray-700 ring-gray-200 hover:bg-gray-50 hover:text-gray-900',
          'dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white',
        ].join(' '),
  ].join(' ')
}

function LabelToggleGrid(props: {
  values: string[]
  selected: string[]
  scores?: Record<string, number>
  activeCounts?: Record<string, number>
  onToggle: (value: string) => void
  drawLabel?: string
  onDrawLabelChange?: (value: string) => void
  disabled?: boolean
  gridClassName?: string
}) {
  if (props.values.length === 0) {
    return (
      <div className="rounded-lg bg-gray-50 px-3 py-2 text-[11px] text-gray-500 ring-1 ring-black/5 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
        Keine Einträge verfügbar.
      </div>
    )
  }

  return (
    <div className={props.gridClassName || 'grid grid-cols-2 gap-2 sm:grid-cols-3'}>
      {props.values.map((value) => {
        const activeCount = Math.max(0, Math.floor(Number(props.activeCounts?.[value] ?? 0)))
        const active = props.selected.includes(value) || activeCount > 0
        const item = getSegmentLabelItem(value)
        const Icon = item.icon
        const score = props.scores?.[value]
        const hasScore = typeof score === 'number' && Number.isFinite(score)
        const isDrawLabel = props.drawLabel === value

        return (
          <button
            key={value}
            type="button"
            aria-pressed={active || isDrawLabel}
            title={
              hasScore
                ? `${value} ${percent(score)}`
                : isDrawLabel
                  ? `${value} zum Zeichnen ausgewählt`
                  : value
            }
            disabled={props.disabled}
            onClick={() => {
              if (props.onDrawLabelChange) {
                props.onDrawLabelChange(value)
                return
              }

              props.onToggle(value)
            }}
            className={[
              labelTileClass(active || isDrawLabel),
              props.disabled ? 'cursor-not-allowed opacity-50' : '',
            ].join(' ')}
          >
            <Icon
              className={[
                'h-5 w-5 transition sm:h-6 sm:w-6',
                active
                  ? 'text-indigo-700 dark:text-indigo-100'
                  : 'text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200',
              ].join(' ')}
              aria-hidden="true"
            />

            <span className="line-clamp-2 max-w-full break-words">
              {item.text}
            </span>

            {activeCount > 0 ? (
              <span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
                {activeCount === 1 ? '1 Box' : `${activeCount} Boxen`}
              </span>
            ) : hasScore ? (
              <span
                className={[
                  'mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
                  scoreDetectionPillClass(score),
                ].join(' ')}
              >
                {percent(score)}
              </span>
            ) : isDrawLabel ? (
              <span className="mt-0.5 rounded-full bg-amber-50 px-1.5 py-0.5 text-[9px] font-bold text-amber-700 ring-1 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30">
                zeichnen
              </span>
            ) : active ? (
              <span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
                aktiv
              </span>
            ) : null}
          </button>
        )
      })}
    </div>
  )
}

function DetectorBoxLabelSelect(props: {
  values: string[]
  value: string
  disabled?: boolean
  compact?: boolean
  onChange: (value: string) => void
}) {
  const [open, setOpen] = useState(false)
  const [menuStyle, setMenuStyle] = useState<CSSProperties>({})
  const buttonRef = useRef<HTMLButtonElement | null>(null)

  const selectedItem = getSegmentLabelItem(props.value)
  const SelectedIcon = selectedItem.icon

  const close = useCallback(() => {
    setOpen(false)
  }, [])

  const updateMenuPosition = useCallback(() => {
    const button = buttonRef.current
    if (!button) return

    const rect = button.getBoundingClientRect()
    const viewportH = window.innerHeight
    const viewportW = window.innerWidth
    const spaceBelow = viewportH - rect.bottom
    const spaceAbove = rect.top
    const openUp = spaceBelow < 180 && spaceAbove > spaceBelow
    const maxHeight = openUp
      ? Math.min(260, Math.max(140, spaceAbove - 12))
      : Math.min(260, Math.max(140, spaceBelow - 12))

    setMenuStyle({
      position: 'fixed',
      left: Math.max(8, Math.min(rect.left, viewportW - rect.width - 8)),
      top: openUp ? undefined : rect.bottom + 4,
      bottom: openUp ? viewportH - rect.top + 4 : undefined,
      width: rect.width,
      maxHeight,
      zIndex: 2147483647,
    })
  }, [])

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

    updateMenuPosition()

    const onPointerDown = (e: PointerEvent) => {
      const target = e.target as HTMLElement | null
      if (target?.closest('[data-detector-label-select="true"]')) return
      close()
    }

    const onUpdate = () => updateMenuPosition()

    window.addEventListener('pointerdown', onPointerDown)
    window.addEventListener('resize', onUpdate)
    window.addEventListener('scroll', onUpdate, true)

    return () => {
      window.removeEventListener('pointerdown', onPointerDown)
      window.removeEventListener('resize', onUpdate)
      window.removeEventListener('scroll', onUpdate, true)
    }
  }, [open, close, updateMenuPosition])

  if (props.values.length === 0) {
    return (
      <button
        type="button"
        disabled
        className="mt-2 flex h-9 w-full items-center rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-400 disabled:cursor-not-allowed dark:border-white/10 dark:bg-gray-950 dark:text-gray-500"
      >
        Keine Box-Labels
      </button>
    )
  }

  return (
    <div
      className={props.compact ? 'mt-1.5' : 'mt-2'}
      data-detector-label-select="true"
      onPointerDown={(e) => e.stopPropagation()}
      onClick={(e) => e.stopPropagation()}
    >
      <button
        ref={buttonRef}
        type="button"
        disabled={props.disabled}
        onClick={() => {
          if (props.disabled) return
          updateMenuPosition()
          setOpen((value) => !value)
        }}
        className={[
          'flex w-full items-center justify-between gap-2 rounded-xl border px-2.5 text-gray-900 shadow-sm outline-none transition',
          'focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:cursor-not-allowed disabled:opacity-50',
          'border-gray-200 bg-white',
          'dark:border-white/10 dark:bg-white/[0.05] dark:text-gray-100 dark:hover:bg-white/[0.07]',
          props.compact ? 'h-8 text-xs' : 'h-9 text-sm',
        ].join(' ')}
        aria-haspopup="listbox"
        aria-expanded={open}
      >
        <span className="flex min-w-0 items-center gap-2">
          <SelectedIcon
            className="h-4 w-4 shrink-0 text-gray-600 dark:text-gray-300"
            aria-hidden="true"
          />

          <span className="truncate">
            {selectedItem.text}
          </span>
        </span>

        <span
          className={[
            'shrink-0 text-sm text-gray-500 transition-transform dark:text-gray-400',
            open ? 'rotate-180' : '',
          ].join(' ')}
          aria-hidden="true"
        >
          ▾
        </span>
      </button>

      {open && typeof document !== 'undefined'
        ? createPortal(
            <div
              role="listbox"
              data-detector-label-select="true"
              style={menuStyle}
              className={[
                'overflow-y-auto rounded-xl border border-gray-200 bg-white py-1 shadow-2xl ring-1 ring-black/10',
                'dark:border-white/10 dark:bg-gray-900 dark:ring-white/15',
              ].join(' ')}
            >
              {props.values.map((value) => {
                const active = value === props.value
                const item = getSegmentLabelItem(value)
                const Icon = item.icon

                return (
                  <button
                    key={value}
                    type="button"
                    role="option"
                    aria-selected={active}
                    onClick={() => {
                      props.onChange(value)
                      setOpen(false)
                    }}
                    className={[
                      'flex w-full items-center gap-2 px-2 py-2 text-left text-sm transition',
                      active
                        ? 'bg-indigo-50 text-indigo-900 dark:bg-indigo-500/20 dark:text-indigo-100'
                        : 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/10 dark:hover:text-white',
                    ].join(' ')}
                  >
                    <Icon
                      className={[
                        'h-4 w-4 shrink-0',
                        active
                          ? 'text-indigo-700 dark:text-indigo-100'
                          : 'text-gray-600 dark:text-gray-300',
                      ].join(' ')}
                      aria-hidden="true"
                    />

                    <span className="min-w-0 flex-1 truncate">
                      {item.text}
                    </span>

                    <span className="shrink-0 text-[10px] text-gray-400">
                      {value}
                    </span>
                  </button>
                )
              })}
            </div>,
            document.body
          )
        : null}
    </div>
  )
}

function CollapsibleSingleLabelSection(props: {
  title: string
  values: string[]
  value: string
  score?: number
  predictionValue?: string
  expanded: boolean
  onExpandedChange: (expanded: boolean) => void
  onChange: (value: string) => void
  disabled?: boolean
  gridClassName?: string
}) {
  const currentValue = normalizeSexPositionValue(props.value)
  const selectedItem = getSegmentLabelItem(currentValue)
  const SelectedIcon = selectedItem.icon
  const shown = props.expanded
  const hasSelection = !isNoSexPositionValue(currentValue)

  return (
    <div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
      <button
        type="button"
        className="flex w-full items-center justify-between gap-2 text-left"
        onClick={() => props.onExpandedChange(!props.expanded)}
        aria-expanded={shown}
      >
        <div className="min-w-0">
          <div className="flex min-w-0 items-center gap-2">
            <SelectedIcon
              className={[
                'h-4 w-4 shrink-0',
                hasSelection
                  ? 'text-indigo-600 dark:text-indigo-300'
                  : 'text-gray-500 dark:text-gray-400',
              ].join(' ')}
              aria-hidden="true"
            />

            <div className="min-w-0 truncate text-xs font-medium text-gray-700 dark:text-gray-200">
              {props.title}
            </div>
          </div>

          <div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400">
            {hasSelection
              ? selectedItem.text
              : shown
                ? 'Zum Einklappen klicken'
                : 'Zum Ausklappen klicken'}
          </div>
        </div>

        <div className="flex shrink-0 items-center gap-2">
          {typeof props.score === 'number' && Number.isFinite(props.score) ? (
            <span
              className={[
                'rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1',
                scoreDetectionPillClass(props.score),
              ].join(' ')}
              title={
                props.predictionValue
                  ? `Modell: ${props.predictionValue}`
                  : 'Modell-Vorhersage'
              }
            >
              {percent(props.score)}
            </span>
          ) : null}

          {hasSelection ? (
            <span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold text-indigo-800 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
              aktiv
            </span>
          ) : null}

          <span
            className={[
              'text-sm text-gray-500 transition-transform dark:text-gray-400',
              shown ? 'rotate-180' : '',
            ].join(' ')}
            aria-hidden="true"
          >
            ▾
          </span>
        </div>
      </button>

      {shown ? (
        <div className="mt-2">
          {props.values.length === 0 ? (
            <div className="rounded-lg bg-gray-50 px-3 py-2 text-[11px] text-gray-500 ring-1 ring-black/5 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
              Keine Einträge verfügbar.
            </div>
          ) : (
            <div className={props.gridClassName || 'grid grid-cols-2 gap-2'}>
              {props.values.map((value) => {
                const active = value === currentValue
                const item = getSegmentLabelItem(value)
                const Icon = item.icon
                const isPrediction = value === props.predictionValue

                return (
                  <button
                    key={value}
                    type="button"
                    aria-pressed={active}
                    title={value}
                    disabled={props.disabled}
                    onClick={() => props.onChange(value)}
                    className={[
                      labelTileClass(active),
                      props.disabled ? 'cursor-not-allowed opacity-50' : '',
                    ].join(' ')}
                  >
                    <Icon
                      className={[
                        'h-5 w-5 transition sm:h-6 sm:w-6',
                        active
                          ? 'text-indigo-700 dark:text-indigo-100'
                          : 'text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200',
                      ].join(' ')}
                      aria-hidden="true"
                    />

                    <span className="line-clamp-2 max-w-full break-words">
                      {item.text}
                    </span>

                    {active ? (
                      <span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
                        aktiv
                      </span>
                    ) : isPrediction && typeof props.score === 'number' ? (
                      <span
                        className={[
                          'mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
                          scoreDetectionPillClass(props.score),
                        ].join(' ')}
                      >
                        Modell {percent(props.score)}
                      </span>
                    ) : null}
                  </button>
                )
              })}
            </div>
          )}
        </div>
      ) : null}
    </div>
  )
}

function CollapsibleLabelSection(props: {
  title: string
  values: string[]
  selected: string[]
  scores?: Record<string, number>
  activeCounts?: Record<string, number>
  expanded: boolean
  onExpandedChange: (expanded: boolean) => void
  onToggle: (value: string) => void
  drawLabel?: string
  onDrawLabelChange?: (value: string) => void
  disabled?: boolean
  singleDrawMode?: boolean
  gridClassName?: string
}) {
  const cleanDrawLabel = String(props.drawLabel || '').trim()
  const hasDrawLabelInSection =
    cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel)

  const countedActiveItems = props.activeCounts
    ? props.values.reduce(
        (sum, value) => sum + Math.max(0, Math.floor(Number(props.activeCounts?.[value] ?? 0))),
        0
      )
    : null

  const activeCount = countedActiveItems !== null
    ? Math.max(countedActiveItems, hasDrawLabelInSection ? 1 : 0)
    : props.singleDrawMode
      ? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0)
      : props.selected.length

  const hasActiveItems = activeCount > 0
  const shown = props.expanded

  return (
    <div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
      <button
        type="button"
        className="flex w-full items-center justify-between gap-2 text-left"
        onClick={() => props.onExpandedChange(!props.expanded)}
        aria-expanded={shown}
      >
        <div className="min-w-0">
          <div className="text-xs font-medium text-gray-700 dark:text-gray-200">
            {props.title}
          </div>

          <div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400">
            {hasActiveItems
              ? `${activeCount} aktiv`
              : shown
                ? 'Zum Einklappen klicken'
                : 'Zum Ausklappen klicken'}
          </div>
        </div>

        <div className="flex shrink-0 items-center gap-2">
          {hasActiveItems ? (
            <span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold text-indigo-800 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
              {activeCount}
            </span>
          ) : null}

          <span
            className={[
              'text-sm text-gray-500 transition-transform dark:text-gray-400',
              shown ? 'rotate-180' : '',
            ].join(' ')}
            aria-hidden="true"
          >
            ▾
          </span>
        </div>
      </button>

      {shown ? (
        <div className="mt-2">
          <LabelToggleGrid
            values={props.values}
            selected={props.selected}
            scores={props.scores}
            activeCounts={props.activeCounts}
            onToggle={props.onToggle}
            drawLabel={props.drawLabel}
            onDrawLabelChange={props.onDrawLabelChange}
            disabled={props.disabled}
            gridClassName={props.gridClassName}
          />
        </div>
      ) : null}
    </div>
  )
}

function TrainingStatsList(props: {
  title: string
  description?: string
  values: TrainingLabelStat[]
  total: number
  confidence?: TrainingConfidence
  emptyText?: string
}) {
  const sorted = [...props.values].sort((a, b) => b.count - a.count)

  return (
    <div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
      <div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0">
            <div className="text-sm font-semibold text-gray-900 dark:text-white">
              {props.title}
            </div>

            {props.description ? (
              <div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
                {props.description}
              </div>
            ) : null}
          </div>

          <div className="flex shrink-0 items-center gap-2">
            <span
              className={[
                'rounded-full px-2.5 py-1 text-xs font-bold ring-1',
                confidencePillClass(props.confidence),
              ].join(' ')}
              title={`Kategorie-Confidence: ${confidencePercent(props.confidence)}`}
            >
              {confidenceLabel(props.confidence)} · {confidencePercent(props.confidence)}
            </span>

            <span
              className={[
                'rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 ring-1 ring-gray-200',
                'dark:bg-white/10 dark:text-gray-100 dark:ring-white/20',
                'dark:shadow-sm',
              ].join(' ')}
            >
              {sorted.length} Labels
            </span>
          </div>
        </div>

        <div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
          <span className="inline-flex items-center gap-1">
            <span className="h-1.5 w-5 rounded-full bg-indigo-500" />
            Häufigkeit
          </span>

          <span className="inline-flex items-center gap-1">
            <span className="h-1.5 w-5 rounded-full bg-emerald-500" />
            Confidence
          </span>
        </div>
      </div>

      {sorted.length === 0 ? (
        <div className="flex min-h-40 items-center justify-center px-4 py-8 text-center">
          <div>
            <div className="text-sm font-medium text-gray-700 dark:text-gray-200">
              Keine Labels gefunden
            </div>

            <div className="mt-1 max-w-sm text-xs text-gray-500 dark:text-gray-400">
              {props.emptyText || 'Sobald Feedback für diese Kategorie gespeichert wurde, erscheinen hier die Werte.'}
            </div>
          </div>
        </div>
      ) : (
        <div className="divide-y divide-gray-100 dark:divide-white/10">
          {sorted.map((item) => {
            const labelItem = getSegmentLabelItem(item.label)
            const Icon = labelItem.icon

            const shareWidth = countPercent(item.count, props.total)
            const confWidth = confidencePercent(item.confidence)

            return (
              <div
                key={item.label}
                className="px-4 py-3 transition hover:bg-gray-50 dark:hover:bg-white/[0.03]"
              >
                <div className="flex items-start justify-between gap-3">
                  <div className="flex min-w-0 items-start gap-3">
                    <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
                      <Icon
                        className="h-5 w-5 text-gray-600 dark:text-gray-300"
                        aria-hidden="true"
                      />
                    </div>

                    <div className="min-w-0">
                      <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
                        {labelItem.text}
                      </div>

                      <div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
                        {item.label}
                      </div>
                    </div>
                  </div>

                  <div className="flex shrink-0 items-center gap-2">
                    <span
                      className={[
                        'rounded-full px-2 py-0.5 text-[11px] font-bold ring-1',
                        confidencePillClass(item.confidence),
                      ].join(' ')}
                      title={`Confidence: ${confidencePercent(item.confidence)}`}
                    >
                      {confidenceLabel(item.confidence)}
                    </span>

                    <div className="min-w-10 text-right text-sm font-bold text-gray-900 dark:text-white">
                      {item.count}
                    </div>
                  </div>
                </div>

                <div className="mt-3 space-y-1.5">
                  <div>
                    <div className="mb-1 flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400">
                      <span>Häufigkeit in dieser Gruppe</span>
                      <span>{shareWidth}</span>
                    </div>

                    <div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
                      <div
                        className="h-full rounded-full bg-indigo-500 transition-all"
                        style={{ width: shareWidth }}
                      />
                    </div>
                  </div>

                  <div>
                    <div className="mb-1 flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400">
                      <span>Daten-Confidence</span>
                      <span>{confWidth}</span>
                    </div>

                    <div className="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
                      <div
                        className="h-full rounded-full bg-emerald-500 transition-all"
                        style={{ width: confWidth }}
                      />
                    </div>
                  </div>
                </div>
              </div>
            )
          })}
        </div>
      )}
    </div>
  )
}

type TrainingStatsTabKey =
  | 'people'
  | 'sexPositions'
  | 'bodyParts'
  | 'objects'
  | 'clothing'

function topTrainingLabelStat(values: TrainingLabelStat[]) {
  return [...values]
    .filter((item) => String(item.label || '').trim())
    .sort((a, b) => b.count - a.count)[0]
}

function TrainingStatsModal(props: {
  open: boolean
  onClose: () => void
  stats: TrainingStats | null
  history?: TrainingHistoryEntry[]
  loading: boolean
  error: string | null
  feedbackCount: number
  requiredCount: number
  deletingTrainingData?: boolean
  deleteTrainingDataDisabled?: boolean
  onDeleteTrainingData?: () => void | Promise<void>
}) {
  const [activeTab, setActiveTab] = useState<TrainingStatsTabKey>('people')
  const stats = props.stats

  const acceptedCount = stats?.acceptedCount ?? 0
  const correctedCount = stats?.correctedCount ?? 0
  const negativeCount = stats?.negativeCount ?? 0
  const totalFeedback = stats?.feedbackCount ?? props.feedbackCount
  const boxCount = stats?.boxCount ?? 0
  const sampleCount = stats?.sampleCount ?? 0
  const overallConfidence = stats?.confidence

  const detectorModelAvailable = Boolean(
    stats?.detectorModelAvailable ?? stats?.modelAvailable
  )
  const detectorModelInfo = stats?.detectorModelInfo ?? stats?.modelInfo
  const poseModelAvailable = Boolean(stats?.poseModelAvailable)
  const poseModelInfo = stats?.poseModelInfo
  const videoMAEModelAvailable = Boolean(stats?.videoMAEModelAvailable)
  const videoMAEModelInfo = stats?.videoMAEModelInfo
  const modelTrainedAtLabel = formatModelTrainedAt(detectorModelInfo)
  const modelMap50Label = formatMapPercent(detectorModelInfo?.map50)
  const modelMap5095Label = formatMapPercent(detectorModelInfo?.map5095)
  const modelInfoDetails = (() => {
    const info = stats?.modelInfo
    if (!info) return ''

    const parts: string[] = []
    if (Number(info.epochs) > 0) parts.push(`${info.epochs} Epochen`)
    if (Number(info.trainSamples) > 0) parts.push(`${info.trainSamples} Train`)
    if (Number(info.valSamples) > 0) parts.push(`${info.valSamples} Val`)
    if (String(info.device || '').trim()) parts.push(String(info.device).trim())

    return parts.join(' · ')
  })()

  const modelCards = [
    {
      key: 'detector',
      title: 'Detector',
      available: detectorModelAvailable,
      info: detectorModelInfo,
    },
    {
      key: 'pose',
      title: 'Pose',
      available: poseModelAvailable,
      info: poseModelInfo,
    },
    {
      key: 'videomae',
      title: 'VideoMAE',
      available: videoMAEModelAvailable,
      info: videoMAEModelInfo,
    },
  ].map((model) => ({
    ...model,
    trainedAtLabel: formatModelTrainedAt(model.info),
    map50Label: formatMapPercent(model.info?.map50),
    map5095Label: formatMapPercent(model.info?.map5095),
    details:
      model.key === 'detector'
        ? modelInfoDetails
        : formatTrainingModelDetails(model.info),
  }))
  const availableModelCount = modelCards.filter((model) => model.available).length
  const totalModelCount = modelCards.length
  const availableModelNames = modelCards
    .filter((model) => model.available)
    .map((model) => model.title)
  const modelSummaryLabel =
    availableModelCount === totalModelCount
      ? 'Alle Modelle verfuegbar'
      : availableModelCount > 0
        ? `${availableModelNames.join(' + ')} verfuegbar`
        : 'Noch kein trainiertes Modell verfuegbar'

  const history = props.history ?? []

  const tabItems: Array<{
    key: TrainingStatsTabKey
    title: string
    shortTitle: string
    description: string
    values: TrainingLabelStat[]
    total: number
    confidence?: TrainingConfidence
    topLabel?: TrainingLabelStat
  }> = [
    {
      key: 'people',
      title: 'Personen',
      shortTitle: 'Personen',
      description: 'Personen- und Gender-Labels aus Boxen.',
      values: stats?.labels.people ?? [],
      total: Math.max(1, boxCount),
      confidence: averageCategoryConfidence(stats?.labels.people ?? []),
      topLabel: topTrainingLabelStat(stats?.labels.people ?? []),
    },
    {
      key: 'sexPositions',
      title: 'Sexpositionen',
      shortTitle: 'Positionen',
      description: 'Positions-Labels aus YOLO-Detector-Labels pro bewertetem Frame.',
      values: stats?.labels.sexPositions ?? [],
      total: Math.max(1, totalFeedback),
      confidence: averageCategoryConfidence(stats?.labels.sexPositions ?? []),
      topLabel: topTrainingLabelStat(stats?.labels.sexPositions ?? []),
    },
    {
      key: 'bodyParts',
      title: 'Körperteile',
      shortTitle: 'Körper',
      description: 'Körperteil-Labels aus Korrekturen und Boxen.',
      values: stats?.labels.bodyParts ?? [],
      total: Math.max(1, totalFeedback),
      confidence: averageCategoryConfidence(stats?.labels.bodyParts ?? []),
      topLabel: topTrainingLabelStat(stats?.labels.bodyParts ?? []),
    },
    {
      key: 'objects',
      title: 'Gegenstände',
      shortTitle: 'Objekte',
      description: 'Objekt-Labels aus Korrekturen und Boxen.',
      values: stats?.labels.objects ?? [],
      total: Math.max(1, totalFeedback),
      confidence: averageCategoryConfidence(stats?.labels.objects ?? []),
      topLabel: topTrainingLabelStat(stats?.labels.objects ?? []),
    },
    {
      key: 'clothing',
      title: 'Kleidung',
      shortTitle: 'Kleidung',
      description: 'Kleidungs-Labels aus Korrekturen und Boxen.',
      values: stats?.labels.clothing ?? [],
      total: Math.max(1, totalFeedback),
      confidence: averageCategoryConfidence(stats?.labels.clothing ?? []),
      topLabel: topTrainingLabelStat(stats?.labels.clothing ?? []),
    },
  ]

  const activeTabItem =
    tabItems.find((item) => item.key === activeTab) ?? tabItems[0]

  return (
    <Modal
      open={props.open}
      onClose={props.onClose}
      title="Training-Statistiken"
      width="max-w-3xl"
      bodyClassName="p-0"
    >
      <div className="px-3 pb-4 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
        {props.loading ? (
          <div className="flex min-h-40 items-center justify-center">
            <div className="text-center">
              <LoadingSpinner
                size="lg"
                srLabel="Statistiken werden geladen…"
              />

              <div className="mt-3 text-sm font-medium text-gray-700 dark:text-gray-200">
                Statistiken werden geladen…
              </div>
            </div>
          </div>
        ) : props.error ? (
          <div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-200">
            {props.error}
          </div>
        ) : (
          <div className="space-y-3 sm:space-y-4">
            {/* Mobile: kompakte Top-Zusammenfassung */}
            <div className="sm:hidden">
              <div className="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
                <div className="flex items-start justify-between gap-3">
                  <div className="min-w-0">
                    <div className="text-sm font-semibold text-gray-900 dark:text-white">
                      {availableModelCount > 0
                        ? 'Modell verfügbar'
                        : 'Noch kein Modell'}
                    </div>

                    <div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
                      {totalFeedback} Feedback · {boxCount} Boxen · {sampleCount} Samples
                    </div>
                  </div>

                  <span
                    className={[
                      'shrink-0 rounded-full px-2.5 py-1 text-xs font-bold ring-1',
                      confidencePillClass(overallConfidence),
                    ].join(' ')}
                    title={`Daten-Confidence: ${confidencePercent(overallConfidence)}`}
                  >
                    {confidencePercent(overallConfidence)}
                  </span>
                </div>

                <div className="mt-3 h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
                  <div
                    className="h-full rounded-full bg-emerald-500 transition-all"
                    style={{ width: confidencePercent(overallConfidence) }}
                  />
                </div>

                <div className="mt-3 grid grid-cols-5 gap-1.5">
                  <div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                    <div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                      Feedback
                    </div>
                    <div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
                      {totalFeedback}
                    </div>
                  </div>

                  <div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                    <div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                      Passt
                    </div>
                    <div className="mt-0.5 text-sm font-black text-emerald-700 dark:text-emerald-300">
                      {acceptedCount}
                    </div>
                  </div>

                  <div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                    <div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                      Korr.
                    </div>
                    <div className="mt-0.5 text-sm font-black text-amber-700 dark:text-amber-300">
                      {correctedCount}
                    </div>
                  </div>

                  <div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                    <div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                      Negativ
                    </div>
                    <div className="mt-0.5 text-sm font-black text-blue-700 dark:text-blue-300">
                      {negativeCount}
                    </div>
                  </div>

                  <div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                    <div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                      Boxen
                    </div>
                    <div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
                      {boxCount}
                    </div>
                  </div>
                </div>

                <div className="mt-3 grid grid-cols-3 gap-1.5">
                  {modelCards.map((model) => (
                    <div
                      key={model.key}
                      className="rounded-xl bg-gray-50 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
                    >
                      <div className="flex items-center justify-between gap-2">
                        <span className="truncate text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                          {model.title}
                        </span>
                        <span
                          className={[
                            'h-2 w-2 shrink-0 rounded-full',
                            model.available
                              ? 'bg-emerald-500'
                              : 'bg-gray-300 dark:bg-gray-600',
                          ].join(' ')}
                          title={model.available ? 'bereit' : 'fehlt'}
                        />
                      </div>
                      <div className="mt-0.5 truncate text-xs font-bold text-gray-900 dark:text-white">
                        {model.available ? 'bereit' : 'fehlt'}
                        {model.map50Label ? ` | ${model.map50Label}` : ''}
                      </div>
                    </div>
                  ))}
                </div>
              </div>
            </div>

            {/* Desktop/Tablet: ausführliche Karten */}
            <div className="hidden sm:grid sm:grid-cols-5 sm:gap-2">
              <div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                <div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
                  Feedback
                </div>
                <div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
                  {totalFeedback}
                </div>
                <div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
                  benötigt: {props.requiredCount}
                </div>
              </div>

              <div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                <div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
                  Passt so
                </div>
                <div className="mt-1 text-2xl font-bold text-emerald-700 dark:text-emerald-300">
                  {acceptedCount}
                </div>
                <div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
                  {countPercent(acceptedCount, totalFeedback)}
                </div>
              </div>

              <div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                <div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
                  Korrigiert
                </div>
                <div className="mt-1 text-2xl font-bold text-amber-700 dark:text-amber-300">
                  {correctedCount}
                </div>
                <div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
                  {countPercent(correctedCount, totalFeedback)}
                </div>
              </div>

              <div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                <div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
                  Negativ
                </div>
                <div className="mt-1 text-2xl font-bold text-blue-700 dark:text-blue-300">
                  {negativeCount}
                </div>
                <div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
                  {countPercent(negativeCount, totalFeedback)}
                </div>
              </div>

              <div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                <div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
                  Boxen
                </div>
                <div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
                  {boxCount}
                </div>
                <div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
                  {sampleCount} Samples
                </div>
              </div>
            </div>

            <div className="hidden grid-cols-1 gap-3 sm:grid sm:grid-cols-2">
              <div className="rounded-xl border border-indigo-100 bg-indigo-50 p-4 dark:border-indigo-400/20 dark:bg-indigo-500/10">
                <div className="text-[11px] font-medium uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80">
                  Modellstatus
                </div>

                <div className="mt-2 text-sm font-semibold text-indigo-950 dark:text-indigo-50">
                  {availableModelCount > 0
                    ? 'Trainiertes Modell verfügbar'
                    : 'Noch kein trainiertes Modell verfügbar'}
                </div>

                {detectorModelAvailable && modelTrainedAtLabel ? (
                  <div className="mt-2 space-y-1">
                    <div className="flex items-center justify-between gap-2 text-xs">
                      <span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
                        Version vom
                      </span>
                      <span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
                        {modelTrainedAtLabel}
                      </span>
                    </div>

                    {modelMap50Label ? (
                      <div className="flex items-center justify-between gap-2 text-xs">
                        <span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
                          Qualität (mAP50{modelMap5095Label ? ' / 50-95' : ''})
                        </span>
                        <span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
                          {modelMap50Label}
                          {modelMap5095Label ? ` / ${modelMap5095Label}` : ''}
                        </span>
                      </div>
                    ) : null}

                    {modelInfoDetails ? (
                      <div className="text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
                        {modelInfoDetails}
                      </div>
                    ) : null}
                  </div>
                ) : (
                  <div className="mt-1 text-xs leading-relaxed text-indigo-800/80 dark:text-indigo-100/70">
                    {availableModelCount > 0
                      ? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.'
                      : 'Sammle weiter Feedback und starte anschließend das Training.'}
                  </div>
                )}

                <div className="mt-3 space-y-2">
                  <div className="text-[11px] font-semibold uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80">
                    {modelSummaryLabel} - {availableModelCount}/{totalModelCount}
                  </div>

                  {modelCards.map((model) => (
                    <div
                      key={model.key}
                      className="rounded-lg bg-white/60 p-2.5 ring-1 ring-indigo-200/70 dark:bg-white/5 dark:ring-indigo-300/20"
                    >
                      <div className="flex items-center justify-between gap-2">
                        <span className="text-xs font-bold text-indigo-950 dark:text-indigo-50">
                          {model.title}
                        </span>
                        <span
                          className={[
                            'rounded-full px-2 py-0.5 text-[10px] font-bold ring-1',
                            model.available
                              ? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
                              : 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
                          ].join(' ')}
                        >
                          {model.available ? 'bereit' : 'fehlt'}
                        </span>
                      </div>

                      {model.available && model.trainedAtLabel ? (
                        <div className="mt-2 space-y-1">
                          <div className="flex items-center justify-between gap-2 text-[11px]">
                            <span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
                              Version
                            </span>
                            <span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
                              {model.trainedAtLabel}
                            </span>
                          </div>

                          {model.map50Label ? (
                            <div className="flex items-center justify-between gap-2 text-[11px]">
                              <span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
                                mAP50{model.map5095Label ? ' / 50-95' : ''}
                              </span>
                              <span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
                                {model.map50Label}
                                {model.map5095Label ? ` / ${model.map5095Label}` : ''}
                              </span>
                            </div>
                          ) : null}

                          {model.details ? (
                            <div className="text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
                              {model.details}
                            </div>
                          ) : null}
                        </div>
                      ) : (
                        <div className="mt-1 text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
                          {model.available
                            ? 'Modell gefunden, Details fehlen.'
                            : 'Noch nicht trainiert.'}
                        </div>
                      )}
                    </div>
                  ))}
                </div>
              </div>

              <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
                <div className="flex items-center justify-between gap-3">
                  <div>
                    <div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
                      Daten-Confidence
                    </div>

                    <div className="mt-2 text-xl font-bold text-gray-900 dark:text-white">
                      {confidenceLabel(overallConfidence)}
                    </div>
                  </div>

                  <span
                    className={[
                      'rounded-full px-3 py-1.5 text-sm font-bold ring-1',
                      confidencePillClass(overallConfidence),
                    ].join(' ')}
                  >
                    {confidencePercent(overallConfidence)}
                  </span>
                </div>

                <div className="mt-3 h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
                  <div
                    className="h-full rounded-full bg-emerald-500 transition-all"
                    style={{ width: confidencePercent(overallConfidence) }}
                  />
                </div>

                <div className="mt-2 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
                  Daten-Confidence aus Feedback-Menge, Boxen, Label-Abdeckung und Korrekturanteil. Kein direkter Modell-Qualitätswert.
                </div>
              </div>
            </div>

            {history.length > 0 ? (
              <div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
                <div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
                  <div className="text-sm font-semibold text-gray-900 dark:text-white">
                    Trainings-Verlauf
                  </div>
                  <div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
                    Modellqualität (mAP) über die letzten Trainingsläufe
                  </div>
                </div>

                <div className="max-h-56 divide-y divide-gray-100 overflow-y-auto dark:divide-white/10">
                  {history.map((entry, idx) => {
                    const map50 = formatMapPercent(entry.map50)
                    const map5095 = formatMapPercent(entry.map5095)
                    const duration =
                      Number(entry.durationMs) > 0
                        ? formatDuration(Number(entry.durationMs))
                        : ''
                    const barPct = Math.max(
                      0,
                      Math.min(100, Math.round(Number(entry.map50 ?? 0) * 100))
                    )

                    const meta: string[] = []
                    const target = trainingHistoryTarget(entry.target)
                    if (target === 'detector') meta.push('Detector')
                    if (target === 'pose') meta.push('Pose')
                    if (target === 'videomae') meta.push('VideoMAE')
                    if (Number(entry.epochs) > 0) meta.push(`${entry.epochs} Ep.`)
                    if (Number(entry.trainSamples) > 0) meta.push(`${entry.trainSamples} Train`)
                    if (duration) meta.push(duration)

                    return (
                      <div key={`${entry.trainedAtMs}-${idx}`} className="px-4 py-2.5">
                        <div className="flex items-center justify-between gap-3">
                          <div className="min-w-0">
                            <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
                              {formatHistoryDate(entry)}
                              {idx === 0 ? (
                                <span className="ml-2 align-middle rounded-full bg-indigo-100 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
                                  aktuell
                                </span>
                              ) : null}
                            </div>
                            {meta.length > 0 ? (
                              <div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
                                {meta.join(' · ')}
                              </div>
                            ) : null}
                          </div>

                          <div className="shrink-0 text-right">
                            <div className="text-sm font-bold tabular-nums text-gray-900 dark:text-white">
                              {map50 || '—'}
                            </div>
                            <div className="text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
                              {map5095 ? `50-95: ${map5095}` : 'mAP50'}
                            </div>
                          </div>
                        </div>

                        <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
                          <div
                            className="h-full rounded-full bg-indigo-500 transition-all"
                            style={{ width: `${barPct}%` }}
                          />
                        </div>
                      </div>
                    )
                  })}
                </div>
              </div>
            ) : null}

            {/* Mobile: Tabs kompakter, direkt nach Summary */}
            <div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-50/70 p-1.5 dark:border-white/10 dark:bg-white/[0.03] sm:p-2">
              <div className="grid grid-cols-2 gap-1 sm:grid-cols-5">
                {tabItems.map((item) => {
                  const active = item.key === activeTab
                  const topLabelItem = item.topLabel
                    ? getSegmentLabelItem(item.topLabel.label)
                    : null
                  const TopIcon = topLabelItem?.icon

                  return (
                    <button
                      key={item.key}
                      type="button"
                      onClick={() => setActiveTab(item.key)}
                      className={[
                        'rounded-xl px-2.5 py-2 text-left transition',
                        'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
                        active
                          ? [
                              'bg-white text-indigo-700 shadow-sm ring-1 ring-indigo-200',
                              'dark:bg-indigo-500/18 dark:text-indigo-50 dark:ring-indigo-300/45',
                              'dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(79,70,229,0.18)]',
                            ].join(' ')
                          : [
                              'text-gray-600 hover:bg-white/70 hover:text-gray-900',
                              'dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white',
                            ].join(' '),
                      ].join(' ')}
                    >
                      <div className="flex min-w-0 items-center gap-2.5">
                        {TopIcon ? (
                          <div
                            className={[
                              'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1',
                              active
                                ? [
                                    'bg-indigo-50 text-indigo-700 ring-indigo-200',
                                    'dark:bg-white/12 dark:text-indigo-50 dark:ring-white/20',
                                  ].join(' ')
                                : [
                                    'bg-white text-gray-500 ring-gray-200',
                                    'dark:bg-white/7 dark:text-gray-300 dark:ring-white/10',
                                  ].join(' '),
                            ].join(' ')}
                            aria-hidden="true"
                          >
                            <TopIcon className="h-5 w-5" aria-hidden="true" />
                          </div>
                        ) : null}

                        <div className="min-w-0 flex-1">
                          <div className="truncate text-xs font-semibold">
                            <span className="sm:hidden">{item.shortTitle}</span>
                            <span className="hidden sm:inline">{item.title}</span>
                          </div>

                          <div className="mt-1 flex items-center justify-start gap-2">
                            <span
                              className={[
                                'rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
                                confidencePillClass(item.confidence),
                              ].join(' ')}
                              title={`Kategorie-Confidence: ${confidencePercent(item.confidence)}`}
                            >
                              {confidenceLabel(item.confidence)} · {confidencePercent(item.confidence)}
                            </span>
                          </div>
                        </div>
                      </div>
                    </button>
                  )
                })}
              </div>
            </div>

            <TrainingStatsList
              title={activeTabItem.title}
              description={activeTabItem.description}
              values={activeTabItem.values}
              total={activeTabItem.total}
              confidence={activeTabItem.confidence}
            />

            <div className="rounded-xl border border-red-100 bg-red-50 p-3 dark:border-red-400/20 dark:bg-red-500/10 sm:p-4">
              <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                <div className="min-w-0">
                  <div className="text-sm font-semibold text-red-950 dark:text-red-100">
                    Trainingsdaten löschen
                  </div>

                  <div className="mt-0.5 text-xs leading-relaxed text-red-800/80 dark:text-red-100/70">
                    Entfernt Feedback, Frames, Samples und Trainingsdaten. Diese Aktion kann nicht rückgängig gemacht werden.
                  </div>
                </div>

                <Button
                  size="md"
                  variant="primary"
                  color="red"
                  className="shrink-0 justify-center px-3 text-xs sm:w-auto"
                  disabled={
                    Boolean(props.deletingTrainingData) ||
                    Boolean(props.deleteTrainingDataDisabled) ||
                    !props.onDeleteTrainingData
                  }
                  onClick={() => void props.onDeleteTrainingData?.()}
                  title="Löscht Feedback, Frames, Samples und Trainingsdaten."
                >
                  <span className="inline-flex items-center gap-1.5">
                    <TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
                    {props.deletingTrainingData ? 'Lösche…' : 'Trainingsdaten löschen'}
                  </span>
                </Button>
              </div>
            </div>
          </div>
        )}
      </div>
    </Modal>
  )
}

function makeRequestId() {
  if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
    return crypto.randomUUID()
  }

  return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}

function annotationToTrainingSample(item: TrainingAnnotation): TrainingSample {
  return {
    sampleId: item.sampleId,
    frameUrl: item.frameUrl,
    sourceFile: item.sourceFile,
    sourcePath: item.sourcePath,
    sourceSizeBytes: item.sourceSizeBytes,
    second: item.second,
    createdAt: item.createdAt,
    prediction: item.prediction,
  }
}

function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState {
  if (item.negative) {
    return {
      sexPosition: NO_SEX_POSITION_LABEL,
      peoplePresent: [],
      bodyPartsPresent: [],
      objectsPresent: [],
      clothingPresent: [],
      boxes: [],
      posePersons: [],
    }
  }

  if (item.correction) {
    return {
      ...item.correction,
      sexPosition: normalizeSexPositionValue(item.correction.sexPosition),
      posePersons: clonePosePersons(item.correction.posePersons ?? item.prediction.persons),
    }
  }

  return predictionToCorrection(annotationToTrainingSample(item))
}

const FEEDBACK_PAGE_SIZE = 10

const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded'
const TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY = 'training:pending-import-video'
const TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY = 'training:active-import-video'
const TRAINING_ACTIVE_NEXT_STORAGE_KEY = 'training:active-next'
const ALL_TRAINING_TARGETS: TrainingTargetKey[] = ['detector', 'pose', 'videomae']

export default function TrainingTab(props: {
  active?: boolean
  onTrainingRunningChange?: (running: boolean) => void
  onImageExpandedChange?: (expanded: boolean) => void
}) {
  const tabActive = props.active ?? true
  const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
  const [sample, setSample] = useState<TrainingSample | null>(null)
  const sampleRef = useRef<TrainingSample | null>(null)
  const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null))
  const [hasManualCorrection, setHasManualCorrection] = useState(false)
  const [loading, setLoading] = useState(false)
  const [analysisProgress, setAnalysisProgress] = useState(0)
  const [analysisStep, setAnalysisStep] = useState('')
  const [analysisSourceFile, setAnalysisSourceFile] = useState('')
  const [saving, setSaving] = useState(false)
  const [savingOverlayText, setSavingOverlayText] = useState('')
  const [training, setTraining] = useState(false)
  const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null)
  const [deletingTrainingData, setDeletingTrainingData] = useState(false)
  const [trainingProgress, setTrainingProgress] = useState(0)
  const [trainingStep, setTrainingStep] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [message, setMessage] = useState<string | null>(null)
  const [statsModalOpen, setStatsModalOpen] = useState(false)
  const [trainingStartModalOpen, setTrainingStartModalOpen] = useState(false)
  const [trainingStartMode, setTrainingStartMode] = useState<TrainingStartMode>('full')
  const [trainingStartTargets, setTrainingStartTargets] = useState<TrainingTargetKey[]>(ALL_TRAINING_TARGETS)
  const [activeTrainingTargets, setActiveTrainingTargets] = useState<TrainingTargetKey[]>([])
  const [cancellingTraining, setCancellingTraining] = useState(false)
  const [trainingStats, setTrainingStats] = useState<TrainingStats | null>(null)
  const [trainingStatsLoading, setTrainingStatsLoading] = useState(false)
  const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
  const [trainingHistory, setTrainingHistory] = useState<TrainingHistoryEntry[]>([])
  const [trainingEstimateSettings, setTrainingEstimateSettings] = useState<RecorderSettingsState | null>(null)
  const wasTrainingRunningRef = useRef(false)
  const shownTrainingCompletionRef = useRef<string | null>(null)
  const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => {
    if (typeof window === 'undefined') return ''

    try {
      return window.localStorage.getItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) || ''
    } catch {
      return ''
    }
  })

  const [importedSampleQueue, setImportedSampleQueue] = useState<QueuedTrainingSample[]>([])
  const importedSampleQueueRef = useRef<QueuedTrainingSample[]>([])
  const feedbackEditReturnSampleRef = useRef<QueuedTrainingSample | null>(null)

  const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
  const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
  const [feedbackLoading, setFeedbackLoading] = useState(false)
  const [feedbackLoadingMore, setFeedbackLoadingMore] = useState(false)
  const [feedbackError, setFeedbackError] = useState<string | null>(null)
  const [feedbackTotal, setFeedbackTotal] = useState(0)
  const [feedbackHasMore, setFeedbackHasMore] = useState(false)
  const [selectedFeedbackIndex, setSelectedFeedbackIndex] = useState(0)
  const [feedbackSearchQuery, setFeedbackSearchQuery] = useState('')
  const [feedbackSearchFilter, setFeedbackSearchFilter] = useState<FeedbackFilter>('all')
  
  const initializedRef = useRef(false)
  const initRunIdRef = useRef(0)

  const [editingFeedback, setEditingFeedback] = useState<{
    sampleId: string
    answeredAt: string
  } | null>(null)

  const notify = useNotify()

  const activePointerIdRef = useRef<number | null>(null)
  const activePointerCaptureElementRef = useRef<HTMLElement | null>(null)
  const finishingGestureRef = useRef(false)

  const [frameImageLoaded, setFrameImageLoaded] = useState(false)
  const [imageExpanded, setImageExpanded] = useState(() => {
    if (typeof window === 'undefined') return false

    try {
      return window.localStorage.getItem(TRAINING_IMAGE_EXPANDED_STORAGE_KEY) === '1'
    } catch {
      return false
    }
  })
  const [showPoseSkeleton, setShowPoseSkeleton] = useState(false)

  const [frameNaturalSize, setFrameNaturalSize] = useState<{
    width: number
    height: number
  } | null>(null)

  const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('')
  const [loadingPreviewFallbackUrl, setLoadingPreviewFallbackUrl] = useState('')
  const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false)
  const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false)

  // Während des Trainings sendet das Backend zum gerade trainierten Batch eine
  // Vorschau. Wir zeigen immer das zuletzt eingegangene Bild (live) und merken es
  // in einem Ref, damit die Anzeige unabhängig von den (gedrosselten) Status-
  // Updates bleibt und die Render-Rate begrenzt ist.
  const [trainingPreviewUrl, setTrainingPreviewUrl] = useState('')
  const latestTrainingPreviewRef = useRef('')
  const lastTrainingStatusApplyRef = useRef(0)

  const [stageOverlayMounted, setStageOverlayMounted] = useState(false)
  const [stageOverlayVisible, setStageOverlayVisible] = useState(false)

  const imageBoxRef = useRef<HTMLDivElement | null>(null)
  const frameImageRef = useRef<HTMLImageElement | null>(null)

  const [imageLayerStyle, setImageLayerStyle] = useState<CSSProperties | null>(null)
  const [imageBoxSize, setImageBoxSize] = useState({ width: 0, height: 0 })

  type ImageContentRect = {
    left: number
    top: number
    width: number
    height: number
    right: number
    bottom: number
  }

  const activeImageContentRectRef = useRef<ImageContentRect | null>(null)

  const loadFeedbackHistoryInitial = useCallback(async (
    options: {
      query?: string
      filter?: FeedbackFilter
    } = {}
  ) => {
    const query = options.query ?? ''
    const filter = options.filter ?? 'all'

    setFeedbackLoading(true)
    setFeedbackError(null)

    try {
      const params = new URLSearchParams()
      params.set('limit', String(FEEDBACK_PAGE_SIZE))
      params.set('offset', '0')

      if (query.trim()) {
        params.set('q', query.trim())
      }

      if (filter !== 'all') {
        params.set('filter', filter)
      }

      const res = await fetch(`/api/training/feedback/list?${params.toString()}`, {
        cache: 'no-store',
      })

      const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null

      if (!res.ok || !data) {
        throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`)
      }

      setFeedbackItems(data.items)
      setFeedbackTotal(data.total)
      setFeedbackHasMore(data.hasMore)
      setSelectedFeedbackIndex(0)
    } catch (e) {
      setFeedbackError(e instanceof Error ? e.message : String(e))
    } finally {
      setFeedbackLoading(false)
    }
  }, [])

  const loadMoreFeedbackHistory = useCallback(async () => {
    if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return

    setFeedbackLoadingMore(true)
    setFeedbackError(null)

    try {
      const params = new URLSearchParams()
      params.set('limit', String(FEEDBACK_PAGE_SIZE))
      params.set('offset', String(feedbackItems.length))

      if (feedbackSearchQuery.trim()) {
        params.set('q', feedbackSearchQuery.trim())
      }

      if (feedbackSearchFilter !== 'all') {
        params.set('filter', feedbackSearchFilter)
      }

      const res = await fetch(`/api/training/feedback/list?${params.toString()}`, {
        cache: 'no-store',
      })

      const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null

      if (!res.ok || !data) {
        throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`)
      }

      setFeedbackItems((current) => {
        const existing = new Set(
          current.map((item) => `${item.sampleId}-${item.answeredAt}`)
        )

        const nextItems = data.items.filter(
          (item) => !existing.has(`${item.sampleId}-${item.answeredAt}`)
        )

        return [...current, ...nextItems]
      })

      setFeedbackTotal(data.total)
      setFeedbackHasMore(data.hasMore)
    } catch (e) {
      setFeedbackError(e instanceof Error ? e.message : String(e))
    } finally {
      setFeedbackLoadingMore(false)
    }
  }, [
    feedbackHasMore,
    feedbackItems.length,
    feedbackLoading,
    feedbackLoadingMore,
    feedbackSearchFilter,
    feedbackSearchQuery,
  ])

  const editFeedbackItem = useCallback((item: TrainingAnnotation) => {
    const currentSample = sampleRef.current

    if (
      !editingFeedback &&
      !feedbackEditReturnSampleRef.current &&
      currentSample &&
      currentSample.sampleId !== item.sampleId
    ) {
      feedbackEditReturnSampleRef.current = {
        sample: currentSample,
        correction: cloneCorrectionState(correctionRef.current),
        manualCorrection: hasManualCorrectionRef.current,
      }
    }

    const nextSample = annotationToTrainingSample(item)
    const nextCorrection = annotationToCorrectionState(item)
    const nextManualCorrection = !item.accepted

    sampleRef.current = nextSample
    correctionRef.current = nextCorrection
    hasManualCorrectionRef.current = nextManualCorrection
    setSample(nextSample)
    setCorrection(nextCorrection)
    setHasManualCorrection(nextManualCorrection)

    setEditingFeedback({
      sampleId: item.sampleId,
      answeredAt: item.answeredAt,
    })

    setDrawingBox(null)
    setBoxInteraction(null)
    setTouchMagnifier(null)
    setBoxLabel('')
    setActiveBoxIndex(null)
    setShowPoseSkeleton(false)
    setActivePosePersonIndex(null)
    setPendingPoseKeypoint(null)
    setHoveredPoseKeypoint(null)
    if (poseKeypointMapAnimationFrameRef.current !== null) {
      cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current)
      poseKeypointMapAnimationFrameRef.current = null
    }
    poseKeypointMapViewRef.current = 'body'
    poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX
    setPoseKeypointMapView('body')
    setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX)
    setPoseKeypointMapAnimating(false)
    setFeedbackModalOpen(false)

    window.requestAnimationFrame(() => {
      mobileLabelsScrollRef.current?.scrollIntoView({
        block: 'start',
        behavior: 'smooth',
      })
    })
  }, [editingFeedback])

  const getImageContentRect = useCallback((): ImageContentRect | null => {
    const imgEl = frameImageRef.current
    if (!imgEl) return null

    const rect = imgEl.getBoundingClientRect()

    const naturalW = imgEl.naturalWidth
    const naturalH = imgEl.naturalHeight

    if (
      rect.width <= 0 ||
      rect.height <= 0 ||
      naturalW <= 0 ||
      naturalH <= 0
    ) {
      return null
    }

    // Entspricht object-contain: sichtbarer Bildinhalt innerhalb der img-Box.
    const scale = Math.min(rect.width / naturalW, rect.height / naturalH)

    const contentW = naturalW * scale
    const contentH = naturalH * scale

    const left = rect.left + (rect.width - contentW) / 2
    const top = rect.top + (rect.height - contentH) / 2

    return {
      left,
      top,
      width: contentW,
      height: contentH,
      right: left + contentW,
      bottom: top + contentH,
    }
  }, [])

  const updateImageLayerStyle = useCallback(() => {
    const boxEl = imageBoxRef.current
    const imgEl = frameImageRef.current

    if (!boxEl || !imgEl) {
      setImageLayerStyle(null)
      return
    }

    const naturalW = imgEl.naturalWidth
    const naturalH = imgEl.naturalHeight
    const imageLayoutW = imgEl.offsetWidth || imgEl.clientWidth
    const imageLayoutH = imgEl.offsetHeight || imgEl.clientHeight

    if (
      naturalW <= 0 ||
      naturalH <= 0 ||
      imageLayoutW <= 0 ||
      imageLayoutH <= 0
    ) {
      setImageLayerStyle(null)
      return
    }

    // Untransformierte Layout-Werte verwenden. getBoundingClientRect() wuerde
    // beim Face-Zoom die bereits transformierte Groesse messen und die Kamera
    // dadurch schrittweise verschieben.
    const scale = Math.min(imageLayoutW / naturalW, imageLayoutH / naturalH)
    const contentW = naturalW * scale
    const contentH = naturalH * scale
    const nextBoxSize = {
      width: boxEl.clientWidth || boxEl.offsetWidth,
      height: boxEl.clientHeight || boxEl.offsetHeight,
    }

    const nextStyle: CSSProperties = {
      left: imgEl.offsetLeft + (imageLayoutW - contentW) / 2,
      top: imgEl.offsetTop + (imageLayoutH - contentH) / 2,
      width: contentW,
      height: contentH,
    }

    setImageBoxSize((prev) =>
      prev.width === nextBoxSize.width && prev.height === nextBoxSize.height
        ? prev
        : nextBoxSize
    )

    setImageLayerStyle((prev) => {
      if (
        prev &&
        Number(prev.left) === nextStyle.left &&
        Number(prev.top) === nextStyle.top &&
        Number(prev.width) === nextStyle.width &&
        Number(prev.height) === nextStyle.height
      ) {
        return prev
      }

      return nextStyle
    })
  }, [])

  const detectorBoxesScrollRef = useRef<HTMLDivElement | null>(null)
  const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([])

  const activeAnalysisRequestIdRef = useRef<string | null>(null)
  const loadingRef = useRef(false)

  const videoImportStartedRef = useRef(false)
  const videoImportInFlightKeyRef = useRef<string | null>(null)
  const nextAnalysisInFlightRequestIdRef = useRef<string | null>(null)
  

  const epochTimingRef = useRef<{
    target: string
    firstEpochAt: number
    lastEpoch: number
    lastAt: number
  }>({
    target: '',
    firstEpochAt: 0,
    lastEpoch: 0,
    lastAt: 0,
  })

  const etaSmoothingRef = useRef<{
    lastAt: number
    lastRawEtaMs: number
  }>({
    lastAt: 0,
    lastRawEtaMs: 0,
  })

  const [trainingNowMs, setTrainingNowMs] = useState(() => Date.now())
  const [smoothedTrainingEtaMs, setSmoothedTrainingEtaMs] = useState(0)

  const [estimatedEpochMs, setEstimatedEpochMs] = useState(0)

  const [drawingBox, setDrawingBox] = useState<DrawingTrainingBox | null>(null)
  const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null)
  const [poseInteraction, setPoseInteraction] = useState<PoseInteraction | null>(null)
  const [pendingPoseKeypoint, setPendingPoseKeypoint] = useState<PoseKeypointPlacement | null>(null)
  const [hoveredPoseKeypoint, setHoveredPoseKeypoint] = useState<PoseKeypointPlacement | null>(null)
  const [poseKeypointMapView, setPoseKeypointMapView] = useState<PoseKeypointMapView>('body')
  const [poseKeypointMapViewBoxValue, setPoseKeypointMapViewBoxValue] =
    useState(POSE_BODY_MAP_VIEW_BOX)
  const [poseKeypointMapAnimating, setPoseKeypointMapAnimating] = useState(false)
  const [poseMapSlotHeight, setPoseMapSlotHeight] = useState(0)
  const [poseFaceImagePanCenter, setPoseFaceImagePanCenter] = useState<{
    x: number
    y: number
  } | null>(null)
  const [poseFaceImageDisplayCamera, setPoseFaceImageDisplayCamera] =
    useState<PoseFaceImageCamera | null>(null)
  const [poseFaceImageCameraAnimating, setPoseFaceImageCameraAnimating] = useState(false)
  const [poseFaceImageZoomOutActive, setPoseFaceImageZoomOutActive] = useState(false)
  const [poseFaceImagePanning, setPoseFaceImagePanning] = useState(false)
  const [touchMagnifier, setTouchMagnifier] = useState<MagnifierState | null>(null)
  const [boxLabel, setBoxLabel] = useState('')
  const [activeBoxIndex, setActiveBoxIndex] = useState<number | null>(null)
  const [activePosePersonIndex, setActivePosePersonIndex] = useState<number | null>(null)
  const [imageReloadKey, setImageReloadKey] = useState(0)
  const [trainingSampleMode, setTrainingSampleMode] = useState<TrainingSampleMode>('random')
  const trainingSampleModeRef = useRef<TrainingSampleMode>('random')
  const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({
    sexPosition: false,
    people: false,
    bodyParts: false,
    objects: false,
    clothing: false,
  })

  type CorrectionSectionKey = keyof typeof expandedCorrectionSections

  function nextExpandedCorrectionSections(
    key: CorrectionSectionKey,
    expanded: boolean
  ) {
    return {
      sexPosition: false,
      people: false,
      bodyParts: false,
      objects: false,
      clothing: false,
      [key]: expanded,
    }
  }
  
  const [mobilePanel, setMobilePanel] = useState<'labels' | 'boxes' | 'training'>('labels')

  const trainingRunningRef = useRef(false)

  const drawingBoxRef = useRef<DrawingTrainingBox | null>(null)
  const boxInteractionRef = useRef<BoxInteraction | null>(null)
  const poseInteractionRef = useRef<PoseInteraction | null>(null)
  const poseKeypointMapViewRef = useRef<PoseKeypointMapView>('body')
  const poseKeypointMapViewBoxValueRef = useRef(POSE_BODY_MAP_VIEW_BOX)
  const poseKeypointMapAnimationFrameRef = useRef<number | null>(null)
  const poseMapSlotRef = useRef<HTMLDivElement | null>(null)
  const poseFaceImagePanCenterRef = useRef<{ x: number; y: number } | null>(null)
  const poseFaceImagePanGestureRef = useRef<PoseFaceImagePanGesture | null>(null)
  const poseFaceImageDisplayCameraRef = useRef<PoseFaceImageCamera | null>(null)
  const poseFaceImageCameraAnimationFrameRef = useRef<number | null>(null)
  const poseFaceImageZoomOutActiveRef = useRef(false)
  const correctionRef = useRef<CorrectionState>(correction)
  const hasManualCorrectionRef = useRef(hasManualCorrection)
  const latestGestureBoxRef = useRef<TrainingBox | null>(null)
  const pendingPointerMoveRef = useRef<{ clientX: number; clientY: number } | null>(null)
  const pointerMoveRafRef = useRef<number | null>(null)

  const mobileLabelsScrollRef = useRef<HTMLDivElement | null>(null)

  const mobileSectionRefs = useRef<Record<CorrectionSectionKey, HTMLDivElement | null>>({
    sexPosition: null,
    people: null,
    bodyParts: null,
    objects: null,
    clothing: null,
  })

  const scrollMobileSectionToTop = useCallback(
    (key: keyof typeof expandedCorrectionSections) => {
      window.requestAnimationFrame(() => {
        const sectionEl = mobileSectionRefs.current[key]

        if (!sectionEl) return

        sectionEl.scrollIntoView({
          block: 'start',
          behavior: 'smooth',
        })
      })
    },
    []
  )

  const toggleMobileCorrectionSection = useCallback(
    (key: CorrectionSectionKey, expanded: boolean) => {
      setExpandedCorrectionSections(
        nextExpandedCorrectionSections(key, expanded)
      )

      if (expanded && mobilePanel === 'labels') {
        scrollMobileSectionToTop(key)
      }
    },
    [mobilePanel, scrollMobileSectionToTop]
  )

  const toggleCorrectionSection = useCallback(
    (key: CorrectionSectionKey, expanded: boolean) => {
      setExpandedCorrectionSections(
        nextExpandedCorrectionSections(key, expanded)
      )
    },
    []
  )

  const switchPoseKeypointMapView = useCallback((nextView: PoseKeypointMapView) => {
    const currentView = poseKeypointMapViewRef.current

    if (currentView === nextView) return

    if (poseKeypointMapAnimationFrameRef.current !== null) {
      cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current)
      poseKeypointMapAnimationFrameRef.current = null
    }

    const fromViewBox = poseKeypointMapViewBoxValueRef.current
    const toViewBox = poseKeypointMapViewBox(nextView)
    const startedAt = performance.now()

    poseKeypointMapViewRef.current = nextView
    setPoseKeypointMapView(nextView)
    setPoseKeypointMapAnimating(true)

    const animateZoom = (now: number) => {
      const progress = Math.min(1, (now - startedAt) / POSE_KEYPOINT_MAP_ZOOM_DURATION_MS)
      const nextViewBox = interpolatePoseMapViewBox(fromViewBox, toViewBox, progress)

      poseKeypointMapViewBoxValueRef.current = nextViewBox
      setPoseKeypointMapViewBoxValue(nextViewBox)

      if (progress < 1) {
        poseKeypointMapAnimationFrameRef.current = requestAnimationFrame(animateZoom)
        return
      }

      poseKeypointMapAnimationFrameRef.current = null
      poseKeypointMapViewBoxValueRef.current = toViewBox
      setPoseKeypointMapViewBoxValue(toViewBox)
      setPoseKeypointMapAnimating(false)
    }

    poseKeypointMapAnimationFrameRef.current = requestAnimationFrame(animateZoom)
  }, [])

  const resetPoseUiToBoxes = useCallback(() => {
    setShowPoseSkeleton(false)
    setActivePosePersonIndex(null)
    setPendingPoseKeypoint(null)
    setHoveredPoseKeypoint(null)

    poseFaceImagePanGestureRef.current = null
    poseFaceImagePanCenterRef.current = null
    setPoseFaceImagePanCenter(null)
    setPoseFaceImagePanning(false)

    if (poseFaceImageCameraAnimationFrameRef.current !== null) {
      cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current)
      poseFaceImageCameraAnimationFrameRef.current = null
    }

    poseFaceImageDisplayCameraRef.current = null
    poseFaceImageZoomOutActiveRef.current = false
    setPoseFaceImageDisplayCamera(null)
    setPoseFaceImageCameraAnimating(false)
    setPoseFaceImageZoomOutActive(false)

    if (poseKeypointMapAnimationFrameRef.current !== null) {
      cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current)
      poseKeypointMapAnimationFrameRef.current = null
    }

    poseKeypointMapViewRef.current = 'body'
    poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX
    setPoseKeypointMapView('body')
    setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX)
    setPoseKeypointMapAnimating(false)
  }, [])

  const labelsRef = useRef<TrainingLabels>(emptyLabels)

  useEffect(() => {
    importedSampleQueueRef.current = importedSampleQueue
  }, [importedSampleQueue])

  useEffect(() => {
    sampleRef.current = sample
  }, [sample])

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

    setFeedbackSearchQuery('')
    setFeedbackSearchFilter('all')

    void loadFeedbackHistoryInitial({
      query: '',
      filter: 'all',
    })
  }, [feedbackModalOpen, loadFeedbackHistoryInitial])

  useEffect(() => {
    loadingRef.current = loading
  }, [loading])

  useEffect(() => {
    labelsRef.current = labels
  }, [labels])

  useEffect(() => {
    drawingBoxRef.current = drawingBox
  }, [drawingBox])

  useEffect(() => {
    boxInteractionRef.current = boxInteraction
  }, [boxInteraction])

  useEffect(() => {
    poseInteractionRef.current = poseInteraction
  }, [poseInteraction])

  useEffect(() => {
    poseKeypointMapViewRef.current = poseKeypointMapView
  }, [poseKeypointMapView])

  useEffect(() => {
    poseFaceImagePanCenterRef.current = poseFaceImagePanCenter
  }, [poseFaceImagePanCenter])

  useEffect(() => {
    poseFaceImageDisplayCameraRef.current = poseFaceImageDisplayCamera
  }, [poseFaceImageDisplayCamera])

  useEffect(() => {
    poseFaceImageZoomOutActiveRef.current = poseFaceImageZoomOutActive
  }, [poseFaceImageZoomOutActive])

  useEffect(() => {
    return () => {
      if (poseKeypointMapAnimationFrameRef.current !== null) {
        cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current)
        poseKeypointMapAnimationFrameRef.current = null
      }

      if (poseFaceImageCameraAnimationFrameRef.current !== null) {
        cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current)
        poseFaceImageCameraAnimationFrameRef.current = null
      }

      poseFaceImagePanGestureRef.current = null
    }
  }, [])

  useEffect(() => {
    correctionRef.current = correction
  }, [correction])

  useEffect(() => {
    hasManualCorrectionRef.current = hasManualCorrection
  }, [hasManualCorrection])

  useEffect(() => {
    trainingSampleModeRef.current = trainingSampleMode
  }, [trainingSampleMode])

  const boxLabels = useMemo(() => {
    return uniqStrings([
      ...labels.people,
      ...labels.bodyParts,
      ...labels.objects,
      ...labels.clothing,
    ]).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
  }, [labels.people, labels.bodyParts, labels.objects, labels.clothing])

  const correctionBoxes = correction.boxes ?? []
  const hasTrainableFeedbackContent = useMemo(
    () => correctionHasTrainablePositionOrBoxes(correction),
    [correction]
  )
  const willSaveAsNegative = Boolean(sample) && !hasTrainableFeedbackContent

  const visibleBoxes = [
    ...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })),
    ...(drawingBox
      ? [{ box: drawingBox, index: -1, isDraft: true }]
      : []),
  ]

  const posePersons = useMemo(() => {
    return correction.posePersons ?? []
  }, [correction.posePersons])

  const hasPosePersons = posePersons.some((person) =>
    hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible)
  )
  const visiblePosePersonCount = posePersons.filter((person) =>
    hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible)
  ).length
  const visiblePosePersonIndexes = useMemo(
    () =>
      posePersons
        .map((person, index) => ({ person, index }))
        .filter(
          ({ person }) =>
            hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible)
        )
        .map(({ index }) => index),
    [posePersons]
  )
  const originalPosePersonCount = sample?.prediction.persons?.length ?? 0
  const firstVisiblePosePersonIndex = posePersons.findIndex((person) =>
    hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible)
  )
  const activePosePerson =
    activePosePersonIndex !== null ? posePersons[activePosePersonIndex] : undefined
  const activePosePersonVisible = Boolean(
    activePosePerson &&
      (hasVisiblePoseBox(activePosePerson) ||
        activePosePerson.keypoints?.some(isPoseKeypointVisible))
  )
  const poseCorrectionPersonIndex =
    activePosePersonIndex !== null && activePosePersonVisible
      ? activePosePersonIndex
      : visiblePosePersonCount === 1
        ? firstVisiblePosePersonIndex
        : null
  const poseCorrectionPerson =
    poseCorrectionPersonIndex !== null ? posePersons[poseCorrectionPersonIndex] : undefined
  const poseCorrectionVisibleIndex =
    poseCorrectionPersonIndex !== null
      ? visiblePosePersonIndexes.indexOf(poseCorrectionPersonIndex)
      : -1

  useEffect(() => {
    setActivePosePersonIndex((current) =>
      current !== null && current >= posePersons.length ? null : current
    )
  }, [posePersons.length])

  const bodyPartScores = useMemo(() => {
    return Object.fromEntries(
      (sample?.prediction.bodyPartsPresent ?? [])
        .filter((x) => x.label)
        .map((x) => [x.label, x.score])
    )
  }, [sample?.prediction.bodyPartsPresent])

  const objectScores = useMemo(() => {
    return Object.fromEntries(
      (sample?.prediction.objectsPresent ?? [])
        .filter((x) => x.label)
        .map((x) => [x.label, x.score])
    )
  }, [sample?.prediction.objectsPresent])

  const clothingScores = useMemo(() => {
    return Object.fromEntries(
      (sample?.prediction.clothingPresent ?? [])
        .filter((x) => x.label)
        .map((x) => [x.label, x.score])
    )
  }, [sample?.prediction.clothingPresent])

  const peopleScores = useMemo(() => {
    const bestScores = new Map<string, number>()

    for (const box of sample?.prediction.boxes ?? []) {
      const cleanLabel = String(box.label || '').trim()
      const n = Number(box.score)

      if (!cleanLabel || !labels.people.includes(cleanLabel)) continue
      if (!Number.isFinite(n)) continue

      const current = bestScores.get(cleanLabel)
      if (current === undefined || n > current) {
        bestScores.set(cleanLabel, n)
      }
    }

    return Object.fromEntries(bestScores)
  }, [sample?.prediction.boxes, labels.people])

  const peopleBoxCounts = useMemo(() => {
    const counts = new Map<string, number>()

    for (const box of correctionBoxes) {
      const cleanLabel = String(box.label || '').trim()
      if (!labels.people.includes(cleanLabel)) continue

      counts.set(cleanLabel, (counts.get(cleanLabel) ?? 0) + 1)
    }

    return Object.fromEntries(counts)
  }, [correctionBoxes, labels.people])

  const selectedPeopleLabels = useMemo(() => {
    return correction.peoplePresent
  }, [correction.peoplePresent])

  const drawLabelForSection = useCallback((values: string[]) => {
    const clean = String(boxLabel || '').trim()
    return values.includes(clean) ? clean : ''
  }, [boxLabel])

  const imageSrc = useMemo(() => {
    if (!sample?.frameUrl) return ''

    const sep = sample.frameUrl.includes('?') ? '&' : '?'

    return `${sample.frameUrl}${sep}t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
  }, [sample?.frameUrl, sample?.sampleId, imageReloadKey])

  useEffect(() => {
    poseFaceImagePanGestureRef.current = null
    poseFaceImagePanCenterRef.current = null
    setPoseFaceImagePanCenter(null)
    setPoseFaceImagePanning(false)
  }, [imageSrc, poseCorrectionPersonIndex, poseKeypointMapView])

  useEffect(() => {
    if (poseFaceImageZoomOutActiveRef.current) {
      return
    }

    if (poseFaceImageCameraAnimationFrameRef.current !== null) {
      cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current)
      poseFaceImageCameraAnimationFrameRef.current = null
    }

    poseFaceImageDisplayCameraRef.current = null
    setPoseFaceImageDisplayCamera(null)
    setPoseFaceImageCameraAnimating(false)
  }, [imageSrc, poseKeypointMapView])

  const loadingPreviewRawUrlRef = useRef('')

  const setLoadingPreviewCandidate = useCallback((url: string) => {
    const clean = String(url || '').trim()

    if (!clean) {
      loadingPreviewRawUrlRef.current = ''
      setLoadingPreviewUrl('')
      setLoadingPreviewFallbackUrl('')
      setLoadingPreviewLoaded(false)
      setLoadingPreviewFailed(false)
      return
    }

    // Wichtig:
    // Bei gleichem Preview nicht neu cache-busten und nicht loaded=false setzen.
    // Sonst flackert das Overlay bei jedem Phasenwechsel.
    if (loadingPreviewRawUrlRef.current === clean) {
      return
    }

    loadingPreviewRawUrlRef.current = clean

    const sep = clean.includes('?') ? '&' : '?'

    setLoadingPreviewUrl(`${clean}${sep}r=${Date.now()}`)
    setLoadingPreviewLoaded(false)
    setLoadingPreviewFailed(false)
  }, [])

  const applyTrainingAnalysisEvent = useCallback((raw: any, opts?: { requireActiveRequest?: boolean }) => {
    const data = raw?.analysis || raw
    if (!data) return false

    const requestId = String(
      data?.requestId ||
      data?.analysisRequestId ||
      ''
    ).trim()

    const activeRequestId = activeAnalysisRequestIdRef.current
    if (opts?.requireActiveRequest && (!activeRequestId || requestId !== activeRequestId)) {
      return false
    }

    const scope = String(data?.scope || '').trim()
    if (scope && scope !== 'training') {
      return false
    }

    const running = Boolean(data?.running)
    if (running) {
      loadingRef.current = true
      setLoading(true)
    }

    const sourceFile = String(data?.sourceFile || '').trim()
    if (sourceFile) {
      setAnalysisSourceFile(sourceFile)
    }

    const previewUrl = String(data?.previewUrl || '').trim()
    if (previewUrl) {
      setLoadingPreviewCandidate(previewUrl)
    }

    const message = String(
      data?.message ||
      data?.step ||
      data?.title ||
      ''
    ).trim()

    if (message) {
      setAnalysisStep(message)
    }

    const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
    const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
    const rawProgress = Number(data?.progress ?? data?.percent)

    let nextProgress: number | null = null

    if (
      Number.isFinite(current) &&
      Number.isFinite(total) &&
      total > 0
    ) {
      nextProgress = (current / total) * 100
    } else if (Number.isFinite(rawProgress)) {
      nextProgress = rawProgress <= 1 ? rawProgress * 100 : rawProgress
    }

    if (nextProgress !== null) {
      setAnalysisProgress((prev) =>
        running
          ? Math.max(prev, clampPercent(nextProgress))
          : clampPercent(nextProgress)
      )
    }

    return true
  }, [setLoadingPreviewCandidate])

  useEffect(() => {
    if (!imageSrc) {
      setFrameImageLoaded(false)
      return
    }

    setFrameImageLoaded(false)
  }, [imageSrc])

  const canStartTraining = Boolean(trainingStatus?.canTrain)
  const feedbackCount = trainingStatus?.feedbackCount ?? 0
  const requiredCount = trainingStatus?.requiredCount ?? 5
  const feedbackBadgeText =
    feedbackCount > requiredCount
      ? String(feedbackCount)
      : `${feedbackCount}/${requiredCount}`

  const trainingRunning = training || Boolean(trainingStatus?.training?.running)
  const uiLocked = loading || saving || trainingRunning || deletingTrainingData
  const serverTrainingProgress = trainingRunning
    ? trainingStatus?.training?.progress ?? trainingProgress
    : trainingProgress
  const shownTrainingStep = trainingRunning
    ? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
    : trainingStep

  const trainingHistoryFloorMs = useMemo(
    () => trainingHistoryDurationFloorMs(trainingHistory),
    [trainingHistory]
  )
  const trainingEstimateRuntime = useMemo(
    () => trainingEstimateRuntimeFromSettings(trainingEstimateSettings),
    [trainingEstimateSettings]
  )
  const trainingEstimateRuntimeLabel = useMemo(
    () => trainingEstimateRuntimeText(trainingEstimateRuntime),
    [trainingEstimateRuntime]
  )
  const trainingEstimateDetectorEpochs = useMemo(
    () => trainingEstimateInt(trainingEstimateSettings?.trainingDetectorEpochs, 60, 1, 300),
    [trainingEstimateSettings?.trainingDetectorEpochs]
  )

  const trainingStartOptions = useMemo(() => {
    const feedbackReady = feedbackCount >= requiredCount
    const detector = trainingStatus?.detector
    const pose = trainingStatus?.pose
    const videoMAE = trainingStatus?.videoMAE

    const detectorReady = feedbackReady && Boolean(detector?.dataReady)
    const poseReady = feedbackReady && Boolean(pose?.dataReady)
    const videoMAEReady = feedbackReady && Boolean(videoMAE?.dataReady)
    const detectorHasHistory = trainingHasTargetHistory(trainingHistory, 'detector')
    const poseHasHistory = trainingHasTargetHistory(trainingHistory, 'pose')
    const videoMAEHasHistory = trainingHasTargetHistory(trainingHistory, 'videomae')
    const detectorHistoryEstimateMs = estimateTrainingHistoryDurationMs(
      trainingHistory,
      'detector',
      Number(detector?.trainCount ?? 0),
      Number(detector?.valCount ?? 0),
      0,
      trainingEstimateRuntime,
      trainingEstimateDetectorEpochs
    )
    const poseHistoryEstimateMs = estimateTrainingHistoryDurationMs(
      trainingHistory,
      'pose',
      Number(pose?.trainCount ?? 0),
      Number(pose?.valCount ?? 0),
      0,
      trainingEstimateRuntime,
      trainingEstimateDetectorEpochs
    )
    const videoMAEHistoryEstimateMs = estimateTrainingHistoryDurationMs(
      trainingHistory,
      'videomae',
      Number(videoMAE?.trainCount ?? 0),
      Number(videoMAE?.valCount ?? 0),
      Number(videoMAE?.eligibleCount ?? 0),
      trainingEstimateRuntime,
      8
    )
    const detectorFineTuneFactor = trainingFineTuneEstimateFactor(
      'detector',
      Boolean(detector?.trainedModelExists),
      detectorHasHistory
    )
    const poseFineTuneFactor = trainingFineTuneEstimateFactor(
      'pose',
      Boolean(pose?.trainedModelExists),
      poseHasHistory
    )
    const videoMAEFineTuneFactor = trainingFineTuneEstimateFactor(
      'videomae',
      Boolean(videoMAE?.trainedModelExists),
      videoMAEHasHistory
    )
    const detectorFallbackHistoryMs = detectorHistoryEstimateMs ||
      (trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * detectorFineTuneFactor : 0)
    const poseFallbackHistoryMs = poseHistoryEstimateMs ||
      (trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * poseFineTuneFactor : 0)
    const videoMAEFallbackHistoryMs = videoMAEHistoryEstimateMs ||
      (trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * videoMAEFineTuneFactor : 0)
    const detectorEstimateMs = estimateTrainingDurationMs(
      'detector',
      Number(detector?.trainCount ?? 0),
      Number(detector?.valCount ?? 0),
      0,
      detectorFallbackHistoryMs,
      trainingEstimateRuntime.yoloFactor,
      trainingEstimateDetectorEpochs,
      detectorFineTuneFactor
    )
    const poseEstimateMs = estimateTrainingDurationMs(
      'pose',
      Number(pose?.trainCount ?? 0),
      Number(pose?.valCount ?? 0),
      0,
      poseFallbackHistoryMs,
      trainingEstimateRuntime.yoloFactor,
      trainingEstimateDetectorEpochs,
      poseFineTuneFactor
    )
    const videoMAEEstimateMs = estimateTrainingDurationMs(
      'videomae',
      Number(videoMAE?.trainCount ?? 0),
      Number(videoMAE?.valCount ?? 0),
      Number(videoMAE?.eligibleCount ?? 0),
      videoMAEFallbackHistoryMs,
      trainingEstimateRuntime.factor,
      60,
      videoMAEFineTuneFactor
    )

    return [
      {
        key: 'detector' as const,
        label: 'YOLO26 Detector',
        icon: RectangleGroupIcon,
        description: 'Boxen, Personen, Körperteile, Objekte und Kleidung.',
        ready: detectorReady,
        estimateMs: detectorEstimateMs,
        estimateText: formatApproxTrainingDuration(detectorEstimateMs),
        detail: `Train ${Number(detector?.trainCount ?? 0)}/${Number(detector?.requiredTrain ?? 20)}, Val ${Number(detector?.valCount ?? 0)}/${Number(detector?.requiredVal ?? 3)}, Positiv ${Number(detector?.positiveTrainCount ?? 0)}/${Number(detector?.positiveValCount ?? 0)}${detector?.trainedModelExists ? ', Fine-Tuning' : ''}`,
        blockedText: !feedbackReady
          ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
          : 'Detector-Datensatz ist noch nicht bereit.',
      },
      {
        key: 'pose' as const,
        label: 'YOLO26 Pose',
        icon: UserGroupIcon,
        description: 'Keypoints und Positions-Kontext aus Skeleton-Beispielen.',
        ready: poseReady,
        estimateMs: poseEstimateMs,
        estimateText: formatApproxTrainingDuration(poseEstimateMs),
        detail: `Train ${Number(pose?.trainCount ?? 0)}/${Number(pose?.requiredTrain ?? 20)}, Val ${Number(pose?.valCount ?? 0)}/${Number(pose?.requiredVal ?? 3)}${pose?.trainedModelExists ? ', Fine-Tuning' : ''}`,
        blockedText: !feedbackReady
          ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
          : 'Pose-Datensatz ist noch nicht bereit.',
      },
      {
        key: 'videomae' as const,
        label: 'VideoMAE Clip-Analyse',
        icon: VideoCameraIcon,
        description: 'Clip-basierte Positionsanalyse aus mehreren Video-Frames.',
        ready: videoMAEReady,
        estimateMs: videoMAEEstimateMs,
        estimateText: formatApproxTrainingDuration(videoMAEEstimateMs),
        detail: `Eligible ${Number(videoMAE?.eligibleCount ?? 0)}, Train ${Number(videoMAE?.trainCount ?? 0)}/${Number(videoMAE?.requiredTrain ?? 40)}, Val ${Number(videoMAE?.valCount ?? 0)}/${Number(videoMAE?.requiredVal ?? 5)}${videoMAE?.trainedModelExists ? ', Fine-Tuning' : ''}`,
        blockedText: !feedbackReady
          ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
          : 'VideoMAE-Datensatz ist noch nicht bereit.',
      },
    ]
  }, [
    feedbackCount,
    requiredCount,
    trainingStatus?.detector,
    trainingStatus?.pose,
    trainingStatus?.videoMAE,
    trainingHistory,
    trainingHistoryFloorMs,
    trainingEstimateRuntime.factor,
    trainingEstimateRuntime.yoloFactor,
    trainingEstimateDetectorEpochs,
  ])

  const selectableTrainingTargets = useMemo(
    () => trainingStartOptions
      .filter((option) => option.ready)
      .map((option) => option.key),
    [trainingStartOptions]
  )

  const selectedTrainingTargets = useMemo(
    () => trainingStartTargets.filter((key) => selectableTrainingTargets.includes(key)),
    [selectableTrainingTargets, trainingStartTargets]
  )

  const canConfirmTrainingStart =
    trainingStartMode === 'full'
      ? canStartTraining
      : selectedTrainingTargets.length > 0

  const plannedTrainingTargets = useMemo(
    () => trainingStartMode === 'full'
      ? selectableTrainingTargets
      : selectedTrainingTargets,
    [selectableTrainingTargets, selectedTrainingTargets, trainingStartMode]
  )

  const trainingStartTotalEstimateMs = useMemo(() => {
    const planned = new Set(plannedTrainingTargets)
    const estimate = trainingStartOptions.reduce((sum, option) => (
      planned.has(option.key) ? sum + Number(option.estimateMs ?? 0) : sum
    ), 0)

    return trainingHistoryFloorMs > 0 && plannedTrainingTargets.length > 1
      ? Math.max(estimate, trainingHistoryFloorMs)
      : estimate
  }, [plannedTrainingTargets, trainingHistoryFloorMs, trainingStartOptions])

  const trainingStartTotalEstimateText =
    plannedTrainingTargets.length > 0
      ? formatApproxTrainingDuration(trainingStartTotalEstimateMs)
      : 'ca. —'

  const trainingEstimateByTarget = useMemo(() => {
    const estimates: Record<TrainingTargetKey, number> = {
      detector: 0,
      pose: 0,
      videomae: 0,
    }

    for (const option of trainingStartOptions) {
      estimates[option.key] = Math.max(0, Number(option.estimateMs ?? 0) || 0)
    }

    return estimates
  }, [trainingStartOptions])

  const activeTrainingTarget = useMemo(
    () => trainingTargetFromStageText(
      trainingStatus?.training?.stage,
      trainingStatus?.training?.step
    ),
    [
      trainingStatus?.training?.stage,
      trainingStatus?.training?.step,
    ]
  )

  const effectiveTrainingTargets = useMemo(() => {
    const selected = activeTrainingTargets.filter(isTrainingTargetKey)
    if (selected.length > 0) return selected

    if (selectableTrainingTargets.length > 0) {
      return selectableTrainingTargets
    }

    if (activeTrainingTarget) {
      const activeIndex = ALL_TRAINING_TARGETS.indexOf(activeTrainingTarget)
      return activeIndex >= 0
        ? ALL_TRAINING_TARGETS.slice(activeIndex)
        : [activeTrainingTarget]
    }

    return ALL_TRAINING_TARGETS
  }, [
    activeTrainingTarget,
    activeTrainingTargets,
    selectableTrainingTargets,
  ])

  const currentTrainingTarget = useMemo<TrainingTargetKey | ''>(() => {
    if (!trainingRunning || effectiveTrainingTargets.length === 0) return ''

    const progress = clampPercent(Number(serverTrainingProgress) || 0)
    const activeIndex = activeTrainingTarget
      ? effectiveTrainingTargets.indexOf(activeTrainingTarget)
      : -1

    if (activeIndex >= 0) {
      const window = trainingTargetProgressWindow(activeTrainingTarget)
      if (progress < window.end || activeIndex === effectiveTrainingTargets.length - 1) {
        return activeTrainingTarget
      }
    }

    return effectiveTrainingTargets.find((target) => {
      const window = trainingTargetProgressWindow(target)
      return progress < window.end
    }) ?? effectiveTrainingTargets[effectiveTrainingTargets.length - 1] ?? ''
  }, [
    activeTrainingTarget,
    effectiveTrainingTargets,
    serverTrainingProgress,
    trainingRunning,
  ])

  const currentTrainingTargetProgress = useMemo(() => {
    if (!currentTrainingTarget) return 0

    const localProgress = trainingLocalProgressFromJob(
      trainingStatus?.training,
      currentTrainingTarget
    )
    if (localProgress !== null) return localProgress

    const window = trainingTargetProgressWindow(currentTrainingTarget)
    const span = Math.max(1, window.end - window.start)
    const progress = clampPercent(Number(serverTrainingProgress) || 0)

    return clamp01((progress - window.start) / span)
  }, [
    currentTrainingTarget,
    serverTrainingProgress,
    trainingStatus?.training?.stage,
    trainingStatus?.training?.stageProgress,
    trainingStatus?.training?.step,
  ])

  const estimatedTrainingProgress = useMemo(() => {
    if (!trainingRunning || effectiveTrainingTargets.length === 0) return 0

    const totalEstimateMs = effectiveTrainingTargets.reduce(
      (sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0),
      0
    )

    if (totalEstimateMs <= 0) return 0

    const currentTarget = currentTrainingTarget
    if (!currentTarget) return 0

    const currentIndex = effectiveTrainingTargets.indexOf(currentTarget)

    if (currentIndex < 0) return 0

    const completedMs = effectiveTrainingTargets
      .slice(0, currentIndex)
      .reduce((sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0), 0)
    const currentMs = Math.max(0, trainingEstimateByTarget[currentTarget] || 0)
    const progressMs = completedMs + currentMs * currentTrainingTargetProgress

    return clampPercent((progressMs / totalEstimateMs) * 100)
  }, [
    currentTrainingTarget,
    currentTrainingTargetProgress,
    effectiveTrainingTargets,
    trainingEstimateByTarget,
    trainingRunning,
  ])

  const serverTrainingProgressPercent = clampPercent(Number(serverTrainingProgress) || 0)
  const shownTrainingProgress = trainingRunning
    ? estimatedTrainingProgress > 0
      ? estimatedTrainingProgress
      : serverTrainingProgressPercent
    : serverTrainingProgress

  const drawingCursorClass =
    boxInteraction?.type === 'move'
      ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing'
      : drawingBox || pendingPoseKeypoint || (boxLabel && !uiLocked)
        ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-crosshair'
        : ''

  const trainingInfoJob = trainingStatus?.training ?? null
  const trainingInfoKey = useMemo(() => {
    const job = trainingInfoJob

    if (!job || job.running) return ''

    const finishedAt = String(job.finishedAt || '').trim()
    const startedAt = String(job.startedAt || '').trim()
    const message = String(job.message || job.error || '').trim()

    if (finishedAt) return finishedAt
    if (startedAt && message) return `${startedAt}:${message}`

    return ''
  }, [
    trainingInfoJob?.running,
    trainingInfoJob?.finishedAt,
    trainingInfoJob?.startedAt,
    trainingInfoJob?.message,
    trainingInfoJob?.error,
  ])

  const showTrainingInfo =
    Boolean(trainingInfoKey) &&
    !trainingRunning &&
    dismissedTrainingInfoKey !== trainingInfoKey

  const trainingInfoMessage = String(
    trainingInfoJob?.message ||
    trainingInfoJob?.error ||
    'Training abgeschlossen.'
  ).trim()

  const trainingInfoDurationMs = trainingDurationMs(trainingInfoJob)

  const trainingInfoLooksPartial =
    Boolean(trainingInfoJob?.error) ||
    /übersprungen|fehlgeschlagen|abgebrochen/i.test(trainingInfoMessage)

  const dismissTrainingInfo = useCallback(() => {
    if (!trainingInfoKey) return

    setDismissedTrainingInfoKey(trainingInfoKey)

    try {
      window.localStorage.setItem(
        TRAINING_INFO_DISMISSED_STORAGE_KEY,
        trainingInfoKey
      )
    } catch {
      // ignore
    }
  }, [trainingInfoKey])

  const analysisConfidence = useMemo(() => {
    return currentAnalysisConfidence(sample?.prediction)
  }, [sample?.prediction])

  const loadLabels = useCallback(async () => {
    const res = await fetch('/api/training/labels', { cache: 'no-store' })
    if (!res.ok) return

    const data = await res.json().catch(() => null)
    if (data) setLabels(sortTrainingLabels(data))
  }, [])

  const applyTrainingStatus = useCallback((data: any) => {
    if (!data) return

    const job = data.training || null
    const videoMAE = data.videoMAE || data.videomae || data.scene || null

    setTrainingStatus((prev) => ({
      feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0),
      requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5),
      canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false),

      detector: data.detector
        ? {
            trainCount: Number(data.detector.trainCount ?? 0),
            valCount: Number(data.detector.valCount ?? 0),
            positiveTrainCount: Number(data.detector.positiveTrainCount ?? 0),
            positiveValCount: Number(data.detector.positiveValCount ?? 0),
            requiredTrain: Number(data.detector.requiredTrain ?? 20),
            requiredVal: Number(data.detector.requiredVal ?? 3),
            datasetReady: Boolean(data.detector.datasetReady),
            dataReady: Boolean(data.detector.dataReady),
            modelExists: Boolean(data.detector.modelExists),
            modelPath: data.detector.modelPath,
            trainedModelExists: Boolean(data.detector.trainedModelExists ?? data.detector.modelExists),
            trainedModelPath: data.detector.trainedModelPath,
            source: data.detector.source || data.detector.modelSource,
          }
        : prev?.detector,

      pose: data.pose
        ? {
            trainCount: Number(data.pose.trainCount ?? 0),
            valCount: Number(data.pose.valCount ?? 0),
            requiredTrain: Number(data.pose.requiredTrain ?? 20),
            requiredVal: Number(data.pose.requiredVal ?? 3),
            datasetReady: Boolean(data.pose.datasetReady),
            dataReady: Boolean(data.pose.dataReady),
            modelExists: Boolean(data.pose.modelExists),
            modelPath: data.pose.modelPath,
            trainedModelExists: Boolean(data.pose.trainedModelExists ?? data.pose.modelExists),
            trainedModelPath: data.pose.trainedModelPath,
            source: data.pose.source || data.pose.modelSource,
          }
        : prev?.pose,

      videoMAE: videoMAE
        ? {
            eligibleCount: Number(videoMAE.eligibleCount ?? 0),
            trainCount: Number(videoMAE.trainCount ?? 0),
            valCount: Number(videoMAE.valCount ?? 0),
            requiredTrain: Number(videoMAE.requiredTrain ?? videoMAE.requiredCount ?? 40),
            requiredVal: Number(videoMAE.requiredVal ?? 5),
            datasetReady: Boolean(videoMAE.datasetReady),
            dataReady: Boolean(videoMAE.dataReady),
            modelExists: Boolean(videoMAE.modelExists ?? videoMAE.modelReady),
            modelPath: videoMAE.modelPath,
            trainedModelExists: Boolean(
              videoMAE.trainedModelExists ??
                videoMAE.trainedModelReady ??
                videoMAE.modelExists ??
                videoMAE.modelReady
            ),
            trainedModelPath: videoMAE.trainedModelPath,
            source: videoMAE.source || videoMAE.modelSource,
          }
        : prev?.videoMAE,

      training: job
        ? {
            running: Boolean(job.running),
            progress: Number(job.progress ?? 0),
            step: String(job.step ?? ''),
            message: job.message,
            error: job.error,
            startedAt: job.startedAt,
            finishedAt: job.finishedAt,
            durationMs: Number(job.durationMs ?? 0),
            stage: job.stage,
            stageStartedAt: String(job.stageStartedAt ?? ''),
            stageProgress:
              job.stageProgress === undefined || job.stageProgress === null
                ? undefined
                : Number(job.stageProgress),
            epoch: Number(job.epoch ?? 0),
            epochs: Number(job.epochs ?? 0),
            previewUrl: String(job.previewUrl ?? ''),
            paused: Boolean(job.paused),
            pauseReason: String(job.pauseReason ?? ''),
            cpuPercent: Number(job.cpuPercent ?? 0),
            temperatureC: Number(job.temperatureC ?? 0),
            map50: Number(job.map50 ?? 0),
            map5095: Number(job.map5095 ?? 0),
            accuracy: Number(job.accuracy ?? 0),
            loss: Number(job.loss ?? 0),
          }
        : prev?.training,
    }))

    setTraining(Boolean(job?.running))

    if (job?.message && !job.running) {
      setTrainingProgress(100)
      setTrainingStep('Training abgeschlossen.')

      const finishedAt = String(job.finishedAt || '').trim()
      const completionKey =
        finishedAt || `${String(job.startedAt || '')}:${String(job.message || '')}`

      if (completionKey && shownTrainingCompletionRef.current !== completionKey) {
        shownTrainingCompletionRef.current = completionKey

        const duration = trainingDurationMs(job)
        const durationText = duration > 0
          ? ` Dauer: ${formatDuration(duration)}.`
          : ''

        setMessage(`${String(job.message)}${durationText}`)
      }
    }

    if (job?.error && !job.running) {
      const finishedAt = String(job.finishedAt || '').trim()
      const errorKey = finishedAt
        ? `${finishedAt}:error`
        : `${String(job.startedAt || '')}:error`

      if (errorKey && shownTrainingCompletionRef.current !== errorKey) {
        shownTrainingCompletionRef.current = errorKey
        setError(String(job.error))
      }
    }
  }, [])

  const loadTrainingSampleIntoTab = useCallback((
    nextSample: TrainingSample,
    opts?: { manualCorrection?: boolean; correction?: CorrectionState }
  ) => {
    const nextCorrection = opts?.correction
      ? cloneCorrectionState(opts.correction)
      : predictionToCorrection(nextSample)

    setDrawingBox(null)
    setBoxInteraction(null)
    setTouchMagnifier(null)
    setBoxLabel('')
    setActiveBoxIndex(null)
    resetPoseUiToBoxes()
    setMobilePanel(trainingRunningRef.current ? 'training' : 'labels')

    window.requestAnimationFrame(() => {
      mobileLabelsScrollRef.current?.scrollIntoView({
        block: 'start',
        behavior: 'smooth',
      })
    })

    sampleRef.current = nextSample
    correctionRef.current = nextCorrection
    hasManualCorrectionRef.current = Boolean(opts?.manualCorrection)
    setSample(nextSample)
    setCorrection(nextCorrection)
    setHasManualCorrection(Boolean(opts?.manualCorrection))

    const initiallyExpandedSection: CorrectionSectionKey | null =
      nextCorrection.sexPosition && !isNoSexPositionValue(nextCorrection.sexPosition)
        ? 'sexPosition'
        : nextCorrection.peoplePresent.length > 0
          ? 'people'
          : nextCorrection.bodyPartsPresent.length > 0
            ? 'bodyParts'
            : nextCorrection.objectsPresent.length > 0
              ? 'objects'
              : nextCorrection.clothingPresent.length > 0
                ? 'clothing'
                : null

    setExpandedCorrectionSections(
      initiallyExpandedSection
        ? nextExpandedCorrectionSections(initiallyExpandedSection, true)
        : {
            sexPosition: false,
            people: false,
            bodyParts: false,
            objects: false,
            clothing: false,
          }
    )
  }, [resetPoseUiToBoxes])

  const setQueuedTrainingSamples = useCallback((nextQueue: QueuedTrainingSample[]) => {
    importedSampleQueueRef.current = nextQueue
    setImportedSampleQueue(nextQueue)
  }, [])

  const currentSampleToQueuedItem = useCallback((): QueuedTrainingSample | null => {
    const currentSample = sampleRef.current

    if (!currentSample) {
      return null
    }

    return {
      sample: currentSample,
      correction: cloneCorrectionState(correctionRef.current),
      manualCorrection: hasManualCorrectionRef.current,
    }
  }, [])

  const loadQueuedTrainingSample = useCallback((item: QueuedTrainingSample) => {
    loadTrainingSampleIntoTab(item.sample, {
      correction: item.correction,
      manualCorrection: item.manualCorrection,
    })
  }, [loadTrainingSampleIntoTab])

  const loadNextImportedQueuedSample = useCallback(() => {
    const [nextItem, ...rest] = importedSampleQueueRef.current

    if (!nextItem) {
      return false
    }

    setQueuedTrainingSamples(rest)

    loadQueuedTrainingSample(nextItem)

    return true
  }, [loadQueuedTrainingSample, setQueuedTrainingSamples])

  const deferCurrentSampleToQueueEnd = useCallback(() => {
    const currentItem = currentSampleToQueuedItem()

    if (!currentItem) {
      return false
    }

    setQueuedTrainingSamples([...importedSampleQueueRef.current, currentItem])
    return true
  }, [currentSampleToQueuedItem, setQueuedTrainingSamples])

  const loadPriorityTrainingSamples = useCallback((
    prioritySamples: TrainingSample[],
    opts?: { deferCurrentSampleToQueueEnd?: boolean }
  ) => {
    const priorityItems = prioritySamples.map((prioritySample) => ({
      sample: prioritySample,
    }))

    if (priorityItems.length === 0) {
      return false
    }

    const currentItem = opts?.deferCurrentSampleToQueueEnd
      ? currentSampleToQueuedItem()
      : null
    const nextQueue = [
      ...priorityItems,
      ...importedSampleQueueRef.current,
      ...(currentItem ? [currentItem] : []),
    ]
    const [nextItem, ...rest] = nextQueue

    if (!nextItem) {
      return false
    }

    setQueuedTrainingSamples(rest)
    loadQueuedTrainingSample(nextItem)

    return true
  }, [currentSampleToQueuedItem, loadQueuedTrainingSample, setQueuedTrainingSamples])

  const completeNextFromData = useCallback((data: any, opts?: { deferCurrentSampleToQueueEnd?: boolean }) => {
    const nextSample = data?.sample || (
      data?.sampleId && data?.frameUrl
        ? data as TrainingSample
        : null
    )

    if (!nextSample) {
      throw new Error(backendText(data, 'Es wurde kein Trainingsbild erzeugt.'))
    }

    setAnalysisProgress(92)
    setAnalysisStep('Analyse-Ergebnis wird übernommen…')

    if (opts?.deferCurrentSampleToQueueEnd) {
      deferCurrentSampleToQueueEnd()
    }

    loadTrainingSampleIntoTab(nextSample as TrainingSample)
    setImageReloadKey((value) => value + 1)
    return true
  }, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab])

  const waitForNextResult = useCallback(async (
    requestId: string,
    opts?: { deferCurrentSampleToQueueEnd?: boolean }
  ) => {
    const id = String(requestId || '').trim()
    if (!id) throw new Error('requestId fehlt.')

    for (;;) {
      const res = await fetch(
        `/api/training/next/status?requestId=${encodeURIComponent(id)}`,
        { cache: 'no-store' }
      )
      const data = await res.json().catch(() => null)

      if (data?.analysis) {
        applyTrainingAnalysisEvent(data.analysis)
      }

      if (res.status === 202 || data?.running) {
        await new Promise((resolve) => window.setTimeout(resolve, 800))
        continue
      }

      if (!res.ok || !data?.ok) {
        throw new Error(backendText(data, `HTTP ${res.status}`))
      }

      return completeNextFromData(data, opts)
    }
  }, [applyTrainingAnalysisEvent, completeNextFromData])

  const loadNext = useCallback(async (opts?: {
      forceNew?: boolean
      refreshPrediction?: boolean
      preserveNotice?: boolean
      mode?: TrainingSampleMode
      previewUrl?: string
      deferCurrentSampleToQueueEnd?: boolean
    }) => {
      const requestId = makeRequestId()
      activeAnalysisRequestIdRef.current = requestId
      nextAnalysisInFlightRequestIdRef.current = requestId
      const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId

      const mode = opts?.mode ?? trainingSampleModeRef.current
      const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction

      const previewUrl = String(opts?.previewUrl ?? '').trim()

      setLoadingPreviewFallbackUrl(previewUrl)
      setLoadingPreviewCandidate(previewUrl)
      loadingRef.current = true
      setLoading(true)
      resetPoseUiToBoxes()
      setAnalysisSourceFile('')
      setAnalysisProgress(8)
      setAnalysisStep(
      opts?.refreshPrediction
        ? 'Aktuelles Bild wird neu analysiert…'
        : uncertainMode
          ? 'Unsichere Prediction wird gesucht…'
          : opts?.forceNew
            ? 'Neues Trainingsbild wird gesucht…'
            : 'Trainingsbild wird geladen…'
    )

    if (!opts?.preserveNotice) {
      setError(null)
      setMessage(null)
    }

    let keepActiveJob = false
    let completed = false

    try {
      const params = new URLSearchParams()

      params.set('analysisRequestId', requestId)
      params.set('async', '1')

      if (opts?.forceNew) params.set('force', '1')
      if (opts?.refreshPrediction) params.set('refresh', '1')
      if (uncertainMode) params.set('mode', 'uncertain')

      try {
        window.sessionStorage.setItem(
          TRAINING_ACTIVE_NEXT_STORAGE_KEY,
          JSON.stringify({
            requestId,
            opts: {
              forceNew: Boolean(opts?.forceNew),
              refreshPrediction: Boolean(opts?.refreshPrediction),
              mode,
              previewUrl,
              deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
            },
          })
        )
      } catch {
        // ignore
      }

      const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}`

      setAnalysisProgress(uncertainMode ? 5 : 25)
      setAnalysisStep(
        uncertainMode
          ? 'Mehrere Kandidaten werden vorbereitet…'
          : 'Bild wird vorbereitet…'
      )

      const res = await fetch(url, { cache: 'no-store' })
      const data = await res.json().catch(() => null)

      if (!res.ok) {
        throw new Error(data?.error || `HTTP ${res.status}`)
      }

      if (!isCurrentRequest()) {
        return
      }

      if (data?.analysis) {
        applyTrainingAnalysisEvent(data.analysis)
      }

      if (res.status === 202 || data?.accepted || data?.running) {
        await waitForNextResult(requestId, {
          deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
        })
      } else {
        completeNextFromData(data, {
          deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
        })
      }

      completed = true
    } catch (e) {
      if (isCurrentRequest()) {
        const msg = e instanceof Error ? e.message : String(e)
        const mayStillRun =
          /load failed|failed to fetch|networkerror|network error/i.test(msg)

        if (mayStillRun) {
          keepActiveJob = true
          setMessage('Analyse läuft im Backend weiter. Beim Zurückkehren wird das nächste offene Trainingsbild wieder geladen.')
        } else {
          try {
            window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
          } catch {
            // ignore
          }
          setError(msg)
        }
      }
    } finally {
      if (!isCurrentRequest()) {
        return
      }

      if (completed) {
        try {
          window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
        } catch {
          // ignore
        }
      }

      if (keepActiveJob) {
        return
      }

      setAnalysisProgress((value) => Math.max(value, 100))
      setAnalysisStep((value) => value || 'Analyse abgeschlossen.')

      const finishedRequestId = requestId

      window.setTimeout(() => {
        if (activeAnalysisRequestIdRef.current !== finishedRequestId) return

        activeAnalysisRequestIdRef.current = null
        nextAnalysisInFlightRequestIdRef.current = null
        loadingRef.current = false
        setLoading(false)
        setAnalysisSourceFile('')
        setAnalysisProgress(0)
        setAnalysisStep('')
      }, 500)
    }
  }, [
    applyTrainingAnalysisEvent,
    completeNextFromData,
    resetPoseUiToBoxes,
    setLoadingPreviewCandidate,
    waitForNextResult,
  ])

  const reloadCurrentImage = useCallback(async () => {
    setDrawingBox(null)
    setBoxInteraction(null)
    setTouchMagnifier(null)
    setActiveBoxIndex(null)

    await loadNext({
      refreshPrediction: true,
      previewUrl: imageSrc,
    })
    setImageReloadKey((value) => value + 1)
  }, [imageSrc, loadNext])

  const loadTrainingStatus = useCallback(async () => {
    const res = await fetch('/api/training/status', { cache: 'no-store' })
    const data = await res.json().catch(() => null)

    if (!res.ok || !data) return

    applyTrainingStatus(data)
  }, [applyTrainingStatus])

  const completeVideoImportFromData = useCallback(async (data: any) => {
    const rawSamples: TrainingSample[] = Array.isArray(data?.samples)
      ? data.samples
      : data?.sample
        ? [data.sample]
        : []
    const samples = withTrainingFrameLabels(rawSamples)

    if (samples.length === 0) {
      throw new Error('Es wurden keine Trainingsframes erzeugt.')
    }

    const deferredCurrentSample = Boolean(sampleRef.current)

    loadPriorityTrainingSamples(samples, {
      deferCurrentSampleToQueueEnd: deferredCurrentSample,
    })
    setImageReloadKey((value) => value + 1)

    await loadTrainingStatus()

    const errorCount = Array.isArray(data?.errors) ? data.errors.length : 0
    const baseMessage = errorCount > 0
      ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.`
      : `${samples.length} Frames ins Training übernommen.`

    setMessage(
      deferredCurrentSample
        ? `${baseMessage} Das aktuelle Bild wurde ans Ende der Queue gelegt.`
        : baseMessage
    )

    return true
  }, [loadPriorityTrainingSamples, loadTrainingStatus])

  const waitForVideoImportResult = useCallback(async (requestId: string) => {
    const id = String(requestId || '').trim()
    if (!id) throw new Error('requestId fehlt.')

    for (;;) {
      const res = await fetch(
        `/api/training/import-video/status?requestId=${encodeURIComponent(id)}`,
        { cache: 'no-store' }
      )
      const data = await res.json().catch(() => null)

      if (res.status === 202 || data?.running) {
        await new Promise((resolve) => window.setTimeout(resolve, 1200))
        continue
      }

      if (!res.ok || !data?.ok) {
        throw new Error(backendText(data, `HTTP ${res.status}`))
      }

      return completeVideoImportFromData(data)
    }
  }, [completeVideoImportFromData])

  const importVideoIntoTraining = useCallback(async (raw: any) => {
    const output = String(raw?.output || '').trim()
    if (!output) return false
    if (trainingRunning) {
      setMessage('Während das Training läuft, kann kein Video ins Training übernommen werden.')
      return false
    }

    setLoadingPreviewCandidate('')

    const TRAINING_IMPORT_FRAME_COUNT = 10

    const detail: PendingTrainingVideoImport = {
      jobId: String(raw?.jobId || '').trim(),
      output,
      sourceFile: String(raw?.sourceFile || '').trim(),
      count: Number(raw?.count || TRAINING_IMPORT_FRAME_COUNT),
    }

    const importKey = `${detail.jobId || ''}|${detail.output}|${detail.count || TRAINING_IMPORT_FRAME_COUNT}`

    // Verhindert Doppelimport durch sessionStorage + CustomEvent.
    if (videoImportInFlightKeyRef.current === importKey) {
      return false
    }

    videoImportStartedRef.current = true
    videoImportInFlightKeyRef.current = importKey

    const requestId = makeRequestId()
    activeAnalysisRequestIdRef.current = requestId
    loadingRef.current = true

    setLoading(true)
    setAnalysisSourceFile(detail.sourceFile || detail.output.split(/[\\/]/).pop() || '')
    setAnalysisProgress(5)
    setAnalysisStep('Video wird ins Training übernommen…')
    setError(null)
    setMessage(null)

    try {
      try {
        window.sessionStorage.removeItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY)
        window.sessionStorage.setItem(
          TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY,
          JSON.stringify({ requestId, importKey, detail })
        )
      } catch {
        // ignore
      }

      const res = await fetch('/api/training/import-video', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        cache: 'no-store',
        body: JSON.stringify({
          jobId: detail.jobId,
          output: detail.output,
          count: detail.count || TRAINING_IMPORT_FRAME_COUNT,
          analysisRequestId: requestId,
        }),
      })

      const data = await res.json().catch(() => null)

      if (!res.ok || !data?.ok) {
        throw new Error(backendText(data, `HTTP ${res.status}`))
      }

      if (res.status === 202 || data?.accepted || data?.running) {
        await waitForVideoImportResult(String(data?.requestId || requestId))

        try {
          window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
        } catch {
          // ignore
        }

        return true
      }

      const rawSamples: TrainingSample[] = Array.isArray(data.samples)
        ? data.samples
        : data.sample
          ? [data.sample]
          : []
      const samples = withTrainingFrameLabels(rawSamples)

      if (samples.length === 0) {
        throw new Error('Es wurden keine Trainingsframes erzeugt.')
      }

      const deferredCurrentSample = Boolean(sampleRef.current)

      loadPriorityTrainingSamples(samples, {
        deferCurrentSampleToQueueEnd: deferredCurrentSample,
      })
      setImageReloadKey((value) => value + 1)

      await loadTrainingStatus()

      const errorCount = Array.isArray(data.errors) ? data.errors.length : 0

      setMessage(
        errorCount > 0
          ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.`
          : `${samples.length} Frames ins Training übernommen.`
      )

      if (deferredCurrentSample) {
        setMessage(
          errorCount > 0
            ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen. Das aktuelle Bild wurde ans Ende der Queue gelegt.`
            : `${samples.length} Frames ins Training übernommen. Das aktuelle Bild wurde ans Ende der Queue gelegt.`
        )
      }

      return true
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e)
      const mayStillRun =
        /load failed|failed to fetch|networkerror|network error/i.test(msg)

      if (mayStillRun) {
        setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.')
      } else {
        try {
          window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
        } catch {
          // ignore
        }
        setError(msg)
      }
      return false
    } finally {
      setAnalysisProgress(100)
      setAnalysisStep('Video-Import abgeschlossen.')

      const finishedRequestId = requestId

      window.setTimeout(() => {
        if (activeAnalysisRequestIdRef.current === finishedRequestId) {
          activeAnalysisRequestIdRef.current = null
          loadingRef.current = false
          setLoading(false)
          setAnalysisSourceFile('')
          setAnalysisProgress(0)
          setAnalysisStep('')
        }

        if (videoImportInFlightKeyRef.current === importKey) {
          videoImportInFlightKeyRef.current = null
        }
      }, 500)
    }
  }, [
    loadPriorityTrainingSamples,
    loadTrainingStatus,
    setLoadingPreviewCandidate,
    trainingRunning,
    waitForVideoImportResult,
  ])

  const loadTrainingStats = useCallback(async () => {
    setTrainingStatsLoading(true)
    setTrainingStatsError(null)

    try {
      const res = await fetch('/api/training/stats', { cache: 'no-store' })
      const data = await res.json().catch(() => null)

      if (!res.ok) {
        throw new Error(data?.error || `HTTP ${res.status}`)
      }

      setTrainingStats({
        feedbackCount: Number(data?.feedbackCount ?? 0),
        acceptedCount: Number(data?.acceptedCount ?? 0),
        correctedCount: Number(data?.correctedCount ?? 0),
        negativeCount: Number(data?.negativeCount ?? 0),
        sampleCount: Number(data?.sampleCount ?? 0),
        boxCount: Number(data?.boxCount ?? 0),
        modelAvailable: Boolean(data?.modelAvailable),
        modelInfo: parseTrainingModelInfo(data?.modelInfo),
        detectorModelAvailable: Boolean(
          data?.detectorModelAvailable ?? data?.modelAvailable
        ),
        detectorModelInfo: parseTrainingModelInfo(
          data?.detectorModelInfo ?? data?.modelInfo
        ),
        poseModelAvailable: Boolean(data?.poseModelAvailable),
        poseModelInfo: parseTrainingModelInfo(data?.poseModelInfo),
        videoMAEModelAvailable: Boolean(
          data?.videoMAEModelAvailable ?? data?.videomaeModelAvailable
        ),
        videoMAEModelInfo: parseTrainingModelInfo(
          data?.videoMAEModelInfo ?? data?.videomaeModelInfo
        ),
        confidence: data?.confidence,
        labels: {
          people: Array.isArray(data?.labels?.people) ? data.labels.people : [],
          sexPositions: Array.isArray(data?.labels?.sexPositions) ? data.labels.sexPositions : [],
          bodyParts: Array.isArray(data?.labels?.bodyParts) ? data.labels.bodyParts : [],
          objects: Array.isArray(data?.labels?.objects) ? data.labels.objects : [],
          clothing: Array.isArray(data?.labels?.clothing) ? data.labels.clothing : [],
        },
      })
    } catch (e) {
      setTrainingStatsError(e instanceof Error ? e.message : String(e))
    } finally {
      setTrainingStatsLoading(false)
    }
  }, [])

  const loadTrainingHistory = useCallback(async () => {
    try {
      const res = await fetch('/api/training/history', { cache: 'no-store' })
      const data = await res.json().catch(() => null)

      if (!res.ok || !data) return

      setTrainingHistory(
        Array.isArray(data?.entries)
          ? data.entries.map((e: any) => ({
              trainedAt: e?.trainedAt,
              trainedAtMs: Number(e?.trainedAtMs ?? 0),
              target: e?.target,
              status: e?.status,
              durationMs: Number(e?.durationMs ?? 0),
              epochs: Number(e?.epochs ?? 0),
              trainSamples: Number(e?.trainSamples ?? 0),
              valSamples: Number(e?.valSamples ?? 0),
              imgsz: Number(e?.imgsz ?? 0),
              device: e?.device,
              map50: Number(e?.map50 ?? 0),
              map5095: Number(e?.map5095 ?? 0),
              performanceMode: e?.performanceMode,
              cpuCoreCount: Number(e?.cpuCoreCount ?? 0),
              cpuThreads: Number(e?.cpuThreads ?? 0),
              workers: Number(e?.workers ?? 0),
              yoloBatchSize: Number(e?.yoloBatchSize ?? 0),
              lowPriority: Boolean(e?.lowPriority),
            }))
          : []
      )
    } catch {
      // ignore
    }
  }, [])

  const loadTrainingEstimateSettings = useCallback(async () => {
    try {
      const res = await fetch('/api/settings', { cache: 'no-store' })
      const data = await res.json().catch(() => null)

      if (!res.ok || !data) return

      setTrainingEstimateSettings(data as RecorderSettingsState)
    } catch {
      // ignore
    }
  }, [])

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

    updateImageLayerStyle()

    const boxEl = imageBoxRef.current
    const imgEl = frameImageRef.current

    const resizeObserver =
      typeof ResizeObserver !== 'undefined'
        ? new ResizeObserver(() => updateImageLayerStyle())
        : null

    if (resizeObserver) {
      if (boxEl) resizeObserver.observe(boxEl)
      if (imgEl) resizeObserver.observe(imgEl)
    }

    window.addEventListener('resize', updateImageLayerStyle)
    window.addEventListener('scroll', updateImageLayerStyle, true)

    return () => {
      resizeObserver?.disconnect()
      window.removeEventListener('resize', updateImageLayerStyle)
      window.removeEventListener('scroll', updateImageLayerStyle, true)
    }
  }, [tabActive, imageSrc, imageExpanded, frameImageLoaded, updateImageLayerStyle])

  useEffect(() => {
    trainingRunningRef.current = trainingRunning

    if (trainingRunning) {
      setMobilePanel('training')
    } else {
      setActiveTrainingTargets([])
    }
  }, [trainingRunning])

  useEffect(() => {
    const onTraining = (event: Event) => {
      try {
        const data = (event as CustomEvent<any>).detail

        if (data?.type !== 'training_status') return

        const job = data.training || null
        const running = Boolean(job?.running)

        // Immer das zuletzt gesendete Bild merken (live, ältere werden übersprungen).
        const previewUrl = String(job?.previewUrl ?? '').trim()
        if (running && previewUrl) {
          latestTrainingPreviewRef.current = previewUrl
        }

        // Status (Progress/Epoche) nur gedrosselt anwenden, damit pro Bild
        // kein Re-Render des gesamten Tabs ausgelöst wird. Das Abschluss-Event
        // (running=false) wird immer angewendet.
        const now = Date.now()
        if (!running || now - lastTrainingStatusApplyRef.current >= 200) {
          lastTrainingStatusApplyRef.current = now
          applyTrainingStatus({ training: job })
        }
      } catch {
        // ignore
      }
    }

    window.addEventListener('app:sse:training', onTraining as EventListener)

    return () => {
      window.removeEventListener('app:sse:training', onTraining as EventListener)
    }
  }, [applyTrainingStatus])

  // Im festen Takt immer das zuletzt eingegangene Bild anzeigen.
  // Rund 500 ms halten die Anzeige ruhig genug, damit der Crossfade weich bleibt.
  useEffect(() => {
    if (!trainingRunning) {
      latestTrainingPreviewRef.current = ''
      setTrainingPreviewUrl('')
      return
    }

    const timer = window.setInterval(() => {
      const latest = latestTrainingPreviewRef.current
      if (!latest) return

      setTrainingPreviewUrl((cur) => (cur === latest ? cur : latest))
    }, 500)

    return () => window.clearInterval(timer)
  }, [trainingRunning])

  useEffect(() => {
    const onAnalysis = (event: Event) => {
      try {
        const data = (event as CustomEvent<any>).detail
        if (!loadingRef.current || !activeAnalysisRequestIdRef.current) return
        applyTrainingAnalysisEvent(data, { requireActiveRequest: true })
      } catch {
        // ignore
      }
    }

    window.addEventListener('app:sse:analysis', onAnalysis as EventListener)

    return () => {
      window.removeEventListener('app:sse:analysis', onAnalysis as EventListener)
    }
  }, [applyTrainingAnalysisEvent])

  useEffect(() => {
    const draggingBox = Boolean(drawingBox || boxInteraction || poseInteraction)

    if (!draggingBox) return

    const previousUserSelect = document.body.style.userSelect
    const previousWebkitUserSelect = document.body.style.webkitUserSelect

    document.body.style.userSelect = 'none'
    document.body.style.webkitUserSelect = 'none'

    const clearSelection = () => {
      window.getSelection()?.removeAllRanges()
    }

    const preventSelection = (event: Event) => {
      event.preventDefault()
    }

    clearSelection()

    document.addEventListener('selectionchange', clearSelection)
    document.addEventListener('selectstart', preventSelection)

    return () => {
      document.body.style.userSelect = previousUserSelect
      document.body.style.webkitUserSelect = previousWebkitUserSelect

      document.removeEventListener('selectionchange', clearSelection)
      document.removeEventListener('selectstart', preventSelection)

      clearSelection()
    }
  }, [drawingBox, boxInteraction, poseInteraction])

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

    void loadTrainingStats()
    void loadTrainingHistory()
  }, [statsModalOpen, loadTrainingStats, loadTrainingHistory])

  const onTrainingRunningChange = props.onTrainingRunningChange

  useEffect(() => {
    onTrainingRunningChange?.(trainingRunning)
  }, [trainingRunning, onTrainingRunningChange])

  useEffect(() => {
    props.onImageExpandedChange?.(tabActive ? imageExpanded : false)

    return () => {
      props.onImageExpandedChange?.(false)
    }
  }, [tabActive, imageExpanded, props.onImageExpandedChange])

  useEffect(() => {
    try {
      window.localStorage.setItem(
        TRAINING_IMAGE_EXPANDED_STORAGE_KEY,
        imageExpanded ? '1' : '0'
      )
    } catch {
      // ignore
    }
  }, [imageExpanded])

  useEffect(() => {
    if (!trainingRunning) {
      etaSmoothingRef.current = {
        lastAt: 0,
        lastRawEtaMs: 0,
      }

      setSmoothedTrainingEtaMs(0)
      return
    }

    setTrainingNowMs(Date.now())

    const timer = window.setInterval(() => {
      setTrainingNowMs(Date.now())
    }, 1000)

    return () => window.clearInterval(timer)
  }, [trainingRunning])

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

    const currentLabels = labelsRef.current
    const hasLoadedBoxLabels =
      currentLabels.people.length > 0 ||
      currentLabels.bodyParts.length > 0 ||
      currentLabels.objects.length > 0 ||
      currentLabels.clothing.length > 0

    // Wichtig: Während Labels noch laden oder kurz leer sind, Auswahl nicht löschen.
    if (!hasLoadedBoxLabels) return

    const stillExists = [
      ...currentLabels.people,
      ...currentLabels.bodyParts,
      ...currentLabels.objects,
      ...currentLabels.clothing,
    ].includes(boxLabel)

    if (!stillExists) {
      setBoxLabel('')
    }
  }, [boxLabel, labels])

  useEffect(() => {
    const wasRunning = wasTrainingRunningRef.current

    if (wasRunning && !trainingRunning && trainingStatus?.training?.finishedAt) {
      void loadNext({ refreshPrediction: true, preserveNotice: true })
    }

    wasTrainingRunningRef.current = trainingRunning
  }, [trainingRunning, trainingStatus?.training?.finishedAt, loadNext])

  useEffect(() => {
    if (activeBoxIndex === null) return

    const scrollEl = detectorBoxesScrollRef.current
    const itemEl = detectorBoxItemRefs.current[activeBoxIndex]

    if (!scrollEl || !itemEl) return

    itemEl.scrollIntoView({
      block: 'nearest',
      inline: 'nearest',
      behavior: 'smooth',
    })
  }, [activeBoxIndex])

  useEffect(() => {
    const onImportVideo = (event: Event) => {
      const detail = (event as CustomEvent<any>).detail
      void importVideoIntoTraining(detail)
    }

    window.addEventListener('training:import-video', onImportVideo as EventListener)

    let resumedActiveImport = false
    let resumedActiveNext = false

    try {
      const activeRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)

      if (activeRaw) {
        const active = JSON.parse(activeRaw)
        const requestId = String(active?.requestId || '').trim()
        const importKey = String(active?.importKey || '').trim()

        if (requestId) {
          if (importKey) {
            videoImportInFlightKeyRef.current = importKey
          }

          activeAnalysisRequestIdRef.current = requestId
          loadingRef.current = true
          setLoading(true)
          setAnalysisSourceFile(String(active?.detail?.sourceFile || active?.detail?.output || '').split(/[\\/]/).pop() || '')
          setAnalysisProgress(5)
          setAnalysisStep('Video-Import wird fortgesetzt…')
          setError(null)

          void waitForVideoImportResult(requestId)
            .then(() => {
              window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
            })
            .catch((e) => {
              const msg = e instanceof Error ? e.message : String(e)
              const mayStillRun =
                /load failed|failed to fetch|networkerror|network error/i.test(msg)

              if (mayStillRun) {
                setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.')
              } else {
                window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
                setError(msg)
              }
            })
            .finally(() => {
              if (activeAnalysisRequestIdRef.current === requestId) {
                activeAnalysisRequestIdRef.current = null
                loadingRef.current = false
                setLoading(false)
                setAnalysisSourceFile('')
                setAnalysisProgress(0)
                setAnalysisStep('')
              }

              if (importKey && videoImportInFlightKeyRef.current === importKey) {
                videoImportInFlightKeyRef.current = null
              }
            })

          resumedActiveImport = true
        }
      }

      const activeNextRaw = resumedActiveImport
        ? ''
        : window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)

      if (activeNextRaw) {
        const active = JSON.parse(activeNextRaw)
        const requestId = String(active?.requestId || '').trim()
        const activeOpts = active?.opts || {}

        if (requestId) {
          activeAnalysisRequestIdRef.current = requestId
          nextAnalysisInFlightRequestIdRef.current = requestId
          loadingRef.current = true
          setLoading(true)
          setAnalysisSourceFile('')
          setAnalysisProgress(5)
          setAnalysisStep('Analyse wird fortgesetzt…')
          setError(null)

          void fetch(
            `/api/training/next/status?requestId=${encodeURIComponent(requestId)}`,
            { cache: 'no-store' }
          )
            .then((res) => res.json().catch(() => null))
            .then((data) => {
              if (data?.analysis) {
                applyTrainingAnalysisEvent(data.analysis)
              }
            })
            .catch(() => {
              // ignore; waitForNextResult pollt danach weiter.
            })

          void waitForNextResult(requestId, {
            deferCurrentSampleToQueueEnd: Boolean(activeOpts?.deferCurrentSampleToQueueEnd),
          })
            .then(() => {
              window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
            })
            .catch((e) => {
              const msg = e instanceof Error ? e.message : String(e)
              const mayStillRun =
                /load failed|failed to fetch|networkerror|network error/i.test(msg)

              if (mayStillRun) {
                setMessage('Analyse läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.')
              } else {
                window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
                setError(msg)
              }
            })
            .finally(() => {
              if (activeAnalysisRequestIdRef.current === requestId) {
                activeAnalysisRequestIdRef.current = null
                nextAnalysisInFlightRequestIdRef.current = null
                loadingRef.current = false
                setLoading(false)
                setAnalysisSourceFile('')
                setAnalysisProgress(0)
                setAnalysisStep('')
              }
            })

          resumedActiveNext = true
        }
      }

      const raw = resumedActiveImport
        || resumedActiveNext
        ? ''
        : window.sessionStorage.getItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY)

      if (raw) {
        const detail = JSON.parse(raw)
        void importVideoIntoTraining(detail)
      }
    } catch {
      // ignore
    }

    return () => {
      window.removeEventListener('training:import-video', onImportVideo as EventListener)
    }
  }, [applyTrainingAnalysisEvent, importVideoIntoTraining, waitForNextResult, waitForVideoImportResult])

  useEffect(() => {
    if (!tabActive || initializedRef.current) return

    const runId = initRunIdRef.current + 1
    initRunIdRef.current = runId
    let cancelled = false

    async function init() {
      await loadLabels()
      await loadTrainingStatus()

      if (cancelled || initRunIdRef.current !== runId) return

      // Wichtig:
      // Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft,
      // darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen.
      if (
        videoImportStartedRef.current ||
        videoImportInFlightKeyRef.current ||
        nextAnalysisInFlightRequestIdRef.current
      ) {
        initializedRef.current = true
        return
      }

      await loadNext()

      if (!cancelled && initRunIdRef.current === runId) {
        initializedRef.current = true
      }
    }

    void init()

    return () => {
      cancelled = true
    }
  }, [tabActive, loadLabels, loadNext, loadTrainingStatus])

  useEffect(() => {
    if (!tabActive || !initializedRef.current) return

    void loadTrainingStatus()

    const frame = window.requestAnimationFrame(() => {
      updateImageLayerStyle()
    })

    return () => window.cancelAnimationFrame(frame)
  }, [tabActive, loadTrainingStatus, updateImageLayerStyle])

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

    let cancelled = false

    async function refreshActiveAnalysisStatus() {
      let nextRequestId = nextAnalysisInFlightRequestIdRef.current || ''
      let importRequestId = activeAnalysisRequestIdRef.current || ''

      try {
        const activeNextRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
        if (!nextRequestId && activeNextRaw) {
          nextRequestId = String(JSON.parse(activeNextRaw)?.requestId || '').trim()
        }
      } catch {
        // ignore
      }

      try {
        const activeImportRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
        if (activeImportRaw) {
          importRequestId = String(JSON.parse(activeImportRaw)?.requestId || importRequestId || '').trim()
        }
      } catch {
        // ignore
      }

      const url = nextRequestId
        ? `/api/training/next/status?requestId=${encodeURIComponent(nextRequestId)}`
        : importRequestId
          ? `/api/training/import-video/status?requestId=${encodeURIComponent(importRequestId)}`
          : '/api/training/analysis/status'

      try {
        const res = await fetch(url, { cache: 'no-store' })
        const data = await res.json().catch(() => null)
        if (cancelled || !res.ok || !data?.analysis) return

        applyTrainingAnalysisEvent(data.analysis)
      } catch {
        // ignore
      }
    }

    void refreshActiveAnalysisStatus()

    return () => {
      cancelled = true
    }
  }, [applyTrainingAnalysisEvent, tabActive])

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

    const onVisibilityChange = () => {
      if (!document.hidden) {
        void loadTrainingStatus()
      }
    }

    document.addEventListener('visibilitychange', onVisibilityChange)

    return () => {
      document.removeEventListener('visibilitychange', onVisibilityChange)
    }
  }, [loadTrainingStatus, trainingRunning])

  useEffect(() => {
    if (!trainingRunning) {
      const timer = window.setTimeout(() => {
        setTrainingProgress(0)
        setTrainingStep('')
      }, 800)

      return () => window.clearTimeout(timer)
    }

    const serverProgress = Number(trainingStatus?.training?.progress ?? 0)
    const serverStep = String(trainingStatus?.training?.step ?? '')

    setTrainingProgress(Number.isFinite(serverProgress) ? clampPercent(serverProgress) : 0)
    setTrainingStep(serverStep || 'Training läuft…')
  }, [
    trainingRunning,
    trainingStatus?.training?.progress,
    trainingStatus?.training?.step,
  ])

  useEffect(() => {
    const job = trainingStatus?.training
    const epoch = Number(job?.epoch ?? 0)
    const epochs = Number(job?.epochs ?? 0)

    if (!trainingRunning || epoch <= 0 || epochs <= 0) {
      epochTimingRef.current = {
        target: '',
        firstEpochAt: 0,
        lastEpoch: 0,
        lastAt: 0,
      }

      setEstimatedEpochMs(0)
      return
    }

    const now = Date.now()
    const previous = epochTimingRef.current
    const target = currentTrainingTarget || activeTrainingTarget || ''
    const targetChanged = previous.target !== target
    const stageStarted = job?.stageStartedAt && Number.isFinite(Date.parse(job.stageStartedAt))
      ? Date.parse(job.stageStartedAt)
      : 0
    const jobStarted = job?.startedAt && Number.isFinite(Date.parse(job.startedAt))
      ? Date.parse(job.startedAt)
      : 0

    if (targetChanged) {
      setEstimatedEpochMs(0)
    }

    const firstEpochAt =
      stageStarted > 0
        ? stageStarted
        : !targetChanged && previous.firstEpochAt > 0
        ? previous.firstEpochAt
        : jobStarted > 0 && (epoch > 1 || !targetChanged)
          ? jobStarted
          : now

    const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch)))
    const completedEpochUnits = trainingCompletedEpochUnits(
      epoch,
      epochs,
      currentTrainingTargetProgress
    )
    const elapsedSinceStartMs = Math.max(0, now - firstEpochAt)

    // Direkt ab Epoche 1 eine erste Schätzung:
    // bisherige Laufzeit / aktuelle Epoche.
    const averageFromElapsed =
      elapsedSinceStartMs > 0 && completedEpochUnits > 0
        ? elapsedSinceStartMs / completedEpochUnits
        : 0

    if (Number.isFinite(averageFromElapsed) && averageFromElapsed > 0) {
      setEstimatedEpochMs((old) => {
        if (!Number.isFinite(old) || old <= 0) {
          return averageFromElapsed
        }

        const clampedMeasured = Math.max(
          old * 0.60,
          Math.min(old * 1.60, averageFromElapsed)
        )

        return old * 0.75 + clampedMeasured * 0.25
      })
    }

    epochTimingRef.current = {
      target,
      firstEpochAt,
      lastEpoch: safeEpoch,
      lastAt: now,
    }
  }, [
    trainingRunning,
    trainingStatus?.training?.epoch,
    trainingStatus?.training?.epochs,
    trainingStatus?.training?.stageStartedAt,
    trainingStatus?.training?.startedAt,
    activeTrainingTarget,
    currentTrainingTarget,
    currentTrainingTargetProgress,
    trainingNowMs,
  ])

  const saveFeedback = useCallback(
    async (
      accepted: boolean,
      options?: {
        negative?: boolean
      }
    ) => {
      if (!sample || trainingRunning) return

      setSavingOverlayText(editingFeedback ? 'Feedback wird aktualisiert…' : 'Feedback wird gespeichert…')
      setSaving(true)
      setError(null)
      setMessage(null)

      try {
        const normalizedBoxes = (correction.boxes ?? [])
          .map(normalizeBox)
          .filter((box) => box.label && box.w > 0 && box.h > 0)

        const feedbackCorrection = {
          ...correction,
          boxes: normalizedBoxes,
          posePersons: clonePosePersons(correction.posePersons),
        }
        const negative =
          options?.negative ??
          !correctionHasTrainablePositionOrBoxes(feedbackCorrection)
        const correctionPayload: CorrectionState = negative
          ? {
              sexPosition: NO_SEX_POSITION_LABEL,
              peoplePresent: [],
              bodyPartsPresent: [],
              objectsPresent: [],
              clothingPresent: [],
              boxes: [],
              posePersons: [],
            }
          : {
              ...correction,
              peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
              boxes: normalizedBoxes,
              posePersons: clonePosePersons(correction.posePersons),
            }
        const effectiveAccepted = negative ? false : accepted
        setSavingOverlayText(
          negative
            ? editingFeedback
              ? 'Negativbeispiel wird aktualisiert…'
              : 'Negativbeispiel wird gespeichert…'
            : effectiveAccepted
              ? editingFeedback
                ? 'Feedback wird aktualisiert…'
                : 'Feedback wird gespeichert…'
              : editingFeedback
                ? 'Korrektur wird aktualisiert…'
                : 'Korrektur wird gespeichert…'
        )

        const payload = {
          sampleId: sample.sampleId,
          accepted: effectiveAccepted,
          negative,
          correction: effectiveAccepted && correctionPayload.boxes.length === 0
            ? undefined
            : correctionPayload,
        }

        const res = await fetch(
          editingFeedback ? '/api/training/feedback/update' : '/api/training/feedback',
          {
            method: editingFeedback ? 'PUT' : 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(
              editingFeedback
                ? {
                    ...payload,
                    sampleId: editingFeedback.sampleId,
                    answeredAt: editingFeedback.answeredAt,
                  }
                : payload
            ),
          }
        )

        const data = await res.json().catch(() => null)

        if (!res.ok) {
          throw new Error(backendText(data, `HTTP ${res.status}`))
        }

        const wasEditingFeedback = Boolean(editingFeedback)

        if (wasEditingFeedback) {
          setEditingFeedback(null)

          setFeedbackItems((current) =>
            current.map((item) =>
              item.sampleId === editingFeedback?.sampleId &&
              item.answeredAt === editingFeedback?.answeredAt
                ? {
                    ...item,
                    accepted: effectiveAccepted,
                    negative,
                    correction: effectiveAccepted ? undefined : correctionPayload,
                  }
                : item
            )
          )
        }

        setMessage(
          wasEditingFeedback
            ? negative
              ? 'Negativbeispiel aktualisiert.'
              : effectiveAccepted
                ? 'Feedback aktualisiert.'
                : 'Korrektur aktualisiert.'
            : negative
              ? 'Negativbeispiel gespeichert.'
              : effectiveAccepted
                ? 'Feedback gespeichert.'
                : 'Korrektur gespeichert.'
        )

        await loadTrainingStatus()

        if (wasEditingFeedback) {
          const returnItem = feedbackEditReturnSampleRef.current
          feedbackEditReturnSampleRef.current = null

          if (returnItem) {
            loadQueuedTrainingSample(returnItem)
            return
          }

          if (!loadNextImportedQueuedSample()) {
            await loadNext({ preserveNotice: true })
          }
          return
        }

        if (!loadNextImportedQueuedSample()) {
          await loadNext({
            forceNew: true,
            preserveNotice: true,
          })
        }
      } catch (e) {
        console.error('Feedback speichern fehlgeschlagen:', e)

        const raw = e instanceof Error ? e.message : String(e)
        const short = raw.length > 220
          ? `${raw.slice(0, 220).trimEnd()}…`
          : raw

        setError(`Feedback konnte nicht gespeichert werden. ${short}`)
      } finally {
        setSaving(false)
        setSavingOverlayText('')
      }
    },
    [
      sample,
      correction,
      editingFeedback,
      loadNext,
      loadTrainingStatus,
      loadQueuedTrainingSample,
      loadNextImportedQueuedSample,
      trainingRunning,
    ]
  )

  const skipCurrentSample = useCallback(async () => {
    if (!sample) return

    if (editingFeedback) {
      const returnItem = feedbackEditReturnSampleRef.current
      feedbackEditReturnSampleRef.current = null

      setEditingFeedback(null)
      setError(null)
      setMessage('Feedback-Bearbeitung abgebrochen.')

      if (returnItem) {
        loadQueuedTrainingSample(returnItem)
      }

      return
    }

    const skippedSampleId = sample.sampleId

    setSavingOverlayText('Bild wird übersprungen…')
    setSaving(true)
    setError(null)
    setMessage(null)

    try {
      const res = await fetch('/api/training/skip', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sampleId: skippedSampleId }),
      })

      const data = await res.json().catch(() => null)

      if (!res.ok) {
        throw new Error(backendText(data, `HTTP ${res.status}`))
      }

      sampleRef.current = null
      correctionRef.current = predictionToCorrection(null)
      hasManualCorrectionRef.current = false
      setSample(null)
      setCorrection(predictionToCorrection(null))
      setHasManualCorrection(false)
      setDrawingBox(null)
      setBoxInteraction(null)
      setTouchMagnifier(null)
      setBoxLabel('')
      setActiveBoxIndex(null)

      if (!loadNextImportedQueuedSample()) {
        await loadNext({
          forceNew: true,
          preserveNotice: true,
        })
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e))
    } finally {
      setSaving(false)
      setSavingOverlayText('')
    }
  }, [
    sample,
    editingFeedback,
    loadNext,
    loadQueuedTrainingSample,
    loadNextImportedQueuedSample,
  ])

  const openTrainingStartModal = useCallback(() => {
    const readyTargets = selectableTrainingTargets.length > 0
      ? selectableTrainingTargets
      : ALL_TRAINING_TARGETS

    setTrainingStartMode('full')
    setTrainingStartTargets(readyTargets)
    setTrainingStartModalOpen(true)
    void loadTrainingHistory()
    void loadTrainingEstimateSettings()
  }, [loadTrainingEstimateSettings, loadTrainingHistory, selectableTrainingTargets])

  const toggleTrainingStartTarget = useCallback((target: TrainingTargetKey) => {
    setTrainingStartTargets((prev) =>
      prev.includes(target)
        ? prev.filter((item) => item !== target)
        : [...prev, target]
    )
  }, [])

  const startTraining = useCallback(async (options?: TrainingStartOptions) => {
    shownTrainingCompletionRef.current = null
    setDismissedTrainingInfoKey('')

    try {
      window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY)
    } catch {
      // ignore
    }

    const nextActiveTargets =
      options?.mode === 'custom'
        ? (options.targets ?? []).filter(isTrainingTargetKey)
        : selectableTrainingTargets.length > 0
          ? selectableTrainingTargets
          : ALL_TRAINING_TARGETS

    setActiveTrainingTargets(nextActiveTargets)
    setTraining(true)
    setTrainingProgress(5)
    setTrainingStep('Training wird gestartet…')
    setError(null)
    setMessage(null)

    try {
      const payload =
        options?.mode === 'custom'
          ? {
              scope: 'custom',
              targets: options.targets ?? [],
            }
          : {
              scope: 'full',
            }

      const res = await fetch('/api/training/train', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      })

      const data = await res.json().catch(() => null)

      if (!res.ok) {
        throw new Error(backendText(data, `HTTP ${res.status}`))
      }

      await loadTrainingStatus()

      // WICHTIG:
      // Hier NICHT direkt loadNext() aufrufen.
      // Das Training läuft im Backend asynchron weiter.
    } catch (e) {
      setActiveTrainingTargets([])
      setTraining(false)
      setTrainingProgress(0)
      setTrainingStep('')
      setError(e instanceof Error ? e.message : String(e))
    }
  }, [loadTrainingStatus, selectableTrainingTargets])

  const startTrainingFromModal = useCallback(() => {
    if (!canConfirmTrainingStart) return

    const options: TrainingStartOptions =
      trainingStartMode === 'custom'
        ? {
            mode: 'custom',
            targets: selectedTrainingTargets,
          }
        : {
            mode: 'full',
          }

    setTrainingStartModalOpen(false)
    void startTraining(options)
  }, [
    canConfirmTrainingStart,
    selectedTrainingTargets,
    startTraining,
    trainingStartMode,
  ])

  const cancelTraining = useCallback(async () => {
    const confirmed = window.confirm(
      'Training wirklich abbrechen? Temporäre Trainingsausgaben dieses Laufs werden gelöscht. Deine Feedbacks und Labels bleiben erhalten.'
    )

    if (!confirmed) return

    setCancellingTraining(true)
    setError(null)
    setMessage(null)
    setTrainingStep('Training wird abgebrochen…')

    try {
      const res = await fetch('/api/training/cancel', {
        method: 'POST',
      })

      const data = await res.json().catch(() => null)

      if (!res.ok) {
        throw new Error(backendText(data, `HTTP ${res.status}`))
      }

      await loadTrainingStatus()
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e))
    } finally {
      setCancellingTraining(false)
    }
  }, [loadTrainingStatus])

  const deleteAllTrainingData = useCallback(async () => {
    const confirmed = window.confirm(
      'Wirklich alle Trainingsdaten löschen? Das entfernt Feedback, Frames, Samples und Detector-Daten. Diese Aktion kann nicht rückgängig gemacht werden.'
    )

    if (!confirmed) return

    setDeletingTrainingData(true)
    setError(null)
    setMessage(null)

    try {
      const res = await fetch('/api/training/delete-all', {
        method: 'DELETE',
      })

      const data = await res.json().catch(() => null)

      if (!res.ok) {
        throw new Error(data?.error || `HTTP ${res.status}`)
      }

      sampleRef.current = null
      correctionRef.current = predictionToCorrection(null)
      hasManualCorrectionRef.current = false
      setSample(null)
      setCorrection(predictionToCorrection(null))
      setTrainingStatus({
        feedbackCount: 0,
        requiredCount,
        canTrain: false,
      })

      setMessage(backendText(data, 'Alle Trainingsdaten wurden gelöscht.'))

      await loadTrainingStatus()
      await loadNext({ forceNew: true, preserveNotice: true })
      if (statsModalOpen) {
        await loadTrainingStats()
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e))
    } finally {
      setDeletingTrainingData(false)
    }
  }, [loadNext, loadTrainingStats, loadTrainingStatus, requiredCount, statsModalOpen])

  const getPointerPosFromRect = useCallback((
    rect: ImageContentRect,
    clientX: number,
    clientY: number,
    opts?: { clamp?: boolean }
  ) => {
    const x = (clientX - rect.left) / rect.width
    const y = (clientY - rect.top) / rect.height

    if (opts?.clamp === false) {
      return { x, y }
    }

    return {
      x: clamp01(x),
      y: clamp01(y),
    }
  }, [])

  const getPointerPosInImage = useCallback((
    clientX: number,
    clientY: number,
    opts?: { clamp?: boolean }
  ) => {
    const rect = activeImageContentRectRef.current ?? getImageContentRect()
    if (!rect) return null

    return getPointerPosFromRect(rect, clientX, clientY, opts)
  }, [getImageContentRect, getPointerPosFromRect])

  const releaseActivePointerCapture = useCallback((pointerId?: number | null) => {
    const id = typeof pointerId === 'number'
      ? pointerId
      : activePointerIdRef.current

    if (typeof id !== 'number') {
      activePointerIdRef.current = null
      activePointerCaptureElementRef.current = null
      return
    }

    try {
      const captureElement = activePointerCaptureElementRef.current ?? imageBoxRef.current
      captureElement?.releasePointerCapture(id)
    } catch {
      // Pointer-Capture war evtl. schon weg.
    }

    activePointerCaptureElementRef.current = null
    activePointerIdRef.current = null
  }, [])

  const markManualCorrection = useCallback(() => {
    if (hasManualCorrectionRef.current) return

    hasManualCorrectionRef.current = true
    setHasManualCorrection(true)
  }, [])

  const cancelPointerMoveFrame = useCallback(() => {
    if (pointerMoveRafRef.current !== null) {
      window.cancelAnimationFrame(pointerMoveRafRef.current)
      pointerMoveRafRef.current = null
    }

    pendingPointerMoveRef.current = null
  }, [])

  const clearBoxGestureRefs = useCallback(() => {
    cancelPointerMoveFrame()
    activeImageContentRectRef.current = null
    drawingBoxRef.current = null
    boxInteractionRef.current = null
    poseInteractionRef.current = null
    latestGestureBoxRef.current = null
  }, [cancelPointerMoveFrame])

  const applyPointerMoveSnapshot = useCallback((snapshot: {
    clientX: number
    clientY: number
  }) => {
    const drawing = drawingBoxRef.current
    const interaction = boxInteractionRef.current
    const poseInteraction = poseInteractionRef.current

    if (!drawing && !interaction && !poseInteraction) return

    const clampedPos = getPointerPosInImage(snapshot.clientX, snapshot.clientY)
    if (!clampedPos) return

    const pos =
      interaction?.type === 'move'
        ? getPointerPosInImage(snapshot.clientX, snapshot.clientY, { clamp: false }) ?? clampedPos
        : clampedPos

    if (poseInteraction) {
      markManualCorrection()

      let nextPoseMagnifier: MagnifierState | null = null

      setCorrection((prev) => {
        const persons = clonePosePersons(prev.posePersons)
        const person = persons[poseInteraction.personIndex]
        const point = person?.keypoints?.[poseInteraction.keypointIndex]

        if (!person || !point) {
          return prev
        }

        const nextPoint = {
          ...point,
          x: snap01(clampedPos.x),
          y: snap01(clampedPos.y),
          conf: Math.max(clamp01(Number(point.conf ?? 1)), POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE),
        }

        nextPoseMagnifier = {
          visible: true,
          clientX: snapshot.clientX,
          clientY: snapshot.clientY,
          imageX: nextPoint.x,
          imageY: nextPoint.y,
        }

        const nextPerson = updatePosePersonQuality({
          ...person,
          box: poseBoxFromKeypoints({
            ...person,
            keypoints: person.keypoints.map((candidate, index) =>
              index === poseInteraction.keypointIndex ? nextPoint : candidate
            ),
          }),
          keypoints: person.keypoints.map((candidate, index) =>
            index === poseInteraction.keypointIndex ? nextPoint : candidate
          ),
        })

        persons[poseInteraction.personIndex] = nextPerson

        const next: CorrectionState = {
          ...prev,
          posePersons: persons,
        }

        correctionRef.current = next
        return next
      })

      if (nextPoseMagnifier) {
        setTouchMagnifier(nextPoseMagnifier)
      }

      return
    }

    const nextMagnifier: MagnifierState = {
      visible: true,
      clientX: snapshot.clientX,
      clientY: snapshot.clientY,
      imageX: clampedPos.x,
      imageY: clampedPos.y,
    }

    setTouchMagnifier((prev) => {
      if (
        prev?.visible &&
        prev.clientX === nextMagnifier.clientX &&
        prev.clientY === nextMagnifier.clientY &&
        Math.abs(prev.imageX - nextMagnifier.imageX) <= 0.0005 &&
        Math.abs(prev.imageY - nextMagnifier.imageY) <= 0.0005
      ) {
        return prev
      }

      return nextMagnifier
    })

    if (interaction) {
      const dx = pos.x - interaction.startX
      const dy = pos.y - interaction.startY
      const original = interaction.original

      let nextBox: TrainingBox = original

      if (interaction.type === 'move') {
        nextBox = normalizeMovedBox({
          ...original,
          x: original.x + dx,
          y: original.y + dy,
        })
      }

      if (interaction.type === 'resize') {
        let x1 = original.x
        let y1 = original.y
        let x2 = original.x + original.w
        let y2 = original.y + original.h

        const pointerX = snap01(clampedPos.x)
        const pointerY = snap01(clampedPos.y)

        if (interaction.handle.includes('n')) y1 = pointerY
        if (interaction.handle.includes('s')) y2 = pointerY
        if (interaction.handle.includes('w')) x1 = pointerX
        if (interaction.handle.includes('e')) x2 = pointerX

        const left = Math.min(x1, x2)
        const top = Math.min(y1, y2)
        const right = Math.max(x1, x2)
        const bottom = Math.max(y1, y2)

        nextBox = normalizeBox({
          ...original,
          x: left,
          y: top,
          w: right - left,
          h: bottom - top,
        })
      }

      const geometryChanged = boxGeometryChanged(original, nextBox)
      const correctedNextBox = geometryChanged
        ? markBoxCorrected(nextBox)
        : nextBox

      latestGestureBoxRef.current = correctedNextBox

      if (geometryChanged) {
        markManualCorrection()
      }

      setCorrection((prev) => {
        const boxes = prev.boxes ?? []
        const currentBox = boxes[interaction.index]

        if (!currentBox || !boxVisualStateChanged(currentBox, correctedNextBox)) {
          return prev
        }

        const next: CorrectionState = {
          ...prev,
          boxes: boxes.map((box, index) =>
            index === interaction.index ? correctedNextBox : box
          ),
        }

        correctionRef.current = next
        return next
      })

      return
    }

    if (!drawing) return

    const x1 = drawing.startX
    const y1 = drawing.startY
    const x2 = pos.x
    const y2 = pos.y

    const nextDrawing: DrawingTrainingBox = {
      ...drawing,
      x: Math.min(x1, x2),
      y: Math.min(y1, y2),
      w: Math.abs(x2 - x1),
      h: Math.abs(y2 - y1),
    }

    latestGestureBoxRef.current = nextDrawing
    drawingBoxRef.current = nextDrawing

    setDrawingBox((prev) => {
      if (prev && !boxVisualStateChanged(prev, nextDrawing)) {
        return prev
      }

      return nextDrawing
    })
  }, [getPointerPosInImage, markManualCorrection])

  const schedulePointerMove = useCallback((snapshot: {
    clientX: number
    clientY: number
  }) => {
    pendingPointerMoveRef.current = snapshot

    if (pointerMoveRafRef.current !== null) return

    pointerMoveRafRef.current = window.requestAnimationFrame(() => {
      pointerMoveRafRef.current = null

      const pending = pendingPointerMoveRef.current
      pendingPointerMoveRef.current = null

      if (pending) {
        applyPointerMoveSnapshot(pending)
      }
    })
  }, [applyPointerMoveSnapshot])

  const flushPendingPointerMove = useCallback(() => {
    const pending = pendingPointerMoveRef.current
    pendingPointerMoveRef.current = null

    if (pointerMoveRafRef.current !== null) {
      window.cancelAnimationFrame(pointerMoveRafRef.current)
      pointerMoveRafRef.current = null
    }

    if (pending) {
      applyPointerMoveSnapshot(pending)
    }
  }, [applyPointerMoveSnapshot])

  const setPoseKeypointOnPerson = useCallback((
    personIndex: number,
    keypointName: string,
    position: { x: number; y: number }
  ) => {
    const cleanKeypointName = poseKeypointId(keypointName)
    if (!cleanKeypointName) return false

    const currentPerson = correctionRef.current.posePersons?.[personIndex]
    if (!currentPerson) return false

    markManualCorrection()

    setCorrection((prev) => {
      const persons = clonePosePersons(prev.posePersons)
      const person = persons[personIndex]

      if (!person) {
        return prev
      }

      const nextPoint: TrainingKeypoint = {
        name: cleanKeypointName,
        x: snap01(position.x),
        y: snap01(position.y),
        conf: 1,
      }

      const existingIndex = person.keypoints.findIndex(
        (point) => poseKeypointId(point.name) === cleanKeypointName
      )
      const keypoints =
        existingIndex >= 0
          ? person.keypoints.map((point, index) =>
              index === existingIndex ? nextPoint : point
            )
          : [...person.keypoints, nextPoint]

      const nextPerson = updatePosePersonQuality({
        ...person,
        keypoints,
        box: poseBoxFromKeypoints({
          ...person,
          keypoints,
        }),
      })

      persons[personIndex] = nextPerson

      const next: CorrectionState = {
        ...prev,
        posePersons: persons,
      }

      correctionRef.current = next
      return next
    })

    setShowPoseSkeleton(true)
    setActivePosePersonIndex(personIndex)
    setPendingPoseKeypoint(null)

    return true
  }, [markManualCorrection])

  useEffect(() => {
    return () => cancelPointerMoveFrame()
  }, [cancelPointerMoveFrame])

  const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
    if (pendingPoseKeypoint) {
      const isFrameBusy = loading || (!!imageSrc && !frameImageLoaded)
      if (uiLocked || isFrameBusy || trainingRunning || saving) return
      if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return

      const target = e.target as HTMLElement | null
      if (target?.closest('[data-box-control="true"]')) return
      if (target?.closest('[data-pose-control="true"]')) return

      const contentRect = getImageContentRect()
      if (!contentRect) return

      const rawPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false })

      if (
        rawPos.x < 0 ||
        rawPos.x > 1 ||
        rawPos.y < 0 ||
        rawPos.y > 1
      ) {
        return
      }

      e.preventDefault()
      e.stopPropagation()
      window.getSelection()?.removeAllRanges()

      setPoseKeypointOnPerson(
        pendingPoseKeypoint.personIndex,
        pendingPoseKeypoint.keypointName,
        {
          x: clamp01(rawPos.x),
          y: clamp01(rawPos.y),
        }
      )
      return
    }

    if (!boxLabel) return
    if (uiLocked) return
    if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return

    const target = e.target as HTMLElement | null
    if (target?.closest('[data-box-control="true"]')) return
    if (target?.closest('[data-pose-control="true"]')) return

    const contentRect = getImageContentRect()
    if (!contentRect) return

    const rawPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false })

    // Nicht im schwarzen Randbereich starten.
    if (
      rawPos.x < 0 ||
      rawPos.x > 1 ||
      rawPos.y < 0 ||
      rawPos.y > 1
    ) {
      return
    }

    activeImageContentRectRef.current = contentRect

    const pos = {
      x: clamp01(rawPos.x),
      y: clamp01(rawPos.y),
    }

    e.preventDefault()
    e.stopPropagation()
    window.getSelection()?.removeAllRanges()

    finishingGestureRef.current = false
    activePointerIdRef.current = e.pointerId

    try {
      activePointerCaptureElementRef.current = e.currentTarget
      e.currentTarget.setPointerCapture(e.pointerId)
    } catch {
      activePointerIdRef.current = null
      activePointerCaptureElementRef.current = null
    }

    const nextMagnifier: MagnifierState = {
      visible: true,
      clientX: e.clientX,
      clientY: e.clientY,
      imageX: pos.x,
      imageY: pos.y,
    }

    const nextDrawingBox: DrawingTrainingBox = {
      label: boxLabel,
      startX: pos.x,
      startY: pos.y,
      x: pos.x,
      y: pos.y,
      w: 0,
      h: 0,
    }

    latestGestureBoxRef.current = nextDrawingBox
    drawingBoxRef.current = nextDrawingBox

    setTouchMagnifier(nextMagnifier)
    setDrawingBox(nextDrawingBox)
  }, [
    boxLabel,
    frameImageLoaded,
    getImageContentRect,
    getPointerPosFromRect,
    imageSrc,
    loading,
    pendingPoseKeypoint,
    saving,
    setPoseKeypointOnPerson,
    trainingRunning,
    uiLocked,
  ])

  const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
    const hasActiveGesture = Boolean(
      drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current
    )

    if (hasActiveGesture) {
      e.preventDefault()
      e.stopPropagation()

      schedulePointerMove({
        clientX: e.clientX,
        clientY: e.clientY,
      })
    }
  }, [schedulePointerMove])

  const finishDrawBox = useCallback((e?: React.PointerEvent<HTMLDivElement> | PointerEvent) => {
    flushPendingPointerMove()

    const activeDrawingBox = drawingBoxRef.current
    const activeInteraction = boxInteractionRef.current
    const activePoseInteraction = poseInteractionRef.current

    releaseActivePointerCapture(
      typeof e?.pointerId === 'number' ? e.pointerId : null
    )

    setTouchMagnifier(null)

    if (finishingGestureRef.current) {
      setDrawingBox(null)
      setBoxInteraction(null)
      setPoseInteraction(null)
      clearBoxGestureRefs()
      return
    }

    finishingGestureRef.current = true

    if (activeDrawingBox || activeInteraction || activePoseInteraction) {
      e?.preventDefault()
      e?.stopPropagation()
    }

    if (activePoseInteraction) {
      setPoseInteraction(null)
      clearBoxGestureRefs()
      return
    }

    if (activeInteraction) {
      const finalBox = latestGestureBoxRef.current

      if (finalBox) {
        setCorrection((prev) => {
          const boxes = prev.boxes ?? []
          const currentBox = boxes[activeInteraction.index]

          if (!currentBox || !boxVisualStateChanged(currentBox, finalBox)) {
            return prev
          }

          const next: CorrectionState = {
            ...prev,
            boxes: boxes.map((box, index) =>
              index === activeInteraction.index ? finalBox : box
            ),
          }

          correctionRef.current = next
          return next
        })
      }

      setBoxInteraction(null)
      clearBoxGestureRefs()
      return
    }

    if (!activeDrawingBox) {
      setDrawingBox(null)
      clearBoxGestureRefs()
      return
    }

    const box = normalizeBox(latestGestureBoxRef.current ?? activeDrawingBox)

    setDrawingBox(null)

    if (box.w < 0.01 || box.h < 0.01) {
      clearBoxGestureRefs()
      return
    }

    markManualCorrection()

    setCorrection((prev) => {
      const previousBoxes = prev.boxes ?? []
      const newBoxIndex = previousBoxes.length

      const next: CorrectionState = {
        ...prev,
        boxes: [...previousBoxes, box],
      }

      setActiveBoxIndex(newBoxIndex)

      const applied = applyBoxLabelToCorrection(next, box.label, labelsRef.current)
      correctionRef.current = applied
      return applied
    })

    clearBoxGestureRefs()
  }, [
    clearBoxGestureRefs,
    flushPendingPointerMove,
    markManualCorrection,
    releaseActivePointerCapture,
  ])

  useEffect(() => {
    if (!drawingBox && !boxInteraction && !poseInteraction) return

    const movePointer = (event: PointerEvent) => {
      if (!drawingBoxRef.current && !boxInteractionRef.current && !poseInteractionRef.current) return

      event.preventDefault()
      event.stopPropagation()
      schedulePointerMove({
        clientX: event.clientX,
        clientY: event.clientY,
      })
    }

    const finishPointer = (event: PointerEvent) => {
      finishDrawBox(event)
    }

    const finishWithoutPointer = () => {
      finishDrawBox()
    }

    const finishOnHidden = () => {
      if (document.hidden) {
        finishDrawBox()
      }
    }

    window.addEventListener('pointermove', movePointer, true)
    window.addEventListener('pointerup', finishPointer, true)
    window.addEventListener('pointercancel', finishPointer, true)
    window.addEventListener('blur', finishWithoutPointer)
    document.addEventListener('visibilitychange', finishOnHidden)

    return () => {
      window.removeEventListener('pointermove', movePointer, true)
      window.removeEventListener('pointerup', finishPointer, true)
      window.removeEventListener('pointercancel', finishPointer, true)
      window.removeEventListener('blur', finishWithoutPointer)
      document.removeEventListener('visibilitychange', finishOnHidden)
    }
  }, [drawingBox, boxInteraction, poseInteraction, finishDrawBox, schedulePointerMove])

  const removeBox = useCallback((index: number) => {
    setHasManualCorrection(true)

    let removedLabel = ''
    let shouldClearBoxLabel = false

    setCorrection((prev) => {
      const removed = prev.boxes?.[index]
      removedLabel = String(removed?.label || '').trim()

      const next = removeBoxFromCorrection(prev, index, labelsRef.current)

      if (removedLabel) {
        shouldClearBoxLabel = !next.boxes.some(
          (box) => String(box.label || '').trim() === removedLabel
        )
      }

      return next
    })

    if (removedLabel && shouldClearBoxLabel) {
      setBoxLabel((current) => current === removedLabel ? '' : current)
    }

    setActiveBoxIndex((current) => {
      if (current === null) return null
      if (current === index) return null
      if (current > index) return current - 1
      return current
    })
  }, [])

  const removePosePerson = useCallback((personIndex: number) => {
    markManualCorrection()

    setCorrection((prev) => {
      const persons = clonePosePersons(prev.posePersons)
      if (personIndex < 0 || personIndex >= persons.length) return prev

      const next: CorrectionState = {
        ...prev,
        posePersons: persons.filter((_, index) => index !== personIndex),
      }

      correctionRef.current = next
      return next
    })

    setActivePosePersonIndex((current) => {
      if (current === null) return null
      if (current === personIndex) return null
      if (current > personIndex) return current - 1
      return current
    })

    setPendingPoseKeypoint((current) => {
      if (!current) return null
      if (current.personIndex === personIndex) return null
      if (current.personIndex > personIndex) {
        return {
          ...current,
          personIndex: current.personIndex - 1,
        }
      }
      return current
    })
  }, [markManualCorrection])

  const addPosePerson = useCallback(() => {
    if (uiLocked) return

    markManualCorrection()

    const nextPersonIndex = clonePosePersons(correctionRef.current.posePersons).length

    setCorrection((prev) => {
      const persons = clonePosePersons(prev.posePersons)
      const next: CorrectionState = {
        ...prev,
        posePersons: [...persons, createEmptyPosePerson()],
      }

      correctionRef.current = next
      return next
    })

    setShowPoseSkeleton(true)
    setActiveBoxIndex(null)
    setActivePosePersonIndex(nextPersonIndex)
    setPendingPoseKeypoint(null)
    switchPoseKeypointMapView('body')
  }, [markManualCorrection, switchPoseKeypointMapView, uiLocked])

  const removePoseKeypoint = useCallback((personIndex: number, keypointIndex: number) => {
    markManualCorrection()

    setCorrection((prev) => {
      const persons = clonePosePersons(prev.posePersons)
      const person = persons[personIndex]
      const point = person?.keypoints?.[keypointIndex]

      if (!person || !point) return prev

      const keypoints = person.keypoints.map((candidate, index) =>
        index === keypointIndex
          ? {
              ...candidate,
              x: 0,
              y: 0,
              conf: 0,
            }
          : candidate
      )

      const nextPerson = updatePosePersonQuality({
        ...person,
        keypoints,
        box: poseBoxFromKeypoints({
          ...person,
          keypoints,
        }),
      })

      persons[personIndex] = nextPerson

      const next: CorrectionState = {
        ...prev,
        posePersons: persons,
      }

      correctionRef.current = next
      return next
    })
  }, [markManualCorrection])

  const resetPosePersons = useCallback(() => {
    markManualCorrection()
    const persons = clonePosePersons(sampleRef.current?.prediction.persons)

    setCorrection((prev) => {
      const next: CorrectionState = {
        ...prev,
        posePersons: persons,
      }

      correctionRef.current = next
      return next
    })

    setActivePosePersonIndex(null)
    if (poseKeypointMapAnimationFrameRef.current !== null) {
      cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current)
      poseKeypointMapAnimationFrameRef.current = null
    }
    setPendingPoseKeypoint(null)
    poseKeypointMapViewRef.current = 'body'
    poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX
    setPoseKeypointMapView('body')
    setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX)
    setPoseKeypointMapAnimating(false)
  }, [markManualCorrection])

  const selectAdjacentPoseFace = useCallback((direction: -1 | 1) => {
    if (visiblePosePersonIndexes.length === 0) return

    const currentPosition =
      poseCorrectionPersonIndex !== null
        ? visiblePosePersonIndexes.indexOf(poseCorrectionPersonIndex)
        : -1
    const fallbackPosition = direction > 0 ? -1 : 0
    const nextPosition =
      (currentPosition >= 0 ? currentPosition : fallbackPosition) + direction
    const wrappedPosition =
      (nextPosition + visiblePosePersonIndexes.length) % visiblePosePersonIndexes.length
    const nextPersonIndex = visiblePosePersonIndexes[wrappedPosition]

    if (nextPersonIndex === undefined) return

    setActivePosePersonIndex(nextPersonIndex)
    setPendingPoseKeypoint(null)
  }, [poseCorrectionPersonIndex, visiblePosePersonIndexes])

  const startPoseKeypointDrag = useCallback((
    e: React.PointerEvent<HTMLElement>,
    personIndex: number,
    keypointIndex: number
  ) => {
    if (uiLocked) return
    if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return

    const contentRect = getImageContentRect()
    if (!contentRect) return

    const pos = getPointerPosFromRect(contentRect, e.clientX, e.clientY)
    const point = correctionRef.current.posePersons?.[personIndex]?.keypoints?.[keypointIndex]
    const pointX = Number(point?.x)
    const pointY = Number(point?.y)
    const hasPointPosition =
      Number.isFinite(pointX) &&
      Number.isFinite(pointY)

    e.preventDefault()
    e.stopPropagation()
    window.getSelection()?.removeAllRanges()

    activeImageContentRectRef.current = contentRect
    finishingGestureRef.current = false
    activePointerIdRef.current = e.pointerId

    try {
      activePointerCaptureElementRef.current = e.currentTarget
      e.currentTarget.setPointerCapture(e.pointerId)
    } catch {
      activePointerIdRef.current = null
      activePointerCaptureElementRef.current = null
    }

    const nextInteraction: PoseInteraction = {
      type: 'keypoint',
      personIndex,
      keypointIndex,
    }

    poseInteractionRef.current = nextInteraction
    setPoseInteraction(nextInteraction)
    setActivePosePersonIndex(personIndex)
    setTouchMagnifier({
      visible: true,
      clientX: e.clientX,
      clientY: e.clientY,
      imageX: hasPointPosition ? clamp01(pointX) : pos.x,
      imageY: hasPointPosition ? clamp01(pointY) : pos.y,
    })
  }, [getImageContentRect, getPointerPosFromRect, uiLocked])

  const changeBoxLabel = useCallback((index: number, nextLabel: string) => {
    const currentLabel = String(correction.boxes?.[index]?.label || '').trim()
    const cleanNextLabel = String(nextLabel || '').trim()

    if (currentLabel !== cleanNextLabel) {
      setHasManualCorrection(true)
    }

    if (cleanNextLabel) {
      setBoxLabel(cleanNextLabel)
    }

    setCorrection((prev) =>
      changeBoxLabelInCorrection(prev, index, nextLabel, labelsRef.current)
    )
  }, [correction.boxes])

  const clearBoxes = useCallback(() => {
    setHasManualCorrection(true)

    setBoxLabel('')
    setActiveBoxIndex(null)

    setCorrection((prev) => ({
      ...prev,
      peoplePresent: [],
      bodyPartsPresent: [],
      objectsPresent: [],
      clothingPresent: [],
      boxes: [],
    }))

    setExpandedCorrectionSections({
      sexPosition: false,
      people: false,
      bodyParts: false,
      objects: false,
      clothing: false,
    })
  }, [])

  const frameBusy = loading || (!!imageSrc && !frameImageLoaded)

  useEffect(() => {
    if (loading || frameBusy) return
    if (!loadingPreviewUrl && !loadingPreviewFallbackUrl) return

    setLoadingPreviewUrl('')
    setLoadingPreviewFallbackUrl('')
    setLoadingPreviewLoaded(false)
    setLoadingPreviewFailed(false)
  }, [loading, frameBusy, loadingPreviewFallbackUrl, loadingPreviewUrl])

  const poseModeAvailable = Boolean(sample) || hasPosePersons || originalPosePersonCount > 0
  const poseModeActive = showPoseSkeleton && poseModeAvailable
  const annotationLayerHidden = frameBusy || trainingRunning || saving
  const poseSkeletonVisible = poseModeActive && hasPosePersons && !annotationLayerHidden
  const showImageBoxes = !annotationLayerHidden && !poseModeActive
  const showAnnotationMagnifier = showImageBoxes || poseSkeletonVisible
  const focusedPoseOverlayPersonIndex = poseCorrectionPersonIndex
  const shouldRenderPoseOverlayPerson = useCallback((personIndex: number) =>
    focusedPoseOverlayPersonIndex === null || personIndex === focusedPoseOverlayPersonIndex,
    [focusedPoseOverlayPersonIndex]
  )
  const poseFaceImageZoomActive =
    poseModeActive &&
    poseKeypointMapView === 'head' &&
    Boolean(poseCorrectionPerson)
  const poseFaceImageZoomRegion = useMemo(
    () =>
      poseFaceImageZoomActive
        ? posePersonFaceZoomRegion(poseCorrectionPerson)
        : null,
    [poseCorrectionPerson, poseFaceImageZoomActive]
  )
  const poseFaceImageEffectiveZoomRegion = useMemo(() => {
    if (!poseFaceImageZoomRegion) return null

    if (!poseFaceImagePanCenter) {
      return poseFaceImageZoomRegion
    }

    return normalizedZoomRegion(
      poseFaceImagePanCenter.x,
      poseFaceImagePanCenter.y,
      poseFaceImageZoomRegion.width,
      poseFaceImageZoomRegion.height
    )
  }, [poseFaceImagePanCenter, poseFaceImageZoomRegion])
  const poseFaceImageCamera = useMemo((): PoseFaceImageCamera | null => {
    if (!poseFaceImageZoomActive || !poseFaceImageEffectiveZoomRegion || !imageLayerStyle) {
      return null
    }

    const boxWidth = Number(imageBoxSize.width)
    const boxHeight = Number(imageBoxSize.height)
    const contentLeft = Number(imageLayerStyle.left)
    const contentTop = Number(imageLayerStyle.top)
    const contentWidth = Number(imageLayerStyle.width)
    const contentHeight = Number(imageLayerStyle.height)

    if (
      !Number.isFinite(boxWidth) ||
      !Number.isFinite(boxHeight) ||
      !Number.isFinite(contentLeft) ||
      !Number.isFinite(contentTop) ||
      !Number.isFinite(contentWidth) ||
      !Number.isFinite(contentHeight) ||
      boxWidth <= 0 ||
      boxHeight <= 0 ||
      contentWidth <= 0 ||
      contentHeight <= 0
    ) {
      return null
    }

    const targetWidth = Math.max(1, poseFaceImageEffectiveZoomRegion.width * contentWidth)
    const targetHeight = Math.max(1, poseFaceImageEffectiveZoomRegion.height * contentHeight)
    const fitScale = Math.min(boxWidth / targetWidth, boxHeight / targetHeight)
    const scale = Math.min(4.25, Math.max(1.45, fitScale * 0.72))
    const viewportWidth = Math.min(1, Math.max(0.015, boxWidth / (scale * contentWidth)))
    const viewportHeight = Math.min(1, Math.max(0.015, boxHeight / (scale * contentHeight)))
    const cameraCenter = clampPoseFaceCameraCenter(
      poseFaceImageEffectiveZoomRegion.centerX,
      poseFaceImageEffectiveZoomRegion.centerY,
      viewportWidth,
      viewportHeight
    )
    const centerX = cameraCenter.x
    const centerY = cameraCenter.y
    const focusX = contentLeft + centerX * contentWidth
    const focusY = contentTop + centerY * contentHeight
    const translateX = boxWidth / 2 - focusX * scale
    const translateY = boxHeight / 2 - focusY * scale

    return {
      scale,
      translateX,
      translateY,
      centerX,
      centerY,
      viewportWidth,
      viewportHeight,
      visibleRegion: {
        x: Math.max(0, Math.min(1 - viewportWidth, centerX - viewportWidth / 2)),
        y: Math.max(0, Math.min(1 - viewportHeight, centerY - viewportHeight / 2)),
        w: viewportWidth,
        h: viewportHeight,
      },
    }
  }, [imageBoxSize.height, imageBoxSize.width, imageLayerStyle, poseFaceImageEffectiveZoomRegion, poseFaceImageZoomActive])
  useEffect(() => {
    if (poseFaceImageZoomOutActiveRef.current) {
      return
    }

    if (poseFaceImageCameraAnimationFrameRef.current !== null) {
      cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current)
      poseFaceImageCameraAnimationFrameRef.current = null
    }

    if (!poseFaceImageCamera) {
      poseFaceImageDisplayCameraRef.current = null
      setPoseFaceImageDisplayCamera(null)
      setPoseFaceImageCameraAnimating(false)
      return
    }

    if (poseFaceImagePanning) {
      poseFaceImageDisplayCameraRef.current = poseFaceImageCamera
      setPoseFaceImageDisplayCamera(poseFaceImageCamera)
      setPoseFaceImageCameraAnimating(false)
      return
    }

    const fromCamera = poseFaceImageDisplayCameraRef.current ?? poseFaceImageFullCamera()
    const toCamera = poseFaceImageCamera
    const startedAt = performance.now()
    const almostSame =
      Math.abs(fromCamera.scale - toCamera.scale) <= 0.001 &&
      Math.abs(fromCamera.translateX - toCamera.translateX) <= 0.5 &&
      Math.abs(fromCamera.translateY - toCamera.translateY) <= 0.5

    if (almostSame) {
      poseFaceImageDisplayCameraRef.current = toCamera
      setPoseFaceImageDisplayCamera(toCamera)
      setPoseFaceImageCameraAnimating(false)
      return
    }

    setPoseFaceImageCameraAnimating(true)
    poseFaceImageDisplayCameraRef.current = fromCamera
    setPoseFaceImageDisplayCamera(fromCamera)

    const animateCamera = (now: number) => {
      const progress = Math.min(1, (now - startedAt) / POSE_FACE_IMAGE_ZOOM_DURATION_MS)
      const nextCamera = interpolatePoseFaceImageCamera(fromCamera, toCamera, progress)

      poseFaceImageDisplayCameraRef.current = nextCamera
      setPoseFaceImageDisplayCamera(nextCamera)

      if (progress < 1) {
        poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateCamera)
        return
      }

      poseFaceImageCameraAnimationFrameRef.current = null
      poseFaceImageDisplayCameraRef.current = toCamera
      setPoseFaceImageDisplayCamera(toCamera)
      setPoseFaceImageCameraAnimating(false)
    }

    poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateCamera)
  }, [poseFaceImageCamera, poseFaceImagePanning])
  const poseFaceImageRenderedCamera = poseFaceImageCamera || poseFaceImageZoomOutActive
    ? poseFaceImageDisplayCamera ?? poseFaceImageFullCamera()
    : null
  const poseFaceImageZoomStyle = useMemo((): CSSProperties | undefined => {
    if (!poseFaceImageRenderedCamera) {
      return undefined
    }

    return {
      transform: `matrix(${poseFaceImageRenderedCamera.scale}, 0, 0, ${poseFaceImageRenderedCamera.scale}, ${poseFaceImageRenderedCamera.translateX}, ${poseFaceImageRenderedCamera.translateY})`,
      transformOrigin: '0 0',
    }
  }, [poseFaceImageRenderedCamera])
  const poseFaceImageZoomApplied = Boolean(poseFaceImageZoomStyle)
  const setClampedPoseFaceImagePanCenter = useCallback((
    centerX: number,
    centerY: number,
    camera: PoseFaceImageCamera | null = poseFaceImageCamera
  ) => {
    if (!camera) return

    const next = clampPoseFaceCameraCenter(
      centerX,
      centerY,
      camera.viewportWidth,
      camera.viewportHeight
    )

    poseFaceImagePanCenterRef.current = next
    setPoseFaceImagePanCenter((current) => {
      if (
        current &&
        Math.abs(current.x - next.x) <= 0.0005 &&
        Math.abs(current.y - next.y) <= 0.0005
      ) {
        return current
      }

      return next
    })
  }, [poseFaceImageCamera])

  const startPoseFaceImagePan = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
    if (
      !poseFaceImageZoomApplied ||
      !poseFaceImageCamera ||
      !imageLayerStyle ||
      poseFaceImageCameraAnimating
    ) {
      return
    }
    if (event.button !== 0) return
    if (pendingPoseKeypoint || boxLabel || uiLocked || frameBusy || trainingRunning || saving) return
    if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return

    const target = event.target as HTMLElement | null
    if (target?.closest('[data-box-control="true"]')) return
    if (target?.closest('[data-pose-control="true"]')) return

    const contentWidth = Number(imageLayerStyle.width)
    const contentHeight = Number(imageLayerStyle.height)

    if (
      !Number.isFinite(contentWidth) ||
      !Number.isFinite(contentHeight) ||
      contentWidth <= 0 ||
      contentHeight <= 0
    ) {
      return
    }

    event.preventDefault()
    event.stopPropagation()
    window.getSelection()?.removeAllRanges()

    poseFaceImagePanGestureRef.current = {
      pointerId: event.pointerId,
      source: 'image',
      startClientX: event.clientX,
      startClientY: event.clientY,
      startCenterX: poseFaceImageCamera.centerX,
      startCenterY: poseFaceImageCamera.centerY,
      contentWidth,
      contentHeight,
      scale: poseFaceImageCamera.scale,
      moved: false,
    }
    setPoseFaceImagePanning(true)

    try {
      event.currentTarget.setPointerCapture(event.pointerId)
    } catch {
      // Pointer-Capture ist nur Komfort; die lokale Geste funktioniert auch ohne.
    }
  }, [
    boxLabel,
    frameBusy,
    imageLayerStyle,
    pendingPoseKeypoint,
    poseFaceImageCamera,
    poseFaceImageCameraAnimating,
    poseFaceImageZoomApplied,
    saving,
    trainingRunning,
    uiLocked,
  ])

  const startPoseFacePreviewPan = useCallback((event: React.PointerEvent<HTMLElement>) => {
    if (!poseFaceImageZoomApplied || !poseFaceImageCamera || poseFaceImageCameraAnimating) return
    if (event.button !== 0) return

    event.preventDefault()
    event.stopPropagation()
    window.getSelection()?.removeAllRanges()

    poseFaceImagePanGestureRef.current = {
      pointerId: event.pointerId,
      source: 'preview',
      startClientX: event.clientX,
      startClientY: event.clientY,
      startCenterX: poseFaceImageCamera.centerX,
      startCenterY: poseFaceImageCamera.centerY,
      contentWidth: 1,
      contentHeight: 1,
      scale: 1,
      moved: false,
    }
    setPoseFaceImagePanning(true)

    try {
      event.currentTarget.setPointerCapture(event.pointerId)
    } catch {
      // Pointer-Capture ist nur Komfort; die lokale Geste funktioniert auch ohne.
    }
  }, [poseFaceImageCamera, poseFaceImageCameraAnimating, poseFaceImageZoomApplied])

  const movePoseFaceImagePan = useCallback((event: React.PointerEvent<HTMLElement>) => {
    const gesture = poseFaceImagePanGestureRef.current
    if (!gesture || gesture.pointerId !== event.pointerId) return

    event.preventDefault()
    event.stopPropagation()

    const deltaX = event.clientX - gesture.startClientX
    const deltaY = event.clientY - gesture.startClientY

    if (!gesture.moved && Math.hypot(deltaX, deltaY) >= 4) {
      gesture.moved = true
    }

    if (gesture.source === 'image') {
      setClampedPoseFaceImagePanCenter(
        gesture.startCenterX - deltaX / (gesture.scale * gesture.contentWidth),
        gesture.startCenterY - deltaY / (gesture.scale * gesture.contentHeight)
      )
      return
    }

    const rect = event.currentTarget.getBoundingClientRect()
    if (rect.width <= 0 || rect.height <= 0) return

    setClampedPoseFaceImagePanCenter(
      gesture.startCenterX + deltaX / rect.width,
      gesture.startCenterY + deltaY / rect.height
    )
  }, [setClampedPoseFaceImagePanCenter])

  const zoomOutPoseFaceImageToBody = useCallback(() => {
    if (poseFaceImageCameraAnimationFrameRef.current !== null) {
      cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current)
      poseFaceImageCameraAnimationFrameRef.current = null
    }

    const fromCamera =
      poseFaceImageDisplayCameraRef.current ??
      poseFaceImageCamera ??
      poseFaceImageFullCamera()
    const toCamera = poseFaceImageFullCamera()
    const startedAt = performance.now()

    poseFaceImagePanGestureRef.current = null
    poseFaceImagePanCenterRef.current = null
    setPoseFaceImagePanCenter(null)
    setPoseFaceImagePanning(false)
    poseFaceImageZoomOutActiveRef.current = true
    setPoseFaceImageZoomOutActive(true)
    setPoseFaceImageCameraAnimating(true)
    poseFaceImageDisplayCameraRef.current = fromCamera
    setPoseFaceImageDisplayCamera(fromCamera)

    // Die rechte Map darf parallel zurueckfahren; die Bildkamera bleibt durch
    // den Override sichtbar, bis der Zoom-Out fertig ist.
    switchPoseKeypointMapView('body')

    const animateZoomOut = (now: number) => {
      const progress = Math.min(1, (now - startedAt) / POSE_FACE_IMAGE_ZOOM_DURATION_MS)
      const nextCamera = interpolatePoseFaceImageCamera(fromCamera, toCamera, progress)

      poseFaceImageDisplayCameraRef.current = nextCamera
      setPoseFaceImageDisplayCamera(nextCamera)

      if (progress < 1) {
        poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateZoomOut)
        return
      }

      poseFaceImageCameraAnimationFrameRef.current = null
      poseFaceImageDisplayCameraRef.current = null
      poseFaceImageZoomOutActiveRef.current = false
      setPoseFaceImageDisplayCamera(null)
      setPoseFaceImageCameraAnimating(false)
      setPoseFaceImageZoomOutActive(false)
    }

    poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateZoomOut)
  }, [poseFaceImageCamera, switchPoseKeypointMapView])

  const deletePosePerson = useCallback((personIndex: number) => {
    const shouldNavigateAfterDelete =
      poseFaceImageZoomApplied &&
      poseCorrectionPersonIndex === personIndex

    if (!shouldNavigateAfterDelete) {
      removePosePerson(personIndex)
      return
    }

    const remainingVisibleIndexes = visiblePosePersonIndexes.filter((index) => index !== personIndex)

    if (remainingVisibleIndexes.length === 0) {
      removePosePerson(personIndex)
      zoomOutPoseFaceImageToBody()
      return
    }

    const currentPosition = visiblePosePersonIndexes.indexOf(personIndex)
    const nextOldIndex =
      remainingVisibleIndexes.find((index) =>
        visiblePosePersonIndexes.indexOf(index) > currentPosition
      ) ?? remainingVisibleIndexes[0]
    const nextPersonIndex = nextOldIndex > personIndex ? nextOldIndex - 1 : nextOldIndex

    removePosePerson(personIndex)
    setShowPoseSkeleton(true)
    setActivePosePersonIndex(nextPersonIndex)
    setPendingPoseKeypoint(null)
    switchPoseKeypointMapView('head')
  }, [
    poseCorrectionPersonIndex,
    poseFaceImageZoomApplied,
    removePosePerson,
    switchPoseKeypointMapView,
    visiblePosePersonIndexes,
    zoomOutPoseFaceImageToBody,
  ])

  const finishPoseFaceImagePan = useCallback((event: React.PointerEvent<HTMLElement>) => {
    const gesture = poseFaceImagePanGestureRef.current
    if (!gesture || gesture.pointerId !== event.pointerId) return

    event.preventDefault()
    event.stopPropagation()

    try {
      event.currentTarget.releasePointerCapture(event.pointerId)
    } catch {
      // Pointer-Capture war evtl. schon weg.
    }

    poseFaceImagePanGestureRef.current = null
    setPoseFaceImagePanning(false)

    if (gesture.source === 'preview' && !gesture.moved) {
      zoomOutPoseFaceImageToBody()
    }
  }, [zoomOutPoseFaceImageToBody])

  useEffect(() => {
    if (!poseModeActive) {
      setPoseMapSlotHeight(0)
      return
    }

    const mediaQuery = window.matchMedia('(min-width: 1024px)')

    const updateHeight = () => {
      const element = poseMapSlotRef.current

      if (!mediaQuery.matches || !element) {
        setPoseMapSlotHeight(0)
        return
      }

      const nextHeight = Math.floor(element.getBoundingClientRect().height)

      setPoseMapSlotHeight((current) =>
        current === nextHeight ? current : nextHeight
      )
    }

    updateHeight()

    const resizeObserver =
      typeof ResizeObserver !== 'undefined'
        ? new ResizeObserver(() => updateHeight())
        : null

    if (resizeObserver && poseMapSlotRef.current) {
      resizeObserver.observe(poseMapSlotRef.current)
    }

    mediaQuery.addEventListener('change', updateHeight)
    window.addEventListener('resize', updateHeight)

    return () => {
      resizeObserver?.disconnect()
      mediaQuery.removeEventListener('change', updateHeight)
      window.removeEventListener('resize', updateHeight)
    }
  }, [poseModeActive])

  const annotationModeTabs = () => {
    if (!poseModeAvailable) return null

    const modeButtonClass = (active: boolean) => [
      'inline-flex h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-bold transition',
      'focus:outline-none focus:ring-2 focus:ring-indigo-500/35',
      active
        ? 'bg-white text-indigo-700 shadow-sm ring-1 ring-indigo-200 dark:bg-indigo-500 dark:text-white dark:ring-indigo-300/30'
        : 'text-gray-600 hover:bg-white/80 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white',
    ].join(' ')

    return (
      <div className="grid grid-cols-2 gap-1 rounded-xl bg-gray-200/70 p-1 dark:bg-black/20">
        <button
          type="button"
          className={modeButtonClass(!poseModeActive)}
          aria-pressed={!poseModeActive}
          onClick={() => {
            setShowPoseSkeleton(false)
            setActivePosePersonIndex(null)
            setPendingPoseKeypoint(null)
          }}
          title="Detector-Boxen bearbeiten"
        >
          <RectangleGroupIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
          <span className="truncate">Boxen</span>
          <span className="shrink-0 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] font-black leading-none text-current ring-1 ring-black/10 dark:bg-white/15 dark:ring-white/10">
            {correctionBoxes.length}
          </span>
        </button>

        <button
          type="button"
          className={modeButtonClass(poseModeActive)}
          aria-pressed={poseModeActive}
          onClick={() => {
            setShowPoseSkeleton(true)
            setActiveBoxIndex(null)
          }}
          title="Pose-Skelett korrigieren"
        >
          <UserGroupIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
          <span className="truncate">Pose</span>
          <span className="shrink-0 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] font-black leading-none text-current ring-1 ring-black/10 dark:bg-white/15 dark:ring-white/10">
            {visiblePosePersonCount}
          </span>
        </button>
      </div>
    )
  }

  const shownTrainingDurationMs = useMemo(() => {
    const job = trainingStatus?.training

    if (!job) return 0

    if (job.running && job.startedAt) {
      const started = Date.parse(job.startedAt)
      if (Number.isFinite(started)) {
        return Math.max(0, trainingNowMs - started)
      }
    }

    return trainingDurationMs(job)
  }, [trainingStatus?.training, trainingNowMs])

  const rawTrainingEtaMs = useMemo(() => {
    if (!trainingRunning) return 0

    const job = trainingStatus?.training
    const epoch = Number(job?.epoch ?? 0)
    const epochs = Number(job?.epochs ?? 0)

    const hasEpochInfo =
      Number.isFinite(epoch) &&
      Number.isFinite(epochs) &&
      epoch > 0 &&
      epochs > 0
    const completedEpochUnits = hasEpochInfo
      ? trainingCompletedEpochUnits(epoch, epochs, currentTrainingTargetProgress)
      : 0
    const remainingEpochs =
      Number.isFinite(epochs) && epochs > 0
        ? Math.max(0, epochs - completedEpochUnits)
        : 0
    const epochEtaMs =
      Number.isFinite(estimatedEpochMs) && estimatedEpochMs > 0
        ? remainingEpochs * estimatedEpochMs
        : 0

    if (!currentTrainingTarget || effectiveTrainingTargets.length === 0) {
      return Math.max(0, epochEtaMs)
    }

    const currentIndex = effectiveTrainingTargets.indexOf(currentTrainingTarget)
    if (currentIndex < 0) {
      return Math.max(0, epochEtaMs)
    }

    const currentEstimateMs = Math.max(
      0,
      trainingEstimateByTarget[currentTrainingTarget] || 0
    )
    const stageRemainingMs = currentEstimateMs > 0
      ? currentEstimateMs * (1 - currentTrainingTargetProgress)
      : 0
    const currentRemainingMs = combineTrainingEtaMs(stageRemainingMs, epochEtaMs)
    const laterTargetsMs = effectiveTrainingTargets
      .slice(currentIndex + 1)
      .reduce((sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0), 0)

    return Math.max(0, currentRemainingMs + laterTargetsMs)
  }, [
    currentTrainingTarget,
    currentTrainingTargetProgress,
    effectiveTrainingTargets,
    trainingRunning,
    trainingStatus?.training?.epoch,
    trainingStatus?.training?.epochs,
    estimatedEpochMs,
    trainingEstimateByTarget,
  ])

  useEffect(() => {
    if (!trainingRunning || rawTrainingEtaMs <= 0) {
      etaSmoothingRef.current = {
        lastAt: 0,
        lastRawEtaMs: 0,
      }

      setSmoothedTrainingEtaMs(0)
      return
    }

    setSmoothedTrainingEtaMs((previous) => {
      const state = etaSmoothingRef.current

      const lastAt = state.lastAt || trainingNowMs
      const elapsed = Math.max(0, trainingNowMs - lastAt)

      // 1) Immer erstmal echte Anzeige runterzählen.
      const countedDown =
        previous > 0
          ? Math.max(0, previous - elapsed)
          : rawTrainingEtaMs

      const rawChanged =
        state.lastRawEtaMs <= 0 ||
        Math.abs(rawTrainingEtaMs - state.lastRawEtaMs) > 1000

      let next = countedDown

      if (rawChanged) {
        const diff = rawTrainingEtaMs - countedDown

        // Neue Backend-Schätzung einblenden, aber nicht hart springen.
        if (diff > 0) {
          // Restzeit wurde höher neu berechnet: langsam nach oben.
          next = countedDown + Math.min(diff * 0.20, 15_000)
        } else {
          // Restzeit wurde niedriger neu berechnet: schneller nach unten.
          next = countedDown + diff * 0.45
        }
      }

      next = Math.max(0, next)

      etaSmoothingRef.current = {
        lastAt: trainingNowMs,
        lastRawEtaMs: rawTrainingEtaMs,
      }

      return next
    })
  }, [trainingRunning, rawTrainingEtaMs, trainingNowMs])

  const shownTrainingEtaMs = smoothedTrainingEtaMs

  const shownTrainingEpochText = useMemo(() => {
    const epoch = Number(trainingStatus?.training?.epoch ?? 0)
    const epochs = Number(trainingStatus?.training?.epochs ?? 0)

    if (!Number.isFinite(epoch) || !Number.isFinite(epochs)) return ''
    if (epoch <= 0 || epochs <= 0) return ''

    return `Epoche ${epoch}/${epochs}`
  }, [
    trainingStatus?.training?.epoch,
    trainingStatus?.training?.epochs,
  ])

  const trainingMetricLabel =
    currentTrainingTarget === 'videomae' ? 'Accuracy' : 'mAP50'
  const trainingMetricText =
    currentTrainingTarget === 'videomae'
      ? formatMapPercent(trainingStatus?.training?.accuracy) || '—'
      : formatMapPercent(trainingStatus?.training?.map50) || '—'

  useEffect(() => {
    const toastImage =
      imageSrc
        ? {
            imageUrl: imageSrc,
            imageAlt: sample?.sourceFile || 'Training Frame',
          }
        : undefined

    if (error) {
      notify.error('Aktion fehlgeschlagen', error, toastImage)
      setError(null)
      return
    }

    if (!message) return

    const lowerMessage = message.toLowerCase()

    const looksPartial =
      lowerMessage.includes('übersprungen') ||
      lowerMessage.includes('fehlgeschlagen') ||
      lowerMessage.includes('abgebrochen')

    if (looksPartial) {
      notify.warning('Training teilweise abgeschlossen', message, toastImage)
    } else {
      notify.success('Erfolg', message, toastImage)
    }

    setMessage(null)
  }, [error, message, notify, imageSrc, sample?.sourceFile])

  const trainingActionsPanel = (opts?: { compact?: boolean }) => {
    const compact = Boolean(opts?.compact)
    const feedbackReady = feedbackCount >= requiredCount
    const detector = trainingStatus?.detector
    const detectorReady = Boolean(detector?.dataReady)
    const pose = trainingStatus?.pose
    const poseReady = Boolean(pose?.dataReady)
    const videoMAE = trainingStatus?.videoMAE
    const videoMAEReady = Boolean(videoMAE?.dataReady)
    const missingTrain = Math.max(
      0,
      Number(detector?.requiredTrain ?? 20) - Number(detector?.trainCount ?? 0)
    )
    const missingVal = Math.max(
      0,
      Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 0)
    )
    const missingPositiveTrain = Number(detector?.positiveTrainCount ?? 0) < 1
    const missingPositiveVal = Number(detector?.positiveValCount ?? 0) < 1
    const positiveSamplesMissing = missingPositiveTrain || missingPositiveVal
    const missingPoseTrain = Math.max(
      0,
      Number(pose?.requiredTrain ?? 20) - Number(pose?.trainCount ?? 0)
    )
    const missingPoseVal = Math.max(
      0,
      Number(pose?.requiredVal ?? 3) - Number(pose?.valCount ?? 0)
    )

    const boundedCount = (value: number, required: number) => {
      const cleanValue = Number.isFinite(value) ? Math.max(0, value) : 0
      const cleanRequired = Number.isFinite(required) ? Math.max(0, required) : 0
      return Math.min(cleanValue, cleanRequired)
    }

    const detectorTrainCount = Number(detector?.trainCount ?? 0)
    const detectorValCount = Number(detector?.valCount ?? 0)
    const detectorRequiredTrain = Number(detector?.requiredTrain ?? 20)
    const detectorRequiredVal = Number(detector?.requiredVal ?? 3)
    const detectorPositiveDone =
      (Number(detector?.positiveTrainCount ?? 0) >= 1 ? 1 : 0) +
      (Number(detector?.positiveValCount ?? 0) >= 1 ? 1 : 0)
    const detectorPositiveRequired = 2
    const detectorDone =
      boundedCount(detectorTrainCount, detectorRequiredTrain) +
      boundedCount(detectorValCount, detectorRequiredVal) +
      detectorPositiveDone
    const detectorRequired =
      Math.max(0, detectorRequiredTrain) +
      Math.max(0, detectorRequiredVal) +
      detectorPositiveRequired

    const poseTrainCount = Number(pose?.trainCount ?? 0)
    const poseValCount = Number(pose?.valCount ?? 0)
    const poseRequiredTrain = Number(pose?.requiredTrain ?? 20)
    const poseRequiredVal = Number(pose?.requiredVal ?? 3)
    const poseDone =
      boundedCount(poseTrainCount, poseRequiredTrain) +
      boundedCount(poseValCount, poseRequiredVal)
    const poseRequired =
      Math.max(0, poseRequiredTrain) +
      Math.max(0, poseRequiredVal)
    const videoMAETrainCount = Number(videoMAE?.trainCount ?? 0)
    const videoMAEValCount = Number(videoMAE?.valCount ?? 0)
    const videoMAEEligibleCount = Number(videoMAE?.eligibleCount ?? 0)
    const videoMAERequiredTrain = Number(videoMAE?.requiredTrain ?? 40)
    const videoMAERequiredVal = Number(videoMAE?.requiredVal ?? 5)
    const videoMAEDone =
      boundedCount(videoMAETrainCount, videoMAERequiredTrain) +
      boundedCount(videoMAEValCount, videoMAERequiredVal)
    const videoMAERequired =
      Math.max(0, videoMAERequiredTrain) +
      Math.max(0, videoMAERequiredVal)

    const feedbackDone = boundedCount(feedbackCount, requiredCount)
    const feedbackRequired = Math.max(0, requiredCount)
    const readinessDone = feedbackDone + detectorDone + poseDone + videoMAEDone
    const readinessRequired = feedbackRequired + detectorRequired + poseRequired + videoMAERequired
    const readinessProgress = readinessRequired > 0 ? readinessDone / readinessRequired : 0
    const readinessItems = [
      {
        key: 'detector',
        label: 'Object Detection',
        value: detectorRequired > 0 ? detectorDone / detectorRequired : 1,
        text: `${detectorDone}/${detectorRequired}`,
        detail: `Train ${detectorTrainCount}/${detectorRequiredTrain}, Val ${detectorValCount}/${detectorRequiredVal}, Positiv ${detectorPositiveDone}/${detectorPositiveRequired}`,
      },
      {
        key: 'pose',
        label: 'Pose Detection',
        value: poseRequired > 0 ? poseDone / poseRequired : 1,
        text: `${poseDone}/${poseRequired}`,
        detail: `Train ${poseTrainCount}/${poseRequiredTrain}, Val ${poseValCount}/${poseRequiredVal}`,
      },
      {
        key: 'videomae',
        label: 'VideoMAE',
        value: videoMAERequired > 0 ? videoMAEDone / videoMAERequired : 1,
        text: `${videoMAEDone}/${videoMAERequired}`,
        detail: `Eligible ${videoMAEEligibleCount}, Train ${videoMAETrainCount}/${videoMAERequiredTrain}, Val ${videoMAEValCount}/${videoMAERequiredVal}`,
      },
    ]

    const trainingPaused = Boolean(trainingStatus?.training?.paused)
    const pauseReason = String(trainingStatus?.training?.pauseReason || '').trim()
    const pauseMetrics = [
      trainingStatus?.training?.cpuPercent
        ? `CPU ${Math.round(Number(trainingStatus.training.cpuPercent))}%`
        : '',
      trainingStatus?.training?.temperatureC
        ? `${Math.round(Number(trainingStatus.training.temperatureC))}°C`
        : '',
    ].filter(Boolean).join(' · ')
    const progress = clampPercent(
      trainingRunning
        ? shownTrainingProgress
        : readinessProgress * 100
    )
    const statusText = trainingRunning
      ? trainingPaused
        ? `Pausiert${pauseReason ? `: ${pauseReason}` : ''}${pauseMetrics ? ` · ${pauseMetrics}` : ''}`
        : shownTrainingStep || 'Training läuft…'
      : !feedbackReady
        ? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch`
        : canStartTraining
          ? 'Bereit zum Trainieren'
        : !detectorReady
          ? missingTrain > 0 || missingVal > 0
            ? `YOLO-Beispiele fehlen: ${missingTrain} Train, ${missingVal} Val`
            : positiveSamplesMissing
              ? 'Je ein positives Beispiel in Train und Val erforderlich'
              : 'YOLO-Datensatz noch nicht bereit'
          : !poseReady
            ? missingPoseTrain > 0 || missingPoseVal > 0
              ? `Pose-Beispiele fehlen: ${missingPoseTrain} Train, ${missingPoseVal} Val`
              : 'Pose-Datensatz noch nicht bereit'
          : !videoMAEReady
            ? 'VideoMAE-Datensatz noch nicht bereit'
          : 'Noch nicht trainingsbereit'
    return (
      <div
        className={[
          'relative z-0 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70',
          compact ? 'p-2' : 'p-3',
        ].join(' ')}
      >
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0">
            <div className="flex items-center gap-2">
              <div
                className={[
                  'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
                  trainingPaused
                    ? 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30'
                    : trainingRunning
                    ? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30'
                    : canStartTraining
                      ? 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30'
                      : 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
                ].join(' ')}
              >
                {trainingPaused ? (
                  <PauseIcon className="h-4 w-4" aria-hidden="true" />
                ) : trainingRunning ? (
                  <ArrowPathIcon className="h-4 w-4 animate-spin" aria-hidden="true" />
                ) : (
                  <BoltIcon className="h-4 w-4" aria-hidden="true" />
                )}
              </div>

              <div className="min-w-0">
                <div className="text-xs font-semibold text-gray-900 dark:text-white">
                  Training-Aktionen
                </div>

                <div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
                  {statusText}
                </div>
              </div>
            </div>
          </div>

          <button
            type="button"
            onClick={() => setStatsModalOpen(true)}
            className="shrink-0 rounded-full bg-gray-50 px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 transition hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-indigo-500/20 dark:hover:text-indigo-100 dark:hover:ring-indigo-300/30"
            title="Training-Datenstatistiken anzeigen"
            aria-label="Training-Datenstatistiken anzeigen"
          >
            {feedbackBadgeText}
          </button>
        </div>

        <div className="mt-3 rounded-xl bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/[0.04] dark:ring-white/10">
          <button
            type="button"
            disabled={uiLocked}
            onClick={() => {
              const nextMode: TrainingSampleMode =
                trainingSampleMode === 'uncertain' ? 'random' : 'uncertain'

              setTrainingSampleMode(nextMode)

              if (!uiLocked && nextMode === 'uncertain') {
                void loadNext({
                  forceNew: true,
                  mode: nextMode,
                  deferCurrentSampleToQueueEnd: Boolean(sampleRef.current),
                })
              } else if (!uiLocked && !sampleRef.current) {
                if (!loadNextImportedQueuedSample()) {
                  void loadNext({
                    forceNew: true,
                    mode: nextMode,
                  })
                }
              }
            }}
            className={[
              'flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left transition',
              'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
              uiLocked
                ? 'cursor-not-allowed opacity-50'
                : 'hover:bg-white dark:hover:bg-white/10',
            ].join(' ')}
            title="Wenn aktiv, werden bevorzugt Frames mit niedriger oder mittlerer Modell-Confidence geladen."
            aria-pressed={trainingSampleMode === 'uncertain'}
          >
            <span className="min-w-0">
              <span className="block text-[11px] font-bold text-gray-900 dark:text-white">
                Unsichere zuerst
              </span>

              <span className="mt-0.5 block text-[10px] leading-snug text-gray-500 dark:text-gray-400">
                Lädt bevorzugt Grenzfälle, die dem Modell am meisten helfen.
              </span>
            </span>

            <span
              className={[
                'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition',
                trainingSampleMode === 'uncertain'
                  ? 'bg-indigo-600'
                  : 'bg-gray-300 dark:bg-white/20',
              ].join(' ')}
              aria-hidden="true"
            >
              <span
                className={[
                  'inline-block h-4 w-4 rounded-full bg-white shadow transition',
                  trainingSampleMode === 'uncertain'
                    ? 'translate-x-4'
                    : 'translate-x-0.5',
                ].join(' ')}
              />
            </span>
          </button>
        </div>

        {trainingRunning || !canStartTraining ? (
          <div className="mt-3">
            <div className="mb-1 flex items-center justify-between text-[10px] font-medium text-gray-500 dark:text-gray-400">
              <span>{trainingRunning ? 'Trainingsfortschritt' : 'Trainingsbereitschaft'}</span>
              <span>{Math.round(progress)}%</span>
            </div>

            <div className="h-2 overflow-hidden rounded-full bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
              <div
                className={[
                  'h-full rounded-full transition-all duration-500',
                  trainingPaused
                    ? 'bg-amber-500'
                    : trainingRunning
                    ? 'bg-indigo-500'
                    : canStartTraining
                      ? 'bg-emerald-500'
                      : readinessDone > 0
                        ? 'bg-amber-500'
                        : 'bg-gray-400',
                ].join(' ')}
                style={{ width: `${progress}%` }}
              />
            </div>

            {!trainingRunning ? (
              <div className="mt-2 grid gap-1.5">
                {readinessItems.map((item) => {
                  const itemProgress = clampPercent(item.value * 100)

                  return (
                    <div key={item.key} className="min-w-0">
                      <div className="flex items-center justify-between gap-2 text-[10px] leading-tight">
                        <div className="min-w-0 truncate font-semibold text-gray-700 dark:text-gray-200">
                          {item.label}
                        </div>

                        <div className="shrink-0 font-semibold tabular-nums text-gray-500 dark:text-gray-400">
                          {item.text}
                        </div>
                      </div>

                      <div className="mt-0.5 truncate text-[10px] text-gray-500 dark:text-gray-400">
                        {item.detail}
                      </div>

                      <div className="mt-1 h-1 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10">
                        <div
                          className={[
                            'h-full rounded-full transition-all duration-500',
                            item.key === 'detector'
                              ? 'bg-sky-500'
                              : item.key === 'pose'
                                ? 'bg-fuchsia-500'
                                : 'bg-emerald-500',
                          ].join(' ')}
                          style={{ width: `${itemProgress}%` }}
                        />
                      </div>
                    </div>
                  )
                })}
              </div>
            ) : null}
          </div>
        ) : null}

        {showTrainingInfo ? (
          <div
            className={[
              'mt-3 rounded-xl p-3 ring-1',
              trainingInfoLooksPartial
                ? 'bg-amber-50 text-amber-950 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-100 dark:ring-amber-400/30'
                : 'bg-emerald-50 text-emerald-950 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/30',
            ].join(' ')}
          >
            <div className="flex items-start gap-3">
              <div
                className={[
                  'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
                  trainingInfoLooksPartial
                    ? 'bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30'
                    : 'bg-emerald-100 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30',
                ].join(' ')}
              >
                {trainingInfoLooksPartial ? (
                  <XCircleIcon className="h-4 w-4" aria-hidden="true" />
                ) : (
                  <CheckIcon className="h-4 w-4" aria-hidden="true" />
                )}
              </div>

              <div className="min-w-0 flex-1">
                <div className="flex items-start justify-between gap-2">
                  <div className="min-w-0">
                    <div className="text-xs font-bold">
                      Training-Info
                    </div>

                    <div className="mt-1 text-[11px] leading-snug opacity-90">
                      {trainingInfoMessage}
                    </div>
                  </div>

                  <button
                    type="button"
                    onClick={dismissTrainingInfo}
                    className="shrink-0 rounded-md px-1.5 py-0.5 text-xs font-bold opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
                    aria-label="Training-Info ausblenden"
                    title="Bis zum nächsten Training ausblenden"
                  >
                    ×
                  </button>
                </div>

                <div className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
                  <div
                    className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
                    title={`${trainingStatus?.detector?.positiveTrainCount ?? 0} positive Trainingsbeispiele`}
                  >
                    <div className="font-semibold opacity-60">
                      Dauer
                    </div>
                    <div className="mt-0.5 font-bold">
                      {trainingInfoDurationMs > 0
                        ? formatDuration(trainingInfoDurationMs)
                        : '—'}
                    </div>
                  </div>

                  <div
                    className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
                    title={`${trainingStatus?.detector?.positiveValCount ?? 0} positive Validierungsbeispiele`}
                  >
                    <div className="font-semibold opacity-60">
                      Feedback
                    </div>
                    <div className="mt-0.5 font-bold">
                      {feedbackCount}/{requiredCount}
                    </div>
                  </div>

                  <div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                    <div className="font-semibold opacity-60">
                      YOLO Train
                    </div>
                    <div className="mt-0.5 font-bold">
                      {trainingStatus?.detector?.trainCount ?? 0}/{trainingStatus?.detector?.requiredTrain ?? 20}
                    </div>
                  </div>

                  <div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
                    <div className="font-semibold opacity-60">
                      YOLO Val
                    </div>
                    <div className="mt-0.5 font-bold">
                      {trainingStatus?.detector?.valCount ?? 0}/{trainingStatus?.detector?.requiredVal ?? 3}
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        ) : null}

        <div className="mt-3 grid grid-cols-2 gap-2">
          <Button
            size="md"
            variant="secondary"
            className="w-full justify-center px-2 text-[11px]"
            disabled={uiLocked || !sample}
            onClick={() => void reloadCurrentImage()}
            title="Lädt das aktuelle Bild erneut und führt die Analyse neu aus."
          >
            <span className="inline-flex items-center gap-1.5">
              <ArrowPathIcon className="h-3.5 w-3.5" aria-hidden="true" />
              Neuladen
            </span>
          </Button>

          <Button
            size="md"
            variant={trainingRunning ? 'primary' : canStartTraining ? 'primary' : 'soft'}
            color={trainingRunning ? 'red' : undefined}
            className="w-full justify-center px-2 text-[11px]"
            disabled={
              trainingRunning
                ? cancellingTraining || deletingTrainingData
                : uiLocked || !canStartTraining
            }
            onClick={() => {
              if (trainingRunning) {
                void cancelTraining()
                return
              }

              openTrainingStartModal()
            }}
            title={
              trainingRunning
                ? 'Training abbrechen und temporäre Trainingsausgaben löschen.'
                : canStartTraining
                  ? 'Trainingsumfang auswählen und Training starten.'
                  : !feedbackReady
                    ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
                    : !detectorReady
                      ? `YOLO-Datensatz noch nicht bereit: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20} (${detector?.positiveTrainCount ?? 0} positiv), Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3} (${detector?.positiveValCount ?? 0} positiv).`
                      : !poseReady
                        ? `Pose-Datensatz noch nicht bereit: Train ${pose?.trainCount ?? 0}/${pose?.requiredTrain ?? 20}, Val ${pose?.valCount ?? 0}/${pose?.requiredVal ?? 3}.`
                        : 'Training ist aktuell nicht verfügbar.'
            }
          >
            <span className="inline-flex items-center gap-1.5">
              {trainingRunning ? (
                <XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
              ) : (
                <BoltIcon className="h-3.5 w-3.5" aria-hidden="true" />
              )}

              {trainingRunning
                ? cancellingTraining
                  ? 'Breche ab…'
                  : 'Abbrechen'
                : 'Trainieren'}
            </span>
          </Button>
        </div>

        <div className="mt-2">
          <Button
            size="md"
            variant="secondary"
            className="w-full justify-center px-2 text-[11px]"
            disabled={feedbackCount === 0 || uiLocked}
            onClick={() => setFeedbackModalOpen(true)}
            title="Bisher abgegebenes Feedback ansehen."
          >
            <span className="inline-flex items-center gap-1.5">
              <InboxArrowDownIcon className="h-3.5 w-3.5" aria-hidden="true" />
              Abgegebenes Feedback ansehen
            </span>
          </Button>
        </div>

        {trainingRunning && compact ? (
          <div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
            <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
              <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                Laufzeit
              </div>
              <div className="mt-0.5 font-bold">
                {formatDuration(shownTrainingDurationMs)}
              </div>
            </div>

            <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
              <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                Restzeit
              </div>
              <div className="mt-0.5 font-bold">
                {shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
              </div>
            </div>

            <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
              <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                Epoche
              </div>
              <div className="mt-0.5 font-bold">
                {shownTrainingEpochText
                  ? shownTrainingEpochText.replace(/^Epoche\s+/i, '')
                  : '—'}
              </div>
            </div>

            <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
              <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                Ø / Epoche
              </div>
              <div className="mt-0.5 font-bold">
                {estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
              </div>
            </div>
          </div>
        ) : null}

      </div>
    )
  }

  const detectorBoxesPanel = (opts?: {
    compact?: boolean
    stretch?: boolean
    maxHeightClassName?: string
  }) => {
    const compact = Boolean(opts?.compact)
    const stretch = Boolean(opts?.stretch)
    const hasBoxes = correctionBoxes.length > 0
    const poseItems = posePersons
      .map((person, index) => ({ person, index }))
      .filter(({ person }) =>
        hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible)
      )
    const hasPoseItems = poseItems.length > 0
    const activeAnnotationTitle = poseModeActive ? 'Pose' : 'Detector-Boxen'
    const activeAnnotationCount = poseModeActive ? poseItems.length : correctionBoxes.length
    const activeAnnotationHasItems = poseModeActive ? hasPoseItems : hasBoxes

    return (
      <div
        className={[
          'relative z-0 flex min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm',
          'dark:border-white/10 dark:bg-gray-900/70',
          stretch ? 'h-full max-h-full flex-1' : 'max-h-full',
        ].join(' ')}
      >
        <div
          className={[
            'shrink-0 border-b border-gray-100 bg-gray-50/80 dark:border-white/10 dark:bg-white/[0.03]',
            compact ? 'px-2.5 py-2' : 'px-3 py-2.5',
          ].join(' ')}
        >
          <div className="flex items-start justify-between gap-3">
            <div className="min-w-0">
              <div className="flex items-center gap-2">
                <div className="truncate text-xs font-bold text-gray-900 dark:text-white">
                  {activeAnnotationTitle}
                </div>

                <span
                  className={[
                    'rounded-full px-2 py-0.5 text-[10px] font-black ring-1',
                    activeAnnotationHasItems
                      ? 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-500/15 dark:text-blue-100 dark:ring-blue-400/30'
                      : 'bg-gray-100 text-gray-500 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10',
                  ].join(' ')}
                >
                  {activeAnnotationCount}
                </span>
              </div>

              {!compact ? (
                <div className="mt-0.5 truncate text-[10px] text-gray-500 dark:text-gray-400">
                  {poseModeActive
                    ? 'Personen und Punkte prüfen oder einzeln löschen.'
                    : 'Prüfen, Label ändern oder einzeln löschen.'}
                </div>
              ) : null}
            </div>

            {poseModeActive && originalPosePersonCount > 0 ? (
              <button
                type="button"
                disabled={uiLocked}
                onClick={(e) => {
                  e.preventDefault()
                  e.stopPropagation()
                  resetPosePersons()
                  setShowPoseSkeleton(true)
                }}
                className={[
                  'shrink-0 rounded-lg px-2 py-1 text-[10px] font-bold transition ring-1',
                  'bg-white text-gray-600 ring-gray-200 hover:bg-gray-100 hover:text-gray-900',
                  'disabled:cursor-not-allowed disabled:opacity-50',
                  'dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white',
                ].join(' ')}
                title="Pose auf die ursprüngliche Erkennung zurücksetzen"
              >
                <span className="inline-flex items-center gap-1">
                  <ArrowPathIcon className="h-3 w-3" aria-hidden="true" />
                  Reset
                </span>
              </button>
            ) : !poseModeActive && hasBoxes ? (
              <button
                type="button"
                disabled={uiLocked}
                onClick={clearBoxes}
                className={[
                  'shrink-0 rounded-lg px-2 py-1 text-[10px] font-bold transition ring-1',
                  'bg-white text-red-600 ring-red-100 hover:bg-red-50 hover:text-red-700 hover:ring-red-200',
                  'disabled:cursor-not-allowed disabled:opacity-50',
                  'dark:bg-white/5 dark:text-red-300 dark:ring-red-400/20 dark:hover:bg-red-500/10',
                ].join(' ')}
                title="Alle Boxen entfernen"
              >
                <span className="inline-flex items-center gap-1">
                  <TrashIcon className="h-3 w-3" aria-hidden="true" />
                  Alle löschen
                </span>
              </button>
            ) : null}
          </div>

          {poseModeAvailable ? (
            <div className={compact ? 'mt-2' : 'mt-2.5'}>
              {annotationModeTabs()}
            </div>
          ) : null}
        </div>

        <div
          ref={detectorBoxesScrollRef}
          className={[
            'min-h-0 flex-1 overflow-y-auto overscroll-contain scroll-smooth',
            compact ? 'space-y-1 p-1.5' : 'space-y-1.5 p-2',
            !stretch && (opts?.maxHeightClassName || 'lg:max-h-[32dvh]'),
          ].filter(Boolean).join(' ')}
        >
          {poseModeActive ? (
            <>
              {!hasPoseItems ? (
              <div className="flex min-h-32 items-center justify-center rounded-xl border border-dashed border-gray-200 bg-gray-50 px-3 py-6 text-center dark:border-white/10 dark:bg-white/[0.03]">
                <div>
                  <div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
                    Keine Pose vorhanden
                  </div>

                  <div className="mt-1 max-w-[220px] text-[11px] leading-snug text-gray-500 dark:text-gray-400">
                    {originalPosePersonCount > 0
                      ? 'Reset stellt die zuletzt erkannte Pose wieder her.'
                      : 'Lege eine Person an und setze die Pose-Punkte im Bild.'}
                  </div>
                </div>
              </div>
            ) : (
              poseItems.map(({ person, index: personIndex }) => {
                const reliable = isPosePersonReliable(person)
                const isActive = activePosePersonIndex === personIndex
                const color = reliable
                  ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
                  : POSE_UNRELIABLE_COLOR
                const scoreText = typeof person.score === 'number' ? percent(person.score) : ''
                const quality = Math.round(posePersonQuality(person) * 100)
                const visibleKeypoints = posePersonVisibleKeypoints(person)
                const keypoints = (person.keypoints ?? [])
                  .map((point, pointIndex) => ({ point, pointIndex }))
                  .filter(({ point }) => isPoseKeypointVisible(point))

                return (
                  <div
                    key={`pose-list-${personIndex}`}
                    aria-disabled={uiLocked}
                    onClick={() => {
                      if (uiLocked) return
                      setShowPoseSkeleton(true)
                      setPendingPoseKeypoint(null)
                      setActivePosePersonIndex((current) =>
                        current === personIndex ? null : personIndex
                      )
                    }}
                    className={[
                      'group relative overflow-hidden rounded-2xl border transition-all duration-200',
                      'bg-white shadow-sm dark:border-white/10 dark:bg-gray-950/55',
                      uiLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
                      isActive
                        ? 'border-blue-200 bg-blue-50/40 ring-1 ring-blue-100 dark:border-blue-400/30 dark:bg-blue-500/10 dark:ring-blue-400/20'
                        : 'border-gray-200 hover:bg-gray-50/80 hover:shadow-md dark:hover:bg-white/[0.04]',
                    ].join(' ')}
                  >
                    <div
                      className="absolute inset-y-2 left-0 w-1 rounded-r-full transition-all duration-200"
                      style={{ backgroundColor: isActive ? color : 'transparent' }}
                      aria-hidden="true"
                    />

                    <div className={compact ? 'p-2 pl-3' : 'p-2.5 pl-3.5'}>
                      <div className="flex items-center gap-2.5">
                        <button
                          type="button"
                          disabled={uiLocked}
                          onClick={(e) => {
                            e.stopPropagation()
                            if (uiLocked) return
                            setShowPoseSkeleton(true)
                            setPendingPoseKeypoint(null)
                            setActivePosePersonIndex((current) =>
                              current === personIndex ? null : personIndex
                            )
                          }}
                          className={[
                            'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1 transition',
                            isActive
                              ? 'bg-blue-100 text-blue-700 ring-blue-200 dark:bg-blue-500/20 dark:text-blue-100 dark:ring-blue-300/30'
                              : 'bg-gray-50 text-gray-500 ring-gray-200 group-hover:bg-gray-100 group-hover:text-gray-700 dark:bg-white/5 dark:ring-white/10',
                          ].join(' ')}
                          title="Pose im Bild markieren"
                          aria-label={`Pose ${personIndex + 1} im Bild markieren`}
                        >
                          <UserGroupIcon className="h-4.5 w-4.5" aria-hidden="true" />
                        </button>

                        <div className="min-w-0 flex-1">
                          <div className="flex min-w-0 items-center justify-between gap-2">
                            <div className="min-w-0">
                              <div className="flex min-w-0 items-center gap-2">
                                <div className="truncate text-[13px] font-bold leading-tight text-gray-950 dark:text-white">
                                  Person
                                </div>

                                <span className="shrink-0 text-[10px] font-black text-gray-400 dark:text-gray-500">
                                  #{personIndex + 1}
                                </span>
                              </div>

                              <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium leading-tight text-gray-500 dark:text-gray-400">
                                <span
                                  className="h-1.5 w-1.5 shrink-0 rounded-full"
                                  style={{ backgroundColor: color }}
                                  aria-hidden="true"
                                />

                                <span className="truncate">
                                  {scoreText ? `Confidence ${scoreText}` : 'korrigiert'}
                                </span>

                                <span className="text-gray-300 dark:text-gray-600">·</span>

                                <span className="shrink-0">
                                  {visibleKeypoints} Punkte
                                </span>

                                {isActive ? (
                                  <>
                                    <span className="text-gray-300 dark:text-gray-600">·</span>
                                    <span className="font-semibold text-blue-700 dark:text-blue-200">
                                      ausgewählt
                                    </span>
                                  </>
                                ) : null}
                              </div>
                            </div>

                            <button
                              type="button"
                              disabled={uiLocked}
                              className={[
                                'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition',
                                'text-gray-400 hover:bg-red-50 hover:text-red-600',
                                'focus:outline-none focus:ring-2 focus:ring-red-500/30',
                                'disabled:cursor-not-allowed disabled:opacity-50',
                                'dark:text-gray-500 dark:hover:bg-red-500/10 dark:hover:text-red-300',
                              ].join(' ')}
                              title="Pose löschen"
                              aria-label={`Pose ${personIndex + 1} löschen`}
                              onClick={(e) => {
                                e.stopPropagation()
                                if (uiLocked) return
                                deletePosePerson(personIndex)
                              }}
                            >
                              <TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
                            </button>
                          </div>

                          {isActive ? (
                            <div className={compact ? 'mt-2 space-y-1' : 'mt-3 space-y-1'}>
                              <div className="flex items-center justify-between gap-2 rounded-lg bg-gray-50 px-2 py-1 text-[10px] font-semibold text-gray-500 ring-1 ring-gray-200 dark:bg-white/[0.04] dark:text-gray-400 dark:ring-white/10">
                                <span>Punkte</span>
                                <span>{quality}% Qualität</span>
                              </div>

                              {keypoints.length === 0 ? (
                                <div className="rounded-lg border border-dashed border-gray-200 px-2 py-2 text-[11px] text-gray-500 dark:border-white/10 dark:text-gray-400">
                                  Keine sichtbaren Punkte vorhanden.
                                </div>
                              ) : (
                                keypoints.map(({ point, pointIndex }) => {
                                  const label = poseKeypointLabel(point.name)
                                  const confidence = percent(point.conf)

                                  return (
                                    <div
                                      key={`pose-list-point-${personIndex}-${poseKeypointId(point.name)}-${pointIndex}`}
                                      className={[
                                        'flex items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-[11px] shadow-sm transition',
                                        uiLocked
                                          ? 'cursor-not-allowed opacity-70'
                                          : 'cursor-pointer hover:border-cyan-200 hover:bg-cyan-50/50 dark:hover:border-cyan-300/30 dark:hover:bg-cyan-500/10',
                                        'dark:border-white/10 dark:bg-gray-900/70',
                                      ].join(' ')}
                                      onPointerEnter={() => {
                                        if (uiLocked) return
                                        setHoveredPoseKeypoint({
                                          personIndex,
                                          keypointName: poseKeypointId(point.name),
                                        })
                                      }}
                                      onPointerLeave={() => {
                                        const keypointName = poseKeypointId(point.name)
                                        setHoveredPoseKeypoint((current) =>
                                          current?.personIndex === personIndex &&
                                          current.keypointName === keypointName
                                            ? null
                                            : current
                                        )
                                      }}
                                      onClick={(event) => {
                                        event.stopPropagation()
                                        if (uiLocked) return
                                        setShowPoseSkeleton(true)
                                        setActivePosePersonIndex(personIndex)
                                        setPendingPoseKeypoint({
                                          personIndex,
                                          keypointName: poseKeypointId(point.name),
                                        })
                                      }}
                                    >
                                      <div className="min-w-0">
                                        <div className="truncate font-bold text-gray-800 dark:text-gray-100">
                                          {label}
                                        </div>
                                        <div className="text-[10px] text-gray-500 dark:text-gray-400">
                                          Confidence {confidence}
                                        </div>
                                      </div>

                                      <button
                                        type="button"
                                        disabled={uiLocked}
                                        className={[
                                          'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition',
                                          'text-gray-400 hover:bg-red-50 hover:text-red-600',
                                          'focus:outline-none focus:ring-2 focus:ring-red-500/30',
                                          'disabled:cursor-not-allowed disabled:opacity-50',
                                          'dark:text-gray-500 dark:hover:bg-red-500/10 dark:hover:text-red-300',
                                        ].join(' ')}
                                        title={`${label} löschen`}
                                        aria-label={`${label} löschen`}
                                        onClick={(e) => {
                                          e.stopPropagation()
                                          if (uiLocked) return
                                          removePoseKeypoint(personIndex, pointIndex)
                                        }}
                                      >
                                        <TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
                                      </button>
                                    </div>
                                  )
                                })
                              )}
                            </div>
                          ) : null}
                        </div>
                      </div>
                    </div>
                  </div>
                )
              })
            )}

              <button
                type="button"
                disabled={uiLocked}
                onClick={addPosePerson}
                className={[
                  'group flex w-full items-center gap-2.5 rounded-2xl border border-dashed px-3 py-3 text-left transition-all duration-200',
                  'border-blue-200 bg-blue-50/35 text-blue-800 hover:border-blue-300 hover:bg-blue-50 hover:shadow-sm',
                  'focus:outline-none focus:ring-2 focus:ring-blue-500/30',
                  'disabled:cursor-not-allowed disabled:opacity-50',
                  'dark:border-blue-400/25 dark:bg-blue-500/10 dark:text-blue-100 dark:hover:border-blue-300/40 dark:hover:bg-blue-500/15',
                ].join(' ')}
              >
                <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white text-blue-700 ring-1 ring-blue-200 transition group-hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-100 dark:ring-blue-300/30">
                  <UserPlusIcon className="h-4.5 w-4.5" aria-hidden="true" />
                </span>

                <span className="min-w-0">
                  <span className="block text-[13px] font-bold leading-tight">
                    Neue Person
                  </span>
                  <span className="mt-0.5 block text-[11px] leading-snug text-blue-700/75 dark:text-blue-100/70">
                    Pose-Skelett hinzufügen
                  </span>
                </span>
              </button>
            </>
          ) : !hasBoxes ? (
            <div className="flex min-h-32 items-center justify-center rounded-xl border border-dashed border-gray-200 bg-gray-50 px-3 py-6 text-center dark:border-white/10 dark:bg-white/[0.03]">
              <div>
                <div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
                  Keine Boxen vorhanden
                </div>

                <div className="mt-1 max-w-[220px] text-[11px] leading-snug text-gray-500 dark:text-gray-400">
                  Wähle rechts ein Label aus und zeichne im Bild eine neue Box.
                </div>
              </div>
            </div>
          ) : (
            correctionBoxes.map((box, index) => {
              const item = getSegmentLabelItem(box.label)
              const Icon = item.icon
              const isActive = activeBoxIndex === index
              const isCorrected = typeof box.score !== 'number'
              const scoreText = typeof box.score === 'number' ? percent(box.score) : 'korrigiert'
              const tone = detectorBoxAppearance(box.label)

              return (
                <div
                  ref={(el) => {
                    detectorBoxItemRefs.current[index] = el
                  }}
                  key={`box-${index}`}
                  aria-disabled={uiLocked}
                  onClick={() => {
                    if (uiLocked) return
                    setActiveBoxIndex((current) => current === index ? null : index)
                  }}
                  className={[
                    'group relative overflow-hidden rounded-2xl border transition-all duration-200',
                    'bg-white shadow-sm',
                    'dark:border-white/10 dark:bg-gray-950/55',
                    uiLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
                    isActive
                      ? [
                          'border-gray-200 bg-white',
                          'dark:border-white/10',
                          tone.activeSurface,
                        ].join(' ')
                      : [
                          'border-gray-200',
                          uiLocked ? '' : 'hover:bg-gray-50/80 hover:shadow-md',
                          'dark:border-white/10',
                          uiLocked ? '' : 'dark:hover:bg-white/[0.04]',
                          uiLocked ? '' : tone.idleHover,
                        ].join(' '),
                  ].join(' ')}
                >
                  <div
                    className={[
                      'absolute inset-y-2 left-0 w-1 rounded-r-full transition-all duration-200',
                      isActive
                        ? tone.line
                        : ['bg-transparent', tone.lineHover].join(' '),
                    ].join(' ')}
                    aria-hidden="true"
                  />

                  <div className={compact ? 'p-2 pl-3' : 'p-2.5 pl-3.5'}>
                    <div className="flex items-center gap-2.5">
                      <button
                        type="button"
                        disabled={uiLocked}
                        onClick={(e) => {
                          e.stopPropagation()
                          if (uiLocked) return
                          setActiveBoxIndex((current) => current === index ? null : index)
                        }}
                        className={[
                          'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1 transition',
                          isActive
                            ? [
                                'bg-blue-100 text-blue-700 ring-blue-200',
                                tone.iconActive,
                              ].join(' ')
                            : [
                                'bg-gray-50 text-gray-500 ring-gray-200',
                                'group-hover:bg-gray-100 group-hover:text-gray-700',
                                'dark:bg-white/5 dark:ring-white/10',
                                tone.iconIdle,
                              ].join(' '),
                        ].join(' ')}
                        title="Box im Bild markieren"
                        aria-label={`Box ${index + 1} im Bild markieren`}
                      >
                        <Icon className="h-4.5 w-4.5" aria-hidden="true" />
                      </button>

                      <div className="min-w-0 flex-1">
                        <div className="flex min-w-0 items-center justify-between gap-2">
                          <div className="min-w-0">
                            <div className="flex min-w-0 items-center gap-2">
                              <div className="truncate text-[13px] font-bold leading-tight text-gray-950 dark:text-white">
                                {item.text}
                              </div>

                              <span className="shrink-0 text-[10px] font-black text-gray-400 dark:text-gray-500">
                                #{index + 1}
                              </span>
                            </div>

                            <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium leading-tight text-gray-500 dark:text-gray-400">
                              <span
                                className={[
                                  'h-1.5 w-1.5 shrink-0 rounded-full',
                                  isCorrected
                                    ? 'bg-amber-400'
                                    : scoreLevel(box.score) === 'high'
                                      ? 'bg-emerald-400'
                                      : scoreLevel(box.score) === 'mid'
                                        ? 'bg-yellow-400'
                                        : scoreLevel(box.score) === 'low'
                                          ? 'bg-red-400'
                                          : 'bg-gray-300 dark:bg-gray-500',
                                ].join(' ')}
                                aria-hidden="true"
                              />

                              <span className="truncate">
                                {isCorrected ? 'korrigiert' : `Confidence ${scoreText}`}
                              </span>

                              {isActive ? (
                                <>
                                  <span className="text-gray-300 dark:text-gray-600">·</span>
                                  <span
                                    className={[
                                      'font-semibold text-blue-700',
                                      tone.selectedText,
                                    ].join(' ')}
                                  >
                                    ausgewählt
                                  </span>
                                </>
                              ) : null}
                            </div>
                          </div>

                          <button
                            type="button"
                            disabled={uiLocked}
                            className={[
                              'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition',
                              'text-gray-400 hover:bg-red-50 hover:text-red-600',
                              'focus:outline-none focus:ring-2 focus:ring-red-500/30',
                              'disabled:cursor-not-allowed disabled:opacity-50',
                              'dark:text-gray-500 dark:hover:bg-red-500/10 dark:hover:text-red-300',
                            ].join(' ')}
                            title={`${item.text} löschen`}
                            aria-label={`${item.text} löschen`}
                            onClick={(e) => {
                              e.stopPropagation()
                              if (uiLocked) return
                              removeBox(index)
                            }}
                          >
                            <TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
                          </button>
                        </div>

                        {!isActive ? null : (
                          <div className={compact ? 'mt-2' : 'mt-3'}>
                            <DetectorBoxLabelSelect
                              values={boxLabels}
                              value={box.label}
                              compact={compact}
                              disabled={uiLocked || boxLabels.length === 0}
                              onChange={(value) => changeBoxLabel(index, value)}
                            />
                          </div>
                        )}
                      </div>
                    </div>
                  </div>
                </div>
              )
            })
          )}
        </div>
      </div>
    )
  }

  const poseKeypointCorrectionPanel = () => {
    const targetIndex = poseCorrectionPersonIndex
    const targetPerson =
      targetIndex !== null && targetIndex >= 0 ? posePersons[targetIndex] : undefined
    const targetPoseLabel =
      targetIndex !== null && targetIndex >= 0 ? `Pose #${targetIndex + 1}` : 'Keine Pose ausgewählt'
    const visibleKeypointIds = new Set(
      (targetPerson?.keypoints ?? [])
        .filter(isPoseKeypointVisible)
        .map((point) => poseKeypointId(point.name))
    )
    const disabled = uiLocked || frameBusy || trainingRunning || saving || !targetPerson
    const faceVisibleCount = POSE_FACE_KEYPOINT_SHAPES.filter((shape) =>
      visibleKeypointIds.has(shape.keypointName)
    ).length
    const mapShapes =
      poseKeypointMapView === 'head' ? POSE_FACE_KEYPOINT_SHAPES : POSE_BODY_KEYPOINT_SHAPES
    const mapAspectRatio =
      poseKeypointMapView === 'head' ? POSE_HEAD_MAP_ASPECT_RATIO : POSE_BODY_MAP_ASPECT_RATIO
    const mapViewBox = poseKeypointMapViewBoxValue
    const mapMarkerRadius = poseKeypointMapView === 'head' ? 6 : 7
    const mapWidthLimitPx =
      poseMapSlotHeight > 0
        ? Math.floor(poseMapSlotHeight * POSE_BODY_MAP_ASPECT_RATIO_VALUE)
        : 0
    const mapContainerStyle = {
      aspectRatio: mapAspectRatio,
      ...(mapWidthLimitPx > 0
        ? {
            maxHeight: `${poseMapSlotHeight}px`,
            width: `min(14.5rem, 86%, ${mapWidthLimitPx}px)`,
          }
        : {}),
    } as CSSProperties
    const headPending =
      pendingPoseKeypoint?.personIndex === targetIndex &&
      POSE_FACE_KEYPOINT_SHAPES.some(
        (shape) => shape.keypointName === pendingPoseKeypoint.keypointName
      )
    const headPresent = faceVisibleCount > 0
    const headActive = headPending || headPresent
    const showMapParts = !poseKeypointMapAnimating

    const selectPoseKeypoint = (keypointName: string) => {
      if (disabled || targetIndex === null) return

      const pending =
        pendingPoseKeypoint?.personIndex === targetIndex &&
        pendingPoseKeypoint.keypointName === keypointName

      if (pending) {
        setPendingPoseKeypoint(null)
        return
      }

      setShowPoseSkeleton(true)
      setActivePosePersonIndex(targetIndex)
      setPendingPoseKeypoint({
        personIndex: targetIndex,
        keypointName,
      })
    }

    return (
      <div className="space-y-3 lg:h-full lg:min-h-0">
        <div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10 lg:flex lg:h-full lg:min-h-0 lg:flex-col lg:overflow-hidden">
          <div className="flex shrink-0 items-start justify-between gap-3">
            <div className="min-w-0">
              <div className="text-xs font-medium text-gray-700 dark:text-gray-200">
                Pose-Körperteile
              </div>

              <div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400">
                {targetPerson
                  ? `${targetPoseLabel}: Punkt wählen und im Bild setzen.`
                  : visiblePosePersonCount > 1
                    ? 'Links erst eine Pose auswählen.'
                    : 'Keine Pose vorhanden.'}
              </div>
            </div>

            {targetPerson ? (
              <span className="shrink-0 rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold text-indigo-800 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
                {visibleKeypointIds.size}/{POSE_KEYPOINT_OPTIONS.length}
              </span>
            ) : null}
          </div>

          <div className="mt-2 rounded-xl bg-white p-2 ring-1 ring-gray-200 dark:bg-gray-950/40 dark:ring-white/10 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
            <div
              ref={poseMapSlotRef}
              className="mt-3 flex justify-center overflow-hidden lg:min-h-0 lg:flex-1"
            >
              <div
                className={[
                  'relative z-0 w-[13.5rem] max-w-[86%] overflow-hidden rounded-lg transition-all duration-1000 ease-out sm:w-[14.5rem]',
                ].join(' ')}
                style={mapContainerStyle}
              >
                <svg
                  className="absolute inset-0 h-full w-full overflow-hidden"
                  viewBox={mapViewBox}
                  preserveAspectRatio="xMidYMid meet"
                  aria-hidden="false"
                >
                <image
                  href={bodyFrontSvgUrl}
                  x="0"
                  y="0"
                  width="537"
                  height="1386"
                  preserveAspectRatio="xMidYMid meet"
                  pointerEvents="none"
                  className={targetPerson ? 'opacity-90' : 'opacity-35'}
                />

                {showMapParts && poseKeypointMapView === 'head' ? (
                  <path
                    d={POSE_BODY_SILHOUETTE_SHAPE.d}
                    role="button"
                    tabIndex={0}
                    aria-label="Koerperansicht oeffnen"
                    className={[
                      'cursor-zoom-out focus:outline-none',
                      'fill-transparent stroke-transparent',
                    ].join(' ')}
                    strokeWidth={0}
                    pointerEvents="all"
                    vectorEffect="non-scaling-stroke"
                    onPointerDown={(event) => {
                      event.preventDefault()
                      event.stopPropagation()
                      switchPoseKeypointMapView('body')
                    }}
                    onClick={(event) => {
                      event.preventDefault()
                      event.stopPropagation()
                      switchPoseKeypointMapView('body')
                    }}
                    onKeyDown={(event) => {
                      if (event.key !== 'Enter' && event.key !== ' ') return
                      event.preventDefault()
                      switchPoseKeypointMapView('body')
                    }}
                  >
                    <title>Koerperansicht oeffnen</title>
                  </path>
                ) : null}

                {showMapParts && poseKeypointMapView === 'body' ? (
                  <path
                    d={POSE_HEAD_SHAPE.d}
                    role="button"
                    tabIndex={disabled ? -1 : 0}
                    aria-label="Kopf vergrößern"
                    aria-pressed={headActive}
                    className={[
                      'cursor-zoom-in focus:outline-none',
                      'fill-transparent stroke-transparent',
                      disabled ? 'pointer-events-none opacity-45' : '',
                    ].join(' ')}
                    strokeWidth={0}
                    pointerEvents="all"
                    vectorEffect="non-scaling-stroke"
                    onPointerDown={(event) => {
                      event.preventDefault()
                      event.stopPropagation()
                      if (disabled) return
                      switchPoseKeypointMapView('head')
                    }}
                    onClick={(event) => {
                      event.preventDefault()
                      event.stopPropagation()
                      if (disabled) return
                      switchPoseKeypointMapView('head')
                    }}
                    onKeyDown={(event) => {
                      if (event.key !== 'Enter' && event.key !== ' ') return
                      event.preventDefault()
                      if (disabled) return
                      switchPoseKeypointMapView('head')
                    }}
                  >
                    <title>Kopf vergrößern</title>
                  </path>
                ) : null}

                {showMapParts && poseKeypointMapView === 'body' ? (
                  <circle
                    cx={POSE_HEAD_SHAPE.center.x}
                    cy={POSE_HEAD_SHAPE.center.y}
                    r={mapMarkerRadius}
                    className={poseKeypointMarkerClassName(headPresent, headPending)}
                    strokeWidth={2}
                    pointerEvents="none"
                    vectorEffect="non-scaling-stroke"
                  />
                ) : null}

                {showMapParts ? mapShapes.map((shape) => {
                  const present = visibleKeypointIds.has(shape.keypointName)
                  const pending =
                    pendingPoseKeypoint?.personIndex === targetIndex &&
                    pendingPoseKeypoint.keypointName === shape.keypointName
                  const highlighted =
                    hoveredPoseKeypoint?.personIndex === targetIndex &&
                    hoveredPoseKeypoint.keypointName === shape.keypointName

                  return (
                    <circle
                      key={`${shape.keypointName}-marker`}
                      cx={shape.center.x}
                      cy={shape.center.y}
                      r={mapMarkerRadius}
                      className={poseKeypointMarkerClassName(present, pending, highlighted)}
                      strokeWidth={2}
                      pointerEvents="none"
                      vectorEffect="non-scaling-stroke"
                    />
                  )
                }) : null}

                {showMapParts ? mapShapes.map((shape) => {
                  const keypointName = shape.keypointName
                  const label = poseKeypointLabel(keypointName)
                  const present = visibleKeypointIds.has(keypointName)
                  const pending =
                    pendingPoseKeypoint?.personIndex === targetIndex &&
                    pendingPoseKeypoint.keypointName === keypointName
                  const highlighted =
                    hoveredPoseKeypoint?.personIndex === targetIndex &&
                    hoveredPoseKeypoint.keypointName === keypointName
                  const active = present || pending

                  return (
                    <path
                      key={keypointName}
                      d={shape.d}
                      role="button"
                      tabIndex={disabled ? -1 : 0}
                      aria-label={label}
                      aria-pressed={active}
                      className={[
                        'cursor-pointer transition',
                        'focus:outline-none',
                        'fill-transparent stroke-transparent',
                        pending
                          ? 'hover:stroke-amber-700 focus:stroke-amber-700 dark:hover:stroke-amber-200 dark:focus:stroke-amber-200'
                          : highlighted
                            ? 'stroke-cyan-600 dark:stroke-cyan-200'
                            : present
                            ? 'hover:stroke-indigo-800 focus:stroke-indigo-800 dark:hover:stroke-indigo-100 dark:focus:stroke-indigo-100'
                            : 'hover:stroke-indigo-600 focus:stroke-indigo-600 dark:hover:stroke-indigo-200 dark:focus:stroke-indigo-200',
                        disabled ? 'pointer-events-none opacity-45' : '',
                      ].join(' ')}
                      strokeWidth={pending || present ? 2.5 : 2}
                      pointerEvents="all"
                      vectorEffect="non-scaling-stroke"
                      onPointerEnter={() => {
                        if (disabled || targetIndex === null) return
                        setHoveredPoseKeypoint({
                          personIndex: targetIndex,
                          keypointName,
                        })
                      }}
                      onPointerLeave={() => {
                        setHoveredPoseKeypoint((current) =>
                          current?.personIndex === targetIndex &&
                          current.keypointName === keypointName
                            ? null
                            : current
                        )
                      }}
                      onFocus={() => {
                        if (disabled || targetIndex === null) return
                        setHoveredPoseKeypoint({
                          personIndex: targetIndex,
                          keypointName,
                        })
                      }}
                      onBlur={() => {
                        setHoveredPoseKeypoint((current) =>
                          current?.personIndex === targetIndex &&
                          current.keypointName === keypointName
                            ? null
                            : current
                        )
                      }}
                      onClick={() => selectPoseKeypoint(keypointName)}
                      onKeyDown={(event) => {
                        if (event.key !== 'Enter' && event.key !== ' ') return
                        event.preventDefault()
                        selectPoseKeypoint(keypointName)
                      }}
                    >
                      <title>{label}</title>
                    </path>
                  )
                }) : null}
              </svg>
              </div>
            </div>
          </div>
        </div>
      </div>
    )
  }

  const imageTouchClass =
    boxLabel ||
    drawingBox ||
    boxInteraction ||
    poseInteraction ||
    pendingPoseKeypoint ||
    poseFaceImageZoomApplied
      ? 'touch-none'
      : 'touch-pan-y'

  const loadingPreviewBackgroundUrl =
    loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed
      ? loadingPreviewUrl
      : loadingPreviewFallbackUrl
  const sampleSourceDetails = compactTrainingSourceFile(sample?.sourceFile || '')

  const frameLayoutSize = useMemo(() => {
    const width = Number(frameNaturalSize?.width)
    const height = Number(frameNaturalSize?.height)

    if (
      Number.isFinite(width) &&
      Number.isFinite(height) &&
      width > 0 &&
      height > 0
    ) {
      return { width, height }
    }

    // Fallback, bis das echte Bildformat bekannt ist.
    return { width: 1600, height: 900 }
  }, [frameNaturalSize])

  const imageAspectRatio = Math.max(
    0.25,
    Math.min(4, frameLayoutSize.width / frameLayoutSize.height)
  )

  const imageStageLimits = imageExpanded
    ? {
        baseDvh: 52,
        smDvh: 60,
        lgDvh: 78,
        lgPx: 820,

        // Platz für:
        // - Speichern/Überspringen
        // - Bild vergrößern/Normal anzeigen
        // - Abstände
        bottomReservePx: 158,
      }
    : {
        baseDvh: 44,
        smDvh: 52,
        lgDvh: 64,
        lgPx: 680,
        bottomReservePx: 138,
      }

  const imageStageStyle = imageExpanded
    ? ({
        '--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
        '--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
      } as CSSProperties & Record<string, string | number>)
    : ({
        aspectRatio: `${frameLayoutSize.width} / ${frameLayoutSize.height}`,

        '--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
        '--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
        '--image-stage-max-h-lg': `min(${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`,

        '--image-stage-w': `${imageStageLimits.baseDvh * imageAspectRatio}dvh`,
        '--image-stage-w-sm': `${imageStageLimits.smDvh * imageAspectRatio}dvh`,
        '--image-stage-w-lg': `min(${imageStageLimits.lgDvh * imageAspectRatio}dvh, ${Math.round(
          imageStageLimits.lgPx * imageAspectRatio
        )}px)`,
      } as CSSProperties & Record<string, string | number>)

  const imageStageHeightClass = [
    'max-h-[var(--image-stage-max-h)]',
    'sm:max-h-[var(--image-stage-max-h-sm)]',
    'lg:max-h-[var(--image-stage-max-h-lg)]',
    'w-[min(100%,var(--image-stage-w))]',
    'sm:w-[min(100%,var(--image-stage-w-sm))]',
    'lg:w-[min(100%,var(--image-stage-w-lg))]',
  ].join(' ')

  const savingStageActive = saving && !trainingRunning && !frameBusy
  const stageBusy = trainingRunning || frameBusy || saving

  const stageOverlayMode: 'training' | 'analysis' | 'saving' =
    trainingRunning
      ? 'training'
      : savingStageActive
        ? 'saving'
        : 'analysis'

  const analysisOverlayDetails = compactTrainingStageDetails(
    analysisSourceFile,
    analysisStep || 'Bild wird geladen…'
  )

  const stageOverlayText = trainingRunning
    ? shownTrainingStep || 'Aktuelles Bild wird geladen…'
    : savingStageActive
      ? savingOverlayText || 'Feedback wird gespeichert…'
      : analysisOverlayDetails.statusText

  const stageOverlayProgress = trainingRunning
    ? shownTrainingProgress
    : loading
      ? analysisProgress
      : savingStageActive
        ? 65
        : 100

  const stageOverlaySourceFile = stageOverlayMode === 'analysis'
    ? analysisOverlayDetails.sourceFile
    : stageOverlayMode === 'saving'
      ? sampleSourceDetails.sourceFile
      : undefined
  const stageOverlayFrameLabel = stageOverlayMode === 'analysis'
    ? analysisOverlayDetails.frameLabel
    : stageOverlayMode === 'saving'
      ? sampleSourceDetails.frameLabel
      : undefined
  const stageOverlayStatusText = stageOverlayMode === 'analysis'
    ? analysisOverlayDetails.statusText
    : stageOverlayMode === 'saving'
      ? stageOverlayText
      : undefined

  const stageOverlayFadeMs = 300

  useEffect(() => {
    if (stageBusy) {
      setStageOverlayMounted(true)

      const frame = window.requestAnimationFrame(() => {
        setStageOverlayVisible(true)
      })

      return () => window.cancelAnimationFrame(frame)
    }

    setStageOverlayVisible(false)

    const timer = window.setTimeout(() => {
      setStageOverlayMounted(false)
    }, stageOverlayFadeMs)

    return () => window.clearTimeout(timer)
  }, [stageBusy])

  const renderStageOverlay = stageBusy || stageOverlayMounted
  const stageOverlayIsVisible = stageBusy && stageOverlayVisible

  return (
    <div className="min-h-full lg:h-[calc(100dvh-15rem)] lg:min-h-0 lg:max-h-[calc(100dvh-15rem)] lg:overflow-hidden">
      {loadingPreviewUrl ? (
        <img
          src={loadingPreviewUrl}
          alt=""
          aria-hidden="true"
          draggable={false}
          className="pointer-events-none fixed h-px w-px opacity-0"
          style={{ left: -9999, top: -9999 }}
          onLoad={(e) => {
            const img = e.currentTarget

            if (img.naturalWidth > 0 && img.naturalHeight > 0) {
              setFrameNaturalSize({
                width: img.naturalWidth,
                height: img.naturalHeight,
              })
            }

            setLoadingPreviewLoaded(true)
            setLoadingPreviewFailed(false)
          }}
          onError={() => {
            setLoadingPreviewLoaded(false)
            setLoadingPreviewFailed(true)
          }}
        />
      ) : null}
    
      <div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/80 lg:hidden">
        <div className="min-w-0 flex-1">
          <div className="flex min-w-0 items-center gap-2">
            <div className="shrink-0 text-sm font-semibold text-gray-900 dark:text-white">
              Training
            </div>

            {sampleSourceDetails.sourceFile ? (
              <div
                className="inline-flex h-6 min-w-0 max-w-[52vw] items-center gap-1.5 overflow-hidden rounded-full bg-gray-100 px-2.5 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10"
                title={sample?.sourceFile || sampleSourceDetails.sourceFile}
              >
                <span className="min-w-0 translate-y-px truncate leading-[14px]">
                  {sampleSourceDetails.sourceFile}
                </span>

                {sampleSourceDetails.frameLabel ? (
                  <span className="shrink-0 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-bold leading-none text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-100 dark:ring-emerald-400/25">
                    {sampleSourceDetails.frameLabel}
                  </span>
                ) : null}
              </div>
            ) : null}
          </div>
        </div>

        <button
          type="button"
          onClick={() => setStatsModalOpen(true)}
          className="ml-auto inline-flex h-6 shrink-0 items-center rounded-full bg-gray-100 px-2 text-[11px] font-bold leading-none text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10"
          title="Training-Datenstatistiken anzeigen"
          aria-label="Training-Datenstatistiken anzeigen"
        >
          {feedbackBadgeText}
        </button>
      </div>

      <div
        className={[
          'grid grid-cols-1 items-start gap-2 lg:h-full lg:min-h-0 lg:items-stretch lg:overflow-hidden',
          imageExpanded
            ? [
                // Desktop: Sidebars bleiben sichtbar, Mitte bekommt deutlich mehr Breite.
                'lg:grid-cols-[280px_minmax(0,1fr)_280px]',
                'xl:grid-cols-[300px_minmax(0,1fr)_300px]',
                '2xl:grid-cols-[320px_minmax(0,1fr)_320px]',
              ].join(' ')
            : 'lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]',
        ].join(' ')}
      >
        {/* Sidebar links */}
        <aside
          className={[
            'hidden h-full max-h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
            'lg:flex',
          ].join(' ')}
        >
          <div className="flex items-start justify-between gap-2">
            <div className="min-w-0 flex-1">
              <div className="flex min-w-0 items-center gap-2">
                <div className="shrink-0 text-sm font-semibold text-gray-900 dark:text-white">
                  Training
                </div>

                {sampleSourceDetails.sourceFile ? (
                  <div
                    className="inline-flex h-6 min-w-0 max-w-[11rem] items-center gap-1.5 overflow-hidden rounded-full bg-gray-100 px-2.5 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10"
                    title={sample?.sourceFile || sampleSourceDetails.sourceFile}
                  >
                    <span className="min-w-0 translate-y-px truncate leading-[14px]">
                      {sampleSourceDetails.sourceFile}
                    </span>

                    {sampleSourceDetails.frameLabel ? (
                      <span className="shrink-0 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-bold leading-none text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-100 dark:ring-emerald-400/25">
                        {sampleSourceDetails.frameLabel}
                      </span>
                    ) : null}
                  </div>
                ) : null}
              </div>
            </div>

            <button
              type="button"
              onClick={() => setStatsModalOpen(true)}
              className={[
                'inline-flex h-6 shrink-0 items-center rounded-full bg-gray-100 px-2 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 transition',
                'hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200',
                'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
                'dark:bg-white/10 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-indigo-500/20 dark:hover:text-indigo-100 dark:hover:ring-indigo-300/30',
              ].join(' ')}
              title="Training-Datenstatistiken anzeigen"
              aria-label="Training-Datenstatistiken anzeigen"
            >
              {feedbackBadgeText}
            </button>
          </div>

          <div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
            <div className="min-h-0 flex-1 rounded-xl overflow-hidden">
              {detectorBoxesPanel({
                stretch: true,
                maxHeightClassName: 'lg:max-h-none',
              })}
            </div>

            <div className="shrink-0">
              {trainingActionsPanel()}
            </div>
          </div>
        </aside>

        {/* Mitte */}
        <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col lg:overflow-hidden">
          <section
            className={[
              'min-w-0 w-full rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3',
              imageExpanded
                ? 'lg:grid lg:h-full lg:min-h-0 lg:grid-rows-[minmax(0,1fr)_auto] lg:gap-3 lg:overflow-hidden'
                : 'lg:self-start'
            ].join(' ')}
          >
            <div
              className={[
                'relative z-50 mx-auto flex items-center justify-center rounded-lg bg-black p-2 sm:p-3',
                imageExpanded
                  ? 'w-full min-h-0 max-h-[var(--image-stage-max-h)] sm:max-h-[var(--image-stage-max-h-sm)] lg:h-full lg:max-h-full lg:self-stretch'
                  : imageStageHeightClass,
                poseFaceImageZoomApplied ? 'overflow-hidden' : 'overflow-visible',
              ].join(' ')}
              style={imageStageStyle}
            >
              {imageSrc ? (
                <div
                  className={[
                    'relative z-50 flex h-full min-h-0 w-full items-center justify-center bg-black',
                    poseFaceImageZoomApplied
                      ? 'overflow-hidden rounded-md'
                      : 'overflow-visible',
                  ].join(' ')}
                >
                  <div
                    ref={imageBoxRef}
                    className={[
                      imageExpanded
                        ? 'relative z-50 flex h-full w-full min-h-0 select-none items-center justify-center overscroll-contain transition'
                        : 'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
                      imageTouchClass,
                      drawingCursorClass,
                      '[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
                      poseFaceImageZoomApplied &&
                      !poseFaceImageCameraAnimating &&
                      !pendingPoseKeypoint &&
                      !boxLabel
                        ? poseFaceImagePanning ? 'cursor-grabbing' : 'cursor-grab'
                        : '',
                      poseFaceImagePanning || poseFaceImageCameraAnimating
                        ? 'transition-none'
                        : 'transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none',
                      trainingRunning || loading ? 'pointer-events-none' : '',
                    ].join(' ')}
                    style={poseFaceImageZoomStyle}
                    onContextMenu={(e) => e.preventDefault()}
                    onPointerDownCapture={startPoseFaceImagePan}
                    onPointerMoveCapture={movePoseFaceImagePan}
                    onPointerUpCapture={finishPoseFaceImagePan}
                    onPointerCancelCapture={finishPoseFaceImagePan}
                    onPointerDown={startDrawBox}
                    onPointerMove={moveDrawBox}
                  >
                    <img
                      ref={frameImageRef}
                      src={imageSrc}
                      alt="Training Frame"
                      draggable={false}
                      width={frameLayoutSize.width}
                      height={frameLayoutSize.height}
                      onLoad={(e) => {
                        const img = e.currentTarget

                        if (img.naturalWidth > 0 && img.naturalHeight > 0) {
                          setFrameNaturalSize({
                            width: img.naturalWidth,
                            height: img.naturalHeight,
                          })
                        }

                        setFrameImageLoaded(true)
                        window.requestAnimationFrame(updateImageLayerStyle)
                      }}
                      onError={() => {
                        setFrameImageLoaded(true)
                        window.requestAnimationFrame(updateImageLayerStyle)
                      }}
                      onContextMenu={(e) => e.preventDefault()}
                      onDragStart={(e) => e.preventDefault()}
                      className={[
                        imageExpanded
                          ? 'block h-full w-full min-h-0 rounded-md object-contain'
                          : 'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain',
                        'select-none',
                        'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
                        frameImageLoaded ? 'opacity-100' : 'opacity-0',
                        imageTouchClass,
                        '[-webkit-user-drag:none] [-webkit-touch-callout:none]',
                      ].join(' ')}
                    />

                    {poseSkeletonVisible && imageLayerStyle ? (() => {
                      const layerWidth = Number(imageLayerStyle.width)
                      const layerHeight = Number(imageLayerStyle.height)

                      if (
                        !Number.isFinite(layerWidth) ||
                        !Number.isFinite(layerHeight) ||
                        layerWidth <= 0 ||
                        layerHeight <= 0
                      ) {
                        return null
                      }

                      return (
                        <div
                          aria-label="Pose-Skelett korrigieren"
                          className="pointer-events-none absolute z-[305] overflow-visible"
                          style={imageLayerStyle}
                        >
                          <svg
                            className="absolute inset-0 h-full w-full overflow-visible"
                            viewBox={`0 0 ${layerWidth} ${layerHeight}`}
                            preserveAspectRatio="none"
                          >
                            {posePersons.map((person, personIndex) => {
                              if (!shouldRenderPoseOverlayPerson(personIndex)) {
                                return null
                              }

                              const reliable = isPosePersonReliable(person)
                              const color = reliable
                                ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
                                : POSE_UNRELIABLE_COLOR
                              const keypointsByName = new Map(
                                person.keypoints.map((point) => [poseKeypointId(point.name), point])
                              )

                              return (
                                <g key={`pose-lines-${personIndex}`}>
                                  {POSE_SKELETON_EDGES.map(([from, to]) => {
                                    if (
                                      poseFaceImageZoomActive &&
                                      (!POSE_FACE_KEYPOINT_NAME_SET.has(from) ||
                                        !POSE_FACE_KEYPOINT_NAME_SET.has(to))
                                    ) {
                                      return null
                                    }

                                    const fromPoint = keypointsByName.get(from)
                                    const toPoint = keypointsByName.get(to)

                                    if (
                                      !isPoseKeypointVisible(fromPoint) ||
                                      !isPoseKeypointVisible(toPoint)
                                    ) {
                                      return null
                                    }

                                    return (
                                      <line
                                        key={`${from}-${to}`}
                                        x1={poseCoordPx(fromPoint.x, layerWidth)}
                                        y1={poseCoordPx(fromPoint.y, layerHeight)}
                                        x2={poseCoordPx(toPoint.x, layerWidth)}
                                        y2={poseCoordPx(toPoint.y, layerHeight)}
                                        stroke={color}
                                        strokeOpacity={reliable ? 0.9 : 0.45}
                                        strokeWidth={2.5}
                                        strokeLinecap="round"
                                        strokeDasharray={reliable ? undefined : '4 4'}
                                        vectorEffect="non-scaling-stroke"
                                      />
                                    )
                                  })}
                                </g>
                              )
                            })}
                          </svg>

                          {posePersons.map((person, personIndex) => {
                            if (!shouldRenderPoseOverlayPerson(personIndex)) {
                              return null
                            }

                            const reliable = isPosePersonReliable(person)
                            const color = reliable
                              ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
                              : POSE_UNRELIABLE_COLOR

                            return person.keypoints
                              .map((point, pointIndex) => {
                                if (!isPoseKeypointVisible(point)) return null

                                const keypointId = poseKeypointId(point.name)
                                if (
                                  poseFaceImageZoomActive &&
                                  !POSE_FACE_KEYPOINT_NAME_SET.has(keypointId)
                                ) {
                                  return null
                                }

                                const left = poseCoordPx(point.x, layerWidth)
                                const top = poseCoordPx(point.y, layerHeight)
                                const label = poseKeypointLabel(point.name)
                                const confidence = Math.round(clamp01(Number(point.conf)) * 100)
                                const selected =
                                  pendingPoseKeypoint?.personIndex === personIndex &&
                                  pendingPoseKeypoint.keypointName === keypointId
                                const hovered =
                                  hoveredPoseKeypoint?.personIndex === personIndex &&
                                  hoveredPoseKeypoint.keypointName === keypointId
                                const dragging =
                                  poseInteraction?.personIndex === personIndex &&
                                  poseInteraction.keypointIndex === pointIndex
                                const highlighted = selected || hovered || dragging
                                const showLabel = selected || dragging
                                const facePlacement = poseFaceImageZoomActive
                                  ? poseFaceKeypointLabelPlacement(keypointId)
                                  : null
                                const labelToLeft = facePlacement?.labelToLeft ?? left > layerWidth * 0.72

                                return (
                                  <Fragment
                                    key={`pose-point-${personIndex}-${poseKeypointId(point.name)}-${pointIndex}`}
                                  >
                                    <button
                                      data-pose-control="true"
                                      type="button"
                                      disabled={uiLocked}
                                      className={[
                                        pendingPoseKeypoint ? 'pointer-events-none' : 'pointer-events-auto',
                                        'absolute z-[350] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 touch-none items-center justify-center rounded-full',
                                        'focus:outline-none focus:ring-2 focus:ring-white/80',
                                        uiLocked ? 'cursor-default opacity-60' : 'cursor-grab active:cursor-grabbing',
                                      ].join(' ')}
                                      style={{
                                        left,
                                        top,
                                        opacity: reliable ? 1 : 0.72,
                                      }}
                                      title={`${label} | ${confidence}%`}
                                      aria-label={`${label} verschieben`}
                                      onPointerEnter={() => {
                                        if (uiLocked) return
                                        setHoveredPoseKeypoint({
                                          personIndex,
                                          keypointName: keypointId,
                                        })
                                      }}
                                      onPointerLeave={() => {
                                        setHoveredPoseKeypoint((current) =>
                                          current?.personIndex === personIndex &&
                                          current.keypointName === keypointId
                                            ? null
                                            : current
                                        )
                                      }}
                                      onPointerDown={(e) => startPoseKeypointDrag(e, personIndex, pointIndex)}
                                    >
                                      <span
                                        className={[
                                          'block rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.55),0_0_10px_rgba(0,0,0,0.35)] transition-all duration-150',
                                          highlighted ? 'h-4 w-4 ring-4 ring-cyan-300/45' : 'h-2.5 w-2.5 ring-2 ring-black/25',
                                        ].join(' ')}
                                        style={{ backgroundColor: highlighted ? '#22d3ee' : color }}
                                      />
                                    </button>

                                    {showLabel ? (
                                      <div
                                        className="pointer-events-none absolute z-[370] flex max-w-[9rem] items-center gap-1 rounded-full bg-white/95 px-2 py-1 text-[10px] font-black leading-none text-gray-950 shadow-lg ring-1 ring-black/10 dark:bg-white/95 dark:text-gray-950"
                                        style={{
                                          left,
                                          top,
                                          transform: facePlacement?.transform ?? (
                                            labelToLeft
                                              ? 'translate(calc(-100% - 12px), -50%)'
                                              : 'translate(12px, -50%)'
                                          ),
                                          opacity: reliable ? 1 : 0.82,
                                          zIndex: facePlacement?.zIndex,
                                        }}
                                      >
                                        <span
                                          className="h-2 w-2 shrink-0 rounded-full"
                                          style={{ backgroundColor: color }}
                                        />
                                        <span className="min-w-0 truncate">{label}</span>
                                      </div>
                                    ) : null}
                                  </Fragment>
                                )
                              })
                          })}
                        </div>
                      )
                    })() : null}

                    {showImageBoxes && imageLayerStyle ? (
                      <div
                        className="absolute z-[300] overflow-visible"
                        style={imageLayerStyle}
                      >
                        {visibleBoxes.map(({ box, index, isDraft }) => {
                          const left = clampPercent(box.x * 100)
                          const top = clampPercent(box.y * 100)
                          const width = clampPercent(box.w * 100)
                          const height = clampPercent(box.h * 100)

                          const item = getSegmentLabelItem(box.label)
                          const Icon = item.icon
                          const isSmallBox = width < 18 || height < 12
                          const isActiveBox = !isDraft && activeBoxIndex === index
                          const alignLabelRight = box.x + box.w > 0.62
                          const layerWidth = Number(imageLayerStyle.width)
                          const labelMaxWidth = Number.isFinite(layerWidth) && layerWidth > 0
                            ? Math.max(72, Math.min(210, layerWidth - 8))
                            : 210

                          return (
                            <div
                              key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
                              className={[
                                'pointer-events-none absolute overflow-visible rounded border-2',
                                isActiveBox
                                  ? 'border-blue-500 shadow-none'
                                  : [
                                      'shadow-none',
                                      scoreBorderClass(box.score, { draft: isDraft }),
                                    ].join(' '),
                              ].join(' ')}
                              style={{
                                left: `${left}%`,
                                top: `${top}%`,
                                width: `${width}%`,
                                height: `${height}%`,
                                backgroundColor: 'transparent',
                                zIndex: isActiveBox ? 330 : isDraft ? 320 : 310,
                              }}
                              title={box.label}
                            >
                              <div
                                data-box-control="true"
                                className={[
                                  'pointer-events-auto absolute top-0 z-[360] flex -translate-y-[calc(100%+4px)] touch-none select-none items-center',
                                  '[-webkit-user-select:none] [-webkit-touch-callout:none]',
                                  alignLabelRight ? 'right-0 justify-end' : 'left-0 justify-start',
                                ].join(' ')}
                                style={{
                                  maxWidth: labelMaxWidth,
                                }}
                                title={isDraft ? box.label : `${item.text} verschieben`}
                              >
                                <div
                                  role={isDraft || uiLocked ? undefined : 'button'}
                                  aria-disabled={Boolean(isDraft) || uiLocked}
                                  className={[
                                    'group/label flex h-5 max-w-full min-w-0 touch-none select-none items-center overflow-hidden rounded-full text-left',
                                    isSmallBox ? 'gap-0.5 px-1' : 'gap-1 pl-1.5 pr-0.5',
                                    'text-[10px] font-bold leading-none shadow-md ring-1 transition',
                                    '[-webkit-user-select:none] [-webkit-touch-callout:none]',
                                    isActiveBox
                                      ? [
                                          'bg-white/95 text-blue-700 ring-blue-500/30 hover:bg-blue-50',
                                          'dark:bg-blue-600 dark:text-white dark:ring-white/25 dark:hover:bg-blue-700',
                                        ].join(' ')
                                      : isDraft
                                        ? [
                                            'bg-amber-100/95 text-amber-950 ring-amber-500/30',
                                            'dark:bg-amber-400 dark:text-black dark:ring-black/15',
                                          ].join(' ')
                                        : [
                                            'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50',
                                            'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50',
                                          ].join(' '),
                                    isDraft ? 'cursor-default' : 'cursor-move',
                                  ].join(' ')}
                                  onPointerDown={(e) => {
                                    if (isDraft || uiLocked) return

                                    e.preventDefault()
                                    e.stopPropagation()
                                    window.getSelection()?.removeAllRanges()

                                    const target = e.target as HTMLElement | null
                                    if (target?.closest('[data-box-trash="true"]')) return

                                    const requiresActivationFirst = !isActiveBox

                                    setActiveBoxIndex(index)

                                    if (requiresActivationFirst) return

                                    const contentRect = getImageContentRect()
                                    if (!contentRect) return

                                    activeImageContentRectRef.current = contentRect

                                    const pos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false })
                                    const clampedPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY)

                                    finishingGestureRef.current = false
                                    activePointerIdRef.current = e.pointerId

                                    try {
                                      activePointerCaptureElementRef.current = imageBoxRef.current
                                      imageBoxRef.current?.setPointerCapture(e.pointerId)
                                    } catch {
                                      activePointerIdRef.current = null
                                      activePointerCaptureElementRef.current = null
                                    }

                                    const nextMagnifier: MagnifierState = {
                                      visible: true,
                                      clientX: e.clientX,
                                      clientY: e.clientY,
                                      imageX: clampedPos.x,
                                      imageY: clampedPos.y,
                                    }

                                    const nextInteraction: BoxInteraction = {
                                      type: 'move',
                                      index,
                                      startX: pos.x,
                                      startY: pos.y,
                                      original: box,
                                    }

                                    latestGestureBoxRef.current = box
                                    boxInteractionRef.current = nextInteraction

                                    setTouchMagnifier(nextMagnifier)
                                    setBoxInteraction(nextInteraction)
                                  }}
                                >
                                  <span
                                    className={[
                                      'shrink-0 rounded-full px-1 py-0.5 text-[9px] font-black leading-none',
                                      isActiveBox
                                        ? 'bg-blue-100 text-blue-700 dark:bg-white/20 dark:text-white'
                                        : isDraft
                                          ? 'bg-amber-200 text-amber-950 dark:bg-black/15 dark:text-black'
                                          : 'bg-gray-100 text-gray-700 dark:bg-gray-100 dark:text-gray-700',
                                    ].join(' ')}
                                  >
                                    {isDraft ? 'neu' : `#${index + 1}`}
                                  </span>

                                  <Icon
                                    className="h-3 w-3 shrink-0 text-current"
                                    aria-hidden="true"
                                  />

                                  {!isSmallBox ? (
                                    <span className="min-w-0 max-w-[104px] truncate text-current sm:max-w-[132px]">
                                      {item.text}
                                    </span>
                                  ) : null}

                                  {!isDraft ? (
                                    <button
                                      data-box-trash="true"
                                      type="button"
                                      disabled={uiLocked}
                                      className={[
                                        'ml-0.5 flex h-4.5 w-4.5 shrink-0 cursor-pointer touch-none items-center justify-center rounded-full',
                                        '!bg-red-600 !text-white ring-1 ring-red-800/40 transition',
                                        'hover:!bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/60',
                                        'disabled:cursor-not-allowed disabled:opacity-50',
                                        'dark:!bg-red-500 dark:!text-white dark:ring-white/20 dark:hover:!bg-red-600',
                                      ].join(' ')}
                                      title={`${box.label} löschen`}
                                      aria-label={`${box.label} löschen`}
                                      onPointerDown={(e) => {
                                        e.stopPropagation()
                                      }}
                                      onClick={(e) => {
                                        e.preventDefault()
                                        e.stopPropagation()
                                        removeBox(index)
                                      }}
                                    >
                                      <TrashIcon
                                        className="h-3 w-3 shrink-0 !text-white"
                                        aria-hidden="true"
                                      />
                                    </button>
                                  ) : null}
                                </div>
                              </div>

                              {!isDraft ? (
                                <>
                                  {(['nw', 'ne', 'sw', 'se'] as const).map((handle) => (
                                    <button
                                      key={handle}
                                      data-box-control="true"
                                      type="button"
                                      disabled={uiLocked}
                                      className={[
                                        isActiveBox
                                          ? [
                                              'pointer-events-auto absolute z-[350] flex h-7 w-7 touch-none items-center justify-center',
                                              'appearance-none rounded-none border-0 p-0 opacity-100 shadow-none ring-0',
                                              '!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
                                              'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
                                            ].join(' ')
                                          : [
                                              'pointer-events-none absolute z-[100] hidden h-7 w-7 touch-none items-center justify-center',
                                              'appearance-none rounded-none border-0 p-0 opacity-100 shadow-none ring-0',
                                              '!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
                                              'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
                                            ].join(' '),
                                        handle === 'nw' ? '-left-3.5 -top-3.5 cursor-nwse-resize sm:-left-2.5 sm:-top-2.5' : '',
                                        handle === 'ne' ? '-right-3.5 -top-3.5 cursor-nesw-resize sm:-right-2.5 sm:-top-2.5' : '',
                                        handle === 'sw' ? '-bottom-3.5 -left-3.5 cursor-nesw-resize sm:-bottom-2.5 sm:-left-2.5' : '',
                                        handle === 'se' ? '-bottom-3.5 -right-3.5 cursor-nwse-resize sm:-bottom-2.5 sm:-right-2.5' : '',
                                      ].join(' ')}
                                      title="Boxgröße ändern"
                                      onPointerDown={(e) => {
                                        if (uiLocked) return

                                        e.preventDefault()
                                        e.stopPropagation()
                                        window.getSelection()?.removeAllRanges()

                                        const requiresActivationFirst = !isActiveBox

                                        setActiveBoxIndex(index)

                                        if (requiresActivationFirst) return

                                        const contentRect = getImageContentRect()
                                        if (!contentRect) return

                                        activeImageContentRectRef.current = contentRect

                                        const pos = getPointerPosFromRect(contentRect, e.clientX, e.clientY)

                                        finishingGestureRef.current = false
                                        activePointerIdRef.current = e.pointerId

                                        try {
                                          activePointerCaptureElementRef.current = imageBoxRef.current
                                          imageBoxRef.current?.setPointerCapture(e.pointerId)
                                        } catch {
                                          activePointerIdRef.current = null
                                          activePointerCaptureElementRef.current = null
                                        }

                                        const nextMagnifier: MagnifierState = {
                                          visible: true,
                                          clientX: e.clientX,
                                          clientY: e.clientY,
                                          imageX: pos.x,
                                          imageY: pos.y,
                                        }

                                        const nextInteraction: BoxInteraction = {
                                          type: 'resize',
                                          index,
                                          handle,
                                          startX: pos.x,
                                          startY: pos.y,
                                          original: box,
                                        }

                                        latestGestureBoxRef.current = box
                                        boxInteractionRef.current = nextInteraction

                                        setTouchMagnifier(nextMagnifier)
                                        setBoxInteraction(nextInteraction)
                                      }}
                                    >
                                      <span
                                        className={[
                                          'block h-2 w-2 rounded-full bg-white shadow-none ring-2 sm:h-2 sm:w-2',
                                          isActiveBox ? 'ring-blue-500' : scoreRingClass(box.score),
                                        ].join(' ')}
                                      />
                                    </button>
                                  ))}
                                </>
                              ) : null}
                            </div>
                          )
                        })}
                      </div>
                    ) : null}

                    {touchMagnifier?.visible && imageSrc && showAnnotationMagnifier ? (() => {
                      const rect = activeImageContentRectRef.current ?? getImageContentRect()

                      if (!rect || rect.width <= 0 || rect.height <= 0) return null

                      const visualViewport =
                        typeof window !== 'undefined' ? window.visualViewport : undefined

                      const viewportLeft = visualViewport?.offsetLeft ?? 0
                      const viewportTop = visualViewport?.offsetTop ?? 0

                      const viewportW =
                        visualViewport?.width ??
                        (typeof window !== 'undefined' ? window.innerWidth : 390)

                      const viewportH =
                        visualViewport?.height ??
                        (typeof window !== 'undefined' ? window.innerHeight : 800)

                      const isCoarsePointer =
                        typeof window !== 'undefined' &&
                        typeof window.matchMedia === 'function' &&
                        window.matchMedia('(pointer: coarse)').matches

                      const isTouchLike = isCoarsePointer || viewportW < 640

                      const baseSize = isTouchLike ? 136 : 156
                      const largeBoxSize = isTouchLike
                        ? Math.min(176, Math.max(152, viewportW - 32))
                        : 190

                      const padding = isTouchLike ? 16 : 20

                      const activeBox =
                        drawingBox ||
                        (boxInteraction
                          ? correction.boxes?.[boxInteraction.index] ?? null
                          : null)

                      const hasUsableBox =
                        activeBox &&
                        Number.isFinite(activeBox.w) &&
                        Number.isFinite(activeBox.h) &&
                        activeBox.w > 0.003 &&
                        activeBox.h > 0.003

                      const boxCenterX = hasUsableBox
                        ? clamp01(activeBox.x + activeBox.w / 2)
                        : touchMagnifier.imageX

                      const boxCenterY = hasUsableBox
                        ? clamp01(activeBox.y + activeBox.h / 2)
                        : touchMagnifier.imageY

                      const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
                      const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0

                      const boxNeedsLargeMagnifier =
                        hasUsableBox &&
                        (
                          boxPixelW > baseSize - padding * 2 ||
                          boxPixelH > baseSize - padding * 2
                        )

                      const size = boxNeedsLargeMagnifier ? largeBoxSize : baseSize
                      const poseMagnifierInteraction = poseInteraction
                      const activePoseMagnifierPoint = poseMagnifierInteraction
                        ? correction.posePersons?.[poseMagnifierInteraction.personIndex]?.keypoints?.[
                            poseMagnifierInteraction.keypointIndex
                          ] ?? null
                        : null
                      const activePoseMagnifierX = Number(activePoseMagnifierPoint?.x)
                      const activePoseMagnifierY = Number(activePoseMagnifierPoint?.y)
                      const hasUsablePoseMagnifierPoint =
                        Boolean(poseMagnifierInteraction) &&
                        Number.isFinite(activePoseMagnifierX) &&
                        Number.isFinite(activePoseMagnifierY)
                      const magnifierFocusX = hasUsablePoseMagnifierPoint
                        ? clamp01(activePoseMagnifierX)
                        : boxCenterX
                      const magnifierFocusY = hasUsablePoseMagnifierPoint
                        ? clamp01(activePoseMagnifierY)
                        : boxCenterY
                      const magnifierMarkerX = hasUsablePoseMagnifierPoint
                        ? magnifierFocusX
                        : touchMagnifier.imageX
                      const magnifierMarkerY = hasUsablePoseMagnifierPoint
                        ? magnifierFocusY
                        : touchMagnifier.imageY

                      const fitPadding = isTouchLike ? 14 : 18

                      const fitZoom = hasUsableBox
                        ? Math.min(
                            (size - fitPadding * 2) / Math.max(1, boxPixelW),
                            (size - fitPadding * 2) / Math.max(1, boxPixelH)
                          )
                        : 2

                      const zoom = hasUsableBox
                        ? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94)
                        : 2

                      const gap = isTouchLike ? 10 : 12
                      const edgeGap = isTouchLike ? 8 : 10

                      // Die Lupe wird an der Box ausgerichtet, nicht am Finger.
                      // Wenn gerade noch keine echte Box existiert, fällt sie auf den aktuellen Bildpunkt zurück.
                      const anchorX = hasUsableBox
                        ? rect.left + boxCenterX * rect.width
                        : rect.left + magnifierFocusX * rect.width

                      const anchorTop = hasUsableBox
                        ? rect.top + activeBox.y * rect.height
                        : rect.top + magnifierFocusY * rect.height

                      const anchorBottom = hasUsableBox
                        ? rect.top + (activeBox.y + activeBox.h) * rect.height
                        : rect.top + magnifierFocusY * rect.height

                      let left = anchorX - size / 2

                      // Normalfall: Lupe über der Box.
                      let top = anchorTop - size - gap

                      // Wenn oben kein Platz ist: Lupe unter die Box setzen.
                      if (top < viewportTop + edgeGap) {
                        top = anchorBottom + gap
                      }

                      // Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde:
                      // an das untere Ende des sichtbaren Viewports klemmen.
                      if (top + size > viewportTop + viewportH - edgeGap) {
                        top = viewportTop + viewportH - size - edgeGap
                      }

                      // Falls der Viewport extrem klein ist, trotzdem sichtbar halten.
                      if (top < viewportTop + edgeGap) {
                        top = viewportTop + edgeGap
                      }

                      left = Math.max(
                        viewportLeft + edgeGap,
                        Math.min(viewportLeft + viewportW - size - edgeGap, left)
                      )

                      const imageWidth = rect.width * zoom
                      const imageHeight = rect.height * zoom

                      const imageLeft = size / 2 - magnifierFocusX * imageWidth
                      const imageTop = size / 2 - magnifierFocusY * imageHeight

                      const pointerX = imageLeft + magnifierMarkerX * imageWidth
                      const pointerY = imageTop + magnifierMarkerY * imageHeight

                      const boxLeft = hasUsableBox ? imageLeft + activeBox.x * imageWidth : 0
                      const boxTop = hasUsableBox ? imageTop + activeBox.y * imageHeight : 0
                      const boxWidth = hasUsableBox ? activeBox.w * imageWidth : 0
                      const boxHeight = hasUsableBox ? activeBox.h * imageHeight : 0
                      const showPoseMagnifierSkeleton = poseSkeletonVisible && Boolean(poseMagnifierInteraction)
                      const magnifierIsAbovePointer = top + size / 2 <= touchMagnifier.clientY
                      const activeMagnifierLabel = hasUsablePoseMagnifierPoint
                        ? poseKeypointLabel(activePoseMagnifierPoint?.name)
                        : hasUsableBox
                          ? getSegmentLabelItem(activeBox.label).text || activeBox.label
                          : ''

                      return typeof document !== 'undefined'
                        ? createPortal(
                            <div
                              className="pointer-events-none fixed z-[2147483647] overflow-hidden rounded-xl border-2 border-white bg-black shadow-2xl ring-2 ring-black/70"
                              style={{
                                left,
                                top,
                                width: size,
                                height: size,
                              }}
                            >
                              <img
                                src={imageSrc}
                                alt=""
                                draggable={false}
                                className="absolute max-h-none max-w-none select-none"
                                style={{
                                  left: imageLeft,
                                  top: imageTop,
                                  width: imageWidth,
                                  height: imageHeight,
                                }}
                              />

                              {showPoseMagnifierSkeleton ? (
                                <svg
                                  className="absolute inset-0 h-full w-full overflow-visible"
                                  viewBox={`0 0 ${size} ${size}`}
                                  preserveAspectRatio="none"
                                >
                                  {posePersons.map((person, personIndex) => {
                                    if (!shouldRenderPoseOverlayPerson(personIndex)) {
                                      return null
                                    }

                                    if (!person.keypoints?.some(isPoseKeypointVisible)) {
                                      return null
                                    }

                                    const reliable = isPosePersonReliable(person)
                                    const isActivePose = poseMagnifierInteraction?.personIndex === personIndex
                                    const color = reliable
                                      ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
                                      : POSE_UNRELIABLE_COLOR
                                    const keypointsByName = new Map(
                                      person.keypoints.map((point) => [poseKeypointId(point.name), point])
                                    )

                                    return (
                                      <g
                                        key={`magnifier-pose-${personIndex}`}
                                        opacity={isActivePose ? 1 : reliable ? 0.78 : 0.52}
                                      >
                                        {POSE_SKELETON_EDGES.map(([from, to]) => {
                                          if (
                                            poseFaceImageZoomActive &&
                                            (!POSE_FACE_KEYPOINT_NAME_SET.has(from) ||
                                              !POSE_FACE_KEYPOINT_NAME_SET.has(to))
                                          ) {
                                            return null
                                          }

                                          const fromPoint = keypointsByName.get(from)
                                          const toPoint = keypointsByName.get(to)

                                          if (
                                            !isPoseKeypointVisible(fromPoint) ||
                                            !isPoseKeypointVisible(toPoint)
                                          ) {
                                            return null
                                          }

                                          return (
                                            <line
                                              key={`${from}-${to}`}
                                              x1={imageLeft + poseCoordPx(fromPoint.x, imageWidth)}
                                              y1={imageTop + poseCoordPx(fromPoint.y, imageHeight)}
                                              x2={imageLeft + poseCoordPx(toPoint.x, imageWidth)}
                                              y2={imageTop + poseCoordPx(toPoint.y, imageHeight)}
                                              stroke={color}
                                              strokeOpacity={isActivePose ? 0.95 : reliable ? 0.76 : 0.52}
                                              strokeWidth={isActivePose ? 3 : 2.25}
                                              strokeLinecap="round"
                                              strokeDasharray={reliable ? undefined : '4 4'}
                                              vectorEffect="non-scaling-stroke"
                                            />
                                          )
                                        })}

                                        {person.keypoints.map((point, pointIndex) => {
                                          if (!isPoseKeypointVisible(point)) return null
                                          if (
                                            poseFaceImageZoomActive &&
                                            !POSE_FACE_KEYPOINT_NAME_SET.has(poseKeypointId(point.name))
                                          ) {
                                            return null
                                          }

                                          const isActivePoint =
                                            poseMagnifierInteraction?.personIndex === personIndex &&
                                            poseMagnifierInteraction.keypointIndex === pointIndex

                                          return (
                                            <circle
                                              key={`magnifier-point-${personIndex}-${poseKeypointId(point.name)}-${pointIndex}`}
                                              cx={imageLeft + poseCoordPx(point.x, imageWidth)}
                                              cy={imageTop + poseCoordPx(point.y, imageHeight)}
                                              r={isActivePoint ? 5 : isActivePose ? 4 : 3}
                                              fill={isActivePoint ? '#ef4444' : color}
                                              fillOpacity={isActivePoint ? 1 : reliable ? 0.95 : 0.75}
                                              stroke="#fff"
                                              strokeOpacity={0.95}
                                              strokeWidth={isActivePoint ? 2 : 1.25}
                                              vectorEffect="non-scaling-stroke"
                                            />
                                          )
                                        })}
                                      </g>
                                    )
                                  })}
                                </svg>
                              ) : null}

                              {hasUsableBox ? (
                                <div
                                  className={[
                                    'absolute rounded border-2 bg-black/0 shadow-[0_0_0_1px_rgba(0,0,0,0.85)]',
                                    scoreBorderClass(activeBox.score, { draft: Boolean(drawingBox) }),
                                  ].join(' ')}
                                  style={{
                                    left: boxLeft,
                                    top: boxTop,
                                    width: boxWidth,
                                    height: boxHeight,
                                  }}
                                />
                              ) : null}

                              <div
                                className="absolute h-5 w-px -translate-x-1/2 -translate-y-1/2 bg-red-500/90"
                                style={{
                                  left: pointerX,
                                  top: pointerY,
                                }}
                              />

                              <div
                                className="absolute h-px w-5 -translate-x-1/2 -translate-y-1/2 bg-red-500/90"
                                style={{
                                  left: pointerX,
                                  top: pointerY,
                                }}
                              />

                              <div
                                className="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-red-500 shadow"
                                style={{
                                  left: pointerX,
                                  top: pointerY,
                                }}
                              />

                              {activeMagnifierLabel ? (
                                <div
                                  className={[
                                    'absolute left-1/2 z-20 max-w-[calc(100%-1rem)] -translate-x-1/2 truncate rounded-full',
                                    'bg-black/75 px-2.5 py-1 text-[10px] font-black leading-none text-white',
                                    'shadow-lg ring-1 ring-white/20 backdrop-blur',
                                    magnifierIsAbovePointer ? 'top-2' : 'bottom-2',
                                  ].join(' ')}
                                  title={activeMagnifierLabel}
                                >
                                  {activeMagnifierLabel}
                                </div>
                              ) : null}
                            </div>,
                            document.body
                          )
                        : null
                    })() : null}
                  </div>

                  {poseFaceImageZoomActive && poseCorrectionPersonIndex !== null ? (
                    <div
                      className="pointer-events-auto absolute left-3 top-3 z-[430] flex max-w-[calc(100%-1.5rem)] flex-wrap items-center gap-1.5 rounded-full bg-black/65 p-1 text-white shadow-lg ring-1 ring-white/20 backdrop-blur"
                      onPointerDown={(event) => event.stopPropagation()}
                      onClick={(event) => event.stopPropagation()}
                    >
                      {visiblePosePersonIndexes.length > 1 ? (
                        <>
                          <button
                            type="button"
                            className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/12 text-white ring-1 ring-white/15 transition hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
                            onClick={() => selectAdjacentPoseFace(-1)}
                            title="Vorheriges Gesicht"
                            aria-label="Vorheriges Gesicht"
                          >
                            <ForwardIcon className="h-3.5 w-3.5 rotate-180" aria-hidden="true" />
                          </button>

                          <span className="inline-flex h-7 items-center rounded-full bg-white/10 px-2 text-[11px] font-black leading-none text-white ring-1 ring-white/10">
                            {Math.max(0, poseCorrectionVisibleIndex) + 1}/{visiblePosePersonIndexes.length}
                          </span>

                          <button
                            type="button"
                            className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/12 text-white ring-1 ring-white/15 transition hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50"
                            onClick={() => selectAdjacentPoseFace(1)}
                            title="Nächstes Gesicht"
                            aria-label="Nächstes Gesicht"
                          >
                            <ForwardIcon className="h-3.5 w-3.5" aria-hidden="true" />
                          </button>
                        </>
                      ) : null}

                      <button
                        type="button"
                        className="inline-flex h-7 items-center gap-1 rounded-full bg-red-500/90 px-2 text-[11px] font-bold leading-none text-white ring-1 ring-red-200/30 transition hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-200/70"
                        onClick={() => deletePosePerson(poseCorrectionPersonIndex)}
                        title="Ausgewählte Pose löschen"
                        aria-label="Ausgewählte Pose löschen"
                      >
                        <TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
                        Pose löschen
                      </button>
                    </div>
                  ) : null}

                  {poseFaceImageZoomApplied && poseFaceImageRenderedCamera?.visibleRegion ? (() => {
                    const region = poseFaceImageRenderedCamera.visibleRegion

                    return (
                      <div
                        role="button"
                        tabIndex={0}
                        className={[
                          'pointer-events-auto absolute bottom-3 left-3 z-[430] w-28 max-w-[42vw] touch-none overflow-hidden rounded-lg border border-black/90 bg-black/75 text-left shadow-xl ring-1 ring-white/25 backdrop-blur transition',
                          'hover:bg-black/85 hover:ring-white/40 focus:outline-none focus:ring-2 focus:ring-cyan-300/80',
                          poseFaceImagePanning ? 'cursor-grabbing' : 'cursor-grab',
                          'sm:left-auto sm:right-3 sm:w-36 md:w-40',
                        ].join(' ')}
                        style={{
                          aspectRatio: `${frameLayoutSize.width} / ${frameLayoutSize.height}`,
                        }}
                        onPointerDown={startPoseFacePreviewPan}
                        onPointerMove={movePoseFaceImagePan}
                        onPointerUp={finishPoseFaceImagePan}
                        onPointerCancel={finishPoseFaceImagePan}
                        onKeyDown={(event) => {
                          if (event.key !== 'Enter' && event.key !== ' ') return

                          event.preventDefault()
                          event.stopPropagation()
                          zoomOutPoseFaceImageToBody()
                        }}
                        title="Vorschau öffnen"
                        aria-label="Vorschau öffnen"
                      >
                        <img
                          src={imageSrc}
                          alt=""
                          draggable={false}
                          className="absolute inset-0 h-full w-full select-none object-cover opacity-85"
                        />
                        <span className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/30 via-transparent to-black/10" />
                        <span
                          className={[
                            'pointer-events-none absolute rounded-[3px] border-2 border-cyan-300 shadow-[0_0_0_1px_rgba(0,0,0,0.9),0_0_16px_rgba(34,211,238,0.55)] motion-reduce:transition-none',
                            poseFaceImagePanning || poseFaceImageCameraAnimating
                              ? 'transition-none'
                              : 'transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]',
                          ].join(' ')}
                          style={{
                            left: `${region.x * 100}%`,
                            top: `${region.y * 100}%`,
                            width: `${region.w * 100}%`,
                            height: `${region.h * 100}%`,
                          }}
                        />
                      </div>
                    )
                  })() : null}
                </div>
              ) : (
                <div className="relative z-10 text-sm text-white/80">
                  Kein Bild geladen
                </div>
              )}

              {renderStageOverlay ? (
                <TrainingStageOverlay
                  mode={stageOverlayMode}
                  text={stageOverlayText}
                  sourceFile={stageOverlaySourceFile}
                  frameLabel={stageOverlayFrameLabel}
                  statusText={stageOverlayStatusText}
                  progress={stageOverlayProgress}
                  visible={stageOverlayIsVisible}
                  backgroundUrl={
                    stageOverlayMode === 'training'
                      ? trainingPreviewUrl || trainingStatus?.training?.previewUrl || imageSrc
                      : stageOverlayMode === 'saving'
                        ? imageSrc
                        : loadingPreviewBackgroundUrl
                  }
                />
              ) : null}
            </div>

            <div
              className="mt-3 grid shrink-0 grid-cols-2 gap-2 sm:mt-4"
            >
              <Button
                size="md"
                variant={hasManualCorrection || willSaveAsNegative ? 'primary' : 'soft'}
                color={
                  willSaveAsNegative
                    ? 'blue'
                    : hasManualCorrection
                      ? undefined
                      : 'emerald'
                }
                disabled={
                  trainingRunning ||
                  uiLocked ||
                  frameBusy ||
                  !sample ||
                  (!hasManualCorrection && !willSaveAsNegative && !sample.prediction.modelAvailable)
                }
                onClick={() => void saveFeedback(!hasManualCorrection)}
                className="w-full justify-center px-2 text-xs sm:text-sm"
                title={
                  trainingRunning
                    ? 'Während das Training läuft, kann kein Bild ins Training übernommen werden.'
                    : willSaveAsNegative
                    ? 'Keine Box und keine Position gesetzt. Das Bild wird als Negativbeispiel gespeichert.'
                    : hasManualCorrection
                    ? 'Die korrigierten Werte werden gespeichert.'
                    : sample?.prediction.modelAvailable
                      ? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.'
                      : 'Erst verfügbar, wenn ein Modell trainiert wurde.'
                }
              >
                {saving ? (
                  <span className="inline-flex items-center gap-1.5">
                    <ArrowPathIcon className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
                    Speichere…
                  </span>
                ) : willSaveAsNegative ? (
                  <span className="inline-flex items-center gap-1.5">
                    <XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
                    <span className="sm:hidden">Negativ</span>
                    <span className="hidden sm:inline">Negativbeispiel & weiter</span>
                  </span>
                ) : hasManualCorrection ? (
                  <span className="inline-flex items-center gap-1.5">
                    <InboxArrowDownIcon className="h-3.5 w-3.5" aria-hidden="true" />
                    <span className="sm:hidden">Speichern</span>
                    <span className="hidden sm:inline">Speichern & weiter</span>
                  </span>
                ) : (
                  <span className="inline-flex items-center gap-1.5">
                    <CheckIcon className="h-3.5 w-3.5" aria-hidden="true" />
                    <span className="sm:hidden">Passt</span>
                    <span className="hidden sm:inline">Passt so & weiter</span>
                  </span>
                )}
              </Button>

              <Button
                size="md"
                variant="secondary"
                disabled={uiLocked || frameBusy || !sample}
                onClick={() => void skipCurrentSample()}
                className="w-full justify-center px-2 text-xs sm:text-sm"
                title={
                  editingFeedback
                    ? 'Feedback-Bearbeitung abbrechen und zum vorherigen Trainingsbild zurückkehren.'
                    : trainingSampleMode === 'uncertain'
                      ? 'Dieses Bild löschen und eine andere unsichere Prediction laden.'
                      : 'Dieses Bild löschen und ein anderes laden.'
                }
              >
                <span className="inline-flex items-center gap-1.5">
                  {editingFeedback ? (
                    <XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
                  ) : (
                    <ForwardIcon className="h-3.5 w-3.5" aria-hidden="true" />
                  )}
                  {editingFeedback ? 'Bearbeitung abbrechen' : 'Überspringen'}
                </span>
              </Button>

              <div className="col-span-2 hidden lg:block">
                <Button
                  size="md"
                  variant="secondary"
                  disabled={uiLocked}
                  onClick={() => setImageExpanded((value) => !value)}
                  className="w-full justify-center px-2 text-xs sm:text-sm"
                  title={imageExpanded ? 'Layout wieder normal anzeigen' : 'Mittlere Spalte vergrößern'}
                  aria-pressed={imageExpanded}
                >
                  <span className="inline-flex items-center gap-1.5">
                    {imageExpanded ? (
                      <ArrowsPointingInIcon className="h-3.5 w-3.5" aria-hidden="true" />
                    ) : (
                      <ArrowsPointingOutIcon className="h-3.5 w-3.5" aria-hidden="true" />
                    )}

                    {imageExpanded ? 'Normal anzeigen' : 'Bild vergrößern'}
                  </span>
                </Button>
              </div>
            </div>
          </section>

          {trainingRunning ? (
            <div className="mt-2 hidden rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/70 lg:block">
              <div className="mb-2 flex items-center justify-between gap-3">
                <div className="min-w-0">
                  <div className="text-xs font-semibold text-gray-900 dark:text-white">
                    Trainingszeit
                  </div>

                  <div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
                    {shownTrainingStep || 'Training läuft…'}
                  </div>
                </div>

                <span className="shrink-0 rounded-full bg-indigo-50 px-2 py-1 text-[11px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/30">
                  {Math.round(shownTrainingProgress)}%
                </span>
              </div>

              <div className="grid grid-cols-5 gap-2 text-[11px]">
                <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
                  <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                    Laufzeit
                  </div>
                  <div className="mt-0.5 font-bold">
                    {formatDuration(shownTrainingDurationMs)}
                  </div>
                </div>

                <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
                  <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                    Restzeit
                  </div>
                  <div className="mt-0.5 font-bold">
                    {shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
                  </div>
                </div>

                <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
                  <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                    Epoche
                  </div>
                  <div className="mt-0.5 font-bold">
                    {shownTrainingEpochText
                      ? shownTrainingEpochText.replace(/^Epoche\s+/i, '')
                      : '—'}
                  </div>
                </div>

                <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
                  <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                    Ø / Epoche
                  </div>
                  <div className="mt-0.5 font-bold">
                    {estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
                  </div>
                </div>

                <div className="rounded-lg bg-emerald-50 px-2 py-1.5 text-center text-emerald-900 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/20">
                  <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
                    {trainingMetricLabel}
                  </div>
                  <div className="mt-0.5 font-bold tabular-nums">
                    {trainingMetricText}
                  </div>
                </div>
              </div>
            </div>
          ) : null}
        </div>
        
        <div className="flex min-h-[260px] flex-col rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/80 lg:hidden">
          <div className="grid grid-cols-3 gap-1 border-b border-gray-200 p-1 dark:border-white/10">
            {[
              { key: 'labels', label: 'Labels' },
              { key: 'boxes', label: 'Boxen', count: correctionBoxes.length },
              { key: 'training', label: 'Training' },
            ].map((item) => {
              const active = mobilePanel === item.key

              return (
                <button
                  key={item.key}
                  type="button"
                  onClick={() => setMobilePanel(item.key as 'labels' | 'boxes' | 'training')}
                  className={[
                    'rounded-md px-2 py-2 text-xs font-semibold transition',
                    active
                      ? [
                          'bg-indigo-100 text-indigo-900 shadow-sm ring-1 ring-indigo-200',
                          'dark:bg-indigo-600 dark:text-white dark:ring-indigo-500',
                        ].join(' ')
                      : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10',
                  ].join(' ')}
                >
                  <span className="inline-flex items-center justify-center gap-1.5">
                    <span>{item.label}</span>

                    {typeof item.count === 'number' ? (
                      <span
                        className={[
                          'min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-black leading-none ring-1',
                          active
                            ? 'bg-indigo-600 text-white ring-indigo-600 dark:bg-white dark:text-indigo-700 dark:ring-white'
                            : 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
                        ].join(' ')}
                        aria-label={`${item.count} Boxen`}
                      >
                        {item.count}
                      </span>
                    ) : null}
                  </span>
                </button>
              )
            })}
          </div>

          { /*  Rechte Seite */ }
          <div
            ref={mobileLabelsScrollRef}
            className="p-3"
          >
            {mobilePanel === 'labels' ? (
              <div
                className={[
                  'space-y-3',
                  uiLocked ? 'pointer-events-none opacity-60' : '',
                ].join(' ')}
              >
                <div className="flex items-start justify-between gap-3">
                  <div className="min-w-0">
                    <div className="text-sm font-semibold text-gray-900 dark:text-white">
                      Korrektur
                    </div>

                    <div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
                      Label wählen, dann Box im Bild zeichnen.
                    </div>
                  </div>

                  <span
                    className={[
                      'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold ring-1',
                      confidencePillClass(analysisConfidence),
                    ].join(' ')}
                    title={`Analyse-Confidence aktuelles Bild: ${confidencePercent(analysisConfidence)}`}
                  >
                    {confidencePercent(analysisConfidence)}
                  </span>
                </div>

                {poseModeActive ? (
                  poseKeypointCorrectionPanel()
                ) : (
                  <>
                    <div
                      ref={(el) => {
                        mobileSectionRefs.current.sexPosition = el
                      }}
                    >
                      <CollapsibleSingleLabelSection
                        title="Sexposition"
                        values={labels.sexPositions}
                        value={correction.sexPosition}
                        score={sample?.prediction.sexPositionScore}
                        predictionValue={sample?.prediction.sexPosition}
                        expanded={expandedCorrectionSections.sexPosition}
                        onExpandedChange={(expanded) =>
                          toggleMobileCorrectionSection('sexPosition', expanded)
                        }
                        onChange={(value) =>
                          setCorrection((p) => {
                            if (p.sexPosition !== value) {
                              setHasManualCorrection(true)
                            }

                            return {
                              ...p,
                              sexPosition: value,
                            }
                          })
                        }
                        disabled={uiLocked}
                        gridClassName="grid grid-cols-3 gap-2"
                      />
                    </div>

                    <div
                      ref={(el) => {
                        mobileSectionRefs.current.people = el
                      }}
                    >
                      <CollapsibleLabelSection
                        title="Personen"
                        values={labels.people}
                        selected={selectedPeopleLabels}
                        scores={peopleScores}
                        activeCounts={peopleBoxCounts}
                        expanded={expandedCorrectionSections.people}
                        onExpandedChange={(expanded) =>
                          toggleMobileCorrectionSection('people', expanded)
                        }
                        onToggle={() => {}}
                        drawLabel={drawLabelForSection(labels.people)}
                        onDrawLabelChange={setBoxLabel}
                        disabled={uiLocked}
                        singleDrawMode
                        gridClassName="grid grid-cols-2 gap-2"
                      />
                    </div>

                    <div
                      ref={(el) => {
                        mobileSectionRefs.current.bodyParts = el
                      }}
                    >
                      <CollapsibleLabelSection
                        title="Körperteile"
                        values={labels.bodyParts}
                        selected={correction.bodyPartsPresent}
                        scores={bodyPartScores}
                        expanded={expandedCorrectionSections.bodyParts}
                        gridClassName="grid grid-cols-3 gap-2"
                        onExpandedChange={(expanded) =>
                          toggleMobileCorrectionSection('bodyParts', expanded)
                        }
                        onToggle={(value) =>
                          setCorrection((p) => {
                            setHasManualCorrection(true)

                            return {
                              ...p,
                              bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
                            }
                          })
                        }
                        drawLabel={drawLabelForSection(labels.bodyParts)}
                        onDrawLabelChange={setBoxLabel}
                        disabled={uiLocked}
                      />
                    </div>

                    <div
                      ref={(el) => {
                        mobileSectionRefs.current.objects = el
                      }}
                    >
                      <CollapsibleLabelSection
                        title="Gegenstände"
                        values={labels.objects}
                        selected={correction.objectsPresent}
                        scores={objectScores}
                        expanded={expandedCorrectionSections.objects}
                        gridClassName="grid grid-cols-3 gap-2"
                        onExpandedChange={(expanded) =>
                          toggleMobileCorrectionSection('objects', expanded)
                        }
                        onToggle={(value) =>
                          setCorrection((p) => {
                            setHasManualCorrection(true)

                            return {
                              ...p,
                              objectsPresent: toggleArrayValue(p.objectsPresent, value),
                            }
                          })
                        }
                        drawLabel={drawLabelForSection(labels.objects)}
                        onDrawLabelChange={setBoxLabel}
                        disabled={uiLocked}
                      />
                    </div>

                    <div
                      ref={(el) => {
                        mobileSectionRefs.current.clothing = el
                      }}
                    >
                      <CollapsibleLabelSection
                        title="Kleidung"
                        values={labels.clothing}
                        selected={correction.clothingPresent}
                        scores={clothingScores}
                        expanded={expandedCorrectionSections.clothing}
                        gridClassName="grid grid-cols-3 gap-2"
                        onExpandedChange={(expanded) =>
                          toggleMobileCorrectionSection('clothing', expanded)
                        }
                        onToggle={(value) =>
                          setCorrection((p) => {
                            setHasManualCorrection(true)

                            return {
                              ...p,
                              clothingPresent: toggleArrayValue(p.clothingPresent, value),
                            }
                          })
                        }
                        drawLabel={drawLabelForSection(labels.clothing)}
                        onDrawLabelChange={setBoxLabel}
                        disabled={uiLocked}
                      />
                    </div>
                  </>
                )}
              </div>
            ) : null}

            {mobilePanel === 'boxes' ? (
              <div>
                {detectorBoxesPanel({ compact: true })}
              </div>
            ) : null}

            {mobilePanel === 'training' ? (
              <div className="space-y-2 text-xs">
                <div className="pt-1">
                  {trainingActionsPanel({ compact: true })}
                </div>
              </div>
            ) : null}
          </div>
        </div>

        {/* Rechte Details/Korrektur */}
        <aside
          className={[
            'hidden h-full max-h-full min-h-0 rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
            poseModeActive
              ? 'overflow-hidden lg:flex lg:flex-col'
              : 'overflow-y-auto overscroll-contain lg:block',
            uiLocked ? 'pointer-events-none opacity-60' : '',
          ].join(' ')}
        >
          <div className="flex shrink-0 items-start justify-between gap-3">
            <div className="min-w-0">
              <div className="text-sm font-semibold text-gray-900 dark:text-white">
                Korrektur
              </div>

            </div>

            <span
              className={[
                'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold ring-1',
                confidencePillClass(analysisConfidence),
              ].join(' ')}
              title={`Analyse-Confidence aktuelles Bild: ${confidencePercent(analysisConfidence)}`}
            >
              {confidencePercent(analysisConfidence)}
            </span>
          </div>

          <div
            className={
              poseModeActive
                ? 'mt-3 min-h-0 flex-1 overflow-hidden'
                : 'mt-3 space-y-3'
            }
          >
            {poseModeActive ? (
              poseKeypointCorrectionPanel()
            ) : (
              <>
            <CollapsibleSingleLabelSection
              title="Sexposition"
              values={labels.sexPositions}
              value={correction.sexPosition}
              score={sample?.prediction.sexPositionScore}
              predictionValue={sample?.prediction.sexPosition}
              expanded={expandedCorrectionSections.sexPosition}
              onExpandedChange={(expanded) =>
                toggleCorrectionSection('sexPosition', expanded)
              }
              onChange={(value) =>
                setCorrection((p) => {
                  if (p.sexPosition !== value) {
                    setHasManualCorrection(true)
                  }

                  return {
                    ...p,
                    sexPosition: value,
                  }
                })
              }
              disabled={uiLocked}
              gridClassName="grid grid-cols-3 gap-2"
            />
            
            <CollapsibleLabelSection
              title="Personen"
              values={labels.people}
              selected={selectedPeopleLabels}
              scores={peopleScores}
              activeCounts={peopleBoxCounts}
              expanded={expandedCorrectionSections.people}
              onExpandedChange={(expanded) =>
                toggleCorrectionSection('people', expanded)
              }
              onToggle={() => {}}
              drawLabel={drawLabelForSection(labels.people)}
              onDrawLabelChange={setBoxLabel}
              disabled={uiLocked}
              singleDrawMode
              gridClassName="grid grid-cols-2 gap-2"
            />

            <CollapsibleLabelSection
              title="Körperteile"
              values={labels.bodyParts}
              selected={correction.bodyPartsPresent}
              scores={bodyPartScores}
              expanded={expandedCorrectionSections.bodyParts}
              gridClassName="grid grid-cols-3 gap-2"
              onExpandedChange={(expanded) =>
                toggleCorrectionSection('bodyParts', expanded)
              }
              onToggle={(value) =>
                setCorrection((p) => {
                  setHasManualCorrection(true)

                  return {
                    ...p,
                    bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
                  }
                })
              }
              drawLabel={drawLabelForSection(labels.bodyParts)}
              onDrawLabelChange={setBoxLabel}
              disabled={uiLocked}
            />

            <CollapsibleLabelSection
              title="Gegenstände"
              values={labels.objects}
              selected={correction.objectsPresent}
              scores={objectScores}
              expanded={expandedCorrectionSections.objects}
              gridClassName="grid grid-cols-3 gap-2"
              onExpandedChange={(expanded) =>
                toggleCorrectionSection('objects', expanded)
              }
              onToggle={(value) =>
                setCorrection((p) => {
                  setHasManualCorrection(true)

                  return {
                    ...p,
                    objectsPresent: toggleArrayValue(p.objectsPresent, value),
                  }
                })
              }
              drawLabel={drawLabelForSection(labels.objects)}
              onDrawLabelChange={setBoxLabel}
              disabled={uiLocked}
            />

            <CollapsibleLabelSection
              title="Kleidung"
              values={labels.clothing}
              selected={correction.clothingPresent}
              scores={clothingScores}
              expanded={expandedCorrectionSections.clothing}
              gridClassName="grid grid-cols-3 gap-2"
              onExpandedChange={(expanded) =>
                toggleCorrectionSection('clothing', expanded)
              }
              onToggle={(value) =>
                setCorrection((p) => {
                  setHasManualCorrection(true)

                  return {
                    ...p,
                    clothingPresent: toggleArrayValue(p.clothingPresent, value),
                  }
                })
              }
              drawLabel={drawLabelForSection(labels.clothing)}
              onDrawLabelChange={setBoxLabel}
              disabled={uiLocked}
            />
              </>
            )}
          </div>
        </aside>
      </div>

      <Modal
        open={trainingStartModalOpen}
        onClose={() => setTrainingStartModalOpen(false)}
        title="Training starten"
        width="max-w-xl"
        footer={
          <>
            <Button
              type="button"
              size="sm"
              variant="secondary"
              onClick={() => setTrainingStartModalOpen(false)}
            >
              Abbrechen
            </Button>

            <Button
              type="button"
              size="sm"
              variant="primary"
              disabled={!canConfirmTrainingStart || trainingRunning}
              onClick={startTrainingFromModal}
            >
              <span className="inline-flex items-center gap-1.5">
                <BoltIcon className="h-3.5 w-3.5" aria-hidden="true" />
                Training starten
              </span>
            </Button>
          </>
        }
      >
        <div className="space-y-4 px-4 pb-4 pt-3 sm:px-6 sm:pb-6 sm:pt-4">
          <div className="grid gap-2 sm:grid-cols-2">
            <button
              type="button"
              onClick={() => setTrainingStartMode('full')}
              aria-pressed={trainingStartMode === 'full'}
              className={[
                'rounded-xl border px-3 py-2.5 text-left transition',
                trainingStartMode === 'full'
                  ? 'border-indigo-300 bg-indigo-50 text-indigo-950 ring-2 ring-indigo-500/20 dark:border-indigo-400/40 dark:bg-indigo-500/15 dark:text-indigo-100'
                  : 'border-gray-200 bg-white text-gray-800 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-100 dark:hover:bg-white/10',
              ].join(' ')}
            >
              <span className="block text-sm font-bold">Vollständig</span>
              <span className="mt-1 block text-xs leading-snug text-gray-500 dark:text-gray-400">
                Startet alle verfügbaren Trainings und überspringt intern nur nicht bereite Teile.
              </span>
            </button>

            <button
              type="button"
              onClick={() => setTrainingStartMode('custom')}
              aria-pressed={trainingStartMode === 'custom'}
              className={[
                'rounded-xl border px-3 py-2.5 text-left transition',
                trainingStartMode === 'custom'
                  ? 'border-indigo-300 bg-indigo-50 text-indigo-950 ring-2 ring-indigo-500/20 dark:border-indigo-400/40 dark:bg-indigo-500/15 dark:text-indigo-100'
                  : 'border-gray-200 bg-white text-gray-800 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-100 dark:hover:bg-white/10',
              ].join(' ')}
            >
              <span className="block text-sm font-bold">Einzeln auswählen</span>
              <span className="mt-1 block text-xs leading-snug text-gray-500 dark:text-gray-400">
                Trainiert nur die ausgewählten Modelle.
              </span>
            </button>
          </div>

          <div className="rounded-xl bg-gray-50 px-3 py-2.5 ring-1 ring-gray-200 dark:bg-white/[0.04] dark:ring-white/10">
            <div className="flex items-center justify-between gap-3">
              <span className="min-w-0 text-xs font-semibold text-gray-600 dark:text-gray-300">
                Geschätzte Gesamtdauer
              </span>

              <span className="shrink-0 rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/30">
                {trainingStartTotalEstimateText}
              </span>
            </div>

            <div className="mt-1 truncate text-[11px] text-gray-500 dark:text-gray-400">
              {plannedTrainingTargets.length > 0
                ? `${plannedTrainingTargets.length} Training${plannedTrainingTargets.length === 1 ? '' : 's'} eingeplant`
                : 'Noch kein bereites Training ausgewählt'}
            </div>

            <div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
              {trainingEstimateSettings
                ? trainingEstimateRuntimeLabel
                : 'Trainingsmodus wird geladen...'}
            </div>
          </div>

          <div className="space-y-2">
            {trainingStartOptions.map((option) => {
              const fullMode = trainingStartMode === 'full'
              const selected =
                !fullMode && trainingStartTargets.includes(option.key)
              const disabled = fullMode || !option.ready
              const Icon = option.icon

              return (
                <button
                  key={option.key}
                  type="button"
                  disabled={disabled}
                  aria-pressed={selected}
                  onClick={() => {
                    if (trainingStartMode !== 'custom' || !option.ready) return
                    toggleTrainingStartTarget(option.key)
                  }}
                  className={[
                    'flex w-full gap-3 rounded-xl border px-3 py-2.5 text-left transition ring-1',
                    fullMode
                      ? 'border-gray-200 bg-gray-50 opacity-55 ring-transparent dark:border-white/10 dark:bg-white/[0.03]'
                      : selected
                        ? 'border-indigo-300 bg-indigo-50 ring-indigo-500/20 dark:border-indigo-400/40 dark:bg-indigo-500/15 dark:ring-indigo-400/20'
                        : option.ready
                          ? 'border-gray-200 bg-white ring-transparent dark:border-white/10 dark:bg-white/5'
                          : 'border-gray-200 bg-gray-50 opacity-70 ring-transparent dark:border-white/10 dark:bg-white/[0.03]',
                    !fullMode && option.ready
                      ? 'cursor-pointer hover:border-indigo-200 hover:bg-indigo-50/60 hover:ring-indigo-500/10 dark:hover:border-indigo-400/30 dark:hover:bg-indigo-500/10'
                      : option.ready
                        ? 'cursor-default'
                        : 'cursor-not-allowed',
                  ].join(' ')}
                >
                  <span
                    className={[
                      'mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ring-1 transition',
                      fullMode
                        ? 'bg-gray-100 text-gray-400 ring-gray-200 dark:bg-white/5 dark:text-white/35 dark:ring-white/10'
                        : selected
                          ? 'bg-indigo-600 text-white ring-indigo-500 dark:bg-indigo-500 dark:ring-indigo-300/30'
                          : option.ready
                            ? 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10'
                            : 'bg-gray-100 text-gray-400 ring-gray-200 dark:bg-white/5 dark:text-white/35 dark:ring-white/10',
                    ].join(' ')}
                    aria-hidden="true"
                  >
                    <Icon className="h-5 w-5" />
                  </span>

                  <span className="min-w-0 flex-1">
                    <span className="flex items-start justify-between gap-2">
                      <span className="min-w-0">
                        <span className="block truncate text-sm font-bold text-gray-900 dark:text-white">
                          {option.label}
                        </span>

                        <span className="mt-1 block text-xs leading-snug text-gray-500 dark:text-gray-400">
                          {option.description}
                        </span>
                      </span>

                      <span className="flex shrink-0 items-center gap-1.5">
                        <span className="rounded-full bg-gray-50 px-2 py-0.5 text-[10px] font-bold text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
                          {option.estimateText}
                        </span>

                        <span
                          className={[
                            'rounded-full px-2 py-0.5 text-[10px] font-bold ring-1',
                            option.ready
                              ? 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
                              : 'bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-400/30',
                          ].join(' ')}
                        >
                          {option.ready ? 'bereit' : 'fehlt'}
                        </span>
                      </span>
                    </span>

                    <span className="mt-1.5 block truncate text-[11px] font-medium text-gray-500 dark:text-gray-400">
                      {option.ready ? option.detail : option.blockedText}
                    </span>
                  </span>
                </button>
              )
            })}
          </div>

          {trainingStartMode === 'custom' && selectedTrainingTargets.length === 0 ? (
            <div className="rounded-lg bg-amber-50 px-3 py-2 text-xs font-medium text-amber-900 ring-1 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-100 dark:ring-amber-400/30">
              Wähle mindestens ein bereites Training aus.
            </div>
          ) : null}
        </div>
      </Modal>

      <TrainingStatsModal
        open={statsModalOpen}
        onClose={() => setStatsModalOpen(false)}
        stats={trainingStats}
        history={trainingHistory}
        loading={trainingStatsLoading}
        error={trainingStatsError}
        feedbackCount={feedbackCount}
        requiredCount={requiredCount}
        deletingTrainingData={deletingTrainingData}
        deleteTrainingDataDisabled={uiLocked}
        onDeleteTrainingData={deleteAllTrainingData}
      />

      <TrainingFeedbackHistoryModal
        open={feedbackModalOpen}
        onClose={() => setFeedbackModalOpen(false)}
        items={feedbackItems}
        loading={feedbackLoading || feedbackLoadingMore}
        error={feedbackError}
        total={feedbackTotal}
        hasMore={feedbackHasMore}
        selectedIndex={selectedFeedbackIndex}
        onSelectedIndexChange={setSelectedFeedbackIndex}
        onLoadMore={() => void loadMoreFeedbackHistory()}
        onEditItem={editFeedbackItem}
        onSearchChange={(query, filter) => {
          setFeedbackSearchQuery(query)
          setFeedbackSearchFilter(filter)

          void loadFeedbackHistoryInitial({
            query,
            filter,
          })
        }}
      />
    </div>
  )
}
