import { useEffect, useRef, useState } from 'react';
import {
  Send, Key as KeyIcon, Copy, Check, X, AlertTriangle, Clock, Globe,
  Loader2, RotateCcw, CheckCircle2, XCircle, ArrowRight, Inbox, Upload,
  HelpCircle,
} from 'lucide-react';
import { Card, CardBody, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Loading, ErrorState, Toast } from '@/components/ui/States';
import { api, ApiError, type MigrationKey, type MigrationStatus, type LogEntry } from '@/lib/api';
import { useQuery, useMutation } from '@/hooks/useQuery';
import { useConfirm } from '@/components/ConfirmProvider';
import { formatDateTime, formatBytes, formatDuration } from '@/lib/format';
import { cn } from '@/lib/cn';

/**
 * Site-to-site migration page.
 *
 * Two flows live here:
 *   - **Receive** (this site): generate a key, hand the URL to whoever
 *     is pushing FROM the source site. UI implemented in Phase 1.
 *   - **Send** (this site): paste a key from a destination site, push a
 *     backup INTO it. UI placeholder in Phase 1; full implementation
 *     in Phase 2+.
 *
 * Each role is a separate tab so the user is never confused about which
 * direction they're operating in. WPvivid splits these across two pages
 * ("Auto-Migration" + "Key"); we keep them under one nav entry but with
 * clear separation.
 */
export function Migrate() {
  const [tab, setTab] = useState<'receive' | 'send'>('receive');

  return (
    <div className="space-y-4 max-w-[1400px]">
      {/* Role picker. Two presentations:
           - Mobile (<sm): compact segmented control. Two big cards
             stacked vertically pushed everything off-screen; users were
             scrolling for ages before reaching the actual feature.
           - sm and up: the full cards with icon + subtitle + hint. */}

      {/* Mobile: segmented control */}
      <div className="sm:hidden">
        <div className="grid grid-cols-2 gap-1 p-1 rounded-xl bg-ink-100">
          <RolePill
            active={tab === 'receive'}
            onClick={() => setTab('receive')}
            icon={<Inbox className="h-4 w-4" />}
            title="Receive"
            subtitle="New site"
          />
          <RolePill
            active={tab === 'send'}
            onClick={() => setTab('send')}
            icon={<Upload className="h-4 w-4" />}
            title="Send"
            subtitle="Old site"
          />
        </div>
      </div>

      {/* sm and up: full role cards */}
      <div className="hidden sm:grid sm:grid-cols-2 gap-3">
        <RoleCard
          active={tab === 'receive'}
          onClick={() => setTab('receive')}
          icon={<Inbox className="h-5 w-5" />}
          title="Receive"
          subtitle="This is the NEW site"
          hint="Generate a key. Hand it to the old site."
        />
        <RoleCard
          active={tab === 'send'}
          onClick={() => setTab('send')}
          icon={<Upload className="h-5 w-5" />}
          title="Send"
          subtitle="This is the OLD site"
          hint="Paste the new site's key. Push your backup."
        />
      </div>

      {tab === 'receive' && <ReceivePanel />}
      {tab === 'send'    && <SendPanel />}
    </div>
  );
}

/**
 * The big "which side am I on?" picker at the top of the Migrate page.
 * Same data as a Tabs component, but treated as a first-class decision
 * because it's the source of every first-time-user mistake on this page.
 * Active state uses the brand colour + a clear ring so there's no doubt.
 */
function RoleCard({
  active, onClick, icon, title, subtitle, hint,
}: {
  active: boolean;
  onClick: () => void;
  icon: React.ReactNode;
  title: string;
  subtitle: string;
  hint: string;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn(
        // Suppress the browser-default focus outline that was overdrawing
        // our brand border. No `ring-offset` — that draws a transparent
        // gap which showed as a dark outline against the page background.
        // The active brand border already serves as the visible focus
        // signal for keyboard users (they just tabbed to it).
        'group text-left rounded-xl border-2 p-4 transition-all outline-none focus:outline-none focus-visible:outline-none',
        active
          ? 'border-brand-500 bg-brand-50/60 ring-2 ring-brand-100'
          : 'border-ink-200 bg-white hover:border-ink-300 hover:bg-ink-50',
      )}
      aria-pressed={active}
    >
      <div className="flex items-start gap-3">
        <span className={cn(
          'h-10 w-10 grid place-items-center rounded-lg shrink-0',
          active ? 'bg-brand-100 text-brand-700' : 'bg-ink-100 text-ink-600',
        )}>
          {icon}
        </span>
        <div className="flex-1 min-w-0">
          <div className="flex items-center gap-2">
            <span className={cn(
              'text-base font-semibold',
              active ? 'text-brand-900' : 'text-ink-900',
            )}>{title}</span>
            <span className={cn(
              'text-[10px] uppercase tracking-wider font-medium px-1.5 py-0.5 rounded',
              active ? 'bg-brand-200/60 text-brand-800' : 'bg-ink-100 text-ink-600',
            )}>
              {subtitle}
            </span>
          </div>
          <p className="text-xs text-ink-600 mt-1 leading-relaxed">{hint}</p>
        </div>
      </div>
    </button>
  );
}

/**
 * Compact role picker for mobile. Renders as one half of a segmented
 * control — much less vertical real estate than the full RoleCard while
 * preserving the role + "which side am I on?" cues. The active half lifts
 * up with a white background; the inactive half blends into the wrapper.
 */
function RolePill({
  active, onClick, icon, title, subtitle,
}: {
  active: boolean;
  onClick: () => void;
  icon: React.ReactNode;
  title: string;
  subtitle: string;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-pressed={active}
      className={cn(
        'flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg transition-colors outline-none focus:outline-none',
        active
          ? 'bg-white text-brand-900 shadow-sm'
          : 'text-ink-600 hover:text-ink-900',
      )}
    >
      <span className={cn(active ? 'text-brand-600' : 'text-ink-500')}>
        {icon}
      </span>
      <span className="flex flex-col items-start leading-tight">
        <span className="text-sm font-semibold">{title}</span>
        <span className={cn('text-[10px] uppercase tracking-wider', active ? 'text-brand-700' : 'text-ink-500')}>
          {subtitle}
        </span>
      </span>
    </button>
  );
}

/**
 * Live progress of an incoming migration's auto-restore, shown on the Receive
 * (destination) side. Polls /migrate/receive/status; renders nothing when no
 * migration restore is running. This is what the destination admin watches
 * while a pushed backup is being restored onto this site.
 */
const RECEIVE_PHASES = [
  { key: 'downloading', label: 'Download' },
  { key: 'restoring',   label: 'Restore files' },
  { key: 'database',    label: 'Database' },
  { key: 'migrating',   label: 'Rewrite URLs' },
] as const;

function ReceiveRestoreProgress() {
  const [st, setSt] = useState<Awaited<ReturnType<typeof api.migrate.receiveStatus>> | null>(null);
  const [logs, setLogs] = useState<LogEntry[]>([]);
  // Set when the status poll starts returning 401/403 — which happens the
  // moment the database step restores the source's users table and drops our
  // login session. We surface this as an explanation rather than a dead panel.
  const [signedOut, setSignedOut] = useState(false);

  useEffect(() => {
    let stop = false;
    const poll = async () => {
      try {
        const r = await api.migrate.receiveStatus();
        if (stop) return;
        setSignedOut(false);
        setSt(r);
        // Pull the per-restore log feed so the destination shows the same
        // detail as the source's Send panel (download → files → DB → rewrite).
        if (r.active && r.backup_id) {
          const l = await api.logs
            .list({ backup_id: r.backup_id, limit: 40 })
            .catch(() => ({ items: [] as LogEntry[] }));
          if (!stop) setLogs(l.items);
        }
      } catch (e) {
        if (stop) return;
        // 401/403 = the DB restore replaced this site's users and invalidated
        // our session. The restore itself keeps running in the background
        // (token-authed), so this is expected, not a failure.
        if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
          setSignedOut(true);
        }
        // Other errors are transient — keep the last state on screen.
      }
    };
    poll();
    const iv = setInterval(poll, 3000);
    return () => { stop = true; clearInterval(iv); };
  }, []);

  // Signed out mid-restore: keep the panel meaningful instead of freezing.
  if (signedOut) {
    return (
      <Card className="border-amber-300">
        <CardBody>
          <div className="flex items-start gap-3">
            <span className="h-9 w-9 grid place-items-center rounded-lg bg-amber-50 text-amber-600 shrink-0">
              <AlertTriangle className="h-5 w-5" />
            </span>
            <div className="min-w-0 text-sm">
              <div className="font-semibold text-ink-900">You've been signed out — this is expected</div>
              <p className="text-ink-600 mt-1 leading-relaxed">
                The migration just restored the database, which replaced this site's users with the
                old site's. The restore is <strong>still finishing in the background</strong>. Wait a
                moment, then sign back in using the <strong>old site's</strong> username and password —
                this site is now a copy of it. You can watch the full detail on the <strong>Logs</strong> page
                after signing in.
              </p>
            </div>
          </div>
        </CardBody>
      </Card>
    );
  }

  if (!st?.active) return null;
  const phase = st.phase ?? 'starting';
  const done  = phase === 'complete';
  const fail  = phase === 'failed';
  const activeIdx = RECEIVE_PHASES.findIndex((p) => p.key === phase);
  const pct = st.files_total ? Math.round(((st.files_done ?? 0) / st.files_total) * 100) : (done ? 100 : 8);

  return (
    <Card className={cn('border-brand-200', fail && 'border-red-300', done && 'border-emerald-300')}>
      <CardBody>
        <div className="flex items-center gap-3">
          <span className={cn('h-9 w-9 grid place-items-center rounded-lg',
            done ? 'bg-emerald-50 text-emerald-600' : fail ? 'bg-red-50 text-red-600' : 'bg-brand-50 text-brand-600')}>
            {done ? <CheckCircle2 className="h-5 w-5" /> : fail ? <XCircle className="h-5 w-5" /> : <Loader2 className="h-5 w-5 animate-spin" />}
          </span>
          <div className="min-w-0">
            <div className="font-semibold text-ink-900">
              {done ? 'Migration received & restored' : fail ? 'Restore failed' : 'Restoring incoming migration…'}
            </div>
            <div className="text-xs text-ink-500 truncate">
              {st.source_url ? <>From <span className="font-mono">{st.source_url}</span> · </> : null}{st.label || phase}
            </div>
          </div>
        </div>

        {!done && !fail && (
          <div className="mt-4">
            <div className="flex items-center justify-between mb-1.5">
              {RECEIVE_PHASES.map((p, i) => (
                <span key={p.key} className={cn('text-[11px]',
                  i < activeIdx ? 'text-emerald-600' : i === activeIdx ? 'text-brand-600 font-medium' : 'text-ink-400')}>
                  {p.label}
                </span>
              ))}
            </div>
            <div className="h-2 rounded-full bg-ink-100 overflow-hidden">
              <div className="h-full bg-brand-500 transition-all" style={{ width: `${Math.max(4, pct)}%` }} />
            </div>
            {st.files_total ? (
              <div className="text-xs text-ink-500 mt-1">{st.files_done ?? 0} / {st.files_total} files</div>
            ) : null}
          </div>
        )}

        {/* Heads-up: the Database step signs the admin out (users table is
            replaced by the source's). Shown proactively so it isn't a shock. */}
        {!done && !fail && (
          <div className="mt-3 flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 p-2.5 text-[11px] text-amber-800">
            <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-px" />
            <span>
              When the <strong>Database</strong> step runs, you'll be signed out — this site's users get
              replaced by the old site's. That's normal; the restore keeps going in the background. Sign back
              in with the <strong>old site's</strong> username &amp; password to see the result.
            </span>
          </div>
        )}

        {/* Live log feed — same dark terminal box the Send panel uses, so the
            destination shows real detail instead of just a stepper. */}
        <div
          aria-live="polite"
          aria-label="Restore activity log"
          role="log"
          className={cn(
            'mt-3 bg-ink-900 text-ink-100 rounded-lg p-2.5 font-mono text-[11px] leading-5 max-h-40 overflow-auto',
            logs.length === 0 && 'opacity-50',
          )}
        >
          {logs.length === 0 && <div className="text-ink-500">Waiting for first log line…</div>}
          {logs.slice().reverse().map((l) => (
            <div key={l.id} className="flex gap-2">
              <span className={cn('uppercase text-[9px] tracking-wider w-12 shrink-0', migrationLogLevelClass[l.level])}>
                {l.level}
              </span>
              <span className="break-all">{l.message}</span>
            </div>
          ))}
        </div>

        {fail && st.error ? <div className="mt-3 text-xs text-red-600">{st.error}</div> : null}
      </CardBody>
    </Card>
  );
}

/**
 * Destination-side panel: generate a migration key, copy it, hand it off
 * to whoever is pushing the backup from the source. Active keys are listed
 * below so the admin can see what's outstanding and revoke any they didn't
 * mean to issue.
 */
function ReceivePanel() {
  const { data, isLoading, error, refetch } = useQuery(() => api.migrate.listKeys(), []);
  const [toast, setToast] = useState<{ msg: string; tone: 'success' | 'danger' } | null>(null);
  const [ttl, setTtl] = useState<'1h' | '8h' | '24h' | '7d'>('24h');
  // Destination-side consent for auto-restore. Defaults FALSE — generating
  // a key means "I'll accept a backup," not "I authorise destruction of
  // my current site." Opt in explicitly when you want WPvivid-style
  // one-click migration end-to-end.
  // Default ON for the one-click migration experience. Destination admin
  // can flip it off if they want to review imported backups manually
  // before applying. Source toggle on the Send tab gates the actual run,
  // so both must agree for auto-restore to happen.
  const [allowAutoRestore, setAllowAutoRestore] = useState(true);
  // The full key URL is shown ONCE — the response to createKey() is the
  // only time the plaintext token is sent over the wire. After a refetch
  // the list endpoint returns only the public metadata. We keep the URL
  // in component state until the user copies it or generates a new one.
  const [freshKeyUrl, setFreshKeyUrl] = useState<string | null>(null);
  const [freshKeyId, setFreshKeyId] = useState<string | null>(null);

  const create = useMutation(
    (args: { ttl: '1h' | '8h' | '24h' | '7d'; allowAutoRestore: boolean }) =>
      api.migrate.createKey(args.ttl, args.allowAutoRestore),
    {
      onSuccess: (res) => {
        setFreshKeyUrl(res.key_url);
        setFreshKeyId(res.key.id);
        refetch();
      },
      onError: (e) => setToast({ msg: e.message, tone: 'danger' }),
    },
  );

  const confirm = useConfirm();
  const revoke = useMutation(
    (id: string) => api.migrate.revokeKey(id),
    {
      onSuccess: () => {
        setToast({ msg: 'Key revoked', tone: 'success' });
        refetch();
      },
      onError: (e) => setToast({ msg: e.message, tone: 'danger' }),
    },
  );

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

  const activeKeys = (data?.items ?? []);

  return (
    <div className="space-y-4">
      {/* Live auto-restore progress for an incoming migration (destination side). */}
      <ReceiveRestoreProgress />

      {/* Generator card */}
      <Card>
        <CardHeader>
          <div className="flex items-center gap-3">
            <span className="h-8 w-8 grid place-items-center rounded-lg bg-ink-100 text-ink-700">
              <KeyIcon className="h-4 w-4" />
            </span>
            <div>
              <CardTitle>Generate a migration key</CardTitle>
              <CardDescription>
                Give this site permission to receive ONE backup push from a source site.
              </CardDescription>
            </div>
          </div>
        </CardHeader>
        <CardBody className="space-y-4">
          {/* TTL picker — pill group instead of a hidden <select>. All four
              choices visible at once with a single tap to pick. Mirrors the
              ContentTile pattern used in the Send preflight, so the page
              has one visual language for "pick one of N." */}
          <div>
            <span className="text-xs font-medium text-ink-700 block mb-2">Key valid for</span>
            <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
              {(
                [
                  { value: '1h',  label: '1 hour',   hint: 'Tightest'   },
                  { value: '8h',  label: '8 hours',  hint: 'Short job'  },
                  { value: '24h', label: '24 hours', hint: 'Recommended'},
                  { value: '7d',  label: '7 days',   hint: 'Long-lived' },
                ] as const
              ).map((opt) => {
                const picked = ttl === opt.value;
                return (
                  <button
                    key={opt.value}
                    type="button"
                    onClick={() => setTtl(opt.value)}
                    aria-pressed={picked}
                    className={cn(
                      'flex flex-col items-center justify-center py-2.5 px-2 rounded-lg border-2 transition-colors outline-none focus:outline-none',
                      picked
                        ? 'border-brand-500 bg-brand-50/60'
                        : 'border-ink-200 bg-white hover:border-ink-300 hover:bg-ink-50',
                    )}
                  >
                    <span className={cn(
                      'text-sm font-medium tabular-nums',
                      picked ? 'text-brand-900' : 'text-ink-900',
                    )}>
                      {opt.label}
                    </span>
                    <span className={cn(
                      'text-[10px] mt-0.5',
                      picked ? 'text-brand-700' : 'text-ink-500',
                    )}>
                      {opt.hint}
                    </span>
                  </button>
                );
              })}
            </div>
            <p className="text-[11px] text-ink-500 mt-1.5">
              The key auto-expires after this window. Pick the shortest that fits; you can always generate a new one.
            </p>
          </div>

          {/* Auto-restore consent toggle. Placed BEFORE the Generate button
              so the flow visually reads: pick TTL → set consent → click
              Generate. The value is captured INTO the key at generate
              time; flipping it later does nothing to existing keys (each
              row's "auto-restore" chip shows what that key was issued
              with). This avoids the trust-asymmetry where the source
              decides whether the destination gets restored. */}
          <div
            role="button"
            tabIndex={0}
            onClick={() => setAllowAutoRestore(!allowAutoRestore)}
            onKeyDown={(e) => {
              if (e.key === ' ' || e.key === 'Enter') {
                e.preventDefault();
                setAllowAutoRestore(!allowAutoRestore);
              }
            }}
            aria-pressed={allowAutoRestore}
            className={cn(
              'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors outline-none',
              allowAutoRestore
                ? 'border-brand-300 bg-brand-50/40'
                : 'border-ink-200 bg-white hover:bg-ink-50',
            )}
          >
            <span
              className={cn(
                'mt-0.5 relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
                allowAutoRestore ? 'bg-brand-500' : 'bg-ink-300',
              )}
              aria-hidden
            >
              <span
                className={cn(
                  'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
                  allowAutoRestore ? 'translate-x-4' : 'translate-x-0.5',
                )}
              />
            </span>
            <div className="flex-1 min-w-0">
              <div className="text-sm font-medium text-ink-900">
                Allow the source to auto-restore after push
              </div>
              <div className="text-[11px] text-ink-500 mt-0.5">
                {allowAutoRestore
                  ? "When the push completes, the source can restore on top of this site without manual approval. One-click migration."
                  : "Push only. The migrated backup will land here, but you'll need to open Restore to apply it. The source's auto-restore request will be ignored."}
              </div>
              <div className="text-[10px] text-ink-400 mt-1.5 italic">
                Applies to the next key you generate. Existing keys keep the setting they were created with.
              </div>
            </div>
          </div>

          {/* Generate button — applies BOTH the TTL and the toggle values
              above into a fresh key. After this, the toggle becomes
              irrelevant to that key. */}
          <Button
            variant="primary"
            leftIcon={<KeyIcon className="h-4 w-4" />}
            onClick={() => create.mutate({ ttl, allowAutoRestore })}
            disabled={create.isLoading}
          >
            {create.isLoading ? 'Generating…' : 'Generate key'}
          </Button>

          {freshKeyUrl && (
            <FreshKeyDisplay
              url={freshKeyUrl}
              onDismiss={() => { setFreshKeyUrl(null); setFreshKeyId(null); }}
            />
          )}

          <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 flex items-start gap-2.5">
            <AlertTriangle className="h-4 w-4 text-amber-600 shrink-0 mt-0.5" />
            <div className="text-xs text-amber-900 leading-relaxed">
              <p className="font-medium">The key acts as a single-use password to push a backup INTO this site.</p>
              <p className="mt-1 text-amber-800">
                Anyone who has the key URL can replace this site's contents with a backup of their choosing.
                Treat it like a password: share over a secure channel, set a short expiry,
                and revoke any keys you no longer need.
              </p>
            </div>
          </div>
        </CardBody>
      </Card>

      {/* Active keys table */}
      <Card>
        <CardHeader>
          <div>
            <CardTitle>Active keys</CardTitle>
            <CardDescription>
              Keys that haven't been redeemed yet or sessions still in progress.
            </CardDescription>
          </div>
        </CardHeader>
        <CardBody className="p-0">
          {activeKeys.length === 0 ? (
            <div className="px-5 py-10 text-center">
              <span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-ink-50 text-ink-400 mb-3">
                <KeyIcon className="h-5 w-5" />
              </span>
              <p className="text-sm font-medium text-ink-700">No active keys</p>
              <p className="text-xs text-ink-500 mt-1 max-w-sm mx-auto">
                Generate a key above to invite a source site to push a backup into here.
                Keys are single-use and expire on a timer you control.
              </p>
            </div>
          ) : (
            <ul className="divide-y divide-ink-100">
              {activeKeys.map((k) => (
                <KeyRow
                  key={k.id}
                  k={k}
                  isFresh={k.id === freshKeyId}
                  onRevoke={async () => {
                    const ok = await confirm({
                      title: `Revoke this key?`,
                      description:
                        k.state === 'redeemed'
                          ? 'The source site is currently using this key. Revoking will abort the migration in progress.'
                          : 'The key will stop working immediately. The destination site has not yet received any backup.',
                      confirmLabel: 'Revoke',
                      tone: 'danger',
                    });
                    if (!ok) return;
                    // Clear the on-screen plaintext URL card if it's THIS
                    // key — leaving a "Key ready" panel up for a revoked
                    // key would mislead the user into thinking it still
                    // works. Server-side it's dead the moment revoke succeeds.
                    if (k.id === freshKeyId) {
                      setFreshKeyUrl(null);
                      setFreshKeyId(null);
                    }
                    revoke.mutate(k.id);
                  }}
                />
              ))}
            </ul>
          )}
        </CardBody>
      </Card>

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

/**
 * Big copy-ready key display. Shown EXACTLY ONCE per key generation —
 * the plaintext token can't be reconstructed from server state after
 * this point. We make it as easy as possible to copy correctly: full
 * URL in a monospaced box, prominent Copy button, "Copied" confirmation,
 * and a clear hint that this won't be shown again.
 */
function FreshKeyDisplay({ url, onDismiss }: { url: string; onDismiss: () => void }) {
  const [copied, setCopied] = useState(false);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(url);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {
      // Fallback for non-HTTPS contexts where the clipboard API is gated.
      const ta = document.createElement('textarea');
      ta.value = url;
      ta.style.position = 'fixed';
      ta.style.opacity = '0';
      document.body.appendChild(ta);
      ta.select();
      try { document.execCommand('copy'); setCopied(true); } catch {}
      document.body.removeChild(ta);
      setTimeout(() => setCopied(false), 1500);
    }
  };

  return (
    <div className="rounded-xl border-2 border-emerald-300 bg-emerald-50/50 p-4">
      <div className="flex items-start justify-between gap-3">
        <div className="flex items-center gap-2 text-sm font-semibold text-emerald-900">
          <Check className="h-4 w-4" />
          Key ready
        </div>
        <button
          type="button"
          onClick={onDismiss}
          aria-label="Dismiss"
          className="h-6 w-6 grid place-items-center rounded-md text-emerald-700 hover:bg-emerald-100"
        >
          <X className="h-3.5 w-3.5" />
        </button>
      </div>
      <p className="text-xs text-emerald-800 mt-1">
        Copy the URL below and paste it on the source site under <strong>Migrate → Send</strong>.
        The plaintext token is shown only this once. Generate a new key if you lose it.
      </p>
      <div className="mt-3 flex items-stretch gap-2">
        <code
          className="flex-1 min-w-0 font-mono text-[11px] text-ink-800 bg-white border border-emerald-200 rounded px-2.5 py-2 break-all"
          title={url}
        >
          {url}
        </code>
        <button
          type="button"
          onClick={copy}
          aria-label="Copy key URL"
          className={cn(
            'shrink-0 inline-flex items-center gap-1.5 px-3 text-xs font-medium rounded border-2 transition-colors',
            copied
              ? 'border-emerald-400 bg-emerald-100 text-emerald-900'
              : 'border-emerald-300 bg-white text-emerald-800 hover:bg-emerald-50'
          )}
        >
          {copied
            ? <><Check className="h-3.5 w-3.5" /> Copied</>
            : <><Copy className="h-3.5 w-3.5" /> Copy URL</>}
        </button>
      </div>
    </div>
  );
}

/** One row in the active-keys list. */
function KeyRow({
  k, isFresh, onRevoke,
}: {
  k: MigrationKey;
  isFresh: boolean;
  onRevoke: () => void;
}) {
  const expired = k.state === 'expired' || (k.state === 'active' && k.expires_at * 1000 < Date.now());
  const tone: 'success' | 'warning' | 'danger' | 'neutral' =
    k.state === 'redeemed' ? 'success'
    : k.state === 'revoked' ? 'neutral'
    : expired ? 'danger'
    : 'warning';
  const label =
    k.state === 'redeemed' ? 'in use'
    : k.state === 'revoked' ? 'revoked'
    : expired ? 'expired'
    : 'active';

  // ISO-format the timestamps for formatDateTime (which expects an ISO string,
  // not a unix seconds value — we hold seconds on the wire to keep payloads
  // small but convert before display).
  const expiresIso  = new Date(k.expires_at * 1000).toISOString();
  const createdIso  = new Date(k.created_at  * 1000).toISOString();
  const redeemedIso = k.redeemed_at ? new Date(k.redeemed_at * 1000).toISOString() : null;

  return (
    <li className={cn('px-5 py-3.5 flex items-center gap-4', isFresh && 'bg-emerald-50/40')}>
      <span className="h-8 w-8 grid place-items-center rounded-lg bg-ink-100 text-ink-700 shrink-0">
        <KeyIcon className="h-4 w-4" />
      </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-700">{k.id}</span>
          <Badge tone={tone} dot>{label}</Badge>
          {/* Auto-restore consent chip — captured at generate time. Tells
              the admin at a glance whether the source can auto-restore on
              this key, or whether the migrated backup will land for
              manual Restore. */}
          {k.allow_auto_restore ? (
            <Badge tone="success" dot={false}>auto-restore</Badge>
          ) : (
            <Badge tone="neutral" dot={false}>import only</Badge>
          )}
          {k.source_url && (
            <span className="inline-flex items-center gap-1 text-[11px] text-ink-600">
              <Globe className="h-3 w-3" />
              from {k.source_url}
            </span>
          )}
        </div>
        <div className="flex items-center gap-3 text-[11px] text-ink-500 mt-1 flex-wrap">
          <span className="inline-flex items-center gap-1">
            <Clock className="h-3 w-3" />
            Created {formatDateTime(createdIso)}
          </span>
          <span>·</span>
          <span>Expires {formatDateTime(expiresIso)}</span>
          {redeemedIso && (
            <>
              <span>·</span>
              <span>Redeemed {formatDateTime(redeemedIso)}</span>
            </>
          )}
        </div>
      </div>
      {k.state !== 'revoked' && (
        <Button
          variant="ghost"
          size="sm"
          onClick={onRevoke}
          className="text-danger hover:text-danger"
        >
          Revoke
        </Button>
      )}
    </li>
  );
}

/**
 * Source-side panel: paste a key from a destination, validate it, review
 * the pre-flight summary, choose content, then (in Phase 3) start the
 * push. Validation is non-destructive — it does NOT redeem the key, so
 * the user can paste / validate / unpaste freely without burning the key.
 */
/**
 * Advisory pre-flight card on the Send page: names the active plugins on this
 * site (cache, security, redirect, page-builder) that commonly cause trouble
 * during/after a migration, so the user can handle them first. Renders nothing
 * when there's nothing to warn about.
 */
function SendPreflightWarnings() {
  const [pf, setPf] = useState<Awaited<ReturnType<typeof api.migrate.preflight>> | null>(null);
  useEffect(() => {
    let stop = false;
    api.migrate.preflight().then((r) => { if (!stop) setPf(r); }).catch(() => { /* advisory only */ });
    return () => { stop = true; };
  }, []);
  if (!pf) return null;

  const groups = [
    { label: 'Caching / optimization', items: pf.cache,    note: 'Clear or deactivate before pushing — stale caches and cache drop-ins (object-cache.php, advanced-cache.php) can break the moved site.' },
    { label: 'Security / firewall',    items: pf.security, note: 'A WAF or login lockdown can block the transfer or lock you out after the users table changes.' },
    { label: 'Redirects',              items: pf.redirect, note: 'Old→new domain rules can trap requests on the destination.' },
    { label: 'Page builders',          items: pf.builder,  note: 'Regenerate their CSS on the destination after the move (see the Elementor tip below if you use it).' },
  ].filter((g) => g.items.length > 0);
  if (groups.length === 0) return null;

  return (
    <Card className="border-amber-300">
      <CardBody>
        <div className="flex items-start gap-3">
          <span className="h-8 w-8 grid place-items-center rounded-lg bg-amber-50 text-amber-600 shrink-0">
            <AlertTriangle className="h-4 w-4" />
          </span>
          <div className="min-w-0 text-sm">
            <div className="font-semibold text-ink-900">Before you migrate — a few plugins may interfere</div>
            <p className="text-ink-500 text-xs mt-0.5">
              Detected active on this site. The migration still works, but handling these avoids the most common post-move problems.
            </p>
            <ul className="mt-2 space-y-2">
              {groups.map((g) => (
                <li key={g.label} className="text-xs">
                  <span className="font-medium text-ink-800">{g.label}: </span>
                  <span className="text-ink-700">{g.items.join(', ')}</span>
                  <div className="text-ink-500 mt-0.5">{g.note}</div>
                </li>
              ))}
            </ul>
            {pf.elementor && (
              <div className="mt-2.5 text-xs rounded-lg bg-amber-100/60 p-2 text-amber-900 leading-relaxed">
                <strong>Elementor tip:</strong> after the migration finishes, on the destination go to{' '}
                <strong>Elementor → Tools → Regenerate CSS &amp; Data</strong>. This prevents the
                “critical error” Elementor can throw when its cached CSS is rebuilt on the new URL.
              </div>
            )}
          </div>
        </div>
      </CardBody>
    </Card>
  );
}

function SendPanel() {
  const [keyUrl, setKeyUrl] = useState('');
  const [content, setContent] = useState<'full' | 'files' | 'database'>('full');
  // Auto-restore on by default. Source user can flip this off for a
  // "push only" run (backup lands on destination ready for manual restore).
  // The destination ALSO has a consent flag on its key; both have to be
  // true for the restore to actually run, but defaulting both ON gives a
  // one-click experience for the common case.
  const [autoRestore, setAutoRestore] = useState(true);
  const [validated, setValidated] = useState<{
    destination_url: string;
    expires_at: number;
    wp_max_upload_bytes: number;
    plugin_version: string;
    source_url: string;
  } | null>(null);
  // Once a migration is running, we keep its ext_id here. The progress
  // panel polls /migrate/source/status using it; everything else hides.
  const [runningExtId, setRunningExtId] = useState<string | null>(null);
  const [toast, setToast] = useState<{ msg: string; tone: 'success' | 'danger' } | null>(null);

  // Re-attach to an in-flight migration after a page reload. The push runs
  // server-side and survives a browser refresh, but `runningExtId` lives only
  // in React state — so on mount we ask the server whether a migration is
  // currently being pushed from this site and, if so, resume the live
  // progress panel. Runs once; if nothing is running the panel stays hidden.
  useEffect(() => {
    let cancelled = false;
    api.migrate.sourceCurrent()
      .then((res) => {
        if (!cancelled && res.active) setRunningExtId(res.active);
      })
      .catch(() => { /* no active migration / transient error — ignore */ });
    return () => { cancelled = true; };
  }, []);

  const validate = useMutation(
    (url: string) => api.migrate.validate(url),
    {
      onSuccess: (res) => setValidated(res),
      onError:   (e) => { setValidated(null); setToast({ msg: e.message, tone: 'danger' }); },
    },
  );

  const start = useMutation(
    (args: { url: string; content: 'full' | 'files' | 'database'; autoRestore: boolean }) =>
      api.migrate.start(args.url, args.content, args.autoRestore),
    {
      onSuccess: (res) => setRunningExtId(res.ext_id),
      onError:   (e)   => setToast({ msg: e.message, tone: 'danger' }),
    },
  );

  // Whenever the pasted URL changes, drop the prior validation result so
  // the user can't accidentally proceed against a stale validation.
  const handlePaste = (next: string) => {
    setKeyUrl(next);
    if (validated) setValidated(null);
  };

  // When a migration is in flight we hide the paste / preflight cards
  // entirely. There's no useful action there until it finishes — and
  // showing them would invite the user to start a second migration over
  // the top of the running one.
  if (runningExtId) {
    return (
      <div className="space-y-4">
        <MigrationProgress
          extId={runningExtId}
          onDone={() => {
            setRunningExtId(null);
            setKeyUrl('');
            setValidated(null);
            setToast({ msg: 'Migration complete.', tone: 'success' });
          }}
          onAbort={() => {
            setRunningExtId(null);
            setKeyUrl('');
            setValidated(null);
            setToast({ msg: 'Migration aborted.', tone: 'danger' });
          }}
          onRestart={() => {
            setRunningExtId(null);
            // Keep the pasted URL so the user can re-validate + retry
            // without re-pasting. They'll generally need a fresh key
            // anyway (handshake already burned the old one) — surfaced
            // by the validate step's "expired/used" error.
          }}
        />
        {toast && <Toast message={toast.msg} tone={toast.tone} onClose={() => setToast(null)} />}
      </div>
    );
  }

  return (
    <div className="space-y-4">
      {/* "How it works" mini-stepper — only when the user hasn't pasted
          anything yet. Three numbered steps orient first-timers about the
          shape of the flow without burying them in paragraph text. */}
      {!keyUrl && !validated && <SendStepsGuide />}

      {/* Advisory: interfering plugins detected on this (source) site. */}
      <SendPreflightWarnings />

      {/* Paste card */}
      <Card>
        <CardHeader>
          <div className="flex items-center gap-3">
            <span className="h-8 w-8 grid place-items-center rounded-lg bg-ink-100 text-ink-700">
              <Send className="h-4 w-4" />
            </span>
            <div>
              <CardTitle>Paste the destination's key</CardTitle>
              <CardDescription>
                Get a key from the destination site under <strong>Migrate → Receive</strong>, then paste it here.
              </CardDescription>
            </div>
          </div>
        </CardHeader>
        <CardBody className="space-y-4">
          <label className="block">
            <span className="text-xs font-medium text-ink-700 block mb-1.5">Key URL</span>
            <textarea
              value={keyUrl}
              onChange={(e) => handlePaste(e.target.value)}
              placeholder="https://destination.com?domain=...&token=...&expires=..."
              spellCheck={false}
              className="w-full min-h-[100px] rounded-lg border border-ink-200 bg-white px-3 py-2 text-xs font-mono break-all focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-brand-500"
            />
            <p className="text-[11px] text-ink-500 mt-1.5">
              The full URL including the <code className="font-mono">?domain</code>, <code className="font-mono">?token</code>, and <code className="font-mono">?expires</code> parameters.
            </p>
          </label>

          <div className="flex items-center gap-2 flex-wrap">
            <Button
              variant="primary"
              leftIcon={<Check className="h-4 w-4" />}
              onClick={() => validate.mutate(keyUrl.trim())}
              disabled={!keyUrl.trim() || validate.isLoading}
            >
              {validate.isLoading ? 'Checking…' : 'Validate key'}
            </Button>
            {keyUrl && (
              <Button
                variant="ghost"
                onClick={() => { setKeyUrl(''); setValidated(null); }}
              >
                Clear
              </Button>
            )}
          </div>
        </CardBody>
      </Card>

      {/* Pre-flight summary. only shown after a successful validation */}
      {validated && (
        <PreflightSummary
          info={validated}
          content={content}
          onContentChange={setContent}
          autoRestore={autoRestore}
          onAutoRestoreChange={setAutoRestore}
          starting={start.isLoading}
          onStart={() => start.mutate({ url: keyUrl.trim(), content, autoRestore })}
        />
      )}

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

/**
 * Three-step numbered guide shown above the Send tab's paste card on
 * first visit. Disappears as soon as the user pastes anything — once
 * they're past step 1, the rest of the UI shows them where to go.
 */
function SendStepsGuide() {
  const steps: Array<{ n: number; title: string; body: React.ReactNode }> = [
    {
      n: 1,
      title: 'On the destination',
      body: <>Open <strong>BackReload → Migrate → Receive</strong>. Click <strong>Generate key</strong> and copy the URL.</>,
    },
    {
      n: 2,
      title: 'Here, on the old site',
      body: <>Paste the URL into the box below and click <strong>Validate key</strong>.</>,
    },
    {
      n: 3,
      title: 'Push',
      body: <>Review the summary, pick what to transfer, then <strong>Start migration</strong>. The destination restores automatically.</>,
    },
  ];
  return (
    <Card>
      <CardHeader>
        <div className="flex items-center gap-3">
          <span className="h-8 w-8 grid place-items-center rounded-lg bg-brand-50 text-brand-700">
            <HelpCircle className="h-4 w-4" />
          </span>
          <div>
            <CardTitle>How site-to-site migration works</CardTitle>
            <CardDescription>
              Three steps. Takes a couple of minutes for a small site.
            </CardDescription>
          </div>
        </div>
      </CardHeader>
      <CardBody>
        <ol className="grid grid-cols-1 sm:grid-cols-3 gap-3">
          {steps.map((s) => (
            <li key={s.n} className="rounded-lg border border-ink-200 bg-white p-3.5">
              <div className="flex items-center gap-2 mb-1.5">
                <span className="h-6 w-6 grid place-items-center rounded-full bg-brand-100 text-brand-800 text-[11px] font-semibold tabular-nums">
                  {s.n}
                </span>
                <span className="text-sm font-medium text-ink-900">{s.title}</span>
              </div>
              <p className="text-xs text-ink-600 leading-relaxed">{s.body}</p>
            </li>
          ))}
        </ol>
      </CardBody>
    </Card>
  );
}

/**
 * Pre-flight: shows the result of /migrate/source/validate as a clear
 * "here's what's about to happen" summary, plus a content picker. The
 * Start button kicks off the actual migration via /migrate/source/start.
 */
function PreflightSummary({
  info, content, onContentChange, autoRestore, onAutoRestoreChange, starting, onStart,
}: {
  info: {
    destination_url: string;
    expires_at: number;
    wp_max_upload_bytes: number;
    plugin_version: string;
    source_url: string;
  };
  content: 'full' | 'files' | 'database';
  onContentChange: (c: 'full' | 'files' | 'database') => void;
  autoRestore: boolean;
  onAutoRestoreChange: (v: boolean) => void;
  starting: boolean;
  onStart: () => void;
}) {
  const expiresIso = new Date(info.expires_at * 1000).toISOString();
  const expiresIn = Math.max(0, info.expires_at - Math.floor(Date.now() / 1000));
  const expiresInLabel =
    expiresIn > 3600 ? `${Math.floor(expiresIn / 3600)}h ${Math.floor((expiresIn % 3600) / 60)}m`
    : expiresIn > 60  ? `${Math.floor(expiresIn / 60)}m`
    : `${expiresIn}s`;

  return (
    <>
      <Card>
        <CardHeader>
          <div>
            <CardTitle>Ready to push</CardTitle>
            <CardDescription>
              The destination accepted the key. Review the details below before starting.
            </CardDescription>
          </div>
        </CardHeader>
        <CardBody className="space-y-3">
          <div className="rounded-xl border border-emerald-300 bg-emerald-50/40 p-4 space-y-3">
            <div className="flex items-center gap-2 text-sm font-semibold text-emerald-900">
              <Check className="h-4 w-4" />
              Destination reachable + key valid
            </div>
            <dl className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
              <SummaryItem
                icon={<Globe className="h-3.5 w-3.5" />}
                label="Destination"
                value={info.destination_url}
                mono
              />
              <SummaryItem
                icon={<Globe className="h-3.5 w-3.5" />}
                label="Source (this site)"
                value={info.source_url}
                mono
              />
              <SummaryItem
                icon={<Clock className="h-3.5 w-3.5" />}
                label="Key expires"
                value={`${formatDateTime(expiresIso)} (in ${expiresInLabel})`}
              />
              <SummaryItem
                icon={<Send className="h-3.5 w-3.5" />}
                label="Destination plugin"
                value={`BackReload v${info.plugin_version || 'unknown'}`}
              />
            </dl>
          </div>

          <div>
            <p className="text-[11px] uppercase tracking-wider font-medium text-ink-500 mb-2">
              Choose what to transfer
            </p>
            <div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
              <ContentTile value="full"     current={content} onPick={onContentChange}
                label="Everything" sub="Database + uploads + plugins + themes" />
              <ContentTile value="files"    current={content} onPick={onContentChange}
                label="Files only" sub="Uploads, plugins, themes (no database)" />
              <ContentTile value="database" current={content} onPick={onContentChange}
                label="Database only" sub="All tables, no files" />
            </div>
          </div>

          {/* Auto-restore toggle. Defaults ON for the one-click migration
              experience users expect. Flip it off for a "push only" run:
              the backup lands on the destination ready for manual review
              before applying. The destination's key has its own consent
              flag too. both have to agree, but with both defaulting ON
              the common case is one click. */}
          <div
            role="button"
            tabIndex={0}
            onClick={() => onAutoRestoreChange(!autoRestore)}
            onKeyDown={(e) => {
              if (e.key === ' ' || e.key === 'Enter') {
                e.preventDefault();
                onAutoRestoreChange(!autoRestore);
              }
            }}
            aria-pressed={autoRestore}
            className={cn(
              'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors outline-none',
              autoRestore
                ? 'border-brand-300 bg-brand-50/40'
                : 'border-ink-200 bg-white hover:bg-ink-50',
            )}
          >
            <span
              className={cn(
                'mt-0.5 relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
                autoRestore ? 'bg-brand-500' : 'bg-ink-300',
              )}
              aria-hidden
            >
              <span
                className={cn(
                  'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
                  autoRestore ? 'translate-x-4' : 'translate-x-0.5',
                )}
              />
            </span>
            <div className="flex-1 min-w-0">
              <div className="text-sm font-medium text-ink-900">
                Auto-restore on destination
              </div>
              <div className="text-[11px] text-ink-500 mt-0.5">
                {autoRestore
                  ? 'When the push completes, the destination restores automatically. One-click migration.'
                  : "Push only. The backup lands on the destination but isn't applied. You'll open Restore on the destination to run it."}
              </div>
            </div>
          </div>

          {/* Destructive-action warning. Copy tracks the toggle state. */}
          <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 flex items-start gap-2.5">
            <AlertTriangle className="h-4 w-4 text-amber-600 shrink-0 mt-0.5" />
            <div className="text-xs text-amber-900">
              <p className="font-medium">
                {autoRestore
                  ? 'This will replace all matching data on the destination.'
                  : 'The push will land on the destination without applying.'}
              </p>
              <p className="mt-1 text-amber-800">
                {autoRestore
                  ? "If the destination already has content, the restore overwrites it. The key is single-use, so you'll have to generate a new one to push again."
                  : "You'll need to open Restore on the destination site to apply the imported backup. The key is single-use, so you'll have to generate a new one to push again."}
              </p>
            </div>
          </div>

          {/* Login-credential heads-up. The single biggest surprise after
              a successful migration is "I can't log into the destination
              anymore" because the restored DB replaced the destination's
              wp_users table with this site's. Only relevant when auto-restore
              is on AND the database is part of the transfer. */}
          {autoRestore && content !== 'files' && (
            <div className="rounded-lg border border-sky-200 bg-sky-50 p-3 flex items-start gap-2.5">
              <KeyIcon className="h-4 w-4 text-sky-700 shrink-0 mt-0.5" />
              <div className="text-xs text-sky-900">
                <p className="font-medium">After the restore, log into the destination with THIS site's credentials.</p>
                <p className="mt-1 text-sky-800">
                  The database transfer replaces the destination's user accounts with this site's, so the destination's
                  current admin login stops working. Use your username + password from {info.source_url} on the destination's
                  login page after the migration finishes.
                </p>
              </div>
            </div>
          )}
        </CardBody>
      </Card>

      {/* Start button. kicks off handshake + push engine. */}
      <Card>
        <CardBody className="flex items-center justify-between gap-3 flex-wrap">
          <div className="text-xs text-ink-500">
            Ready when you are. Click <strong>Start migration</strong> to build a backup and push it.
          </div>
          <Button
            variant="primary"
            leftIcon={
              starting
                ? <Loader2 className="h-4 w-4 animate-spin" />
                : <Send className="h-4 w-4" />
            }
            onClick={onStart}
            disabled={starting}
          >
            {starting ? 'Starting…' : 'Start migration'}
          </Button>
        </CardBody>
      </Card>
    </>
  );
}

/**
 * Live progress panel shown once a migration is running. Polls
 * /migrate/source/status every 2 seconds and renders one of:
 *   - building     → "Preparing backup on this site"
 *   - pushing      → progress bar + current file
 *   - finalizing   → "Importing on destination"
 *   - complete     → green check + done button
 *   - failed       → red X + error + retry button
 *   - aborted      → grey X + close button
 *
 * Polling stops automatically on terminal phases.
 */
function MigrationProgress({
  extId, onDone, onAbort, onRestart,
}: {
  extId: string;
  onDone: () => void;
  onAbort: () => void;
  onRestart: () => void;
}) {
  const [status, setStatus] = useState<MigrationStatus | null>(null);
  const [logs, setLogs] = useState<LogEntry[]>([]);
  const [pollError, setPollError] = useState<string | null>(null);
  const [confirmingAbort, setConfirmingAbort] = useState(false);
  // Track the last non-terminal phase the UI observed. Used as a
  // fallback in PhaseStepper to highlight the broken step when the
  // server didn't populate failed_at_phase (e.g. a state row created
  // by a pre-fix engine version, or an `aborted` phase where the user
  // clicked Abort during a specific step — the engine doesn't snapshot
  // failed_at_phase for user-initiated aborts).
  const lastNonTerminalPhaseRef = useRef<MigrationStatus['phase'] | undefined>(undefined);

  // Stall detection. We fingerprint the migration's progress (phase + bytes +
  // files + current chunk + log-line count); whenever it changes we stamp the
  // time. If nothing changes for a while the task may be genuinely stuck (host
  // PHP timeout, a WAF dropping loopbacks, a hung DB dump) rather than just
  // slow — so we surface a "not responding" hint with elapsed time instead of
  // a bar that silently sits still. `nowTs` ticks every second to keep the
  // elapsed readout live even when no poll has landed.
  const progressFpRef = useRef<string>('');
  const progressAtRef = useRef<number>(Date.now());
  const [nowTs, setNowTs] = useState<number>(Date.now());
  useEffect(() => {
    const id = window.setInterval(() => setNowTs(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  // Auto-scroll handling for the log feed. The naive "always scroll to
  // bottom on every update" pattern is hostile — the moment the user
  // scrolls up to read an earlier line, the next poll yanks them back
  // down. The pattern below records whether the user WAS at the bottom
  // BEFORE the update; only re-stick if so.
  const logBoxRef    = useRef<HTMLDivElement | null>(null);
  const wasAtBottom  = useRef(true);
  const STICK_THRESHOLD_PX = 32; // forgiving — within 2 line-heights counts as "at the bottom"

  // Poll every 2s while the migration is active. Stop on terminal phases.
  //
  // History: the previous version read `status.phase` from the useEffect
  // closure, which was permanently stuck at the value at the time the
  // interval was installed (null on first run). A small migration that
  // completed on the FIRST status fetch would never stop polling. Fix:
  // check the FRESH response phase inside tick() itself and clear the
  // interval there.
  //
  // We also fetch the per-backup log feed in parallel so the user can
  // watch what's actually happening (which file is being archived, when
  // a chunk fails, etc.) — same UX as the regular Backup-progress widget.
  useEffect(() => {
    let cancelled = false;
    let intervalId: number | undefined;
    const stop = () => {
      if (intervalId !== undefined) {
        clearInterval(intervalId);
        intervalId = undefined;
      }
    };
    const tick = async () => {
      try {
        const [s, l] = await Promise.all([
          api.migrate.sourceStatus(extId),
          api.logs.list({ backup_id: extId, limit: 30 }).catch(() => ({ items: [] as LogEntry[] })),
        ]);
        if (cancelled) return;
        // Capture scroll state BEFORE the state update triggers a re-render.
        // We need this synchronous snapshot so the post-render effect can
        // decide whether to re-stick to the bottom.
        const box = logBoxRef.current;
        if ( box ) {
          wasAtBottom.current =
            ( box.scrollHeight - box.scrollTop - box.clientHeight ) <= STICK_THRESHOLD_PX;
        }
        setStatus(s);
        setLogs(l.items);
        setPollError(null);
        // Stall fingerprint — bump the "last progressed at" stamp whenever any
        // observable progress signal moves.
        const fp = `${s.phase}|${s.bytes_pushed}|${s.files_done ?? 0}|${s.current_file?.chunk_idx ?? -1}|${l.items.length}`;
        if (fp !== progressFpRef.current) {
          progressFpRef.current = fp;
          progressAtRef.current = Date.now();
        }
        // Remember the most recent NON-terminal phase. Used as a fallback
        // in PhaseStepper to highlight the broken step when the server
        // didn't include failed_at_phase (legacy state, or a user-
        // initiated abort where the engine doesn't snapshot).
        if (!['complete', 'failed', 'aborted'].includes(s.phase)) {
          lastNonTerminalPhaseRef.current = s.phase;
        }
        if (['complete', 'failed', 'aborted'].includes(s.phase)) {
          stop();
        }
      } catch (e) {
        if (cancelled) return;
        // Don't flip the panel into an error state on a transient network
        // hiccup — just surface a small banner so the user can still see
        // the most recent known progress.
        setPollError((e as Error).message);
      }
    };
    tick();
    intervalId = window.setInterval(tick, 2000);
    return () => { cancelled = true; stop(); };
  }, [extId]);

  // After every render where `logs` changed, re-stick to the bottom of
  // the log feed if the user was at the bottom before the update. If
  // they've manually scrolled up to read older lines, we leave them in
  // place — auto-scrolling them down would yank the reading position
  // mid-sentence every two seconds.
  useEffect(() => {
    const box = logBoxRef.current;
    if ( ! box ) return;
    if ( wasAtBottom.current ) {
      box.scrollTop = box.scrollHeight;
    }
  }, [logs]);

  const abort = useMutation(
    (id: string) => api.migrate.sourceAbort(id),
    { onSuccess: () => onAbort() },
  );

  if (!status) {
    return (
      <Card>
        <CardBody className="py-10">
          <Loading label="Starting migration…" />
        </CardBody>
      </Card>
    );
  }

  const terminal = ['complete', 'failed', 'aborted'].includes(status.phase);

  // Stall: no observable progress for a while on a still-running migration.
  const STALL_AFTER_MS = 90_000; // 1.5 min of no movement → likely stuck
  const sinceProgressMs = Math.max(0, nowTs - progressAtRef.current);
  const stalled = !terminal && sinceProgressMs > STALL_AFTER_MS;
  const stalledLabel = `${Math.floor(sinceProgressMs / 60000)}m ${Math.floor((sinceProgressMs % 60000) / 1000)}s`;

  const filesDone = status.files_done ?? 0;
  const filesTotal = Math.max(filesDone, status.files_total || 0);

  // Fold partial progress on the current file into the overall percentage.
  // Without this the bar reads 0% until the first whole file finishes —
  // which on a 4-part backup means the bar stays empty for ~25% of the
  // total push and feels broken even though work is happening.
  let progressFiles = filesDone;
  if (
    status.phase === 'pushing' &&
    status.current_file &&
    status.current_file.chunk_total > 0 &&
    filesDone < filesTotal
  ) {
    progressFiles += status.current_file.chunk_idx / status.current_file.chunk_total;
  }
  const overallPct = filesTotal > 0
    ? Math.min(100, Math.max(0, Math.round((progressFiles / filesTotal) * 100)))
    : 0;

  // Friendlier "Part 1 of 4" rather than "0 / 4 parts" while in flight.
  const partLabel = filesTotal > 0
    ? (filesDone >= filesTotal
        ? `${filesTotal} of ${filesTotal} parts`
        : `Part ${filesDone + 1} of ${filesTotal}`)
    : '';

  return (
    <Card>
      <CardHeader>
        <div className="flex items-center gap-3">
          <PhaseIcon phase={status.phase} />
          <div className="min-w-0 flex-1">
            <CardTitle>{phaseTitle(status.phase)}</CardTitle>
            <CardDescription>
              {status.destination_url
                ? <>Pushing to <span className="font-mono">{status.destination_url}</span></>
                : 'Site-to-site migration'}
            </CardDescription>
          </div>
          {/* Elapsed-time chip. Lives in the header so it's visible no
              matter how far the user scrolls in the progress panel. */}
          {status.started_at && !terminal && (
            <ElapsedClock startedAt={status.started_at} />
          )}
        </div>
      </CardHeader>
      <CardBody className="space-y-4">
        {/* Phase stepper. Shows ALL four phases laid out as a horizontal
            breadcrumb so the user can see "I'm at step 2 of 4" rather than
            wondering whether more invisible work is coming. */}
        <PhaseStepper
          phase={status.phase}
          failedAtPhase={status.failed_at_phase ?? null}
          lastNonTerminalPhase={lastNonTerminalPhaseRef.current}
        />

        {/* Overall progress */}
        {!terminal && (
          <div>
            <div className="flex items-center justify-between text-[11px] text-ink-500 mb-1.5">
              <span>{phaseHint(status.phase)}</span>
              {filesTotal > 0 && (
                <span className="font-mono">
                  {partLabel}{status.phase === 'pushing' ? ` · ${overallPct}%` : ''}
                </span>
              )}
            </div>
            <div className="h-2 rounded-full bg-ink-100 overflow-hidden">
              {status.phase === 'pushing' ? (
                <div
                  className="h-full bg-brand-500 transition-all duration-300"
                  style={{ width: `${Math.max(2, overallPct)}%` }}
                />
              ) : (
                // Building + finalizing have no per-file granularity — show
                // a pulsing fill instead of a misleading 0% (or a stuck
                // partial fill that would look frozen).
                <div className="h-full w-full bg-ink-300 animate-pulse" />
              )}
            </div>
          </div>
        )}

        {/* Per-file detail (pushing phase only) */}
        {status.phase === 'pushing' && status.current_file && (
          <div className="rounded-lg border border-ink-200 bg-ink-50/40 p-3 text-xs">
            <div className="flex items-center justify-between gap-2 mb-1">
              <span className="text-ink-500">Current file</span>
              <span className="font-mono text-ink-500">
                chunk {status.current_file.chunk_idx + 1} / {Math.max(1, status.current_file.chunk_total)}
              </span>
            </div>
            <div className="font-mono text-ink-900 break-all">{status.current_file.name}</div>
          </div>
        )}

        {/* Bytes + throughput counter. Throughput fights the "is it stuck?"
            anxiety on long pushes — even a slow number is more reassuring
            than no number. */}
        {status.bytes_pushed > 0 && (
          <div className="text-[11px] text-ink-500 flex items-center gap-3 flex-wrap">
            <span>{formatBytes(status.bytes_pushed)} pushed</span>
            {status.started_at && status.phase === 'pushing' && (
              <ThroughputBadge bytesPushed={status.bytes_pushed} startedAt={status.started_at} />
            )}
          </div>
        )}

        {/* Live log feed — same dark terminal box used by the regular
            Backup-progress widget. The backup engine + migration push
            engine both write to this log keyed by ext_id, so the user
            sees build messages first, then push messages, then finalize.
            On failure, the last few lines usually contain the cause. */}
        <div
          ref={logBoxRef}
          aria-live="polite"
          aria-label="Migration activity log"
          role="log"
          className={cn(
            'bg-ink-900 text-ink-100 rounded-lg p-2.5 font-mono text-[11px] leading-5 max-h-48 overflow-auto',
            logs.length === 0 && 'opacity-50',
          )}
        >
          {logs.length === 0 && <div className="text-ink-500">Waiting for first log line…</div>}
          {logs.slice().reverse().map((l) => (
            <div key={l.id} className="flex gap-2">
              <span className={cn('uppercase text-[9px] tracking-wider w-12 shrink-0', migrationLogLevelClass[l.level])}>
                {l.level}
              </span>
              <span className="break-all">{l.message}</span>
            </div>
          ))}
        </div>

        {/* Terminal — success */}
        {status.phase === 'complete' && (
          <div className="space-y-3">
            <div className="rounded-xl border border-emerald-300 bg-emerald-50/40 p-4 space-y-2">
              <div className="flex items-center gap-2 text-sm font-semibold text-emerald-900">
                <CheckCircle2 className="h-4 w-4" />
                {status.auto_restore_dispatched
                  ? 'Push complete · destination is restoring'
                  : 'Push complete'}
              </div>
              <p className="text-xs text-emerald-900/90">
                {status.auto_restore_dispatched
                  ? <>The destination is applying the backup now. Open the destination's <strong>Restore</strong> page to watch live progress, or visit the destination URL once it finishes.</>
                  : <>The destination accepted the backup. Open the destination's <strong>Restore</strong> page to apply it.</>}
              </p>
              {status.destination_url && (
                <p className="text-[11px]">
                  <a
                    href={`${status.destination_url.replace(/\/$/, '')}/wp-admin/admin.php?page=backreload#/restore`}
                    target="_blank"
                    rel="noreferrer"
                    className="text-emerald-900 underline hover:no-underline font-medium"
                  >
                    Open destination Restore page →
                  </a>
                </p>
              )}
              {status.finalized_backup_id && (
                <p className="text-[11px] text-emerald-900/70 font-mono">
                  Destination backup ID: {status.finalized_backup_id}
                </p>
              )}
            </div>

            {/* Login-credential reminder. Surfaced AGAIN after the push
                completes because users who missed the pre-flight warning
                hit "Sorry, you are not allowed to access this page" the
                moment they open the destination's wp-admin. We don't
                know server-side whether DB was part of the transfer, so
                we only show this if the destination is restoring. */}
            {status.auto_restore_dispatched && (
              <div className="rounded-xl border border-sky-200 bg-sky-50/60 p-4 space-y-2">
                <div className="flex items-center gap-2 text-sm font-semibold text-sky-900">
                  <KeyIcon className="h-4 w-4" />
                  Log in with THIS site's credentials
                </div>
                <p className="text-xs text-sky-900/90 leading-relaxed">
                  Once the restore finishes, the destination's old admin login stops working  
                  the database transfer replaced its user accounts with this site's.
                </p>
                <p className="text-xs text-sky-900/90">
                  Use your <strong>username + password from this site</strong> on the destination's login page.
                </p>
                {status.destination_url && (
                  <p className="text-[11px]">
                    <a
                      href={`${status.destination_url.replace(/\/$/, '')}/wp-login.php`}
                      target="_blank"
                      rel="noreferrer"
                      className="text-sky-900 underline hover:no-underline font-medium"
                    >
                      Open destination login →
                    </a>
                  </p>
                )}
              </div>
            )}
          </div>
        )}

        {/* Terminal — failure */}
        {status.phase === 'failed' && (
          <div role="alert" aria-live="assertive" className="rounded-xl border border-rose-300 bg-rose-50/40 p-4 space-y-2">
            <div className="flex items-center gap-2 text-sm font-semibold text-rose-900">
              <XCircle className="h-4 w-4" />
              Migration failed
            </div>
            {status.error && (
              <p className="text-xs text-rose-900/90 break-words">{status.error}</p>
            )}
          </div>
        )}

        {/* Terminal — aborted */}
        {status.phase === 'aborted' && (
          <div role="status" aria-live="polite" className="rounded-xl border border-ink-200 bg-ink-50 p-4 space-y-2">
            <div className="flex items-center gap-2 text-sm font-semibold text-ink-700">
              <X className="h-4 w-4" />
              Migration aborted
            </div>
          </div>
        )}

        {/* Stall banner: progress hasn't moved for a while. The migration may
            still recover (a slow DB dump, a retrying chunk), so we inform
            rather than fail — and point at the usual culprits. */}
        {stalled && (
          <div className="rounded-lg border border-amber-300 bg-amber-50 p-2.5 text-[11px] text-amber-900 flex items-start gap-2">
            <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-px" />
            <span>
              <strong>No progress for {stalledLabel}</strong> during the <strong>{status.phase}</strong> step —
              the task may be stuck. Common causes: the host's PHP time limit on a large database, a firewall/WAF
              blocking background requests, or a hung dump. It can still recover on the next tick; if it stays
              frozen, <strong>Abort</strong> and retry (a smaller "Only database" or "Files only" run often gets
              past a host timeout).
            </span>
          </div>
        )}

        {/* Transient poll error banner. Doesn't replace the panel. */}
        {pollError && !terminal && (
          <div className="rounded-lg border border-amber-200 bg-amber-50 p-2.5 text-[11px] text-amber-900 flex items-start gap-2">
            <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-px" />
            Couldn't refresh status: {pollError}. Will retry.
          </div>
        )}

        {/* Action row */}
        <div className="flex items-center justify-end gap-2 pt-1">
          {!terminal && (
            <>
              {confirmingAbort ? (
                <>
                  <span className="text-xs text-ink-600">Abort this migration?</span>
                  <Button variant="ghost" onClick={() => setConfirmingAbort(false)}>Keep going</Button>
                  <Button
                    variant="danger"
                    leftIcon={<X className="h-4 w-4" />}
                    onClick={() => abort.mutate(extId)}
                    disabled={abort.isLoading}
                  >
                    {abort.isLoading ? 'Aborting…' : 'Confirm abort'}
                  </Button>
                </>
              ) : (
                <Button variant="ghost" leftIcon={<X className="h-4 w-4" />} onClick={() => setConfirmingAbort(true)}>
                  Abort
                </Button>
              )}
            </>
          )}
          {status.phase === 'complete' && (
            <Button variant="primary" leftIcon={<Check className="h-4 w-4" />} onClick={onDone}>
              Done
            </Button>
          )}
          {status.phase === 'failed' && (
            <Button variant="primary" leftIcon={<RotateCcw className="h-4 w-4" />} onClick={onRestart}>
              Start over
            </Button>
          )}
          {status.phase === 'aborted' && (
            <Button variant="ghost" onClick={onRestart}>Close</Button>
          )}
        </div>
      </CardBody>
    </Card>
  );
}

/**
 * Horizontal breadcrumb of all four active phases. The current phase glows
 * brand-coloured; passed phases get a check mark; future phases are muted.
 * Failed/aborted overlays the current step with the matching colour so the
 * user immediately sees WHERE it broke (during build? push? finalize?).
 */
function PhaseStepper({
  phase,
  failedAtPhase,
  lastNonTerminalPhase,
}: {
  phase: MigrationStatus['phase'];
  /** Server-provided phase that was active when the failure occurred.
   *  Authoritative — drives which step gets the red bar. */
  failedAtPhase?: MigrationStatus['failed_at_phase'];
  /** Last non-terminal phase the UI observed (used as fallback when
   *  failed_at_phase isn't present — e.g. an aborted migration whose
   *  state was written by a pre-fix engine version). */
  lastNonTerminalPhase?: MigrationStatus['phase'];
}) {
  const steps: Array<{ key: MigrationStatus['phase']; label: string }> = [
    { key: 'building',   label: 'Build' },
    { key: 'pushing',    label: 'Push' },
    { key: 'finalizing', label: 'Finalize' },
    { key: 'complete',   label: 'Done' },
  ];
  const orderIdx: Record<string, number> = {
    building: 0, pushing: 1, finalizing: 2, complete: 3, failed: -1, aborted: -1,
  };
  const currentIdx = orderIdx[phase] ?? 1;
  const terminalFailed  = phase === 'failed';
  const terminalAborted = phase === 'aborted';

  return (
    <ol className="flex items-center gap-1.5 sm:gap-2">
      {steps.map((s, i) => {
        // "Last step" gets the brand fill on completion.
        const isCompleteFinal = phase === 'complete' && i === steps.length - 1;
        // isCurrent = "actively in flight at this step." Exclude the
        // terminal-complete final step so we don't render both a check
        // and a spinner on it at the same time (the previous version
        // showed "✓ DONE ⟳" once everything finished).
        const isCurrent = !terminalFailed && !terminalAborted && currentIdx === i && !isCompleteFinal;
        const isDone    = currentIdx > i;
        // Highlight the step that was active when the failure happened.
        // Uses the server-provided `failed_at_phase` snapshot (preserved
        // before the engine overwrites phase=failed). Falls back to the
        // last "real" phase if the server somehow didn't populate it.
        const failedPhaseIdx = (() => {
          const fp = failedAtPhase ?? lastNonTerminalPhase;
          const idx = steps.findIndex((st) => st.key === fp);
          return idx >= 0 ? idx : 1; // 1 = 'pushing' fallback for legacy state without the field.
        })();
        const isFailedHere    = (terminalFailed || terminalAborted) && i === failedPhaseIdx;

        return (
          <li key={s.key} className="flex-1 min-w-0 flex items-center gap-1.5 sm:gap-2">
            <div className="flex-1 min-w-0">
              <div className={cn(
                'h-1.5 rounded-full transition-colors',
                isFailedHere && terminalFailed  ? 'bg-rose-400' :
                isFailedHere && terminalAborted ? 'bg-ink-400'  :
                isDone || isCompleteFinal       ? 'bg-emerald-400' :
                isCurrent                       ? 'bg-brand-500 animate-pulse' :
                                                  'bg-ink-200',
              )} />
              <div className={cn(
                'mt-1.5 flex items-center gap-1 text-[10px] uppercase tracking-wider font-medium',
                isFailedHere && terminalFailed  ? 'text-rose-700' :
                isFailedHere && terminalAborted ? 'text-ink-500'  :
                isDone || isCompleteFinal       ? 'text-emerald-700' :
                isCurrent                       ? 'text-brand-700'   :
                                                  'text-ink-400',
              )}>
                {(isDone || isCompleteFinal) && <Check className="h-3 w-3" />}
                {isCurrent && <Loader2 className="h-3 w-3 animate-spin" />}
                <span className="truncate">{s.label}</span>
              </div>
            </div>
            {i < steps.length - 1 && (
              <ArrowRight className="h-3 w-3 text-ink-300 shrink-0 -mt-3.5" aria-hidden />
            )}
          </li>
        );
      })}
    </ol>
  );
}

/**
 * Live-ticking elapsed-time chip. Updates once per second so the user sees
 * proof of life even when the rest of the progress hasn't changed (large
 * file mid-chunk, slow network, etc.).
 */
function ElapsedClock({ startedAt }: { startedAt: number }) {
  const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
  useEffect(() => {
    const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000);
    return () => clearInterval(id);
  }, []);
  const secs = Math.max(0, now - startedAt);
  return (
    <span
      className="inline-flex items-center gap-1 text-[11px] font-mono text-ink-600 bg-ink-100 rounded-md px-2 py-1 shrink-0 tabular-nums"
      title="Elapsed time"
    >
      <Clock className="h-3 w-3" />
      {formatDuration(secs)}
    </span>
  );
}

/**
 * Throughput badge — bytes/sec averaged across the migration's elapsed time.
 *
 * Why its own ticking state: the previous implementation computed throughput
 * inline in JSX via `Date.now()`, which meant the denominator (elapsed
 * seconds) only refreshed when the parent re-rendered from a status poll.
 * On a 5s poll interval, the "average" was effectively a step function
 * that only moved every 5 seconds — and a slow push would visibly DROP
 * (or worse, freeze entirely) the moment the user expected smooth motion.
 * Now both ElapsedClock and ThroughputBadge tick at 1Hz so the user sees
 * a continuously-updating bps figure even between polls.
 *
 * `bytesPushed` itself still only updates on poll — that's the server-
 * authoritative number — but the elapsed-time denominator is local, so
 * the displayed bps approaches the true average more smoothly.
 */
function ThroughputBadge({ bytesPushed, startedAt }: { bytesPushed: number; startedAt: number }) {
  const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
  useEffect(() => {
    const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000);
    return () => clearInterval(id);
  }, []);
  const secs = Math.max(1, now - startedAt);
  const bps  = bytesPushed / secs;
  return (
    <>
      <span aria-hidden>·</span>
      <span>{formatBytes(bps)}/s avg</span>
    </>
  );
}

function PhaseIcon({ phase }: { phase: MigrationStatus['phase'] }) {
  if (phase === 'complete') {
    return (
      <span className="h-8 w-8 grid place-items-center rounded-lg bg-emerald-100 text-emerald-700">
        <CheckCircle2 className="h-4 w-4" />
      </span>
    );
  }
  if (phase === 'failed') {
    return (
      <span className="h-8 w-8 grid place-items-center rounded-lg bg-rose-100 text-rose-700">
        <XCircle className="h-4 w-4" />
      </span>
    );
  }
  if (phase === 'aborted') {
    return (
      <span className="h-8 w-8 grid place-items-center rounded-lg bg-ink-100 text-ink-600">
        <X className="h-4 w-4" />
      </span>
    );
  }
  return (
    <span className="h-8 w-8 grid place-items-center rounded-lg bg-brand-100 text-brand-700">
      <Loader2 className="h-4 w-4 animate-spin" />
    </span>
  );
}

function phaseTitle(phase: MigrationStatus['phase']): string {
  switch (phase) {
    case 'building':   return 'Preparing backup';
    case 'pushing':    return 'Pushing to destination';
    case 'finalizing': return 'Finalizing on destination';
    case 'complete':   return 'Migration complete';
    case 'failed':     return 'Migration failed';
    case 'aborted':    return 'Migration aborted';
  }
}

function phaseHint(phase: MigrationStatus['phase']): string {
  switch (phase) {
    case 'building':   return 'Building backup archives on this site';
    case 'pushing':    return 'Streaming archives to destination';
    case 'finalizing': return 'Asking destination to assemble + import';
    default:           return '';
  }
}

// Tailwind classes for each log-level badge inside the terminal box.
// Kept local to Migrate.tsx so the BackupProgress widget's map stays
// authoritative for its own pane — if a level ever diverges (e.g. a new
// "debug" tier), the two surfaces can evolve independently.
const migrationLogLevelClass: Record<string, string> = {
  info:    'text-ink-400',
  success: 'text-emerald-400',
  warn:    'text-amber-400',
  error:   'text-red-400',
};

function SummaryItem({
  icon, label, value, mono,
}: { icon: React.ReactNode; label: string; value: string; mono?: boolean }) {
  return (
    <div className="flex items-start gap-2.5">
      <span className="h-6 w-6 grid place-items-center rounded-md bg-white text-ink-600 shrink-0 mt-0.5 ring-1 ring-inset ring-emerald-200">
        {icon}
      </span>
      <div className="min-w-0 flex-1">
        <dt className="text-[10px] uppercase tracking-wider text-ink-500 font-medium">{label}</dt>
        <dd className={cn('mt-0.5 text-ink-900 break-words', mono && 'font-mono text-[11px]')} title={value}>
          {value}
        </dd>
      </div>
    </div>
  );
}

function ContentTile({
  value, current, onPick, label, sub,
}: {
  value: 'full' | 'files' | 'database';
  current: 'full' | 'files' | 'database';
  onPick: (v: 'full' | 'files' | 'database') => void;
  label: string;
  sub: string;
}) {
  const picked = value === current;
  return (
    <button
      type="button"
      onClick={() => onPick(value)}
      className={cn(
        'flex flex-col gap-1 p-3 rounded-lg border-2 text-left transition-colors',
        picked
          ? 'border-brand-500 bg-brand-50/60'
          : 'border-ink-200 bg-white hover:bg-ink-50',
      )}
    >
      <span className={cn('text-sm font-medium', picked ? 'text-brand-900' : 'text-ink-900')}>
        {label}
      </span>
      <span className="text-[11px] text-ink-500">{sub}</span>
    </button>
  );
}
