import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
  api,
  type PresetRef,
  type PresetSource,
  type SliceJobProgress,
  type SliceRequest,
  type SlicerCloudStatus,
  type UnifiedPreset,
  type UnifiedPresetsBySlot,
  type UnifiedPresetsResponse,
} from '../api/client';
import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
import { useToast } from '../contexts/ToastContext';
import { useIsWideLayout } from '../hooks/useIsWideLayout';
import { PlatePickerModal } from './PlatePickerModal';
import SlicerSettingsPanel, { type FilamentChoice } from './SlicerSettingsPanel';
import type { DesignOverride, PlateFilament } from '../types/plates';
import type { SettingValue } from '../types/slicerSettings';
import {
  presetCompatibility,
  buildCompatibilityIndex,
  EMPTY_COMPATIBILITY_INDEX,
  type PrinterCompatibilityIndex,
} from '../utils/slicerPrinterMatch';
import {
  findPreset,
  findPresetByName,
  pickDefault,
  pickFilamentForSlot,
  pickProcessDefault,
  type Slot,
} from '../utils/slicePresetPicker';

export type SliceSource =
  | { kind: 'libraryFile'; id: number; filename: string }
  | { kind: 'archive'; id: number; filename: string };

interface SliceModalProps {
  source: SliceSource;
  onClose: () => void;
}

function toRefValue(ref: PresetRef | null): string {
  // The HTML `<select>` value space is flat strings; encode source + id so
  // the same preset name can live in multiple tiers without collision.
  return ref ? `${ref.source}:${ref.id}` : '';
}

function fromRefValue(raw: string): PresetRef | null {
  if (!raw) return null;
  const idx = raw.indexOf(':');
  if (idx < 0) return null;
  const source = raw.slice(0, idx) as PresetSource;
  const id = raw.slice(idx + 1);
  if (source !== 'orca_cloud' && source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
  return { source, id };
}

// Inline spinner for the filament-requirements query. The backend runs a
// preview slice on first open of an unsliced project file (cached after);
// on a complex multi-color model that's a real slice — multi-second to
// multi-minute. The static "Analyzing plate filaments…" string left
// users wondering whether anything was happening, so the spinner now
// shows elapsed seconds, polls the sidecar's --pipe progress (via the
// /slicer/preview-progress proxy) for live stage + percent, and after ~5s
// surfaces a "this is a one-time slice — repeat opens are instant"
// note so users don't worry it'll be slow forever.
//
// requestId: a UUID generated by the modal when the filament-requirements
// fetch starts. Forwarded to the sidecar via the API call AND used here
// to poll the matching progress snapshot. Same id, two consumers.
function FilamentAnalysisSpinner({
  requestId,
  sourceName,
}: {
  requestId: string;
  sourceName: string;
}) {
  const { t } = useTranslation();
  const { showPersistentToast, dismissToast } = useToast();
  const [elapsed, setElapsed] = useState(0);
  const [progress, setProgress] = useState<SliceJobProgress | null>(null);
  // Defensive decode — see prettifyFilename comment in SliceJobTrackerContext.
  let prettyName = sourceName;
  try {
    prettyName = decodeURIComponent(sourceName);
  } catch {
    /* keep raw on malformed encoding */
  }

  // Elapsed-time tick.
  useEffect(() => {
    const startedAt = Date.now();
    const id = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
    return () => clearInterval(id);
  }, []);

  // Progress polling — once per second while the spinner is mounted.
  // Mirrors the slice-job tracker's cadence. Sidecar 404s during the
  // race window between fetch start and progressStore.start() are
  // swallowed by the API method (returns null) so we keep polling.
  useEffect(() => {
    let cancelled = false;
    const id = setInterval(async () => {
      if (cancelled) return;
      const snap = await api.getPreviewSliceProgress(requestId);
      if (!cancelled && snap) setProgress(snap);
    }, 1000);
    return () => {
      cancelled = true;
      clearInterval(id);
    };
  }, [requestId]);

  // Mirror the spinner's contents into a persistent toast so the user
  // sees activity even when their cursor is elsewhere on the page.
  // Dismissed in the parent's effect when the requirements arrive.
  const toastId = `slice-preview-${requestId}`;
  useEffect(() => {
    const hasUseful = progress && progress.stage && progress.total_percent > 0;
    const elapsedStr = formatElapsed(elapsed);
    if (hasUseful) {
      showPersistentToast(
        toastId,
        t(
          'slice.previewWithProgress',
          'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
          {
            name: prettyName,
            stage: progress!.stage,
            percent: Math.min(100, Math.max(0, Math.round(progress!.total_percent))),
            elapsed: elapsedStr,
          },
        ),
        'loading',
      );
    } else {
      showPersistentToast(
        toastId,
        t('slice.previewToast', {
          name: prettyName,
          elapsed: elapsedStr,
        }),
        'loading',
      );
    }
    return () => {
      dismissToast(toastId);
    };
  }, [elapsed, progress, prettyName, showPersistentToast, dismissToast, t, toastId]);

  const stage = progress?.stage;
  const percent = progress?.total_percent;
  const inlineLabel =
    stage && typeof percent === 'number' && percent > 0
      ? `${stage} (${Math.min(100, Math.max(0, Math.round(percent)))}%)`
      : t('slice.analyzingPlateFilaments');
  return (
    <div className="flex flex-col gap-1 text-bambu-gray text-sm py-2">
      <div className="flex items-center gap-2">
        <Loader2 className="w-4 h-4 animate-spin" />
        {inlineLabel}
        <span className="text-xs tabular-nums">{elapsed}s</span>
      </div>
      {elapsed >= 5 && (
        <div className="text-xs text-bambu-gray/70 pl-6">
          {t(
            'slice.analyzingPlateFilamentsHint',
            'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
          )}
        </div>
      )}
    </div>
  );
}

function formatElapsed(seconds: number): string {
  const s = Math.max(0, Math.floor(seconds));
  if (s < 60) return `${s}s`;
  const m = Math.floor(s / 60);
  const remS = s % 60;
  if (m < 60) return `${m}m ${remS}s`;
  const h = Math.floor(m / 60);
  const remM = m % 60;
  return `${h}h ${remM}m`;
}

export function SliceModal({ source, onClose }: SliceModalProps) {
  const { t } = useTranslation();
  const { trackJob } = useSliceJobTracker();
  const queryClient = useQueryClient();

  const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
  const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
  // One filament ref per plate slot, in plate order. For STL / single-plate /
  // single-color sources this is a one-element array; multi-color 3MFs get one
  // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
  // each slot from the source plate's required (type, colour).
  const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  // null = plate not yet picked (or single-plate / non-3MF — picker is skipped
  // and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
  // the user picks one (or implicitly for single-plate sources).
  const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
  // "Slice all plates" mode: sends ``plate=0`` to the backend which forwards
  // ``--slice 0`` to the BS CLI, producing a single output 3MF whose
  // ``Metadata/plate_N.gcode`` entries are *all* plates sliced together —
  // one archive, one file, all plates. Distinct from the per-plate
  // ``selectedPlate`` mode (which slices just that one plate). Filament
  // selection in this mode covers every slot the project defines, not
  // just the slots the currently-visible plate happens to use — see
  // ``allProjectFilamentSlots`` below.
  const [sliceAllPlates, setSliceAllPlates] = useState(false);
  // Build-plate override (#1337). null = inherit from the process preset
  // (the default). Set to a canonical slicer enum value to patch
  // curr_bed_type into the resolved process JSON before slicing — needed
  // because the process preset's default plate (typically "Cool Plate") is
  // incompatible with high-temp filaments like ABS / ASA / PC, and the
  // user had no way to switch plates without cloning the preset.
  const [bedType, setBedType] = useState<string | null>(null);

  // "Slice as designed" (#2611). When on, the backend honours the source
  // 3MF's embedded project_settings.config (the designer's own wall count,
  // infill, etc.) instead of the picked process/filament profiles. Only
  // offered when the picked printer matches the design's target model —
  // see canUseEmbedded below.
  const [useEmbedded, setUseEmbedded] = useState(false);

  // Auto-orient / auto-arrange (#2548) — the GUI's two layout buttons,
  // forwarded as the slicer's --orient / --arrange CLI actions. Per-slice
  // and off by default: both rewrite the object placement the file came
  // with, so they are something the user asks for, never a default. Kept
  // enabled in embedded mode, unlike the process-level options around
  // them — these act on the geometry, whichever config drives the slice.
  const [autoOrient, setAutoOrient] = useState(false);
  const [autoArrange, setAutoArrange] = useState(false);

  // #2622: process settings the designer changed away from the stock preset,
  // carried onto the picked process profile so a cross-printer re-slice keeps
  // the model's intended wall count / infill / first layer instead of losing
  // them to --load-settings. Keys the file flags as machine-coupled (speeds,
  // accelerations, prime-tower geometry) are listed but start unticked — those
  // were tuned for the designer's printer and can be plain wrong on another.
  const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());

  // Process settings the user edited by hand in the settings panel. Two shapes
  // are kept: the panel's editing values, and the same set serialised into the
  // string forms a process preset stores. The panel owns the option schema, so
  // it hands back both rather than making this component re-derive the second.
  const [processOverrides, setProcessOverrides] = useState<Record<string, SettingValue>>({});
  const [serializedProcessOverrides, setSerializedProcessOverrides] = useState<Record<string, string | string[]>>({});
  const [settingsExpanded, setSettingsExpanded] = useState(false);
  // Wide enough for the two-column layout, where the panel has a column to
  // itself and so is always open. The disclosure only exists for the narrow
  // single-stack layout, in which 348 unfolded options would bury the preset
  // pickers above them.
  const isWideLayout = useIsWideLayout();
  const panelOpen = isWideLayout || settingsExpanded;

  // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
  // with one pick, or save the current selection as a new pipeline.
  const pipelinesQuery = useQuery({
    queryKey: ['slicer-pipelines'],
    queryFn: () => api.listSlicerPipelines(),
    staleTime: 60_000,
  });
  const [savePipelineOpen, setSavePipelineOpen] = useState(false);
  const [pipelineDraftName, setPipelineDraftName] = useState('');
  const { showToast } = useToast();
  const createPipelineMutation = useMutation({
    mutationFn: (body: {
      name: string;
      printer_preset: PresetRef;
      process_preset: PresetRef;
      filament_presets: PresetRef[];
      bed_type: string | null;
    }) => api.createSlicerPipeline(body),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
      showToast(t('slice.pipelines.toast.saved', 'Pipeline saved'), 'success');
      setSavePipelineOpen(false);
      setPipelineDraftName('');
    },
    onError: (err: Error) => {
      showToast(err.message || t('slice.pipelines.toast.saveFailed', 'Save failed'), 'error');
    },
  });

  const platesQuery = useQuery({
    queryKey: ['slicePlates', source.kind, source.id],
    queryFn: async () => {
      if (source.kind === 'libraryFile') {
        return api.getLibraryFilePlates(source.id);
      }
      return api.getArchivePlates(source.id);
    },
    staleTime: 60_000,
  });

  const isMultiPlate =
    !!platesQuery.data?.is_multi_plate && (platesQuery.data?.plates?.length ?? 0) > 1;
  // Single-plate / non-3MF / fetch failure: skip the picker, default to plate 1
  // at submit time so the backend's existing default behaviour is preserved.
  const needsPlatePicker = isMultiPlate && selectedPlate == null;

  // Per-plate filament requirements via the same endpoint the print/schedule
  // modal uses. Reusing it here keeps the SliceModal honest with whatever
  // logic that endpoint applies (slice_info parsing, future enhancements for
  // unsliced project files, dual-nozzle fields, etc.) instead of duplicating
  // extraction. plate_id is always sent: single-plate falls through to plate
  // 1 server-side; multi-plate uses the user's pick.
  const effectivePlateId = selectedPlate ?? 1;
  // Generate a request_id per (source, plate) pair so the backend's
  // preview-slice and the FilamentAnalysisSpinner's progress poll share
  // the same id. useMemo keeps it stable across renders within the same
  // pair; switching plates regenerates so a stale poll doesn't bleed
  // progress between plates.
  const previewRequestId = useMemo(() => {
    const random =
      typeof crypto !== 'undefined' && 'randomUUID' in crypto
        ? crypto.randomUUID()
        : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
    // Tag the id with the (source, plate) so logs/Network panel show which
    // pair owns the poll. Also lets the lint rule see the deps in use.
    return `${source.kind}-${source.id}-p${effectivePlateId}-${random}`;
  }, [source.kind, source.id, effectivePlateId]);
  const filamentReqsQuery = useQuery({
    queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
    queryFn: async () => {
      // `fullSlots`: one row per project slot, not only the ones this plate
      // prints with. The list below is positional all the way to the CLI's
      // filament_N.json parts, so a source whose only used slot is 4 has to
      // present four rows — otherwise the single pick binds to slot 1 and
      // slot 4 slices with whatever the source had baked in (#2712). The
      // unused rows stay disabled exactly as before.
      if (source.kind === 'libraryFile') {
        return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
      }
      return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
    },
    enabled: !needsPlatePicker,
    staleTime: 60_000,
  });

  // Filament slot list for the active plate. Falls back to one synthetic slot
  // for STL/STEP and any "no metadata available" case so the modal still
  // works (single dropdown, mono-color slice). In ``sliceAllPlates`` mode
  // we keep the same slot list (the backend already returns every project
  // slot via ``extract_project_filaments_from_3mf``'s fallback path when
  // slice_info doesn't carry per-plate filaments) but override every
  // slot's ``used_in_plate`` flag to ``true`` so the dropdown labels
  // drop the "— not used by this plate" suffix and the dropdowns become
  // selectable. Across the whole project, every defined slot IS used by
  // at least one plate, so this is correct in slice-all mode.
  const filamentSlots = useMemo<PlateFilament[]>(() => {
    const reqs = filamentReqsQuery.data?.filaments ?? [];
    const base: PlateFilament[] =
      reqs.length > 0
        ? (reqs as PlateFilament[])
        : [{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 }];
    if (sliceAllPlates) {
      return base.map((slot) => ({ ...slot, used_in_plate: true }));
    }
    return base;
  }, [sliceAllPlates, filamentReqsQuery.data]);

  const presetsQuery = useQuery({
    queryKey: ['slicerPresets'],
    queryFn: () => api.getSlicerPresets(),
    staleTime: 60_000,
    // Don't fetch presets while the plate picker is on screen — saves a
    // round-trip if the user cancels out of the plate step.
    enabled: !platesQuery.isLoading && !needsPlatePicker,
  });

  // Manual refresh — bypasses the backend's 5-minute cloud cache and 1-hour
  // bundled cache for one call so users who deleted a preset in Bambu
  // Studio / Bambu Handy see the change immediately (#1581). The cache write
  // inside _fetch_cloud_presets / _fetch_bundled_presets refills with the
  // fresh result so subsequent normal callers still get cached responses.
  const [isRefreshing, setIsRefreshing] = useState(false);
  const handleRefreshPresets = async () => {
    if (isRefreshing) return;
    setIsRefreshing(true);
    try {
      const fresh = await api.getSlicerPresets({ refresh: true });
      queryClient.setQueryData(['slicerPresets'], fresh);
    } catch {
      // Fall through to invalidate so React Query retries via its normal
      // path on the next render — surfacing the failure through the existing
      // presetsQuery.isError banner instead of duplicating error UI here.
      queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
    } finally {
      setIsRefreshing(false);
    }
  };

  // Canonical Bambu printer-model registry — drives the @BBL <code> name
  // fallback in slicerPrinterMatch for cloud / standard presets (#1325).
  // Long staleTime: the registry only changes across backend releases.
  const printerModelsQuery = useQuery({
    queryKey: ['slicerPrinterModels'],
    queryFn: api.getSlicerPrinterModels,
    staleTime: Infinity,
  });

  // Selected-printer context for the process / filament filter (#1325).
  const selectedPrinterName = useMemo<string | null>(() => {
    if (!presetsQuery.data || !printerPreset) return null;
    return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
  }, [presetsQuery.data, printerPreset]);
  // Compatibility ground truth: the slicer's own `compatible_printers` list
  // on local-imported presets, plus the @BBL <code> name fallback for cloud
  // / standard presets via the backend Bambu printer-model registry.
  const compatIndex = useMemo<PrinterCompatibilityIndex>(
    () => buildCompatibilityIndex(printerModelsQuery.data ?? {}),
    [printerModelsQuery.data],
  );

  // The picked process preset's effective values, flattened by the sidecar.
  // Without this the settings panel shows OrcaSlicer's compiled-in defaults —
  // a preset with a 0.42mm line width would read 0, which is the C++ default
  // meaning "derive from the nozzle". Keyed on the preset so switching presets
  // re-baselines the panel.
  const presetValuesQuery = useQuery({
    queryKey: ['slicer-preset-values', processPreset?.source, processPreset?.id],
    queryFn: () => api.getSlicerPresetValues(processPreset as PresetRef),
    enabled: processPreset != null,
    // Preset contents only change when the user edits them in the slicer, and
    // the modal is short-lived; no need to re-fetch while it is open.
    staleTime: 5 * 60_000,
  });

  // A failed fetch is not an error the user must act on — the panel falls back
  // to schema defaults and says so — so treat "no data yet" as unresolved
  // rather than blocking the panel on it.
  const presetValues = presetValuesQuery.data?.values as Record<string, SettingValue> | undefined;
  const presetValuesResolved = presetValuesQuery.data?.resolved ?? presetValuesQuery.isLoading;
  // A failed request (rather than a 'resolved: false' answer) means we
  // never reached the backend, which is the same situation as an
  // unreachable sidecar as far as the user is concerned.
  const presetValuesReason = presetValuesQuery.data?.reason ?? (presetValuesQuery.isError ? 'sidecar_unavailable' : undefined);

  // Slot list for the settings panel's filament pickers (support base and
  // interface, and the Multimaterial page's per-region options). Those store a
  // plain integer, so without this the user has to map slot numbers onto their
  // own AMS by hand. Falls back to the slot's material when a slot has no pick
  // yet, so the list is never a column of blanks.
  const filamentChoices = useMemo<FilamentChoice[]>(() => {
    const data = presetsQuery.data;
    return filamentSlots.map((slot, idx) => {
      const ref = filamentPresets[idx] ?? null;
      const preset = data && ref ? findPreset(data, ref, 'filament') : null;
      return {
        index: idx + 1,
        label: preset?.name || slot.type || t('slice.filamentSlotUnset', 'not set'),
        color: slot.color || undefined,
      };
    });
  }, [filamentSlots, filamentPresets, presetsQuery.data, t]);

  // Printer / process preset names the source 3MF was prepared with. The
  // plates query resolves before the presets query (the latter is gated on
  // it), so these are known by the time the pre-pick effects run.
  const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
  const designOverrides = useMemo<DesignOverride[]>(
    () => platesQuery.data?.design_overrides ?? [],
    [platesQuery.data],
  );
  const embeddedProcess = platesQuery.data?.embedded_process ?? null;

  // "Slice as designed" is offered only when the source carries embedded
  // settings (a real project 3MF, not an STL) AND the picked printer matches
  // the design's target model. The match gate is load-bearing: honouring
  // embedded settings for a different model would place the model on the
  // wrong bed. Names come from the same preset namespace, so a normalised
  // (strip "# " prefix, case-fold) equality is enough.
  const canUseEmbedded = useMemo<boolean>(() => {
    if (!embeddedPrinter || !embeddedProcess || !selectedPrinterName) return false;
    const norm = (s: string) => s.replace(/^#\s*/, '').trim().toLowerCase();
    return norm(selectedPrinterName) === norm(embeddedPrinter);
  }, [embeddedPrinter, embeddedProcess, selectedPrinterName]);

  // Drop back to profile slicing whenever the toggle stops being offered
  // (e.g. the user switches to a printer that doesn't match the design).
  useEffect(() => {
    if (!canUseEmbedded) setUseEmbedded(false);
  }, [canUseEmbedded]);

  // Pre-tick the printer-independent design settings once the source's list
  // arrives. Machine-coupled keys stay off until the user opts in explicitly.
  useEffect(() => {
    setDesignKeys(new Set(designOverrides.filter((o) => !o.printer_coupled).map((o) => o.key)));
  }, [designOverrides]);

  // Printer pre-pick: defaults to the printer the 3MF was prepared for when
  // that preset is available, else the first listed printer. Runs once when
  // presets first arrive; later re-renders preserve any manual choice.
  useEffect(() => {
    const data = presetsQuery.data;
    if (!data) return;
    if (printerPreset == null) {
      setPrinterPreset(
        findPresetByName(data, 'printer', embeddedPrinter) ?? pickDefault(data, 'printer'),
      );
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [presetsQuery.data, embeddedPrinter]);

  // Process pre-pick / re-pick (#1325): defaults to a process compatible with
  // the selected printer, and re-defaults when a printer change leaves the
  // current process incompatible. A compatible or unknown manual pick is kept.
  useEffect(() => {
    const data = presetsQuery.data;
    if (!data) return;
    setProcessPreset((current) => {
      if (current) {
        const p = findPreset(data, current, 'process');
        if (p && presetCompatibility(p, 'process', selectedPrinterName, compatIndex) !== 'mismatch') {
          return current;
        }
      }
      return pickProcessDefault(data, selectedPrinterName, compatIndex, embeddedProcess);
    });
  }, [presetsQuery.data, selectedPrinterName, compatIndex, embeddedProcess]);

  // Filament pre-pick: re-runs when the active filament-slot count changes
  // (plate selection, single-plate metadata arriving) or the selected printer
  // changes. Each slot scores every available filament preset against the
  // slot's required (type, colour); an existing pick (incl. a user override)
  // is kept as long as it's still compatible with the selected printer, while
  // null slots and printer-incompatible picks are re-picked (#1325).
  useEffect(() => {
    const data = presetsQuery.data;
    if (!data) return;
    setFilamentPresets((current) => {
      return filamentSlots.map((slot, i) => {
        const cur = current[i] ?? null;
        if (cur) {
          const p = findPreset(data, cur, 'filament');
          if (p && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch') {
            return cur;
          }
        }
        return pickFilamentForSlot(
          data,
          { type: slot.type, color: slot.color },
          selectedPrinterName,
          compatIndex,
        );
      });
    });
  }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);

  const enqueueMutation = useMutation({
    mutationFn: async (plate: number | null) => {
      const body = buildSliceBody(plate);
      if (source.kind === 'libraryFile') {
        return api.sliceLibraryFile(source.id, body);
      }
      return api.sliceArchive(source.id, body);
    },
    onSuccess: (enqueue) => {
      trackJob(enqueue.job_id, source.kind, source.filename);
      onClose();
    },
    onError: (err: unknown) => {
      const msg = err instanceof Error ? err.message : String(err);
      setErrorMessage(msg);
    },
  });

  // Body builder shared by the single-plate and slice-all paths. ``plate``
  // is the 1-indexed plate number to slice, or ``null`` for STL / single-
  // plate 3MF sources where the field is omitted entirely.
  function buildSliceBody(plate: number | null): SliceRequest {
    if (
      !printerPreset ||
      !processPreset ||
      filamentPresets.length === 0 ||
      filamentPresets.some((r) => r == null)
    ) {
      throw new Error(t('slice.allPresetsRequired'));
    }
    return {
      printer_preset: printerPreset,
      process_preset: processPreset,
      filament_preset: filamentPresets[0] as PresetRef,
      filament_presets: filamentPresets as PresetRef[],
      ...(plate != null ? { plate } : {}),
      ...(bedType != null ? { bed_type: bedType } : {}),
      // The preset refs above are still sent (the backend validator requires
      // them) but go unused when this flag is set — the slicer falls back on
      // the file's embedded project_settings.config instead.
      ...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
      // Carried design settings are patched onto the resolved process JSON,
      // which the embedded-settings path never sends — so they are mutually
      // exclusive by construction (#2622).
      ...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
      // The user's own edits from the settings panel. Like design_overrides
      // these patch the resolved process JSON, so the embedded-settings path
      // (which sends no process JSON at all) cannot carry them.
      ...(!useEmbedded && Object.keys(processOverrides).length > 0
        ? { process_overrides: serializedProcessOverrides }
        : {}),
      // Sent only when on. The backend defaults both to false, so omitting
      // them keeps the request identical to what older clients send.
      ...(autoOrient ? { auto_orient: true } : {}),
      ...(autoArrange ? { auto_arrange: true } : {}),
    };
  }


  // Slice button stays disabled until the preview slice / embedded-metadata
  // read has succeeded (filamentReqsQuery.isSuccess) and every filament slot
  // has a picked profile.
  const isReady =
    printerPreset != null &&
    processPreset != null &&
    filamentReqsQuery.isSuccess &&
    filamentPresets.length > 0 &&
    filamentPresets.every((r) => r != null);
  const isEnqueuing = enqueueMutation.isPending;
  const totalPlateCount = platesQuery.data?.plates?.length ?? 0;
  const canSliceAll = isMultiPlate && totalPlateCount > 1 && !needsPlatePicker;

  // Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
  // entire flow (matches the existing PlatePickerModal contract used by the
  // archive g-code-viewer entry point).
  if (needsPlatePicker && platesQuery.data) {
    return (
      <PlatePickerModal
        plates={platesQuery.data.plates}
        onSelect={(plateIndex) => setSelectedPlate(plateIndex)}
        onClose={onClose}
      />
    );
  }

  // Step 2 (or only step for single-plate / non-3MF / load-failure): preset
  // picker. While the plates query is in-flight we still render the shell
  // because the presets query is gated on it; the loader covers both.
  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
      onClick={() => {
        if (!isEnqueuing) onClose();
      }}
    >
      <div
        className="w-full max-w-xl lg:max-w-5xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
        onClick={(e) => e.stopPropagation()}
      >
        {/* Header */}
        <div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
          <div className="min-w-0">
            <h3 className="text-white font-medium flex items-center gap-2">
              <Cog className="w-4 h-4" />
              {t('slice.title')}
            </h3>
            <p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
              {source.filename}
              {selectedPlate != null
                ? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}`
                : ''}
            </p>
          </div>
          <button
            onClick={onClose}
            disabled={isEnqueuing}
            className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors disabled:opacity-50"
            aria-label={t('common.close')}
          >
            <X className="w-5 h-5" />
          </button>
        </div>

        {/* Body */}
        <div className="flex-1 overflow-y-auto p-4 space-y-4">
          {/* Preset listing loader — printer/process dropdowns can't render
              without it. Plate query reuses the same spinner since it's
              also blocking. */}
          {(platesQuery.isLoading || presetsQuery.isLoading) && (
            <div className="flex items-center gap-2 text-bambu-gray text-sm">
              <Loader2 className="w-4 h-4 animate-spin" />
              {t('slice.loadingPresets')}
            </div>
          )}

          {presetsQuery.isError && (
            <div className="text-sm text-red-700 dark:text-red-400" role="alert">
              {t(
                'slice.presetsLoadFailed',
                'Failed to load presets. Open Settings → Profiles to import them, or sign in to Bambu Cloud.',
              )}
            </div>
          )}

          {presetsQuery.data && (
            <>
              <div className="flex items-start justify-between gap-2">
                <div className="flex-1 space-y-2">
                  <CloudStatusBanner status={presetsQuery.data.cloud_status} cloudName="bambu" />
                  <CloudStatusBanner status={presetsQuery.data.orca_cloud_status} cloudName="orca" />
                </div>
                <button
                  type="button"
                  onClick={handleRefreshPresets}
                  disabled={isRefreshing || isEnqueuing}
                  className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                  title={t('slice.refreshPresetsTitle')}
                  aria-label={t('slice.refreshPresets')}
                >
                  <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
                  {t('slice.refreshPresets')}
                </button>
              </div>
              {/* CloudStatusBanner above is hidden via flex-1 wrapper when
                  status === 'ok' (returns null in that case), but the Refresh
                  button stays visible regardless so users can pick up cloud /
                  bundled changes even when sign-in is healthy. */}
              {/* Two columns once there is room for them. The left keeps the
                  "what am I slicing with" decisions together; the right gives
                  the process-settings panel a column of its own, which is the
                  only way 348 options are comfortable to work through. Below
                  lg both collapse back into the original single stack. */}
              <div className="lg:grid lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-5 lg:items-start">
                <div className="space-y-4 min-w-0">
              {/* Slicer Pipelines (#1425): apply a saved preset bundle to all
                  four slots, or save the current selection as a pipeline.
                  Pipelines are managed in Settings → Workflow → Pipelines. */}
              <div className="flex flex-wrap items-center gap-2 px-2 py-1.5 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary">
                <span className="text-xs font-medium text-bambu-gray flex items-center gap-1">
                  <Cog className="w-3.5 h-3.5" /> {t('slice.pipelines.label', 'Pipeline')}
                </span>
                <select
                  value=""
                  disabled={isEnqueuing || (pipelinesQuery.data?.pipelines.length ?? 0) === 0}
                  onChange={(e) => {
                    const id = parseInt(e.target.value, 10);
                    if (Number.isNaN(id)) return;
                    const picked = pipelinesQuery.data?.pipelines.find((p) => p.id === id);
                    if (!picked) return;
                    // Apply slot state. The filament list is right-padded from
                    // current state so a pipeline with fewer entries than the
                    // current source's slot count keeps the existing tail.
                    setPrinterPreset(picked.printer_preset);
                    setProcessPreset(picked.process_preset);
                    setBedType(picked.bed_type);
                    setFilamentPresets((current) => {
                      const next = current.length > 0 ? [...current] : picked.filament_presets.map(() => null);
                      for (let i = 0; i < next.length; i++) {
                        if (i < picked.filament_presets.length) {
                          next[i] = picked.filament_presets[i];
                        }
                      }
                      return next;
                    });
                    showToast(t('slice.pipelines.toast.applied', 'Applied "{{name}}"', { name: picked.name }), 'success');
                    // Reset the dropdown so the user can re-apply the same
                    // pipeline if needed (selects don't fire onChange when
                    // value reselects the same option).
                    e.target.value = '';
                  }}
                  className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white disabled:opacity-50 disabled:cursor-not-allowed flex-1 min-w-[10ch]"
                  aria-label={t('slice.pipelines.applyAria', 'Apply pipeline')}
                >
                  <option value="">
                    {(pipelinesQuery.data?.pipelines.length ?? 0) === 0
                      ? t('slice.pipelines.empty', 'No saved pipelines')
                      : t('slice.pipelines.applyPrompt', 'Apply pipeline…')}
                  </option>
                  {pipelinesQuery.data?.pipelines.map((p) => (
                    <option key={p.id} value={p.id}>
                      {p.name}
                    </option>
                  ))}
                </select>
                {!savePipelineOpen ? (
                  <button
                    type="button"
                    onClick={() => {
                      setPipelineDraftName('');
                      setSavePipelineOpen(true);
                    }}
                    disabled={
                      isEnqueuing ||
                      !printerPreset ||
                      !processPreset ||
                      filamentPresets.length === 0 ||
                      filamentPresets.some((f) => f === null)
                    }
                    className="text-xs px-2 py-1 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green border border-bambu-green/40 rounded disabled:opacity-50 disabled:cursor-not-allowed"
                    title={t('slice.pipelines.saveTitle', 'Save the current four-slot selection as a reusable pipeline')}
                  >
                    {t('slice.pipelines.saveButton', 'Save as pipeline')}
                  </button>
                ) : (
                  <div className="flex items-center gap-1 flex-1 min-w-[16ch]">
                    <input
                      autoFocus
                      value={pipelineDraftName}
                      onChange={(e) => setPipelineDraftName(e.target.value)}
                      placeholder={t('slice.pipelines.namePlaceholder', 'Pipeline name')}
                      aria-label={t('slice.pipelines.nameAria', 'New pipeline name')}
                      className="flex-1 text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
                    />
                    <button
                      type="button"
                      onClick={() => {
                        const trimmed = pipelineDraftName.trim();
                        if (!trimmed || !printerPreset || !processPreset) return;
                        const nonNull = filamentPresets.filter((f): f is PresetRef => f !== null);
                        if (nonNull.length === 0) return;
                        createPipelineMutation.mutate({
                          name: trimmed,
                          printer_preset: printerPreset,
                          process_preset: processPreset,
                          filament_presets: nonNull,
                          bed_type: bedType,
                        });
                      }}
                      disabled={createPipelineMutation.isPending || !pipelineDraftName.trim()}
                      className="text-xs px-2 py-1 bg-bambu-green hover:bg-bambu-green/80 text-white rounded disabled:opacity-50"
                    >
                      {createPipelineMutation.isPending ? (
                        <Loader2 className="w-3 h-3 animate-spin" />
                      ) : (
                        t('common.save', 'Save')
                      )}
                    </button>
                    <button
                      type="button"
                      onClick={() => {
                        setSavePipelineOpen(false);
                        setPipelineDraftName('');
                      }}
                      className="text-xs px-2 py-1 text-bambu-gray hover:text-white"
                    >
                      {t('common.cancel', 'Cancel')}
                    </button>
                  </div>
                )}
              </div>
              <PresetDropdown
                label={t('slice.printer')}
                slot="printer"
                data={presetsQuery.data}
                value={printerPreset}
                onChange={setPrinterPreset}
                // Locked in embedded mode too: the picked printer is unused on
                // the embedded-settings path, and changing it away from the
                // design's target would drop canUseEmbedded and yank the toggle
                // out from under the user (#2611).
                disabled={isEnqueuing || useEmbedded}
              />
              {/* "Slice as designed" (#2611): honour the file's embedded
                  settings instead of the picked process/filament. Offered
                  only when the picked printer matches the design's target. */}
              {canUseEmbedded && (
                <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
                  <input
                    type="checkbox"
                    checked={useEmbedded}
                    onChange={(e) => setUseEmbedded(e.target.checked)}
                    disabled={isEnqueuing}
                    className="mt-0.5 cursor-pointer"
                  />
                  <span>
                    {t('slice.useEmbedded')}
                    <span className="block text-xs text-bambu-gray/70">
                      {t('slice.useEmbeddedHint')}
                    </span>
                  </span>
                </label>
              )}
              <PresetDropdown
                label={t('slice.process')}
                slot="process"
                data={presetsQuery.data}
                value={processPreset}
                onChange={setProcessPreset}
                disabled={isEnqueuing || useEmbedded}
                selectedPrinterName={selectedPrinterName}
                compatIndex={compatIndex}
              />

              {/* Bed-type override (#1337). Always visible, always enabled.
                  The backend patches curr_bed_type on the resolved process
                  JSON before forwarding to the sidecar. */}
              {/* Bed-type patches curr_bed_type onto the resolved process
                  JSON, which the embedded-settings path never sends — so it
                  has no effect there and is disabled to avoid implying it
                  does. */}
              <BedTypeDropdown
                value={bedType}
                onChange={setBedType}
                disabled={isEnqueuing || useEmbedded}
              />
              {/* Layout passes (#2548) — the GUI's "Auto orient" / "Auto
                  arrange". Not disabled in embedded mode: these are CLI
                  actions on the geometry, so they work regardless of where
                  the print config comes from. */}
              <div className="flex flex-col gap-2">
                <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
                  <input
                    type="checkbox"
                    checked={autoOrient}
                    onChange={(e) => setAutoOrient(e.target.checked)}
                    disabled={isEnqueuing}
                    className="mt-0.5 cursor-pointer"
                  />
                  <span>
                    {t('slice.autoOrient')}
                    <span className="block text-xs text-bambu-gray/70">
                      {t('slice.autoOrientHint')}
                    </span>
                  </span>
                </label>
                <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
                  <input
                    type="checkbox"
                    checked={autoArrange}
                    onChange={(e) => setAutoArrange(e.target.checked)}
                    disabled={isEnqueuing}
                    className="mt-0.5 cursor-pointer"
                  />
                  <span>
                    {t('slice.autoArrange')}
                    <span className="block text-xs text-bambu-gray/70">
                      {t('slice.autoArrangeHint')}
                    </span>
                  </span>
                </label>
              </div>
              {/* Filament reqs may need a server-side preview-slice for
                  unsliced project files (single-pass, then cached). Show a
                  scoped spinner so the user sees the printer/process
                  dropdowns instead of an opaque "Loading presets…" wait. */}
              {filamentReqsQuery.isLoading ? (
                <FilamentAnalysisSpinner
                  requestId={previewRequestId}
                  sourceName={source.filename}
                />
              ) : (
                filamentSlots.map((slot, idx) => {
                  // Slots flagged by the backend as not used by the
                  // picked plate are auto-picked from project metadata
                  // and disabled — the slicer CLI still needs a
                  // profile per project slot, but the user shouldn't
                  // have to think about slots their plate doesn't
                  // paint with. used_in_plate defaults to true when
                  // missing (sliced 3MFs and the no-flag legacy path).
                  const isUsed = slot.used_in_plate !== false;
                  const baseLabel =
                    filamentSlots.length > 1
                      ? t('slice.filamentSlot', {
                          index: idx + 1,
                          type: slot.type,
                        })
                      : t('slice.filament');
                  const label = isUsed
                    ? baseLabel
                    : `${baseLabel} ${t('slice.notUsedByPlate')}`;
                  return (
                    <PresetDropdown
                      key={`filament-${idx}`}
                      label={label}
                      slot="filament"
                      data={presetsQuery.data}
                      value={filamentPresets[idx] ?? null}
                      onChange={(ref) =>
                        setFilamentPresets((current) => {
                          const next = current.length === filamentSlots.length
                            ? [...current]
                            : filamentSlots.map((_, i) => current[i] ?? null);
                          next[idx] = ref;
                          return next;
                        })
                      }
                      disabled={isEnqueuing || !isUsed || useEmbedded}
                      swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
                      selectedPrinterName={selectedPrinterName}
                      compatIndex={compatIndex}
                    />
                  );
                })
              )}
                </div>

                {/* Right column: the settings panel. It owns this column, so
                    there is nothing to collapse it out of the way of — the
                    disclosure below lg exists only because the single-column
                    stack cannot afford 348 options unfolded.

                    Kept on screen in embedded mode but disabled rather than
                    removed: nothing here is sent on that path (the file's own
                    settings drive the slice), and dropping the column outright
                    made the dialog look like it had lost a feature whenever
                    the toggle was flipped. */}
                <div className="mt-4 lg:mt-0 min-w-0">
                  <div
                    className={`rounded border border-bambu-dark-tertiary p-3 ${useEmbedded ? 'opacity-60' : ''}`}
                  >
                    <button
                      type="button"
                      onClick={() => setSettingsExpanded((v) => !v)}
                      aria-expanded={panelOpen}
                      disabled={isWideLayout}
                      className="flex w-full items-center justify-between gap-2 text-left lg:cursor-default"
                    >
                      <span className="text-sm text-white">
                        {t('slice.processSettings', 'Process settings')}
                        <span className="block text-xs text-bambu-gray/70">
                          {useEmbedded
                            ? t(
                                'slice.processSettingsEmbedded',
                                "Not used while \"Use the file's built-in settings\" is on -- the file's own settings drive this slice.",
                              )
                            : t(
                                'slice.processSettingsHint',
                                "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.",
                              )}
                        </span>
                      </span>
                      <span className="shrink-0 text-xs text-bambu-gray">
                        {useEmbedded
                          ? t('slice.processSettingsInactive', 'Inactive')
                          : Object.keys(serializedProcessOverrides).length > 0
                            ? t('slice.processSettingsChanged', '{{count}} changed', {
                                count: Object.keys(serializedProcessOverrides).length,
                              })
                            : t('slice.processSettingsUnchanged', 'Preset defaults')}
                      </span>
                    </button>
                    {panelOpen && (
                      <div className="mt-3 border-t border-bambu-dark-tertiary pt-3">
                        <SlicerSettingsPanel
                          values={processOverrides}
                          onChange={(values, serialized) => {
                            setProcessOverrides(values);
                            setSerializedProcessOverrides(serialized);
                          }}
                          disabled={isEnqueuing || useEmbedded}
                          // The designer's own deviations (#2622) are shown
                          // against the options they belong to rather than in
                          // a list of their own. Only the tick state lives
                          // here; the values still travel as design_overrides,
                          // so the backend keeps reading them from the file
                          // and keys outside the vendored schema stay faithful.
                          filamentChoices={filamentChoices}
                          presetValues={presetValues}
                          presetValuesResolved={presetValuesResolved}
                          presetValuesReason={presetValuesReason}
                          sourceOverrides={designOverrides}
                          sourceSelected={designKeys}
                          onToggleSource={(key, on) =>
                            setDesignKeys((prev) => {
                              const next = new Set(prev);
                              if (on) next.add(key);
                              else next.delete(key);
                              return next;
                            })
                          }
                        />
                      </div>
                    )}
                  </div>
                </div>
              </div>
            </>
          )}

          {errorMessage && (
            <div className="text-sm text-red-700 dark:text-red-400 bg-red-900/20 border border-red-900/40 rounded p-2" role="alert">
              {errorMessage}
            </div>
          )}
        </div>

        {/* Footer */}
        <div className="flex-shrink-0 flex justify-end gap-2 px-4 py-3 border-t border-bambu-dark-tertiary/40">
          <button
            type="button"
            onClick={onClose}
            disabled={isEnqueuing}
            className="px-3 py-1.5 text-sm rounded-md border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray transition-colors disabled:opacity-50"
          >
            {t('common.cancel')}
          </button>
          {canSliceAll && (
            <label
              className="flex items-center gap-2 mr-auto text-sm text-bambu-gray cursor-pointer select-none"
              title={t('slice.actionAllTitle', { count: totalPlateCount })}
            >
              <input
                type="checkbox"
                checked={sliceAllPlates}
                onChange={(e) => setSliceAllPlates(e.target.checked)}
                disabled={isEnqueuing}
                className="cursor-pointer"
              />
              {t('slice.allPlatesToggle', { count: totalPlateCount })}
            </label>
          )}
          <button
            type="button"
            onClick={() => {
              setErrorMessage(null);
              // ``plate=0`` is the sidecar's "all plates" sentinel — passes
              // ``--slice 0`` to the BS CLI which produces a single 3MF
              // with one ``Metadata/plate_N.gcode`` entry per plate.
              const platePayload = sliceAllPlates ? 0 : selectedPlate;
              enqueueMutation.mutate(platePayload);
            }}
            disabled={!isReady || isEnqueuing}
            className="px-3 py-1.5 text-sm rounded-md bg-bambu-green hover:bg-bambu-green/90 text-bambu-dark font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
          >
            {isEnqueuing ? (
              <>
                <Loader2 className="w-4 h-4 animate-spin" />
                {t('slice.enqueuing')}
              </>
            ) : sliceAllPlates ? (
              t('slice.actionAll', { count: totalPlateCount })
            ) : (
              t('slice.action')
            )}
          </button>
        </div>
      </div>
    </div>
  );
}

function CloudStatusBanner({
  status,
  cloudName = 'bambu',
}: {
  status: SlicerCloudStatus;
  cloudName?: 'bambu' | 'orca';
}) {
  const { t } = useTranslation();
  // `ok` is the happy path. `not_authenticated` is silenced too: a user who
  // hasn't signed in (or has explicitly logged out — #1712) doesn't need a
  // permanent nag at the top of the modal; sign-in lives on the Profiles
  // page if they want it. Only `expired` and `unreachable` surface — those
  // are real breakage states a previously-signed-in user needs to see.
  if (status === 'ok' || status === 'not_authenticated') return null;

  // Same status vocabulary for both Bambu and Orca Cloud — only the
  // user-facing text varies. The fallbacks below name each cloud explicitly
  // so the banner makes sense without translation when i18n hasn't been
  // updated for a new locale.
  const messages =
    cloudName === 'orca'
      ? {
          expired: {
            key: 'slice.orcaCloud.expired',
            fallback: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
          },
          unreachable: {
            key: 'slice.orcaCloud.unreachable',
            fallback: 'Orca Cloud is unreachable right now. Other presets still work.',
          },
        }
      : {
          expired: {
            key: 'slice.cloud.expired',
            fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
          },
          unreachable: {
            key: 'slice.cloud.unreachable',
            fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
          },
        };

  const tones: Record<'expired' | 'unreachable', { tone: string; icon: typeof Cloud }> = {
    expired: {
      tone: 'border-amber-300 dark:border-amber-700/40 bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200',
      icon: CloudOff,
    },
    unreachable: {
      tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
      icon: CloudOff,
    },
  };
  const { tone, icon: Icon } = tones[status];
  const { key, fallback } = messages[status];
  return (
    <div className={`flex items-start gap-2 text-xs rounded-md border p-2 ${tone}`} role="status">
      <Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />
      <span>{t(key, fallback)}</span>
    </div>
  );
}

// Build-plate options offered in the SliceModal (#1337). Values are the
// canonical strings the slicer's StaticPrintConfig validator accepts as
// `curr_bed_type` — BambuStudio is the default sidecar, so this matches its
// enum; OrcaSlicer accepts the same set with a Supertack alias that users
// can target via the same dropdown if they re-import their presets.
const BED_TYPE_OPTIONS: { value: string; labelKey: string; fallback: string }[] = [
  { value: 'Cool Plate', labelKey: 'slice.bedType.coolPlate', fallback: 'Cool Plate' },
  {
    value: 'Cool Plate (SuperTack)',
    labelKey: 'slice.bedType.coolPlateSuperTack',
    fallback: 'Cool Plate SuperTack',
  },
  { value: 'Engineering Plate', labelKey: 'slice.bedType.engineering', fallback: 'Engineering Plate' },
  { value: 'High Temp Plate', labelKey: 'slice.bedType.highTemp', fallback: 'High Temp Plate' },
  { value: 'Textured PEI Plate', labelKey: 'slice.bedType.texturedPEI', fallback: 'Textured PEI Plate' },
  { value: 'Smooth PEI Plate', labelKey: 'slice.bedType.smoothPEI', fallback: 'Smooth PEI Plate' },
];

function BedTypeDropdown({
  value,
  onChange,
  disabled,
}: {
  value: string | null;
  onChange: (value: string | null) => void;
  disabled?: boolean;
}) {
  const { t } = useTranslation();
  return (
    <label className="block">
      <span className="block text-xs text-bambu-gray mb-1">
        {t('slice.bedType.label')}
      </span>
      <select
        value={value ?? ''}
        onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
        disabled={disabled}
        className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
      >
        <option value="">{t('slice.bedType.auto')}</option>
        {BED_TYPE_OPTIONS.map((opt) => (
          <option key={opt.value} value={opt.value}>
            {t(opt.labelKey, opt.fallback)}
          </option>
        ))}
      </select>
    </label>
  );
}

interface PresetDropdownProps {
  label: string;
  slot: Slot;
  data: UnifiedPresetsResponse;
  value: PresetRef | null;
  onChange: (ref: PresetRef | null) => void;
  disabled?: boolean;
  // Optional colour swatch shown next to the label — used for multi-color
  // filament slots so the user can see at a glance which slot they're
  // configuring against the source 3MF's per-slot colour.
  swatchColor?: string;
  // Selected printer context (#1325). When provided for a process / filament
  // slot, presets that resolve to a different printer (per compatIndex) are
  // held back behind a "Show all" link instead of padding out the main list.
  selectedPrinterName?: string | null;
  compatIndex?: PrinterCompatibilityIndex;
}

function PresetDropdown({
  label,
  slot,
  data,
  value,
  onChange,
  disabled,
  swatchColor,
  selectedPrinterName,
  compatIndex,
}: PresetDropdownProps) {
  const { t } = useTranslation();
  // Reveals the other-printer group for this slot only. Per-dropdown rather
  // than modal-wide: wanting a filament from another printer's library says
  // nothing about wanting its process profiles too.
  const [showAll, setShowAll] = useState(false);
  // Binds the label to the select now that they are siblings rather than
  // nested. Filament slots render several of these, so the id must be unique
  // per instance rather than derived from the slot name.
  const selectId = useId();

  // Tier sections (imported → cloud → standard), plus — for a process /
  // filament slot with a selected printer — a trailing group of presets that
  // resolve to a different printer (#1325). Compatibility-unknown presets
  // stay in their tier, so a custom / untagged preset is never hidden, and
  // empty sections collapse out.
  const { sections, otherEntries } = useMemo(() => {
    const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
      { key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
      { key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
      { key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' },
      { key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
    ];
    const filterByPrinter = slot !== 'printer';
    const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
    const other: UnifiedPreset[] = [];
    for (const { key, label: lk, fallback } of tiers) {
      const entries = (data[key] as UnifiedPresetsBySlot)[slot];
      if (!filterByPrinter) {
        if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries });
        continue;
      }
      const compatible: UnifiedPreset[] = [];
      for (const p of entries) {
        if (
          presetCompatibility(
            p,
            // filterByPrinter is true here, so slot is never 'printer'.
            slot as 'process' | 'filament',
            selectedPrinterName ?? null,
            compatIndex ?? EMPTY_COMPATIBILITY_INDEX,
          ) === 'mismatch'
        ) {
          other.push(p);
        } else {
          compatible.push(p);
        }
      }
      if (compatible.length > 0) {
        compatSections.push({ tierLabel: t(lk, fallback), entries: compatible });
      }
    }
    return { sections: compatSections, otherEntries: other };
  }, [data, slot, t, selectedPrinterName, compatIndex]);

  // Other-printer presets are held back by default so the list shows what is
  // usable on the selected printer. Two things are never hidden: a preset whose
  // compatibility is merely *unknown* (it never reaches otherEntries), and the
  // one currently selected — a pipeline or an auto-pick can land on a
  // cross-printer preset, and dropping it from the options would blank the
  // select and silently discard the choice.
  const selectedRefValue = toRefValue(value);
  const visibleOther = useMemo(() => {
    if (showAll) return otherEntries;
    return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
  }, [showAll, otherEntries, selectedRefValue]);

  const hiddenCount = otherEntries.length - visibleOther.length;
  const totalEntries =
    sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length;

  return (
    // A plain wrapper rather than a <label> around everything: the "Show all"
    // control is a button, and a button inside a label that also wraps the
    // select inherits the whole label as its accessible name (screen readers
    // announced it as "Process profile 2 hidden 0.20mm Standard @BBL X1C") as
    // well as being invalid HTML. The label is bound to the select by id.
    <div className="block">
      <div className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
        {swatchColor && (
          <span
            className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
            style={{ backgroundColor: swatchColor || 'transparent' }}
            aria-hidden
          />
        )}
        <label htmlFor={selectId}>{label}</label>
        {(hiddenCount > 0 || showAll) && (
          <span className="ml-auto flex items-center gap-1.5 font-normal">
            {hiddenCount > 0 && (
              <span className="text-bambu-gray/60">
                {t('slice.presetsHidden', '{{count}} hidden', { count: hiddenCount })}
              </span>
            )}
            <button
              type="button"
              onClick={() => setShowAll((v) => !v)}
              disabled={disabled}
              className="text-bambu-green hover:underline disabled:opacity-50 disabled:no-underline"
            >
              {showAll
                ? t('slice.showFewerPresets', 'Show fewer')
                : t('slice.showAllPresets', 'Show all')}
            </button>
          </span>
        )}
      </div>
      <select
        id={selectId}
        value={toRefValue(value)}
        onChange={(e) => onChange(fromRefValue(e.target.value))}
        disabled={disabled || totalEntries === 0}
        className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
      >
        <option value="">
          {totalEntries === 0
            ? t('slice.noPresetsForSlot')
            : t('slice.selectPreset')}
        </option>
        {sections.map((section) => (
          <optgroup key={section.tierLabel} label={section.tierLabel}>
            {section.entries.map((p) => (
              <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
                {p.name}
              </option>
            ))}
          </optgroup>
        ))}
        {visibleOther.length > 0 && (
          <optgroup label={t('slice.otherPrinters')}>
            {visibleOther.map((p) => (
              <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
                {p.name}
              </option>
            ))}
          </optgroup>
        )}
      </select>
    </div>
  );
}
