import { useEffect, useMemo, useRef, useState } from 'react';
import { Download, RotateCcw, Trash2, Play, Filter, Lock, Upload, StopCircle, X as XIcon, Database, Image as ImageIcon, Puzzle, Palette, FolderCog, FileBox, Layers, Copy, Check as CheckIcon, ChevronDown, CircleAlert, PlayCircle, ScrollText, RefreshCw } from 'lucide-react';
import { ImportBackup } from '@/components/ImportBackup';
import { BackupNowButton } from '@/components/BackupNowButton';
import { Popover } from '@/components/ui/Popover';
import { Dialog } from '@/components/ui/Dialog';
import { Card, CardBody, CardHeader, CardTitle } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Tabs } from '@/components/ui/Tabs';
import { Table, THead, TH, TR, TD } from '@/components/ui/Table';
import { Pagination } from '@/components/ui/Pagination';
import { Loading, ErrorState, Empty, Toast } from '@/components/ui/States';
import { formatBytes, backupSlug, formatDateTime, formatDuration, backupActualDestinations } from '@/lib/format';
import { api, bk, type Backup, type BackupStatus, type BackupType } from '@/lib/api';
import { useQuery, useMutation } from '@/hooks/useQuery';
import { useApp } from '@/App';
import { useConfirm } from '@/components/ConfirmProvider';
import { cn } from '@/lib/cn';

const tabs = [
  { value: 'all',      label: 'All' },
  { value: 'full',     label: 'Full' },
  { value: 'database', label: 'Database' },
  { value: 'files',    label: 'Files' },
] as const;
type Tab = typeof tabs[number]['value'];

/**
 * Per-category metadata for the download tile picker. Mirrors the Restore
 * wizard's component tiles so the two surfaces feel like the same product.
 * Order here drives render order inside the popover — DB first because users
 * downloading individual parts most commonly want the database first.
 */
const DOWNLOAD_CATEGORIES: Array<{
  key: string; title: string; path: string; icon: React.ComponentType<{ className?: string }>;
}> = [
  { key: 'db',      title: 'Database',   path: 'All tables',            icon: Database  },
  { key: 'plugins', title: 'Plugins',    path: 'wp-content/plugins',    icon: Puzzle    },
  { key: 'themes',  title: 'Themes',     path: 'wp-content/themes',     icon: Palette   },
  { key: 'uploads', title: 'Uploads',    path: 'wp-content/uploads',    icon: ImageIcon },
  { key: 'mu',      title: 'MU-Plugins', path: 'wp-content/mu-plugins', icon: FolderCog },
  { key: 'others',  title: 'Other',      path: 'Misc wp-content files', icon: FileBox   },
];

const statusTone: Record<BackupStatus, 'success' | 'warning' | 'danger' | 'neutral'> = {
  success:    'success',
  running:    'warning',
  failed:     'danger',
  pending:    'neutral',
  cancelling: 'warning',
  cancelled:  'neutral',
  // Skipped: the engine bailed BEFORE doing any work because there was no
  // usable destination to upload to. Different from "failed" — nothing
  // actually broke, it just had nowhere to write. Shown in neutral grey so
  // the user isn't alarmed and can clearly distinguish it from a real
  // failure. The error column carries the human-readable reason.
  skipped:    'neutral',
};

export function Backups() {
  const [tab, setTab] = useState<Tab>('all');
  const { data, isLoading, error, refetch } = useQuery(() => api.backups.list(), []);
  const destsQ = useQuery(() => api.destinations.list(), []);
  const [toast, setToast] = useState<{ msg: string; tone: 'success' | 'danger' } | null>(null);
  const { trackBackup, activeBackupId, navigate } = useApp();
  const confirm = useConfirm();
  const [importOpen, setImportOpen] = useState(false);

  // ── Filter state ───────────────────────────────────────────────────────
  const [statusFilter, setStatusFilter] = useState<BackupStatus[]>([]);
  const [fromDate,     setFromDate]     = useState<string>('');
  const [toDate,       setToDate]       = useState<string>('');
  const [encryptedOnly,setEncryptedOnly]= useState(false);

  // ── Pagination ─────────────────────────────────────────────────────────
  const [page, setPage]       = useState(1);
  const [perPage, setPerPage] = useState(10);

  // Which backup, if any, has the download tile picker open. Drives a modal
  // Dialog rather than an inline Popover so the picker doesn't fight the
  // surrounding table rows visually.
  const [downloadOpenId, setDownloadOpenId] = useState<string | null>(null);
  const activeFilterCount =
    statusFilter.length +
    (fromDate ? 1 : 0) +
    (toDate ? 1 : 0) +
    (encryptedOnly ? 1 : 0);
  const clearFilters = () => {
    setStatusFilter([]); setFromDate(''); setToDate(''); setEncryptedOnly(false);
  };

  // Build lookup maps of currently-configured destinations
  const destById = useMemo(() => {
    const map: Record<string, { type: string; name: string; status: string; paused: boolean }> = {};
    for (const d of destsQ.data?.items ?? []) {
      map[d.id] = { type: d.type, name: d.name, status: d.status, paused: !!d.paused };
    }
    return map;
  }, [destsQ.data]);

  // Any currently connected Local destination — the download endpoint and the
  // restore engine both scan these as a fallback when the backup's original
  // destination IDs no longer exist (after a disconnect/reconnect cycle).
  const anyConnectedLocal = useMemo(
    // Paused Local destinations don't count — the engine skips them, so
    // they can't act as a fallback source for restore.
    () => (destsQ.data?.items ?? []).some((d) => d.type === 'local' && d.status === 'connected' && !d.paused),
    [destsQ.data],
  );

  /** Can we restore this backup? Any destination that actually holds a copy
   *  must be still usable (connected + not paused), OR any Local destination
   *  exists that we can fall back to. */
  const canRestore = (destIds: string[]) =>
    destIds.some((id) => {
      const d = destById[id];
      return d && d.status === 'connected' && !d.paused;
    }) || anyConnectedLocal;

  /**
   * Can we download this backup? Either:
   *   1. A connected Local destination has the files (the fast path — direct
   *      filesystem stream), OR
   *   2. ANY destination this backup was uploaded to is currently connected
   *      and not paused (the fallback path — the server pulls bytes from
   *      that remote into a tmp file and streams them).
   *
   * The old logic required (1) and rejected (2), which made the Download
   * button useless for users whose only working destination is FTP / SFTP /
   * S3. The server endpoint now supports both tiers, so the UI shouldn't
   * gate based on Local alone.
   */
  const canDownload = (destIds: string[]) => {
    if (anyConnectedLocal) return true;
    return destIds.some((id) => {
      const d = destById[id];
      return d && d.status === 'connected' && !d.paused;
    });
  };

  /**
   * Trigger a streaming download for one archive file.
   *   - Multi-zip with >1 parts:  callers pass `partIndex` to pick which one
   *   - Single-part / legacy:     no `partIndex` — server resolves the file
   * Uses an <a download> via the REST URL with _wpnonce in the query (WP also accepts that).
   */
  const downloadBackup = (id: string, partIndex?: number) => {
    const params = new URLSearchParams({ _wpnonce: bk.nonce });
    if (typeof partIndex === 'number') params.set('part', String(partIndex));
    const url = bk.apiUrl.replace(/\/$/, '') + `/backups/${id}/download?` + params.toString();
    const a = document.createElement('a');
    a.href = url;
    a.rel = 'noopener';
    document.body.appendChild(a);
    a.click();
    a.remove();
  };

  const create = useMutation(
    (type: BackupType) => api.backups.create({ type }),
    {
      // No "queued" toast — the floating progress panel covers it.
      onSuccess: (b) => { trackBackup(b.id); refetch(); },
      onError:   (e) => setToast({ msg: e.message, tone: 'danger' }),
    },
  );
  const remove = useMutation(
    (id: string) => api.backups.remove(id),
    { onSuccess: () => { setToast({ msg: 'Deleted', tone: 'success' }); refetch(); } },
  );
  const cancelBackup = useMutation(
    (id: string) => api.backups.cancel(id),
    { onSuccess: () => { setToast({ msg: 'Cancellation requested', tone: 'success' }); refetch(); },
      onError:   (e) => setToast({ msg: e.message, tone: 'danger' }) },
  );
  const resumeBackup = useMutation(
    (id: string) => api.backups.resume(id),
    {
      onSuccess: (b) => { setToast({ msg: 'Resuming backup…', tone: 'success' }); trackBackup(b.id); refetch(); },
      onError:   (e) => setToast({ msg: e.message, tone: 'danger' }),
    },
  );

  // Sync: scan every connected destination for bk_* folders not yet in the DB
  // and auto-import them. Tolerates per-destination failures (returns an
  // `errors[]` instead of throwing), so a single broken credential set doesn't
  // hide importable backups on other destinations.
  // Explicit type params: TArgs=void (no args), TResult inferred via the
  // factory call. Default inference makes TArgs=void here, but TypeScript
  // then complains that mutate() requires a void-typed arg — so we pin it.
  const sync = useMutation<void, Awaited<ReturnType<typeof api.backups.sync>>>(
    () => api.backups.sync(),
    {
      onSuccess: (res) => {
        const n = res.imported.length;
        const e = res.errors.length;
        const b = res.broken?.length ?? 0;
        const brokenSuffix = b > 0 ? `. ${b} record${b === 1 ? '' : 's'} missing on destination.` : '';
        if (n === 0 && e === 0) {
          setToast({ msg: `Already up to date. Nothing new on any destination${brokenSuffix}`, tone: b > 0 ? 'danger' : 'success' });
        } else if (n > 0 && e === 0) {
          setToast({ msg: `Synced ${n} backup${n === 1 ? '' : 's'} from your destinations${brokenSuffix}`, tone: 'success' });
        } else if (n > 0 && e > 0) {
          setToast({ msg: `Synced ${n} backup${n === 1 ? '' : 's'}. ${e} destination${e === 1 ? '' : 's'} couldn't be read. Check Logs.`, tone: 'danger' });
        } else {
          setToast({ msg: `Sync failed on ${e} destination${e === 1 ? '' : 's'}. Check Logs for details.`, tone: 'danger' });
        }
        refetch();
      },
      onError: (e) => setToast({ msg: e.message, tone: 'danger' }),
    },
  );

  // Cleanup-broken: drop DB rows the most recent sync flagged as no longer
  // present on any destination. DB-only delete — the destinations have
  // already confirmed the files are gone, so trying to delete remotely just
  // spams the log with 404s. Gated behind a confirm dialog (cleanup is
  // irreversible from the UI) and only enabled when there's actually
  // something to clean up.
  //
  // The ref below captures the broken count AT CLICK TIME so we can detect
  // the "stale tab" case: the user opened Backups when broken=N, then left
  // the tab open for 12+ hours, and the PHP transient that backs the broken
  // list has expired. PHP sees no broken ids → cleanup returns deleted=0.
  // Without this ref, the toast would say "nothing to clean up" — which is
  // technically true but misleads the user into thinking they just cleaned
  // up the records they saw. Comparing the click-time count against the
  // server result lets us tell them to re-sync instead.
  const expectedCleanupCount = useRef(0);
  const cleanupBroken = useMutation<void, Awaited<ReturnType<typeof api.backups.cleanupBroken>>>(
    () => api.backups.cleanupBroken(),
    {
      onSuccess: (res) => {
        const n = res.deleted;
        const expected = expectedCleanupCount.current;
        if (n === 0 && expected > 0) {
          // The UI showed N broken records when the user clicked, but PHP
          // saw zero. Either the transient expired (12h TTL) or another
          // tab already cleaned them up. Tell the user to re-sync rather
          // than reassure them the list was empty.
          setToast({
            msg: 'The broken-records list expired. Click Sync to refresh, then try again.',
            tone: 'danger',
          });
        } else if (n === 0) {
          setToast({ msg: 'Nothing to clean up. The broken list was already empty.', tone: 'success' });
        } else {
          setToast({ msg: `Removed ${n} broken record${n === 1 ? '' : 's'}. Destination files were not touched.`, tone: 'success' });
        }
        expectedCleanupCount.current = 0;
        refetch();
      },
      onError: (e) => {
        expectedCleanupCount.current = 0;
        setToast({ msg: e.message, tone: 'danger' });
      },
    },
  );

  // Auto-fire sync once per browser tab — but back off when destinations are
  // broken so we don't spam an unreachable FTP with a fresh list_backups()
  // request every page load. Two backoff signals:
  //
  //   1. SESSION key — once per tab session.
  //   2. LOCAL key  — when the last attempt returned errors, hold off for the
  //                   configured cooldown (1 hour). This survives tab close,
  //                   so a user with a broken destination doesn't watch the
  //                   error toast pop up every single time they open the page.
  //
  // The cooldown is cleared whenever the user clicks the explicit Sync button,
  // because that's their signal of "I want to try again now".
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const SESSION_KEY = 'backreload_synced_this_session';
    const COOLDOWN_KEY = 'backreload_sync_error_cooldown_until';
    if (sessionStorage.getItem(SESSION_KEY) === '1') return;
    const cooldownUntil = Number(localStorage.getItem(COOLDOWN_KEY) || '0');
    if (cooldownUntil && Date.now() < cooldownUntil) return;
    sessionStorage.setItem(SESSION_KEY, '1');
    api.backups.sync()
      .then((res) => {
        if (res.errors.length > 0) {
          // Silently cool down for 1 hour. The user can still click Sync
          // manually to override; their click clears the cooldown.
          localStorage.setItem(COOLDOWN_KEY, String(Date.now() + 60 * 60 * 1000));
        } else {
          localStorage.removeItem(COOLDOWN_KEY);
        }
        if (res.imported.length > 0) {
          setToast({ msg: `Found ${res.imported.length} new backup${res.imported.length === 1 ? '' : 's'} on your destinations`, tone: 'success' });
          refetch();
        }
      })
      .catch(() => { /* swallow — explicit Sync button surfaces errors */ });
    // eslint-disable-next-line react-hooks/exhaustive-deps -- run-once on mount only. Adding `refetch`/`syncFn` from the closure would trigger an extra sync every time the parent re-runs, which is exactly the loop the once-per-session localStorage flag is designed to avoid.
  }, []);

  const list = useMemo(() => {
    let items = data?.items ?? [];
    if (tab !== 'all')           items = items.filter((b) => b.type === tab);
    if (statusFilter.length > 0) items = items.filter((b) => statusFilter.includes(b.status));
    if (encryptedOnly)           items = items.filter((b) => b.encrypted);
    if (fromDate) {
      // Parse the YYYY-MM-DD picker value as LOCAL midnight, not UTC
      // midnight. The backup timestamps are stored UTC but rendered
      // through toLocaleString() in the table — so the user picks a date
      // matching what they SEE in the row, not the UTC underlying it.
      // Old code (`T00:00:00Z`) treated the picker value as UTC, which
      // on negative offsets (e.g. PST = UTC-8) included backups whose
      // displayed date was the PREVIOUS day. Dropping the `Z` parses
      // the literal as local time, matching the row's rendered date.
      const from = new Date(fromDate + 'T00:00:00').getTime();
      items = items.filter((b) => b.date && new Date(b.date).getTime() >= from);
    }
    if (toDate) {
      const to = new Date(toDate + 'T23:59:59').getTime();
      items = items.filter((b) => b.date && new Date(b.date).getTime() <= to);
    }
    return items;
  }, [data, tab, statusFilter, fromDate, toDate, encryptedOnly]);

  // Pagination guards (see Restore.tsx for the same pattern + rationale).
  // Filter change → snap to page 1; data shrink → clamp current page down.
  // Both guarded against no-op setPage so fast filter-flipping doesn't
  // bounce between the two effects.
  const lastPage = Math.max(1, Math.ceil(list.length / perPage));
  useEffect(() => { if (page !== 1) setPage(1); }, [tab, statusFilter, fromDate, toDate, encryptedOnly]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally omits `page`: only want to react to filter changes.
  useEffect(() => { if (page > lastPage) setPage(lastPage); }, [lastPage, page]);

  const pageStart = (page - 1) * perPage;
  const pageList  = list.slice(pageStart, pageStart + perPage);

  // Count broken records across the WHOLE dataset, not just the current page
  // or filter. The cleanup button needs to reflect total cleanup-able work so
  // it stays visible even when the user has filtered to a slice that doesn't
  // include the broken rows. (Source of truth: the server's most-recent
  // sync, exposed per-row as missing_on_destination.)
  const brokenCount = (data?.items ?? []).filter((b) => !!b.missing_on_destination).length;

  if (isLoading) return <Loading label="Loading backups…" />;
  if (error)     return <ErrorState error={error} onRetry={refetch} />;

  return (
    <div className="space-y-4 max-w-[1400px]">
      <div className="flex items-center justify-between gap-3 flex-wrap">
        <Tabs value={tab} onChange={setTab} items={[...tabs]} />
        <div className="flex items-center gap-2 flex-wrap">
          <Popover
            align="right"
            width={340}
            trigger={(open, toggle) => (
              <Button
                variant="secondary"
                size="md"
                leftIcon={<Filter className="h-4 w-4" />}
                onClick={toggle}
                className={cn(open && 'ring-2 ring-brand-200 border-brand-500')}
              >
                Filter
                {activeFilterCount > 0 && (
                  <span className="ml-1.5 inline-flex items-center justify-center h-5 min-w-5 px-1.5 text-[11px] font-medium rounded-full bg-brand-500 text-white">
                    {activeFilterCount}
                  </span>
                )}
              </Button>
            )}
          >
            {(close) => (
              <div>
                <div className="px-4 py-3 border-b border-ink-100 flex items-center justify-between">
                  <p className="text-sm font-semibold text-ink-900">Filters</p>
                  {activeFilterCount > 0 && (
                    <button
                      onClick={clearFilters}
                      className="text-[11px] font-medium text-brand-700 hover:underline"
                    >
                      Clear all
                    </button>
                  )}
                </div>

                <div className="p-4 space-y-4">
                  {/* Status */}
                  <div>
                    <p className="text-[11px] uppercase tracking-wider text-ink-500 font-medium mb-2">Status</p>
                    <div className="grid grid-cols-2 gap-1.5">
                      {(['success', 'failed', 'cancelled', 'running'] as const).map((s) => {
                        const on = statusFilter.includes(s);
                        return (
                          <button
                            key={s}
                            onClick={() =>
                              setStatusFilter(on
                                ? statusFilter.filter((x) => x !== s)
                                : [...statusFilter, s])
                            }
                            className={cn(
                              'flex items-center justify-between gap-2 px-2.5 h-8 rounded-md border text-xs font-medium capitalize transition-colors',
                              on
                                ? 'bg-brand-50 border-brand-500 text-brand-800'
                                : 'bg-white border-ink-200 text-ink-700 hover:bg-ink-50',
                            )}
                          >
                            {s}
                            {on && <XIcon className="h-3 w-3" />}
                          </button>
                        );
                      })}
                    </div>
                  </div>

                  {/* Date range */}
                  <div>
                    <p className="text-[11px] uppercase tracking-wider text-ink-500 font-medium mb-2">Date range</p>
                    <div className="grid grid-cols-2 gap-2">
                      <label className="block">
                        <span className="text-[11px] text-ink-500 block mb-1">From</span>
                        <input
                          type="date"
                          value={fromDate}
                          onChange={(e) => setFromDate(e.target.value)}
                          className="w-full h-8 px-2 text-xs rounded-md border border-ink-200 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-brand-500"
                        />
                      </label>
                      <label className="block">
                        <span className="text-[11px] text-ink-500 block mb-1">To</span>
                        <input
                          type="date"
                          value={toDate}
                          onChange={(e) => setToDate(e.target.value)}
                          className="w-full h-8 px-2 text-xs rounded-md border border-ink-200 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-brand-500"
                        />
                      </label>
                    </div>
                    <div className="flex items-center gap-1.5 mt-2">
                      {[7, 30, 90].map((d) => {
                        const today = new Date();
                        const past = new Date(today.getTime() - d * 86400000);
                        const set = () => {
                          setFromDate(past.toISOString().slice(0, 10));
                          setToDate(today.toISOString().slice(0, 10));
                        };
                        return (
                          <button key={d} onClick={set}
                            className="text-[11px] font-medium px-2 h-6 rounded-md bg-ink-100 text-ink-700 hover:bg-ink-200">
                            Last {d}d
                          </button>
                        );
                      })}
                    </div>
                  </div>

                  {/* Encrypted only */}
                  <label className="flex items-center justify-between p-2.5 rounded-md border border-ink-200">
                    <span className="flex items-center gap-2 text-sm text-ink-800">
                      <Lock className="h-3.5 w-3.5 text-ink-500" />
                      Encrypted only
                    </span>
                    <input
                      type="checkbox"
                      checked={encryptedOnly}
                      onChange={(e) => setEncryptedOnly(e.target.checked)}
                      className="accent-brand-500"
                    />
                  </label>
                </div>

                <div className="px-4 py-3 border-t border-ink-100 bg-ink-50/50 flex items-center justify-end gap-2">
                  <Button size="sm" variant="ghost" onClick={() => { clearFilters(); close(); }}>Reset</Button>
                  <Button size="sm" variant="primary" onClick={close}>Apply</Button>
                </div>
              </div>
            )}
          </Popover>

          <Button
            variant="secondary"
            size="md"
            leftIcon={<RefreshCw className={cn('h-4 w-4', sync.isLoading && 'animate-spin')} />}
            onClick={() => {
              // Explicit click clears any auto-sync cooldown — the user is
              // signalling "I know it failed earlier, try anyway".
              try { localStorage.removeItem('backreload_sync_error_cooldown_until'); } catch {}
              sync.mutate();
            }}
            disabled={sync.isLoading}
            title="Scan every connected destination for backups not yet in this list, and import them. Useful after restoring on a new server, reconnecting a destination, or pulling backups created by a sibling site."
          >
            {sync.isLoading ? 'Syncing…' : 'Sync'}
          </Button>
          {brokenCount > 0 && (
            <Button
              variant="secondary"
              size="md"
              leftIcon={<CircleAlert className="h-4 w-4 text-amber-600" />}
              onClick={async () => {
                const ok = await confirm({
                  title: `Remove ${brokenCount} broken record${brokenCount === 1 ? '' : 's'}?`,
                  description:
                    `The last sync couldn't find ${brokenCount === 1 ? 'this backup' : 'these backups'} on any destination. They were probably deleted directly on the server. ` +
                    `This will remove the database entries only. No destination files will be touched.`,
                  confirmLabel: 'Remove',
                  tone: 'danger',
                });
                if (ok) {
                  // Snapshot the count BEFORE firing the mutation so the
                  // onSuccess handler can detect "I thought there were N but
                  // PHP says zero" (transient expired, see ref comment).
                  expectedCleanupCount.current = brokenCount;
                  cleanupBroken.mutate();
                }
              }}
              disabled={cleanupBroken.isLoading}
              title="Sweep out records the last sync couldn't find on any destination. Removes only the database entries. The destinations themselves are not touched."
              className="border-amber-300 text-amber-800 hover:bg-amber-50"
            >
              {cleanupBroken.isLoading
                ? 'Removing…'
                : `Remove ${brokenCount} broken`}
            </Button>
          )}
          <Button variant="secondary" size="md" leftIcon={<Upload className="h-4 w-4" />}
            onClick={() => {
              setImportOpen(true);
              setTimeout(() => document.getElementById('backreload-import-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
            }}>
            Import
          </Button>
          <BackupNowButton
            disabled={create.isLoading || !!activeBackupId}
            label={activeBackupId ? 'Running…' : (create.isLoading ? 'Queuing…' : 'Backup now')}
            onRun={(t) => create.mutate(t)}
          />
        </div>
      </div>

      <ImportBackup
        open={importOpen}
        onClose={() => setImportOpen(false)}
        onImported={() => { setToast({ msg: 'Backup imported successfully', tone: 'success' }); refetch(); }}
      />

      {list.length === 0 ? (
        <Empty
          title="No backups yet"
          hint="Run your first backup, or import an existing archive you downloaded earlier."
          action={
            <div className="flex items-center gap-2">
              <Button variant="primary" leftIcon={<Play className="h-4 w-4" />} onClick={() => create.mutate('full')}>Backup now</Button>
              <Button variant="secondary" leftIcon={<Upload className="h-4 w-4" />} onClick={() => setImportOpen(true)}>Import</Button>
            </div>
          }
        />
      ) : (
        <Card>
          <CardHeader>
            <div>
              <CardTitle>All backups</CardTitle>
              <p className="text-xs text-ink-500 mt-0.5">
                {list.length} backups · {formatBytes(list.reduce((a, b) => a + b.size, 0))} total
                {(activeFilterCount > 0 || tab !== 'all') && data?.items.length !== list.length &&
                  <> · <span className="text-brand-700">filtered from {data?.items.length}</span></>
                }
              </p>
            </div>
            {activeFilterCount > 0 && (
              <button onClick={clearFilters} className="text-xs font-medium text-brand-700 hover:underline">
                Clear filters
              </button>
            )}
          </CardHeader>
          <CardBody className="p-0 overflow-x-auto">
            <Table className="min-w-[900px]">
              <THead>
                <tr>
                  <TH>Date</TH>
                  <TH>Type</TH>
                  <TH>Size</TH>
                  <TH>Duration</TH>
                  <TH>Destinations</TH>
                  <TH>Status</TH>
                  <TH className="text-right">Actions</TH>
                </tr>
              </THead>
              <tbody>
                {pageList.map((b) => (
                  <TR key={b.id}>
                    <TD className="text-ink-600">
                      <Popover
                        align="left"
                        width={420}
                        trigger={(_open, toggle) => (
                          <button
                            type="button"
                            onClick={toggle}
                            title="Show backup details"
                            className="group inline-flex items-center gap-1.5 -mx-1 px-1 py-0.5 rounded hover:bg-ink-100 text-ink-700 hover:text-ink-900 transition-colors"
                          >
                            <span>{formatDateTime(b.date)}</span>
                            {b.encrypted && (
                              <Lock
                                className="h-3 w-3 text-ink-400"
                                aria-label="Encrypted"
                              />
                            )}
                            <ChevronDown className="h-3 w-3 text-ink-300 group-hover:text-ink-500 shrink-0" />
                          </button>
                        )}
                      >
                        {(close) => (
                          <BackupDetails
                            backup={b}
                            destById={destById}
                            onViewLogs={() => { close(); navigate('logs', { backupId: b.id }); }}
                          />
                        )}
                      </Popover>
                    </TD>
                    <TD><Badge tone={b.type === 'full' ? 'accent' : 'neutral'}>{b.type}</Badge></TD>
                    <TD className="font-medium">
                      <span title={b.files && b.files.length > 0
                        ? `${b.files.length} part(s):\n` + b.files.map((f) => `  • ${f.name} (${formatBytes(f.size)}, ${f.category})`).join('\n')
                        : ''}>
                        {formatBytes(b.size)}
                        {b.files && b.files.length > 1 && (
                          <span className="text-[10px] text-ink-500 ml-1">· {b.files.length} parts</span>
                        )}
                      </span>
                    </TD>
                    <TD className="text-ink-600">{formatDuration(b.duration)}</TD>
                    <TD>
                      {(() => {
                        // Reality, not intent: badges only render for
                        // destinations that have a COMPLETE copy of this
                        // backup. A paused/disconnected destination during
                        // the run still appears in b.destinations[] but
                        // received no parts — those shouldn't show up here.
                        const actual = backupActualDestinations(b);
                        const isMissing = !!b.missing_on_destination;
                        return (
                          <div className="flex items-center gap-2 flex-wrap">
                            {actual.length === 0 ? (
                              <span className="text-[11px] text-ink-400 italic">no copies</span>
                            ) : (
                              <div className="flex -space-x-1.5">
                                {actual.map((id) => {
                                  const d = destById[id];
                                  const isGone = !d;
                                  const isDisconnected = d && d.status !== 'connected';
                                  return (
                                    <span
                                      key={id}
                                      title={isGone ? `${id} (removed)` : (isDisconnected ? `${d.name} (disconnected)` : d.name)}
                                      className={cn(
                                        'h-6 w-6 rounded-full ring-2 ring-white text-[10px] font-medium grid place-items-center',
                                        (isGone || isDisconnected || isMissing)
                                          ? 'bg-ink-50 text-ink-400 line-through'
                                          : 'bg-ink-100 text-ink-700',
                                      )}
                                    >
                                      {(d?.name ?? id).charAt(0).toUpperCase()}
                                    </span>
                                  );
                                })}
                              </div>
                            )}
                            {isMissing && (
                              <span
                                title="The last sync couldn't find this backup's folder on any destination. It was probably deleted directly on the server. Use Remove broken to clear the database entry."
                                className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-md ring-1 ring-inset ring-amber-200 bg-amber-50 text-amber-800"
                              >
                                <CircleAlert className="h-3 w-3" />
                                missing
                              </span>
                            )}
                          </div>
                        );
                      })()}
                    </TD>
                    <TD><Badge tone={statusTone[b.status]} dot>{b.status}</Badge></TD>
                    <TD className="text-right">
                      <div className="inline-flex gap-1">
                        {(() => {
                          const isActive  = b.status === 'pending' || b.status === 'running';
                          const isSuccess = b.status === 'success';
                          const dlOk      = canDownload(b.destinations);
                          const rsOk      = canRestore(b.destinations);

                          if (isActive) {
                            return (
                              <IconBtn
                                title="Stop this backup"
                                onClick={async () => {
                                  const ok = await confirm({
                                    title: 'Stop this backup?',
                                    description: 'The engine will abort at its next checkpoint. Any files already uploaded will remain on the destination.',
                                    confirmLabel: 'Stop backup',
                                    tone: 'danger',
                                  });
                                  if (ok) cancelBackup.mutate(b.id);
                                }}
                              >
                                <StopCircle className="h-4 w-4 text-danger" />
                              </IconBtn>
                            );
                          }

                          // Resumable: a failed/cancelled backup that has prior progress
                          // recorded in files[]. The engine treats the same execute()
                          // path as a resume — it just skips categories that already
                          // built and uploads already done.
                          const isResumable =
                            (b.status === 'failed' || b.status === 'cancelled') &&
                            (b.files?.length ?? 0) > 0;

                          const dlTitle = !isSuccess ? 'Only successful backups can be downloaded'
                                        : !dlOk     ? 'No reachable destination holds a copy of this backup'
                                        : 'Download';
                          const rsTitle = !isSuccess ? 'Only successful backups can be restored'
                                        : !rsOk     ? 'No reachable destination for this backup'
                                        : 'Restore';
                          const isMultiPart = (b.files?.length ?? 0) > 1;
                          return (
                            <>
                              {isResumable && (
                                <IconBtn
                                  title={`Resume. ${b.files.length} part(s) already built.`}
                                  onClick={() => resumeBackup.mutate(b.id)}
                                  disabled={resumeBackup.isLoading}
                                >
                                  <PlayCircle className="h-4 w-4 text-brand-700" />
                                </IconBtn>
                              )}
                              {isMultiPart && isSuccess && dlOk ? (
                                <IconBtn
                                  title={`Download (${b.files.length} parts)`}
                                  onClick={() => setDownloadOpenId(b.id)}
                                >
                                  <Download className="h-4 w-4" />
                                </IconBtn>
                              ) : (
                                <IconBtn title={dlTitle} onClick={() => downloadBackup(b.id)}
                                  disabled={!isSuccess || !dlOk}>
                                  <Download className="h-4 w-4" />
                                </IconBtn>
                              )}
                              <IconBtn title={rsTitle} onClick={() => navigate('restore', { backupId: b.id })}
                                disabled={!isSuccess || !rsOk}>
                                <RotateCcw className="h-4 w-4" />
                              </IconBtn>
                            </>
                          );
                        })()}
                        <IconBtn title="Delete" onClick={async () => {
                          const ok = await confirm({
                            title: `Delete backup ${b.id}?`,
                            description: 'The record will be removed from the database. Files already uploaded to destinations remain.',
                            confirmLabel: 'Delete',
                            tone: 'danger',
                          });
                          if (ok) remove.mutate(b.id);
                        }}><Trash2 className="h-4 w-4" /></IconBtn>
                      </div>
                    </TD>
                  </TR>
                ))}
              </tbody>
            </Table>
          </CardBody>
          {list.length > 0 && (
            <div className="px-4 py-3 border-t border-ink-100">
              <Pagination
                totalItems={list.length}
                page={page}
                perPage={perPage}
                onPageChange={setPage}
                onPerPageChange={setPerPage}
                perPageOptions={[10, 25, 50, 100]}
                itemLabel="backups"
              />
            </div>
          )}
        </Card>
      )}

      {/* Download picker. Mounted once at the page level; opens for whichever
          backup row asks. Closing on download keeps the flow short — the toast
          handles the multi-part feedback after the modal disappears. */}
      {downloadOpenId && (() => {
        const target = (data?.items ?? []).find((x) => x.id === downloadOpenId);
        if (!target) return null;
        return (
          <Dialog
            open
            onClose={() => setDownloadOpenId(null)}
            title="Download backup"
            description={backupSlug(target.date, target.id)}
            size="lg"
          >
            <DownloadTilePicker
              backup={target}
              downloadPart={(index) => downloadBackup(target.id, index)}
            />
          </Dialog>
        );
      })()}

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

/**
 * "More info" popover anchored to the Date cell. Replaces the old Backup ID
 * column with a richer detail surface — same data was hidden behind tooltips
 * and unused columns before, now it has a home.
 *
 * Sections:
 *   - Identity:  full backup ID (copyable) and checksum (copyable, truncated)
 *   - Source:    site URL + filesystem path the backup was taken from
 *   - Files:     each archive part with its category, size, and a per-row
 *                indicator of which destinations it actually reached
 *   - Error:     surfaced when the backup failed
 */
function BackupDetails({
  backup,
  destById,
  onViewLogs,
}: {
  backup: Backup;
  destById: Record<string, { type: string; name: string; status: string }>;
  onViewLogs: () => void;
}) {
  return (
    <div className="max-h-[460px] overflow-y-auto">
      <div className="px-4 py-3 border-b border-ink-100 flex items-center justify-between gap-3">
        <div>
          <p className="text-sm font-semibold text-ink-900">Backup details</p>
          <p className="text-[11px] text-ink-500">
            {formatDateTime(backup.date)}
          </p>
        </div>
        <Badge tone={backup.type === 'full' ? 'accent' : 'neutral'}>{backup.type}</Badge>
      </div>

      <div className="p-4 space-y-4 text-xs">
        {/* Identity. Clicking the Backup ID itself opens the logs filtered
            to this backup — same destination as the "View logs" button at
            the bottom, just reachable straight from the ID. The copy icon
            on the right still copies. */}
        <Section title="Identifier">
          <CopyableRow
            label="Backup ID"
            value={backup.id}
            mono
            onClickValue={onViewLogs}
            valueHint="Click to view this backup's logs"
          />
          {backup.checksum && (
            <CopyableRow label="Checksum" value={backup.checksum} mono truncate />
          )}
        </Section>

        {/* Source — useful when restoring onto a different site */}
        {(backup.source_url || backup.source_path) && (
          <Section title="Source">
            {backup.source_url  && <KV label="URL"  value={backup.source_url}  mono />}
            {backup.source_path && <KV label="Path" value={backup.source_path} mono />}
          </Section>
        )}

        {/* Components */}
        {backup.components && backup.components.length > 0 && (
          <Section title="Components">
            <p className="text-ink-700 capitalize">{backup.components.join(' · ')}</p>
          </Section>
        )}

        {/* Files + per-destination upload status */}
        {backup.files && backup.files.length > 0 && (
          <Section title={`Files (${backup.files.length})`}>
            <ul className="space-y-1.5">
              {backup.files.map((f) => (
                <li key={f.name} className="rounded-md border border-ink-100 bg-ink-50/40 px-2.5 py-1.5">
                  <div className="flex items-start justify-between gap-2">
                    <div className="min-w-0 flex-1">
                      <p className="font-mono text-[11px] text-ink-800 truncate" title={f.name}>{f.name}</p>
                      <p className="text-[10px] text-ink-500 mt-0.5 capitalize">
                        {f.category} · {formatBytes(f.size)}
                        {f.encrypted && <> · encrypted</>}
                      </p>
                    </div>
                  </div>
                  {/* Per-destination upload status: a green dot if this part
                      reached the destination, gray if it didn't. Helps debug
                      partial uploads without leaving the page. */}
                  {backup.destinations.length > 0 && (
                    <div className="flex flex-wrap gap-1 mt-1.5">
                      {backup.destinations.map((destId) => {
                        const d = destById[destId];
                        const uploaded = (f.uploaded_to ?? []).includes(destId);
                        return (
                          <span
                            key={destId}
                            title={uploaded ? `Uploaded to ${d?.name ?? destId}` : `Not uploaded to ${d?.name ?? destId}`}
                            className={cn(
                              'inline-flex items-center gap-1 h-4 px-1.5 rounded text-[9px] font-medium',
                              uploaded
                                ? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
                                : 'bg-ink-100 text-ink-500 border border-ink-200',
                            )}
                          >
                            <span className={cn(
                              'h-1.5 w-1.5 rounded-full',
                              uploaded ? 'bg-emerald-500' : 'bg-ink-300',
                            )} />
                            {d?.name ?? destId}
                          </span>
                        );
                      })}
                    </div>
                  )}
                </li>
              ))}
            </ul>
          </Section>
        )}

        {/* Error */}
        {backup.error && (
          <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 flex items-start gap-2">
            <CircleAlert className="h-3.5 w-3.5 text-danger shrink-0 mt-0.5" />
            <div className="min-w-0">
              <p className="text-[11px] font-medium text-danger">Last error</p>
              <p className="text-[11px] text-ink-700 mt-0.5 break-words">{backup.error}</p>
            </div>
          </div>
        )}
      </div>

      {/* Footer action — jumps to the Logs page with this backup pre-filtered. */}
      <div className="px-4 py-2.5 border-t border-ink-100 bg-ink-50/40">
        <button
          type="button"
          onClick={onViewLogs}
          className="w-full inline-flex items-center justify-center gap-1.5 h-8 rounded-md text-xs font-medium text-brand-700 hover:text-brand-800 hover:bg-brand-50 transition-colors"
        >
          <ScrollText className="h-3.5 w-3.5" />
          View logs for this backup
        </button>
      </div>
    </div>
  );
}

function Section({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div>
      <p className="text-[10px] uppercase tracking-wider text-ink-500 font-medium mb-1.5">{title}</p>
      <div className="space-y-1">{children}</div>
    </div>
  );
}

function KV({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
  return (
    <div className="flex items-start gap-2">
      <span className="text-ink-500 shrink-0 w-12">{label}</span>
      <span className={cn('text-ink-800 break-all', mono && 'font-mono text-[11px]')}>{value}</span>
    </div>
  );
}

/** Inline row with a copy-to-clipboard button. */
function CopyableRow({
  label, value, mono, truncate, onClickValue, valueHint,
}: {
  label: string;
  value: string;
  mono?: boolean;
  truncate?: boolean;
  /**
   * When provided, clicking the value text fires THIS instead of copying.
   * (The dedicated copy icon on the right still copies.) Use for fields
   * where the natural "click" action is navigation — e.g. Backup ID
   * opens the logs filtered to that backup.
   */
  onClickValue?: () => void;
  /** Override the hover tooltip on the value area. */
  valueHint?: string;
}) {
  const [copied, setCopied] = useState(false);
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(value);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1200);
    } catch {
      // Browser blocked clipboard access — fall back to selecting the text.
    }
  };
  return (
    <div className="flex items-center gap-2 group">
      <span className="text-ink-500 shrink-0 w-12">{label}</span>
      {/* Value area. By default clicks copy; when onClickValue is given,
          clicks navigate (e.g. open logs). The copy icon on the right
          always copies, so users still have an explicit copy path. */}
      <button
        type="button"
        onClick={onClickValue ?? copy}
        title={valueHint ?? (onClickValue ? 'Click to open' : (copied ? 'Copied!' : 'Click to copy'))}
        className={cn(
          'flex-1 text-left -ml-1 px-1 py-0.5 rounded transition-colors',
          onClickValue
            ? 'text-brand-700 hover:bg-brand-50 hover:underline cursor-pointer'
            : 'text-ink-800 hover:bg-ink-100 active:bg-ink-200 cursor-pointer',
          !onClickValue && copied && 'bg-emerald-50 text-emerald-900',
          mono && 'font-mono text-[11px]',
          truncate && 'truncate',
        )}
      >
        {value}
      </button>
      {/* Labeled Copy button. Always copies, never navigates. Promoted
          from icon-only to icon+label so the action is obvious — when
          the value text itself has a different click action (e.g. open
          logs), users couldn't tell where to click to copy. */}
      <button
        type="button"
        onClick={copy}
        title={copied ? 'Copied!' : `Copy ${label}`}
        aria-label={copied ? 'Copied' : `Copy ${label}`}
        className={cn(
          'inline-flex items-center gap-1 h-7 px-2 rounded-md text-[11px] font-medium border transition-colors shrink-0',
          copied
            ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
            : 'border-ink-200 bg-white text-ink-700 hover:bg-ink-100 hover:border-ink-300',
        )}
      >
        {copied
          ? <><CheckIcon className="h-3 w-3" /> Copied</>
          : <><Copy className="h-3 w-3" /> Copy</>}
      </button>
    </div>
  );
}

/**
 * The download popover content for multi-zip backups. Uses the same tile-card
 * language as the Restore wizard's component step so the two flows feel like
 * one product, and so users who have already restored once know how to read
 * this surface at a glance.
 *
 * "Download everything" and any multi-part category download their parts one
 * by one — the picker fires each part ~1s apart and shows a progress list.
 * Browsers refuse to let one click trigger many downloads, so spacing them out
 * (one "allow multiple downloads" prompt) is how we hand over the whole set
 * without ever building a single huge bundle file.
 *
 * Layout:
 *   ┌────────────────────────────────────────────┐
 *   │  Download backup           bk_xyz · 7 parts │
 *   ├────────────────────────────────────────────┤
 *   │  [   Download everything · 444 MB · 7 parts ]
 *   │  [Database     ]  [Plugins · 4 parts        ]
 *   │  [Themes       ]  [Uploads                  ]
 *   └────────────────────────────────────────────┘
 */
function DownloadTilePicker({
  backup, downloadPart,
}: {
  backup: Backup;
  /** Trigger a single-part download by its index in backup.files[]. */
  downloadPart: (index: number) => void;
}) {
  // An in-progress "download these parts" run. `cursor` = how many parts have
  // been downloaded so far. null = no run yet.
  const [run, setRun] = useState<{ indexes: number[]; label: string; cursor: number } | null>(null);

  // Group every file by category, remembering its position in backup.files[]
  // because the server identifies parts by `?part=N` (the original index).
  const groups = new Map<string, { indexes: number[]; size: number }>();
  backup.files.forEach((f, idx) => {
    const cat = f.category || 'others';
    const g = groups.get(cat) ?? { indexes: [], size: 0 };
    g.indexes.push(idx);
    g.size += f.size;
    groups.set(cat, g);
  });

  /** Short, human file name for a part (basename of the stored path). */
  const partName = (idx: number) =>
    backup.files[idx]?.name?.split('/').pop() || `part ${idx + 1}`;

  /**
   * Start downloading a set of parts. The first part downloads immediately
   * (inside this click — a download from a direct user gesture is never
   * blocked). A single part is finished right there; multiple parts open the
   * stepper below. "One click → one download" is the ONLY pattern every
   * browser reliably honours: firing several at once — with or without timers
   * — gets the extras silently blocked.
   */
  const start = (indexes: number[], label: string) => {
    if (indexes.length === 0) return;
    downloadPart(indexes[0]);
    if (indexes.length === 1) return;
    setRun({ indexes, label, cursor: 1 });
  };

  /** Download the next queued part. Runs inside a real click → never blocked. */
  const next = () => {
    if (!run || run.cursor >= run.indexes.length) return;
    downloadPart(run.indexes[run.cursor]);
    setRun({ ...run, cursor: run.cursor + 1 });
  };

  // ── Active run: step through the remaining parts, one click each. ───────
  if (run) {
    const total   = run.indexes.length;
    const done    = run.cursor >= total;
    const nextIdx = done ? -1 : run.indexes[run.cursor];
    return (
      <div className="space-y-4">
        <div className="flex items-center gap-2.5">
          <span className={cn(
            'h-8 w-8 rounded-lg grid place-items-center shrink-0',
            done ? 'bg-emerald-50 text-emerald-600' : 'bg-brand-50 text-brand-600',
          )}>
            {done ? <CheckIcon className="h-4 w-4" /> : <Download className="h-4 w-4" />}
          </span>
          <div className="min-w-0">
            <p className="text-sm font-semibold text-ink-900">
              {done ? `All ${total} parts downloaded` : `${run.cursor} of ${total} downloaded`}
            </p>
            <p className="text-[11px] text-ink-500">
              {done
                ? 'Keep every part together in one folder for a future restore.'
                : `${run.label.charAt(0).toUpperCase() + run.label.slice(1)} · one part per click`}
            </p>
          </div>
        </div>

        <div className="h-1.5 rounded-full bg-ink-100 overflow-hidden">
          <div className="h-full bg-brand-500 transition-all" style={{ width: `${Math.round((run.cursor / total) * 100)}%` }} />
        </div>

        {/* Primary stepper button — each press is its own click → guaranteed. */}
        {!done && (
          <button
            type="button"
            onClick={next}
            className="w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl border-2 border-brand-500 bg-brand-50/80 hover:bg-brand-100 text-left transition-colors"
          >
            <span className="flex items-center gap-2.5 min-w-0">
              <span className="h-6 w-6 rounded-md bg-white border border-brand-200 grid place-items-center text-brand-700 shrink-0">
                <Download className="h-3.5 w-3.5" />
              </span>
              <span className="min-w-0">
                <span className="block text-sm font-medium text-ink-900">Download next part</span>
                <span className="block text-[11px] text-ink-600 truncate font-mono">{partName(nextIdx)}</span>
              </span>
            </span>
            <span className="text-[11px] text-ink-500 shrink-0 tabular-nums">{run.cursor + 1} / {total}</span>
          </button>
        )}

        {/* Per-part list — done parts ticked, every row independently grabbable. */}
        <div className="rounded-lg border border-ink-200 divide-y divide-ink-100 max-h-48 overflow-auto">
          {run.indexes.map((idx, i) => {
            const isDone = i < run.cursor;
            return (
              <div key={idx} className="flex items-center gap-2 px-2.5 py-1.5">
                <span className={cn(
                  'h-4 w-4 rounded-full grid place-items-center text-[9px] font-semibold shrink-0',
                  isDone ? 'bg-emerald-100 text-emerald-700' : 'bg-ink-100 text-ink-500',
                )}>
                  {isDone ? '✓' : i + 1}
                </span>
                <span className="flex-1 min-w-0 text-[11px] text-ink-700 truncate font-mono">
                  {partName(idx)}
                </span>
                <span className="text-[10px] text-ink-400 tabular-nums shrink-0">
                  {formatBytes(backup.files[idx]?.size ?? 0)}
                </span>
                <button
                  type="button"
                  onClick={() => downloadPart(idx)}
                  className="text-[10px] font-medium text-brand-600 hover:text-brand-700 hover:underline shrink-0"
                >
                  {isDone ? 'Re-download' : 'Download'}
                </button>
              </div>
            );
          })}
        </div>

        <button
          type="button"
          onClick={() => setRun(null)}
          className="text-[11px] text-ink-500 hover:text-ink-800"
        >
          ← Back to all parts
        </button>
      </div>
    );
  }

  // Legacy / imported single-zip → one "Full archive" tile and that's it.
  if (groups.has('legacy')) {
    const g = groups.get('legacy')!;
    return (
      <Tile
        icon={<Layers className="h-4 w-4" />}
        title="Full archive"
        desc="The whole backup as a single file"
        meta={formatBytes(g.size)}
        onClick={() => start(g.indexes, 'Full archive')}
        wide
      />
    );
  }

  // "Download everything" order: follow the category list (DB first), then
  // append any part whose category isn't in the known list.
  const allIndexes: number[] = [];
  DOWNLOAD_CATEGORIES.forEach(({ key }) => {
    const g = groups.get(key);
    if (g) allIndexes.push(...g.indexes);
  });
  backup.files.forEach((_, idx) => {
    if (!allIndexes.includes(idx)) allIndexes.push(idx);
  });

  return (
    <div className="space-y-3">
      {/* "Download everything" — downloads every part one by one. */}
      <button
        type="button"
        onClick={() => start(allIndexes, 'entire backup')}
        title="Download every part, one after another"
        className="w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl border-2 border-brand-500 bg-brand-50/80 hover:bg-brand-100 text-left transition-colors"
      >
        <span className="flex items-center gap-2.5 min-w-0">
          <span className="h-6 w-6 rounded-md bg-white border border-brand-200 grid place-items-center text-brand-700 shrink-0">
            <Download className="h-3.5 w-3.5" />
          </span>
          <span className="min-w-0">
            <span className="block text-sm font-medium text-ink-900">Download everything</span>
            <span className="block text-[11px] text-ink-600">
              {backup.files.length} parts · {formatBytes(backup.size)} · downloaded one by one
            </span>
          </span>
        </span>
      </button>

      {/* Explain how the parts behave. */}
      <p className="text-[11px] text-ink-500 leading-relaxed">
        Each part downloads as its own file. Keep all the parts together in one
        folder. A future restore needs the complete set.
      </p>

      <div className="grid grid-cols-2 gap-2">
        {DOWNLOAD_CATEGORIES.map(({ key, title, path, icon: Icon }) => {
          const g = groups.get(key);
          if (!g) return null; // hide categories the backup doesn't contain

          const meta =
            g.indexes.length > 1
              ? `${formatBytes(g.size)} · ${g.indexes.length} parts`
              : formatBytes(g.size);

          return (
            <Tile
              key={key}
              icon={<Icon className="h-4 w-4" />}
              title={title}
              desc={path}
              meta={meta}
              onClick={() => start(g.indexes, title)}
            />
          );
        })}
      </div>
    </div>
  );
}

function Tile({
  icon, title, desc, meta, onClick, wide,
}: {
  icon: React.ReactNode;
  title: string;
  desc: string;
  meta: string;
  onClick: () => void;
  wide?: boolean;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn(
        '!justify-start flex items-start gap-2.5 p-3 rounded-xl border-2 border-ink-200 hover:border-brand-400 hover:bg-brand-50/40 text-left transition-all group',
        wide && 'w-full',
      )}
    >
      <span className="h-6 w-6 rounded-md bg-white border border-ink-200 grid place-items-center text-ink-700 group-hover:text-brand-700 group-hover:border-brand-300 shrink-0">
        {icon}
      </span>
      <span className="flex-1 min-w-0">
        <span className="block text-sm font-medium text-ink-900 truncate">{title}</span>
        <span className="block text-[11px] text-ink-500 truncate">{desc}</span>
        <span className="block text-[11px] text-ink-700 mt-1 font-medium tabular-nums">{meta}</span>
      </span>
      <Download className="h-3.5 w-3.5 text-ink-300 group-hover:text-brand-600 shrink-0 mt-0.5" />
    </button>
  );
}

function IconBtn({ children, title, onClick, disabled }: { children: React.ReactNode; title: string; onClick?: () => void; disabled?: boolean }) {
  // `title` shows on hover; screen readers ignore it and would otherwise
  // announce these icon-only buttons as just "button". Mirror `title` into
  // `aria-label` so the same one-word description ("Delete", "Download",
  // "Restore", "Resume", "Stop this backup", etc.) becomes the accessible
  // name. Every existing call site already passes `title`, so no other
  // changes are needed for the table action column to be screen-reader-safe.
  return (
    <button title={title} aria-label={title} onClick={onClick} disabled={disabled}
      className="h-7 w-7 grid place-items-center rounded-md text-ink-500 hover:text-ink-900 hover:bg-ink-100 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed">
      {children}
    </button>
  );
}
