import { useEffect, useMemo, useRef, useState } from 'react';
import {
  Check, ChevronRight, Database, Image, Puzzle, Palette, ShieldCheck,
  AlertTriangle, Cloud, HardDrive, Server, CircleDot, FolderCog, FileBox,
  Package, CalendarDays, Layers, Download,
} from 'lucide-react';
import { Card, CardBody, CardHeader, CardTitle, CardFooter } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress';
import { Pagination } from '@/components/ui/Pagination';
import { Loading, ErrorState, Empty, Toast } from '@/components/ui/States';
import { api, bk, type Backup, type DestinationType, type LogEntry } from '@/lib/api';
import { useQuery, useMutation } from '@/hooks/useQuery';
import { formatBytes, backupSlug, formatDateTime, backupActualDestinations } from '@/lib/format';
import { cn } from '@/lib/cn';
import { useApp } from '@/App';

const steps = ['Choose backup', 'Select components', 'Pre-flight check', 'Confirm', 'Restoring'] as const;

/** Same palette BackupProgress uses, kept consistent so the live-log surface
 *  in the Restore wizard reads identically to the one in the backup panel. */
const logLevelClass: Record<string, string> = {
  info:    'text-ink-400',
  success: 'text-emerald-400',
  warn:    'text-amber-400',
  error:   'text-red-400',
};

const destIcon: Record<DestinationType, React.ComponentType<{ className?: string }>> = {
  local: HardDrive, ftp: Server, sftp: Server, google: Cloud, onedrive: Cloud,
  aws: Database, wasabi: Database, s3: Database,
};

/** All component keys, in display order. Plugins/themes/mu are code components:
 *  shown in the full build, hidden in a data-only build (bk.dataOnly) where the
 *  restore engine won't extract them anyway. */
const ALL_COMPONENTS = ['db', 'uploads', 'plugins', 'themes', 'mu', 'others'] as const;
type ComponentKey = typeof ALL_COMPONENTS[number];
const CODE_COMPONENTS: ComponentKey[] = ['plugins', 'themes', 'mu'];

const componentMeta: Record<ComponentKey, { title: string; desc: string; icon: React.ComponentType<{ className?: string }> }> = {
  db:      { title: 'Database', desc: 'All tables',                icon: Database  },
  uploads: { title: 'Uploads',  desc: 'wp-content/uploads',        icon: Image     },
  plugins: { title: 'Plugins',  desc: 'wp-content/plugins',        icon: Puzzle    },
  themes:  { title: 'Themes',   desc: 'wp-content/themes',         icon: Palette   },
  mu:      { title: 'Must-use', desc: 'wp-content/mu-plugins',     icon: FolderCog },
  others:  { title: 'Other',    desc: 'Misc wp-content files',     icon: FileBox   },
};

/**
 * Components the manual Restore wizard offers. Full-fidelity build restores
 * everything — database, uploads, plugins, themes, mu-plugins, and other
 * content. A data-only build (bk.dataOnly, i.e. BACKRELOAD_DATA_ONLY defined)
 * hides the executable-code components since the engine won't deploy them.
 */
const RESTORABLE_COMPONENTS: ComponentKey[] = bk.dataOnly
  ? ALL_COMPONENTS.filter((c) => !CODE_COMPONENTS.includes(c))
  : [...ALL_COMPONENTS];

export function Restore() {
  const backupsQ = useQuery(() => api.backups.list(), []);
  const destsQ   = useQuery(() => api.destinations.list(), []);
  const [step, setStep] = useState(0);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [components, setComponents] = useState<Record<ComponentKey, boolean>>(
    { db: true, uploads: true, plugins: true, themes: true, mu: true, others: true },
  );
  const [migrate, setMigrate]       = useState(true);
  // Restore mode — default is REPLACE (the conservative "rewind the site to
  // exactly this backup's state" semantics most users want when they're
  // restoring after a malware incident, broken plugin, or bad deploy).
  // Toggling Merge ON flips the engine into additive mode: same-named files
  // are overwritten from the backup, but files added since the backup
  // (uploads, new plugin installs, etc.) survive untouched.
  //
  // Internally the server still uses the `clean_restore` flag — which is
  // simply !mergeMode. Renaming on the UI side without churning the server
  // contract keeps the wire format stable and avoids breaking any tooling /
  // CLI calls that pass `clean_restore` directly.
  const [mergeMode, setMergeMode] = useState(false);

  // Source destination override. When a backup was uploaded to multiple
  // destinations, the restore engine auto-picks one — but the user might
  // want to control which copy to read from (e.g. the SFTP is faster than
  // the S3 bucket, or one destination is geographically closer). Empty
  // string = let the engine auto-resolve.
  const [sourceDestId, setSourceDestId] = useState<string>('');
  const [toast, setToast] = useState<{ msg: string; tone: 'success' | 'danger' } | null>(null);
  const [search, setSearch] = useState('');
  const [page, setPage]       = useState(1);
  const [perPage, setPerPage] = useState(10);
  const { restoreTarget, clearRestoreTarget, navigate } = useApp();

  // Current site URL — what the backup will be restored ONTO.
  const currentUrl = (window.Backreload?.siteUrl ?? window.location.origin).replace(/\/$/, '');

  const successful = (backupsQ.data?.items ?? []).filter((b) => b.status === 'success');
  const destById = useMemo(() => {
    // Carry `id` through so consumers (D.10 list-key fixes, primarily)
    // can use it as a stable React key without falling back to array
    // index. Cheap to include; not carrying it forced consumers to
    // pluck it from the lookup key, which gets awkward when the
    // collection is the result of a filter/map chain.
    const map: Record<string, { id: string; type: DestinationType; name: string; status: string; paused: boolean }> = {};
    for (const d of destsQ.data?.items ?? []) {
      map[d.id] = { id: d.id, type: d.type, name: d.name, status: d.status, paused: !!d.paused };
    }
    return map;
  }, [destsQ.data]);

  useEffect(() => {
    if (restoreTarget && successful.some((b) => b.id === restoreTarget)) {
      // User came from the Backups page clicking Restore on a specific row.
      // They already chose — don't ask them to pick again. Jump straight to
      // "Select components" so the wizard starts where the work actually is.
      setSelectedId(restoreTarget);
      setStep(1);
      clearRestoreTarget();
    } else if (!selectedId && successful.length > 0) {
      setSelectedId(successful[0].id);
    }
  }, [backupsQ.data, selectedId, successful, restoreTarget, clearRestoreTarget]);

  // How many backups got auto-imported during the post-restore reconcile.
  // Surfaced on the Restore-complete screen so the user understands why the
  // Backups list looks different after a restore (their snapshot DB might
  // have had fewer records than what's actually on their destinations).
  const [reconciled, setReconciled] = useState<number | null>(null);

  const restore = useMutation(
    (id: string) => api.backups.restore(id, {
      components: ALL_COMPONENTS.filter((k) => components[k]),
      migrate,
      // Replace-everything is the default — merge mode is opt-in.
      clean_restore: !mergeMode,
      // Honour the user's source-destination pick when they had more than
      // one to choose from. Empty string falls back to the engine's
      // auto-resolve (first connected non-paused destination that holds
      // this backup).
      destination: sourceDestId || undefined,
      // target_url/path default to the current site server-side; we just pass the source.
      source_url: selected?.source_url ?? undefined,
      source_path: selected?.source_path ?? undefined,
      target_url: currentUrl,
    }),
    {
      onSuccess: async () => {
        setToast({ msg: 'Restore complete', tone: 'success' });
        // POST-RESTORE RECONCILE — the restore replayed an older database
        // dump, which means the `backreload_backups` table now reflects the state
        // at backup time. Any backups that were created AFTER the snapshot
        // (or on a sibling site sharing this destination) are missing from
        // the list, which has been confusing — "my newer backups vanished
        // after I restored an old one". Auto-sync against every connected
        // destination so the list reflects what's actually on disk, not
        // just what was in the restored snapshot. Errors are non-fatal —
        // the restore itself succeeded; reconcile is a UX polish.
        try {
          const res = await api.backups.sync();
          setReconciled(res.imported.length);
        } catch { setReconciled(0); }
      },
      onError:   (e) => setToast({ msg: e.message, tone: 'danger' }),
    },
  );

  // Live-log feed for the restore-in-progress panel (step 4). Polls the
  // logs endpoint scoped to the backup we're restoring while the request is
  // in flight, exactly the same way BackupProgress polls during a backup.
  // The restore endpoint runs synchronously on the server, so the HTTP
  // request stays open for the entire restore — meanwhile this polling
  // surfaces the logger calls the engine is making (downloads, decryption,
  // SQL import, migration) so the user sees progress instead of a frozen
  // "Restoring…" button.
  const [restoreLogs, setRestoreLogs] = useState<LogEntry[]>([]);
  // Auto-scroll the log feed to the bottom on new lines, but only when
  // the user is already tailing (within 2 line heights of the bottom).
  // Without this guard, scrolling up to read an earlier line would yank
  // them back down every 2s. Mirrors BackupProgress + MigrationProgress.
  const restoreLogBoxRef = useRef<HTMLDivElement | null>(null);
  const restoreWasAtBottom = useRef(true);
  const RESTORE_STICK_THRESHOLD_PX = 32;
  useEffect(() => {
    if (step !== 4 || !selectedId) return;
    let cancelled = false;
    const tick = async () => {
      try {
        const res = await api.logs.list({ backup_id: selectedId, limit: 50 });
        if (cancelled) return;
        // Snapshot scroll position BEFORE the state update so the
        // post-render effect can decide whether to re-stick.
        const box = restoreLogBoxRef.current;
        if (box) {
          restoreWasAtBottom.current =
            (box.scrollHeight - box.scrollTop - box.clientHeight) <= RESTORE_STICK_THRESHOLD_PX;
        }
        setRestoreLogs(res.items);
      } catch { /* keep trying */ }
    };
    tick();
    const id = window.setInterval(tick, 2000);
    return () => { cancelled = true; window.clearInterval(id); };
  }, [step, selectedId]);

  // Re-stick to bottom after every log update if the user was tailing.
  useEffect(() => {
    const box = restoreLogBoxRef.current;
    if (!box) return;
    if (restoreWasAtBottom.current) {
      box.scrollTop = box.scrollHeight;
    }
  }, [restoreLogs]);


  /**
   * Reset the wizard back to "pick a backup". Used by:
   *   - The Finish button after a completed restore (so the user can do
   *     another one without leaving the page).
   *   - The "Restore failed → Try again" path (clears the mutation error so
   *     Continue / Start restore is clickable again).
   *
   * Keeps state clean across runs — without this, the second restore on a
   * page session would reuse the previous selection/log feed.
   */
  const resetWizard = () => {
    setStep(0);
    setSelectedId(null);
    setComponents({ db: false, uploads: false, plugins: false, themes: false, mu: false, others: false });
    setRestoreLogs([]);
    setReconciled(null);
    setMergeMode(false);
    setSourceDestId('');
    restore.reset();
  };

  // NOTE: All hooks must run on every render. Compute `selected` and any derived
  // hook state BEFORE the loading/error/empty early returns — moving them below
  // would violate React's Rules of Hooks (the hook count between renders would
  // change as data arrives, and the page would crash to blank).
  const selected: Backup | undefined = successful.find((b) => b.id === selectedId) ?? successful[0];

  /**
   * Components the selected backup actually contains.
   *
   * - Multi-zip records expose this directly via files[].category. A `legacy`
   *   category means the backup is a single full archive (imported or pre-rewrite) —
   *   surface all six components since the server filters at extract time.
   * - Records with no files[] at all (very old) fall back to whatever was recorded
   *   in `components` — usually [db, uploads, themes, plugins].
   */
  const availableComponents: ComponentKey[] = useMemo(() => {
    if (!selected) return [];
    // RESTORABLE_COMPONENTS excludes code (plugins/themes/mu) in a data-only
    // build; in the full build it's all six.
    if (selected.files && selected.files.length > 0) {
      const cats = new Set(selected.files.map((f) => f.category));
      if (cats.has('legacy')) return [...RESTORABLE_COMPONENTS];
      return RESTORABLE_COMPONENTS.filter((c) => cats.has(c));
    }
    // Pre-rewrite legacy: trust the components[] field
    return RESTORABLE_COMPONENTS.filter((c) => (selected.components ?? []).includes(c));
  }, [selected]);

  // Whenever the user picks a different backup, pre-check every available
  // component and clear the rest. Keyed off `selectedId` (the stable handle
  // the user changed) NOT `selected` (the resolved record, which is derived
  // and would re-run this whenever its files[] mutated — e.g. a sync
  // updated uploaded_to). `availableComponents` is read inside the effect
  // body using whatever value it has at run time; we intentionally don't
  // depend on it because we want the user's explicit pick to be the
  // trigger, not a data refresh.
  useEffect(() => {
    if (!selected) return;
    const next: Record<ComponentKey, boolean> = { db: false, uploads: false, plugins: false, themes: false, mu: false, others: false };
    for (const c of availableComponents) next[c] = true;
    setComponents(next);
    // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment above; reacting to `selected`/`availableComponents` would flip checkboxes the user just touched.
  }, [selectedId]);

  /**
   * Bytes-on-disk for the user's current component selection.
   *   - Multi-zip: sum the size of every part whose category is checked.
   *     `legacy` parts count toward every selected component (single archive).
   *   - Legacy records: we can't break it apart, show full size as an upper bound.
   */
  const selectedSize = useMemo(() => {
    if (!selected) return 0;
    const checked = ALL_COMPONENTS.filter((c) => components[c]);
    if (!selected.files || selected.files.length === 0) return selected.size;
    let total = 0;
    let countedLegacy = false;
    for (const f of selected.files) {
      if (f.category === 'legacy') {
        // Full archive; count once if anything is selected.
        if (!countedLegacy && checked.length > 0) { total += f.size; countedLegacy = true; }
      } else if (checked.includes(f.category as ComponentKey)) {
        total += f.size;
      }
    }
    return total;
  }, [selected, components]);

  const anyComponentSelected = ALL_COMPONENTS.some((c) => components[c]);

  // Filter, then paginate. We don't pin the selected backup into the visible
  // slice — the Pagination control lets the user jump pages directly, and the
  // selected ID is still tracked in state regardless of which page is on screen.
  const filteredSuccessful = useMemo(() => {
    const q = search.trim().toLowerCase();
    if (!q) return successful;
    return successful.filter((b) => {
      const slug = backupSlug(b.date, b.id).toLowerCase();
      const date = b.date ? new Date(b.date).toLocaleString().toLowerCase() : '';
      return b.id.toLowerCase().includes(q) || slug.includes(q) || date.includes(q);
    });
  }, [successful, search]);

  // Pagination guards. Two distinct triggers, each guarded against
  // no-op setPage() calls so a render flush from one effect doesn't
  // pointlessly re-fire the other:
  //
  //   - Search changes  → snap back to page 1 (a user typing a fresh
  //                       query expects results from the top).
  //   - Data shrinks   → if the current page no longer has rows (e.g.
  //                       a retention sweep removed half the list while
  //                       the user was reading), clamp down.
  //
  // The `page !== ...` guards matter because without them, fast typing
  // could trigger render → setPage → render → setPage in tight succession
  // (each effect committing its own update). The guards make every
  // setState a true state change.
  const lastPage = Math.max(1, Math.ceil(filteredSuccessful.length / perPage));
  useEffect(() => { if (page !== 1) setPage(1); }, [search]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally omits `page`: we only want to react to search changes, not page changes.
  useEffect(() => { if (page > lastPage) setPage(lastPage); }, [lastPage, page]);

  const pageStart = (page - 1) * perPage;
  const visibleSuccessful = filteredSuccessful.slice(pageStart, pageStart + perPage);

  if (backupsQ.isLoading || destsQ.isLoading) return <Loading label="Loading backups…" />;
  if (backupsQ.error) return <ErrorState error={backupsQ.error} onRetry={backupsQ.refetch} />;
  if (successful.length === 0) {
    return <Empty title="No backups to restore from" hint="Create a successful backup first." />;
  }

  /**
   * A backup is restorable if EITHER:
   *   - one of its original destinations is still connected, OR
   *   - any Local destination is connected (the backend will scan its path for the file).
   * This mirrors the fallback in RestoreEngine::resolve_source().
   */
  // "Usable" everywhere means connected AND not paused — paused destinations
  // are intentionally skipped by the engine, so showing them as restore
  // sources would mislead the user.
  const anyConnectedLocal = (destsQ.data?.items ?? []).some(
    (d) => d.type === 'local' && d.status === 'connected' && !d.paused,
  );
  const isRestorable = (b: Backup) =>
    // A backup is restorable if any destination that actually holds a copy is
    // still usable, OR a Local destination exists that we can fall back to.
    backupActualDestinations(b).some((id) => {
      const d = destById[id];
      return d && d.status === 'connected' && !d.paused;
    }) || anyConnectedLocal;
  const selectedRestorable = !!selected && isRestorable(selected);

  // Hide back button on first/last step (saves a row, removes confusing disabled state)
  const showBack = step > 0 && step < 4;

  return (
    <div className="max-w-4xl space-y-5">
      {/* Stepper */}
      <div className="flex items-center gap-2 flex-wrap">
        {steps.map((s, i) => (
          <div key={s} className="flex items-center gap-2">
            <div className={cn(
              'h-7 w-7 rounded-full grid place-items-center text-xs font-medium border transition-colors',
              i < step && 'bg-brand-500 text-white border-brand-500',
              i === step && 'bg-ink-900 text-white border-ink-900',
              i > step && 'bg-white text-ink-400 border-ink-200',
            )}>
              {i < step ? <Check className="h-3.5 w-3.5" /> : i + 1}
            </div>
            <span className={cn('text-xs', i === step ? 'text-ink-900 font-medium' : i < step ? 'text-ink-700' : 'text-ink-400')}>{s}</span>
            {i < steps.length - 1 && <ChevronRight className="h-3.5 w-3.5 text-ink-300 mx-1" />}
          </div>
        ))}
      </div>

      <Card>
        {/* Step 0 — Choose backup */}
        {step === 0 && (
          <>
            <CardHeader>
              <div className="flex-1 min-w-0">
                <CardTitle>Pick a backup to restore</CardTitle>
                <p className="text-xs text-ink-500 mt-0.5">
                  {successful.length} successful {successful.length === 1 ? 'backup' : 'backups'} available
                  {search.trim() && filteredSuccessful.length !== successful.length && (
                    <> · <span className="text-brand-700">{filteredSuccessful.length} match{filteredSuccessful.length === 1 ? '' : 'es'}</span></>
                  )}
                </p>
              </div>
              {/* Search shows up only when there are enough rows to make it useful. */}
              {successful.length > 5 && (
                <input
                  type="search"
                  placeholder="Search by ID or date…"
                  value={search}
                  onChange={(e) => setSearch(e.target.value)}
                  className="h-8 px-2.5 text-xs rounded-md border border-ink-200 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-brand-500 w-56"
                />
              )}
            </CardHeader>
            <CardBody className="space-y-2">
              {filteredSuccessful.length === 0 && (
                <p className="text-sm text-ink-500 py-6 text-center">
                  No backups match "{search}".
                </p>
              )}
              {visibleSuccessful.map((b) => {
                const isSel    = selectedId === b.id;
                const restorable = isRestorable(b);
                // Check destinations that actually hold a copy AND are still usable.
                const originalReachable = backupActualDestinations(b).some((id) => {
                  const d = destById[id];
                  return d && d.status === 'connected' && !d.paused;
                });
                const onlyViaLocal = !originalReachable && anyConnectedLocal;
                // Explain to the user WHY a backup is unrestorable so they
                // can fix it instead of just seeing a red "no source" badge.
                const unrestorableReason = (() => {
                  if (restorable) return '';
                  const actual = backupActualDestinations(b);
                  if (actual.length === 0) {
                    return 'No destination ever received the parts (everything was paused or failed during backup). Run a fresh backup with at least one destination active.';
                  }
                  const names = actual.map((id) => destById[id]?.name ?? id);
                  const pausedNames = actual
                    .map((id) => destById[id])
                    .filter((d): d is NonNullable<typeof d> => !!d && d.paused)
                    .map((d) => d.name);
                  if (pausedNames.length === actual.length) {
                    return `All destinations holding this backup are paused: ${pausedNames.join(', ')}. Resume one to restore from it.`;
                  }
                  return `Destinations holding this backup are no longer connected: ${names.join(', ')}. Reconnect one, or restore via a Local Storage destination if you have a copy locally.`;
                })();
                return (
                  <button
                    key={b.id}
                    type="button"
                    onClick={() => restorable && setSelectedId(b.id)}
                    disabled={!restorable}
                    className={cn(
                      'w-full !justify-start flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all',
                      isSel  ? 'border-brand-500 bg-brand-50/60'
                             : 'border-ink-200 hover:border-ink-300 hover:bg-ink-50/60',
                      !restorable && 'opacity-50 cursor-not-allowed',
                    )}
                  >
                    {/* Custom selection indicator */}
                    <span className={cn(
                      'h-5 w-5 rounded-full border-2 grid place-items-center shrink-0',
                      isSel ? 'border-brand-500 bg-brand-500' : 'border-ink-300 bg-white',
                    )}>
                      {isSel && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
                    </span>

                    <div className="flex-1 min-w-0">
                      <div className="flex items-center gap-2 flex-wrap">
                        <span className="font-mono text-xs text-ink-900 font-medium" title={b.id}>
                          {backupSlug(b.date, b.id)}
                        </span>
                        <Badge tone={b.type === 'full' ? 'accent' : 'neutral'}>{b.type}</Badge>
                        {!restorable && (
                          <Badge tone="danger" dot title={unrestorableReason}>no source</Badge>
                        )}
                        {onlyViaLocal && <Badge tone="warning" dot>local fallback</Badge>}
                      </div>
                      <p className="text-xs text-ink-500 mt-1">
                        {formatDateTime(b.date)} · {formatBytes(b.size)}
                      </p>
                    </div>

                    {/* Source(s) the restore will actually read from */}
                    <div className="flex items-center gap-1.5 shrink-0">
                      {(() => {
                        // Only show destinations the backup ACTUALLY has parts
                        // on, AND that are still usable (connected + not
                        // paused). A target that was paused at backup time
                        // received no parts and can't be a restore source.
                        const actualIds = backupActualDestinations(b);
                        const reachableOriginals = actualIds
                          .map((id) => destById[id])
                          .filter((d): d is NonNullable<typeof d> => !!d && d.status === 'connected' && !d.paused);

                        if (reachableOriginals.length > 0) {
                          return reachableOriginals.map((d) => {
                            const Icon = destIcon[d.type] ?? Cloud;
                            return (
                              <span key={d.id} title={d.name}
                                className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-[11px] font-medium bg-ink-100 text-ink-700">
                                <Icon className="h-3 w-3" />
                                {d.name}
                              </span>
                            );
                          });
                        }

                        // Original destinations are gone — fall back to any
                        // CONNECTED AND NOT PAUSED Local Storage. Including paused
                        // ones here would suggest a restore source that the engine
                        // would then skip — leaving the user confused why Continue
                        // is disabled despite the chip showing "Local Storage".
                        const fallback = (destsQ.data?.items ?? []).filter(
                          (d) => d.type === 'local' && d.status === 'connected' && !d.paused,
                        );
                        if (fallback.length > 0) {
                          return fallback.map((d) => {
                            const Icon = destIcon.local;
                            return (
                              <span key={d.id} title={`${d.name}. Used as a local fallback because the original destination was removed.`}
                                className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-[11px] font-medium bg-amber-50 text-amber-800 ring-1 ring-amber-200">
                                <Icon className="h-3 w-3" />
                                {d.name}
                              </span>
                            );
                          });
                        }

                        // Truly unrecoverable
                        return (
                          <span className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-[11px] font-medium bg-ink-100 text-ink-400 line-through">
                            <CircleDot className="h-3 w-3" />
                            removed
                          </span>
                        );
                      })()}
                    </div>
                  </button>
                );
              })}

              {filteredSuccessful.length > 0 && (
                <Pagination
                  totalItems={filteredSuccessful.length}
                  page={page}
                  perPage={perPage}
                  onPageChange={setPage}
                  onPerPageChange={setPerPage}
                  itemLabel="backups"
                  className="pt-3 mt-1 border-t border-ink-100"
                />
              )}
            </CardBody>
          </>
        )}

        {/* Step 1 — Components */}
        {step === 1 && (
          <>
            <CardHeader>
              <div className="flex-1 min-w-0">
                <CardTitle>What should we restore?</CardTitle>
                <p className="text-xs text-ink-500 mt-0.5">
                  Only components this backup contains are shown.
                </p>
              </div>
              {/* Context badge — when the user landed here directly from clicking
                  Restore on a backup row, this is the only place they see which
                  backup they're configuring. Click to go back to the picker. */}
              {selected && (
                <button
                  type="button"
                  onClick={() => setStep(0)}
                  title="Change backup"
                  className="shrink-0 inline-flex items-center gap-2 px-2.5 h-8 rounded-md border border-ink-200 hover:border-brand-400 hover:bg-brand-50/40 text-left transition-colors"
                >
                  <span className="text-[10px] uppercase tracking-wider text-ink-500 font-medium">Restoring</span>
                  <span className="font-mono text-[11px] text-ink-800 truncate max-w-[180px]">
                    {backupSlug(selected.date, selected.id)}
                  </span>
                  <ChevronRight className="h-3 w-3 text-ink-400" />
                </button>
              )}
            </CardHeader>
            <CardBody className="grid grid-cols-1 sm:grid-cols-2 gap-3">
              {availableComponents.length === 0 && (
                <p className="text-sm text-ink-500 col-span-full">
                  This backup doesn't have any restorable components recorded.
                </p>
              )}
              {availableComponents.map((key) => {
                const meta = componentMeta[key];
                const Icon = meta.icon;
                return (
                  <Component
                    key={key}
                    icon={<Icon className="h-4 w-4" />}
                    title={meta.title}
                    desc={meta.desc}
                    checked={components[key]}
                    onChange={(v) => setComponents({ ...components, [key]: v })}
                  />
                );
              })}
            </CardBody>
          </>
        )}

        {/* Step 2. Pre-flight (REAL checks, not hardcoded) */}
        {step === 2 && (
          <PreflightStep
            selected={selected}
            isSourceReachable={selectedRestorable}
            destBlocking={(destsQ.data?.items ?? [])
              .filter((d) => selected?.destinations.includes(d.id))
              .some((d) => d.status !== 'connected')}
          />
        )}

        {/* Step 3. Confirm */}
        {step === 3 && (
          <>
            <CardHeader><CardTitle>Review and confirm</CardTitle></CardHeader>
            <CardBody>
              <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 flex gap-3">
                <AlertTriangle className="h-5 w-5 text-amber-600 shrink-0" />
                <div>
                  <p className="text-sm font-medium text-amber-900">This will overwrite your site</p>
                  <p className="text-xs text-amber-800 mt-1">Maintenance mode will be enabled during restore.</p>
                </div>
              </div>
              {/* Summary card. four facts the user needs to verify before
                  clicking Start restore. Designed as a single bordered
                  container divided into four quadrants by a 2x2 grid so the
                  fields read as one cohesive "review panel" instead of four
                  floating tiles. Each quadrant is icon-led for fast visual
                  scan: BACKUP / DATE on top, DOWNLOAD SIZE / COMPONENTS on
                  bottom (the order most users care about: what backup, when,
                  how big, what's included).

                  Components is the most variable-length field. on a full
                  backup it's "Database, Uploads, Plugins, Themes, MU-Plugins,
                  Other" which would overflow a single line. The grid lets it
                  wrap inside its cell without breaking the layout. */}
              <div className="mt-4 rounded-xl border border-ink-200 bg-white overflow-hidden">
                <dl className="grid grid-cols-1 sm:grid-cols-2 divide-y sm:divide-y-0 divide-ink-100 sm:[&>*:nth-child(-n+2)]:border-b sm:[&>*:nth-child(-n+2)]:border-ink-100 sm:[&>*:nth-child(odd)]:border-r sm:[&>*:nth-child(odd)]:border-ink-100">
                  <SummaryRow
                    icon={Package}
                    label="Backup"
                    value={selected ? backupSlug(selected.date, selected.id) : ''}
                    mono
                  />
                  <SummaryRow
                    icon={CalendarDays}
                    label="Date"
                    value={formatDateTime(selected?.date)}
                  />
                  <SummaryRow
                    icon={Download}
                    label="Download size"
                    value={
                      selected
                        ? formatBytes(selectedSize) +
                          (selectedSize !== selected.size ? ` of ${formatBytes(selected.size)}` : '')
                        : ''
                    }
                  />
                  <SummaryRow
                    icon={Layers}
                    label="Components"
                    value={ALL_COMPONENTS.filter((c) => components[c]).map((c) => componentMeta[c].title).join(', ') || '-'}
                  />
                </dl>
              </div>

              {/* Source destination picker. Only shown when the backup has
                  parts on 2+ usable destinations. Designed as a stacked
                  radio-list (one option per row, full-width tiles, uniform
                  heights, radio dot on the right) so the layout doesn't
                  break with an odd number of options and the visual rhythm
                  matches the Merge-mode toggle below. Empty selection
                  = let the engine auto-resolve. */}
              {(() => {
                if (!selected) return null;
                const usable = (selected.destinations ?? []).filter((id) => {
                  const d = destById[id];
                  if (!d) return false;
                  if (d.status !== 'connected') return false;
                  if (d.paused) return false;
                  return (selected.files ?? []).every((f) => (f.uploaded_to ?? []).includes(id));
                });
                if (usable.length < 2) return null;

                // Build a unified list: Auto first, then each usable dest.
                type SourceOption = {
                  id: string;
                  title: string;
                  subtitle: string;
                  badge?: string;
                  icon: React.ComponentType<{ className?: string }>;
                };
                const options: SourceOption[] = [
                  { id: '', title: 'Auto', subtitle: 'Use the first reachable destination', badge: 'Recommended', icon: ShieldCheck },
                  ...usable.map((id) => {
                    const d = destById[id]!;
                    const labels: Record<string, string> = {
                      local: 'Local Storage', ftp: 'FTP server', sftp: 'SFTP server',
                      aws: 'AWS S3', wasabi: 'Wasabi', s3: 'S3-compatible',
                      google: 'Google Drive', onedrive: 'OneDrive',
                    };
                    return {
                      id,
                      title: d.name,
                      subtitle: labels[d.type] ?? 'Remote storage',
                      icon: destIcon[d.type] ?? CircleDot,
                    };
                  }),
                ];

                return (
                  <div className="mt-4 rounded-xl border border-ink-200 bg-white overflow-hidden">
                    <div className="px-4 py-3 border-b border-ink-100">
                      <p className="text-sm font-semibold text-ink-900">Restore from</p>
                      <p className="text-xs text-ink-500 mt-0.5">
                        This backup exists on {usable.length} destinations. Pick which one to read from.
                      </p>
                    </div>
                    {/* Cap height when the list gets long — past 6 rows
                        the picker would push the Start button off-screen
                        on smaller laptops. Internal scroll keeps the
                        page layout stable. */}
                    <div className={cn(
                      'divide-y divide-ink-100',
                      options.length > 6 && 'max-h-[340px] overflow-y-auto',
                    )}>
                      {options.map((opt) => {
                        const isPicked = sourceDestId === opt.id;
                        const Icon = opt.icon;
                        return (
                          <button
                            key={opt.id || '__auto__'}
                            type="button"
                            onClick={() => setSourceDestId(opt.id)}
                            className={cn(
                              'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
                              isPicked ? 'bg-brand-50' : 'hover:bg-ink-50',
                            )}
                          >
                            <span className={cn(
                              'h-8 w-8 grid place-items-center rounded-lg shrink-0',
                              isPicked ? 'bg-brand-100 text-brand-700' : 'bg-ink-100 text-ink-600',
                            )}>
                              <Icon className="h-4 w-4" />
                            </span>
                            <div className="flex-1 min-w-0">
                              <div className="flex items-center gap-2">
                                <p className={cn('text-sm font-medium truncate', isPicked ? 'text-brand-900' : 'text-ink-900')}>
                                  {opt.title}
                                </p>
                                {opt.badge && (
                                  <span className="text-[9px] uppercase tracking-wider font-bold rounded px-1.5 py-0.5 bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-200">
                                    {opt.badge}
                                  </span>
                                )}
                              </div>
                              <p className="text-[11px] text-ink-500 mt-0.5">{opt.subtitle}</p>
                            </div>
                            {/* Radio dot — visible affordance for "this is selected" */}
                            <span
                              role="radio"
                              aria-checked={isPicked}
                              className={cn(
                                'h-4 w-4 rounded-full ring-2 ring-inset shrink-0 grid place-items-center',
                                isPicked ? 'ring-brand-500' : 'ring-ink-300',
                              )}
                            >
                              {isPicked && <span className="h-2 w-2 rounded-full bg-brand-500" />}
                            </span>
                          </button>
                        );
                      })}
                    </div>
                  </div>
                );
              })()}

              {/* Restore mode — a single toggle that swaps between the
                  default "replace everything" semantics (the engine wipes
                  each selected component's target dir before extracting,
                  so the site lands in the exact state captured by the
                  backup — what most users mean by "restore") and a
                  non-default "merge" mode (additive: backup files
                  overwrite same-named ones, but anything added since the
                  backup survives).

                  Visual model:
                    - Default (Merge OFF): replace-everything is shown as
                      a calm, brand-coloured tile. It IS destructive (and
                      the orange banner above already says "This will
                      overwrite your site"), but it's also the normal
                      thing to do — colouring it red would teach users to
                      flinch at the default and conditioning that reflex
                      would make every restore feel scary.
                    - Merge ON: opt-in additive mode is shown as a
                      neutral safe-looking tile, with a tooltip explaining
                      it's an advanced workflow (mostly useful for partial
                      restores where the user is intentionally keeping new
                      uploads). */}
              <div className={cn(
                'mt-4 rounded-xl border-2 overflow-hidden transition-colors',
                mergeMode
                  ? 'border-emerald-300 bg-emerald-50/40'
                  : 'border-brand-300 bg-brand-50/40',
              )}>
                {/* Header strip: title + toggle. Clickable whole-row to flip. */}
                <button
                  type="button"
                  onClick={() => setMergeMode(!mergeMode)}
                  className="w-full flex items-center gap-3 p-4 text-left"
                >
                  <div className={cn(
                    'h-9 w-9 grid place-items-center rounded-lg shrink-0 transition-colors',
                    mergeMode ? 'bg-emerald-100 text-emerald-700' : 'bg-brand-100 text-brand-700',
                  )}>
                    {mergeMode
                      ? <ShieldCheck    className="h-4.5 w-4.5" />
                      : <AlertTriangle  className="h-4.5 w-4.5" />}
                  </div>
                  <div className="flex-1 min-w-0">
                    <div className="flex items-center gap-2 flex-wrap">
                      <p className={cn(
                        'text-sm font-semibold',
                        mergeMode ? 'text-emerald-900' : 'text-brand-900',
                      )}>
                        Merge mode
                      </p>
                      <span className={cn(
                        'text-[10px] uppercase tracking-wider font-bold rounded px-1.5 py-0.5 ring-1 ring-inset',
                        mergeMode
                          ? 'bg-emerald-100 text-emerald-800 ring-emerald-200'
                          : 'bg-brand-100 text-brand-800 ring-brand-200',
                      )}>
                        {mergeMode ? 'ON · additive' : 'OFF · replace everything (default)'}
                      </span>
                    </div>
                    <p className="text-xs text-ink-700 mt-0.5">
                      {mergeMode
                        ? 'Backup files overwrite same-named files. New files added since the backup stay in place.'
                        : 'Each selected component directory is wiped and re-extracted from the backup. The site goes back to the exact state of the backup.'}
                    </p>
                  </div>
                  {/* Toggle switch — purely visual, the whole row is the click target */}
                  <span
                    role="switch"
                    aria-checked={mergeMode}
                    className={cn(
                      'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors',
                      mergeMode ? 'bg-emerald-500' : 'bg-ink-300',
                    )}
                  >
                    <span className={cn(
                      'inline-block h-5 w-5 rounded-full bg-white shadow-sm transform transition-transform',
                      mergeMode ? 'translate-x-5' : 'translate-x-0.5',
                    )} />
                  </span>
                </button>

                {/* Expanded body — different content depending on which
                    mode is active. The default (replace) shows the
                    before/after comparison so the user knows what
                    "replace everything" actually means. The merge view
                    explains when it's the right tool. */}
                <div className="px-4 pb-4 pt-1 space-y-3">
                  {!mergeMode ? (
                    <>
                      <div className="rounded-lg border border-brand-200 bg-white p-3 text-xs text-ink-800">
                        <p className="font-semibold flex items-center gap-1.5 text-brand-900">
                          <AlertTriangle className="h-3.5 w-3.5" />
                          Anything added since this backup will be deleted
                        </p>
                        <p className="mt-1 leading-relaxed">
                          Plugins installed later, new uploads, theme edits, and language packs will be removed.
                          This is usually what you want when rolling back a site.
                          Turn on <strong>Merge mode</strong> above if you want to keep newer files.
                        </p>
                      </div>
                      <div className="grid grid-cols-2 gap-2 text-[11px]">
                        <div className="rounded-md border border-ink-200 bg-white px-3 py-2">
                          <p className="text-[9px] uppercase tracking-wider text-ink-500 font-medium">Files in backup</p>
                          <p className="text-emerald-700 font-medium mt-0.5">✓ Restored</p>
                        </div>
                        <div className="rounded-md border border-ink-200 bg-white px-3 py-2">
                          <p className="text-[9px] uppercase tracking-wider text-ink-500 font-medium">Files added since</p>
                          <p className="text-red-700 font-medium mt-0.5">✗ Deleted</p>
                        </div>
                      </div>
                    </>
                  ) : (
                    <>
                      <div className="rounded-lg border border-emerald-200 bg-white p-3 text-xs text-ink-800">
                        <p className="font-semibold flex items-center gap-1.5 text-emerald-900">
                          <ShieldCheck className="h-3.5 w-3.5" />
                          Newer files will be kept
                        </p>
                        <p className="mt-1 leading-relaxed">
                          Use this when you want to recover specific files or roll back the database
                          without losing uploads, plugin updates, or theme changes added since the backup.
                          Turn this off to wipe the site clean and rewind it exactly to the backup point.
                        </p>
                      </div>
                      <div className="grid grid-cols-2 gap-2 text-[11px]">
                        <div className="rounded-md border border-ink-200 bg-white px-3 py-2">
                          <p className="text-[9px] uppercase tracking-wider text-ink-500 font-medium">Files in backup</p>
                          <p className="text-emerald-700 font-medium mt-0.5">✓ Restored (overwrite)</p>
                        </div>
                        <div className="rounded-md border border-emerald-200 bg-white px-3 py-2">
                          <p className="text-[9px] uppercase tracking-wider text-emerald-700 font-medium">Files added since</p>
                          <p className="text-emerald-700 font-medium mt-0.5">✓ Preserved</p>
                        </div>
                      </div>
                    </>
                  )}
                </div>
              </div>

              {/* Cross-site migration */}
              {(() => {
                const src = selected?.source_url;
                if (!src) {
                  return (
                    <div className="mt-4 flex items-start gap-2 rounded-lg border border-ink-200 bg-ink-50/40 p-3 text-xs text-ink-600">
                      <ShieldCheck className="h-4 w-4 text-ink-500 shrink-0 mt-0.5" />
                      <span>Source URL not recorded on this backup. URLs in the database will be restored as-is.</span>
                    </div>
                  );
                }
                const isMatch = src === currentUrl;
                if (isMatch) {
                  return (
                    <div className="mt-4 flex items-start gap-2 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-900">
                      <ShieldCheck className="h-4 w-4 text-emerald-600 shrink-0 mt-0.5" />
                      <span>Same site. URLs will not be rewritten.</span>
                    </div>
                  );
                }
                return (
                  <div className="mt-4 rounded-lg border-2 border-amber-300 bg-amber-50 p-4 space-y-3">
                    <div className="flex items-start gap-3">
                      <AlertTriangle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
                      <div className="flex-1 min-w-0">
                        <p className="text-sm font-semibold text-amber-900">Restoring onto a different site</p>
                        <p className="text-xs text-amber-800 mt-1">
                          The database in this backup contains URLs from another domain. Without rewriting,
                          your restored site will redirect to the source domain and most images / links will break.
                        </p>
                        <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-3">
                          <div className="rounded-md border border-amber-200 bg-white px-3 py-2">
                            <p className="text-[10px] uppercase tracking-wider text-amber-700 font-medium">Source</p>
                            <p className="text-xs font-mono text-ink-900 truncate" title={src}>{src}</p>
                          </div>
                          <div className="rounded-md border border-amber-200 bg-white px-3 py-2">
                            <p className="text-[10px] uppercase tracking-wider text-amber-700 font-medium">Target (this site)</p>
                            <p className="text-xs font-mono text-ink-900 truncate" title={currentUrl}>{currentUrl}</p>
                          </div>
                        </div>
                      </div>
                    </div>
                    <label className="flex items-start gap-3 p-3 rounded-md bg-white border border-amber-200 cursor-pointer">
                      <input
                        type="checkbox"
                        checked={migrate}
                        onChange={(e) => setMigrate(e.target.checked)}
                        className="mt-0.5 accent-brand-500"
                      />
                      <span className="text-xs text-ink-800">
                        <strong className="text-ink-900">Rewrite URLs and paths during restore</strong>
                        <span className="block text-ink-600 mt-0.5">
                          Serialization-safe replacement across every text column in every table. Recommended.
                        </span>
                      </span>
                    </label>
                  </div>
                );
              })()}
            </CardBody>
          </>
        )}

        {/* Step 4 — Restoring (live log feed mirrors BackupProgress) */}
        {step === 4 && (
          <>
            <CardHeader><CardTitle>{restore.error ? 'Restore failed' : restore.isLoading ? 'Restore in progress' : 'Restore complete'}</CardTitle></CardHeader>
            <CardBody className="space-y-4">
              {restore.isLoading && (
                <div className="flex items-start gap-2.5 rounded-lg border border-amber-200 bg-amber-50 p-3">
                  <ShieldCheck className="h-4 w-4 text-amber-600 shrink-0 mt-0.5" />
                  <div className="text-xs text-amber-900">
                    <p className="font-medium">Maintenance mode is on for visitors.</p>
                    <p className="mt-0.5 text-amber-800">
                      They're seeing your branded "be right back" page. As an admin you keep full access. The site comes back online automatically when this finishes.
                    </p>
                  </div>
                </div>
              )}

              {/* Progress bar. shows a concrete % while running so the user
                  sees motion (not "…"), and picks the latest RESTORE-engine
                  line as the label instead of whatever happens to be at the
                  top of the log feed (which is often the original "Backup
                  complete" line from when the source backup was created  
                  shown as the label was confusing users into thinking the
                  RESTORE had completed when it was still in flight). */}
              {(() => {
                // Restore-engine log signatures, in rough chronological order
                // — used both to pick the latest restore-relevant label and
                // to refine the % once we know which phase we're in.
                //
                // Each entry: [matcher, label, target-% the bar should reach
                // on the FIRST appearance of this phase]. Matchers are
                // startsWith() so we don't have to enumerate every variation.
                const phases: Array<[(m: string) => boolean, string, number]> = [
                  [(m) => m.startsWith('Restore starting from '),     'Connecting to destination…',  10],
                  [(m) => m.startsWith('Downloading '),               'Downloading parts…',          30],
                  [(m) => m === 'All files downloaded and verified',  'Files downloaded',            50],
                  [(m) => m.startsWith('Clean-restore wiped '),       'Wiping target dirs…',         55],
                  [(m) => m.startsWith('Restoring ') && !m.startsWith('Restoring database'), 'Extracting files…', 65],
                  [(m) => m.startsWith('Restoring database from '),   'Importing database…',         80],
                  [(m) => m === 'Database restored',                  'Database restored',           92],
                  [(m) => m.startsWith('Running URL/path migration'), 'Rewriting URLs…',             95],
                  [(m) => m.startsWith('Migration requested but source/target match'), 'Finalising…', 96],
                  [(m) => m === 'Restore complete',                   'Restore complete',           100],
                ];

                // Find the latest log line that matches a restore phase. Walk
                // from newest → oldest so we always pick the most recent
                // recognized step. Anything that doesn't match a phase
                // (background backup-engine lines from the source backup's
                // own history) is skipped — those used to leak through and
                // show "Backup complete" while a restore was running.
                let label  = 'Starting restore…';
                let target = 10;
                for (const l of restoreLogs) {
                  const m = l.message;
                  const hit = phases.find(([test]) => test(m));
                  if (hit) {
                    label  = hit[1];
                    target = hit[2];
                    break;
                  }
                }

                // While the HTTP request is still pending, never claim 100% —
                // the post-restore reconcile sync runs on the server after
                // "Restore complete" is logged but before the response
                // returns. Show 99% to communicate "almost done".
                let pct = target;
                if (restore.isLoading && pct >= 100) pct = 99;
                if (!restore.isLoading) {
                  pct   = restore.error ? 100 : 100;
                  label = restore.error ? 'Failed' : 'Complete';
                }

                return (
                  <div>
                    <div className="flex justify-between text-xs text-ink-600 mb-2">
                      <span className="truncate">{label}</span>
                      <span className="tabular-nums shrink-0">{pct}%</span>
                    </div>
                    <Progress value={pct} />
                  </div>
                );
              })()}

              {/* Live log feed — same renderer as BackupProgress so the two
                  surfaces feel like one product. Auto-scrolls to bottom on
                  new lines unless the user has scrolled up to read. */}
              <div
                ref={restoreLogBoxRef}
                aria-live="polite"
                aria-label="Restore activity log"
                role="log"
                className={cn(
                  'bg-ink-900 text-ink-100 rounded-lg p-3 font-mono text-[11px] leading-5 max-h-72 overflow-auto',
                  restoreLogs.length === 0 && 'opacity-50',
                )}
              >
                {restoreLogs.length === 0 && (
                  <div className="text-ink-500">Waiting for first log line…</div>
                )}
                {restoreLogs.slice().reverse().map((l) => (
                  <div key={l.id} className="flex gap-2">
                    <span className={cn('uppercase text-[9px] tracking-wider w-12 shrink-0', logLevelClass[l.level])}>
                      {l.level}
                    </span>
                    <span className="break-all">{l.message}</span>
                  </div>
                ))}
              </div>

              {restore.error && (
                <div className="rounded-lg border border-red-200 bg-red-50 p-3">
                  <p className="text-xs text-red-800 break-words">{restore.error.message}</p>
                </div>
              )}

              {/* Post-restore reconcile callout. A restore rewinds the entire
                  database, including the backup-records table — so the list
                  on the Backups page reflects whatever was there at backup
                  time, NOT every backup that exists on your destinations.
                  Without surfacing that, users (rightly) get confused when
                  newer backups appear "missing" right after a restore. The
                  reconcile pass re-imports them from destinations; this
                  callout explains what happened so they know it's normal. */}
              {!restore.isLoading && !restore.error && reconciled !== null && (
                <div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
                  <p className="text-xs text-blue-900">
                    {reconciled > 0 ? (
                      <>
                        <strong>{reconciled}</strong> backup{reconciled === 1 ? '' : 's'} re-imported from your destinations.
                        A restore rewinds the database, including the backups table itself, so newer backups appear "missing" until they're rediscovered on disk. Your Backups list is now back in sync.
                      </>
                    ) : (
                      <>
                        Backups list reconciled with destinations. A restore rewinds the database, including the backups table itself, so if backups created after the restored snapshot seem missing, click the Sync button on the Backups page to recover them (this happens automatically here).
                      </>
                    )}
                  </p>
                </div>
              )}
            </CardBody>
          </>
        )}

        <CardFooter className="justify-between">
          {showBack ? (
            <Button variant="ghost" onClick={() => setStep(step - 1)}>Back</Button>
          ) : step === 4 && !restore.isLoading ? (
            // At the terminal step (success or failure), give the user a way
            // to start another restore without leaving the page. Hidden while
            // the restore is still in flight so they don't accidentally lose
            // the live log.
            <Button variant="ghost" onClick={resetWizard}>
              {restore.error ? 'Try another backup' : 'Restore another'}
            </Button>
          ) : <span />}
          <Button variant="primary"
            disabled={
              // While restoring → button is disabled (no action makes sense).
              // After success/failure → it becomes Finish / Try again.
              restore.isLoading ||
              (step === 4 && !restore.error && !restore.data && restore.isLoading) ||
              !selected ||
              (step === 0 && !selectedRestorable) ||
              (step === 1 && !anyComponentSelected) ||
              (step === 3 && !anyComponentSelected)
            }
            onClick={() => {
              if (step === 4) {
                // Terminal step: success or failure.
                // Success → user wants to go back to the Backups list.
                // Failure → user wants to retry from scratch (reset wizard).
                if (restore.error) {
                  resetWizard();
                } else {
                  resetWizard();
                  navigate('backups');
                }
                return;
              }
              if (step === 3) {
                if (selected) {
                  // Advance to the Restoring step IMMEDIATELY so the user
                  // sees the live log feed instead of a frozen "Restoring…"
                  // button while the restore request blocks for minutes.
                  setStep(4);
                  setRestoreLogs([]);
                  restore.mutate(selected.id);
                }
                return;
              }
              setStep(Math.min(steps.length - 1, step + 1));
            }}>
            {step === 3
              ? (restore.isLoading ? 'Restoring…' : 'Start restore')
              : step === 4
                ? (restore.isLoading ? 'Restoring…' : restore.error ? 'Try again' : 'Finish')
                : 'Continue'}
          </Button>
        </CardFooter>
      </Card>

      {toast && <Toast message={toast.msg} tone={toast.tone} onClose={() => setToast(null)} />}
    </div>
  );
}

function Component({ icon, title, desc, checked, onChange }: { icon: React.ReactNode; title: string; desc: string; checked: boolean; onChange: (v: boolean) => void }) {
  return (
    <button
      type="button"
      onClick={() => onChange(!checked)}
      className={cn(
        'w-full !justify-start flex items-start gap-3 p-3 rounded-xl border-2 text-left transition-all',
        checked ? 'border-brand-500 bg-brand-50/60' : 'border-ink-200 hover:border-ink-300 hover:bg-ink-50/50',
      )}
    >
      <span className={cn(
        'h-5 w-5 rounded grid place-items-center shrink-0 mt-0.5 border-2',
        checked ? 'border-brand-500 bg-brand-500' : 'border-ink-300 bg-white',
      )}>
        {checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
      </span>
      <div className="flex-1 min-w-0">
        <div className="flex items-center gap-2">
          <span className="h-6 w-6 rounded-md bg-white border border-ink-200 grid place-items-center text-ink-700">{icon}</span>
          <span className="text-sm font-medium text-ink-900">{title}</span>
        </div>
        <p className="text-xs text-ink-500 mt-1.5 ml-8">{desc}</p>
      </div>
    </button>
  );
}

/**
 * Real pre-flight for the Restore wizard. Calls /system for environment
 * checks (PHP memory, ZipArchive availability, etc.) and surfaces the
 * actual status — not the hardcoded green ticks we used to show.
 *
 * If any blocking check fails, the parent's Continue button stays enabled
 * but the user sees a clear red indicator and can choose to proceed at
 * their own risk. The engine will then refuse via its own pre-flight if
 * the conditions are genuinely broken.
 */
function PreflightStep({
  selected, isSourceReachable, destBlocking,
}: {
  selected: Backup | undefined;
  isSourceReachable: boolean;
  destBlocking: boolean;
}) {
  const sysQ = useQuery(() => api.system.get(), []);

  if (sysQ.isLoading) return (
    <>
      <CardHeader><CardTitle>Pre-flight checks</CardTitle></CardHeader>
      <CardBody><Loading label="Checking environment…" /></CardBody>
    </>
  );

  // Surface specific environment checks by id from the System endpoint.
  const checks = sysQ.data?.checks ?? [];
  const find = (id: string) => checks.find((c) => c.id === id);

  const zip       = find('zip_ext');                // ZipArchive extension
  const memory    = find('php_memory_limit');       // PHP memory limit
  const openssl   = find('openssl');                // for encrypted backups
  const dbWrite   = find('db_writable') ?? find('db_connection');

  const isEncrypted = !!selected?.encrypted;

  // Build the displayed check list. Blocking failures show as red, warnings
  // amber, passes green.
  type Line = { tone: 'ok' | 'warn' | 'fail'; label: string };
  const lines: Line[] = [];

  // PHP / extensions
  if (zip) lines.push({
    tone: zip.status === 'pass' ? 'ok' : 'fail',
    label: zip.status === 'pass'
      ? 'ZipArchive extension available'
      : 'ZipArchive extension missing. Restore cannot proceed.',
  });
  if (memory) lines.push({
    tone: memory.status === 'pass' ? 'ok' : memory.status === 'warn' ? 'warn' : 'fail',
    label: `PHP memory limit: ${memory.value} (${memory.requirement})`,
  });
  if (isEncrypted && openssl) {
    lines.push({
      tone: openssl.status === 'pass' ? 'ok' : 'fail',
      label: openssl.status === 'pass'
        ? 'OpenSSL available for decryption'
        : 'OpenSSL extension missing. Cannot decrypt this backup.',
    });
  }
  if (dbWrite) lines.push({
    tone: dbWrite.status === 'pass' ? 'ok' : 'fail',
    label: dbWrite.status === 'pass' ? 'Database is writable' : 'Database not writable. Restore cannot proceed.',
  });

  // Source reachability — pulled from the parent's earlier check
  lines.push(
    isSourceReachable
      ? { tone: 'ok',   label: destBlocking ? 'Source destination reachable via Local fallback' : 'Source destination is reachable' }
      : { tone: 'fail', label: 'No reachable destination has this backup. Cannot restore.' }
  );

  // Maintenance mode notice (informational)
  lines.push({
    tone: 'ok',
    label: 'Maintenance mode will turn on automatically during the restore (and off when it finishes)',
  });

  return (
    <>
      <CardHeader><CardTitle>Pre-flight checks</CardTitle></CardHeader>
      <CardBody className="space-y-2">
        {lines.map((l, i) => <Check2 key={i} tone={l.tone} label={l.label} />)}
      </CardBody>
    </>
  );
}

function Check2({ tone, label }: { tone: 'ok' | 'warn' | 'fail'; label: string }) {
  return (
    <div className={cn(
      'flex items-center gap-3 p-3 rounded-lg border',
      tone === 'fail' ? 'border-red-200 bg-red-50/60'
      : tone === 'warn' ? 'border-amber-200 bg-amber-50/60'
      : 'border-ink-100 bg-ink-50/50',
    )}>
      {tone === 'ok'   && <ShieldCheck   className="h-4 w-4 text-emerald-600" />}
      {tone === 'warn' && <AlertTriangle className="h-4 w-4 text-amber-600" />}
      {tone === 'fail' && <AlertTriangle className="h-4 w-4 text-danger" />}
      <span className={cn(
        'text-sm',
        tone === 'fail' ? 'text-danger font-medium' : 'text-ink-800',
      )}>{label}</span>
    </div>
  );
}

/**
 * One cell of the Review-step summary card. Icon-led, label above value,
 * sized to read at a glance. The `mono` flag is for the Backup-ID field
 * where the slug is a stable identifier the user might want to copy or
 * cross-reference (so monospace font keeps the alignment crisp and signals
 * "this is an identifier, not a sentence").
 *
 * Long values wrap inside the cell — we deliberately don't truncate, because
 * Components can be 6 categories long ("Database, Uploads, Plugins, Themes,
 * MU-Plugins, Other") and hiding part of it behind an ellipsis would defeat
 * the point of a confirmation step.
 */
function SummaryRow({
  icon: Icon,
  label,
  value,
  mono = false,
}: {
  icon: React.ComponentType<{ className?: string }>;
  label: string;
  value: string;
  mono?: boolean;
}) {
  return (
    <div className="flex items-start gap-3 p-4">
      <span className="h-8 w-8 grid place-items-center rounded-lg bg-ink-100 text-ink-600 shrink-0">
        <Icon className="h-4 w-4" />
      </span>
      <div className="flex-1 min-w-0">
        <dt className="text-[10px] uppercase tracking-wider text-ink-500 font-medium">
          {label}
        </dt>
        {/* `ml-0` is critical here: browsers default <dd> to a 40px
            margin-inline-start so the value renders shifted right of its
            label. Tailwind's preflight resets a lot of UA styles but it
            deliberately keeps the dl/dt/dd indentation, so we have to zero
            it manually. */}
        <dd
          className={cn(
            'ml-0 text-sm text-ink-900 mt-1 break-words',
            mono && 'font-mono text-[13px]',
          )}
          title={value}
        >
          {value || '-'}
        </dd>
      </div>
    </div>
  );
}
