import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Toast } from 'primereact/toast';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Dialog } from 'primereact/dialog';
import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
import CdRipConfigPanel from '../components/CdRipConfigPanel';
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
import { isConverterJob, isSeriesDvdJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';

const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
const ripperStatuses = new Set([
  'ANALYZING',
  'METADATA_SELECTION',
  'WAITING_FOR_USER_DECISION',
  'READY_TO_START',
  'MEDIAINFO_CHECK',
  'READY_TO_ENCODE',
  'RIPPING',
  'ENCODING',
  'CANCELLED',
  'ERROR',
  'CD_METADATA_SELECTION',
  'CD_READY_TO_RIP',
  'CD_ANALYZING',
  'CD_RIPPING',
  'CD_ENCODING'
]);
const hiddenCancelledReviewOrigins = new Set([
  'READY_TO_START',
  'READY_TO_ENCODE',
  'METADATA_SELECTION',
  'WAITING_FOR_USER_DECISION',
  'CD_METADATA_SELECTION',
  'CD_READY_TO_RIP'
]);

function normalizeJobId(value) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed <= 0) {
    return null;
  }
  return Math.trunc(parsed);
}

function normalizeDrivePath(value) {
  return String(value || '').trim();
}

function formatPercent(value, digits = 1) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed)) {
    return 'n/a';
  }
  return `${parsed.toFixed(digits)}%`;
}

function formatTemperature(value) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed)) {
    return 'n/a';
  }
  return `${parsed.toFixed(1)}°C`;
}

function formatBytes(value) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed < 0) {
    return 'n/a';
  }
  if (parsed === 0) {
    return '0 B';
  }
  const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
  let unitIndex = 0;
  let current = parsed;
  while (current >= 1024 && unitIndex < units.length - 1) {
    current /= 1024;
    unitIndex += 1;
  }
  const digits = unitIndex <= 1 ? 0 : 2;
  return `${current.toFixed(digits)} ${units[unitIndex]}`;
}

function formatUpdatedAt(value) {
  if (!value) {
    return '-';
  }
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) {
    return '-';
  }
  return date.toLocaleString('de-DE');
}

function formatDurationMs(value) {
  const ms = Number(value);
  if (!Number.isFinite(ms) || ms < 0) {
    return '-';
  }
  if (ms < 1000) {
    return `${Math.round(ms)} ms`;
  }
  const seconds = Math.round(ms / 1000);
  if (seconds < 60) {
    return `${seconds}s`;
  }
  const minutes = Math.floor(seconds / 60);
  const restSeconds = seconds % 60;
  return `${minutes}m ${restSeconds}s`;
}

function normalizeRuntimeActivitiesPayload(rawPayload) {
  const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
  const normalizeItem = (item) => {
    const source = item && typeof item === 'object' ? item : {};
    const parsedId = Number(source.id);
    const id = Number.isFinite(parsedId) && parsedId > 0 ? Math.trunc(parsedId) : null;
    return {
      id,
      type: String(source.type || '').trim().toLowerCase() || 'task',
      name: String(source.name || '').trim() || '-',
      status: String(source.status || '').trim().toLowerCase() || 'running',
      outcome: String(source.outcome || '').trim().toLowerCase() || null,
      source: String(source.source || '').trim() || null,
      message: String(source.message || '').trim() || null,
      errorMessage: String(source.errorMessage || '').trim() || null,
      currentStep: String(source.currentStep || '').trim() || null,
      currentScriptName: String(source.currentScriptName || '').trim() || null,
      output: source.output != null ? String(source.output) : null,
      stdout: source.stdout != null ? String(source.stdout) : null,
      stderr: source.stderr != null ? String(source.stderr) : null,
      outputTruncated: Boolean(source.outputTruncated),
      stdoutTruncated: Boolean(source.stdoutTruncated),
      stderrTruncated: Boolean(source.stderrTruncated),
      exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
      startedAt: source.startedAt || null,
      finishedAt: source.finishedAt || null,
      durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
      jobId: Number.isFinite(Number(source.jobId)) && Number(source.jobId) > 0 ? Math.trunc(Number(source.jobId)) : null,
      cronJobId: Number.isFinite(Number(source.cronJobId)) && Number(source.cronJobId) > 0 ? Math.trunc(Number(source.cronJobId)) : null,
      canCancel: Boolean(source.canCancel),
      canNextStep: Boolean(source.canNextStep),
      parentActivityId: Number.isFinite(Number(source.parentActivityId)) && Number(source.parentActivityId) > 0
        ? Math.trunc(Number(source.parentActivityId))
        : null,
      stepIndex: Number.isFinite(Number(source.stepIndex)) ? Math.trunc(Number(source.stepIndex)) : null,
      stepTotal: Number.isFinite(Number(source.stepTotal)) && Number(source.stepTotal) > 0
        ? Math.trunc(Number(source.stepTotal))
        : null
    };
  };
  const active = (Array.isArray(payload.active) ? payload.active : []).map(normalizeItem);
  const recent = (Array.isArray(payload.recent) ? payload.recent : []).map(normalizeItem);
  return {
    active,
    recent,
    updatedAt: payload.updatedAt || null
  };
}

function runtimeTypeLabel(type) {
  const normalized = String(type || '').trim().toLowerCase();
  if (normalized === 'script') return 'Skript';
  if (normalized === 'chain') return 'Kette';
  if (normalized === 'cron') return 'Cronjob';
  return normalized || 'Task';
}

function runtimeStatusMeta(status) {
  const normalized = String(status || '').trim().toLowerCase();
  if (normalized === 'running') return { label: 'Läuft', severity: 'warning' };
  if (normalized === 'success') return { label: 'Abgeschlossen', severity: 'success' };
  if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
  return { label: normalized || '-', severity: 'secondary' };
}

function runtimeOutcomeMeta(outcome, status) {
  const normalized = String(outcome || '').trim().toLowerCase();
  if (normalized === 'success') return { label: 'Erfolg', severity: 'success' };
  if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
  if (normalized === 'cancelled') return { label: 'Abgebrochen', severity: 'warning' };
  if (normalized === 'skipped') return { label: 'Übersprungen', severity: 'info' };
  return runtimeStatusMeta(status);
}

function driveStateIconMeta(stateLabel) {
  const normalized = String(stateLabel || '').trim().toUpperCase();
  if (!normalized || normalized === 'LEER' || normalized === 'IDLE') {
    return { icon: 'pi-stop', spin: false };
  }
  if (normalized === 'DISC_DETECTED') {
    return { icon: 'pi-check', spin: false };
  }
  if (normalized === 'FINISHED') {
    return { icon: 'pi-check-circle', spin: false };
  }
  if (normalized === 'ERROR' || normalized === 'CANCELLED') {
    return { icon: 'pi-times-circle', spin: false };
  }
  if (processingStates.includes(normalized)) {
    return { icon: 'pi-spinner', spin: true };
  }
  if (
    normalized === 'METADATA_SELECTION'
    || normalized === 'CD_METADATA_SELECTION'
    || normalized === 'WAITING_FOR_USER_DECISION'
  ) {
    return { icon: 'pi-list', spin: false };
  }
  if (
    normalized === 'READY_TO_START'
    || normalized === 'READY_TO_ENCODE'
    || normalized === 'CD_READY_TO_RIP'
  ) {
    return { icon: 'pi-play', spin: false };
  }
  return { icon: 'pi-question-circle', spin: false };
}

function hasRuntimeOutputDetails(item) {
  if (!item || typeof item !== 'object') {
    return false;
  }
  const hasRelevantExitCode = Number.isFinite(Number(item.exitCode)) && Number(item.exitCode) !== 0;
  return Boolean(
    String(item.errorMessage || '').trim()
    || String(item.output || '').trim()
    || String(item.stdout || '').trim()
    || String(item.stderr || '').trim()
    || hasRelevantExitCode
  );
}

function hasRuntimeLogContent(item) {
  if (!item || typeof item !== 'object') {
    return false;
  }
  return Boolean(
    String(item.output || '').trim()
    || String(item.stdout || '').trim()
    || String(item.stderr || '').trim()
  );
}

function RuntimeActivityDetails({
  item,
  summary,
  emptyLabel = 'Noch keine Log-Ausgabe vorhanden.'
}) {
  const hasLogs = hasRuntimeLogContent(item);
  const hasOutput = Boolean(String(item?.output || '').trim());
  const hasStdout = Boolean(String(item?.stdout || '').trim());
  const hasStderr = Boolean(String(item?.stderr || '').trim());

  return (
    <details className="runtime-activity-details">
      <summary>{summary}</summary>
      {!hasLogs ? <small>{emptyLabel}</small> : null}
      {hasOutput ? (
        <div className="runtime-activity-details-block">
          <small><strong>Ausgabe:</strong>{item?.outputTruncated ? ' (gekürzt)' : ''}</small>
          <pre>{item.output}</pre>
        </div>
      ) : null}
      {hasStdout ? (
        <div className="runtime-activity-details-block">
          <small><strong>stdout:</strong>{item?.stdoutTruncated ? ' (gekürzt)' : ''}</small>
          <pre>{item.stdout}</pre>
        </div>
      ) : null}
      {hasStderr ? (
        <div className="runtime-activity-details-block">
          <small><strong>stderr:</strong>{item?.stderrTruncated ? ' (gekürzt)' : ''}</small>
          <pre>{item.stderr}</pre>
        </div>
      ) : null}
    </details>
  );
}

function normalizeHardwareMonitoringPayload(rawPayload) {
  const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
  return {
    enabled: Boolean(payload.enabled),
    intervalMs: Number(payload.intervalMs || 0),
    updatedAt: payload.updatedAt || null,
    sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
    error: payload.error ? String(payload.error) : null
  };
}

function getStorageUsageTone(usagePercent) {
  const value = Number(usagePercent);
  if (!Number.isFinite(value)) {
    return 'unknown';
  }
  if (value >= 95) {
    return 'critical';
  }
  if (value >= 85) {
    return 'high';
  }
  if (value >= 70) {
    return 'warn';
  }
  return 'ok';
}

function normalizeQueue(queue) {
  const payload = queue && typeof queue === 'object' ? queue : {};
  const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : [];
  const idleJobs = Array.isArray(payload.idleJobs) ? payload.idleJobs : [];
  const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
  return {
    maxParallelJobs: Number(payload.maxParallelJobs || 1),
    maxParallelCdEncodes: Number(payload.maxParallelCdEncodes || 2),
    maxTotalEncodes: Number(payload.maxTotalEncodes || 3),
    cdBypassesQueue: Boolean(payload.cdBypassesQueue),
    runningCount: Number(payload.runningCount || runningJobs.length || 0),
    runningCdCount: Number(payload.runningCdCount || 0),
    runningJobs,
    idleCount: Number(payload.idleCount || idleJobs.length || 0),
    idleJobs,
    queuedJobs,
    queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
    updatedAt: payload.updatedAt || null
  };
}

function buildCdDriveLifecycleKey(cdDrives) {
  if (!cdDrives || typeof cdDrives !== 'object') {
    return '';
  }
  const entries = Object.entries(cdDrives)
    .map(([devicePath, driveState]) => {
      const state = String(driveState?.state || '').trim().toUpperCase();
      const jobId = normalizeJobId(driveState?.jobId || driveState?.context?.jobId) || 0;
      return `${devicePath}|${jobId}|${state}`;
    })
    .sort();
  return entries.join('::');
}

function getQueueActionResult(response) {
  return response?.result && typeof response.result === 'object' ? response.result : {};
}

function showQueuedToast(toastRef, actionLabel, result) {
  if (!toastRef?.current) {
    return;
  }
  const queuePosition = Number(result?.queuePosition || 0);
  const positionText = queuePosition > 0 ? `Position ${queuePosition}` : 'in der Warteschlange';
  toastRef.current.show({
    severity: 'info',
    summary: `${actionLabel} in Queue`,
    detail: `${actionLabel} wurde ${positionText} eingeplant.`,
    life: 3200
  });
}

function reorderQueuedItems(items, draggedEntryId, targetEntryId) {
  const list = Array.isArray(items) ? items : [];
  const from = list.findIndex((item) => Number(item?.entryId) === Number(draggedEntryId));
  const to = list.findIndex((item) => Number(item?.entryId) === Number(targetEntryId));
  if (from < 0 || to < 0 || from === to) {
    return list;
  }
  const next = [...list];
  const [moved] = next.splice(from, 1);
  next.splice(to, 0, moved);
  return next.map((item, index) => ({
    ...item,
    position: index + 1
  }));
}

function queueEntryIcon(type) {
  if (type === 'script') return 'pi pi-code';
  if (type === 'chain') return 'pi pi-link';
  if (type === 'wait') return 'pi pi-clock';
  return 'pi pi-box';
}

function queueEntryLabel(item) {
  if (item.type === 'script') return `Skript: ${item.title}`;
  if (item.type === 'chain') return `Kette: ${item.title}`;
  if (item.type === 'wait') return `Warten: ${item.waitSeconds}s`;
  return item.title || `Job #${item.jobId}`;
}

function normalizeQueueNameList(values) {
  const list = Array.isArray(values) ? values : [];
  const seen = new Set();
  const output = [];
  for (const item of list) {
    const name = String(item || '').trim();
    if (!name) {
      continue;
    }
    const key = name.toLowerCase();
    if (seen.has(key)) {
      continue;
    }
    seen.add(key);
    output.push(name);
  }
  return output;
}

function normalizeQueueScriptSummary(item) {
  const source = item?.scriptSummary && typeof item.scriptSummary === 'object' ? item.scriptSummary : {};
  return {
    preScripts: normalizeQueueNameList(source.preScripts),
    postScripts: normalizeQueueNameList(source.postScripts),
    preChains: normalizeQueueNameList(source.preChains),
    postChains: normalizeQueueNameList(source.postChains)
  };
}

function hasQueueScriptSummary(item) {
  const summary = normalizeQueueScriptSummary(item);
  return summary.preScripts.length > 0
    || summary.postScripts.length > 0
    || summary.preChains.length > 0
    || summary.postChains.length > 0;
}

function QueueJobScriptSummary({ item }) {
  const summary = normalizeQueueScriptSummary(item);
  const groups = [
    { key: 'pre-scripts', icon: 'pi pi-code', label: 'Pre-Encode Skripte', values: summary.preScripts },
    { key: 'post-scripts', icon: 'pi pi-code', label: 'Post-Encode Skripte', values: summary.postScripts },
    { key: 'pre-chains', icon: 'pi pi-link', label: 'Pre-Encode Ketten', values: summary.preChains },
    { key: 'post-chains', icon: 'pi pi-link', label: 'Post-Encode Ketten', values: summary.postChains }
  ].filter((group) => group.values.length > 0);

  if (groups.length === 0) {
    return null;
  }

  return (
    <div className="queue-job-script-details">
      {groups.map((group) => {
        const text = group.values.join(' | ');
        return (
          <div key={group.key} className="queue-job-script-group">
            <strong><i className={group.icon} /> {group.label}</strong>
            <small title={text}>{text}</small>
          </div>
        );
      })}
    </div>
  );
}

function getAnalyzeContext(job) {
  return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
    ? job.makemkvInfo.analyzeContext
    : {};
}

function resolveMediaType(job) {
  const centralMediaType = resolveCentralMediaType(job);
  if (centralMediaType && centralMediaType !== 'other') {
    return centralMediaType;
  }

  const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
  const candidates = [
    job?.mediaType,
    job?.media_type,
    job?.mediaProfile,
    job?.media_profile,
    encodePlan?.mediaProfile,
    job?.makemkvInfo?.analyzeContext?.mediaProfile,
    job?.makemkvInfo?.mediaProfile,
    job?.mediainfoInfo?.mediaProfile
  ];
  for (const candidate of candidates) {
    const raw = String(candidate || '').trim().toLowerCase();
    if (!raw) {
      continue;
    }
    if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
      return 'bluray';
    }
    if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
      return 'dvd';
    }
    if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
      return 'cd';
    }
    if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
      return 'audiobook';
    }
  }
  const statusCandidates = [
    job?.status,
    job?.last_state,
    job?.makemkvInfo?.lastState
  ];
  if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
    return 'cd';
  }
  const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
  const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
  if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
    return 'cd';
  }
  if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
    return 'cd';
  }
  if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
    return 'cd';
  }
  if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
    return 'audiobook';
  }
  if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
    return 'audiobook';
  }
  return centralMediaType || 'other';
}

function mediaIndicatorMeta(job) {
  const mediaType = resolveMediaType(job);
  const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(job);
  if (mediaType === 'bluray') {
    return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' };
  }
  if (mediaType === 'dvd') {
    return {
      mediaType,
      src: discIndicatorIcon,
      alt: isSeriesDvd ? 'DVD Serie' : 'DVD',
      title: isSeriesDvd ? 'DVD Serie' : 'DVD',
      isSeriesDvd
    };
  }
  if (mediaType === 'cd') {
    return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
  }
  if (mediaType === 'audiobook') {
    return { mediaType, src: audiobookIndicatorIcon, alt: 'Audiobook', title: 'Audiobook' };
  }
  return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
}

function JobStepChecks({ backupSuccess, encodeSuccess }) {
  const hasAny = Boolean(backupSuccess || encodeSuccess);
  if (!hasAny) {
    return null;
  }
  return (
    <div className="job-step-checks">
      {backupSuccess ? (
        <span className="job-step-inline-ok" title="Backup/Rip erfolgreich">
          <i className="pi pi-check-circle" aria-hidden="true" />
          <span>Backup</span>
        </span>
      ) : null}
      {encodeSuccess ? (
        <span className="job-step-inline-ok" title="Encode erfolgreich">
          <i className="pi pi-check-circle" aria-hidden="true" />
          <span>Encode</span>
        </span>
      ) : null}
    </div>
  );
}

function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
  const jobId = normalizeJobId(job?.id);
  const isCurrentSessionJob = Boolean(
    jobId
    && currentPipelineJobId
    && jobId === currentPipelineJobId
    && String(currentPipeline?.state || '').trim().toUpperCase() !== 'IDLE'
  );

  const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
  const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
  const resolvedMediaType = resolveMediaType(job);
  const analyzeContext = getAnalyzeContext(job);
  const normalizePlanIdList = (values) => {
    const list = Array.isArray(values) ? values : [];
    const seen = new Set();
    const output = [];
    for (const value of list) {
      const parsed = Number(value);
      if (!Number.isFinite(parsed) || parsed <= 0) {
        continue;
      }
      const id = Math.trunc(parsed);
      const key = String(id);
      if (seen.has(key)) {
        continue;
      }
      seen.add(key);
      output.push(id);
    }
    return output;
  };
  const buildNamedSelection = (ids, entries, fallbackLabel) => {
    const source = Array.isArray(entries) ? entries : [];
    const namesById = new Map(
      source
        .map((entry) => {
          const id = Number(entry?.id ?? entry?.scriptId ?? entry?.chainId);
          const normalized = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
          const name = String(entry?.name || entry?.scriptName || entry?.chainName || '').trim();
          if (!normalized) {
            return null;
          }
          return [normalized, name || null];
        })
        .filter(Boolean)
    );
    return ids.map((id) => ({
      id,
      name: namesById.get(id) || `${fallbackLabel} #${id}`
    }));
  };
  const planPreScriptIds = normalizePlanIdList([
    ...(Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : []),
    ...(Array.isArray(encodePlan?.preEncodeScripts) ? encodePlan.preEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
  ]);
  const planPostScriptIds = normalizePlanIdList([
    ...(Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : []),
    ...(Array.isArray(encodePlan?.postEncodeScripts) ? encodePlan.postEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
  ]);
  const planPreChainIds = normalizePlanIdList([
    ...(Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []),
    ...(Array.isArray(encodePlan?.preEncodeChains) ? encodePlan.preEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
  ]);
  const planPostChainIds = normalizePlanIdList([
    ...(Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []),
    ...(Array.isArray(encodePlan?.postEncodeChains) ? encodePlan.postEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
  ]);
  const cdRipConfig = encodePlan && typeof encodePlan === 'object'
    ? {
      format: String(encodePlan?.format || '').trim().toLowerCase() || null,
      formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
        ? encodePlan.formatOptions
        : {},
      selectedTracks: Array.isArray(encodePlan?.selectedTracks)
        ? encodePlan.selectedTracks
          .map((value) => Number(value))
          .filter((value) => Number.isFinite(value) && value > 0)
          .map((value) => Math.trunc(value))
        : [],
      preEncodeScriptIds: planPreScriptIds,
      postEncodeScriptIds: planPostScriptIds,
      preEncodeChainIds: planPreChainIds,
      postEncodeChainIds: planPostChainIds,
      preEncodeScripts: buildNamedSelection(planPreScriptIds, encodePlan?.preEncodeScripts, 'Skript'),
      postEncodeScripts: buildNamedSelection(planPostScriptIds, encodePlan?.postEncodeScripts, 'Skript'),
      preEncodeChains: buildNamedSelection(planPreChainIds, encodePlan?.preEncodeChains, 'Kette'),
      postEncodeChains: buildNamedSelection(planPostChainIds, encodePlan?.postEncodeChains, 'Kette'),
      outputTemplate: String(encodePlan?.outputTemplate || '').trim() || null
    }
    : null;
  const cdTracks = Array.isArray(makemkvInfo?.tracks)
    ? makemkvInfo.tracks
      .map((track) => {
        const position = Number(track?.position);
        if (!Number.isFinite(position) || position <= 0) {
          return null;
        }
        return {
          ...track,
          position: Math.trunc(position),
          selected: track?.selected !== false
        };
      })
      .filter(Boolean)
    : [];
  const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
    ? makemkvInfo.selectedMetadata
    : {};
  const fallbackCdArtist = cdTracks
    .map((track) => String(track?.artist || '').trim())
    .find(Boolean) || null;
  const resolvedCdMbId = String(
    cdSelectedMeta?.mbId
    || cdSelectedMeta?.musicBrainzId
    || cdSelectedMeta?.musicbrainzId
    || cdSelectedMeta?.mbid
    || ''
  ).trim() || null;
  const resolvedCdCoverUrl = String(
    cdSelectedMeta?.coverUrl
    || cdSelectedMeta?.poster
    || cdSelectedMeta?.posterUrl
    || job?.poster_url
    || ''
  ).trim() || null;
  const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
  const devicePath = String(job?.disc_device || '').trim() || null;
  const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
    ? Number(encodePlan.selectedTracks[0])
    : null;
  const fallbackTrack = cdTracks[0]?.position ? Number(cdTracks[0].position) : null;
  const previewTrackPos = Number.isFinite(firstConfiguredTrack) && firstConfiguredTrack > 0
    ? Math.trunc(firstConfiguredTrack)
    : (Number.isFinite(fallbackTrack) && fallbackTrack > 0 ? Math.trunc(fallbackTrack) : null);
  const previewWavPath = previewTrackPos && job?.raw_path
    ? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
    : '<temp>/trackNN.cdda.wav';
  const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
  const audiobookSelectedMeta = {
    ...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
      ? makemkvInfo.selectedMetadata
      : {}),
    ...(encodePlan?.metadata && typeof encodePlan.metadata === 'object'
      ? encodePlan.metadata
      : {})
  };
  const selectedMetadata = resolvedMediaType === 'audiobook'
    ? {
      title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null,
      author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
      narrator: audiobookSelectedMeta?.narrator || null,
      description: audiobookSelectedMeta?.description || null,
      series: audiobookSelectedMeta?.series || null,
      part: audiobookSelectedMeta?.part || null,
      year: audiobookSelectedMeta?.year ?? job?.year ?? null,
      chapters: Array.isArray(audiobookSelectedMeta?.chapters) ? audiobookSelectedMeta.chapters : [],
      durationMs: audiobookSelectedMeta?.durationMs || 0,
      poster: audiobookSelectedMeta?.poster || job?.poster_url || null
    }
    : {
      title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
      artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
      year: cdSelectedMeta?.year ?? job?.year ?? null,
      mbId: resolvedCdMbId,
      coverUrl: resolvedCdCoverUrl,
      imdbId: job?.imdb_id || null,
      poster: job?.poster_url || resolvedCdCoverUrl || null,
      metadataProvider: analyzeContext?.metadataProvider || 'omdb',
      providerId: analyzeContext?.selectedMetadata?.providerId || null,
      tmdbId: analyzeContext?.selectedMetadata?.tmdbId || null,
      metadataKind: analyzeContext?.selectedMetadata?.metadataKind || null,
      seasonNumber: analyzeContext?.selectedMetadata?.seasonNumber || analyzeContext?.seriesLookupHint?.seasonNumber || null,
      seasonName: analyzeContext?.selectedMetadata?.seasonName || null,
      episodeCount: analyzeContext?.selectedMetadata?.episodeCount || 0,
      episodes: Array.isArray(analyzeContext?.selectedMetadata?.episodes) ? analyzeContext.selectedMetadata.episodes : [],
      discNumber: analyzeContext?.selectedMetadata?.discNumber || analyzeContext?.seriesLookupHint?.discNumber || null
    };
  const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
  const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
  const inputPath = isPreRip
    ? null
    : (job?.encode_input_path || encodePlan?.encodeInputPath || null);
  const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
  const hasEncodableTitle = isPreRip
    ? Boolean(encodePlan?.encodeInputTitleId)
    : Boolean(inputPath || job?.raw_path);
  const jobStatus = String(job?.status || job?.last_state || 'IDLE').trim().toUpperCase() || 'IDLE';
  const lastState = String(job?.last_state || '').trim().toUpperCase();
  const errorText = String(job?.error_message || '').trim().toUpperCase();
  const hasOutputPath = Boolean(String(job?.output_path || '').trim());
  const hasEncodePlan = Boolean(encodePlan && Array.isArray(encodePlan?.titles) && encodePlan.titles.length > 0);
  const looksLikeCancelledEncode = (jobStatus === 'ERROR' || jobStatus === 'CANCELLED') && (
    (errorText.includes('ABGEBROCHEN') || errorText.includes('CANCELLED'))
    && (hasOutputPath || Boolean(job?.encode_input_path) || Boolean(job?.handbrakeInfo))
  );
  const looksLikeEncodingError = (jobStatus === 'ERROR' || jobStatus === 'CANCELLED') && (
    errorText.includes('ENCODING')
    || errorText.includes('HANDBRAKE')
    || lastState === 'ENCODING'
    || Boolean(job?.handbrakeInfo)
    || looksLikeCancelledEncode
  );
  const canRestartEncodeFromLastSettings = Boolean(
    hasEncodePlan
    && reviewConfirmed
    && hasEncodableTitle
    && (
      jobStatus === 'READY_TO_ENCODE'
      || jobStatus === 'ENCODING'
      || jobStatus === 'CANCELLED'
      || looksLikeEncodingError
    )
  );
  const canRestartReviewFromRaw = Boolean(
    job?.raw_path
    && !processingStates.includes(jobStatus)
    && resolvedMediaType !== 'audiobook'
  );
  const cdSkipRipReady = Boolean(
    resolvedMediaType === 'cd'
    && (
      Boolean(encodePlan?.skipRip)
      || (
        Number(job?.rip_successful || 0) === 1
        && Boolean(String(job?.raw_path || '').trim())
      )
    )
  );
  const computedContext = {
    jobId,
    rawPath: job?.raw_path || null,
    outputPath: job?.output_path || null,
    detectedTitle: job?.detected_title || null,
    mediaProfile: resolvedMediaType,
    lastState,
    devicePath,
    cdparanoiaCmd,
    cdparanoiaCommandPreview,
    skipRip: cdSkipRipReady ? true : undefined,
    cdRipConfig,
    tracks: cdTracks,
    inputPath,
    hasEncodableTitle,
    reviewConfirmed,
    mode,
    sourceJobId: encodePlan?.sourceJobId || null,
    selectedMetadata,
    audiobookConfig: resolvedMediaType === 'audiobook'
      ? {
        format: String(encodePlan?.format || '').trim().toLowerCase() || 'mp3',
        formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
          ? encodePlan.formatOptions
          : {}
      }
      : null,
    mediaInfoReview: encodePlan,
    playlistAnalysis: analyzeContext.playlistAnalysis || null,
    playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
    playlistCandidates: Array.isArray(analyzeContext?.playlistAnalysis?.evaluatedCandidates)
      ? analyzeContext.playlistAnalysis.evaluatedCandidates
      : [],
    selectedPlaylist: analyzeContext.selectedPlaylist || null,
    selectedTitleId: analyzeContext.selectedTitleId ?? null,
    metadataProvider: analyzeContext.metadataProvider || 'omdb',
    metadataCandidates: Array.isArray(analyzeContext.metadataCandidates) ? analyzeContext.metadataCandidates : [],
    seriesAnalysis: analyzeContext.seriesAnalysis || null,
    seriesLookupHint: analyzeContext.seriesLookupHint || null,
    omdbCandidates: [],
    canRestartEncodeFromLastSettings,
    canRestartReviewFromRaw
  };

  if (isCurrentSessionJob) {
    const existingContext = currentPipeline?.context && typeof currentPipeline.context === 'object'
      ? currentPipeline.context
      : {};
    const mergedCurrentContext = {
      ...computedContext,
      ...existingContext,
      rawPath: existingContext.rawPath || computedContext.rawPath,
      outputPath: existingContext.outputPath || computedContext.outputPath,
      tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
        ? existingContext.tracks
        : computedContext.tracks,
      selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
      canRestartEncodeFromLastSettings:
        existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
      canRestartReviewFromRaw:
        existingContext.canRestartReviewFromRaw ?? computedContext.canRestartReviewFromRaw
    };
    // The card always represents `job.id`; never let stale live context override it.
    mergedCurrentContext.jobId = jobId;
    return {
      ...currentPipeline,
      context: mergedCurrentContext
    };
  }

  // Use live per-job progress from the backend if available (concurrent jobs).
  const liveJobProgress = currentPipeline?.jobProgress && jobId
    ? (currentPipeline.jobProgress[jobId] || null)
    : null;
  const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
    ? liveJobProgress.context
    : null;
  const liveState = String(liveJobProgress?.state || '').trim().toUpperCase();
  const isTerminalJobStatus = jobStatus === 'FINISHED' || jobStatus === 'ERROR' || jobStatus === 'CANCELLED';
  const ignoreStaleLiveProgress = isTerminalJobStatus && processingStates.includes(liveState);
  const mergedContext = liveContext
    ? {
      ...computedContext,
      ...liveContext,
      // Prefer the DB plan when it has been confirmed but the live (jobProgress) plan hasn't —
      // this happens when confirmEncodeReview is called with skipPipelineStateUpdate=true
      // (always the case for queued jobs), leaving jobProgress with the pre-confirmation plan.
      mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !liveContext.mediaInfoReview?.reviewConfirmedAt)
        ? computedContext.mediaInfoReview
        : (liveContext.mediaInfoReview || computedContext.mediaInfoReview),
      tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
        ? liveContext.tracks
        : computedContext.tracks,
      selectedMetadata: liveContext.selectedMetadata || computedContext.selectedMetadata,
      cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
    }
    : computedContext;
  // The card always represents `job.id`; never let stale live context override it.
  mergedContext.jobId = jobId;

  return {
    state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
    activeJobId: jobId,
    progress: ignoreStaleLiveProgress
      ? 0
      : (liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0),
    eta: ignoreStaleLiveProgress ? null : (liveJobProgress?.eta || null),
    statusText: ignoreStaleLiveProgress
      ? (job?.error_message || null)
      : (liveJobProgress?.statusText || job?.error_message || null),
    context: mergedContext
  };
}

export default function RipperPage({
  pipeline,
  hardwareMonitoring,
  lastDiscEvent,
  refreshPipeline,
  jobsRefreshToken,
  pendingExpandedJobId,
  onPendingExpandedJobHandled,
  downloadSummary = null,
  children = null
}) {
  const location = useLocation();
  const navigate = useNavigate();
  const [busy, setBusy] = useState(false);
  const [busyJobIds, setBusyJobIds] = useState(() => new Set());
  const setJobBusy = (jobId, isBusy) => {
    setBusyJobIds((prev) => {
      const next = new Set(prev);
      if (isBusy) {
        next.add(jobId);
      } else {
        next.delete(jobId);
      }
      return next;
    });
  };
  const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
  const [metadataDialogContext, setMetadataDialogContext] = useState(null);
  const [metadataDialogReassignMode, setMetadataDialogReassignMode] = useState(false);
  const [duplicateJobDialog, setDuplicateJobDialog] = useState({ visible: false, existingJob: null, pendingPayload: null });
  const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
  const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
  const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
  const [cancelCleanupDialog, setCancelCleanupDialog] = useState({
    visible: false,
    jobId: null,
    target: null,
    path: null
  });
  const [cancelCleanupBusy, setCancelCleanupBusy] = useState(false);
  const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue));
  const [queueReorderBusy, setQueueReorderBusy] = useState(false);
  const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null);
  const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null });
  const [runtimeActivities, setRuntimeActivities] = useState(() => normalizeRuntimeActivitiesPayload(null));
  const [runtimeLoading, setRuntimeLoading] = useState(false);
  const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set());
  const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false);
  const [jobsLoading, setJobsLoading] = useState(false);
  const [ripperJobs, setRipperJobs] = useState([]);
  const [expandedJobId, setExpandedJobId] = useState(undefined);
  const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
  const [activationBytesInput, setActivationBytesInput] = useState('');
  const [activationBytesBusy, setActivationBytesBusy] = useState(false);
  const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set());
  const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
  const [knownDrives, setKnownDrives] = useState([]);
  const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set());
  const [driveAnalyzeBusy, setDriveAnalyzeBusy] = useState(() => new Set());
  const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
  const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
  const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
  const toastRef = useRef(null);

  const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
  const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
  const isProcessing = processingStates.includes(state);
  const monitoringState = useMemo(
    () => normalizeHardwareMonitoringPayload(hardwareMonitoring),
    [hardwareMonitoring]
  );
  const cdDriveLifecycleKey = useMemo(
    () => buildCdDriveLifecycleKey(pipeline?.cdDrives),
    [pipeline?.cdDrives]
  );
  // Detects when a background job reaches a terminal interactive state via PIPELINE_PROGRESS
  // (e.g. READY_TO_ENCODE or WAITING_FOR_USER_DECISION), triggering a ripper jobs reload.
  const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {})
    .filter(([, v]) => {
      const s = String(v?.state || '').toUpperCase();
      return s === 'READY_TO_ENCODE' || s === 'WAITING_FOR_USER_DECISION';
    })
    .map(([id]) => id)
    .sort()
    .join(','),
  [pipeline?.jobProgress]);
  const monitoringSample = monitoringState.sample;
  const cpuMetrics = monitoringSample?.cpu || null;
  const memoryMetrics = monitoringSample?.memory || null;
  const gpuMetrics = monitoringSample?.gpu || null;
  const storageMetrics = Array.isArray(monitoringSample?.storage) ? monitoringSample.storage : [];
  const storageGroups = useMemo(() => {
    // Phase 1: group by mountPoint
    const phase1 = [];
    const mountMap = new Map();
    for (const entry of storageMetrics) {
      const groupKey = entry?.mountPoint || `__no_mount_${entry?.key}`;
      if (!mountMap.has(groupKey)) {
        const group = { mountPoint: entry?.mountPoint || null, entries: [], representative: entry };
        mountMap.set(groupKey, group);
        phase1.push(group);
      }
      mountMap.get(groupKey).entries.push(entry);
    }

    // Phase 2: merge groups with identical disk signature (same physical disk)
    const merged = [];
    const diskSigMap = new Map();
    for (const group of phase1) {
      const totalBytes = Number(group.representative?.totalBytes);
      const freeBytes = Number(group.representative?.freeBytes);
      if (Number.isFinite(totalBytes) && totalBytes > 0 && Number.isFinite(freeBytes)) {
        const sig = `${totalBytes}:${freeBytes}`;
        if (diskSigMap.has(sig)) {
          diskSigMap.get(sig).entries.push(...group.entries);
        } else {
          diskSigMap.set(sig, group);
          merged.push(group);
        }
      } else {
        merged.push(group);
      }
    }
    return merged;
  }, [storageMetrics]);
  const cpuPerCoreMetrics = Array.isArray(cpuMetrics?.perCore) ? cpuMetrics.perCore : [];
  const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
  const isRipperMainRoute = location.pathname === '/' || location.pathname === '/ripper';
  const isSubpageRoute = !isRipperMainRoute;

  const loadRipperJobs = async () => {
    setJobsLoading(true);
    try {
      const [jobsResponse, queueResponse] = await Promise.allSettled([
        api.getJobs({
          statuses: Array.from(ripperStatuses),
          limit: 160,
          lite: true,
          includeChildren: true
        }),
        api.getPipelineQueue()
      ]);
      const allJobs = jobsResponse.status === 'fulfilled'
        ? (Array.isArray(jobsResponse.value?.jobs) ? jobsResponse.value.jobs : [])
        : [];
      if (queueResponse.status === 'fulfilled') {
        setQueueState(normalizeQueue(queueResponse.value?.queue));
      }
      const shouldDisplayOnRipper = (job) => {
        if (isConverterJob(job)) {
          return false;
        }
        if (String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container') {
          return false;
        }
        const normalizedStatus = String(job?.status || '').trim().toUpperCase();
        if (!ripperStatuses.has(normalizedStatus)) {
          return false;
        }
        if (normalizedStatus !== 'CANCELLED') {
          return true;
        }
        const cancelledOrigin = String(job?.last_state || '').trim().toUpperCase();
        return !hiddenCancelledReviewOrigins.has(cancelledOrigin);
      };
      const next = allJobs
        .filter((job) => shouldDisplayOnRipper(job))
        .sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));

      const pinnedJobIds = new Set();
      if (currentPipelineJobId) {
        pinnedJobIds.add(currentPipelineJobId);
      }
      for (const driveState of Object.values(pipeline?.cdDrives || {})) {
        const driveJobId = normalizeJobId(driveState?.jobId);
        if (driveJobId) {
          pinnedJobIds.add(driveJobId);
        }
      }
      const missingPinnedJobIds = Array.from(pinnedJobIds)
        .filter((jobId) => !next.some((job) => normalizeJobId(job?.id) === jobId));
      if (missingPinnedJobIds.length > 0) {
        const pinnedResults = await Promise.allSettled(
          missingPinnedJobIds.map((jobId) => api.getJob(jobId, { lite: true, forceRefresh: true }))
        );
        for (const result of pinnedResults) {
          if (result.status !== 'fulfilled') {
            continue;
          }
          const job = result.value?.job;
          if (job && shouldDisplayOnRipper(job)) {
            next.unshift(job);
          }
        }
      }

      const seen = new Set();
      const deduped = [];
      for (const job of next) {
        const id = normalizeJobId(job?.id);
        if (!id || seen.has(String(id))) {
          continue;
        }
        seen.add(String(id));
        deduped.push(job);
      }
      const filteredJobs = deduped.filter((job) => {
        const isSeriesBatchChild = Boolean(job?.encodePlan?.seriesBatchChild);
        return !isSeriesBatchChild;
      });

      setRipperJobs(filteredJobs);

      // Prüfen ob Audiobook-Jobs auf Activation Bytes warten
      try {
        const { pending } = await api.getPendingActivation();
        if (Array.isArray(pending) && pending.length > 0) {
          setPendingActivationJobIds(new Set(pending.map((p) => p.jobId)));
          // Modal automatisch öffnen wenn noch nicht sichtbar
          setActivationBytesDialog((prev) => {
            if (prev.visible) return prev;
            const first = pending[0];
            setActivationBytesInput('');
            return { visible: true, checksum: first.checksum, jobId: first.jobId };
          });
        } else {
          setPendingActivationJobIds(new Set());
        }
      } catch (_err) {
        // ignorieren
      }
    } catch (_error) {
      setRipperJobs([]);
    } finally {
      setJobsLoading(false);
    }
  };

  useEffect(() => {
    if (!metadataDialogVisible) {
      return;
    }
    if (metadataDialogContext?.jobId) {
      return;
    }
    if (pipeline?.state !== 'METADATA_SELECTION' && pipeline?.state !== 'WAITING_FOR_USER_DECISION') {
      setMetadataDialogVisible(false);
    }
  }, [pipeline?.state, metadataDialogVisible, metadataDialogContext?.jobId]);

  // Auto-open CD metadata dialog when any drive enters CD_METADATA_SELECTION
  useEffect(() => {
    const cdDrives = pipeline?.cdDrives;
    if (!cdDrives || typeof cdDrives !== 'object') return;
    for (const drive of Object.values(cdDrives)) {
      const driveState = String(drive?.state || '').trim().toUpperCase();
      const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null;
      if (driveState === 'CD_METADATA_SELECTION' && ctx?.jobId && !cdMetadataDialogVisible) {
        setCdMetadataDialogContext(ctx);
        setCdMetadataDialogVisible(true);
        break;
      }
      if (driveState === 'CD_READY_TO_RIP' && ctx?.jobId) {
        setCdRipPanelJobId(ctx.jobId);
      }
    }
  }, [pipeline?.cdDrives]);

  useEffect(() => {
    if (!cdMetadataDialogVisible) {
      return;
    }
    const dialogJobId = normalizeJobId(cdMetadataDialogContext?.jobId);
    if (!dialogJobId) {
      return;
    }
    const cdDrives = pipeline?.cdDrives;
    if (!cdDrives || typeof cdDrives !== 'object') {
      return;
    }

    let stillInMetadataSelection = false;
    for (const drive of Object.values(cdDrives)) {
      const driveState = String(drive?.state || '').trim().toUpperCase();
      const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null;
      const driveJobId = normalizeJobId(ctx?.jobId || drive?.jobId);
      if (driveJobId === dialogJobId && driveState === 'CD_METADATA_SELECTION') {
        stillInMetadataSelection = true;
        break;
      }
    }

    if (!stillInMetadataSelection) {
      setCdMetadataDialogVisible(false);
      setCdMetadataDialogContext(null);
    }
  }, [cdMetadataDialogVisible, cdMetadataDialogContext?.jobId, pipeline?.cdDrives]);

  useEffect(() => {
    setQueueState(normalizeQueue(pipeline?.queue));
  }, [pipeline?.queue]);

  useEffect(() => {
    void loadRipperJobs();
  }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveKey]);

  useEffect(() => {
    api.getDetectedDrives()
      .then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
      .catch(() => {});
  }, []);

  useEffect(() => {
    const requestedJobId = normalizeJobId(pendingExpandedJobId);
    if (!requestedJobId) {
      return;
    }
    const hasRequestedJob = ripperJobs.some((job) => normalizeJobId(job?.id) === requestedJobId);
    if (!hasRequestedJob) {
      return;
    }
    setExpandedJobId(requestedJobId);
    onPendingExpandedJobHandled?.(requestedJobId);
  }, [pendingExpandedJobId, ripperJobs, onPendingExpandedJobHandled]);

  useEffect(() => {
    let cancelled = false;
    const load = async (silent = false) => {
      try {
        const response = await api.getRuntimeActivities();
        if (!cancelled) {
          setRuntimeActivities(normalizeRuntimeActivitiesPayload(response));
          if (!silent) {
            setRuntimeLoading(false);
          }
        }
      } catch (_error) {
        if (!cancelled && !silent) {
          setRuntimeActivities(normalizeRuntimeActivitiesPayload(null));
          setRuntimeLoading(false);
        }
      }
    };
    setRuntimeLoading(true);
    void load(false);
    const interval = setInterval(() => {
      void load(true);
    }, 10000);
    return () => {
      cancelled = true;
      clearInterval(interval);
    };
  }, []);

  useWebSocket({
    onMessage: (message) => {
      if (message?.type !== 'RUNTIME_ACTIVITY_CHANGED') {
        return;
      }
      setRuntimeActivities(normalizeRuntimeActivitiesPayload(message.payload));
      setRuntimeLoading(false);
    }
  });

  useEffect(() => {
    const normalizedExpanded = normalizeJobId(expandedJobId);
    const hasExpanded = ripperJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
    if (hasExpanded) {
      return;
    }

    // Respect explicit user collapse.
    if (expandedJobId === null) {
      return;
    }

    if (currentPipelineJobId && ripperJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
      setExpandedJobId(currentPipelineJobId);
      return;
    }
    setExpandedJobId(normalizeJobId(ripperJobs[0]?.id));
  }, [ripperJobs, expandedJobId, currentPipelineJobId]);

  const pipelineByJobId = useMemo(() => {
    const map = new Map();
    for (const job of ripperJobs) {
      const id = normalizeJobId(job?.id);
      if (!id) {
        continue;
      }
      map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId));
    }
    return map;
  }, [ripperJobs, pipeline, currentPipelineJobId]);

  const buildMetadataContextForJob = (jobId) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (!normalizedJobId) {
      return null;
    }
    const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
    const pipelineForJob = pipelineByJobId.get(normalizedJobId) || null;
    const context = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
      ? pipelineForJob.context
      : {};
    const selectedMetadata = context.selectedMetadata && typeof context.selectedMetadata === 'object'
      ? context.selectedMetadata
      : {
        title: job?.title || job?.detected_title || context?.detectedTitle || '',
        year: job?.year || null,
        imdbId: job?.imdb_id || null,
        poster: job?.poster_url || null,
        metadataProvider: context?.metadataProvider || 'omdb',
        seasonNumber: null,
        discNumber: null
      };
    return {
      ...context,
      jobId: normalizedJobId,
      detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '',
      selectedMetadata,
      metadataProvider: context?.metadataProvider || selectedMetadata?.metadataProvider || 'omdb',
      metadataCandidates: Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [],
      seriesAnalysis: context?.seriesAnalysis || null,
      seriesLookupHint: context?.seriesLookupHint || null,
      omdbCandidates: Array.isArray(context?.omdbCandidates) ? context.omdbCandidates : []
    };
  };

  const defaultMetadataDialogContext = useMemo(() => {
    const currentState = String(pipeline?.state || '').trim().toUpperCase();
    const currentContext = pipeline?.context && typeof pipeline.context === 'object'
      ? pipeline.context
      : null;
    const currentContextJobId = normalizeJobId(currentContext?.jobId);
    if (
      (currentState === 'METADATA_SELECTION' || currentState === 'WAITING_FOR_USER_DECISION')
      && currentContextJobId
    ) {
      return {
        ...currentContext,
        jobId: currentContextJobId,
        selectedMetadata: currentContext?.selectedMetadata || {
          title: currentContext?.detectedTitle || '',
          year: null,
          imdbId: null,
          poster: null,
          metadataProvider: currentContext?.metadataProvider || 'omdb',
          seasonNumber: null,
          discNumber: null
        },
        metadataProvider: currentContext?.metadataProvider || currentContext?.selectedMetadata?.metadataProvider || 'omdb',
        metadataCandidates: Array.isArray(currentContext?.metadataCandidates) ? currentContext.metadataCandidates : [],
        seriesAnalysis: currentContext?.seriesAnalysis || null,
        seriesLookupHint: currentContext?.seriesLookupHint || null,
        omdbCandidates: Array.isArray(currentContext?.omdbCandidates) ? currentContext.omdbCandidates : []
      };
    }

    const pendingJob = ripperJobs.find((job) => {
      const normalized = normalizeStatus(job?.status);
      return normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION';
    });
    if (!pendingJob) {
      return null;
    }
    return buildMetadataContextForJob(pendingJob.id);
  }, [pipeline, ripperJobs, pipelineByJobId]);

  const effectiveMetadataDialogContext = metadataDialogContext
    || defaultMetadataDialogContext
    || pipeline?.context
    || {};

  const showError = (error) => {
    toastRef.current?.show({
      severity: 'error',
      summary: 'Fehler',
      detail: error.message,
      life: 4500
    });
  };

  const handleOpenMetadataDialog = (jobId = null) => {
    const context = jobId ? buildMetadataContextForJob(jobId) : defaultMetadataDialogContext;
    if (!context?.jobId) {
      showError(new Error('Kein Job mit offener Metadaten-Auswahl gefunden.'));
      return;
    }
    setMetadataDialogReassignMode(false);
    setMetadataDialogContext(context);
    setMetadataDialogVisible(true);
  };

  const handleOpenReassignOmdbDialog = (jobId) => {
    const context = buildMetadataContextForJob(jobId);
    if (!context?.jobId) {
      showError(new Error('Job nicht gefunden.'));
      return;
    }
    setMetadataDialogReassignMode(true);
    setMetadataDialogContext(context);
    setMetadataDialogVisible(true);
  };

  const handleAnalyze = async () => {
    setBusy(true);
    try {
      const response = await api.analyzeDisc();
      await refreshPipeline();
      await loadRipperJobs();
      const analyzedJobId = normalizeJobId(response?.result?.jobId);
      if (analyzedJobId) {
        setMetadataDialogContext({
          jobId: analyzedJobId,
          detectedTitle: response?.result?.detectedTitle || '',
          selectedMetadata: {
            title: response?.result?.detectedTitle || '',
            year: null,
            imdbId: null,
            poster: null,
            metadataProvider: response?.result?.metadataProvider || 'omdb',
            seasonNumber: response?.result?.seriesLookupHint?.seasonNumber || null
          },
          metadataProvider: response?.result?.metadataProvider || 'omdb',
          metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
            ? response.result.metadataCandidates
            : [],
          seriesAnalysis: response?.result?.seriesAnalysis || null,
          seriesLookupHint: response?.result?.seriesLookupHint || null,
          omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
            ? response.result.omdbCandidates
            : []
        });
        setMetadataDialogVisible(true);
      }
    } catch (error) {
      showError(error);
    } finally {
      setBusy(false);
    }
  };

  const handleReanalyze = async () => {
    await handleAnalyze();
  };

  const handleAnalyzeForDrive = async (devicePath) => {
    setDriveAnalyzeBusy((prev) => new Set([...prev, devicePath]));
    try {
      const response = await api.analyzeDisc(devicePath);
      await refreshPipeline();
      await loadRipperJobs();
      const analyzedJobId = normalizeJobId(response?.result?.jobId);
      if (analyzedJobId) {
        setMetadataDialogContext({
          jobId: analyzedJobId,
          detectedTitle: response?.result?.detectedTitle || '',
          selectedMetadata: {
            title: response?.result?.detectedTitle || '',
            year: null,
            imdbId: null,
            poster: null,
            metadataProvider: response?.result?.metadataProvider || 'omdb',
            seasonNumber: response?.result?.seriesLookupHint?.seasonNumber || null
          },
          metadataProvider: response?.result?.metadataProvider || 'omdb',
          metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
            ? response.result.metadataCandidates
            : [],
          seriesAnalysis: response?.result?.seriesAnalysis || null,
          seriesLookupHint: response?.result?.seriesLookupHint || null,
          omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
            ? response.result.omdbCandidates
            : []
        });
        setMetadataDialogVisible(true);
      }
    } catch (error) {
      showError(error);
    } finally {
      setDriveAnalyzeBusy((prev) => {
        const next = new Set(prev);
        next.delete(devicePath);
        return next;
      });
    }
  };

  const handleAnalyzeAll = async () => {
    const lockedPaths = new Set(
      (Array.isArray(pipeline?.driveLocks) ? pipeline.driveLocks : [])
        .filter((lock) => Number(lock?.count || 0) > 0)
        .map((lock) => normalizeDrivePath(lock?.path))
        .filter(Boolean)
    );
    const drivesToAnalyze = allDrives
      .filter((drv) => {
        if (lockedPaths.has(normalizeDrivePath(drv.path))) {
          return false;
        }
        const cdState = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null;
        if (cdState) return cdState === 'DISC_DETECTED';
        if (Boolean(drv.pipelineDevice) && String(drv.pipelineState || '').toUpperCase() === 'DISC_DETECTED') return true;
        return Boolean(drv.detectedDisc);
      })
      .map((drv) => drv.path);
    if (drivesToAnalyze.length === 0) return;
    await Promise.all(drivesToAnalyze.map((path) => handleAnalyzeForDrive(path)));
  };

  const handleRescan = async () => {
    setBusy(true);
    try {
      const response = await api.rescanDisc();
      refreshKnownDrives();
      const allDetected = Array.isArray(response?.result?.allDetected) ? response.result.allDetected : [];
      const count = allDetected.length;
      toastRef.current?.show({
        severity: count > 0 ? 'success' : 'info',
        summary: 'Laufwerke neu gelesen',
        detail: count > 0
          ? `${count > 1 ? `${count} Medien` : '1 Medium'} erkannt.`
          : 'Kein Medium erkannt.',
        life: 2800
      });
      await refreshPipeline();
      await loadRipperJobs();
    } catch (error) {
      showError(error);
    } finally {
      setBusy(false);
    }
  };

  const refreshKnownDrives = () => {
    api.getDetectedDrives()
      .then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
      .catch(() => {});
  };

  const handleRescanDrive = async (devicePath) => {
    setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
    try {
      const response = await api.rescanDrive(devicePath);
      refreshKnownDrives();
      const emitted = response?.result?.emitted || 'none';
      toastRef.current?.show({
        severity: emitted === 'discInserted' ? 'success' : 'info',
        summary: devicePath,
        detail: emitted === 'discInserted'
          ? 'Medium erkannt.'
          : emitted === 'discRemoved' ? 'Medium entfernt.' : 'Leer.',
        life: 2500
      });
      await refreshPipeline();
    } catch (error) {
      showError(error);
    } finally {
      setDriveRescanBusy((prev) => {
        const next = new Set(prev);
        next.delete(devicePath);
        return next;
      });
    }
  };

  const handleCancel = async (jobId = null, jobState = null) => {
    const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId;
    const cancelledJob = ripperJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
    const cancelledState = String(
      jobState
      || cancelledJob?.status
      || state
      || 'IDLE'
    ).trim().toUpperCase();

    if (cancelledJobId) setJobBusy(cancelledJobId, true);
    try {
      await api.cancelPipeline(cancelledJobId);
      await refreshPipeline();
      await loadRipperJobs();
      let latestCancelledJob = null;
      const fetchLatestCancelledJob = async () => {
        if (!cancelledJobId) {
          return null;
        }
        try {
          const latestResponse = await api.getJob(cancelledJobId, { lite: true });
          return latestResponse?.job && typeof latestResponse.job === 'object'
            ? latestResponse.job
            : null;
        } catch (_error) {
          return null;
        }
      };
      latestCancelledJob = await fetchLatestCancelledJob();
      if (cancelledState === 'ENCODING') {
        const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
        for (let attempt = 0; attempt < 8; attempt += 1) {
          const latestStatus = String(
            latestCancelledJob?.status
            || latestCancelledJob?.last_state
            || ''
          ).trim().toUpperCase();
          if (latestStatus && latestStatus !== 'ENCODING') {
            break;
          }
          await wait(250);
          latestCancelledJob = await fetchLatestCancelledJob();
        }
      }
      const latestStatus = String(
        latestCancelledJob?.status
        || latestCancelledJob?.last_state
        || ''
      ).trim().toUpperCase();
      const autoPreparedForRestart = cancelledState === 'ENCODING' && latestStatus === 'READY_TO_ENCODE';
      if (cancelledState === 'ENCODING' && cancelledJobId && !autoPreparedForRestart) {
        setCancelCleanupDialog({
          visible: true,
          jobId: cancelledJobId,
          target: 'movie',
          path: latestCancelledJob?.output_path || cancelledJob?.output_path || null
        });
      } else if (cancelledState === 'RIPPING' && cancelledJobId) {
        setCancelCleanupDialog({
          visible: true,
          jobId: cancelledJobId,
          target: 'raw',
          path: latestCancelledJob?.raw_path || cancelledJob?.raw_path || null
        });
      }
    } catch (error) {
      showError(error);
    } finally {
      if (cancelledJobId) setJobBusy(cancelledJobId, false);
    }
  };

  const handleDeleteCancelledOutput = async () => {
    const jobId = normalizeJobId(cancelCleanupDialog?.jobId);
    const target = String(cancelCleanupDialog?.target || '').trim().toLowerCase();
    const effectiveTarget = target === 'raw' ? 'raw' : 'movie';
    if (!jobId) {
      setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
      return;
    }

    setCancelCleanupBusy(true);
    try {
      const response = await api.deleteJobFiles(jobId, effectiveTarget);
      const summary = response?.summary || {};
      const targetSummary = effectiveTarget === 'raw'
        ? (summary.raw || {})
        : (summary.movie || {});
      const deletedFiles = targetSummary.filesDeleted ?? 0;
      const removedDirs = targetSummary.dirsRemoved ?? 0;
      const deletedSomething = Boolean(targetSummary.deleted);

      if (!deletedSomething) {
        toastRef.current?.show({
          severity: 'warn',
          summary: effectiveTarget === 'raw' ? 'RAW nicht gelöscht' : 'Movie nicht gelöscht',
          detail: targetSummary.reason || 'Keine passenden Dateien/Ordner gefunden.',
          life: 4200
        });
      } else {
        toastRef.current?.show({
          severity: 'success',
          summary: effectiveTarget === 'raw' ? 'RAW gelöscht' : 'Movie gelöscht',
          detail: `Entfernt: ${deletedFiles} Datei(en), ${removedDirs} Ordner.`,
          life: 4000
        });
      }
      await loadRipperJobs();
      await refreshPipeline();
      setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
    } catch (error) {
      showError(error);
    } finally {
      setCancelCleanupBusy(false);
    }
  };

  const handleStartJob = async (jobId, options = null) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (!normalizedJobId) {
      return;
    }

    const startOptions = options && typeof options === 'object' ? options : {};
    const startJobRow = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
    const mediaType = resolveMediaType(startJobRow);
    setJobBusy(normalizedJobId, true);
    try {
      if (startOptions.ensureConfirmed) {
        const confirmPayload = {
          selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
          selectedEncodeTitleIds: startOptions.selectedEncodeTitleIds ?? null,
          selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
          episodeAssignments: startOptions.episodeAssignments ?? null,
          skipPipelineStateUpdate: true
        };
        if (startOptions.selectedPostEncodeScriptIds !== undefined) {
          confirmPayload.selectedPostEncodeScriptIds = startOptions.selectedPostEncodeScriptIds;
        }
        if (startOptions.selectedPreEncodeScriptIds !== undefined) {
          confirmPayload.selectedPreEncodeScriptIds = startOptions.selectedPreEncodeScriptIds;
        }
        if (startOptions.selectedPostEncodeChainIds !== undefined) {
          confirmPayload.selectedPostEncodeChainIds = startOptions.selectedPostEncodeChainIds;
        }
        if (startOptions.selectedPreEncodeChainIds !== undefined) {
          confirmPayload.selectedPreEncodeChainIds = startOptions.selectedPreEncodeChainIds;
        }
        if (startOptions.selectedUserPresetId !== undefined) {
          confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId;
        }
        if (startOptions.selectedHandBrakePreset !== undefined) {
          confirmPayload.selectedHandBrakePreset = startOptions.selectedHandBrakePreset;
        }
        await api.confirmEncodeReview(normalizedJobId, confirmPayload);
      }
      const response = mediaType === 'audiobook'
        ? await api.startJob(normalizedJobId)
        : await api.startJob(normalizedJobId);
      const result = getQueueActionResult(response);
      await refreshPipeline();
      await loadRipperJobs();
      if (result.queued) {
        showQueuedToast(toastRef, 'Start', result);
      } else {
        setExpandedJobId(normalizedJobId);
      }
    } catch (error) {
      showError(error);
    } finally {
      setJobBusy(normalizedJobId, false);
    }
  };

  const handleSaveActivationBytes = async () => {
    const { checksum, jobId } = activationBytesDialog;
    const bytes = activationBytesInput.trim().toLowerCase();
    setActivationBytesBusy(true);
    try {
      await api.saveActivationBytes(checksum, bytes);
      setPendingActivationJobIds(new Set());
      setActivationBytesDialog({ visible: false, checksum: null, jobId: null });
      toastRef.current?.show({
        severity: 'success',
        summary: 'Activation Bytes gespeichert',
        detail: jobId ? `Job #${jobId} kann jetzt gestartet werden.` : 'Bytes wurden lokal gespeichert.',
        life: 4000
      });
      await loadRipperJobs();
    } catch (error) {
      showError(error);
    } finally {
      setActivationBytesBusy(false);
    }
  };

  const handleAudiobookStart = async (jobId, audiobookConfig) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (!normalizedJobId) {
      return;
    }
    if (pendingActivationJobIds.has(normalizedJobId)) {
      setActivationBytesInput('');
      const pending = await api.getPendingActivation().catch(() => ({ pending: [] }));
      const entry = (pending?.pending || []).find((p) => p.jobId === normalizedJobId);
      if (entry) {
        setActivationBytesDialog({ visible: true, checksum: entry.checksum, jobId: normalizedJobId });
      }
      return;
    }
    setJobBusy(normalizedJobId, true);
    try {
      const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
      const result = getQueueActionResult(response);
      await refreshPipeline();
      await loadRipperJobs();
      if (result.queued) {
        showQueuedToast(toastRef, 'Audiobook', result);
      } else {
        setExpandedJobId(normalizedJobId);
      }
    } catch (error) {
      showError(error);
    } finally {
      setJobBusy(normalizedJobId, false);
    }
  };

  const handleConfirmReview = async (
    jobId,
    selectedEncodeTitleId = null,
    selectedTrackSelection = null,
    selectedPostEncodeScriptIds = undefined,
    selectedUserPresetId = undefined,
    selectedHandBrakePreset = undefined,
    selectedEncodeTitleIds = undefined,
    episodeAssignments = undefined
  ) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (normalizedJobId) setJobBusy(normalizedJobId, true);
    try {
      const payload = {
        selectedEncodeTitleId,
        selectedTrackSelection
      };
      if (selectedEncodeTitleIds !== undefined) {
        payload.selectedEncodeTitleIds = selectedEncodeTitleIds;
      }
      if (selectedPostEncodeScriptIds !== undefined) {
        payload.selectedPostEncodeScriptIds = selectedPostEncodeScriptIds;
      }
      if (episodeAssignments !== undefined) {
        payload.episodeAssignments = episodeAssignments;
      }
      if (selectedUserPresetId !== undefined) {
        payload.selectedUserPresetId = selectedUserPresetId;
      }
      if (selectedHandBrakePreset !== undefined) {
        payload.selectedHandBrakePreset = selectedHandBrakePreset;
      }
      await api.confirmEncodeReview(jobId, payload);
      await refreshPipeline();
      await loadRipperJobs();
      setExpandedJobId(normalizedJobId);
    } catch (error) {
      showError(error);
    } finally {
      if (normalizedJobId) setJobBusy(normalizedJobId, false);
    }
  };

  const handleSelectPlaylist = async (jobId, selectedPlaylist = null) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (normalizedJobId) setJobBusy(normalizedJobId, true);
    try {
      await api.selectMetadata({
        jobId,
        selectedPlaylist: selectedPlaylist || null
      });
      await refreshPipeline();
      await loadRipperJobs();
      setExpandedJobId(normalizedJobId);
    } catch (error) {
      showError(error);
    } finally {
      if (normalizedJobId) setJobBusy(normalizedJobId, false);
    }
  };

  const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleIds = null) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (normalizedJobId) setJobBusy(normalizedJobId, true);
    try {
      const normalizedIds = (Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : [selectedHandBrakeTitleIds])
        .map((value) => Number(value))
        .filter((value) => Number.isFinite(value) && value > 0)
        .map((value) => Math.trunc(value));
      const dedupedIds = Array.from(new Set(normalizedIds));
      await api.selectMetadata({
        jobId,
        selectedHandBrakeTitleId: dedupedIds[0] || null,
        selectedHandBrakeTitleIds: dedupedIds
      });
      await refreshPipeline();
      await loadRipperJobs();
      setExpandedJobId(normalizedJobId);
    } catch (error) {
      showError(error);
    } finally {
      if (normalizedJobId) setJobBusy(normalizedJobId, false);
    }
  };

  const handleRetry = async (jobId) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (normalizedJobId) setJobBusy(normalizedJobId, true);
    try {
      const response = await api.retryJob(jobId);
      const result = getQueueActionResult(response);
      const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
      await refreshPipeline();
      await loadRipperJobs();
      if (result.queued) {
        showQueuedToast(toastRef, 'Retry', result);
      } else {
        setExpandedJobId(retryJobId);
      }
    } catch (error) {
      showError(error);
    } finally {
      if (normalizedJobId) setJobBusy(normalizedJobId, false);
    }
  };

  const handleDeleteRipperJob = async (jobId) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (!normalizedJobId) {
      return;
    }
    const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
    const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
    const confirmed = await confirmModal({
      header: 'Job löschen',
      message:
      `Job #${normalizedJobId} wirklich löschen?\n` +
      `${title}\n\n` +
      'Hinweis: Dateien bleiben erhalten. Das zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.',
      acceptLabel: 'Löschen',
      rejectLabel: 'Abbrechen',
      danger: true
    });
    if (!confirmed) {
      return;
    }

    setJobBusy(normalizedJobId, true);
    try {
      const statusBeforeDelete = String(job?.status || '').trim().toUpperCase();
      if (processingStates.includes(statusBeforeDelete)) {
        await api.cancelPipeline(normalizedJobId);
        const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
        for (let attempt = 0; attempt < 12; attempt += 1) {
          const latest = await api.getJob(normalizedJobId, { lite: true, forceRefresh: true }).catch(() => null);
          const latestStatus = String(latest?.job?.status || latest?.job?.last_state || '').trim().toUpperCase();
          if (!processingStates.includes(latestStatus)) {
            break;
          }
          await wait(250);
        }
      }
      await api.deleteJobEntry(normalizedJobId, 'none', {
        includeRelated: false,
        resetDriveState: true,
        keepDetectedDevice: false
      });
      await refreshPipeline();
      await loadRipperJobs();
      setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev));
      toastRef.current?.show({
        severity: 'success',
        summary: 'Job gelöscht',
        detail: `Job #${normalizedJobId} wurde entfernt.`,
        life: 3200
      });
    } catch (error) {
      showError(error);
    } finally {
      setJobBusy(normalizedJobId, false);
    }
  };

  const handleRestartEncodeWithLastSettings = async (jobId, restartOptions = {}) => {
    const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
    const title = job?.title || job?.detected_title || `Job #${jobId}`;
    const restartMode = String(restartOptions?.restartMode || 'all').trim().toLowerCase() === 'from_abort'
      ? 'from_abort'
      : 'all';
    if (job?.encodeSuccess) {
      const confirmed = await confirmModal({
        header: 'Encode neu starten',
        message:
        `Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
        'Es wird eine neue Datei mit Kollisionsprüfung angelegt.',
        acceptLabel: 'Neu encodieren',
        rejectLabel: 'Abbrechen'
      });
      if (!confirmed) {
        return;
      }
    }

    const normalizedJobId = normalizeJobId(jobId);
    if (normalizedJobId) setJobBusy(normalizedJobId, true);
    try {
      const response = await api.restartEncodeWithLastSettings(jobId, { restartMode });
      const result = getQueueActionResult(response);
      const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
      await refreshPipeline();
      await loadRipperJobs();
      if (result.queued) {
        showQueuedToast(toastRef, 'Encode-Neustart', result);
      } else {
        setExpandedJobId(replacementJobId);
      }
    } catch (error) {
      showError(error);
    } finally {
      if (normalizedJobId) setJobBusy(normalizedJobId, false);
    }
  };

  const handleRestartReviewFromRaw = async (jobId) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (!normalizedJobId) {
      return;
    }

    setJobBusy(normalizedJobId, true);
    try {
      const response = await api.restartReviewFromRaw(normalizedJobId, { reuseCurrentJob: true });
      const result = getQueueActionResult(response);
      const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
      await refreshPipeline();
      await loadRipperJobs();
      setExpandedJobId(replacementJobId);
    } catch (error) {
      showError(error);
    } finally {
      setJobBusy(normalizedJobId, false);
    }
  };

  const handleRestartCdReviewFromRaw = async (jobId) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (!normalizedJobId) {
      return;
    }

    setJobBusy(normalizedJobId, true);
    try {
      const response = await api.restartCdReviewFromRaw(normalizedJobId);
      const result = getQueueActionResult(response);
      const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
      await refreshPipeline();
      await loadRipperJobs();
      if (result.queued) {
        showQueuedToast(toastRef, 'CD-Vorprüfung', result);
      } else {
        setExpandedJobId(replacementJobId);
      }
    } catch (error) {
      showError(error);
    } finally {
      setJobBusy(normalizedJobId, false);
    }
  };

  const handleQueueDragEnter = (targetEntryId) => {
    const targetId = Number(targetEntryId);
    const draggedId = Number(draggingQueueEntryId);
    if (!targetId || !draggedId || targetId === draggedId || queueReorderBusy) {
      return;
    }
    setQueueState((prev) => {
      const queuedJobs = reorderQueuedItems(prev?.queuedJobs || [], draggedId, targetId);
      return {
        ...normalizeQueue(prev),
        queuedJobs,
        queuedCount: queuedJobs.length
      };
    });
  };

  const handleQueueDrop = async () => {
    const draggedId = Number(draggingQueueEntryId);
    setDraggingQueueEntryId(null);
    if (!draggedId || queueReorderBusy) {
      return;
    }

    const orderedEntryIds = (Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [])
      .map((item) => Number(item?.entryId))
      .filter(Boolean);
    if (orderedEntryIds.length <= 1) {
      return;
    }

    setQueueReorderBusy(true);
    try {
      const response = await api.reorderPipelineQueue(orderedEntryIds);
      setQueueState(normalizeQueue(response?.queue));
    } catch (error) {
      showError(error);
      try {
        const latest = await api.getPipelineQueue();
        setQueueState(normalizeQueue(latest?.queue));
      } catch (_reloadError) {
        // ignore reload failures after reorder error
      }
    } finally {
      setQueueReorderBusy(false);
    }
  };

  const handleRemoveQueuedJob = async (jobId) => {
    const normalizedJobId = normalizeJobId(jobId);
    if (!normalizedJobId || queueReorderBusy) {
      return;
    }

    setQueueReorderBusy(true);
    setJobBusy(normalizedJobId, true);
    try {
      await api.cancelPipeline(normalizedJobId);
      const latest = await api.getPipelineQueue();
      setQueueState(normalizeQueue(latest?.queue));
    } catch (error) {
      showError(error);
    } finally {
      setQueueReorderBusy(false);
      setJobBusy(normalizedJobId, false);
    }
  };

  const handleRemoveQueueEntry = async (entryId) => {
    if (!entryId || queueReorderBusy) {
      return;
    }
    setQueueReorderBusy(true);
    try {
      const response = await api.removeQueueEntry(entryId);
      setQueueState(normalizeQueue(response?.queue));
    } catch (error) {
      showError(error);
    } finally {
      setQueueReorderBusy(false);
    }
  };

  const openInsertQueueDialog = async (afterEntryId) => {
    const hasJob = (queueState?.runningJobs || []).some((i) => i.jobId != null)
      || (queueState?.queuedJobs || []).some((i) => i.jobId != null);
    if (!hasJob) {
      return;
    }
    setInsertQueueDialog({ visible: true, afterEntryId: afterEntryId ?? null });
    try {
      const [scriptsRes, chainsRes] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
      setQueueCatalog({
        scripts: scriptsRes.status === 'fulfilled' ? (Array.isArray(scriptsRes.value?.scripts) ? scriptsRes.value.scripts : []) : [],
        chains: chainsRes.status === 'fulfilled' ? (Array.isArray(chainsRes.value?.chains) ? chainsRes.value.chains : []) : []
      });
    } catch (_) { /* ignore */ }
  };

  const handleAddQueueEntry = async (type, params) => {
    const afterEntryId = insertQueueDialog.afterEntryId;
    setInsertQueueDialog({ visible: false, afterEntryId: null });
    try {
      const response = await api.addQueueEntry({ type, ...params, insertAfterEntryId: afterEntryId });
      setQueueState(normalizeQueue(response?.queue));
    } catch (error) {
      showError(error);
    }
  };

  const syncQueueFromServer = async () => {
    try {
      const latest = await api.getPipelineQueue();
      setQueueState(normalizeQueue(latest?.queue));
    } catch (_error) {
      // ignore sync failures
    }
  };

  const handleMetadataSearch = async (query, options = {}) => {
    const provider = String(options?.metadataProvider || effectiveMetadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
    const response = provider === 'tmdb'
      ? await api.searchTmdbSeries(query)
      : await api.searchOmdb(query);
    return response.results || [];
  };

  const doSelectMetadata = async (payload, options = {}) => {
    const suppressErrorToast = Boolean(options?.suppressErrorToast);
    setBusy(true);
    try {
      if (metadataDialogReassignMode) {
        await api.assignJobOmdb(payload.jobId, payload);
      } else {
        await api.selectMetadata(payload);
      }
      await refreshPipeline();
      await loadRipperJobs();
      setMetadataDialogVisible(false);
      setMetadataDialogContext(null);
      setMetadataDialogReassignMode(false);
    } catch (error) {
      if (suppressErrorToast) {
        throw error;
      }
      showError(error);
    } finally {
      setBusy(false);
    }
  };

  const handleMetadataSubmit = async (payload) => {
    const payloadProvider = String(payload?.metadataProvider || '').trim().toLowerCase();
    const currentJobMediaProfile = String(effectiveMetadataDialogContext?.mediaProfile || '').trim().toLowerCase();
    const isSeriesDvdPayload = payloadProvider === 'tmdb' && currentJobMediaProfile === 'dvd';

    if (metadataDialogReassignMode) {
      await doSelectMetadata(payload);
      return;
    }

    // Serien-DVDs laufen ohne Film-Duplikatdialog weiter; die Disk-Pruefung
    // erfolgt serverseitig gegen den vorhandenen Staffel-Container.
    if (isSeriesDvdPayload) {
      await doSelectMetadata(payload, { suppressErrorToast: true });
      return;
    }

    // Duplikatprüfung: nur bei OMDB-Auswahl mit imdbId sinnvoll
    const searchTitle = payload.title || '';
    const searchImdbId = payload.imdbId || null;
    if (searchTitle) {
      try {
        const historyResponse = await api.getJobs({ search: searchTitle, limit: 50, lite: true });
        const historyJobs = Array.isArray(historyResponse?.jobs) ? historyResponse.jobs : [];
        const duplicate = historyJobs.find((job) => {
          if (normalizeJobId(job.id) === normalizeJobId(payload.jobId)) {
            return false; // aktueller Job selbst
          }
          // Gleicher Titel / imdbId?
          const titleMatch = searchImdbId
            ? (job.imdb_id && job.imdb_id === searchImdbId)
            : (String(job.title || '').toLowerCase() === searchTitle.toLowerCase());
          if (!titleMatch) {
            return false;
          }
          // Gleiches Medium? Verschiedene Medien (DVD vs. Bluray) → kein Duplikat
          if (!currentJobMediaProfile || currentJobMediaProfile === 'other') {
            return false;
          }
          const jobMediaType = resolveMediaType(job);
          return jobMediaType === currentJobMediaProfile;
        });

        if (duplicate) {
          setDuplicateJobDialog({ visible: true, existingJob: duplicate, pendingPayload: payload });
          return;
        }
      } catch (_error) {
        // Bei Fehler einfach fortfahren
      }
    }

    await doSelectMetadata(payload);
  };

  const handleMusicBrainzSearch = async (query) => {
    try {
      const response = await api.searchMusicBrainz(query);
      return response.results || [];
    } catch (error) {
      showError(error);
      return [];
    }
  };

  const handleMusicBrainzReleaseFetch = async (mbId) => {
    try {
      const response = await api.getMusicBrainzRelease(mbId);
      return response?.release || null;
    } catch (error) {
      showError(error);
      return null;
    }
  };

  const handleCdMetadataSubmit = async (payload) => {
    setBusy(true);
    try {
      await api.selectCdMetadata(payload);
      await refreshPipeline();
      await loadRipperJobs();
      setCdMetadataDialogVisible(false);
      setCdMetadataDialogContext(null);
    } catch (error) {
      showError(error);
    } finally {
      setBusy(false);
    }
  };

  const handleCdRipStart = async (jobId, ripConfig) => {
    if (!jobId) {
      return;
    }
    const normalizedJobId = normalizeJobId(jobId);
    if (normalizedJobId) {
      setJobBusy(normalizedJobId, true);
    }
    try {
      const response = await api.startCdRip(jobId, ripConfig);
      const result = getQueueActionResult(response);
      if (result.queued) {
        showQueuedToast(toastRef, 'Audio CD', result);
      }
      const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
      await refreshPipeline();
      await loadRipperJobs();
      if (replacementJobId) {
        setExpandedJobId(replacementJobId);
      }
    } catch (error) {
      showError(error);
    } finally {
      if (normalizedJobId) {
        setJobBusy(normalizedJobId, false);
      }
    }
  };

  // CD drives are tracked per-drive in pipeline.cdDrives — exclude them from the single-drive display
  const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
  const contextDevice = pipeline?.context?.device;
  const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
  const driveLocksByPath = useMemo(() => {
    const map = new Map();
    const locks = Array.isArray(pipeline?.driveLocks) ? pipeline.driveLocks : [];
    for (const lock of locks) {
      const path = normalizeDrivePath(lock?.path);
      const count = Number(lock?.count || 0);
      if (!path || !Number.isFinite(count) || count <= 0) {
        continue;
      }
      const owners = Array.isArray(lock?.owners) ? lock.owners : [];
      const owner = owners.length > 0 ? owners[owners.length - 1] : null;
      map.set(path, {
        path,
        count,
        owner,
        owners
      });
    }
    return map;
  }, [pipeline?.driveLocks]);

  // Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
  const allDrives = useMemo(() => {
    const driveMap = new Map();
    for (const d of knownDrives) {
      driveMap.set(d.path, { path: d.path, model: d.model, discArg: d.discArg });
    }
    for (const [drivePath, drive] of Object.entries(pipeline?.cdDrives || {})) {
      const base = driveMap.get(drivePath) || { path: drivePath };
      driveMap.set(drivePath, { ...base, cdDrive: drive });
    }
    if (device?.path) {
      const base = driveMap.get(device.path) || { path: device.path, model: device.model };
      // When device comes from a recent DISC_DETECTED event, use 'DISC_DETECTED' as the
      // effective state for that drive even if the global pipeline state is ENCODING etc.
      const effectivePipelineState = lastNonCdDiscEvent ? 'DISC_DETECTED' : state;
      driveMap.set(device.path, { ...base, pipelineDevice: device, pipelineState: effectivePipelineState });
    }
    // For drives that the detection service found but aren't tracked by cdDrives or the global
    // state machine (e.g. a second non-CD disc), show DISC_DETECTED using detectedDiscs.
    for (const [drivePath, discDevice] of Object.entries(pipeline?.detectedDiscs || {})) {
      const existing = driveMap.get(drivePath);
      if (existing && !existing.cdDrive && !existing.pipelineDevice) {
        driveMap.set(drivePath, { ...existing, detectedDisc: discDevice });
      }
    }
    return Array.from(driveMap.values())
      .filter((drv) => !String(drv.path || '').startsWith('__virtual__'))
      .sort((a, b) => (a.path || '').localeCompare(b.path || ''));
  }, [knownDrives, pipeline?.cdDrives, pipeline?.detectedDiscs, device, state, lastNonCdDiscEvent]);
  const isDriveActive = driveActiveStates.includes(state);
  const canRescan = !isDriveActive;
  const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
  const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
  const queueIdleJobs = Array.isArray(queueState?.idleJobs) ? queueState.idleJobs : [];
  const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
  const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
  const queueHasJobEntry = queueRunningJobs.some((i) => i.jobId != null)
    || queuedJobs.some((i) => i.jobId != null);
  const buildRunningQueueScriptKey = (jobId) => `running-${normalizeJobId(jobId) || '-'}`;
  const buildIdleQueueScriptKey = (jobId) => `idle-${normalizeJobId(jobId) || '-'}`;
  const buildQueuedQueueScriptKey = (entryId) => `queued-${Number(entryId) || '-'}`;
  const toggleQueueScriptDetails = (key) => {
    if (!key) {
      return;
    }
    setExpandedQueueScriptKeys((prev) => {
      const next = new Set(prev);
      if (next.has(key)) {
        next.delete(key);
      } else {
        next.add(key);
      }
      return next;
    });
  };
  useEffect(() => {
    const validKeys = new Set();
    for (const item of queueRunningJobs) {
      if (!hasQueueScriptSummary(item)) {
        continue;
      }
      validKeys.add(buildRunningQueueScriptKey(item?.jobId));
    }
    for (const item of queueIdleJobs) {
      if (!hasQueueScriptSummary(item)) {
        continue;
      }
      validKeys.add(buildIdleQueueScriptKey(item?.jobId));
    }
    for (const item of queuedJobs) {
      if (String(item?.type || 'job') !== 'job' || !hasQueueScriptSummary(item)) {
        continue;
      }
      validKeys.add(buildQueuedQueueScriptKey(item?.entryId));
    }
    setExpandedQueueScriptKeys((prev) => {
      let changed = false;
      const next = new Set();
      for (const key of prev) {
        if (validKeys.has(key)) {
          next.add(key);
        } else {
          changed = true;
        }
      }
      return changed ? next : prev;
      });
  }, [queueRunningJobs, queueIdleJobs, queuedJobs]);
  const queuedJobIdSet = useMemo(() => {
    const set = new Set();
    for (const item of queuedJobs) {
      const id = normalizeJobId(item?.jobId);
      if (id) {
        set.add(id);
      }
    }
    return set;
  }, [queuedJobs]);

  const setRuntimeActionBusy = (activityId, action, busyFlag) => {
    const key = `${Number(activityId) || 0}:${String(action || '')}`;
    setRuntimeActionBusyKeys((prev) => {
      const next = new Set(prev);
      if (busyFlag) {
        next.add(key);
      } else {
        next.delete(key);
      }
      return next;
    });
  };

  const isRuntimeActionBusy = (activityId, action) => runtimeActionBusyKeys.has(
    `${Number(activityId) || 0}:${String(action || '')}`
  );

  const handleRuntimeControl = async (item, action) => {
    const activityId = Number(item?.id);
    if (!Number.isFinite(activityId) || activityId <= 0) {
      return;
    }
    const normalizedAction = String(action || '').trim().toLowerCase();
    const actionLabel = normalizedAction === 'next-step' ? 'Nächster Schritt' : 'Abbrechen';
    setRuntimeActionBusy(activityId, normalizedAction, true);
    try {
      const response = normalizedAction === 'next-step'
        ? await api.requestRuntimeNextStep(activityId)
        : await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Ripper' });
      if (response?.snapshot) {
        setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
      } else {
        const fresh = await api.getRuntimeActivities();
        setRuntimeActivities(normalizeRuntimeActivitiesPayload(fresh));
      }
      const accepted = response?.action?.accepted !== false;
      const actionMessage = String(response?.action?.message || '').trim();
      toastRef.current?.show({
        severity: accepted ? 'info' : 'warn',
        summary: actionLabel,
        detail: actionMessage || (accepted ? 'Aktion ausgelöst.' : 'Aktion aktuell nicht möglich.'),
        life: 2600
      });
    } catch (error) {
      toastRef.current?.show({
        severity: 'error',
        summary: actionLabel,
        detail: error?.message || 'Aktion fehlgeschlagen.',
        life: 3200
      });
    } finally {
      setRuntimeActionBusy(activityId, normalizedAction, false);
    }
  };

  const handleClearRuntimeRecent = async () => {
    if (runtimeRecentClearing || runtimeRecentItems.length === 0) {
      return;
    }
    setRuntimeRecentClearing(true);
    try {
      const response = await api.clearRuntimeRecentActivities();
      if (response?.snapshot) {
        setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
      } else {
        const fresh = await api.getRuntimeActivities();
        setRuntimeActivities(normalizeRuntimeActivitiesPayload(fresh));
      }
      toastRef.current?.show({
        severity: 'success',
        summary: 'Abgeschlossene Liste',
        detail: `Einträge entfernt: ${Number(response?.removed || 0)}`,
        life: 2200
      });
    } catch (error) {
      toastRef.current?.show({
        severity: 'error',
        summary: 'Liste leeren',
        detail: error?.message || 'Leeren fehlgeschlagen.',
        life: 3200
      });
    } finally {
      setRuntimeRecentClearing(false);
    }
  };

  const runtimeActiveItems = Array.isArray(runtimeActivities?.active) ? runtimeActivities.active : [];
  const runtimeRecentItems = Array.isArray(runtimeActivities?.recent)
    ? runtimeActivities.recent.slice(0, 8)
    : [];

  // Group chain activities with their current child script — hide child scripts from top level
  const activeChainChildIds = new Set(
    runtimeActiveItems.filter((i) => i.parentActivityId != null).map((i) => i.id)
  );
  const activeItemsDisplay = runtimeActiveItems.filter((i) => !activeChainChildIds.has(i.id));
  const findActiveChainChild = (chainId) => runtimeActiveItems.find((i) => i.parentActivityId === chainId) || null;

  const recentChainChildIds = new Set(
    runtimeRecentItems.filter((i) => i.parentActivityId != null).map((i) => i.id)
  );
  const recentItemsDisplay = runtimeRecentItems.filter((i) => !recentChainChildIds.has(i.id));

  return (
    <div className="page-grid">
      <Toast ref={toastRef} />

      <div className="ripper-3col-grid">
        <div className="ripper-col ripper-col-left">
          <Card title="Disk-Information">
        {/* Per-drive list */}
        {allDrives.length > 0 ? (
          <div className="drive-list">
            {allDrives.map((drv) => {
              const drivePath = drv.path;
              const cdDrive = drv.cdDrive;
              const pipelineDevice = drv.pipelineDevice;
              const isRescanBusy = driveRescanBusy.has(drivePath);
              const isAnalyzeBusy = driveAnalyzeBusy.has(drivePath);

              // Determine display state
              let stateLabel = null;
              let stateSeverity = 'secondary';
              let discInfo = null;
              let showProgress = false;
              let progressValue = 0;
              let statusText = null;

              if (cdDrive) {
                const s = String(cdDrive.state || '').toUpperCase();
                stateLabel = s;
                stateSeverity = s === 'FINISHED' ? 'success'
                  : s === 'ERROR' || s === 'CANCELLED' ? 'danger'
                  : ['CD_RIPPING', 'CD_ENCODING'].includes(s) ? 'warning'
                  : 'info';
                const cd = cdDrive.device || {};
                discInfo = cd.discLabel || cd.model || null;
                showProgress = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(s);
                progressValue = Number(cdDrive.progress ?? 0);
                statusText = cdDrive.statusText || null;
              } else if (pipelineDevice) {
                const s = String(drv.pipelineState || '').toUpperCase();
                stateLabel = s || 'DISC_DETECTED';
                stateSeverity = s === 'FINISHED' ? 'success'
                  : s === 'ERROR' ? 'danger'
                  : processingStates.includes(s) ? 'warning'
                  : 'info';
                discInfo = pipelineDevice.discLabel || pipelineDevice.label || null;
              } else if (drv.detectedDisc) {
                stateLabel = 'DISC_DETECTED';
                stateSeverity = 'info';
                discInfo = drv.detectedDisc.discLabel || drv.detectedDisc.label || null;
              } else {
                stateLabel = 'LEER';
                stateSeverity = 'secondary';
              }

              const driveJobId = normalizeJobId(cdDrive?.jobId);
              const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
              const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
              const pipelineState = String(drv.pipelineState || '').toUpperCase();
              const driveLockEntry = driveLocksByPath.get(normalizeDrivePath(drivePath)) || null;
              const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false;
              const isDriveLocked = Boolean(driveLockEntry) || isCdDriveLocked;
              const lockedByJobId = normalizeJobId(driveLockEntry?.owner?.jobId);
              const lockStage = String(driveLockEntry?.owner?.stage || '').trim().toUpperCase();
              const lockTitle = lockedByJobId
                ? `Laufwerk ist gesperrt (Job #${lockedByJobId}${lockStage ? `, ${lockStage}` : ''}).`
                : 'Laufwerk ist gesperrt, bis Rip/Encode abgeschlossen ist.';
              const canAnalyzeDrive = cdState
                ? cdState === 'DISC_DETECTED'
                : (Boolean(pipelineDevice) && pipelineState === 'DISC_DETECTED') || Boolean(drv.detectedDisc);
              const stateIconMeta = driveStateIconMeta(stateLabel);
              const discArg = String(drv.discArg || '').trim();

              return (
                <div key={drivePath} className="drive-list-item">
                  <div className="drive-list-row">
                    <div className="drive-list-info">
                      <code className="drive-list-path">{drivePath}</code>
                      {drv.model && <span className="drive-list-model">{drv.model}</span>}
                    </div>
                    <div className="drive-list-state">
                      {stateLabel ? (
                        <span
                          className={`drive-list-status-icon tone-${stateSeverity}`}
                          title={getStatusLabel(stateLabel)}
                          aria-label={`Status: ${getStatusLabel(stateLabel)}`}
                        >
                          <i className={`pi ${stateIconMeta.icon}${stateIconMeta.spin ? ' pi-spin' : ''}`} aria-hidden="true" />
                        </span>
                      ) : null}
                    </div>
                    <div className="drive-list-actions">
                      {isDriveLocked ? (
                        <Button
                          icon="pi pi-lock"
                          text
                          rounded
                          size="small"
                          severity="secondary"
                          className="drive-list-action-btn"
                          disabled
                          title={lockTitle}
                          aria-label="Laufwerk gesperrt"
                        />
                      ) : (
                        <>
                          <Button
                            icon="pi pi-refresh"
                            text
                            rounded
                            size="small"
                            severity="secondary"
                            className="drive-list-action-btn"
                            loading={isRescanBusy}
                            disabled={isRescanBusy || isDriveActive}
                            onClick={() => handleRescanDrive(drivePath)}
                            title="Laufwerk neu lesen"
                            aria-label="Laufwerk neu lesen"
                          />
                          {canAnalyzeDrive && (
                            <Button
                              icon="pi pi-search"
                              text
                              rounded
                              size="small"
                              severity="secondary"
                              className="drive-list-action-btn"
                              loading={isAnalyzeBusy}
                              disabled={isAnalyzeBusy}
                              onClick={() => handleAnalyzeForDrive(drivePath)}
                              title="Disk analysieren"
                              aria-label="Disk analysieren"
                            />
                          )}
                        </>
                      )}
                      {cdState === 'CD_METADATA_SELECTION' && (
                        <Button
                          icon="pi pi-list"
                          text
                          rounded
                          size="small"
                          severity="info"
                          className="drive-list-action-btn"
                          onClick={() => {
                            setCdMetadataDialogContext({ ...driveCtx, jobId: driveJobId });
                            setCdMetadataDialogVisible(true);
                          }}
                          disabled={!driveJobId}
                          title="Metadaten auswählen"
                          aria-label="Metadaten auswählen"
                        />
                      )}
                      {(cdState === 'ERROR' || cdState === 'CANCELLED') && driveJobId && (
                        <Button
                          icon="pi pi-replay"
                          text
                          rounded
                          size="small"
                          severity="warning"
                          className="drive-list-action-btn"
                          onClick={() => handleAnalyzeForDrive(drivePath)}
                          loading={isAnalyzeBusy}
                          disabled={isAnalyzeBusy}
                          title="Erneut analysieren"
                          aria-label="Erneut analysieren"
                        />
                      )}
                    </div>
                  </div>
                  {(discArg || discInfo) ? (
                    <div className="drive-list-disc-meta">
                      {discArg ? <span className="drive-list-disc-arg">{discArg}</span> : null}
                      {discInfo ? <span className="drive-list-disc-label" title={discInfo}>{discInfo}</span> : null}
                    </div>
                  ) : null}
                  {showProgress && (
                    <ProgressBar value={progressValue} style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
                  )}
                  {showProgress && statusText && (
                    <div className="drive-list-status-text">{statusText}</div>
                  )}
                </div>
              );
            })}
          </div>
        ) : (
          <p className="drive-list-empty">Keine Laufwerke erkannt.</p>
        )}

        {/* Global action buttons — below drive list, side by side */}
        <div className="drive-list-global-actions">
          <Button
            label="Alle neu lesen"
            icon="pi pi-refresh"
            severity="secondary"
            size="small"
            onClick={handleRescan}
            loading={busy}
            disabled={!canRescan}
          />
          <Button
            label="Alle analysieren"
            icon="pi pi-search"
            severity="warning"
            size="small"
            onClick={handleAnalyzeAll}
            disabled={allDrives.every((drv) => {
              const cs = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null;
              if (cs) return cs !== 'DISC_DETECTED';
              if (Boolean(drv.pipelineDevice) && String(drv.pipelineState || '').toUpperCase() === 'DISC_DETECTED') return false;
              return !drv.detectedDisc;
            })}
          />
        </div>
      </Card>

      <Card title="Freier Speicher">
        <div className="hardware-storage-list">
          {storageGroups.map((group) => {
            const rep = group.representative;
            const tone = getStorageUsageTone(rep?.usagePercent);
            const usagePercent = Number(rep?.usagePercent);
            const barValue = Number.isFinite(usagePercent)
              ? Math.max(0, Math.min(100, usagePercent))
              : 0;
            const hasError = group.entries.every((e) => e?.error);
            const groupKey = group.mountPoint || group.entries.map((e) => e?.key).join('-');
            const labels = group.entries.map((e) => e?.label || e?.key || 'Pfad').join(' · ');
            return (
              <div
                key={`storage-group-${groupKey}`}
                className={`hardware-storage-item compact${hasError ? ' has-error' : ''}`}
              >
                <small className="hardware-storage-group-label">{labels}</small>
                {hasError ? (
                  <small className="error-text">{rep?.error}</small>
                ) : (
                  <>
                    <div className="hardware-storage-bar-row">
                      <div className={`hardware-storage-bar tone-${tone}`}>
                        <ProgressBar value={barValue} showValue={false} />
                      </div>
                      <span className={`hardware-storage-percent tone-${tone}`}>
                        {formatPercent(rep?.usagePercent)}
                      </span>
                    </div>
                    <div className="hardware-storage-summary">
                      <small>Frei: {formatBytes(rep?.freeBytes)}</small>
                      <small>Gesamt: {formatBytes(rep?.totalBytes)}</small>
                    </div>
                  </>
                )}
              </div>
            );
          })}
        </div>
      </Card>
        </div>

        <div className="ripper-col ripper-col-center">
          {isSubpageRoute ? (
            <div className="ripper-subpage-content">
              {children}
            </div>
          ) : (
            <Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
        {jobsLoading && ripperJobs.length === 0 ? (
          <p>Jobs werden geladen ...</p>
        ) : ripperJobs.length === 0 ? (
          <p>Keine relevanten Jobs im Ripper (aktive/fortsetzbare Status).</p>
        ) : (
          <div className="ripper-job-list">
            {ripperJobs.map((job) => {
              const jobId = normalizeJobId(job?.id);
              if (!jobId) {
                return null;
              }
              const normalizedStatus = normalizeStatus(job?.status);
              const isQueued = queuedJobIdSet.has(jobId);
              const statusBadgeValue = getStatusLabel(job?.status, { queued: isQueued });
              const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
              const isExpanded = normalizeJobId(expandedJobId) === jobId;
              const isCurrentSession = jobId === currentPipelineJobId && state !== 'IDLE';
              const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
              const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
              const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
              const mediaIndicator = mediaIndicatorMeta(job);
              const isResumable = (
                normalizedStatus === 'READY_TO_ENCODE'
                || (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
              ) && !isCurrentSession;
              const analyzeContext = getAnalyzeContext(job);
              const selectedMetadata = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
                ? pipelineForJob.context.selectedMetadata
                : (analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
                  ? analyzeContext.selectedMetadata
                  : (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
                    ? job.makemkvInfo.selectedMetadata
                    : {}));
              const seriesSeasonNumber = mediaIndicator.isSeriesDvd
                ? (selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null)
                : null;
              const seriesTmdbId = mediaIndicator.isSeriesDvd
                ? (selectedMetadata?.tmdbId ?? analyzeContext?.tmdbId ?? null)
                : null;
              const seriesDiscNumber = mediaIndicator.isSeriesDvd
                ? (selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null)
                : null;
              const isSeriesContainer = mediaIndicator.isSeriesDvd
                && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
              const parentContainerId = normalizeJobId(job?.parent_job_id);
              const seriesTmdbText = String(seriesTmdbId ?? '').trim();
              const seriesSeasonLabel = mediaIndicator.isSeriesDvd && seriesSeasonNumber
                ? ` Staffel ${seriesSeasonNumber}`
                : '';
              const seriesDiscLabel = mediaIndicator.isSeriesDvd && seriesDiscNumber
                ? ` | Disk ${seriesDiscNumber}`
                : '';
              const seriesContainerLabel = isSeriesContainer
                ? ' | Container'
                : (parentContainerId ? ` | Container #${parentContainerId}` : '');
              const seriesTmdbLabel = mediaIndicator.isSeriesDvd && seriesTmdbText && seriesTmdbText !== '0'
                ? ` | TMDb ${seriesTmdbId}`
                : '';
              const seriesTitleSuffix = mediaIndicator.isSeriesDvd
                ? `${seriesSeasonLabel}${seriesDiscLabel}${seriesContainerLabel}`
                : '';
              const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
              const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
              const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
              const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
              const isCdJob = jobState.startsWith('CD_')
                || pipelineStage.startsWith('CD_')
                || mediaProfile === 'cd'
                || mediaIndicator.mediaType === 'cd'
                || pipelineStatusText.includes('CD_');
              const isAudiobookJob = mediaProfile === 'audiobook'
                || mediaIndicator.mediaType === 'audiobook'
                || String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === 'audiobook';
              const seriesBatchContext = pipelineForJob?.context?.seriesBatch && typeof pipelineForJob.context.seriesBatch === 'object'
                ? pipelineForJob.context.seriesBatch
                : null;
              const seriesBatchChildren = Array.isArray(seriesBatchContext?.children) ? seriesBatchContext.children : [];
              const hasSeriesBatchProgress = seriesBatchChildren.length > 0 || Number(seriesBatchContext?.totalCount || 0) > 0;
              const seriesBatchTotalCountRaw = Number(seriesBatchContext?.totalCount || 0);
              const seriesBatchTotalCount = Number.isFinite(seriesBatchTotalCountRaw) && seriesBatchTotalCountRaw > 0
                ? Math.trunc(seriesBatchTotalCountRaw)
                : seriesBatchChildren.length;
              const seriesBatchAggregateProgress = hasSeriesBatchProgress && seriesBatchTotalCount > 0
                ? (
                  seriesBatchChildren.reduce((sum, child) => {
                    const status = String(child?.status || '').trim().toUpperCase();
                    if (status === 'FINISHED') {
                      return sum + 100;
                    }
                    const childProgress = Number(child?.progress || 0);
                    return sum + (Number.isFinite(childProgress) ? Math.max(0, Math.min(100, childProgress)) : 0);
                  }, 0) / seriesBatchTotalCount
                )
                : 0;
              const runningSeriesChild = hasSeriesBatchProgress
                ? (
                  seriesBatchChildren.find((child) => String(child?.status || '').trim().toUpperCase() === 'RUNNING')
                  || null
                )
                : null;
              const rawProgress = hasSeriesBatchProgress
                ? seriesBatchAggregateProgress
                : Number(pipelineForJob?.progress ?? 0);
              const clampedProgress = Number.isFinite(rawProgress)
                ? Math.max(0, Math.min(100, rawProgress))
                : 0;
              const progressLabel = `${Math.round(clampedProgress)}%`;
              const etaLabel = String(
                hasSeriesBatchProgress
                  ? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
                  : (pipelineForJob?.eta || '')
              ).trim();

              const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
                ? pipelineForJob.context.selectedMetadata
                : {};
              const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
              if (isExpanded) {
                return (
                  <div key={jobId} className="ripper-job-expanded">
                    <div className="ripper-job-expanded-head">
                      {(job?.poster_url && job.poster_url !== 'N/A') ? (
                        <img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
                      ) : (
                        <div className="poster-thumb ripper-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
                      )}
                      <div className="ripper-job-expanded-title">
                        <strong className="ripper-job-title-line">
                          <img
                            src={mediaIndicator.src}
                            alt={mediaIndicator.alt}
                            title={mediaIndicator.title}
                            className="media-indicator-icon"
                          />
                          <span>#{jobId} | {jobTitle}</span>
                        </strong>
                        {mediaIndicator.isSeriesDvd ? (
                          <small className="ripper-job-subtitle">{seriesTitleSuffix || '-'}</small>
                        ) : null}
                        <div className="ripper-job-badges">
                          <Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
                          {mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null}
                          {isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
                          {isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
                          {normalizedStatus === 'READY_TO_ENCODE'
                            ? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
                            : null}
                          <JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
                        </div>
                      </div>
                      <Button
                        label="Einklappen"
                        icon="pi pi-angle-up"
                        severity="secondary"
                        outlined
                        onClick={() => setExpandedJobId(null)}
                        disabled={busyJobIds.has(jobId)}
                      />
                    </div>
                    {(() => {
                      if (isCdJob) {
                        return (
                          <>
                            {isCdJob ? (
                              <CdRipConfigPanel
                                pipeline={pipelineForJob}
                                onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
                                onCancel={() => handleCancel(jobId, jobState)}
                                onRetry={() => handleRetry(jobId)}
                                onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
                                onDeleteJob={() => handleDeleteRipperJob(jobId)}
                                onOpenMetadata={() => {
                                  const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
                                    ? pipelineForJob.context
                                    : pipeline?.context || {};
                                  setCdMetadataDialogContext({ ...ctx, jobId });
                                  setCdMetadataDialogVisible(true);
                                }}
                                busy={busyJobIds.has(jobId)}
                              />
                            ) : null}
                          </>
                        );
                      }
                      if (isAudiobookJob) {
                        const needsBytes = pendingActivationJobIds.has(jobId);
                        return (
                          <>
                            {needsBytes && (
                              <div style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', background: 'var(--yellow-100)', border: '1px solid var(--yellow-400)', borderRadius: '6px', color: 'var(--yellow-900)', fontSize: '0.875rem' }}>
                                <i className="pi pi-lock" style={{ marginRight: '0.5rem' }} />
                                <strong>Activation Bytes fehlen.</strong>{' '}
                                <button type="button" style={{ background: 'none', border: 'none', color: 'var(--primary-color)', cursor: 'pointer', textDecoration: 'underline', padding: 0 }} onClick={() => handleAudiobookStart(jobId, null)}>
                                  Jetzt eintragen
                                </button>
                              </div>
                            )}
                            <AudiobookConfigPanel
                              pipeline={pipelineForJob}
                              onStart={(config) => handleAudiobookStart(jobId, config)}
                              onCancel={() => handleCancel(jobId, jobState)}
                              onRetry={() => handleRetry(jobId)}
                              onDeleteJob={() => handleDeleteRipperJob(jobId)}
                              busy={busyJobIds.has(jobId) || needsBytes}
                            />
                          </>
                        );
                      }
                      return null;
                    })()}
                    {!isCdJob && !isAudiobookJob ? (
                    <PipelineStatusCard
                      jobId={jobId}
                      pipeline={pipelineForJob}
                      onAnalyze={handleAnalyze}
                      onReanalyze={handleReanalyze}
                      onOpenMetadata={handleOpenMetadataDialog}
                      onReassignOmdb={handleOpenReassignOmdbDialog}
                      onStart={handleStartJob}
                      onRestartEncode={handleRestartEncodeWithLastSettings}
                      onRestartReview={handleRestartReviewFromRaw}
                      onConfirmReview={handleConfirmReview}
                      onSelectPlaylist={handleSelectPlaylist}
                      onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
                      onCancel={handleCancel}
                      onRetry={handleRetry}
                      onDeleteJob={handleDeleteRipperJob}
                      onRemoveFromQueue={handleRemoveQueuedJob}
                      isQueued={isQueued}
                      busy={busyJobIds.has(jobId)}
                    />
                    ) : null}
                  </div>
                );
              }

              return (
                <button
                  key={jobId}
                  type="button"
                  className="ripper-job-row"
                  onClick={() => setExpandedJobId(jobId)}
                >
                  {job?.poster_url && job.poster_url !== 'N/A' ? (
                    <img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
                  ) : (
                    <div className="poster-thumb ripper-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
                  )}
                  <div className="ripper-job-row-content">
                    <div className="ripper-job-row-main">
                      <strong className="ripper-job-title-line">
                        <img
                          src={mediaIndicator.src}
                          alt={mediaIndicator.alt}
                          title={mediaIndicator.title}
                          className="media-indicator-icon"
                        />
                        <span>{jobTitle}</span>
                      </strong>
                      <small>
                        {mediaIndicator.isSeriesDvd
                          ? (seriesTitleSuffix || '-')
                          : (
                            `#${jobId}`
                            + (isAudiobookJob
                              ? (
                                `${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
                                + `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
                                + `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
                              )
                              : (
                                `${job?.year ? ` | ${job.year}` : ''}`
                                + (mediaIndicator.isSeriesDvd ? seriesSeasonLabel : '')
                                + (mediaIndicator.isSeriesDvd ? seriesDiscLabel : '')
                                + (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
                                + (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
                                + (mediaIndicator.isSeriesDvd ? '' : `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`)
                              ))
                          )}
                      </small>
                    </div>
                    <div className="ripper-job-badges">
                      <Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
                      {mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null}
                      {isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
                      {isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
                      {normalizedStatus === 'READY_TO_ENCODE'
                        ? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
                        : null}
                      <JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
                    </div>
                    <div className="ripper-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
                      <ProgressBar value={clampedProgress} showValue={false} />
                      <small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
                    </div>
                  </div>
                  <i className="pi pi-angle-down" aria-hidden="true" />
                </button>
              );
            })}
          </div>
        )}
      </Card>
          )}
        </div>

        <div className="ripper-col ripper-col-right">
          <Card title="Job Queue" subTitle="Elemente können per Drag-and-Drop umsortiert werden.">
        <div className="pipeline-queue-meta">
          <Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
          <Tag value={`Audio/CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
          <Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
          {queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio/CD-Jobs überspringen die Film-Queue-Reihenfolge" />}
          <Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
          <Tag value={`Audio/CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
          <Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
          <Tag value={`Idle: ${queueState?.idleCount || 0}`} severity={(queueState?.idleCount || 0) > 0 ? 'info' : 'success'} />
        </div>

        <div className="pipeline-queue-grid">
          <div className="pipeline-queue-col queue-col-running">
            <h4>Laufende Jobs</h4>
            {queueRunningJobs.length === 0 ? (
              <small>Keine laufenden Jobs.</small>
            ) : (
              queueRunningJobs.map((item) => {
                const hasScriptSummary = hasQueueScriptSummary(item);
                const detailKey = buildRunningQueueScriptKey(item?.jobId);
                const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
                const jobProgressEntry = pipeline?.jobProgress?.[String(item.jobId)] || null;
                const seriesBatchContext = jobProgressEntry?.context?.seriesBatch && typeof jobProgressEntry.context.seriesBatch === 'object'
                  ? jobProgressEntry.context.seriesBatch
                  : null;
                const seriesBatchChildren = Array.isArray(seriesBatchContext?.children) ? seriesBatchContext.children : [];
                const seriesBatchTotalCountRaw = Number(seriesBatchContext?.totalCount || 0);
                const seriesBatchTotalCount = Number.isFinite(seriesBatchTotalCountRaw) && seriesBatchTotalCountRaw > 0
                  ? Math.trunc(seriesBatchTotalCountRaw)
                  : seriesBatchChildren.length;
                const seriesBatchOverallProgress = seriesBatchTotalCount > 0
                  ? (
                    seriesBatchChildren.reduce((sum, child) => {
                      const status = String(child?.status || '').trim().toUpperCase();
                      if (status === 'FINISHED') {
                        return sum + 100;
                      }
                      const childProgress = Number(child?.progress || 0);
                      return sum + (Number.isFinite(childProgress) ? Math.max(0, Math.min(100, childProgress)) : 0);
                    }, 0) / seriesBatchTotalCount
                  )
                  : null;
                const runningSeriesChild = seriesBatchChildren.find(
                  (child) => String(child?.status || '').trim().toUpperCase() === 'RUNNING'
                ) || null;
                const runningChildProgress = Number(runningSeriesChild?.progress ?? NaN);
                const rawQueueProgress = Number(
                  Number.isFinite(runningChildProgress)
                    ? runningChildProgress
                    : (jobProgressEntry?.progress ?? 0)
                );
                const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
                return (
                  <div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
                    <div className="pipeline-queue-item running queue-job-item">
                      <div className="pipeline-queue-item-main">
                        <strong>
                          #{item.jobId} | {item.title || `Job #${item.jobId}`}
                          {item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
                          {item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
                        </strong>
                        {item.subtitle ? <small>{item.subtitle}</small> : null}
                        <small>{getStatusLabel(item.status)}</small>
                        {clampedQueueProgress > 0 && (
                          <ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
                        )}
                      </div>
                      {hasScriptSummary ? (
                        <button
                          type="button"
                          className="queue-job-expand-btn"
                          aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
                          aria-expanded={detailsExpanded}
                          onClick={() => toggleQueueScriptDetails(detailKey)}
                        >
                          <i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
                        </button>
                      ) : null}
                    </div>
                    {detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
                  </div>
                );
              })
            )}
          </div>
          <div className="pipeline-queue-col queue-col-idle">
            <h4>Idle</h4>
            {queueIdleJobs.length === 0 ? (
              <small>Keine Idle-Jobs.</small>
            ) : (
              queueIdleJobs.map((item) => {
                const hasScriptSummary = hasQueueScriptSummary(item);
                const detailKey = buildIdleQueueScriptKey(item?.jobId);
                const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
                return (
                  <div key={`idle-${item.jobId}`} className="pipeline-queue-entry-wrap">
                    <div className="pipeline-queue-item idle queue-job-item">
                      <div className="pipeline-queue-item-main">
                        <strong>
                          #{item.jobId} | {item.title || `Job #${item.jobId}`}
                          {item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
                          {item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
                        </strong>
                        <small>{getStatusLabel(item.status)}</small>
                      </div>
                      {hasScriptSummary ? (
                        <button
                          type="button"
                          className="queue-job-expand-btn"
                          aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
                          aria-expanded={detailsExpanded}
                          onClick={() => toggleQueueScriptDetails(detailKey)}
                        >
                          <i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
                        </button>
                      ) : null}
                    </div>
                    {detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
                  </div>
                );
              })
            )}
          </div>
          <div className="pipeline-queue-col queue-col-queued">
            <div className="pipeline-queue-col-header">
              <h4>Warteschlange</h4>
              <button
                type="button"
                className="queue-add-entry-btn"
                title={queueHasJobEntry ? 'Skript, Kette oder Wartezeit zur Queue hinzufügen' : 'Nur möglich wenn mind. ein Job in der Queue ist'}
                disabled={!queueHasJobEntry}
                onClick={() => void openInsertQueueDialog(null)}
              >
                <i className="pi pi-plus" /> Hinzufügen
              </button>
            </div>
            {queuedJobs.length === 0 ? (
              <small className="queue-empty-hint">Queue ist leer.</small>
            ) : (
              <>
                {queuedJobs.map((item) => {
                  const entryId = Number(item?.entryId);
                  const isNonJob = item.type && item.type !== 'job';
                  const isDragging = Number(draggingQueueEntryId) === entryId;
                  const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item);
                  const detailKey = buildQueuedQueueScriptKey(entryId);
                  const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
                  return (
                    <div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
                      <div
                        className={`pipeline-queue-item queued${isDragging ? ' dragging' : ''}${isNonJob ? ' non-job' : ''}`}
                        draggable={canReorderQueue}
                        onDragStart={() => setDraggingQueueEntryId(entryId)}
                        onDragEnter={() => handleQueueDragEnter(entryId)}
                        onDragOver={(event) => event.preventDefault()}
                        onDrop={(event) => {
                          event.preventDefault();
                          void handleQueueDrop();
                        }}
                        onDragEnd={() => {
                          setDraggingQueueEntryId(null);
                          void syncQueueFromServer();
                        }}
                      >
                        <span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
                          <i className="pi pi-bars" />
                        </span>
                        <i className={`pipeline-queue-type-icon ${queueEntryIcon(item.type)}`} title={item.type || 'job'} />
                        <div className="pipeline-queue-item-main">
                          {isNonJob ? (
                            <strong>{item.position || '-'}. {queueEntryLabel(item)}</strong>
                          ) : (
                            <>
                              <strong>
                                {item.position || '-'} | #{item.jobId} | {item.title || `Job #${item.jobId}`}
                                {item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
                                {item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
                              </strong>
                              {item.subtitle ? <small>{item.subtitle}</small> : null}
                              <small>{item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
                            </>
                          )}
                        </div>
                        <div className="pipeline-queue-item-actions">
                          {hasScriptSummary ? (
                            <button
                              type="button"
                              className="queue-job-expand-btn"
                              aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
                              aria-expanded={detailsExpanded}
                              onClick={(event) => {
                                event.preventDefault();
                                event.stopPropagation();
                                toggleQueueScriptDetails(detailKey);
                              }}
                            >
                              <i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
                            </button>
                          ) : null}
                          <Button
                            icon="pi pi-times"
                            severity="danger"
                            text
                            rounded
                            size="small"
                            className="pipeline-queue-remove-btn"
                            disabled={queueReorderBusy}
                            onClick={(event) => {
                              event.preventDefault();
                              event.stopPropagation();
                              if (isNonJob) {
                                void handleRemoveQueueEntry(entryId);
                              } else {
                                void handleRemoveQueuedJob(item.jobId);
                              }
                            }}
                          />
                        </div>
                      </div>
                      {detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
                      {queueHasJobEntry ? (
                        <button
                          type="button"
                          className="queue-insert-btn"
                          title="Eintrag danach einfügen"
                          onClick={() => void openInsertQueueDialog(entryId)}
                        >
                          <i className="pi pi-plus" />
                        </button>
                      ) : null}
                    </div>
                  );
                })}
              </>
            )}
          </div>
        </div>
      </Card>

          <Card title="Automatisierung">
        <div className="runtime-activity-meta pipeline-queue-meta">
          <Tag value={`Laufend: ${runtimeActiveItems.length}`} severity={runtimeActiveItems.length > 0 ? 'warning' : 'success'} />
          <Tag value={`Zuletzt: ${runtimeRecentItems.length}`} severity="info" />
          <Tag value={`Update: ${formatUpdatedAt(runtimeActivities?.updatedAt)}`} severity="secondary" />
          <Button
            type="button"
            icon="pi pi-trash"
            label="Liste leeren"
            severity="secondary"
            outlined
            size="small"
            onClick={() => {
              void handleClearRuntimeRecent();
            }}
            disabled={runtimeRecentItems.length === 0}
            loading={runtimeRecentClearing}
          />
        </div>

        {runtimeLoading && runtimeActiveItems.length === 0 && runtimeRecentItems.length === 0 ? (
          <p>Aktivitäten werden geladen ...</p>
        ) : (
          <div className="runtime-activity-grid pipeline-queue-grid">
            <div className="runtime-activity-col pipeline-queue-col">
              <h4>Aktiv</h4>
              {runtimeActiveItems.length === 0 ? (
                <small className="queue-empty-hint">Keine laufenden Skript-/Ketten-/Cron-Ausführungen.</small>
              ) : (
                <div className="runtime-activity-list">
                  {activeItemsDisplay.map((item, index) => {
                    const isChain = String(item?.type || '').trim().toLowerCase() === 'chain';
                    const chainChild = isChain ? findActiveChainChild(item?.id) : null;
                    const outputItem = chainChild || item;
                    const statusMeta = runtimeStatusMeta(item?.status);
                    const canCancel = Boolean(item?.canCancel);
                    const canNextStep = isChain && Boolean(item?.canNextStep);
                    const cancelBusy = isRuntimeActionBusy(item?.id, 'cancel');
                    const nextStepBusy = isRuntimeActionBusy(item?.id, 'next-step');
                    const stepLabel = item?.stepIndex != null && item?.stepTotal != null
                      ? `Schritt ${item.stepIndex}/${item.stepTotal}`
                      : item?.currentStep
                        ? 'Schritt'
                        : null;
                    return (
                      <div key={`runtime-active-${item?.id || index}`} className="runtime-activity-item running">
                        <div className="runtime-activity-head">
                          <strong>{item?.name || '-'}</strong>
                          <div className="runtime-activity-tags">
                            <Tag value={runtimeTypeLabel(item?.type)} severity="info" />
                            <Tag value={statusMeta.label} severity={statusMeta.severity} />
                          </div>
                        </div>
                        <small>
                          Quelle: {item?.source || '-'}
                          {item?.jobId ? ` | Job #${item.jobId}` : ''}
                          {item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
                        </small>
                        {isChain && (chainChild || item?.currentStep || item?.currentScriptName) ? (
                          <div className="runtime-chain-current">
                            <small>
                              {stepLabel ? <strong>{stepLabel}: </strong> : null}
                              {chainChild?.name || item?.currentScriptName || item?.currentStep || null}
                            </small>
                            {chainChild?.message ? <small>{chainChild.message}</small> : null}
                          </div>
                        ) : null}
                        {!isChain && item?.currentStep ? <small>Schritt: {item.currentStep}</small> : null}
                        {!isChain && item?.message ? <small>{item.message}</small> : null}
                        <RuntimeActivityDetails
                          item={outputItem}
                          summary="Live-Ausgabe anzeigen"
                        />
                        <small>Gestartet: {formatUpdatedAt(item?.startedAt)}</small>
                        {canCancel || canNextStep ? (
                          <div className="runtime-activity-actions">
                            {canNextStep ? (
                              <Button
                                type="button"
                                icon="pi pi-step-forward"
                                label="Nächster Schritt"
                                outlined
                                severity="secondary"
                                size="small"
                                loading={nextStepBusy}
                                disabled={cancelBusy}
                                onClick={() => {
                                  void handleRuntimeControl(item, 'next-step');
                                }}
                              />
                            ) : null}
                            {canCancel ? (
                              <Button
                                type="button"
                                icon="pi pi-stop"
                                label="Abbrechen"
                                outlined
                                severity="danger"
                                size="small"
                                loading={cancelBusy}
                                disabled={nextStepBusy}
                                onClick={() => {
                                  void handleRuntimeControl(item, 'cancel');
                                }}
                              />
                            ) : null}
                          </div>
                        ) : null}
                      </div>
                    );
                  })}
                </div>
              )}
            </div>

            <div className="runtime-activity-col pipeline-queue-col">
              <h4>Zuletzt abgeschlossen</h4>
              {runtimeRecentItems.length === 0 ? (
                <small className="queue-empty-hint">Keine abgeschlossenen Einträge vorhanden.</small>
              ) : (
                <div className="runtime-activity-list">
                  {recentItemsDisplay.map((item, index) => {
                    const outcomeMeta = runtimeOutcomeMeta(item?.outcome, item?.status);
                    return (
                      <div key={`runtime-recent-${item?.id || index}`} className="runtime-activity-item done">
                        <div className="runtime-activity-head">
                          <strong>{item?.name || '-'}</strong>
                          <div className="runtime-activity-tags">
                            <Tag value={runtimeTypeLabel(item?.type)} severity="info" />
                            <Tag value={outcomeMeta.label} severity={outcomeMeta.severity} />
                          </div>
                        </div>
                        <small>
                          Quelle: {item?.source || '-'}
                          {item?.jobId ? ` | Job #${item.jobId}` : ''}
                          {item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
                        </small>
                        {Number.isFinite(Number(item?.exitCode)) ? <small>Exit-Code: {item.exitCode}</small> : null}
                        {item?.message ? <small>{item.message}</small> : null}
                        {item?.errorMessage ? <small className="error-text">{item.errorMessage}</small> : null}
                        {hasRuntimeOutputDetails(item) ? (
                          <RuntimeActivityDetails
                            item={item}
                            summary="Details anzeigen"
                          />
                        ) : null}
                        <small>
                          Ende: {formatUpdatedAt(item?.finishedAt || item?.startedAt)}
                          {item?.durationMs != null ? ` | Dauer: ${formatDurationMs(item.durationMs)}` : ''}
                        </small>
                      </div>
                    );
                  })}
                </div>
              )}
            </div>
          </div>
        )}

        <div className="ripper-downloads-row">
          <h4>ZIP-Downloads</h4>
          <div className="ripper-downloads-meta">
            {downloadSummary?.activeCount > 0 ? (
              <Tag icon="pi pi-spinner" value={`${downloadSummary.activeCount} aktiv`} severity="warning" />
            ) : downloadSummary?.totalCount > 0 ? (
              <Tag icon="pi pi-check" value={downloadSummary.failedCount > 0 ? `Fertig (${downloadSummary.failedCount} fehlgeschlagen)` : `${downloadSummary.totalCount} fertig`} severity={downloadSummary.failedCount > 0 ? 'danger' : 'success'} />
            ) : (
              <Tag icon="pi pi-download" value="Keine aktiven Downloads" severity="secondary" />
            )}
            <Button
              label="Downloads öffnen"
              icon="pi pi-arrow-right"
              size="small"
              severity="secondary"
              outlined
              onClick={() => navigate('/downloads')}
            />
          </div>
        </div>
      </Card>
        </div>
      </div>

      {!isSubpageRoute && monitoringState.enabled ? (
        <Card title="Hardware Monitoring">
          <div className="hardware-monitor-head">
            {!monitoringState.enabled ? (
              <small>Hardware-Monitoring ist deaktiviert.</small>
            ) : !monitoringSample ? (
              <small>Warte auf erste Messung ...</small>
            ) : (
              <div className="hardware-monitor-grid">
                <section className="hardware-monitor-block">
                  <h4>CPU</h4>
                  <div className="hardware-cpu-summary">
                    <div className="hardware-cpu-chip" title="CPU Gesamtauslastung">
                      <i className="pi pi-chart-line" />
                      <span>{formatPercent(cpuMetrics?.overallUsagePercent)}</span>
                    </div>
                    <div className="hardware-cpu-chip" title="CPU Gesamttemperatur">
                      <i className="pi pi-bolt" />
                      <span>{formatTemperature(cpuMetrics?.overallTemperatureC)}</span>
                    </div>
                    <div className="hardware-cpu-load-group">
                      <div className="hardware-cpu-chip" title="CPU Load Average">
                        <i className="pi pi-chart-bar" />
                        <span>{Array.isArray(cpuMetrics?.loadAverage) ? cpuMetrics.loadAverage.join(' / ') : '-'}</span>
                      </div>
                      {cpuPerCoreMetrics.length > 0 ? (
                        <button
                          type="button"
                          className="hardware-cpu-core-toggle-btn"
                          onClick={() => setCpuCoresExpanded((prev) => !prev)}
                          aria-label={cpuCoresExpanded ? 'CPU-Kerne ausblenden' : 'CPU-Kerne einblenden'}
                          aria-expanded={cpuCoresExpanded}
                        >
                          <i className={`pi ${cpuCoresExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
                        </button>
                      ) : null}
                    </div>
                  </div>
                  {cpuPerCoreMetrics.length === 0 ? (
                    <small>Pro-Core-Daten sind noch nicht verfuegbar.</small>
                  ) : null}
                  {cpuPerCoreMetrics.length > 0 && cpuCoresExpanded ? (
                    <div className="hardware-core-grid compact">
                      {cpuPerCoreMetrics.map((core) => (
                        <div key={`core-${core.index}`} className="hardware-core-item compact">
                          <div className="hardware-core-title">C{core.index}</div>
                          <div className="hardware-core-metric" title="Auslastung">
                            <i className="pi pi-chart-line" />
                            <small>{formatPercent(core.usagePercent)}</small>
                          </div>
                          <div className="hardware-core-metric" title="Temperatur">
                            <i className="pi pi-bolt" />
                            <small>{formatTemperature(core.temperatureC)}</small>
                          </div>
                        </div>
                      ))}
                    </div>
                  ) : null}
                </section>

                <section className="hardware-monitor-block">
                  <h4>RAM</h4>
                  <div className="hardware-cpu-summary">
                    <div className="hardware-cpu-chip" title="RAM Auslastung">
                      <i className="pi pi-chart-pie" />
                      <span>{formatPercent(memoryMetrics?.usagePercent)}</span>
                    </div>
                    <div className="hardware-cpu-chip" title="RAM Belegt">
                      <i className="pi pi-arrow-up" />
                      <span>{formatBytes(memoryMetrics?.usedBytes)}</span>
                    </div>
                    <div className="hardware-cpu-chip" title="RAM Frei">
                      <i className="pi pi-arrow-down" />
                      <span>{formatBytes(memoryMetrics?.freeBytes)}</span>
                    </div>
                    <div className="hardware-cpu-chip" title="RAM Gesamt">
                      <i className="pi pi-database" />
                      <span>{formatBytes(memoryMetrics?.totalBytes)}</span>
                    </div>
                  </div>
                </section>

                <section className="hardware-monitor-block">
                  <h4>GPU</h4>
                  {!gpuMetrics?.available ? (
                    <small>{gpuMetrics?.message || 'Keine GPU-Metriken verfuegbar.'}</small>
                  ) : (
                    <div className="hardware-gpu-list">
                      {gpuDevices.map((gpu, index) => (
                        <div key={`gpu-${gpu?.index ?? index}`} className="hardware-gpu-item">
                          <strong>
                            GPU {gpu?.index ?? index}
                            {gpu?.name ? ` | ${gpu.name}` : ''}
                          </strong>
                          <span className="hardware-gpu-chip" title="GPU Load">
                            <i className="pi pi-chart-line" />{formatPercent(gpu?.utilizationPercent)}
                          </span>
                          <span className="hardware-gpu-chip" title="Speicher-Load">
                            <i className="pi pi-chart-pie" />{formatPercent(gpu?.memoryUtilizationPercent)}
                          </span>
                          <span className="hardware-gpu-chip" title="Temperatur">
                            <i className="pi pi-bolt" />{formatTemperature(gpu?.temperatureC)}
                          </span>
                          <span className="hardware-gpu-chip" title="VRAM">
                            <i className="pi pi-database" />{formatBytes(gpu?.memoryUsedBytes)} / {formatBytes(gpu?.memoryTotalBytes)}
                          </span>
                          <span className="hardware-gpu-chip" title="Power">
                            <i className="pi pi-flash" />
                            {Number.isFinite(Number(gpu?.powerDrawW)) ? `${gpu.powerDrawW} W` : 'n/a'}
                            {' / '}
                            {Number.isFinite(Number(gpu?.powerLimitW)) ? `${gpu.powerLimitW} W` : 'n/a'}
                          </span>
                        </div>
                      ))}
                    </div>
                  )}
                </section>
              </div>
            )}
          </div>
        </Card>
      ) : null}

      <MetadataSelectionDialog
        visible={metadataDialogVisible}
        context={effectiveMetadataDialogContext}
        onHide={() => {
          setMetadataDialogVisible(false);
          setMetadataDialogContext(null);
          setMetadataDialogReassignMode(false);
        }}
        onSubmit={handleMetadataSubmit}
        onSearch={handleMetadataSearch}
        busy={busy}
      />

      <CdMetadataDialog
        visible={cdMetadataDialogVisible}
        context={cdMetadataDialogContext || pipeline?.context || {}}
        onHide={() => {
          setCdMetadataDialogVisible(false);
          setCdMetadataDialogContext(null);
        }}
        onSubmit={handleCdMetadataSubmit}
        onSearch={handleMusicBrainzSearch}
        onFetchRelease={handleMusicBrainzReleaseFetch}
        busy={busy}
      />

      <Dialog
        header="Titel bereits in der Historie"
        visible={duplicateJobDialog.visible}
        onHide={() => setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null })}
        style={{ width: '30rem', maxWidth: '96vw' }}
        modal
      >
        {(() => {
          const pendingPayload = duplicateJobDialog.pendingPayload || {};
          const metadataProvider = String(pendingPayload?.metadataProvider || '').trim().toLowerCase();
          const seasonNumber = Number(pendingPayload?.seasonNumber || 0) || 0;
          const tmdbId = Number(pendingPayload?.tmdbId || 0) || 0;
          const isSeriesContainerAssign = metadataProvider === 'tmdb' && seasonNumber > 0 && tmdbId > 0;
          return (
            <>
        <p>
          <strong>{duplicateJobDialog.existingJob?.title || duplicateJobDialog.pendingPayload?.title}</strong> ist bereits als Job #{duplicateJobDialog.existingJob?.id} in der Historie vorhanden.
        </p>
        <p>
          {isSeriesContainerAssign
            ? 'Dieser Job kann dem vorhandenen Serien-Container automatisch zugewiesen werden.'
            : 'Neuen Job anlegen oder mit dem vorhandenen Eintrag weiterarbeiten?'}
        </p>
        <div className="dialog-actions">
          <Button
            label={isSeriesContainerAssign ? 'Vorhandenem Container zuweisen' : 'Vorhandenen Job öffnen'}
            icon={isSeriesContainerAssign ? 'pi pi-sitemap' : 'pi pi-history'}
            onClick={async () => {
              const payload = duplicateJobDialog.pendingPayload;
              const jobId = duplicateJobDialog.existingJob?.id;
              setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
              if (isSeriesContainerAssign) {
                await doSelectMetadata(payload);
                return;
              }
              navigate(`/history?open=${jobId}`);
            }}
          />
          {isSeriesContainerAssign ? (
            <Button
              label="Abbrechen"
              severity="secondary"
              outlined
              onClick={() => setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null })}
            />
          ) : (
            <Button
              label="Neuen Job anlegen"
              severity="secondary"
              outlined
              onClick={async () => {
                const payload = duplicateJobDialog.pendingPayload;
                setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
                await doSelectMetadata(payload);
              }}
            />
          )}
        </div>
            </>
          );
        })()}
      </Dialog>

      <Dialog
        header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
        visible={Boolean(cancelCleanupDialog.visible)}
        onHide={() => setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null })}
        style={{ width: '32rem', maxWidth: '96vw' }}
        modal
      >
        <p>
          {cancelCleanupDialog?.target === 'raw'
            ? 'Soll der bisher erzeugte RAW-Ordner gelöscht werden?'
            : 'Soll die bisher erzeugte Movie-Datei inklusive Job-Ordner im Ausgabeverzeichnis gelöscht werden?'}
        </p>
        {cancelCleanupDialog?.path ? (
          <small className="muted-inline">
            {cancelCleanupDialog?.target === 'raw' ? 'RAW-Pfad' : 'Output-Pfad'}: {cancelCleanupDialog.path}
          </small>
        ) : null}
        <div className="dialog-actions">
          <Button
            label="Behalten"
            severity="secondary"
            outlined
            onClick={() => setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null })}
            disabled={cancelCleanupBusy}
          />
          <Button
            label={cancelCleanupDialog?.target === 'raw' ? 'RAW löschen' : 'Movie löschen'}
            icon="pi pi-trash"
            severity="danger"
            onClick={handleDeleteCancelledOutput}
            loading={cancelCleanupBusy}
          />
        </div>
      </Dialog>

      <Dialog
        header="Queue-Eintrag einfügen"
        visible={insertQueueDialog.visible}
        onHide={() => setInsertQueueDialog({ visible: false, afterEntryId: null })}
        style={{ width: '28rem', maxWidth: '96vw' }}
        modal
      >
        <div className="queue-insert-dialog-body">
          <p className="queue-insert-dialog-hint">
            {insertQueueDialog.afterEntryId
              ? 'Eintrag wird nach dem ausgewählten Element eingefügt.'
              : 'Eintrag wird am Ende der Queue eingefügt.'}
          </p>

          {queueCatalog.scripts.length > 0 ? (
            <div className="queue-insert-section">
              <span className="queue-insert-section-label"><i className="pi pi-code" /> Skript</span>
              <div className="queue-insert-options">
                {queueCatalog.scripts.map((script) => (
                  <button
                    key={`qi-script-${script.id}`}
                    type="button"
                    className="queue-insert-option"
                    onClick={() => void handleAddQueueEntry('script', { scriptId: script.id })}
                  >
                    {script.name}
                  </button>
                ))}
              </div>
            </div>
          ) : null}

          {queueCatalog.chains.length > 0 ? (
            <div className="queue-insert-section">
              <span className="queue-insert-section-label"><i className="pi pi-link" /> Skriptkette</span>
              <div className="queue-insert-options">
                {queueCatalog.chains.map((chain) => (
                  <button
                    key={`qi-chain-${chain.id}`}
                    type="button"
                    className="queue-insert-option"
                    onClick={() => void handleAddQueueEntry('chain', { chainId: chain.id })}
                  >
                    {chain.name}
                  </button>
                ))}
              </div>
            </div>
          ) : null}

          <div className="queue-insert-section">
            <span className="queue-insert-section-label"><i className="pi pi-desktop" /> System</span>
            <div className="queue-insert-options">
              <div className="queue-insert-system-block">
                <span className="queue-insert-system-name"><i className="pi pi-clock" /> Warten</span>
                <InputNumber
                  value={insertWaitSeconds}
                  onValueChange={(e) => setInsertWaitSeconds(e.value ?? 30)}
                  min={1}
                  max={3600}
                  suffix="s"
                  inputStyle={{ width: '4.5rem' }}
                />
                <Button
                  icon="pi pi-check"
                  size="small"
                  title="Einfügen"
                  onClick={() => void handleAddQueueEntry('wait', { waitSeconds: insertWaitSeconds })}
                />
              </div>
            </div>
          </div>

          {queueCatalog.scripts.length === 0 && queueCatalog.chains.length === 0 ? (
            <small className="muted-inline">Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
          ) : null}
        </div>
      </Dialog>

      <Dialog
        header="Audible Activation Bytes eintragen"
        visible={activationBytesDialog.visible}
        onHide={() => setActivationBytesDialog({ visible: false, checksum: null, jobId: null })}
        style={{ width: '36rem', maxWidth: '96vw' }}
        modal
        footer={(
          <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
            <Button label="Abbrechen" severity="secondary" outlined onClick={() => setActivationBytesDialog({ visible: false, checksum: null, jobId: null })} disabled={activationBytesBusy} />
            <Button label="Speichern" icon="pi pi-check" onClick={() => void handleSaveActivationBytes()} loading={activationBytesBusy} disabled={activationBytesInput.trim().length !== 8} />
          </div>
        )}
      >
        <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
          <div>
            <label style={{ display: 'block', marginBottom: '0.375rem', fontWeight: 600 }}>Checksum (aus der AAX-Datei)</label>
            <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
              <InputText
                value={activationBytesDialog.checksum || ''}
                readOnly
                style={{ flex: 1, fontFamily: 'monospace', fontSize: '0.85rem' }}
              />
              <Button
                icon="pi pi-copy"
                severity="secondary"
                outlined
                tooltip="Kopieren"
                onClick={() => navigator.clipboard.writeText(activationBytesDialog.checksum || '')}
              />
            </div>
          </div>
          <div>
            <label style={{ display: 'block', marginBottom: '0.375rem', fontWeight: 600 }}>Activation Bytes (8 Hex-Zeichen)</label>
            <InputText
              value={activationBytesInput}
              onChange={(e) => setActivationBytesInput(e.target.value)}
              placeholder="z.B. 1a2b3c4d"
              style={{ width: '100%', fontFamily: 'monospace' }}
              maxLength={8}
            />
          </div>
          <div style={{ background: 'var(--surface-100)', borderRadius: '6px', padding: '0.875rem', fontSize: '0.875rem', lineHeight: 1.6 }}>
            <strong>So bekommst du die Activation Bytes:</strong>
            <ol style={{ margin: '0.5rem 0 0 1.25rem', padding: 0 }}>
              <li>Öffne <strong>audible-tools.kamsker.at</strong></li>
              <li>Aktiviere den <strong>Experten-Modus</strong></li>
              <li>Gib die obige Checksum ein</li>
              <li>Kopiere die zurückgegebenen Activation Bytes hier rein</li>
            </ol>
            <p style={{ margin: '0.5rem 0 0', color: 'var(--text-color-secondary)' }}>
              Die Bytes werden lokal gespeichert und für alle weiteren AAX-Dateien desselben Accounts wiederverwendet.
            </p>
          </div>
        </div>
      </Dialog>
    </div>
  );
}
