import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock,
  Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';

import {
  AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
  GetOfflineStatus, GetPendingQSOs, RetryOfflineSync,
  OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
  SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
  ContestDupe,
  GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
  UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
  LookupCallsign, GetStationSettings, GetListsSettings,
  GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
  WorkedBefore,
  SetCompactMode,
  GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
  GetSecretStatus, UnlockSecrets,
  RefreshCtyDat, DownloadAllReferenceLists,
  RotatorGoTo, RotatorStop, GetRotatorHeading,
  GetDBConnectionInfo, GetLogbookRevision,
  GetUltrabeamStatus, SetUltrabeamDirection,
  GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
  OpenExternalURL,
  ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
  ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
  GetCATSettings,
  GetSolarData,
  GetQSORate,
  LoTWUserInfo,
  OperatingDefaultForBand,
  LogUDPLoggedADIF,
  ListCountries,
  GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
  WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
  IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
  FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
  GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
  ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
  QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
  GetAwardDefs,
  GetUIPref, GetActiveProfile, QuitApp,
  ReportLiveActivity, LiveLastQSOAgeSec,
  GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, SPESetOperate, ACOMSetOperate, PGXLSetOperate,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
import { EventsOn, BrowserOpenURL } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';

import { Menubar, type Menu } from '@/components/Menubar';
import { APP_VERSION, APP_AUTHOR } from '@/version';
import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
import { StatsPanel } from '@/components/StatsPanel';
import { StationControlPanel } from '@/components/StationControlPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
import { DuplicatesModal } from '@/components/DuplicatesModal';
import { useI18n } from '@/lib/i18n';
import { AlertsModal } from '@/components/AlertsModal';
import { BulkEditModal } from '@/components/BulkEditModal';
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref';
import { setGridPrefsProfile } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
  Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
import { flagURL } from '@/lib/flags';

type QSO = QSOForm;
type ImportResult = adifModels.ImportResult;
type LookupResult = lookupModels.Result;
type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type WB = WorkedBeforeView;
type CATState = Omit<catModels.RigState, 'convertValues'>;

const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
// so neither is recorded (no REC badge / timer for them).
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);

const emptyDetails: DetailsState = {
  state: '', cnty: '', address: '',
  lat: undefined, lon: undefined,
  dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
  qsl_msg: '', qsl_via: '',
  ant_az: undefined, ant_el: undefined, ant_path: '',
  prop_mode: '', my_rig: '', my_antenna: '',
  tx_pwr: undefined,
  sat_name: '', sat_mode: '',
  contest_id: '', srx_string: '', stx_string: '',
  email: '',
  award_refs: '',
};

// parseAwardRefs turns the QSO row's materialised award_refs JSON string
// ({"DDFM":"74","WAJA":"12"}) into the code→ref object the grid columns read.
// Tolerant of empty / malformed values (returns {}), and passes an already-parsed
// object straight through.
function parseAwardRefs(s: any): Record<string, string> {
  if (!s) return {};
  if (typeof s === 'object') return s as Record<string, string>;
  if (typeof s !== 'string') return {};
  try {
    const o = JSON.parse(s);
    return o && typeof o === 'object' ? o : {};
  } catch {
    return {};
  }
}

function fmtDateUTC(s: any): string {
  if (!s) return '';
  const d = new Date(s);
  if (isNaN(d.getTime())) return s;
  const p = (n: number) => String(n).padStart(2, '0');
  return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
function fmtFreq(hz?: number): string {
  if (!hz) return '';
  return (hz / 1_000_000).toFixed(4);
}
// cleanSpotter / inferSpotMode / spotStatusKey live in lib/spot.ts so
// the BandMap component reads from the same canonical source — keeps
// "CW spot looks like CW everywhere" honest.
function fmtHMSUTC(d: Date): string {
  const p = (n: number) => String(n).padStart(2, '0');
  return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
// parseHMSUTC parses "HH:MM" or "HH:MM:SS" and returns a Date with that
// UTC time on the same UTC day as base. Tolerates "HHMM" / "HHMMSS" too
// (no separators) so the user can type fast. Falls back to base on bad input.
function parseHMSUTC(s: string, base: Date): Date {
  const clean = s.replace(/[^0-9:]/g, '');
  let h = 0, m = 0, sec = 0;
  if (clean.includes(':')) {
    const parts = clean.split(':');
    h = parseInt(parts[0] ?? '0', 10) || 0;
    m = parseInt(parts[1] ?? '0', 10) || 0;
    sec = parseInt(parts[2] ?? '0', 10) || 0;
  } else if (clean.length >= 4) {
    h = parseInt(clean.slice(0, 2), 10) || 0;
    m = parseInt(clean.slice(2, 4), 10) || 0;
    sec = clean.length >= 6 ? (parseInt(clean.slice(4, 6), 10) || 0) : 0;
  } else {
    return base;
  }
  if (h > 23 || m > 59 || sec > 59) return base;
  const d = new Date(base);
  d.setUTCHours(h, m, sec, 0);
  return d;
}
// fmtFreqDots formats a MHz string for display as MHz.kHz.Hz with dot
// separators (14.266500 → "14.266.500"). Pads the fractional part to 6
// digits so partial inputs ("14.21") still render as "14.210.000".
function fmtFreqDots(mhzStr: string): string {
  if (!mhzStr) return '';
  const [intPart, fracRaw = ''] = mhzStr.split('.');
  const frac = (fracRaw + '000000').slice(0, 6);
  return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
}
// shortCatError condenses a backend error into a few words for the topbar
// pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates.
function shortCatError(err?: string): string {
  if (!err) return '';
  const e = err.toLowerCase();
  if (e.includes('not registered') || e.includes('not available')) return 'OmniRig not found';
  if (e.includes('not connected')) return 'not connected';
  if (e.includes('coinitialize')) return 'COM error';
  return err.length > 24 ? err.slice(0, 22) + '…' : err;
}
// bandForMHz maps a dial frequency (MHz) to its ADIF band, or '' if outside
// every known allocation (used to auto-fill the band when the freq changes).
function bandForMHz(mhz: number): string {
  if (!mhz || isNaN(mhz)) return '';
  const plan: [number, number, string][] = [
    [1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
    [10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
    [24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
    [144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'],
  ];
  for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
  return '';
}

// modeAccent maps a mode to a theme-aware colour for the live-stations widget:
// CW gold, phone green, digital blue, unknown muted.
function modeAccent(mode?: string): string {
  const m = (mode || '').toUpperCase();
  if (/CW/.test(m)) return 'var(--chart-3)';
  if (/SSB|USB|LSB|AM|FM|PHONE|DV/.test(m)) return 'var(--chart-2)';
  if (/FT8|FT4|RTTY|PSK|JT|JS8|Q65|MSK|FST|MFSK|OLIVIA|DIG|DATA|WSPR/.test(m)) return 'var(--chart-1)';
  return 'var(--muted-foreground)';
}

// rstCategory buckets a mode into the report family used for its RST list.
type RSTLists = { phone: string[]; cw: string[]; digital: string[] };
function rstCategory(mode: string): keyof RSTLists {
  const m = (mode || '').toUpperCase();
  const digital = ['FT8', 'FT4', 'JT65', 'JT9', 'JS8', 'Q65', 'MSK144', 'FST4', 'FST4W', 'MFSK', 'OLIVIA', 'JT4', 'WSPR'];
  if (digital.includes(m)) return 'digital';
  if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
  return 'phone';
}
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
// lag by tens of seconds over a remote/serial-over-IP link.
function estimateCwMs(resolved: string, wpm: number): number {
  const w = Math.max(5, wpm || 25);
  const chars = resolved.replace(/\s/g, '').length;
  const spaces = (resolved.match(/\s/g) || []).length;
  return ((chars * 10 + spaces * 4) * 1200) / w;
}
// exchangeFields splits a contest exchange the operator typed as free text into
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
function exchangeFields(raw?: string): { num?: number; str: string } {
  const t = (raw || '').trim();
  if (t === '') return { str: '' };
  return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
}
// rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] {
  const cat = rstCategory(mode);
  const l = lists[cat];
  if (l && l.length) return l;
  return cat === 'phone' ? ['59', '58', '57'] : cat === 'cw' ? ['599', '589', '579'] : ['+00', '-10', '-20'];
}

function computePrefix(call: string): string {
  if (!call) return '';
  const c = call.trim().toUpperCase().split('/')[0];
  let lastDigit = -1;
  for (let i = 0; i < c.length; i++) {
    if (c[i] >= '0' && c[i] <= '9') lastDigit = i;
  }
  return lastDigit >= 0 ? c.slice(0, lastDigit + 1) : c;
}

export default function App() {
  const { t, lang } = useI18n();
  // === Lists from settings (fallback for first paint) ===
  const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
  const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
  const [modePresets, setModePresets] = useState<ModePreset[]>([]);

  // === Entry ===
  const [callsign, setCallsign] = useState('');
  // Ref to the callsign input so ESC can snap focus back to it.
  const callsignRef = useRef<HTMLInputElement>(null);
  // QSO start time — frozen when the operator starts typing the callsign,
  // logged as ADIF QSO_DATE. End time = save click (logged as QSO_DATE_OFF).
  const [qsoStartedAt, setQsoStartedAt] = useState<Date | null>(null);
  // Frozen end time used when the End-UTC field is locked (backdated QSO);
  // null means the field tracks the live clock.
  const [qsoEndedAt, setQsoEndedAt] = useState<Date | null>(null);
  // Local string buffers for the time inputs while the user is editing.
  // Parsing only on blur — otherwise every keystroke would re-format the
  // value and React would move the cursor back to the end of the field.
  const [startInputStr, setStartInputStr] = useState('');
  const [endInputStr, setEndInputStr] = useState('');
  const [startFocused, setStartFocused] = useState(false);
  const [endFocused, setEndFocused] = useState(false);
  const [freqFocused, setFreqFocused] = useState(false);
  // Per-field locks — like Log4OM's padlocks. When locked, CAT updates skip
  // that field and the time fields become editable so the user can log a
  // QSO from the past while the radio is parked elsewhere.
  type LockKey = 'band' | 'mode' | 'freq' | 'start' | 'end';
  const [locks, setLocks] = useState<Record<LockKey, boolean>>({
    band: false, mode: false, freq: false, start: false, end: false,
  });
  const locksRef = useRef(locks);
  useEffect(() => { locksRef.current = locks; }, [locks]);
  const toggleLock = (k: LockKey) => {
    setLocks((s) => {
      const wasLocked = s[k];
      const next = { ...s, [k]: !wasLocked };
      if (wasLocked) {
        // Unlocking → restore automatic behavior. Without this the locked
        // value would linger forever: a stale Start time would never refresh
        // even after a new callsign is entered.
        if (k === 'start') {
          // If a QSO is currently in progress (callsign typed), snap start
          // to now since we missed the auto-start moment. Otherwise clear.
          setQsoStartedAt(callsign.trim() ? new Date() : null);
        } else if (k === 'end') {
          // Drop the frozen end so the field tracks the live UTC clock.
          setQsoEndedAt(null);
        }
      } else {
        // Locking (manual / deferred entry) → pre-fill with today's date + the
        // current UTC time so the fields aren't empty; the operator just adjusts.
        const now = new Date();
        if (k === 'start') setQsoStartedAt((d) => d ?? now);
        else if (k === 'end') setQsoEndedAt((d) => d ?? now);
      }
      return next;
    });
  };
  // Small padlock toggle rendered inside each lockable field's label. Match
  // the icon to the current state so the user can tell at a glance which
  // fields are immune to CAT updates / live clock.
  function LockBtn({ k, title }: { k: LockKey; title: string }) {
    const on = locks[k];
    const Icon = on ? Lock : Unlock;
    return (
      <button
        type="button"
        tabIndex={-1}
        onClick={() => toggleLock(k)}
        title={`${on ? 'Unlock' : 'Lock'} ${title}`}
        className={cn(
          'inline-flex items-center justify-center size-3.5 rounded transition-colors',
          on ? 'text-warning hover:text-warning' : 'text-muted-foreground/40 hover:text-muted-foreground',
        )}
      >
        <Icon className="size-3" />
      </button>
    );
  }
  const [band, setBand] = useState('20m');
  const [mode, setMode] = useState('SSB');
  const [freqMhz, setFreqMhz] = useState('');
  // RX freq for split — only set/shown when the rig is in split mode.
  const [rxFreqMhz, setRxFreqMhz] = useState('');
  // RX band — follows the TX band by default; only differs for cross-band work.
  const [bandRx, setBandRx] = useState('20m');
  const [countries, setCountries] = useState<string[]>([]);
  const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
  const [rstSent, setRstSent] = useState('59');
  const [rstRcvd, setRstRcvd] = useState('59');
  const [grid, setGrid] = useState('');
  const [name, setName] = useState('');
  const [qth, setQth] = useState('');
  // Contest mode: a picked contest + exchange config; persisted so a running
  // contest (serial counter included) survives a restart. `rcv` is the received
  // exchange for the current QSO.
  const [contest, setContest] = useState<ContestSession>(CONTEST_DEFAULT);
  const [rcv, setRcv] = useState('');
  const [contestDupe, setContestDupe] = useState(false);
  const [country, setCountry] = useState('');
  const [comment, setComment] = useState('');
  const [note, setNote] = useState('');

  // Compact (popout) mode: shrinks the window + always-on-top so the entry
  // strip can sit on top of WSJT-X / JT-Alert / the cluster.
  const [compact, setCompact] = useState(false);
  function toggleCompact() {
    const next = !compact;
    setCompact(next);
    SetCompactMode(next);
  }

  // CAT — receives live rig state via Wails events.
  const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
  // Configured CAT backend ('icom' USB vs 'icom-net'): the live catState.backend
  // is "icom" for BOTH, so we track the settings value to tell them apart (used to
  // hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
  // rig is off so power-ON can't work).
  const [catBackend, setCatBackend] = useState('');
  // Live space-weather (solar flux / sunspots / A / K) for the header strip.
  // Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
  // fallback poll. These same numbers are stamped onto each logged QSO.
  const [solar, setSolar] = useState<any>(null);
  useEffect(() => {
    const load = () => { GetSolarData().then((d) => setSolar(d ?? null)).catch(() => {}); };
    load();
    const off = EventsOn('solar:update', load);
    const id = window.setInterval(load, 60 * 60 * 1000); // hourly fallback; the event refreshes it live
    return () => { off(); window.clearInterval(id); };
  }, []);
  // LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
  // debounced. Drives a colour-coded LoTW badge by last-upload recency.
  const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null);
  useEffect(() => {
    const c = callsign.trim();
    if (!c) { setLotwInfo(null); return; }
    const run = () => { LoTWUserInfo(c).then((r) => setLotwInfo(r as any)).catch(() => setLotwInfo(null)); };
    const t = window.setTimeout(run, 300);
    // Re-run when the background auto-download finishes (so a call typed before
    // the list loaded gets its badge without re-typing).
    const off = EventsOn('lotwusers:updated', run);
    return () => { window.clearTimeout(t); off(); };
  }, [callsign]);
  const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
  const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
  const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
  const [agEnabled, setAgEnabled] = useState(false);
  // Per-port optimistic selection that the status poll must not revert until the
  // device confirms it (or it expires) — otherwise a stale poll right after a
  // click reverts the UI and the click looks like it did nothing.
  const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
  const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
  // Amplifier chip in the status bar: name + green OPERATE / orange STANDBY /
  // red offline. Config re-read every 5s (so enabling the amp in Settings makes
  // the chip appear); status polled every 2s while enabled.
  const [ampCfg, setAmpCfg] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
  const [ampSt, setAmpSt] = useState<any>({ connected: false });
  useEffect(() => {
    let alive = true;
    const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmpCfg({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
    load();
    const id = window.setInterval(load, 5000);
    return () => { alive = false; window.clearInterval(id); };
  }, []);
  useEffect(() => {
    if (!ampCfg.enabled) { setAmpSt({ connected: false }); return; }
    let alive = true;
    const get = ampCfg.type === 'pgxl' ? GetPGXLStatus : ampCfg.type.startsWith('acom') ? GetACOMStatus : GetSPEStatus;
    const tick = () => get().then((s: any) => alive && setAmpSt(s || { connected: false })).catch(() => {});
    tick();
    const id = window.setInterval(tick, 2000);
    return () => { alive = false; window.clearInterval(id); };
  }, [ampCfg.enabled, ampCfg.type]);
  // Multi-op "who's on air" widget: every operator's live status from the shared
  // MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
  type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
  const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
  const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
  // Depend on the BOOLEAN (is-mysql), not the dbConn object: dbConn gets a fresh
  // reference on several events, and depending on the object reset the 15s timer
  // on every such refresh so it could ~never fire (the widget looked stuck for
  // minutes). Also refresh right after we log a QSO so our own row shows without
  // waiting for the next tick, like the ON AIR badge.
  const liveStationsOn = dbConn?.backend === 'mysql';
  useEffect(() => {
    if (!liveStationsOn) { setLiveStations([]); return; }
    const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
    load();
    const id = window.setInterval(load, 5 * 1000);
    // Refresh the instant the backend actually publishes a row change (a QSO put
    // someone on air, or a 5-min silence took them off) — so this widget tracks the
    // bottom ON-AIR badge instead of lagging behind its poll. Keep the qso:logged
    // fallback too (a small delay lets the async publish land) for older paths.
    const offStatus = EventsOn('livestatus:updated', load);
    const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
    return () => { window.clearInterval(id); offStatus?.(); offLogged?.(); };
  }, [liveStationsOn]);
  // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
  // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
  // in Preferences > Hardware > CAT interface.
  const digitalDefaultRef = useRef<string>('FT8');
  // Don't override freq/band/mode the user JUST typed — track a small grace
  // window after manual edits and skip CAT updates during it.
  const catFreezeUntilRef = useRef<number>(0);
  function noteManualEdit() { catFreezeUntilRef.current = Date.now() + 1500; }

  // Suggested QSY frequency (Hz) for a given band + mode. Common phone /
  // CW / FT8 watering holes per IARU practice. Fallback = mid-band SSB freq.
  // TODO: make this a user preference per band/mode later.
  const QSY_TABLE: Record<string, Record<string, number>> = {
    '160m': { SSB: 1842000,  CW: 1820000,  FT8: 1840000,  FT4: 1840000,  RTTY: 1838000, default: 1840000  },
    '80m':  { SSB: 3750000,  CW: 3520000,  FT8: 3573000,  FT4: 3575000,  RTTY: 3580000, default: 3750000  },
    '60m':  { default: 5357000 },
    '40m':  { SSB: 7150000,  CW: 7030000,  FT8: 7074000,  FT4: 7047500,  RTTY: 7040000, default: 7150000  },
    '30m':  { CW: 10116000,  FT8: 10136000, FT4: 10140000, RTTY: 10142000, default: 10136000 },
    '20m':  { SSB: 14250000, CW: 14030000, FT8: 14074000, FT4: 14080000, RTTY: 14080000, default: 14250000 },
    '17m':  { SSB: 18140000, CW: 18080000, FT8: 18100000, FT4: 18104000, RTTY: 18105000, default: 18140000 },
    '15m':  { SSB: 21300000, CW: 21030000, FT8: 21074000, FT4: 21140000, RTTY: 21080000, default: 21300000 },
    '12m':  { SSB: 24950000, CW: 24910000, FT8: 24915000, FT4: 24919000, RTTY: 24920000, default: 24950000 },
    '10m':  { SSB: 28500000, CW: 28030000, FT8: 28074000, FT4: 28180000, RTTY: 28080000, default: 28500000 },
    '6m':   { SSB: 50150000, CW: 50080000, FT8: 50313000, FT4: 50318000,                default: 50150000 },
    '2m':   { SSB: 144300000, CW: 144050000, FT8: 144174000,                            default: 144300000 },
    '70cm': { SSB: 432300000, CW: 432050000, FT8: 432174000,                            default: 432300000 },
  };
  function qsyFreqHz(band: string, mode: string): number {
    const t = QSY_TABLE[band];
    if (!t) return 0;
    return t[mode] ?? t.default ?? 0;
  }

  // Digital watering holes (±3 kHz tolerance). When the rig is parked here
  // CAT typically reports SSB (USB-D under the hood) which is misleading —
  // auto-promote to the specific digital mode for the entry strip.
  const DIGITAL_FREQS: { freq: number; mode: string }[] = [
    { freq: 1.840,   mode: 'FT8' },
    { freq: 3.573,   mode: 'FT8' },
    { freq: 5.357,   mode: 'FT8' },
    { freq: 7.074,   mode: 'FT8' },  { freq: 7.0475, mode: 'FT4' },
    { freq: 10.136,  mode: 'FT8' },  { freq: 10.140, mode: 'FT4' },
    { freq: 14.074,  mode: 'FT8' },  { freq: 14.080, mode: 'FT4' },
    { freq: 18.100,  mode: 'FT8' },  { freq: 18.104, mode: 'FT4' },
    { freq: 21.074,  mode: 'FT8' },  { freq: 21.140, mode: 'FT4' },
    { freq: 24.915,  mode: 'FT8' },  { freq: 24.919, mode: 'FT4' },
    { freq: 28.074,  mode: 'FT8' },  { freq: 28.180, mode: 'FT4' },
    { freq: 50.313,  mode: 'FT8' },  { freq: 50.318, mode: 'FT4' },
    { freq: 144.174, mode: 'FT8' },
  ];
  function inferDigitalMode(freqHz: number): string {
    const mhz = freqHz / 1_000_000;
    for (const d of DIGITAL_FREQS) {
      if (Math.abs(mhz - d.freq) <= 0.003) return d.mode;
    }
    return '';
  }

  // User changed band/mode in the entry strip → push to the rig if CAT is up.
  // Both calls are fire-and-forget; CAT will reflect back via cat:state.
  // When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
  // NOT drive the radio — the lock means "this value is decoupled from the rig".
  function onBandUserChange(v: string) {
    setBand(v);
    noteManualEdit();
    if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
      const hz = qsyFreqHz(v, mode);
      if (hz > 0) SetCATFrequency(hz).catch(() => {});
    }
  }
  function onModeUserChange(v: string) {
    setMode(v);
    applyModePreset(v);
    noteManualEdit();
    if (catState.enabled && catState.connected && !locks.mode) {
      SetCATMode(v).catch(() => {});
    }
  }

  const userEditedRef = useRef<Set<string>>(new Set());
  const lastLookedUpRef = useRef<string>('');
  // Tracks the call we last auto-switched to the Worked-before tab for, so we
  // don't keep yanking the tab on every wb refresh of the same callsign.
  const lastWbFocusRef = useRef<string>('');
  const rstUserEditedRef = useRef<boolean>(false);

  const [details, setDetails] = useState<DetailsState>(emptyDetails);
  const updateDetails = useCallback((patch: Partial<DetailsState>) => {
    setDetails((d) => ({ ...d, ...patch }));
  }, []);
  // Auto-fill MY_RIG / MY_ANTENNA from the operating conditions tree
  // whenever the band changes. The backend resolves the "default antenna
  // for this band" within the active profile and returns the (rig,
  // antenna) tuple. Empty result → we DO clear the fields so leftover
  // values from a previous band don't get logged against the wrong gear.
  useEffect(() => {
    if (!band) return;
    let cancelled = false;
    OperatingDefaultForBand(band).then((d) => {
      if (cancelled) return;
      setDetails((cur) => ({
        ...cur,
        my_rig: d?.station_name || '',
        my_antenna: d?.antenna_name || '',
        tx_pwr: d?.tx_pwr ?? cur.tx_pwr,
      }));
    }).catch(() => {});
    return () => { cancelled = true; };
  }, [band]);
  const prefix = useMemo(() => computePrefix(callsign), [callsign]);

  // === Logbook list ===
  const [qsos, setQsos] = useState<QSO[]>([]);
  const [total, setTotal] = useState<number>(0);
  const [error, setError] = useState('');
  // Secret vault (encrypted passwords): prompt to unlock at launch when a
  // passphrase is configured but not yet entered this session.
  const [unlockOpen, setUnlockOpen] = useState(false);
  const [unlockPass, setUnlockPass] = useState('');
  const [unlockErr, setUnlockErr] = useState('');
  const [unlockBusy, setUnlockBusy] = useState(false);
  const doUnlock = async () => {
    setUnlockBusy(true); setUnlockErr('');
    try { await UnlockSecrets(unlockPass); setUnlockOpen(false); setUnlockPass(''); }
    catch (e: any) { setUnlockErr(String(e?.message ?? e)); }
    finally { setUnlockBusy(false); }
  };
  // Transient success toast (bottom-right, auto-dismiss). Used for things
  // like "spot sent" where a blocking error banner would be overkill.
  // Toasts are QUEUED so two firing at once don't clobber each other: each shows
  // for 3s, then the next takes over (no gap). `toast` is the one on screen now.
  const [toast, setToast] = useState('');
  const toastQueue = useRef<string[]>([]);
  const toastTimer = useRef<number | undefined>(undefined);
  const advanceToast = useCallback(() => {
    const next = toastQueue.current.shift();
    if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
    setToast(next);
    toastTimer.current = window.setTimeout(advanceToast, 3000);
  }, []);
  const showToast = useCallback((msg: string) => {
    toastQueue.current.push(msg);
    if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
  }, [advanceToast]);
  const dismissToast = useCallback(() => {
    if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
    advanceToast(); // skip to the next queued toast (or clear if none)
  }, [advanceToast]);
  // Status-bar message expanded into a popover (long errors — a TQSL or Club Log
  // failure — don't fit on one line, and truncating them with no way to read the
  // rest is useless).
  const [msgOpen, setMsgOpen] = useState(false);
  // Reset when the message goes away, or the NEXT one would pop open by itself.
  useEffect(() => { if (!error && !toast) setMsgOpen(false); }, [error, toast]);
  // Error banners auto-dismiss after a few seconds (longer than toasts since
  // they may be multi-line). The X button still closes them immediately. Do NOT
  // dismiss while the operator has it expanded and is reading it.
  useEffect(() => {
    if (!error || msgOpen) return;
    const t = window.setTimeout(() => setError(''), 6000);
    return () => window.clearTimeout(t);
  }, [error, msgOpen]);
  // True while the QSO recorder is capturing the current contact (set when we
  // leave the callsign field, cleared on log/cancel). Drives the REC badge.
  const [recording, setRecording] = useState(false);
  // Elapsed recording time (seconds) shown next to the red dot, ticking once a
  // second while a recording is in progress.
  const [recSeconds, setRecSeconds] = useState(0);
  // Bumped on every (re)start so the timer resets even when `recording` was
  // already true (jumping spot→spot keeps recording=true but starts a fresh take).
  const [recTick, setRecTick] = useState(0);
  useEffect(() => {
    if (!recording) { setRecSeconds(0); return; }
    const start = Date.now();
    setRecSeconds(0);
    const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
    return () => window.clearInterval(id);
  }, [recording, recTick]);
  // The callsign the in-progress recording belongs to (uppercased; '' = none).
  // Lets us restart from zero when the operator edits the call to a different
  // station mid-recording, instead of continuing the old take.
  const recordingCallRef = useRef('');
  // restartRecordingForNewTarget (re)starts the take for a new target (clicked
  // spot / external app / edited callsign) and resets the elapsed timer.
  const restartRecordingForNewTarget = (forCall?: string) => {
    if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
    QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
  };
  // Reset the recording to zero (drop everything so far, pre-roll included) —
  // bound to clicking the REC timer. Use when the station was already in a long
  // QSO and you only want your own exchange in the file.
  const resetRecordingClock = () => {
    QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
  };
  const [saving, setSaving] = useState(false);
  // Synchronous re-entrancy guard: `saving` is React state (updates async), so it
  // can't stop a burst of Enter presses / clicks fired before the re-render — each
  // would run a full AddQSO and log the SAME contact several times when a slow QRZ
  // lookup made the log take seconds. This ref blocks the repeat immediately.
  const savingRef = useRef(false);
  const [filterCallsign, setFilterCallsign] = useState('');
  // Advanced filter builder (replaces the old band/mode dropdowns).
  const [filterOpen, setFilterOpen] = useState(false);
  const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
  const [matchCount, setMatchCount] = useState<number | null>(null);
  const [gridFilteredCount, setGridFilteredCount] = useState<number | null>(null); // rows after AG-Grid column filters, or null if none
  // The selected tab is remembered across restarts. Only the always-present tabs
  // are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
  // a feature or CAT backend that isn't known this early, and restoring one that
  // isn't there would show a blank pane.
  const ALWAYS_TABS = ['main', 'recent', 'cluster', 'worked', 'awards', 'bandmap'];
  const [activeTab, setActiveTab] = useState(() => {
    const saved = localStorage.getItem('opslog.activeTab') || '';
    return ALWAYS_TABS.includes(saved) ? saved : 'recent';
  });
  useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
  // QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
  const [qslTabOpen, setQslTabOpen] = useState(false);
  const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
  const [eqslQsoId, setEqslQsoId] = useState<number | null>(null); // QSO being sent as eQSL
  function closeQslTab() {
    setQslTabOpen(false);
    setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
  }
  // Statistics is likewise a closable tab, opened from Tools → Statistics.
  const [statsTabOpen, setStatsTabOpen] = useState(false);
  function closeStatsTab() {
    setStatsTabOpen(false);
    setActiveTab((t) => (t === 'stats' ? 'recent' : t));
  }
  const [stationTabOpen, setStationTabOpen] = useState(false);
  function closeStationTab() {
    setStationTabOpen(false);
    setActiveTab((t) => (t === 'station' ? 'recent' : t));
  }
  // Recent QSOs row cap, persisted. With AG Grid's virtual scroller
  // huge logs render OK once loaded, but a 25k+ logbook still takes a
  // couple of seconds to round-trip from SQLite at launch. Defaulting
  // to 500 keeps the first paint instant; the user can bump to "All"
  // when they actually want to search history.
  const [qsoLimit, setQsoLimit] = useState<number>(() => {
    const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
    return Number.isFinite(raw) && raw > 0 ? raw : 500;
  });
  useEffect(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);

  // Contest session: load once, then persist on every change (merge + save).
  useEffect(() => {
    GetUIPref('opslog.contest').then((s) => {
      if (s) { try { const c = { ...CONTEST_DEFAULT, ...JSON.parse(s) }; setContest(c); if (c.active) setContestTabEnabled(true); } catch {} }
    }).catch(() => {});
  }, []);
  const updateContest = (patch: Partial<ContestSession>) => {
    setContest((c) => {
      const next = { ...c, ...patch };
      writeUiPref('opslog.contest', JSON.stringify(next));
      return next;
    });
  };
  // Contest dupe check: is the typed callsign already worked in this contest on
  // the current band + mode? Debounced so it doesn't fire on every keystroke.
  useEffect(() => {
    if (!contest.active || !contest.code || !callsign.trim()) { setContestDupe(false); return; }
    const call = callsign.trim().toUpperCase();
    let alive = true;
    const t = window.setTimeout(() => {
      ContestDupe(call, contest.code, band, mode, contest.startISO).then((d) => { if (alive) setContestDupe(!!d); }).catch(() => {});
    }, 250);
    return () => { alive = false; window.clearTimeout(t); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [callsign, contest.active, contest.code, band, mode, contest.startISO]);

  // === DX Cluster live state ===
  type ClusterSpot = {
    source_id: number;
    source_name: string;
    spotter: string;
    dx_call: string;
    freq_khz: number;
    freq_hz: number;
    band?: string;
    comment?: string;
    locator?: string;
    time_utc?: string;
    country?: string;
    continent?: string;
    cqz?: number;
    ituz?: number;
    distance_km?: number;
    sp_deg?: number;
    lp_deg?: number;
    received_at: string;
    raw: string;
  };
  type ServerStatus = {
    server_id: number;
    name: string;
    host: string;
    port: number;
    state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
    login?: string;
    error?: string;
    spots_count?: number;
    retries?: number;
  };
  const [clusterServerStatuses, setClusterServerStatuses] = useState<ServerStatus[]>([]);
  // "Send DX spot" dialog (Log4OM-style) — only reachable when a cluster is up.
  const [showSpotModal, setShowSpotModal] = useState(false);
  // Holds our station callsign for the (one-shot) cluster spot listener, so a
  // self-spot can be surfaced in the shared header toast.
  const myCallRef = useRef('');

  // === WinKeyer CW keyer ===
  const [wkEnabled, setWkEnabled] = useState(false);
  const [wkPort, setWkPort] = useState('');
  const [wkWpm, setWkWpm] = useState(25);
  const [wkMacros, setWkMacros] = useState<WKMacro[]>([]);
  const [wkPorts, setWkPorts] = useState<string[]>([]);
  const [wkStatus, setWkStatus] = useState<WKStatus>({ connected: false, busy: false, wpm: 25, version: 0, port: '' });
  const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
  const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
  const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
  // CW keyer output engine (persisted in the WinKeyer settings, chosen in
  // Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
  // CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
  // auto-call and <LOGQSO> are shared; only the transport differs.
  const [wkEngine, setWkEngine] = useState<string>('winkeyer');
  const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
  const cwSourceRef = useRef(cwSource);
  useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
  // CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
  // actually transmit. Exposed in the CW keyer panel when the engine is Icom.
  const [icomBreakIn, setIcomBreakIn] = useState(0);
  const setBreakIn = useCallback((m: number) => { setIcomBreakIn(m); IcomSetBreakIn(m).catch(() => {}); }, []);
  // Seed the current break-in from the rig when the CW panel becomes active in
  // Icom mode (so the control reflects the radio's real state).
  useEffect(() => {
    if (cwSource !== 'icom' || !wkEnabled || !(catState.backend === 'icom' && catState.connected)) return;
    GetIcomState().then((s: any) => { if (s && typeof s.break_in === 'number') setIcomBreakIn(s.break_in); }).catch(() => {});
  }, [cwSource, wkEnabled, catState.backend, catState.connected]);
  // Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
  // until a reply is entered or it's stopped. Persisted as UI prefs.
  const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
  const [wkAutoCallSecs, setWkAutoCallSecs] = useState(() => Number(localStorage.getItem('opslog.wkAutoCallSecs')) || 3);
  const wkAutoCallRef = useRef(wkAutoCall);
  const wkAutoCallSecsRef = useRef(wkAutoCallSecs);
  const autoCallGenRef = useRef(0);   // bump to cancel the running loop
  const autoCallMacroRef = useRef(-1); // macro index currently auto-repeating (-1 = none)
  useEffect(() => { wkAutoCallRef.current = wkAutoCall; }, [wkAutoCall]);
  useEffect(() => { wkAutoCallSecsRef.current = wkAutoCallSecs; }, [wkAutoCallSecs]);
  // F1-F12 macro shortcuts active only when the keyer is enabled + connected.
  const wkActiveRef = useRef(false);
  const wkEscClearsRef = useRef(true);
  const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
  useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
  // Generation token for an in-flight macro send: bumped on every new send AND on
  // any abort (ESC / Stop / callsign cleared). wkSend checks it before each CW
  // segment and before every <LOGQSO> log, so aborting mid-macro stops sending AND
  // skips the log that hasn't happened yet.
  const wkSendGenRef = useRef(0);
  useEffect(() => {
    const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
      : cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
      : wkStatus.connected;
    wkActiveRef.current = wkEnabled && connected;
  }, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
  useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);

  // === Digital Voice Keyer (DVK) ===

  // === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
  const [chatAvailable, setChatAvailable] = useState(false);
  const [chatOpen, setChatOpen] = useState(false);
  const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>([]);
  const [chatOnline, setChatOnline] = useState<ChatPresence[]>([]);
  const [chatUnread, setChatUnread] = useState(0);
  const [chatEpoch, setChatEpoch] = useState(0); // bumped on profile switch to reload the chat for the new logbook
  const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen;
  const chatSeen = useRef<Set<number>>(new Set());
  // Availability (only on a shared MySQL logbook; re-checked as profiles switch).
  useEffect(() => {
    let alive = true;
    const chk = () => ChatAvailable().then((v) => alive && setChatAvailable(!!v)).catch(() => {});
    chk();
    const id = window.setInterval(chk, 10000);
    return () => { alive = false; window.clearInterval(id); };
  }, []);
  // Incoming messages — append + bump unread when the panel is closed.
  useEffect(() => {
    const off = EventsOn('chat:message', (m: ChatMsg) => {
      if (!m || chatSeen.current.has(m.id)) return;
      chatSeen.current.add(m.id);
      setChatMsgs((p) => [...p, m].slice(-300));
      if (!chatOpenRef.current) setChatUnread((u) => u + 1);
    });
    return () => { off?.(); };
  }, []);
  // On open: clear unread, load history + the online list (refreshed).
  useEffect(() => {
    if (!chatOpen) return;
    setChatUnread(0);
    GetChatHistory(80).then((h: any) => {
      const list = (h ?? []) as ChatMsg[];
      list.forEach((m) => chatSeen.current.add(m.id));
      setChatMsgs((prev) => {
        const byId = new Map<number, ChatMsg>();
        [...list, ...prev].forEach((m) => byId.set(m.id, m));
        return Array.from(byId.values()).sort((a, b) => a.id - b.id).slice(-300);
      });
    }).catch(() => {});
    const lo = () => GetOnlineOperators().then((o: any) => setChatOnline((o ?? []) as ChatPresence[])).catch(() => {});
    lo();
    const id = window.setInterval(lo, 15000);
    return () => window.clearInterval(id);
  }, [chatOpen, chatEpoch]);
  async function chatSend(t: string) {
    try {
      const m = (await SendChatMessage(t)) as any as ChatMsg;
      if (m && m.id && !chatSeen.current.has(m.id)) {
        chatSeen.current.add(m.id);
        setChatMsgs((p) => [...p, m].slice(-300));
      }
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  const chatShown = chatOpen && chatAvailable;

  const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal

  // Persistent spot-alert tray: keep the last few fired alerts visible (they used
  // to vanish with the 3 s toast, lost if you were on another window). Each stays
  // ~10 min and is clickable to tune the rig to its freq/mode.
  type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
  const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
  const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown

  // Offline outbox: QSOs parked locally because the database was unreachable.
  const [offlineStatus, setOfflineStatus] = useState<{ offline: boolean; pending: number; path: string }>({ offline: false, pending: 0, path: '' });
  const [pendingOpen, setPendingOpen] = useState(false);
  const [pendingList, setPendingList] = useState<any[]>([]);
  useEffect(() => {
    const load = () => GetOfflineStatus().then((s: any) => setOfflineStatus(s)).catch(() => {});
    load();
    const off = EventsOn('offline:status', (s: any) => {
      setOfflineStatus(s);
      // Keep the open list in step as QSOs are parked or replayed.
      if (pendingOpenRef.current) GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
    });
    return () => { off?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const pendingOpenRef = useRef(false);
  useEffect(() => { pendingOpenRef.current = pendingOpen; }, [pendingOpen]);
  const ALERT_TTL_MS = 10 * 60 * 1000;
  useEffect(() => {
    const id = window.setInterval(() => {
      setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; });
    }, 20000);
    return () => window.clearInterval(id);
  }, []);
  // Close the (now-hidden) dropdown when the last alert clears/expires.
  useEffect(() => { if (recentAlerts.length === 0) setAlertsPanelOpen(false); }, [recentAlerts.length]);

  // NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
  const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
  useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
  // Contest tab is hidden until enabled from Tools → Contest mode.
  const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1');
  useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]);

  const [dvkEnabled, setDvkEnabled] = useState(false);
  const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
  const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
  const dvkActiveRef = useRef(false);
  const dvkPlayingRef = useRef(false);
  const dvkPlayRef = useRef<(slot: number) => void>(() => {});
  useEffect(() => { dvkActiveRef.current = dvkEnabled; }, [dvkEnabled]);
  useEffect(() => { dvkPlayingRef.current = dvkStat.playing; }, [dvkStat.playing]);
  useEffect(() => {
    const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
    return () => { off?.(); };
  }, []);
  const reloadDvk = useCallback(() => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }, []);
  // Load messages + status whenever the keyer is switched on.
  useEffect(() => {
    if (!dvkEnabled) return;
    reloadDvk();
    GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
  }, [dvkEnabled, reloadDvk]);
  const dvkPlay = useCallback((slot: number) => { DVKPlay(slot).catch((e: any) => setError(String(e?.message ?? e))); }, []);
  useEffect(() => { dvkPlayRef.current = dvkPlay; }, [dvkPlay]);
  // Controlled active tab of the F1-F5 detail panel (so Ctrl+F1-F5 can switch
  // it from the keyboard without clashing with the F1-F12 keyer macros).
  type DetailTab = 'stats' | 'info' | 'awards' | 'my' | 'extended';
  const [detailTab, setDetailTab] = useState<DetailTab>('stats');
  const [clusterServers, setClusterServers] = useState<{ id: number; name: string; enabled: boolean; sort_order: number }[]>([]);
  // Ring buffer — only keep the last N spots; cluster firehose can be heavy.
  const [spots, setSpots] = useState<ClusterSpot[]>([]);
  const SPOTS_CAP = 1000;
  // Cluster filter selections persist across restarts (writeUiPref → localStorage
  // + DB, so they also travel with a copied data/ folder). Loaders read the cache
  // synchronously at first render; a single effect below writes them back.
  const lsBool = (k: string, d: boolean) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; };
  const lsSet = <T,>(k: string): Set<T> => { try { const a = JSON.parse(localStorage.getItem(k) || '[]'); return new Set(Array.isArray(a) ? a : []); } catch { return new Set<T>(); } };
  const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(() => {
    const v = localStorage.getItem('opslog.clusterFilterSource'); const n = v ? parseInt(v, 10) : NaN; return Number.isFinite(n) ? n : '';
  });
  const [clusterGroup, setClusterGroup] = useState(() => lsBool('opslog.clusterGroup', true));
  const [clusterCmd, setClusterCmd] = useState('');
  // Cluster console: the raw traffic. Spots are parsed out of the stream into the
  // grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
  // so a command appeared to do nothing at all.
  type ClusterLine = { server_id: number; server_name: string; text: string; sent: boolean; at: string };
  const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever
  const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]);
  const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
  const clusterConsoleRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const off = EventsOn('cluster:line', (l: any) => {
      setClusterLines((prev) => {
        const next = [...prev, l as ClusterLine];
        return next.length > CONSOLE_CAP ? next.slice(next.length - CONSOLE_CAP) : next;
      });
    });
    return () => { off?.(); };
  }, []);
  // Follow the tail, but ONLY when already at the bottom — otherwise scrolling up
  // to read a SH/DX reply would yank you back down on the next spot.
  useEffect(() => {
    const el = clusterConsoleRef.current;
    if (!el) return;
    const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
    if (atBottom) el.scrollTop = el.scrollHeight;
  }, [clusterLines]);
  // Multi-band filter: empty set = all bands. The user toggles chips.
  const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands'));
  // Lock-to-entry: when on, the band filter follows the entry's current
  // band and the mode filter follows the entry's current mode.
  const [clusterLockBand, setClusterLockBand] = useState(() => lsBool('opslog.clusterLockBand', false));
  const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', false));
  // Status filter chips. Empty set = show every status (including
  // already-worked). Otherwise only matching spots pass.
  type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
  const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter'));
  // Mode filter chips. Empty set = show every mode. Categories map the
  // inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
  type SpotModeCat = 'SSB' | 'CW' | 'DATA';
  const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(() => lsSet<SpotModeCat>('opslog.clusterModeFilter'));
  const [clusterSearch, setClusterSearch] = useState(() => localStorage.getItem('opslog.clusterSearch') || '');
  // Hide spots already worked (exact call worked, or this band+mode slot done).
  const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));

  // Persist every cluster filter selection whenever it changes, so it is still
  // set after a close/reopen.
  useEffect(() => {
    writeUiPref('opslog.clusterFilterSource', clusterFilterSource === '' ? '' : String(clusterFilterSource));
    writeUiPref('opslog.clusterGroup', clusterGroup ? '1' : '0');
    writeUiPref('opslog.clusterBands', JSON.stringify([...clusterBands]));
    writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0');
    writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0');
    writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter]));
    writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter]));
    writeUiPref('opslog.clusterSearch', clusterSearch);
    writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0');
  }, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode,
      clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]);
  // Bands shown side-by-side in the Band Map tab (portable).
  const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
    try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
    catch { return []; }
  });
  const toggleBandMapBand = useCallback((b: string) => {
    setBandMapBands((cur) => {
      const next = cur.includes(b) ? cur.filter((x) => x !== b) : [...cur, b];
      writeUiPref('opslog.bandMapBands', JSON.stringify(next));
      return next;
    });
  }, []);
  // Global band-map-tab options (apply to every map in the tab): hide FTx/digital
  // spots, and fit each map to its full band span.
  const [bandMapHideFt, setBandMapHideFt] = useState(() => localStorage.getItem('opslog.bandMapHideFt') === '1');
  const [bandMapFit, setBandMapFit] = useState(() => localStorage.getItem('opslog.bandMapFit') === '1');
  const toggleBandMapHideFt = useCallback(() => setBandMapHideFt((v) => { const n = !v; writeUiPref('opslog.bandMapHideFt', n ? '1' : '0'); return n; }), []);
  const toggleBandMapFit = useCallback(() => setBandMapFit((v) => { const n = !v; writeUiPref('opslog.bandMapFit', n ? '1' : '0'); return n; }), []);
  // Single band map docked beside the table (toggled by the toolbar button,
  // visible across tabs). Independent of the multi-band "Band Map" tab.
  const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1');
  // Persist the Band Map open/closed state (portable) so it survives a restart.
  const setBandMapShown = useCallback((v: boolean) => {
    setShowBandMap(v);
    writeUiPref('bandmap.show', v ? '1' : '0');
  }, []);
  const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
    () => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
  );
  const toggleBandMapSide = useCallback(() => {
    setBandMapSide((s) => {
      const next = s === 'right' ? 'left' : 'right';
      writeUiPref('bandmap.side', next);
      return next;
    });
  }, []);
  // Main tab is two configurable panes; each side shows one of the great-circle
  // map ("map1"), the locator street map ("map2"), the cluster grid or the
  // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
  // so it's loaded async on mount and re-read on profile:changed below.
  type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'netcontrol';
  const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
  const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
  const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
  const loadMainPanes = useCallback(async () => {
    const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'netcontrol';
    const [l, r] = await Promise.all([
      GetUIPref('mainPaneLeft').catch(() => ''),
      GetUIPref('mainPaneRight').catch(() => ''),
    ]);
    setMainPaneLeft(valid(l) ? l : 'map1');
    setMainPaneRight(valid(r) ? r : 'map2');
  }, []);
  useEffect(() => { loadMainPanes(); }, [loadMainPanes]);
  // Report the current entry-strip band/mode/freq to the backend so the live
  // operator status (multi-op) has band/mode/freq even when the CAT is off.
  useEffect(() => {
    const hz = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0;
    ReportLiveActivity(hz || 0, band || '', mode || '').catch(() => {});
  }, [band, mode, freqMhz]);
  // Cluster filter sidebar visibility — shared by the Cluster tab and the
  // Main-view cluster pane (portable UI pref). Hiding it keeps the filters
  // active, it just reclaims the width.
  const [clusterShowFilters, setClusterShowFilters] = useState(() => localStorage.getItem('opslog.clusterShowFilters') !== '0');
  const toggleClusterFilters = useCallback(() => {
    setClusterShowFilters((v) => { const n = !v; writeUiPref('opslog.clusterShowFilters', n ? '1' : '0'); return n; });
  }, []);
  type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
  const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
  // Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
  // Keyed by `${call}|${band}|${mode}` so two spots of the same call on
  // different slots don't share the same colour.
  const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
  // Live mirror of spotStatus so the incoming-spot buffer can tell which slots
  // still need resolving without re-subscribing the cluster:spot listener.
  const spotStatusRef = useRef(spotStatus);
  useEffect(() => { spotStatusRef.current = spotStatus; }, [spotStatus]);
  // Incoming spots are staged here for a few ms, their status resolved, then
  // committed to `spots` together — so a row paints with its NEW BAND/MODE badge
  // already on, instead of flashing plain text then flipping to the pill.
  const pendingSpotsRef = useRef<ClusterSpot[]>([]);
  const pendingSpotTimer = useRef<number | undefined>(undefined);

  // === Modals ===
  const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
  // QSOs queued for the delete confirm (1 or many — multi-row selection).
  const [deletingIds, setDeletingIds] = useState<number[]>([]);
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const [selectedIds, setSelectedIds] = useState<number[]>([]);
  const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
  const [bulkEditOpen, setBulkEditOpen] = useState(false);
  const [showSettings, setShowSettings] = useState(false);
  // Re-read the "beam on map" toggle when Preferences closes (it's edited there).
  useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
  // "ON AIR" status-bar badge: mirrors the multi-op live status this operator
  // publishes — online (blinking) when a QSO was logged in the last 5 min, else
  // offline. Publishing is always on for a shared MySQL logbook (no user toggle:
  // 5 min without a QSO removes the row automatically), so the badge simply
  // follows the backend.
  const [onAir, setOnAir] = useState(false);
  useEffect(() => {
    // Read the ON-AIR state straight from the backend (single source of truth:
    // liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
    // it + refresh on each logged QSO — no fragile frontend timestamp to drift.
    const refresh = () => LiveLastQSOAgeSec()
      .then((sec: number) => setOnAir(typeof sec === 'number' && sec >= 0 && sec < 300))
      .catch(() => {});
    refresh();
    const off = EventsOn('qso:logged', refresh);
    const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (single indexed row)
    return () => { off(); window.clearInterval(id); };
  }, []);
  // QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
  const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
  useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
  const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number; team10: number; team60: number }>({ last10: 0, last60: 0, team10: 0, team60: 0 });
  useEffect(() => {
    if (!showQsoRate) return;
    const load = () => { GetQSORate().then((r: any) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0, team10: r?.team_last10 ?? 0, team60: r?.team_last60 ?? 0 })).catch(() => {}); };
    load();
    // Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
    // trailing windows roll forward even when nothing new is logged.
    const off = EventsOn('qso:logged', load);
    const id = window.setInterval(load, 30 * 1000);
    return () => { off(); window.clearInterval(id); };
  }, [showQsoRate]);
  // Optional deep-link: which Preferences section to open. Cleared on
  // close so the next plain "Preferences" launch reverts to default.
  const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
  const [showDeleteAll, setShowDeleteAll] = useState(false);
  const [showAbout, setShowAbout] = useState(false);
  // "What's new": the changelog for the version(s) since the operator last ran,
  // shown once on the first launch after an update (EN/FR per the UI language).
  const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
  // Which language the What's-new modal shows — defaults to the UI language but
  // toggleable, so a French operator running the English UI can still read it.
  const [clLang, setClLang] = useState<'en' | 'fr'>(lang);
  useEffect(() => { setClLang(lang); }, [lang]);
  useEffect(() => {
    GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
  }, []);
  const [showDuplicates, setShowDuplicates] = useState(false);
  const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
  const [checkingUpdate, setCheckingUpdate] = useState(false);
  // Fresh update check on demand (opening About), so it never shows a stale
  // "you're up to date". Clears updateInfo when the latest check finds nothing.
  const checkUpdateNow = useCallback(() => {
    setCheckingUpdate(true);
    CheckForUpdate().then((u: any) => {
      setUpdateInfo(u?.available && u?.latest ? { latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') } : null);
    }).catch(() => {}).finally(() => setCheckingUpdate(false));
  }, []);
  const [updating, setUpdating] = useState(false);
  const [updateProgress, setUpdateProgress] = useState(0);
  const [updateError, setUpdateError] = useState('');
  // Check GitHub for a newer release at startup AND every 5 minutes (unless
  // disabled in General). Best effort — silent on failure.
  useEffect(() => {
    if (localStorage.getItem('opslog.checkUpdates') === '0') return;
    const check = () => CheckForUpdate().then((u: any) => {
      if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
    }).catch(() => {});
    check();
    const id = window.setInterval(check, 5 * 60 * 1000);
    return () => window.clearInterval(id);
  }, []);
  // Live download progress for the in-app updater.
  useEffect(() => {
    const off = EventsOn('update:progress', (p: any) => setUpdateProgress(Math.max(0, Math.min(100, Number(p) || 0))));
    return () => { off(); };
  }, []);
  // startUpdate downloads the new build in-app and (on success) swaps + relaunches.
  // Falls back to opening the release page when the release has no auto-download asset.
  const startUpdate = useCallback(async () => {
    if (!updateInfo) return;
    if (!updateInfo.downloadUrl) { if (updateInfo.url) BrowserOpenURL(updateInfo.url); return; }
    setUpdating(true); setUpdateProgress(0); setUpdateError('');
    try {
      await DownloadAndApplyUpdate(updateInfo.downloadUrl); // app quits + relaunches on success
    } catch (e: any) {
      setUpdateError(String(e?.message ?? e));
      setUpdating(false);
    }
  }, [updateInfo]);
  const [deletingAll, setDeletingAll] = useState(false);
  const [ctyRefreshing, setCtyRefreshing] = useState(false);
  const [refsDownloading, setRefsDownloading] = useState(false);

  // === ADIF ===
  const [importing, setImporting] = useState(false);
  const [importProgress, setImportProgress] = useState<{ processed: number; total: number } | null>(null);
  const [exporting, setExporting] = useState(false);
  // Export mode chooser: standard ADIF (other loggers) vs full (OpsLog round-trip).
  const [showExportChoice, setShowExportChoice] = useState(false);
  const [importResult, setImportResult] = useState<ImportResult | null>(null);
  const [importErrorsOpen, setImportErrorsOpen] = useState(false);
  const [importDupsOpen, setImportDupsOpen] = useState(false);
  // ADIF import confirmation: after the user picks a file, hold the path
  // until they confirm the options (skip duplicates etc.).
  const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
  const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
  const [importApplyCty, setImportApplyCty] = useState(true);
  const [importApplyStation, setImportApplyStation] = useState(false);
  // QRZ profile photo lightbox (full-size, in-app — not the browser).
  const [photoModal, setPhotoModal] = useState<string | null>(null);
  // Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
  // global ESC handler (which resets the entry) doesn't also fire.
  useEffect(() => {
    if (!photoModal) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') { e.stopImmediatePropagation(); e.preventDefault(); setPhotoModal(null); }
    };
    window.addEventListener('keydown', onKey, true);
    return () => window.removeEventListener('keydown', onKey, true);
  }, [photoModal]);

  // === Lookup + WB ===
  const [lookupResult, setLookupResult] = useState<LookupResult | null>(null);
  const [lookupBusy, setLookupBusy] = useState(false);
  const [lookupError, setLookupError] = useState('');
  const lookupTimerRef = useRef<number | null>(null);
  const wbTimerRef = useRef<number | null>(null);
  // Bumped whenever the entry is cleared (ESC) or a new call starts, so a still
  // in-flight lookup discards its result when it finally returns instead of
  // re-populating a field the operator just cleared.
  const lookupGenRef = useRef(0);
  const [wb, setWb] = useState<WB | null>(null);
  const [wbBusy, setWbBusy] = useState(false);

  // Per-award columns for the Recent QSOs / Worked-before grids: load the award
  // list once, then compute each shown QSO's reference per award and attach it
  // to the rows (the grids render one hideable column per award).
  const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
  // Bumped when award definitions change (or the backend finishes a bulk
  // award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs.
  // The per-QSO values themselves ride on the row (award_refs) and refresh with
  // the grid reload triggered alongside this bump.
  const [awardsVersion, setAwardsVersion] = useState(0);
  useEffect(() => {
    GetAwardDefs().then((defs: any[]) =>
      setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
    ).catch(() => {});
  }, [awardsVersion]);
  // Award references are now MATERIALISED on the QSO row (the award_refs JSON
  // column, written by the backend on log/edit and bulk-recomputed when awards
  // change). The grid reads them straight from the row — no per-page backend
  // recompute — so here we just parse the stored JSON string into the code→ref
  // object the award columns expect (keys are already upper-case).
  const qsosWithAwards = useMemo(
    () => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
    [qsos],
  );
  const wbWithAwards = useMemo(
    () => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null),
    [wb],
  );
  // Always-current copy of the entry callsign, so the UDP event handlers
  // (which live in a []-deps effect with a stale `callsign` closure) can
  // tell whether an incoming DX call actually changed anything.
  const callsignValRef = useRef('');
  useEffect(() => { callsignValRef.current = callsign; }, [callsign]);
  // Last callsign broadcast over UDP (WSJT-X/JTDX/MSHV/DXHunter). Lets us tell
  // "the field still shows the previous broadcast" (safe to update) from "the
  // user has typed a different call" (must not clobber).
  const lastUdpCallRef = useRef('');

  // When the entered callsign turns out to be worked-before, jump to the
  // Worked-before tab so the history is front-and-centre. Only once per call,
  // and we don't yank the user out of the Cluster / QSL-manager tabs.
  useEffect(() => {
    // Opt-out: General settings can disable this auto-jump (read live so the
    // toggle takes effect without a reload).
    if (localStorage.getItem('opslog.autofocusWB') === '0') return;
    const c = callsign.trim().toUpperCase();
    if (!c || !wb || (wb.count ?? 0) <= 0 || (wb.callsign ?? '').toUpperCase() !== c) return;
    if (lastWbFocusRef.current === c) return;
    lastWbFocusRef.current = c;
    setActiveTab((t) => (t === 'cluster' || t === 'qsl' ? t : 'worked'));
  }, [wb, callsign]);

  // === Station ===
  const [station, setStation] = useState<StationSettings>({
    callsign: '', operator: '',
    my_grid: '', my_country: '', my_sota_ref: '', my_pota_ref: '',
  });
  const [showFirstRun, setShowFirstRun] = useState(false);
  myCallRef.current = (station.callsign || '').toUpperCase();

  // Bearing/distance from operator's grid to the DX — used by the entry-strip
  // azimuth, the Info tab, and the rotor compass. Grid-to-grid when both known;
  // else fall back to the DX lat/lon (cty.dat-only entities carry no grid).
  const dxPath = useMemo(() => {
    const byGrid = pathBetween(station.my_grid, grid);
    if (byGrid) return byGrid;
    const myLL = gridToLatLon(station.my_grid);
    if (myLL && details.lat != null && details.lon != null) {
      return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
    }
    return null;
  }, [station.my_grid, grid, details.lat, details.lon]);

  // Effective antenna heading(s): the rotor azimuth, transformed by the
  // Ultrabeam pattern when one is active — reversed (180°) points opposite,
  // bidirectional radiates both ways, normal is the heading itself.
  // Headings are rounded to whole degrees: a rotor reports a jittering float, and
  // a fraction of a degree changes nothing on a compass or a beam lobe — but it
  // does invalidate every memo downstream and force the map to rebuild its whole
  // overlay on each reading.
  const rotorAz = useMemo<number | null>(() => (
    rotatorHeading.enabled && rotatorHeading.ok
      ? Math.round((((rotatorHeading.azimuth % 360) + 360) % 360)) % 360
      : null
  ), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);

  const beamHeadings = useMemo<number[]>(() => {
    if (rotorAz == null) return [];
    if (ubStatus.enabled && ubStatus.connected) {
      if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
      if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
    }
    return [rotorAz];
  }, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);

  // Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
  // show where the antenna physically points (boom) vs where it radiates when
  // the Ultrabeam is reversed/bidirectional.
  const boomHeading = rotorAz;
  const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
    if (!(ubStatus.enabled && ubStatus.connected)) return null;
    return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
  }, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);

  // Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
  const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
  const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
  const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');

  // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
  // picked award references to the QSO field/extras each award actually reads.
  const awardFieldRef = useRef<Record<string, string>>({});
  useEffect(() => {
    GetAwardDefs()
      .then((defs) => {
        const m: Record<string, string> = {};
        for (const d of (defs ?? []) as any[]) m[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
        awardFieldRef.current = m;
      })
      .catch(() => {});
  }, []);

  // === Clock ===
  const [utcNow, setUtcNow] = useState('');
  useEffect(() => {
    function tick() {
      const d = new Date();
      const p = (n: number) => String(n).padStart(2, '0');
      setUtcNow(`${d.getUTCFullYear()}-${p(d.getUTCMonth()+1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`);
    }
    tick();
    const id = window.setInterval(tick, 1000);
    return () => window.clearInterval(id);
  }, []);

  // The full filter sent to the backend = the builder conditions + the quick
  // callsign search box (always ANDed) + the on-screen row threshold.
  const buildActiveFilter = useCallback((): QueryFilter => ({
    quick_callsign: filterCallsign,
    conditions: activeFilter.conditions ?? [],
    match: activeFilter.match ?? 'AND',
    limit: qsoLimit,
    offset: 0,
  }), [filterCallsign, activeFilter, qsoLimit]);

  // refresh reloads the grid. Returns false on failure. When silent, it doesn't
  // surface the error — used by the startup retry, since the logbook DB (a remote
  // MySQL especially) can take a few seconds to connect while the UI is already
  // mounted, and we don't want to flash "db not available" during that window.
  const refreshSeqRef = useRef(0); // guards against an older refresh clobbering a newer one's data
  const refresh = useCallback(async (silent = false): Promise<boolean> => {
    // Monotonic guard: two refreshes can be in flight at once (e.g. the immediate
    // one after a UDP auto-log, which reads the QSO at "R", and the debounced one
    // after extsvc:uploaded, which reads it at "Y"). MySQL query latency can make
    // the OLDER one resolve LAST and clobber the newer data — the Club Log status
    // flicking R→Y→R. Only the most-recently-issued refresh is allowed to apply.
    const seq = ++refreshSeqRef.current;
    try {
      const f = buildActiveFilter();
      const list = await ListQSOFiltered(f as any);
      const n = await CountQSO();
      const hasFilter = !!(f.quick_callsign || (f.conditions && f.conditions.length));
      const matched = hasFilter ? await CountQSOFiltered(f as any) : n;
      if (seq !== refreshSeqRef.current) return true; // a newer refresh superseded us — drop this stale result
      setQsos(list);
      setTotal(n);
      setMatchCount(matched);
      setError('');
      return true;
    } catch (e: any) {
      if (!silent && seq === refreshSeqRef.current) setError(String(e?.message ?? e));
      return false;
    }
  }, [buildActiveFilter]);

  // Refresh the Recent QSOs grid after external-service uploads stamp the
  // sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
  // qslmgr:done). Debounced so a batch of per-QSO events triggers one reload.
  useEffect(() => {
    let t: number | undefined;
    const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
    const offUploaded = EventsOn('extsvc:uploaded', ping);
    const offDone = EventsOn('qslmgr:done', ping);
    const offEqsl = EventsOn('qsl:sent', ping);
    return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
  }, [refresh]);

  // The backend bulk-recomputed the materialised award_refs (an award definition
  // or reference list changed, or the one-time backfill ran). Reload the rows so
  // the award columns show the new values, and bump awardsVersion so the set of
  // award COLUMNS refreshes too (a new award may have appeared).
  useEffect(() => {
    const off = EventsOn('awards:recomputed', () => { refresh(); setAwardsVersion((v) => v + 1); });
    return () => { off(); };
  }, [refresh]);

  // Backend-emitted toast messages (e.g. recording auto-send result/skip).
  useEffect(() => {
    const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
    return () => { off(); };
  }, [showToast]);

  // DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no
  // asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions.
  useEffect(() => {
    const off = EventsOn('alert:fired', (p: any) => {
      if (p?.sound) {
        try {
          const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
          const ctx = new AC();
          const beep = (freq: number, at: number, dur: number) => {
            const o = ctx.createOscillator(); const g = ctx.createGain();
            o.type = 'sine'; o.frequency.value = freq;
            o.connect(g); g.connect(ctx.destination);
            g.gain.setValueAtTime(0.0001, ctx.currentTime + at);
            g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.01);
            g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + dur);
            o.start(ctx.currentTime + at); o.stop(ctx.currentTime + at + dur + 0.02);
          };
          beep(880, 0, 0.14); beep(1320, 0.16, 0.18); // two-tone chirp
          window.setTimeout(() => ctx.close().catch(() => {}), 600);
        } catch { /* audio blocked — ignore */ }
      }
      if (p?.visual) {
        const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
        showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
        // Also keep it in the persistent tray (clickable, ~10 min).
        const a: RecentAlert = {
          id: Date.now() + Math.random(), rule, call, band,
          mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0),
          country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(),
        };
        setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3));
      }
    });
    return () => { off(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [showToast]);

  // Poll PstRotator for the live antenna heading (status bar). Cheap when the
  // rotator is disabled (the backend just reads settings and returns).
  useEffect(() => {
    let alive = true;
    const tick = async () => {
      try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
    };
    tick();
    const id = window.setInterval(tick, 3000);
    return () => { alive = false; window.clearInterval(id); };
  }, []);

  // Poll the Ultrabeam antenna for its connection + pattern direction.
  useEffect(() => {
    let alive = true;
    const tick = async () => {
      try { const s: any = await GetUltrabeamStatus(); if (alive) setUbStatus(s); } catch {}
    };
    tick();
    const id = window.setInterval(tick, 3000);
    return () => { alive = false; window.clearInterval(id); };
  }, []);

  // Poll the Antenna Genius switch for active antenna per port + the list.
  // Re-read the enabled flag each tick so toggling it in Settings makes the
  // top-bar icon appear/disappear without an app restart.
  useEffect(() => {
    let alive = true;
    const tick = async () => {
      try { const en: any = await GetAntGeniusSettings(); if (alive) setAgEnabled(!!en?.enabled); } catch {}
      try {
        const s = (await GetAntGeniusStatus()) as AGStatus;
        if (!alive || !s) return;
        const now = Date.now();
        const pend = agPending.current;
        // Keep an optimistic selection until the device confirms it or it ages out.
        if (pend.a) { if (now > pend.a.t || s.port_a === pend.a.v) delete pend.a; else s.port_a = pend.a.v; }
        if (pend.b) { if (now > pend.b.t || s.port_b === pend.b.v) delete pend.b; else s.port_b = pend.b.v; }
        // Only update when something actually changed — avoids re-rendering the
        // widget every 1.5s (which made buttons flicker on hover).
        setAgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
      } catch {}
    };
    tick();
    const id = window.setInterval(tick, 1500);
    return () => { alive = false; window.clearInterval(id); };
  }, []);
  const agActivate = (port: number, antenna: number) => {
    // Optimistic: reflect the change immediately and pin it for ~3s so the next
    // poll (which may still carry the old cached value) can't revert it.
    agPending.current[port === 1 ? 'a' : 'b'] = { v: antenna, t: Date.now() + 3000 };
    setAgStatus((s) => ({ ...s, ...(port === 1 ? { port_a: antenna } : { port_b: antenna }) }));
    AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
  };

  // RX band auto-follows the TX band (only differs for cross-band work).
  useEffect(() => { setBandRx(band); }, [band]);

  // Logbook connection label for the status bar (MySQL host:port/db, or the
  // local SQLite file path).
  useEffect(() => { GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {}); }, []);

  // The logbook can switch at runtime when the active profile changes (each
  // profile can target its own SQLite/MySQL database). Refresh the grid and the
  // status-bar label when that happens.
  useEffect(() => {
    const off = EventsOn('logbook:changed', () => {
      GetDBConnectionInfo().then((i) => setDbConn(i as any)).catch(() => {});
      refresh();
    });
    return () => { off(); };
  }, [refresh]);

  // Live sync for a SHARED MySQL logbook: poll a cheap revision fingerprint so
  // QSOs another operator's instance adds/removes show up here within a few
  // seconds, without a manual refresh. Pointless on local SQLite (single writer).
  useEffect(() => {
    if (dbConn?.backend !== 'mysql') return;
    let alive = true;
    let last = '';
    const tick = async () => {
      try {
        const rev = await GetLogbookRevision();
        if (alive && last && rev !== last) await refresh();
        if (alive) last = rev;
      } catch { /* logbook briefly unavailable — try again next tick */ }
    };
    tick();
    const id = window.setInterval(tick, 2000);
    return () => { alive = false; window.clearInterval(id); };
  }, [dbConn, refresh]);

  // RX freq mirrors TX freq on every TX change (unless the rig is in split,
  // where the RX freq is genuinely different). It stays editable by hand:
  // a manual RX edit sticks until the next TX-freq change re-syncs it.
  useEffect(() => {
    if (!catState.split) setRxFreqMhz(freqMhz);
  }, [freqMhz, catState.split]);

  // Load the DXCC country list for the Country picker. cty.dat loads a few
  // seconds after startup, so retry until it's available.
  useEffect(() => {
    let tries = 0;
    let timer = 0;
    const load = async () => {
      try {
        const c = await ListCountries();
        if (c && c.length) { setCountries(c); return; }
      } catch {}
      if (tries++ < 15) timer = window.setTimeout(load, 2000);
    };
    load();
    return () => { if (timer) window.clearTimeout(timer); };
  }, []);

  const loadStation = useCallback(async () => {
    try { setStation(await GetStationSettings()); } catch {}
  }, []);
  const loadCATCfg = useCallback(async () => {
    try {
      const c = await GetCATSettings();
      if (c.digital_default) digitalDefaultRef.current = c.digital_default;
      setCatBackend(c.backend ?? '');
    } catch {}
  }, []);
  const loadLists = useCallback(async () => {
    try {
      const l: ListsSettings = await GetListsSettings();
      setRstLists({ phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] });
      if (l.bands && l.bands.length) setBands(l.bands);
      if (l.modes && l.modes.length) {
        setModePresets(l.modes);
        const names = l.modes.map((m) => m.name);
        setModes(names);
        setMode((cur) => names.includes(cur) ? cur : names[0]);
        const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0];
        if (preset && !rstUserEditedRef.current) {
          if (preset.default_rst_sent) setRstSent(preset.default_rst_sent);
          if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd);
        }
      }
    } catch {}
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // tuneRigCAT sends freq + mode to the rig, sequenced with a short settle
  // delay so an older transceiver — busy after a band/freq change — doesn't drop
  // the second command (which forced a second click to apply the mode). The
  // "mode before frequency" order (à la Log4OM) is an option for rigs that need
  // the mode set first; default is frequency-then-mode.
  async function tuneRigCAT(freqHz: number, mode: string) {
    const modeFirst = localStorage.getItem('opslog.catModeBeforeFreq') === '1';
    const doFreq = () => SetCATFrequency(freqHz).catch(() => {});
    const doMode = () => (mode ? SetCATMode(mode).catch(() => {}) : Promise.resolve());
    const settle = () => new Promise((r) => window.setTimeout(r, 150));
    if (modeFirst) {
      await doMode(); await settle(); await doFreq();
    } else {
      await doFreq();
      if (mode) { await settle(); await doMode(); }
    }
  }

  // applyModeFromSpot updates the mode AND its RST default for a fresh target
  // (clicked spot / rig-driven mode change). Unlike a manual mode tweak, this
  // is a new contact, so we clear the "user edited RST" flag first — otherwise
  // a 599 left from a CW QSO would stick when jumping to an SSB spot.
  function applyModeFromSpot(m: string) {
    if (!m) return;
    setMode(m);
    rstUserEditedRef.current = false;
    applyModePreset(m);
  }
  function applyModePreset(m: string) {
    if (rstUserEditedRef.current) return;
    // Prefer the user's configured preset RST; otherwise fall back to the mode
    // category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
    // so switching SSB→CW flips 59→599 even without a configured preset.
    const p = modePresets.find((x) => x.name === m);
    const fallback = rstOptions(m, rstLists)[0] || '';
    setRstSent(p?.default_rst_sent || fallback);
    setRstRcvd(p?.default_rst_rcvd || fallback);
  }
  // Clicking a spot (cluster grid or any band map): tune the rig, set the mode,
  // fill the call, pre-fill POTA, (re)start the recording. Shared so every spot
  // source behaves identically.
  function handleSpotClick(s: any) {
    const m = inferSpotMode(s.comment ?? '', s.freq_hz);
    // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
    // then tune the rig if CAT is connected. We must not wait for the CAT status
    // echo to update the display: on a remote Flex link that echo lags by seconds,
    // so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
    // freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
    // revert the optimistic value before the new freq is reported back.
    if (s.freq_hz && s.freq_hz > 0) {
      const mhz = (s.freq_hz / 1_000_000).toFixed(5);
      setFreqMhz(mhz);
      setRxFreqMhz(mhz);
      noteManualEdit();
    }
    if (s.band) setBand(s.band);
    if (catState.connected) tuneRigCAT(s.freq_hz, m);
    if (m) applyModeFromSpot(m);
    onCallsignInput(s.dx_call, { force: true });
    applySpotPOTA((s as any).pota_ref);
    if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
  }

  // Initial grid load. The logbook (remote MySQL) may still be connecting while
  // the UI is mounted, so retry quietly until the first load succeeds rather
  // than leaving the grid empty until the next manual refresh.
  useEffect(() => {
    let alive = true;
    let tries = 0;
    let timer = 0;
    const attempt = async () => {
      if (!alive) return;
      const ok = await refresh(true);
      if (ok && alive) {
        // The logbook is now connected — refresh the status-bar label too, in
        // case its one-shot fetch ran during the startup race (before the
        // backend was determined) and grabbed the wrong/stale value.
        GetDBConnectionInfo().then((i) => { if (alive) setDbConn(i as any); }).catch(() => {});
      } else if (!ok && alive && tries++ < 360) {
        // Quick retries at first (normal startup connects in ~2 s); then keep
        // trying for several minutes, because the very first migration against a
        // slow remote MySQL can legitimately take that long before the logbook
        // is ready. Stays silent so no "db not available" flashes meanwhile.
        timer = window.setTimeout(attempt, tries < 20 ? 500 : 1000);
      } else if (!ok && alive) {
        refresh(); // give up quietly retrying; surface the error now
      }
    };
    attempt();
    return () => { alive = false; if (timer) window.clearTimeout(timer); };
  }, [refresh]);
  useEffect(() => {
    (async () => {
      try {
        const st = await GetStartupStatus();
        if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
        // First launch (or a never-configured profile): collect the mandatory
        // station identity before anything else.
        try {
          const ss = await GetStationSettings();
          if (!ss.callsign?.trim()) setShowFirstRun(true);
        } catch {}
      } catch {}
      // Prompt to unlock encrypted passwords if a passphrase is configured.
      try {
        const ss: any = await GetSecretStatus();
        if (ss?.has_passphrase && !ss?.unlocked) setUnlockOpen(true);
      } catch {}
      loadStation();
      loadLists();
      loadCATCfg();
    })();
    // Poll the CAT state at launch until the rig reports a frequency: the
    // backend connects asynchronously and only PUSHES cat:state on change, so
    // the first (freq-carrying) event can fire before this listener mounts and
    // be missed — leaving the display blank until the operator next moves the
    // VFO. Stops once a freq arrives, CAT is off, or after ~13s.
    (async () => {
      for (let i = 0; i < 16; i++) {
        try {
          const s = await GetCATState();
          applyCatState(s);
          if (!s?.enabled) break;
          if (s?.connected && s.freq_hz && s.freq_hz > 0) break;
        } catch { /* not ready yet */ }
        await new Promise((r) => window.setTimeout(r, 800));
      }
    })();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
  // just typed something (freeze window) or locked a field. Shared by the live
  // cat:state event and the startup poll below.
  function applyCatState(s: CATState) {
    setCatState(s);
    if (!s?.connected) return;
    if (Date.now() < catFreezeUntilRef.current) return;
    const lk = locksRef.current;
    if (!lk.freq && s.freq_hz && s.freq_hz > 0) {
      setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
    }
    // RX freq: backend follows ADIF — freq_hz = TX, freq_rx_hz = RX.
    if (!lk.freq) {
      if (s.split && s.freq_rx_hz && s.freq_rx_hz > 0) {
        setRxFreqMhz((s.freq_rx_hz / 1_000_000).toFixed(5));
      } else if (s.freq_hz && s.freq_hz > 0) {
        setRxFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
      }
    }
    if (!lk.band && s.band) setBand(s.band);

    // Mode resolution.
    //   FlexRadio reports the exact mode for voice/CW (USB/LSB/CW…) → trust it.
    //   But all digital sub-modes share one radio mode (DIGU/DIGL → "DATA"), so
    //   when it's digital we still pick FT8/FT4/RTTY from the frequency's
    //   watering hole (e.g. 14.080 → FT4), else the operator's default.
    //   OmniRig & other rigs often can't tell digital from SSB on a digital
    //   freq, so for them we infer from the frequency regardless of reported mode.
    if (!lk.mode) {
      let nextMode = '';
      if (s.backend === 'flex') {
        if (s.mode === 'DATA') {
          nextMode = (s.freq_hz ? inferDigitalMode(s.freq_hz) : '') || digitalDefaultRef.current || 'FT8';
        } else if (s.mode) {
          nextMode = s.mode;
        }
      } else {
        const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
        if (inferred) nextMode = inferred;
        else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
        else if (s.mode) nextMode = s.mode;
      }
      if (nextMode) {
        setMode(nextMode);
        applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
      }
    }
  }

  // CAT live updates. Push freq/band/mode into the entry strip when the rig
  // moves, unless the user just typed something (1.5s grace window).
  useEffect(() => {
    const unsub = EventsOn('cat:state', (s: CATState) => applyCatState(s));
    return () => { unsub?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig
  // QSY, spot click, manual band) — so the right antennas follow the frequency.
  // Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex
  // backends. The backend no-ops if the band has no configured mapping.
  const lastAntBandRef = useRef('');
  useEffect(() => {
    if (catState.backend !== 'flex' || locks.band) return;
    const b = band.trim();
    if (!b || b === lastAntBandRef.current) return;
    lastAntBandRef.current = b;
    FlexApplyBandAntenna(b).catch(() => {});
  }, [band, catState.backend, locks.band]);

  // Cluster live wiring: hydrate per-server status + saved server list,
  // then subscribe to push events.
  async function reloadClusterMeta() {
    try {
      const [st, list] = await Promise.all([GetClusterStatus(), ListClusterServers()]);
      setClusterServerStatuses((st ?? []) as ServerStatus[]);
      setClusterServers(((list ?? []) as any[]).map((s) => ({
        id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
      })));
    } catch {}
  }
  useEffect(() => {
    reloadClusterMeta();
    // cluster:state fires on connect/disconnect/save/delete — refresh
    // the saved-server list too so the source dropdown stays in sync
    // when the user adds, deletes or toggles a row in Settings.
    const unsubState = EventsOn('cluster:state', async (sts: ServerStatus[]) => {
      setClusterServerStatuses(sts ?? []);
      try {
        const list = await ListClusterServers();
        setClusterServers(((list ?? []) as any[]).map((s) => ({
          id: s.id as number, name: s.name, enabled: !!s.enabled, sort_order: s.sort_order ?? 0,
        })));
      } catch {}
      // Drop any buffered spots whose source server is no longer in the
      // status list (it was disconnected / deleted). Without this the
      // table keeps showing stale rows from a server the user just
      // turned off.
      const activeIds = new Set((sts ?? []).map((s) => s.server_id));
      setSpots((arr) => arr.filter((sp) => activeIds.has(sp.source_id)));
    });
    // Commit the staged spots: resolve the status for any slot we don't know yet
    // FIRST, then insert the rows — so they appear with the right badge already
    // painted instead of flashing plain text then flipping to a pill.
    const flushPendingSpots = async () => {
      pendingSpotTimer.current = undefined;
      const batch = pendingSpotsRef.current;
      pendingSpotsRef.current = [];
      if (batch.length === 0) return;
      // Resolve unknown statuses before the rows go in.
      try {
        const known = spotStatusRef.current;
        const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
        const seen = new Set<string>();
        for (const s of batch) {
          const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
          if (seen.has(k) || known[k]) continue;
          seen.add(k);
          unknown.push({
            call: s.dx_call, band: s.band ?? '',
            mode: inferSpotMode(s.comment ?? '', s.freq_hz),
            pota_ref: (s as any).pota_ref ?? '',
          });
        }
        if (unknown.length > 0) {
          const res = await ClusterSpotStatuses(unknown as any);
          setSpotStatus((prev) => {
            const next = { ...prev };
            for (const r of res) {
              const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
              next[k] = {
                status: r.status ?? '',
                country: r.country,
                continent: (r as any).continent,
                worked_call: !!(r as any).worked_call,
                new_county: !!(r as any).new_county,
                new_pota: !!(r as any).new_pota,
              };
            }
            return next;
          });
        }
      } catch {}
      // Now insert the staged rows. Same de-dupe as before: a station re-spotted
      // (RBN skimmers on a CQ, several nodes relaying) shows as ONE live row that
      // the freshest spot REPLACES and floats to the top. Historical (SH/DX
      // replay) rows are left alone.
      setSpots((arr) => {
        const key = (x: ClusterSpot) => `${(x.dx_call ?? '').toUpperCase()}|${(x.band ?? '').toLowerCase()}`;
        const hist = (x: ClusterSpot) => !!(x as any).historical;
        let next = arr;
        for (const sp of batch) {
          const k = key(sp);
          const filtered = hist(sp) ? next : next.filter((x) => hist(x) || key(x) !== k);
          next = [sp, ...filtered];
        }
        return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
      });
    };
    const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
      // Stage the spot; a short timer resolves its status then commits it.
      pendingSpotsRef.current.push(sp);
      // 50 ms is enough to coalesce an RBN burst into one status lookup (the
      // worked-index is in memory, so resolving is near-instant) while staying
      // imperceptible.
      if (pendingSpotTimer.current === undefined) {
        pendingSpotTimer.current = window.setTimeout(flushPendingSpots, 50);
      }
      // Self-spot: someone spotted OUR callsign — show it in the shared header
      // toast (same place as the other notifications), not a separate banner.
      // Fired immediately (not staged) so the notification never lags the spot.
      const mine = myCallRef.current;
      if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
        const by = cleanSpotter(sp.spotter ?? '') || '?';
        const c = (sp.comment ?? '').trim();
        const bnd = (sp.band ?? '').trim();
        showToast(`Spotted by ${by}${bnd ? ` on ${bnd}` : ''}${c ? ` with ${c}` : ''}`);
      }
    });
    return () => {
      unsubState?.(); unsubSpot?.();
      if (pendingSpotTimer.current !== undefined) window.clearTimeout(pendingSpotTimer.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ── UDP integration events ───────────────────────────────────────────
  // Live updates from external apps (WSJT-X / JTDX / MSHV / DXHunter…).
  // We push the broadcast DX call into the entry field and auto-log any
  // ADIF record that arrives.
  useEffect(() => {
    // Apply a UDP-broadcast callsign, but never clobber what the operator is
    // typing: only update when the field is empty, already shows this call, or
    // still shows the previous broadcast (i.e. the field content is ours, not
    // a different call the user typed). Returns true if it actually changed.
    // force = true means an EXPLICIT action (a panadapter/spot click, a remote
    // "set call" command) that should always win, even over a call the operator
    // already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
    // guard (it re-broadcasts constantly and must not fight what you typed).
    const applyUdpCall = (raw: string, force = false): boolean => {
      const call = String(raw ?? '').trim();
      if (!call) return false;
      const upper = call.toUpperCase();
      const current = callsignValRef.current.trim().toUpperCase();
      const prev = lastUdpCallRef.current;
      lastUdpCallRef.current = upper; // remember this broadcast either way
      if (current === upper) return false;                              // already shown → no-op
      if (!force && current !== '' && current !== prev) return false;   // user typed a different call → leave it
      onCallsignInput(call, { force: true }); // programmatic → always look up
      return true;
    };
    const unsubDX = EventsOn('udp:dx_call', (p: any) => {
      // Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
      // over UDP…) is an explicit pick → force it over an existing call.
      const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
      // External app moved to a new station → fresh recording for the new target.
      if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
    });
    const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
      if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
    });
    // The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
    // Only when something is actually in the entry, so an idle digital app doesn't
    // wipe a call being typed by hand.
    const unsubClear = EventsOn('udp:clear_call', () => {
      if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
    });
    // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
    // (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
    // An explicit click always wins over whatever call is currently in the field.
    const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
      const call = String(p?.call ?? '');
      if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
    });
    const unsubProg = EventsOn('import:progress', (p: any) => {
      setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
    });
    const unsubLog = EventsOn('udp:logged_qso', async (p: any) => {
      const text = String(p?.adif ?? '').trim();
      if (!text) return;
      try {
        await LogUDPLoggedADIF(text);
        await refresh();
      } catch (e: any) {
        const msg = String(e?.message ?? e);
        // A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
        // show a quiet toast, not a red error.
        if (/duplicate/i.test(msg)) showToast('UDP QSO already logged — skipped');
        else setError('UDP auto-log: ' + msg);
      }
    });
    // ADIF monitor imported new QSOs (backend file watcher) → refresh the grid
    // and show how many, from which file.
    const unsubAdifMon = EventsOn('adifmon:imported', async (p: any) => {
      const n = Number(p?.count ?? 0);
      if (n <= 0) return;
      await refresh();
      const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
      showToast(`ADIF: ${n} QSO imported${file ? ` from ${file}` : ''}`);
    });
    return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); unsubAdifMon?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ── WinKeyer wiring ───────────────────────────────────────────────────
  const reloadWkPorts = useCallback(() => {
    ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {});
  }, []);
  const reloadWk = useCallback(async () => {
    try {
      const s: any = await GetWinkeyerSettings();
      setWkEnabled(!!s.enabled);
      setWkPort(s.port ?? '');
      setWkWpm(s.wpm ?? 25);
      setWkMacros((s.macros ?? []) as WKMacro[]);
      setWkEscClears(s.esc_clears_call !== false);
      setWkSendOnType(!!s.send_on_type);
      setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
    } catch { /* keyer not configured */ }
  }, []);

  // Active profile id — scopes the grids' column layout (visibility / width /
  // order) so each profile keeps its own. Seeded once on mount and updated on
  // every profile switch; the grids take it as their React key so they remount
  // and re-read the now-correct per-profile layout.
  const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
  useEffect(() => {
    GetActiveProfile().then((p: any) => {
      if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
    }).catch(() => {});
  }, []);

  // Every setting is per-profile, so when the active profile changes the whole
  // main UI re-reads its config (station identity, lists, CAT, keyer). The Go
  // side reloads its managers; this keeps the React state in sync.
  useEffect(() => {
    const off = EventsOn('profile:changed', (id: any) => {
      // Re-scope the grid column layout BEFORE the grids remount (key change).
      setGridPrefsProfile(id ?? null);
      setActiveProfileId(typeof id === 'number' ? id : null);
      loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
      // The chat is per shared logbook — clear the previous profile's messages
      // and reload for the new logbook (or hide if it isn't a MySQL log).
      setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
      ChatAvailable().then((v: any) => setChatAvailable(!!v)).catch(() => setChatAvailable(false));
      setChatEpoch((e) => e + 1);
    });
    return () => { off(); };
  }, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
  useEffect(() => {
    (async () => {
      await reloadWk();
      const st: any = await GetWinkeyerStatus().catch(() => null);
      if (st) setWkStatus(st as WKStatus);
      reloadWkPorts();
    })();
    const unsub = EventsOn('winkeyer:status', (st: WKStatus) => setWkStatus(st));
    // Append each echoed char as the keyer transmits it; keep a rolling tail.
    const unsubEcho = EventsOn('winkeyer:echo', (ch: string) => setWkSent((s) => (s + ch).slice(-160)));
    return () => { unsub?.(); unsubEcho?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  // Keep a live ref to wkSendMacro so the global key handler calls the latest.
  const wkSendMacroRef = useRef<(i: number) => void>(() => {});

  // Persist a single WinKeyer field then save (used by the panel's port etc.).
  async function saveWk(patch: Record<string, any>) {
    try {
      const cur: any = await GetWinkeyerSettings();
      await SaveWinkeyerSettings({ ...cur, ...patch });
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  function wkSetEnabled(on: boolean) {
    setWkEnabled(on);
    saveWk({ enabled: on });
    if (!on) WinkeyerDisconnect().catch(() => {});
  }
  function wkSelectPort(p: string) { setWkPort(p); saveWk({ port: p }); }

  // Resolve macro / CW-text variables from the current entry + active profile.
  function resolveCW(text: string): string {
    const myCall = (station.callsign || '').toUpperCase();
    const his = callsign.trim().toUpperCase();
    const cut = (s: string) => (s || '').replace(/0/g, 'T').replace(/9/g, 'N'); // cut numbers
    const vars: Record<string, string> = {
      MY_CALL: myCall, CALL: his,
      STX: cut(rstSent), STRX: cut(rstRcvd), RST_R: cut(rstRcvd),
      STXF: rstSent, STRXF: rstRcvd,
      NAME: station.operator || '', MY_NAME: station.operator || '', MY_OPCALL: station.operator || myCall,
      HIS_NAME: name || '',
      GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
      MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
      MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
      CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '',
    };
    let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
    out = out.replace(/\*/g, myCall).replace(/!/g, his);
    // <n> (2..9): repeat the whole resolved string n times.
    const rep = out.match(/<([2-9])>/);
    if (rep) {
      const n = parseInt(rep[1], 10);
      const base = out.replace(/<[2-9]>/g, '').replace(/\s+/g, ' ').trim();
      out = Array(n).fill(base).join('  ');
    }
    return out.replace(/\s+/g, ' ').trim();
  }
  async function wkSend(rawText: string) {
    const gen = ++wkSendGenRef.current; // this send supersedes any previous; abort bumps it too
    setWkSent('');
    const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
    const aborted = () => gen !== wkSendGenRef.current;
    // Cancellable wait: sleeps up to ms, but bails out immediately on abort.
    const waitMs = async (ms: number) => {
      const end = Date.now() + ms;
      while (Date.now() < end && !aborted()) await sleep(50);
    };
    // Split the macro on <LOGQSO> so the log happens AT the token's position, not
    // only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
    // Each split boundary (every part except the last) is one <LOGQSO>.
    // RESOLVE every segment up front — while the form still holds the callsign — so a
    // segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
    // variables correctly.
    const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
    const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
    for (let p = 0; p < parts.length; p++) {
      if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
      const resolved = parts[p];
      const isLast = p === parts.length - 1;
      const logAfter = !isLast; // a <LOGQSO> follows this segment
      if (resolved) {
        // Trailing word space so back-to-back sends don't run together in the keyer
        // buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
        // current WPM, so it scales automatically.
        const keyed = resolved + ' ';
        setWkSent(resolved);
        const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : null;
        if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
        else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
        // Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
        // send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
        // on: the keyer has already buffered the CW, so logging happens AT ONCE and
        // never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
        // made <LOGQSO> log up to ~10s late when the estimate ran long.)
        if (isLast) {
          if (isRig) {
            await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
          } else {
            // WinKeyer: watch the busy echo, capped by the estimate (+slack) since
            // over a remote/serial-over-IP link that echo can lag tens of seconds.
            const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
            for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
            const deadline = Date.now() + capMs;
            while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
          }
        }
      }
      if (aborted()) return; // aborted while this segment was sending → don't log
      if (logAfter) void save(); // log NOW — the buffered CW keeps sending
    }
  }
  // stopAutoCall cancels any running auto-call loop.
  function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
  // stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine
  // (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going).
  function stopKeyerTx() {
    wkSendGenRef.current++; // cancel any in-flight macro send (and its pending <LOGQSO> log)
    if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
    else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
    else WinkeyerStop().catch(() => {});
  }
  // runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
  // gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
  async function runAutoCall(i: number) {
    const gen = ++autoCallGenRef.current;
    autoCallMacroRef.current = i;
    const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
    while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
      const m = wkMacros[i];
      if (!m) break;
      // wkSend now WAITS for the CW to finish sending on its own (per-segment, all
      // engines), so we must NOT re-wait the estimated duration here — doing both
      // double-counted the send time and made the gap between calls far longer than
      // configured (a 4 s gap became ~13 s). Just wait the configured gap.
      await wkSend(m.text);
      if (gen !== autoCallGenRef.current) break;
      await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
    }
  }
  function wkSendMacro(i: number) {
    const m = wkMacros[i];
    if (!m) return;
    // Auto-call only loops CQ-type macros. Sending any other macro (e.g. a
    // report once someone answers) sends ONCE and cancels a running loop —
    // otherwise a report would keep repeating.
    const isCQ = (m.text || '').toUpperCase().includes('CQ');
    if (wkAutoCallRef.current && isCQ) {
      runAutoCall(i); // loop this CQ until a reply is sent / Stop / ESC
    } else {
      stopAutoCall();
      wkSend(m.text);
    }
  }
  wkSendMacroRef.current = wkSendMacro;
  function wkToggleAutoCall(on: boolean) {
    setWkAutoCall(on);
    writeUiPref('opslog.wkAutoCall', on ? '1' : '0');
    if (!on) stopAutoCall();
  }
  function wkSetAutoCallSecs(n: number) {
    const v = Math.max(0, Math.min(120, n || 0));
    setWkAutoCallSecs(v);
    writeUiPref('opslog.wkAutoCallSecs', String(v));
  }
  // send-on-type: key the typed chars verbatim (no variable substitution).
  function wkSendRaw(chars: string) {
    if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
    WinkeyerSend(chars).catch(() => {});
  }
  function wkBackspace() {
    if (cwSourceRef.current === 'flex') { FlexBackspaceCW(1).catch(() => {}); return; }
    WinkeyerBackspace().catch(() => {});
  }
  function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }

  // Resolve slot status for any spot we haven't seen yet — debounced so we
  // don't hammer the backend at firehose rate. The mode passed to the
  // backend is derived from the comment (CW / FT8 / FT4 / SSB...) and the
  // band-plan fallback, NOT just digital watering-hole detection — that's
  // how CW spots get correctly classified instead of being labelled
  // "new-slot" because the lookup key carried mode="".
  useEffect(() => {
    const t = window.setTimeout(async () => {
      const unknown: { call: string; band: string; mode: string; pota_ref: string }[] = [];
      const seen = new Set<string>();
      for (const s of spots) {
        const mode = inferSpotMode(s.comment ?? '', s.freq_hz);
        const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
        if (seen.has(k) || spotStatus[k]) continue;
        seen.add(k);
        unknown.push({ call: s.dx_call, band: s.band ?? '', mode, pota_ref: (s as any).pota_ref ?? '' });
      }
      if (unknown.length === 0) return;
      try {
        const res = await ClusterSpotStatuses(unknown as any);
        setSpotStatus((prev) => {
          const next = { ...prev };
          for (const r of res) {
            const k = `${r.call}|${r.band ?? ''}|${(r.mode ?? '').toUpperCase()}`;
            next[k] = {
              status: r.status ?? '',
              country: r.country,
              continent: (r as any).continent,
              worked_call: !!(r as any).worked_call,
              new_county: !!(r as any).new_county,
              new_pota: !!(r as any).new_pota,
            };
          }
          return next;
        });
      } catch {}
    }, 400);
    return () => window.clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [spots]);

  async function save() {
    if (savingRef.current) return; // a log is already in flight — ignore the repeat
    if (!callsign.trim()) { setError('Callsign required'); return; }
    savingRef.current = true;
    setSaving(true); setError('');
    try {
      const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
      const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined;
      const now = new Date();
      const baseStart = qsoStartedAt ?? now;
      // End: explicit when locked; for a deferred back-entry (start locked) it
      // defaults to the back-dated start so the QSO doesn't span to today;
      // otherwise it's now.
      const end = (locks.end && qsoEndedAt) ? qsoEndedAt : (locks.start ? baseStart : now);
      // Option: log TIME_ON = TIME_OFF (the moment the QSO completes). Useful
      // when you call a station for a long time — otherwise TIME_ON is frozen at
      // when you first entered the call (minutes early) and won't match LoTW.
      const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
      const start = (startEqualsEnd && !locks.start) ? end : baseStart;
      // Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
      // alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
      const srxE = exchangeFields(details.srx_string);
      const stxE = exchangeFields(details.stx_string);
      const payload: any = {
        callsign: callsign.trim().toUpperCase(),
        qso_date: start.toISOString(),
        qso_date_off: end.toISOString(),
        band, band_rx: bandRx, mode, freq_hz: freqHz, freq_rx_hz: rxFreqHz,
        rst_sent: rstSent, rst_rcvd: rstRcvd,
        grid: grid.trim().toUpperCase(),
        name, qth, country, comment, notes: note,
        state: details.state, cnty: details.cnty, address: details.address,
        lat: details.lat, lon: details.lon,
        dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz, cont: details.cont || undefined,
        qsl_msg: details.qsl_msg, qsl_via: details.qsl_via,
        ant_az: details.ant_az, ant_el: details.ant_el, ant_path: details.ant_path,
        prop_mode: details.prop_mode,
        my_rig: details.my_rig, my_antenna: details.my_antenna,
        tx_pwr: details.tx_pwr,
        sat_name: details.sat_name, sat_mode: details.sat_mode,
        contest_id: details.contest_id,
        srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
        email: details.email,
      };
      applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
      // Contest mode overrides the exchange fields: CONTEST_ID, the sent
      // exchange (running serial or fixed value) and the received exchange.
      if (contest.active && contest.code) {
        const sent = contest.exchange === 'serial' ? String(contest.nextSerial) : contest.sentFixed;
        const sE = exchangeFields(sent);
        const rE = exchangeFields(rcv.trim());
        Object.assign(payload, {
          contest_id: contest.code,
          stx: sE.num, stx_string: sE.str,
          srx: rE.num, srx_string: rE.str,
        });
      }
      // id = -1 means the database was unreachable and the QSO was parked in the
      // offline outbox — it is SAVED (on disk), just not in the logbook yet. Treat
      // it as a success so logging never stops; the banner tells the operator.
      const newId = await AddQSO(payload);
      if (typeof newId === 'number' && newId < 0) {
        showToast(t('offline.queued'));
      }
      // Advance the sent serial only after a successful log.
      if (contest.active && contest.code && contest.exchange === 'serial') {
        updateContest({ nextSerial: contest.nextSerial + 1 });
      }
      resetEntry(); // clears the call AND the Worked-before matrix
      callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
      await refresh();
    } catch (e: any) {
      setError(String(e?.message ?? e));
    } finally { setSaving(false); savingRef.current = false; }
  }

  // resetEntry clears the form for the next QSO. Triggered after a
  // successful log AND by ESC. Locked values (band/mode/freq/start/end)
  // are preserved so backdated batches stay productive.
  function resetEntry() {
    // Stop any callsign lookup DEAD: cancel the pending debounce, hide the "looking
    // up" spinner immediately, and invalidate any request already in flight so its
    // late result can't re-fill the field we're about to clear (the "ESC clears the
    // QSO but the QRZ lookup keeps going" bug).
    if (lookupTimerRef.current) { window.clearTimeout(lookupTimerRef.current); lookupTimerRef.current = null; }
    if (wbTimerRef.current) { window.clearTimeout(wbTimerRef.current); wbTimerRef.current = null; }
    lookupGenRef.current++;
    setLookupBusy(false);
    // Discard any in-progress QSO recording (no-op if it was already saved on
    // log, or if the recorder is off).
    QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
    setCallsign(''); setComment(''); setNote(''); setRcv('');
    if (!locks.start) setQsoStartedAt(null);
    if (!locks.end) setQsoEndedAt(null);
    resetAutoFill();
    setWb(null); // clear the Worked-before grid for the just-cleared callsign
    setLookupError('');
    rstUserEditedRef.current = false;
    applyModePreset(mode);
    setDetails((d) => ({
      ...d,
      state: '', cnty: '', address: '', lat: undefined, lon: undefined,
      dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
      qsl_msg: '', qsl_via: '',
      contest_id: '', srx: undefined, stx: undefined,
      email: '',
      award_refs: '',
    }));
  }

  function resetAutoFill() {
    setName(''); setQth(''); setCountry(''); setGrid('');
    // NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms
    // pass) and the short-callsign guard in scheduleLookup. Clearing it inside
    // runLookup blanked the Worked-before table for the whole (possibly slow,
    // QRZ-then-cty.dat) lookup → entries flashed in and immediately vanished.
    setLookupResult(null);
    setDetails((d) => ({
      ...d,
      state: '', cnty: '', address: '',
      lat: undefined, lon: undefined,
      dxcc: undefined, cqz: undefined, ituz: undefined, cont: '',
      qsl_via: '',
      email: '',
    }));
    userEditedRef.current.clear();
    lastLookedUpRef.current = '';
    lastWbFocusRef.current = '';
    setLookupResult(null);
  }

  async function openEdit(id: number) {
    try { setEditingQSO(await GetQSO(id)); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function onModalSave(q: QSO) {
    try {
      await UpdateQSO(q as any);
      setEditingQSO(null);
      await refresh();
      // The Worked-before grid is loaded separately (per callsign) and isn't
      // touched by refresh(), so an edit made from it would leave stale data
      // on screen. Reload it when one is shown.
      const wbCall = callsign.trim();
      if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
    } catch (err: any) { setError(String(err?.message ?? err)); }
  }
  function onModalDelete(id: number) {
    setEditingQSO(null);
    setDeletingIds([id]);
  }

  // Bulk grid actions (right-click menu). Recompute country/zones from
  // cty.dat (instant, offline) or re-query QRZ.com, then refresh the views.
  async function afterBulkUpdate(n: number, label: string) {
    await refresh();
    const wbCall = callsign.trim();
    if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
    showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`);
  }
  function openBulkEdit(ids: number[]) {
    if (ids.length === 0) return;
    setBulkEditIds(ids);
    setBulkEditOpen(true);
  }
  async function bulkUpdateFromCty(ids: number[]) {
    if (ids.length === 0) return;
    try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function bulkUpdateFromQRZ(ids: number[]) {
    if (ids.length === 0) return;
    showToast(`Querying QRZ.com for ${ids.length} QSO${ids.length > 1 ? 's' : ''}…`);
    try { await afterBulkUpdate(await UpdateQSOsFromQRZ(ids as any), 'from QRZ.com'); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function bulkUpdateFromClublog(ids: number[]) {
    if (ids.length === 0) return;
    try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function bulkSendRecording(ids: number[]) {
    if (ids.length === 0) return;
    showToast(`Sending ${ids.length} recording${ids.length > 1 ? 's' : ''} by e-mail…`);
    let ok = 0; const errs: string[] = [];
    for (const id of ids) {
      try { await SendQSORecordingEmail(id as any); ok++; }
      catch (e: any) { errs.push(String(e?.message ?? e)); }
    }
    if (errs.length) setError(`Recording e-mail: ${ok} sent, ${errs.length} failed — ${errs[0]}`);
    else showToast(`${ok} recording${ok > 1 ? 's' : ''} sent`);
  }
  // Right-click "Send to QRZ.com / Club Log / LoTW": uploads the selected QSOs
  // on demand (regardless of their current upload status). Runs in the
  // background; qslmgr:done refreshes the grid when finished.
  async function bulkSendTo(service: string, ids: number[]) {
    if (ids.length === 0) return;
    const label = service === 'qrz' ? 'QRZ.com' : service === 'clublog' ? 'Club Log' : service === 'lotw' ? 'LoTW' : service;
    showToast(`Uploading ${ids.length} QSO${ids.length > 1 ? 's' : ''} to ${label}…`);
    try { await UploadQSOsManual(service, ids as any); }
    catch (e: any) { setError(String(e?.message ?? e)); }
  }
  // Right-click "Export filtered to ADIF (no limit)": exports every QSO that
  // matches the current filter, bypassing the on-screen row threshold.
  async function exportFilteredADIF() {
    try {
      const path = await SaveADIFFile();
      if (!path) return;
      const f = buildActiveFilter();
      const r = await ExportADIFFiltered(path, false, { ...f, limit: 0, offset: 0 } as any);
      showToast(`Exported ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  // Right-click "Export selected to ADIF": only the highlighted rows.
  async function exportSelectedADIF(ids: number[]) {
    if (ids.length === 0) return;
    try {
      const path = await SaveADIFFile();
      if (!path) return;
      const r = await ExportADIFSelected(path, false, ids as any);
      showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  // Cabrillo (contest log) export → a .log file. The contest name comes from the
  // QSOs' ADIF CONTEST_ID (no prompt). All / filtered / selected mirror the ADIF ones.
  async function exportCabrillo() {
    try {
      const path = await SaveCabrilloFile();
      if (!path) return;
      const r = await ExportCabrillo(path);
      showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function exportFilteredCabrillo() {
    try {
      const path = await SaveCabrilloFile();
      if (!path) return;
      const f = buildActiveFilter();
      const r = await ExportCabrilloFiltered(path, { ...f, limit: 0, offset: 0 } as any);
      showToast(`Cabrillo: ${r.count} QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  async function exportSelectedCabrillo(ids: number[]) {
    if (ids.length === 0) return;
    try {
      const path = await SaveCabrilloFile();
      if (!path) return;
      const r = await ExportCabrilloSelected(path, ids as any);
      showToast(`Cabrillo: ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }
  function askDelete(id: number) { setDeletingIds([id]); }
  // Delete the whole multi-row selection (Edit menu / Delete key).
  function askDeleteSelected() {
    if (selectedIds.length > 0) setDeletingIds(selectedIds);
    else if (selectedId != null) setDeletingIds([selectedId]);
  }
  async function confirmDelete() {
    if (deletingIds.length === 0) return;
    const ids = deletingIds;
    try {
      if (ids.length === 1) await DeleteQSO(ids[0]);
      else await DeleteQSOs(ids as any);
      setDeletingIds([]);
      setSelectedId(null);
      setSelectedIds([]);
      await refresh();
    } catch (err: any) {
      setError(String(err?.message ?? err));
      setDeletingIds([]);
    }
  }
  async function confirmDeleteAll() {
    if (deletingAll) return;
    setDeletingAll(true);
    try {
      await DeleteAllQSO();
      setSelectedId(null);
      setShowDeleteAll(false);
      await refresh();
    } catch (err: any) { setError(String(err?.message ?? err)); }
    finally { setDeletingAll(false); }
  }

  async function runWorkedBefore(call: string, dxccHint: number = 0) {
    setWbBusy(true);
    try { setWb(await WorkedBefore(call, dxccHint)); }
    catch { setWb(null); }
    finally { setWbBusy(false); }
  }
  async function runLookup(call: string) {
    if (call !== lastLookedUpRef.current) resetAutoFill();
    const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
    setLookupBusy(true);
    try {
      const r = await LookupCallsign(call);
      // Discard a STALE result: the operator already moved to another call
      // (clicked a new spot / typed) OR cleared the entry (ESC) while this lookup
      // was in flight. Applying it would clobber the current fields and zoom the
      // map to the wrong station.
      if (gen !== lookupGenRef.current || call !== callsignValRef.current.trim().toUpperCase()) return;
      lastLookedUpRef.current = call;
      // cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
      // A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
      // result is cty.dat-only, it must NEVER overwrite the richer data already
      // shown for the same call — so don't downgrade the result badge/image, and
      // below we only fill text fields that actually carry a value.
      const ctyOnly = r.source === 'cty.dat';
      setLookupResult((prev) => (ctyOnly && prev && prev.callsign === r.callsign ? prev : r));
      const ue = userEditedRef.current;
      if (!ue.has('name')    && (r.name ?? '') !== '')    setName(r.name ?? '');
      if (!ue.has('qth')     && (r.qth ?? '') !== '')     setQth(r.qth ?? '');
      if (!ue.has('grid')) {
        if ((r.grid ?? '') !== '') setGrid(r.grid ?? '');
        // No provider grid (cty.dat-only / portable): derive a 4-char grid from
        // the entity centroid (e.g. Svalbard → JQ88) so the field — and the
        // bearing/map — aren't empty. 4 chars signals it's entity-level, not a
        // precise QTH (matches how Log4OM shows it).
        else if (r.lat || r.lon) setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
      }
      // Country/zones are exactly what cty.dat IS authoritative for — set them
      // (only skipped if empty, so we never blank a known country).
      if (!ue.has('country') && (r.country ?? '') !== '') setCountry(r.country ?? '');
      setDetails((d) => ({
        ...d,
        address: d.address || (r.address ?? ''),
        state:   d.state   || (r.state ?? ''),
        cnty:    d.cnty    || (r.cnty ?? ''),
        lat:     d.lat     ?? (r.lat || undefined),
        lon:     d.lon     ?? (r.lon || undefined),
        dxcc:    d.dxcc    ?? (r.dxcc || undefined),
        cqz:     d.cqz     ?? (r.cqz  || undefined),
        ituz:    d.ituz    ?? (r.ituz || undefined),
        cont:    d.cont    || (r.cont ?? ''),
        email:   d.email   || (r.email ?? ''),
        qsl_via: d.qsl_via || (r.qsl_via ?? ''),
      }));
      if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
      // The DX location is now known (grid set above) — force the world map to
      // auto-zoom right away, so it doesn't lag behind the resolved QSO.
      setMapZoomSignal((n) => n + 1);
      // Recording: tie it to the resolved callsign. Start once a real (≥3-char)
      // call resolves — covers the fast CW workflow (type → Enter, no blur). If
      // we're already recording a DIFFERENT call (the operator edited the
      // callsign), restart from zero instead of continuing the old take.
      const recCall = call.toUpperCase();
      if (recordingCallRef.current && recordingCallRef.current !== recCall) {
        restartRecordingForNewTarget(recCall);
      } else {
        recordingCallRef.current = recCall;
        QSOAudioBegin().then(setRecording).catch(() => {});
      }
    } catch (e: any) {
      if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
        setLookupResult(null);
        setLookupError(String(e?.message ?? e));
      }
    } finally {
      // Only clear the spinner if we're still the current lookup — a newer one
      // (or an ESC that already reset it) owns the busy state otherwise.
      if (gen === lookupGenRef.current) setLookupBusy(false);
    }
  }
  function scheduleLookup(value: string, force?: boolean) {
    setLookupError('');
    if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
    if (wbTimerRef.current)     window.clearTimeout(wbTimerRef.current);
    const call = value.trim().toUpperCase();
    if (call.length < 3) {
      setLookupResult(null); setWb(null);
      if (lastLookedUpRef.current !== '') resetAutoFill();
      return;
    }
    // Option: defer the (network) callsign lookup until the operator leaves the
    // Call field instead of firing it as they type. `force` (programmatic set —
    // clicked spot / external app) always looks up, since there's no blur to
    // wait for. Worked-before stays live (local, feeds the band matrix).
    if (force || localStorage.getItem('opslog.lookupOnBlur') !== '1') {
      lookupTimerRef.current = window.setTimeout(() => runLookup(call), force ? 0 : 400);
    }
    wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
  }
  // applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
  // park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
  // target). Routed to the pota_ref column at save via applyAwardRefs.
  function applySpotPOTA(potaRef?: string) {
    const refs = String(potaRef || '')
      .split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
    setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
  }
  function onCallsignInput(v: string, opts?: { force?: boolean }) {
    // Programmatic call-sets (force: spot click, UDP, external app) count as
    // "not manually typed", so a later UDP DX call (DXHunter remote control) can
    // still replace it. Without this, clicking a cluster spot froze the call:
    // applyUdpCall saw current != lastUdpCall and refused every later UDP call.
    if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
    // A callsign appeared (someone answered the CQ, or a spot was clicked) →
    // stop auto-calling so we don't key over the contact. If a CQ was actually
    // in flight, abort the current transmission too so it stops IMMEDIATELY
    // rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
    // the first keystroke, so we only abort once.)
    if (v.trim() !== '') {
      // Someone answered: on the FIRST character of a new call (the field was
      // empty), abort whatever CW is being sent — a single macro OR an auto-call
      // CQ loop — routed to the ACTIVE engine (was WinKeyer-only, so a Flex CWX /
      // Icom macro kept keying over the answering station).
      if (callsignValRef.current.trim() === '') stopKeyerTx();
      stopAutoCall();
    }
    // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
    // on every status packet. If it matches what's already in the entry,
    // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
    // reload worked-before + the band matrix, making them flicker. Compared
    // via the ref so it's correct even from the stale UDP closure.
    if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
    // QSO recorder: a non-empty callsign marks the QSO start (the recorder
    // keeps the pre-roll from before this); clearing it discards the take.
    // Recording START happens on blur (leaving the callsign field), NOT here —
    // you may type a call and work it minutes later. Clearing it cancels.
    if (v.trim() === '') {
      QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
      // Callsign wiped → drop this contact's award references. They are auto-added
      // per call (live detection merges pickable refs into award_refs), so without
      // this they'd carry over to the NEXT call — e.g. IT9AOT's ref lingering when
      // you then type F4BPO, showing both in the F3 Awards tab.
      updateDetails({ award_refs: '' });
    }
    const isEmpty = v.trim() === '';
    if (!isEmpty && !locks.start) {
      // Restart the start time on every callsign change (each keystroke, a
      // clicked spot, a new call): "Start UTC" should mark when you actually
      // began this contact, not a stale time frozen at the first keystroke.
      // Skip when start is locked (back-entering a past QSO at a chosen time).
      setQsoStartedAt(new Date());
    } else if (isEmpty && !locks.start) {
      // Callsign wiped → user abandoned this QSO; reset the timer.
      setQsoStartedAt(null);
    }
    setCallsign(v);
    // opts.force = the call was set programmatically (clicked spot / external
    // app): there's no "leaving the field", so look it up now regardless of the
    // lookup-on-blur option.
    scheduleLookup(v, opts?.force);
  }
  function markEdited(field: string) { userEditedRef.current.add(field); }

  async function importAdif() {
    if (importing) return;
    setError('');
    try {
      const path = await OpenADIFFile();
      if (!path) return;
      // Stash the path and open the options dialog. The actual import
      // is fired from runImport() when the user clicks "Import".
      setPendingImportPath(path);
    } catch (e: any) { setError(String(e?.message ?? e)); }
  }

  function exportAdif() {
    if (exporting) return;
    setShowExportChoice(true); // pick standard vs full first
  }
  async function runExport(includeAppFields: boolean) {
    setShowExportChoice(false);
    if (exporting) return;
    setError('');
    try {
      const path = await SaveADIFFile();
      if (!path) return;
      setExporting(true);
      const res = await ExportADIF(path, includeAppFields);
      // Green success toast (auto-dismiss) — not the red error banner.
      showToast(`ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`);
    } catch (e: any) {
      setError(`ADIF export failed: ${String(e?.message ?? e)}`);
    } finally {
      setExporting(false);
    }
  }

  async function runImport() {
    const path = pendingImportPath;
    if (!path || importing) return;
    setPendingImportPath(null);
    setImporting(true);
    setImportProgress({ processed: 0, total: 0 });
    setImportResult(null);
    setImportErrorsOpen(false);
    setImportDupsOpen(false);
    try {
      const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
      setImportResult(res);
      await refresh();
    } catch (e: any) {
      setError(String(e?.message ?? e));
    } finally {
      setImporting(false);
      setImportProgress(null);
    }
  }

  const menus: Menu[] = useMemo(() => [
    { name: 'file', label: t('menu.file'), items: [
      { type: 'item', label: t('file.import'), action: 'file.import', shortcut: 'Ctrl+O' },
      { type: 'item', label: exporting ? t('file.exporting') : t('file.export'), action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
      { type: 'item', label: t('file.exportCabrillo'), action: 'file.exportCabrillo', disabled: total === 0 },
      { type: 'separator' },
      { type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
      { type: 'separator' },
      { type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q' },
    ]},
    { name: 'edit', label: t('menu.edit'), items: [
      { type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
      { type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
      { type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 },
      { type: 'separator' },
      { type: 'item', label: t('edit.prefs'), action: 'edit.prefs' },
    ]},
    { name: 'view', label: t('menu.view'), items: [
      { type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
      { type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
    ]},
    { name: 'tools', label: t('menu.tools'), items: [
      { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
      { type: 'item', label: t('stats.tab'), action: 'tools.stats' },
      { type: 'item', label: t('station.title'), action: 'tools.station' },
      { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
      { type: 'separator' },
      { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
      { type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
      { type: 'separator' },
      { type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
      { type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
      { type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
      { type: 'separator' },
      { type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 },
      { type: 'separator' },
      // Maintenance — bumped here while we only have one entry. Will move
      // to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
      { type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
      { type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
    ]},
    { name: 'help', label: t('menu.help'), items: [
      { type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
      { type: 'item', label: t('help.about'), action: 'help.about' },
    ]},
  ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]);

  function handleMenu(action: string) {
    switch (action) {
      case 'file.import':       importAdif(); break;
      case 'file.export':       exportAdif(); break;
      case 'file.exportCabrillo': exportCabrillo(); break;
      case 'file.deleteall':    setShowDeleteAll(true); break;
      case 'file.exit':         QuitApp(); break;
      case 'view.refresh':      refresh(); break;
      case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
      case 'edit.edit':         if (selectedId !== null) openEdit(selectedId); break;
      case 'edit.delete':       askDeleteSelected(); break;
      case 'edit.bulkedit':     openBulkEdit(selectedIds); break;
      case 'edit.prefs':        setShowSettings(true); break;
      case 'tools.qslmanager':  setQslTabOpen(true); setActiveTab('qsl'); break;
      case 'tools.stats':       setStatsTabOpen(true); setActiveTab('stats'); break;
      case 'tools.station':     setStationTabOpen(true); setActiveTab('station'); break;
      case 'tools.qsldesigner': setQslDesignerOpen(true); break;
      case 'tools.winkeyer':    wkSetEnabled(!wkEnabled); break;
      case 'tools.dvk':         setDvkEnabled((v) => !v); break;
      case 'tools.net':         setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
      case 'tools.contest':     setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
      case 'tools.alerts':      setAlertsOpen(true); break;
      case 'tools.duplicates':  setShowDuplicates(true); break;
      case 'tools.refreshCty':  refreshCtyDat(); break;
      case 'tools.downloadRefs': downloadRefs(); break;
      case 'help.about':        setShowAbout(true); checkUpdateNow(); break;
      case 'help.whatsnew':     GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
    }
  }

  async function downloadRefs() {
    if (refsDownloading) return;
    setRefsDownloading(true);
    setError('');
    try {
      const summary = await DownloadAllReferenceLists();
      showToast(`Reference lists updated — ${summary}`);
    } catch (e: any) {
      setError(`Reference download failed: ${String(e?.message ?? e)}`);
    } finally {
      setRefsDownloading(false);
    }
  }
 
  async function refreshCtyDat() {
    if (ctyRefreshing) return;
    setCtyRefreshing(true);
    setError('');
    try {
      const info = await RefreshCtyDat();
      // Use the regular error banner area for a brief success note — keeps
      // us from pulling in a toast system just for one maintenance action.
      setError(`cty.dat refreshed — ${info.entities} entities loaded`);
      setTimeout(() => setError((e) => e.startsWith('cty.dat refreshed') ? '' : e), 4000);
    } catch (e: any) {
      setError(`cty.dat refresh failed: ${String(e?.message ?? e)}`);
    } finally {
      setCtyRefreshing(false);
    }
  }

  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      const tag = (e.target as HTMLElement)?.tagName;
      const typing = tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA';

      // ESC: abort CW when the keyer is live. Whether it ALSO clears the
      // callsign depends on the "ESC clears callsign" option; with the keyer
      // off it always resets the entry (the classic behaviour).
      if (e.key === 'Escape') {
        // If a voice message is transmitting, ESC just stops it (keeps entry).
        if (dvkActiveRef.current && dvkPlayingRef.current) {
          DVKStop();
          e.preventDefault();
          return;
        }
        const keyerLive = wkActiveRef.current;
        // ESC aborts the current CW transmission AND the auto-call loop, so it
        // won't resend after the gap — you must click a CQ macro to restart it.
        // Route the abort to whichever engine is active (was WinKeyer-only, so
        // ESC didn't stop the Icom or Flex keyer).
        if (keyerLive) {
          stopAutoCall();
          wkSendGenRef.current++; // abort an in-flight macro send so a pending <LOGQSO> won't fire
          if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
          else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
          else WinkeyerStop().catch(() => {});
        }
        if (!keyerLive || wkEscClearsRef.current) {
          resetEntry();
          callsignRef.current?.focus();
        }
        e.preventDefault();
        return;
      }

      // Function keys (work even while typing — they're not text input):
      const fn = /^F([1-9]|1[0-2])$/.exec(e.key);
      if (fn) {
        const n = parseInt(fn[1], 10); // 1..12
        const TABS = ['stats', 'info', 'awards', 'my', 'extended'] as const;
        const mod = e.ctrlKey || e.metaKey;
        const plain = !mod && !e.altKey;
        if (wkActiveRef.current) {
          // CW keyer live: plain F1..F12 fire macros; Ctrl+F1..F5 switch the
          // detail tab (so the two don't clash). Labels read "Ctrl+F1…".
          if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
          if (plain) { e.preventDefault(); wkSendMacroRef.current(n - 1); return; }
          return;
        }
        if (dvkActiveRef.current) {
          // Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
          if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
          if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
          return;
        }
        // No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
        if (plain && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
        return;
      }
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o') {
        e.preventDefault(); importAdif(); return;
      }
      if (typing) return;
      if (selectedId !== null) {
        if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
        if (e.key === 'Enter')  { e.preventDefault(); openEdit(selectedId); return; }
      }
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [selectedId, selectedIds, refresh]);

  // ── Entry-field blocks ─────────────────────────────────────────────────
  // Each field is defined once here, then composed into either the compact
  // single-row strip or the full Log4OM-style columnar layout below. Keeping
  // them as shared consts avoids duplicating the (large) per-field JSX +
  // handlers across the two layouts.
  const callsignBlock = (
    <div className="flex flex-col w-44">
      <Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
        {t('field.callsign')}
        {lookupBusy && (
          <span className="inline-flex items-center gap-1 rounded-full bg-muted/70 px-1.5 py-[1px] text-[9px] font-medium text-muted-foreground ring-1 ring-inset ring-border/60">
            <Loader2 className="size-2.5 animate-spin" />…
          </span>
        )}
        {!lookupBusy && lookupResult && (() => {
          const src = (lookupResult.source || '').toLowerCase();
          const tone = src.includes('cache') ? 'amber' : src.includes('qrz') ? 'emerald' : src.includes('ham') ? 'sky' : 'slate';
          const ring = { amber: 'bg-warning/12 text-warning ring-warning/30', emerald: 'bg-success/12 text-success ring-success/30', sky: 'bg-info/12 text-info ring-info/30', slate: 'bg-muted-foreground/12 text-muted-foreground ring-muted-foreground/30' }[tone];
          const dot = { amber: 'bg-warning', emerald: 'bg-success', sky: 'bg-info', slate: 'bg-muted-foreground' }[tone];
          return (
            <span className={cn('inline-flex items-center gap-1 rounded-full px-1.5 py-[1px] text-[9px] font-semibold uppercase tracking-wide ring-1 ring-inset', ring)}>
              <span className={cn('size-1.5 rounded-full', dot)} />{lookupResult.source}
            </span>
          );
        })()}
        {!lookupBusy && !lookupResult && lookupError && (
          <span className="inline-flex items-center rounded-full bg-danger/12 px-1.5 py-[1px] text-[9px] font-semibold text-danger ring-1 ring-inset ring-danger/30">{lookupError}</span>
        )}
        {callsign.trim() && (
          <button
            type="button"
            title={t('qrz.openTitle', { call: callsign.trim().toUpperCase() })}
            onClick={() => {
              const c = callsign.trim().toUpperCase().split('/').map(encodeURIComponent).join('/');
              if (c) OpenExternalURL(`https://www.qrz.com/db/${c}`).catch((e) => setError(String(e?.message ?? e)));
            }}
            className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[9px] font-semibold normal-case tracking-wider text-primary hover:underline"
          >
            QRZ ↗
          </button>
        )}
      </Label>
      <div className="relative">
      {contest.active && contestDupe && (
        <span className="absolute -top-2 right-1 z-20 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-black tracking-widest text-destructive-foreground shadow animate-pulse pointer-events-none">
          DUPE
        </span>
      )}
      {recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
        <button
          type="button"
          onMouseDown={(e) => e.preventDefault()}
          onClick={resetRecordingClock}
          title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
          className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-destructive whitespace-nowrap cursor-pointer rounded px-1 hover:bg-destructive-muted"
        >
          <span className="size-2 rounded-full bg-destructive animate-pulse" />
          {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
        </button>
      )}
      <Input
        ref={callsignRef}
        className={cn('font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card',
          contest.active && contestDupe && 'ring-2 ring-destructive !bg-destructive-muted focus:!bg-destructive-muted text-destructive-muted-foreground')}
        value={callsign}
        onChange={(e) => onCallsignInput(e.target.value)}
        onBlur={() => {
          const c = callsign.trim();
          if (!c) return;
          // Lookup-on-blur mode: run the deferred lookup now (it also starts the
          // recording). Otherwise just start the recording (lookup already ran).
          if (localStorage.getItem('opslog.lookupOnBlur') === '1') runLookup(c.toUpperCase());
          else QSOAudioBegin().then(setRecording).catch(() => {});
        }}
      />
      </div>
    </div>
  );
  const rstTxBlock = (
    <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
      <Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
    </div>
  );
  const rstRxBlock = (
    <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
      <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
    </div>
  );
  // DX country flag, shown large next to RST (moved here from the Country field).
  const flagBlock = flagURL(details.dxcc) ? (
    <div className="flex flex-col justify-end shrink-0">
      <img src={flagURL(details.dxcc)} alt={country} title={country}
        className="h-9 rounded-[3px] border border-border/60 shadow-sm"
        referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
    </div>
  ) : null;
  // Deferred-entry date: only shown when the start time is locked (back-entering
  // a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
  const dateBlock = locks.start ? (
    <div className="flex flex-col w-40">
      <Label className="mb-1 h-3.5 text-success">Date</Label>
      <Input
        type="date"
        value={qsoStartedAt ? qsoStartedAt.toISOString().slice(0, 10) : ''}
        onChange={(e) => {
          const d = e.target.value; // YYYY-MM-DD
          if (!d) return;
          const [y, mo, da] = d.split('-').map(Number);
          const next = new Date(qsoStartedAt ?? new Date());
          next.setUTCFullYear(y, mo - 1, da);
          setQsoStartedAt(next);
        }}
        className="font-mono"
      />
    </div>
  ) : null;
  const startBlock = (
    <div className="flex flex-col w-28">
      <Label className="mb-1 h-3.5 flex items-center gap-1 text-success">{t('field.startUtc')} <LockBtn k="start" title="start time" /></Label>
      <Input
        readOnly={!locks.start}
        tabIndex={locks.start ? 0 : -1}
        value={startFocused ? startInputStr : (qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : (locks.start ? '' : '—'))}
        onFocus={() => { setStartInputStr(qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : ''); setStartFocused(true); }}
        onBlur={() => {
          setStartFocused(false);
          if (startInputStr.trim() === '') { if (locks.start) setQsoStartedAt(null); return; }
          setQsoStartedAt(parseHMSUTC(startInputStr, qsoStartedAt ?? new Date()));
        }}
        onChange={(e) => setStartInputStr(e.target.value)}
        placeholder="HH:MM:SS"
        className={cn('font-mono', locks.start ? '' : 'bg-muted/40 cursor-default')}
      />
    </div>
  );
  const endBlock = (
    <div className="flex flex-col w-28">
      <Label className="mb-1 h-3.5 flex items-center gap-1 text-danger">{t('field.endUtc')} <LockBtn k="end" title="end time" /></Label>
      <Input
        readOnly={!locks.end}
        tabIndex={locks.end ? 0 : -1}
        value={endFocused ? endInputStr : (
          locks.end ? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '')
          // Deferred entry (start locked): the QSO is back-dated, so the end
          // follows the start instead of the live clock.
          : locks.start ? (qsoStartedAt ? fmtHMSUTC(qsoStartedAt) : '')
          : (qsoStartedAt ? utcNow.slice(11) : '—'))}
        onFocus={() => { setEndInputStr(qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : ''); setEndFocused(true); }}
        onBlur={() => {
          setEndFocused(false);
          if (endInputStr.trim() === '') { if (locks.end) setQsoEndedAt(null); return; }
          setQsoEndedAt(parseHMSUTC(endInputStr, qsoEndedAt ?? new Date()));
        }}
        onChange={(e) => setEndInputStr(e.target.value)}
        placeholder="HH:MM:SS"
        className={cn('font-mono', locks.end ? '' : 'bg-muted/40 cursor-default')}
      />
    </div>
  );
  const nameBlock = (
    <div className="flex flex-col flex-1 min-w-[110px]"><Label className="mb-1 h-3.5">{t('field.name')}</Label>
      <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
    </div>
  );
  // In contest mode QTH is replaced by the sent (read-only, auto) and received
  // exchange fields — the exchange is what matters, not the town.
  const contestSnt = contest.exchange === 'serial'
    ? String(contest.nextSerial).padStart(3, '0')
    : (contest.sentFixed || '—');
  const qthBlock = contest.active ? (
    <>
      <div className="flex flex-col w-16 shrink-0"><Label className="mb-1 h-3.5">{t('field.snt')}</Label>
        <Input readOnly tabIndex={-1} value={contestSnt} className="font-mono bg-muted/40" />
      </div>
      <div className="flex flex-col w-24 shrink-0"><Label className="mb-1 h-3.5 text-warning font-bold">{t('field.rcv')}</Label>
        <Input value={rcv} placeholder="exch" className="font-mono"
          onChange={(e) => setRcv(e.target.value.toUpperCase())} />
      </div>
    </>
  ) : (
    <div className="flex flex-col flex-1 min-w-[130px]"><Label className="mb-1 h-3.5">{t('field.qth')}</Label>
      <Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
    </div>
  );
  const gridBlock = (
    <div className="flex flex-col w-[76px] shrink-0"><Label className="mb-1 h-3.5">{t('field.grid')}</Label>
      <Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
    </div>
  );
  // LoTW-user badge: shown only when the entered call is a LoTW user, coloured by
  // last-upload recency (green < 1 week · amber 1–4 weeks · red > 30 days). The
  // tooltip shows the exact last-upload date. Needs the list downloaded once
  // (Settings → LoTW); until then no badge appears.
  const lotwBlock = (lotwInfo && lotwInfo.is_user) ? (() => {
    const d = lotwInfo.days_ago ?? -1;
    const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
      : d < 30 ? 'border-warning text-warning bg-warning/15'
      : 'border-danger text-danger bg-danger/15';
    return (
      <div className="shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
        <span className={cn('inline-flex items-center rounded-md border px-1.5 py-1 text-[9px] font-extrabold tracking-wide leading-none', cls)}>
          LoTW
        </span>
      </div>
    );
  })() : null;
  // A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
  // row instead of the intrusive floating cards. It lights + shows a count when
  // alerts are pending; click it to see the last 3 (each clickable to tune).
  // ── Offline outbox (safety net) ──────────────────────────────────────
  // When the shared database is unreachable, QSOs are parked in a local ADIF
  // file rather than lost. Surface that HONESTLY: the operator must know their
  // worked-before check doesn't include these, and see what's waiting.
  const offlinePendingCount = offlineStatus.pending ?? 0;
  const offlineBlock = offlinePendingCount === 0 ? null : (
    <div className="relative shrink-0">
      <button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
        title={t('offline.tip', { n: offlinePendingCount })}
        className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
        <CloudOff className="size-4" />
        <span className="text-xs font-bold tabular-nums">{offlinePendingCount}</span>
      </button>
      {pendingOpen && (
        <>
          <div className="fixed inset-0 z-40" onClick={() => setPendingOpen(false)} />
          <div className="absolute right-0 top-9 z-50 w-[26rem] rounded-lg border border-border bg-card shadow-xl overflow-hidden">
            <div className="px-3 py-2 border-b border-border bg-danger-muted text-danger-muted-foreground">
              <p className="text-xs font-semibold">{t('offline.title', { n: offlinePendingCount })}</p>
              <p className="text-[11px] mt-0.5 leading-snug">{t('offline.explain')}</p>
            </div>
            <div className="max-h-72 overflow-auto divide-y divide-border/60">
              {pendingList.length === 0 ? (
                <p className="px-3 py-3 text-[11px] text-muted-foreground italic">{t('offline.empty')}</p>
              ) : pendingList.map((q, i) => (
                <div key={i} className="px-3 py-1.5 flex items-center gap-2 text-xs">
                  <span className="font-mono font-semibold w-24 truncate">{q.callsign}</span>
                  <span className="text-muted-foreground w-12">{q.band}</span>
                  <span className="text-muted-foreground w-14">{q.mode}</span>
                  <span className="ml-auto text-[11px] text-muted-foreground tabular-nums">
                    {q.qso_date ? new Date(q.qso_date).toISOString().slice(0, 16).replace('T', ' ') : ''}
                  </span>
                </div>
              ))}
            </div>
            <div className="px-3 py-2 border-t border-border flex items-center gap-2">
              <button type="button"
                onClick={async () => {
                  try {
                    const n = await RetryOfflineSync();
                    if (n > 0) showToast(t('offline.synced', { n }));
                    else showToast(t('offline.stillDown'));
                    GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
                  } catch (e: any) { setError(String(e?.message ?? e)); }
                }}
                className="h-7 px-2 rounded border border-border text-xs hover:bg-muted">
                {t('offline.retry')}
              </button>
              <span className="text-[10px] text-muted-foreground truncate" title={offlineStatus.path}>{offlineStatus.path}</span>
            </div>
          </div>
        </>
      )}
    </div>
  );

  const hasAlerts = recentAlerts.length > 0;
  // Only show the bell when there are pending alerts — hidden otherwise.
  const alertLedBlock = !hasAlerts ? null : (
    <div className="relative shrink-0">
      <button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
        title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
        className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
          hasAlerts ? 'border-warning bg-warning/15 text-warning' : 'border-border bg-card text-muted-foreground hover:bg-muted')}>
        <Bell className="size-4" />
        {hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning animate-ping" />}
        {hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning" />}
      </button>
      {alertsPanelOpen && (
        <>
          <div className="fixed inset-0 z-40" onClick={() => setAlertsPanelOpen(false)} />
          <div className="absolute right-0 top-9 z-50 w-72 rounded-lg border border-border bg-card shadow-xl overflow-hidden">
            <div className="flex items-center justify-between px-3 py-1.5 border-b border-border/60 bg-muted/30">
              <span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{t('alert.recent')}</span>
              {hasAlerts && <button type="button" className="text-[11px] text-muted-foreground hover:text-foreground" onClick={() => setRecentAlerts([])}>{t('alert.clear')}</button>}
            </div>
            {!hasAlerts ? (
              <div className="px-3 py-4 text-center text-xs text-muted-foreground">{t('alert.noneShort')}</div>
            ) : recentAlerts.map((a) => (
              <button key={a.id} type="button" title={t('alert.tuneHint')}
                onClick={() => { handleSpotClick({ comment: a.comment, freq_hz: a.freq_hz, band: a.band, dx_call: a.call }); setAlertsPanelOpen(false); }}
                className="block w-full text-left px-3 py-2 border-b border-border/30 last:border-0 hover:bg-warning/10 transition-colors">
                <div className="flex items-center gap-1.5">
                  <span className="font-bold text-sm truncate">{a.call}</span>
                  {a.freq_hz > 0 && <span className="ml-auto shrink-0 text-[11px] font-mono tabular-nums text-warning">{(a.freq_hz / 1e6).toFixed(3)}</span>}
                </div>
                <div className="text-[11px] text-muted-foreground truncate">
                  {a.rule}{a.band ? ` · ${a.band}` : ''}{a.mode ? ` · ${a.mode}` : ''}{a.country ? ` · ${a.country}` : ''}
                </div>
              </button>
            ))}
          </div>
        </>
      )}
    </div>
  );
  // Compact-strip Country (stacked label) + a narrow Comment.
  const countryBlockSm = (
    <div className="flex flex-col w-36">
      <Label className="mb-1 h-3.5 flex items-center gap-1.5">
        Country
        {flagURL(details.dxcc) && (
          <img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm"
            referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
        )}
      </Label>
      <Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
    </div>
  );
  const commentSm = (
    <div className="flex flex-col w-40"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
      <Input value={comment} onChange={(e) => setComment(e.target.value)} />
    </div>
  );
  // Inline-label variants (label to the LEFT of the control, Log4OM-style) —
  // used in the full layout to save vertical height.
  const bandRow = (
    <div className="flex items-center gap-2">
      <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.band')} <LockBtn k="band" title="band" /></Label>
      <div className="flex-1 min-w-0">
        <Select value={band} onValueChange={onBandUserChange}>
          <SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
          <SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
        </Select>
      </div>
    </div>
  );
  const modeRow = (
    <div className="flex items-center gap-2">
      <Label className="w-20 shrink-0 flex items-center gap-1">{t('field.mode')} <LockBtn k="mode" title="mode" /></Label>
      <div className="flex-1 min-w-0">
        <Select value={mode} onValueChange={onModeUserChange}>
          <SelectTrigger tabIndex={-1} className="h-8"><SelectValue /></SelectTrigger>
          <SelectContent>{modes.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
        </Select>
      </div>
    </div>
  );
  const countryRow = (
    <div className="flex items-center gap-2">
      <Label className="w-20 shrink-0">{t('field.country')}</Label>
      <div className="flex-1 min-w-0">
        <Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
      </div>
    </div>
  );
  // CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
  // Type a frequency, press Enter → tune the radio there. Only when a rig is
  // actually connected (these same fields are the manual-log entry when it isn't),
  // and only on a plausible HF/VHF value so a half-typed "14." doesn't QSY the rig
  // to 14 kHz. noteManualEdit() holds off the incoming poll so the field doesn't
  // snap back before the radio's echo confirms the move.
  const tuneRadioTo = (mhzStr: string) => {
    if (!(catState.enabled && catState.connected)) return;
    const mhz = parseFloat(mhzStr);
    if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
    noteManualEdit();
    SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
  };
  const freqBlock = (
    <div className="flex flex-col w-32">
      <Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
      <Input
        tabIndex={-1}
        className="font-mono"
        value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
        placeholder="14.250"
        title={catState.connected ? t('field.freqTuneHint') : undefined}
        onFocus={() => setFreqFocused(true)}
        onBlur={() => setFreqFocused(false)}
        onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(freqMhz); }}
        onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
      />
    </div>
  );
  const rxFreqBlock = (
    <div className="flex flex-col w-32">
      <Label className={cn('mb-1 h-3.5', catState.split && 'text-danger')}>{t('field.rxFreq')}</Label>
      <Input
        tabIndex={-1}
        value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
        placeholder="14.255"
        title={catState.connected ? t('field.freqTuneHint') : undefined}
        onFocus={() => setFreqFocused(true)}
        onBlur={() => setFreqFocused(false)}
        onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(rxFreqMhz); }}
        onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
        className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
      />
    </div>
  );
  const bandRxBlock = (
    <div className="flex flex-col w-28"><Label className="mb-1 h-3.5">{t('field.rxBand')}</Label>
      <Select value={bandRx} onValueChange={setBandRx}>
        <SelectTrigger tabIndex={-1}><SelectValue /></SelectTrigger>
        <SelectContent>{bands.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
      </Select>
    </div>
  );
  // Single-line Comment/Note for the full layout (stacked in the right
  // column). No flex-1 so they stay one row tall.
  const commentLine = (
    <div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.comment')}</Label>
      <Input value={comment} onChange={(e) => setComment(e.target.value)} />
    </div>
  );
  const noteLine = (
    <div className="flex flex-col"><Label className="mb-1 h-3.5">{t('field.note')}</Label>
      <Input value={note} onChange={(e) => setNote(e.target.value)} />
    </div>
  );
  const logButtons = (
    <div className="flex flex-col">
      <Label className="mb-1 h-3.5">&nbsp;</Label>
      <div className="flex gap-1.5">
        {clusterServerStatuses.some((s) => s.state === 'connected') && (
          <Button type="button" size="sm" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8 px-2.5 text-xs" title="Send a DX spot to the master cluster">
            <Satellite className="size-3.5" />
            {t('btn.spot')}
          </Button>
        )}
        <Button type="button" size="sm" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8 px-2.5 text-xs"
          title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
          <Eraser className="size-3.5" />
          {t('btn.clear')}
        </Button>
        <Button size="sm" onClick={save} disabled={saving} className="h-8 px-3 text-xs">
          <Send className="size-3.5" />
          {saving ? '…' : t('btn.logQso')}
        </Button>
      </div>
    </div>
  );
  // Compact strip: Log only — the Spot dialog would be clipped by the tiny
  // always-on-top window, so it's reachable only in normal mode.
  const logButtonCompact = (
    <div className="flex flex-col">
      <Label className="mb-1 h-3.5">&nbsp;</Label>
      <Button onClick={save} disabled={saving} className="h-8">
        <Send className="size-3.5" />
        {saving ? '…' : t('btn.logQso')}
      </Button>
    </div>
  );

  // Cluster spots after every active filter (band / mode / status / search /
  // hide-worked / group). Shared by the Cluster tab and the Main-view cluster
  // pane so both show exactly the same list.
  const clusterRenderedRows = useMemo(() => {
    const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
    const search = clusterSearch.trim().toUpperCase();
    const list = spots.filter((s) => {
      if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
      if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
      if (search && !s.dx_call.includes(search)) return false;
      if (clusterLockMode) {
        const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
        if (spotMode && mode && spotMode !== mode) return false;
      }
      if (clusterModeFilter.size > 0) {
        const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
        if (!cat || !clusterModeFilter.has(cat)) return false;
      }
      if (clusterStatusFilter.size > 0) {
        const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
        const e = spotStatus[k];
        const st = (e?.status || '') as SpotStatusKey;
        // A previously-worked call counts as WORKED for filtering even when its
        // entity status is still new-band/new-slot (the grid flags it WKD CALL),
        // matching the "Hide worked" toggle. Additive: it still matches its own
        // entity status too, so it stays visible under NEW BAND / NEW SLOT.
        const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked'));
        if (!matches) return false;
      }
      if (clusterHideWorked) {
        const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
        const e = spotStatus[k];
        if (!e) return false;
        if (e.worked_call || e.status === 'worked') return false;
      }
      return true;
    });
    let rendered = list as (ClusterSpot & { repeats?: number })[];
    if (clusterGroup) {
      const seen = new Map<string, ClusterSpot & { repeats: number }>();
      for (const s of list) {
        const e = seen.get(s.dx_call);
        if (e) { e.repeats++; }
        else seen.set(s.dx_call, { ...s, repeats: 1 });
      }
      rendered = Array.from(seen.values());
    }
    return rendered;
  }, [spots, clusterLockBand, band, clusterBands, clusterSearch, clusterFilterSource,
      clusterLockMode, mode, clusterModeFilter, clusterStatusFilter, spotStatus,
      clusterHideWorked, clusterGroup]);

  // The Log4OM-style cluster filter sidebar (callsign search, hide-worked,
  // group, band/mode/status/source). Rendered both in the Cluster tab and the
  // Main-view cluster pane; toggled by clusterShowFilters.
  const renderClusterFilters = () => (
    <div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
      <div className="px-2.5 py-2 border-b border-border/60 flex items-center justify-between">
        <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filters</span>
        <button type="button" onClick={toggleClusterFilters} title="Hide filters"
          className="text-muted-foreground hover:text-foreground">
          <X className="size-3.5" />
        </button>
      </div>
      <div className="flex-1 overflow-auto p-2.5 space-y-3 text-xs">
        {/* Callsign search */}
        <Input
          className="h-7 text-xs font-mono uppercase"
          placeholder="Search call…"
          value={clusterSearch}
          onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
        />

        {/* Toggles */}
        <div className="space-y-1.5">
          <label className="flex items-center gap-1.5 cursor-pointer">
            <Checkbox checked={clusterHideWorked} onCheckedChange={(c) => setClusterHideWorked(!!c)} />
            Hide worked
          </label>
          <label className="flex items-center gap-1.5 cursor-pointer">
            <Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
            Group duplicates
          </label>
        </div>

        {/* Band filter — multi-select listbox */}
        <div>
          <div className="flex items-center justify-between mb-1">
            <span className="text-[10px] uppercase tracking-wider text-muted-foreground">Bands</span>
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => setClusterLockBand((v) => !v)}
                className={cn('inline-flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded border',
                  clusterLockBand ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
                title="Lock to the entry strip's current band"
              >
                {clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} {band}
              </button>
              {clusterBands.size > 0 && (
                <button type="button" onClick={() => setClusterBands(new Set())} className="text-[10px] text-muted-foreground hover:text-foreground underline">clear</button>
              )}
            </div>
          </div>
          <div className={cn('rounded border border-border max-h-36 overflow-auto bg-background', clusterLockBand && 'opacity-40 pointer-events-none')}>
            {bands.map((b) => {
              const on = clusterBands.has(b);
              return (
                <button
                  key={b}
                  type="button"
                  onClick={() => setClusterBands((s) => { const n = new Set(s); if (n.has(b)) n.delete(b); else n.add(b); return n; })}
                  className={cn('block w-full text-left px-2 py-0.5 font-mono text-[11px] border-b border-border/30 last:border-0',
                    on ? 'bg-primary text-primary-foreground' : 'hover:bg-accent/50')}
                >
                  {b}
                </button>
              );
            })}
          </div>
        </div>

        {/* Mode lock */}
        <button
          type="button"
          onClick={() => setClusterLockMode((v) => !v)}
          className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px]',
            clusterLockMode ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' : 'text-muted-foreground border-border hover:bg-muted')}
          title="Only show spots whose mode matches the entry strip"
        >
          {clusterLockMode ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />} Lock mode ({mode})
        </button>

        {/* Status filter */}
        <div>
          <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
          <div className="flex flex-wrap gap-1">
            {([
              { k: 'new'      as SpotStatusKey, label: 'NEW',       cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
              { k: 'new-band' as SpotStatusKey, label: 'NEW BAND',  cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
              { k: 'new-mode' as SpotStatusKey, label: 'NEW MODE',  cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
              { k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT',  cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
              // (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
            ]).map((s) => {
              const on = clusterStatusFilter.has(s.k);
              return (
                <button key={s.k} type="button"
                  onClick={() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
                  className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
                  {s.label}
                </button>
              );
            })}
          </div>
        </div>

        {/* Mode filter */}
        <div>
          <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Mode</div>
          <div className="flex flex-wrap gap-1">
            {([
              { k: 'SSB'  as SpotModeCat, label: 'SSB',  cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
              { k: 'CW'   as SpotModeCat, label: 'CW',   cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
              { k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
            ]).map((s) => {
              const on = clusterModeFilter.has(s.k);
              return (
                <button key={s.k} type="button"
                  onClick={() => setClusterModeFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })}
                  className={cn('px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity', on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`)}>
                  {s.label}
                </button>
              );
            })}
          </div>
        </div>

        {/* Source */}
        <div>
          <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Source</div>
          <Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
            <SelectTrigger className="w-full h-7 text-xs"><SelectValue placeholder="All sources" /></SelectTrigger>
            <SelectContent>
              <SelectItem value="_">All sources</SelectItem>
              {clusterServers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
            </SelectContent>
          </Select>
        </div>
      </div>
    </div>
  );

  // A small "show filters" button shown when the sidebar is collapsed.
  const clusterFiltersToggleBtn = (
    <button type="button" onClick={toggleClusterFilters}
      title={clusterShowFilters ? 'Hide filters' : 'Show filters'}
      className={cn('inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium',
        clusterShowFilters ? 'bg-primary text-primary-foreground border-primary' : 'text-muted-foreground border-border hover:bg-muted')}>
      <SlidersHorizontal className="size-3" /> Filters
    </button>
  );

  // Render one Main-view pane. The two sides (mainPaneLeft/Right) each pick from
  // the same four choices, configured per-profile in Settings → Main view.
  const renderMainPane = (kind: MainPaneKind) => {
    switch (kind) {
      case 'map1':
        return (
          <WorldMap
            fromGrid={station.my_grid}
            toGrid={grid}
            fromLabel={station.callsign}
            toLabel={callsign}
            beamAzimuths={showBeamOnMap ? beamHeadings : []}
            boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
            zoomSignal={mapZoomSignal}
          />
        );
      case 'map2':
        return <LocatorMap toGrid={grid} toLabel={callsign} />;
      case 'cluster':
        return (
          <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
            <div className="flex items-center justify-between px-2 py-1 border-b border-border/60 shrink-0">
              <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Cluster</span>
              {clusterFiltersToggleBtn}
            </div>
            <div className="flex-1 min-h-0 flex">
              <div className="flex-1 min-w-0 flex flex-col min-h-0">
                <ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
              </div>
              {clusterShowFilters && renderClusterFilters()}
            </div>
          </div>
        );
      case 'worked':
        return (
          <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
            <WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
              onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
              onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
          </div>
        );
      case 'flex':
        return (
          <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
            <FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
              onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
          </div>
        );
      case 'icom':
        return (
          <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
            <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
          </div>
        );
      case 'netcontrol':
        return (
          <div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
            <NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
          </div>
        );
      case 'recent':
        return (
          <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
            <RecentQSOsGrid
              key={`rqg-${activeProfileId ?? 'x'}`}
              rows={qsosWithAwards as any}
              total={total}
              awardCols={awardCols}
              onRowDoubleClicked={(q) => openEdit(q.id as number)}
              onUpdateFromCty={bulkUpdateFromCty}
              onUpdateFromQRZ={bulkUpdateFromQRZ}
              onUpdateFromClublog={bulkUpdateFromClublog}
              onSendTo={bulkSendTo}
              onSendRecording={bulkSendRecording}
              onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
              onBulkEdit={openBulkEdit}
              onExportSelected={exportSelectedADIF}
              onExportFiltered={exportFilteredADIF}
              onDelete={(ids) => setDeletingIds(ids)}
              onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
            />
          </div>
        );
    }
  };

  return (
    <div className="flex flex-col h-screen overflow-hidden bg-background">
      <ShutdownProgress />
      {/* ===== TOPBAR ===== */}
      {compact ? (
        // Minimal compact topbar — brand + freq + toggle. Saves vertical space
        // so the single-row entry strip fits in a ~140px tall window.
        <header className="flex items-center gap-3 px-3 h-8 bg-card border-b border-border shrink-0">
          <div className="flex items-center gap-1.5">
            <div className="size-2 rounded-full bg-gradient-to-br from-primary to-orange-400" />
            <span className="font-bold text-xs tracking-tight">OpsLog</span>
          </div>
          <div className="flex items-baseline gap-1.5 font-mono ml-2">
            <span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
            <span className="text-[9px] text-muted-foreground">MHz</span>
            <Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
            <Badge className="bg-success-muted text-success-muted-foreground hover:bg-success-muted font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
          </div>
          <span className="ml-2 font-mono text-[10px] text-muted-foreground">{utcNow}<span className="text-[9px]">Z</span></span>
          <div className="flex-1" />
          {station.callsign && (
            <span className="font-mono text-[11px] font-bold text-primary mr-2">{station.callsign}</span>
          )}
          <Button variant="outline" size="icon" className="size-6" onClick={toggleCompact} title="Exit compact mode">
            <Maximize2 className="size-3" />
          </Button>
        </header>
      ) : (
        <header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
          <div className="flex items-center gap-2 pr-2 border-r border-border/60">
            <div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
            <div className="flex items-baseline gap-1.5">
              <span className="font-bold text-[15px] tracking-tight leading-none">OpsLog</span>
              <span className="text-[11px] text-muted-foreground leading-none cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
            </div>
          </div>

          <Menubar menus={menus} onAction={handleMenu} />

          <div className="relative flex items-center justify-center gap-2 font-mono">
            {/* Toasts and errors live in the STATUS BAR at the bottom now — the
                header band was too narrow and long messages were cut off. */}
            <div className="flex flex-col items-end leading-none">
              <span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
              {catState.split && rxFreqMhz && (
                <span className="text-[10px] text-muted-foreground mt-0.5">
                  <span className="text-danger font-semibold mr-1">RX</span>
                  {fmtFreqDots(rxFreqMhz)}
                </span>
              )}
            </div>
            <span className="text-[10px] text-muted-foreground uppercase">MHz</span>
            {catState.split && (
              <Badge className="bg-warning-muted text-warning-muted-foreground border-warning-border font-mono text-[10px] py-0" variant="outline">SPLIT</Badge>
            )}
            {/* Band & mode removed here — shown in the QSO entry strip below. */}
            {catState.enabled && catState.backend === 'omnirig' && (
              <div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-sans font-semibold">
                {[1, 2].map((n) => {
                  const active = (catState.rig_num || 1) === n;
                  return (
                    <button key={n} type="button" onClick={() => SwitchCATRig(n).catch(() => {})}
                      className={cn('px-2 py-0.5 transition-colors', active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}
                      title={`Use OmniRig Rig ${n}`}>R{n}</button>
                  );
                })}
              </div>
            )}
            <div className="w-px h-4 bg-border mx-2" />
            {/* Bearing controls — three separate buttons so SP and LP are
                both directly clickable, plus an always-visible Stop. The
                old Shift/Ctrl shortcuts were not discoverable enough. */}
            {(() => {
              const p = dxPath;
              const disabled = !p;
              const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
              return (
                <div className="inline-flex items-center rounded-full border border-info-border bg-info-muted overflow-hidden text-[11px] font-mono font-semibold">
                  <button
                    type="button"
                    disabled={disabled}
                    onClick={() => p && goto(p.bearingShort)}
                    title={p
                      ? `Rotate short-path · ${Math.round(p.distanceShort).toLocaleString()} km`
                      : (station.my_grid ? 'No remote grid' : 'Set your station grid in Preferences')}
                    className={cn(
                      'inline-flex items-center gap-1 px-2 py-0.5 transition-colors',
                      disabled
                        ? 'text-muted-foreground/60 cursor-not-allowed'
                        : 'text-info-muted-foreground hover:bg-info-muted cursor-pointer',
                    )}
                  >
                    <Compass className="size-3" />
                    {p ? `${Math.round(p.bearingShort)}°` : '—°'}
                  </button>
                  <button
                    type="button"
                    disabled={disabled}
                    onClick={() => p && goto(p.bearingLong)}
                    title={p ? `Rotate long-path · ${Math.round(p.distanceLong).toLocaleString()} km` : ''}
                    className={cn(
                      'px-1.5 py-0.5 border-l border-info-border text-[10px] transition-colors',
                      disabled
                        ? 'text-muted-foreground/60 cursor-not-allowed'
                        : 'text-info-muted-foreground hover:bg-info-muted cursor-pointer',
                    )}
                  >
                    LP {p ? `${Math.round(p.bearingLong)}°` : '—'}
                  </button>
                  <button
                    type="button"
                    onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
                    title="Stop rotation"
                    className="px-1.5 py-0.5 border-l border-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
                  >
                    <Square className="size-2.5 fill-current" />
                  </button>
                </div>
              );
            })()}

            {/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
            {ubStatus.enabled && (
              <div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
                title={ubStatus.connected ? (ubStatus.moving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
                <button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
                  <span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
                </button>
                {([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
                  <button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
                    onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }}
                    className={cn('px-1.5 py-0.5 transition-colors',
                      ubStatus.direction === o.d ? 'bg-success text-success-foreground' : 'text-success-muted-foreground hover:bg-success-muted',
                      !ubStatus.connected && 'opacity-40 cursor-default')}>
                    {o.l}
                  </button>
                ))}
              </div>
            )}

            {/* Voice keyer (DVK) + CW keyer (WinKeyer) quick status/access. */}
            <div className="w-px h-4 bg-border mx-1" />
            <button
              type="button"
              onClick={() => setDvkEnabled((v) => !v)}
              title={dvkStat.playing ? 'Voice keyer — playing' : dvkEnabled ? 'Voice keyer (DVK) — open · click to close' : 'Voice keyer (DVK) · click to open'}
              className={cn(
                'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                dvkStat.playing ? 'border-danger-border bg-danger-muted text-danger-muted-foreground'
                  : dvkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                  : 'border-border text-muted-foreground hover:bg-muted',
              )}
            >
              <Mic className="size-4" />
              {dvkStat.playing && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-danger animate-pulse" />}
            </button>
            <button
              type="button"
              onClick={() => wkSetEnabled(!wkEnabled)}
              title={wkEnabled && wkStatus.connected ? `CW keyer (WinKeyer) — connected${wkStatus.busy ? ', sending' : ''} · click to close` : wkEnabled ? 'CW keyer (WinKeyer) — enabled · click to close' : 'CW keyer (WinKeyer) · click to open'}
              className={cn(
                'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                wkStatus.busy ? 'border-warning-border bg-warning-muted text-warning-muted-foreground'
                  : wkEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                  : 'border-border text-muted-foreground hover:bg-muted',
              )}
            >
              <Zap className="size-4" />
              {wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
            </button>
            <button
              type="button"
              onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
              title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'}
              className={cn(
                'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                showRotor ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                  : 'border-border text-muted-foreground hover:bg-muted',
              )}
            >
              <Compass className="size-4" />
            </button>
            {agEnabled && (
              <button
                type="button"
                onClick={() => { const v = !showAntGenius; setShowAntGenius(v); writeUiPref('opslog.showAntGenius', v ? '1' : '0'); }}
                title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
                className={cn(
                  'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  showAntGenius ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
                    : 'border-border text-muted-foreground hover:bg-muted',
                )}
              >
                <Antenna className="size-4" />
                {showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
              </button>
            )}
            {chatAvailable && (
              <button
                type="button"
                onClick={() => setChatOpen((o) => !o)}
                title={`Multi-op chat${chatUnread > 0 ? ` — ${chatUnread} new` : ''}`}
                className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  chatShown ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
                    : 'border-border text-muted-foreground hover:bg-muted')}
              >
                <MessageSquare className="size-4" />
                {chatUnread > 0 && (
                  <span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
                    {chatUnread > 9 ? '9+' : chatUnread}
                  </span>
                )}
              </button>
            )}
            {/* Multi-op "who's on air": a dockable widget (toggle), not a popover. */}
            {dbConn?.backend === 'mysql' && (
              <button
                type="button"
                onClick={() => { const v = !showLiveStations; setShowLiveStations(v); writeUiPref('opslog.showLiveStations', v ? '1' : '0'); }}
                title={showLiveStations ? `${t('live.stationsTitle')} — shown · click to hide` : `${t('live.stationsTitle')} · click to show`}
                className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
                  showLiveStations ? 'border-info-border bg-info-muted text-info-muted-foreground hover:bg-info-muted'
                    : 'border-border text-muted-foreground hover:bg-muted')}
              >
                <Radio className="size-4" />
                {liveStations.filter((s) => s.online).length > 0 && (
                  <span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-danger text-danger-foreground text-[9px] font-bold leading-[14px] text-center">
                    {(() => { const n = liveStations.filter((s) => s.online).length; return n > 9 ? '9+' : n; })()}
                  </span>
                )}
              </button>
            )}
          </div>

          {/* QSO-rate meter (opt-in) + propagation share ONE grid cell: the header
              is a fixed 6-column grid, so adding the meter as its own child pushed
              the last columns (profile / band map / compact) onto a 2nd row. */}
          <div className="flex items-center gap-2">
          {showQsoRate && (
            <div className="flex items-center gap-2 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
              title={t('rate.title')}>
              {/* Contest-style rate: QSOs/hour projected from each window (10-min
                  count ×6; the 60-min count is already per hour). On a shared MySQL
                  logbook it shows both OP (the active operator, accent) and TEAM (all
                  operators, muted); single-op shows one line. */}
              <Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
              {dbConn?.backend === 'mysql' ? (
                <div className="flex flex-col gap-0.5 leading-none">
                  <div className="flex items-center gap-1.5">
                    <span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
                    <span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span><span className="text-muted-foreground text-[7px]">10′</span></span>
                    <span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span><span className="text-muted-foreground text-[7px]">60′</span></span>
                  </div>
                  <div className="flex items-center gap-1.5">
                    <span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
                    <span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team10 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}</span><span className="text-muted-foreground text-[7px]">10′</span></span>
                    <span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team60 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}</span><span className="text-muted-foreground text-[7px]">60′</span></span>
                  </div>
                </div>
              ) : (
                <>
                  <span className="inline-flex items-baseline gap-1">
                    <span className="text-muted-foreground uppercase tracking-wider text-[9px]">10′</span>
                    <span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
                  </span>
                  <span className="inline-flex items-baseline gap-1">
                    <span className="text-muted-foreground uppercase tracking-wider text-[9px]">60′</span>
                    <span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
                  </span>
                </>
              )}
              <span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
            </div>
          )}

          {/* Space-weather / propagation — compact, in the header. Live from N0NBH
              (hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
              onto each logged QSO. Always renders one element so the grid columns
              stay aligned (empty span until the feed loads). */}
          {solar && solar.ok ? (
            <div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
              title={`${t('prop.title')}${solar.updated ? ' · ' + solar.updated : ''}`}>
              {(() => {
                const geo = String(solar.geomag_field || '').toUpperCase();
                const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
                  : /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
                const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
                // Semantic colour by band condition: higher flux/sunspots = better HF
                // (green when strong); A and K measure geomagnetic disturbance, so LOW
                // is good (green quiet → yellow unsettled → red storm).
                const sfiCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n >= 120 ? 'text-success' : n >= 90 ? 'text-foreground' : 'text-warning'; };
                const ssnCls = (v: any) => { const n = num(v); return n != null && n >= 80 ? 'text-success' : 'text-foreground'; };
                const aCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 7 ? 'text-success' : n <= 15 ? 'text-warning' : 'text-danger'; };
                const kCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 2 ? 'text-success' : n <= 3 ? 'text-warning' : 'text-danger'; };
                const it = (label: string, val: any, cls = 'text-foreground') => (
                  <span className="inline-flex items-baseline gap-1">
                    <span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
                    <span className={cn('font-bold text-[12px]', cls)}>{(val ?? '') === '' ? '—' : val}</span>
                  </span>
                );
                return (<>
                  {it('SFI', solar.sfi, sfiCls(solar.sfi))}
                  {it('SSN', solar.ssn, ssnCls(solar.ssn))}
                  {it('A', solar.a_index, aCls(solar.a_index))}
                  {it('K', solar.k_index, kCls(solar.k_index))}
                  {geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
                </>);
              })()}
            </div>
          ) : <span />}
          </div>

          <div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
            <Clock className="size-3" />
            {utcNow}<span className="text-[10px]">UTC</span>
          </div>

          <div className="flex items-center gap-3">
            {station.callsign ? (
              <button
                onClick={() => { setSettingsSection('profiles'); setShowSettings(true); }}
                className="flex items-center gap-1.5 bg-accent border border-accent-foreground/20 text-accent-foreground px-3 py-1 rounded-full font-mono text-xs font-bold hover:bg-accent/80 transition-colors"
                title="Click to switch / edit profiles"
              >
                <Antenna className="size-3" />
                {station.callsign}
                {station.my_grid && (
                  <span className="bg-card/60 px-1.5 rounded text-[11px] text-primary">{station.my_grid}</span>
                )}
              </button>
            ) : (
              <Button variant="outline" size="sm" onClick={() => setShowSettings(true)}>
                <Settings className="size-3.5" /> Set station
              </Button>
            )}
            <Button
              variant={showBandMap ? 'default' : 'outline'}
              size="sm"
              onClick={() => setBandMapShown(!showBandMap)}
              title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
              className="h-8"
            >
              Band map
            </Button>
            <Button
              variant="outline"
              size="icon"
              onClick={toggleCompact}
              title="Compact mode (small always-on-top window)"
              className="size-8"
            >
              <Minimize2 className="size-3.5" />
            </Button>
          </div>
        </header>
      )}

      {/* QRZ profile photo lightbox — full size, in-app. Click anywhere or
          press Esc to close; click the image itself doesn't close. */}
      {photoModal && (
        <div
          className="fixed inset-0 z-[200] flex items-center justify-center bg-stone-900/70 backdrop-blur-sm p-6 animate-in fade-in"
          onClick={() => setPhotoModal(null)}
        >
          <button
            type="button"
            className="absolute top-4 right-4 rounded-md bg-white/10 hover:bg-white/20 text-white p-1.5"
            onClick={() => setPhotoModal(null)}
            title="Close (Esc)"
          >
            <X className="size-5" />
          </button>
          <img
            src={photoModal}
            alt="profile full size"
            className="max-h-full max-w-full object-contain rounded-lg shadow-2xl"
            referrerPolicy="no-referrer"
            onClick={(e) => e.stopPropagation()}
          />
        </div>
      )}

      {/* First launch: mandatory station identity. Blocks until filled. */}
      {showFirstRun && (
        <FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
      )}

      {updateInfo && (
        <div className="fixed bottom-4 right-4 z-[150] w-80 rounded-lg border border-primary/40 bg-card shadow-xl p-3 animate-in slide-in-from-bottom-2 fade-in">
          <div className="flex items-start gap-2">
            <div className="size-2.5 mt-1 rounded-full bg-primary shrink-0 animate-pulse" />
            <div className="min-w-0 flex-1">
              <p className="text-sm font-semibold">{t('upd.available', { v: updateInfo.latest })}</p>
              <p className="text-xs text-muted-foreground">{t('upd.current', { v: APP_VERSION })}</p>

              {updating ? (
                <div className="mt-2">
                  <div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
                    <span>{updateProgress >= 100 ? t('upd.installing') : t('upd.downloading')}</span>
                    <span className="tabular-nums">{updateProgress}%</span>
                  </div>
                  <div className="h-2 w-full rounded-full bg-muted overflow-hidden">
                    <div className="h-full bg-primary transition-[width] duration-150" style={{ width: `${updateProgress}%` }} />
                  </div>
                  <p className="mt-1 text-[10px] text-muted-foreground">{t('upd.restartNote')}</p>
                </div>
              ) : updateError ? (
                <div className="mt-2">
                  <p className="text-[11px] text-destructive break-words">{updateError}</p>
                  <div className="mt-1.5 flex items-center gap-2">
                    <button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">{t('upd.retry')}</button>
                    {updateInfo.url && <button onClick={() => BrowserOpenURL(updateInfo.url)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.browser')}</button>}
                  </div>
                </div>
              ) : (
                <div className="mt-2 flex items-center gap-2">
                  <button onClick={startUpdate} className="h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90">
                    {updateInfo.downloadUrl ? t('upd.install') : t('upd.download')}
                  </button>
                  <button onClick={() => setUpdateInfo(null)} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">{t('upd.later')}</button>
                </div>
              )}
            </div>
            {!updating && (
              <button onClick={() => setUpdateInfo(null)} className="text-muted-foreground hover:text-foreground shrink-0" title="Dismiss">
                <X className="size-4" />
              </button>
            )}
          </div>
        </div>
      )}

      {showAbout && (
        <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
          <div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
            <div className="flex items-center justify-center gap-2 mb-1">
              <div className="size-3 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
              <h2 className="text-xl font-bold tracking-tight">OpsLog</h2>
            </div>
            <p className="text-sm text-muted-foreground">Ham-radio logbook</p>
            <p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
            {checkingUpdate ? (
              <p className="mt-1 text-[11px] text-muted-foreground inline-flex items-center gap-1"><Loader2 className="size-3 animate-spin" /> {t('upd.checking')}</p>
            ) : updateInfo ? (
              <button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
                Update available: v{updateInfo.latest} — download
              </button>
            ) : (
              <p className="mt-1 text-[11px] text-success">{t('upd.upToDate')}</p>
            )}
            <p className="mt-3 text-sm">
              Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
            </p>
            <p className="mt-1 text-[11px] text-muted-foreground">73 & good DX</p>
            <button onClick={() => setShowAbout(false)} className="mt-5 h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
              Close
            </button>
          </div>
        </div>
      )}

      {/* What's new — shown once on the first launch after an update. */}
      {whatsNew && (
        <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
          <div className="w-full max-w-lg max-h-[85vh] flex flex-col rounded-xl border border-border bg-card shadow-2xl animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
            <div className="flex items-center gap-2 px-5 pt-5 pb-3 border-b border-border shrink-0">
              <Zap className="size-5 text-primary" />
              <h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2>
              <span className="flex-1" />
              <div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-semibold">
                {(['en', 'fr'] as const).map((l) => (
                  <button key={l} onClick={() => setClLang(l)}
                    className={cn('px-2 py-0.5 uppercase', clLang === l ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
                    {l}
                  </button>
                ))}
              </div>
              <button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground ml-1"><X className="size-4" /></button>
            </div>
            <div className="overflow-y-auto px-5 py-4 space-y-5">
              {whatsNew.map((e) => (
                <div key={e.version}>
                  <div className="flex items-baseline gap-2 mb-1.5">
                    <span className="font-mono font-bold text-primary">v{e.version}</span>
                    {e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
                  </div>
                  <ul className="space-y-1.5 text-sm">
                    {(clLang === 'fr' ? e.fr : e.en).map((line, i) => (
                      <li key={i} className="flex gap-2">
                        <span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
                        <span>{line}</span>
                      </li>
                    ))}
                  </ul>
                </div>
              ))}
            </div>
            <div className="px-5 py-3 border-t border-border shrink-0 text-right">
              <button onClick={() => setWhatsNew(null)} className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
                {t('whatsnew.close')}
              </button>
            </div>
          </div>
        </div>
      )}

      {/* Transient toasts (bottom-right). Errors stack on top of the green
          success toast; both auto-dismiss. */}
      {/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
          skipping leaves lookups/uploads without their passwords until unlocked. */}
      {unlockOpen && (
        <div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 backdrop-blur-sm">
          <div className="w-[360px] rounded-lg border border-border bg-card shadow-xl p-4">
            <div className="flex items-center gap-2 mb-1">
              <Lock className="size-4 text-primary" />
              <h2 className="text-sm font-semibold">Unlock saved passwords</h2>
            </div>
            <p className="text-xs text-muted-foreground mb-3">
              Enter your passphrase to decrypt your QRZ / HamQTH / LoTW / SMTP passwords for this session.
            </p>
            <Input
              type="password"
              autoFocus
              value={unlockPass}
              placeholder="Passphrase"
              onChange={(e) => { setUnlockPass(e.target.value); setUnlockErr(''); }}
              onKeyDown={(e) => { if (e.key === 'Enter' && unlockPass) doUnlock(); }}
              className="mb-2"
            />
            {unlockErr && <div className="text-xs text-destructive mb-2">{unlockErr}</div>}
            <div className="flex items-center justify-end gap-2">
              <Button variant="ghost" size="sm" onClick={() => { setUnlockOpen(false); setUnlockPass(''); setUnlockErr(''); }}>Later</Button>
              <Button size="sm" disabled={!unlockPass || unlockBusy} onClick={doUnlock}>
                {unlockBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />} Unlock
              </Button>
            </div>
          </div>
        </div>
      )}



      {/* ===== ENTRY STRIP =====
          Enter from any <input> inside the strip logs the QSO. Radix Selects
          render as <button> elements and are ignored by this handler — they
          keep their own keyboard behaviour. */}
      <div className={cn(!compact && 'flex gap-2.5 items-stretch px-2.5 pt-2.5 shrink-0')}>
      <section
        className={cn('bg-card shadow-sm border-border',
          compact
            ? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
            : 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[660px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
        onKeyDown={(e) => {
          if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
            e.preventDefault();
            save();
          }
          // ESC is handled globally (stop CW + optional callsign reset).
        }}
      >
        {compact ? (
          /* Compact strip: Call + RST + Name/QTH/Country + Comment on one
             row. Band/Mode/Freq are omitted — they're shown in the compact
             top bar just above. */
          <>
            {callsignBlock}
            {rstTxBlock}
            {rstRxBlock}
            {nameBlock}
            {qthBlock}
            {countryBlockSm}
            {commentSm}
            <div className="ml-auto">{logButtonCompact}</div>
          </>
        ) : (
          /* Full Log4OM-style columnar layout. */
          <>
            {/* Row 1: Callsign + RST, then Start/End at right. CQ/ITU zones moved
                to the Info (F2) tab. */}
            <div className="flex gap-2 items-end">
              {callsignBlock}
              {rstTxBlock}
              {rstRxBlock}
              {flagBlock}
              <div className="ml-auto flex gap-2">
                {dateBlock}
                {startBlock}
                {endBlock}
              </div>
            </div>

            {/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
                its right edge lines up with that column below; QTH grows to fill.
                gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
            <div className="flex gap-4 items-end">
              <div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
                <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
              </div>
              {qthBlock}
              {gridBlock}
            </div>

            {/* Row 3: tight left detail column (Band/Mode/Country) and
                single-line Comment/Note on the right. */}
            <div className="flex gap-4 items-start">
              <div className="flex flex-col gap-1.5 w-[300px] shrink-0">
                {bandRow}
                {modeRow}
                {countryRow}
              </div>
              <div className="flex flex-col gap-1.5 flex-1 min-w-0">
                {commentLine}
                {noteLine}
              </div>
            </div>

            {/* Bottom: TX freq, RX freq, RX band — then the alert LED + LoTW badge
                (vertically centred on the input row), then the action buttons. */}
            <div className="flex gap-2 items-end">
              {freqBlock}
              {rxFreqBlock}
              {bandRxBlock}
              {(alertLedBlock || lotwBlock || offlineBlock) && (
                <div className="flex items-center gap-2 h-9 self-end">
                  {alertLedBlock}
                  {lotwBlock}
                  {offlineBlock}
                </div>
              )}
              <div className="ml-auto">{logButtons}</div>
            </div>
          </>
        )}
      </section>

      {/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
          tabs, then reserved free space. Hidden in compact mode. */}
      {!compact && (
        // relative + absolute inner: the panel's content can't grow the row, so
        // the row height is set by the ENTRY STRIP. A taller tab (Awards/F3) then
        // scrolls inside this fixed height instead of pushing everything down.
        <div className="w-[560px] shrink-0 min-h-0 relative">
          <div className="absolute inset-0 flex flex-col min-h-0">
          <DetailsPanel
            callsign={callsign}
            prefix={prefix}
            operatorGrid={station.my_grid}
            remoteGrid={grid}
            details={details}
            onChange={updateDetails}
            wb={wb}
            wbBusy={wbBusy}
            band={band}
            mode={mode}
            bands={bands}
            tab={detailTab}
            onTab={setDetailTab}
            keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
          />
          </div>
        </div>
      )}
      {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
          Digital Voice Keyer take this slot when enabled (Log4OM-style);
          otherwise it shows the QRZ profile photo. */}
      {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
        <div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
          {/* Multi-op "who's on air" widget: every operator on the shared logbook,
              their freq/mode (colour-coded) and OpsLog version. */}
          {showLiveStations && dbConn?.backend === 'mysql' && (
            <div className="w-[248px] shrink-0 min-h-0 relative">
              <div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
                <div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
                  <Radio className="size-3.5 text-primary" />
                  <span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
                  <div className="flex-1" />
                  <span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}</span>
                  <button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
                    onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
                    <X className="size-3.5" />
                  </button>
                </div>
                <div className="flex-1 min-h-0 overflow-auto p-1 flex flex-col gap-0.5">
                  {liveStations.filter((s) => s.online).length === 0 ? (
                    <p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
                  ) : liveStations.filter((s) => s.online).map((s, i) => {
                    const mc = modeAccent(s.mode);
                    // Single compact row so 5+ stations fit without scrolling.
                    return (
                      <div key={i} className={cn('flex items-center gap-1.5 rounded-md px-1.5 py-1 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
                        <span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
                          title={s.online ? t('live.onAir') : t('live.offline')} />
                        <span className="text-xs font-bold font-mono truncate shrink-0 max-w-[72px]" title={s.operator}>{s.operator}</span>
                        <span className="font-mono text-[11px] font-semibold tabular-nums shrink-0" style={{ color: mc }}>
                          {s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
                        </span>
                        {s.mode && (
                          <span className="text-[9px] font-bold uppercase px-1 rounded-full leading-[15px] shrink-0"
                            style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
                        )}
                        {s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
                        <span className="flex-1" />
                        {s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">v{s.version}</span>}
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>
          )}
          {chatShown && (
            // relative + absolute inner: the chat takes the row height (set by the
            // entry strip) WITHOUT its message list growing the row, like the
            // Stats panel. The list scrolls inside this fixed height.
            <div className="w-[280px] shrink-0 min-h-0 relative">
              <div className="absolute inset-0 flex flex-col min-h-0">
                <ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
                  onSend={chatSend} onClose={() => setChatOpen(false)} />
              </div>
            </div>
          )}
          {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
              rotator is configured or a DX bearing exists. */}
          {showRotor && (rotatorHeading.enabled || dxPath) && (
            <div className="w-[186px] shrink-0 min-h-0">
              <RotorCompass
                bearing={dxPath?.bearingShort ?? null}
                headings={beamHeadings}
                boomHeading={boomHeading}
                pattern={ubPattern}
                centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
                centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
                rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
                onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
                onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
              />
            </div>
          )}
          {showAntGenius && agEnabled && (
            <div className="w-[230px] shrink-0 min-h-0">
              <AntGeniusPanel
                status={agStatus}
                onActivate={agActivate}
                onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
                band={band}
              />
            </div>
          )}
          {dvkEnabled && (
            <div className="w-[264px] shrink-0 min-h-0">
              <DvkPanel
                messages={dvkMsgs}
                status={dvkStat}
                onPlay={dvkPlay}
                onStop={() => DVKStop()}
                onClose={() => setDvkEnabled(false)}
              />
            </div>
          )}
          {wkEnabled && (
            <div className="w-[380px] shrink-0 min-h-0">
              <WinkeyerPanel
                status={cwSource === 'icom' || cwSource === 'flex'
                  ? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
                  : wkStatus}
                ports={wkPorts}
                port={wkPort}
                wpm={wkWpm}
                macros={wkMacros}
                sent={wkSent}
                source={cwSource}
                breakIn={icomBreakIn}
                onSetBreakIn={setBreakIn}
                onSelectPort={wkSelectPort}
                onRefreshPorts={reloadWkPorts}
                onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
                onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
                onSetSpeed={(w) => {
                  setWkWpm(w); saveWk({ wpm: w });
                  if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
                  else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
                  else WinkeyerSetSpeed(w).catch(() => {});
                }}
                onSend={wkSend}
                onSendMacro={wkSendMacro}
                onStop={() => { stopAutoCall(); stopKeyerTx(); }}
                onClose={() => wkSetEnabled(false)}
                sendOnType={wkSendOnType}
                onToggleSendOnType={wkToggleSendOnType}
                onSendRaw={wkSendRaw}
                onBackspace={wkBackspace}
                autoCall={wkAutoCall}
                autoCallSecs={wkAutoCallSecs}
                onToggleAutoCall={wkToggleAutoCall}
                onSetAutoCallSecs={wkSetAutoCallSecs}
              />
            </div>
          )}
          {/* QRZ photo: when the keyer is open it sits to its right at natural
              (capped) width, shrinking the keyer panel rather than hiding it. */}
          {lookupResult?.image_url && (
            <div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}>
              <button
                type="button"
                onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
                className="rounded-lg border border-border overflow-hidden hover:border-primary/60 transition-colors bg-muted/20"
                title="Click to view full size"
              >
                <img
                  src={lookupResult.image_url}
                  alt="profile"
                  className="block max-h-[180px] max-w-full w-auto object-contain"
                  loading="lazy"
                  referrerPolicy="no-referrer"
                  onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
                />
              </button>
            </div>
          )}
        </div>
      )}
      </div>{/* /entry + aside row */}

      {/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
      {/* ===== LOWER: tabbed table / cluster / band map ===== */}
      {compact ? null : <>
      <div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
        showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
        <section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
          <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
            <TabsList className="px-3 shrink-0">
              <TabsTrigger value="main">{t('tab.main')}</TabsTrigger>
              <TabsTrigger value="recent">
                {t('tab.recent')}
              </TabsTrigger>
              <TabsTrigger value="cluster">{t('tab.cluster')}</TabsTrigger>
              <TabsTrigger value="worked">
                {t('tab.worked')}
                {wb && wb.count > 0 && (
                  <Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{wb.count}</Badge>
                )}
              </TabsTrigger>
              <TabsTrigger value="awards">{t('tab.awards')}</TabsTrigger>
              <TabsTrigger value="bandmap">{t('tab.bandmap')}</TabsTrigger>
              {contestTabEnabled && (
                <TabsTrigger value="contest" className={cn('gap-1.5', contest.active && 'text-warning data-[state=active]:text-warning')}>
                  {t('tab.contest')}
                  {contest.active && <span className="inline-block size-1.5 rounded-full bg-warning animate-pulse" />}
                  <span
                    role="button"
                    aria-label="Close Contest"
                    title={t('dup.close')}
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); setContestTabEnabled(false); setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {netEnabled && (
                <TabsTrigger value="net" className="gap-1.5">
                  {t('tab.net')}
                  <span
                    role="button"
                    aria-label="Close Net"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
              {catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
              {statsTabOpen && (
                <TabsTrigger value="stats" className="gap-1.5">
                  {t('stats.tab')}
                  <span
                    role="button"
                    aria-label="Close Statistics"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeStatsTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {stationTabOpen && (
                <TabsTrigger value="station" className="gap-1.5">
                  {t('station.title')}
                  <span
                    role="button"
                    aria-label="Close Station Control"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeStationTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
              {qslTabOpen && (
                <TabsTrigger value="qsl" className="gap-1.5">
                  QSL Manager
                  <span
                    role="button"
                    aria-label="Close QSL Manager"
                    title="Close"
                    className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
                    onPointerDown={(e) => { e.stopPropagation(); }}
                    onClick={(e) => { e.stopPropagation(); closeQslTab(); }}
                  >
                    <X className="size-3" />
                  </span>
                </TabsTrigger>
              )}
            </TabsList>

            <TabsContent value="recent" className="mt-0 flex flex-col min-h-0 flex-1">
              <div className="flex gap-2 p-2.5 border-b border-border/60">
                <Input
                  className="flex-1"
                  placeholder="Search callsign…"
                  value={filterCallsign}
                  onChange={(e) => setFilterCallsign(e.target.value)}
                />
                <Button variant="outline" size="sm" onClick={() => refresh()}>
                  <RefreshCw className="size-3.5" /> Refresh
                </Button>
              </div>

              {importResult && (
                <div className={cn(
                  'mx-2.5 mt-2 px-3 py-2 rounded-md text-xs border flex flex-col gap-1.5',
                  importResult.errors && importResult.errors.length > 0
                    ? 'bg-warning-muted border-warning-border text-warning-muted-foreground'
                    : 'bg-success-muted border-success-border text-success-muted-foreground',
                )}>
                  <div className="flex items-center gap-3 flex-wrap">
                    <strong>Import complete.</strong>
                    <Badge variant="outline" className="bg-card/60 font-mono text-success border-success-border">{importResult.imported} imported</Badge>
                    {importResult.updated > 0 && (
                      <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.updated} updated</Badge>
                    )}
                    {importResult.duplicates > 0 && (
                      <Badge variant="outline" className="bg-card/60 font-mono text-info border-info-border">{importResult.duplicates} duplicates</Badge>
                    )}
                    <Badge variant="outline" className="bg-card/60 font-mono text-warning border-warning-border">{importResult.skipped} skipped</Badge>
                    <Badge variant="outline" className="bg-card/60 font-mono">{importResult.total} total</Badge>
                    {importResult.duplicates > 0 && importResult.duplicate_samples && importResult.duplicate_samples.length > 0 && (
                      <button className="underline text-xs" onClick={() => setImportDupsOpen((v) => !v)}>
                        {importDupsOpen ? 'Hide' : 'Show'} duplicates
                        {importResult.duplicates > importResult.duplicate_samples.length
                          ? ` (first ${importResult.duplicate_samples.length} of ${importResult.duplicates})`
                          : ''}
                      </button>
                    )}
                    {importResult.errors && importResult.errors.length > 0 && (
                      <button className="underline text-xs" onClick={() => setImportErrorsOpen((v) => !v)}>
                        {importErrorsOpen ? 'Hide' : 'Show'} {importResult.errors.length} error{importResult.errors.length > 1 ? 's' : ''}
                      </button>
                    )}
                    <button className="ml-auto" onClick={() => setImportResult(null)}><X className="size-4" /></button>
                  </div>
                  {importDupsOpen && importResult.duplicate_samples && (
                    <ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
                      {importResult.duplicate_samples.map((d, i) => <li key={i}>{d}</li>)}
                    </ul>
                  )}
                  {importErrorsOpen && importResult.errors && (
                    <ul className="font-mono text-[11px] pl-6 max-h-32 overflow-y-auto list-disc border-t border-current/20 pt-2 mt-1">
                      {importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
                    </ul>
                  )}
                </div>
              )}

              <RecentQSOsGrid
                key={`rqg2-${activeProfileId ?? 'x'}`}
                rows={qsosWithAwards as any}
                total={total}
                awardCols={awardCols}
                onFilteredCountChange={setGridFilteredCount}
                onRowDoubleClicked={(q) => openEdit(q.id as number)}
                onUpdateFromCty={bulkUpdateFromCty}
                onUpdateFromQRZ={bulkUpdateFromQRZ}
                onUpdateFromClublog={bulkUpdateFromClublog}
                onSendTo={bulkSendTo}
                onSendRecording={bulkSendRecording}
                onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
                onBulkEdit={openBulkEdit}
                onExportSelected={exportSelectedADIF}
                onExportFiltered={exportFilteredADIF}
                onExportCabrilloSelected={exportSelectedCabrillo}
                onExportCabrilloFiltered={exportFilteredCabrillo}
                onDelete={(ids) => setDeletingIds(ids)}
                onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
              />
              <div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
                <div className="flex items-center gap-3">
                  <Button
                    variant={(activeFilter.conditions?.length || filterCallsign) ? 'default' : 'outline'}
                    size="sm"
                    className="h-7 px-2 text-[11px] gap-1"
                    onClick={() => setFilterOpen(true)}
                    title="Build an advanced filter"
                  >
                    <SlidersHorizontal className="size-3.5" /> Filters
                    {activeFilter.conditions?.length ? (
                      <span className="ml-0.5 rounded-full bg-primary-foreground/20 px-1.5 font-mono">{activeFilter.conditions.length}</span>
                    ) : null}
                  </Button>
                  {(activeFilter.conditions?.length || filterCallsign) ? (
                    <button
                      className="text-muted-foreground hover:text-foreground underline decoration-dotted"
                      onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
                    >clear</button>
                  ) : null}
                  {gridFilteredCount != null ? (
                    <span>
                      Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
                      <span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
                    </span>
                  ) : (
                    <span>
                      Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
                      <span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
                      {(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
                    </span>
                  )}
                </div>
                <div className="flex items-center gap-2">
                  {qsos.length >= qsoLimit && qsos.length < total && (
                    <span className="text-warning">Limit reached — raise Max to see more.</span>
                  )}
                  <Label className="text-[11px] text-muted-foreground">Max</Label>
                  <Input
                    type="number"
                    min={1}
                    step={100}
                    className="w-24 h-7 font-mono text-xs"
                    value={qsoLimit}
                    onChange={(e) => {
                      const n = Number(e.target.value);
                      if (Number.isFinite(n) && n > 0) setQsoLimit(Math.floor(n));
                    }}
                  />
                  <Button
                    variant="ghost"
                    size="sm"
                    className="h-7 px-2 text-[11px]"
                    onClick={() => setQsoLimit(Math.max(total, 1))}
                    title="Load the entire log"
                    disabled={total === 0}
                  >
                    All ({total})
                  </Button>
                </div>
              </div>
            </TabsContent>

            <TabsContent value="cluster" className="mt-0 flex flex-row min-h-0 flex-1">
              <div className="flex flex-col min-h-0 flex-1">
              {/* Row 1: actions + per-server pills */}
              <div className="flex items-center gap-2 px-2.5 pt-2.5 flex-wrap">
                <Button
                  variant="outline" size="sm"
                  onClick={async () => { await ConnectAllClusters().catch((e) => setError(String(e?.message ?? e))); await reloadClusterMeta(); }}
                >
                  Connect all
                </Button>
                <Button
                  variant="outline" size="sm"
                  onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterMeta(); }}
                >
                  Disconnect all
                </Button>
                {clusterServerStatuses.length === 0 && (
                  <span className="text-xs text-muted-foreground italic">
                    No active sessions — configure clusters in Settings → DX Cluster.
                  </span>
                )}
                {clusterServerStatuses.map((s) => {
                  const isMaster = clusterServers
                    .filter((x) => x.enabled)
                    .sort((a, b) => a.sort_order - b.sort_order)[0]?.id === s.server_id;
                  return (
                    <span
                      key={s.server_id}
                      className={cn(
                        'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border',
                        s.state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
                        s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
                        s.state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
                        'bg-muted text-muted-foreground border-border',
                      )}
                      title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
                    >
                      {isMaster && <span className="text-warning" title="Master (commands go here)">★</span>}
                      {s.name}
                      <span className="opacity-60 text-[9px] ml-0.5">{s.state.toUpperCase()}{s.retries ? ` #${s.retries}` : ''}</span>
                    </span>
                  );
                })}
                <div className="flex-1" />
                <Badge variant="secondary" className="text-[10px]">{spots.length} live</Badge>
                {clusterFiltersToggleBtn}
              </div>

              {/* Filters moved to the right-side panel (see below). */}

              {(() => {
                // Filtered + grouped spots (shared with the Main-view cluster
                // pane). All the filter state lives in the right-side panel.
                const rendered = clusterRenderedRows;
                if (rendered.length === 0) {
                  return (
                    <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
                      <Hash className="size-10 opacity-30" />
                      <div className="text-sm font-semibold text-foreground/70">
                        {clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
                      </div>
                      <div className="text-xs">
                        {clusterServerStatuses.some((s) => s.state === 'connected')
                          ? 'Spots will appear as the cluster sends them.'
                          : 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
                      </div>
                    </div>
                  );
                }
                return (
                  <ClusterGrid
                    rows={rendered as any}
                    spotStatus={spotStatus}
                    onSpotClick={handleSpotClick}
                  />
                );
              })()}

              {/* Console — the RAW cluster stream. Spots are parsed out of it into
                  the grid above, but SH/DX, WHO, the MOTD and error replies are not
                  spots: without this they were dropped and the command box looked
                  inert (you typed SH/DX/100 and nothing ever happened). */}
              {clusterConsoleOpen && (
                <div className="border-t border-border/60 shrink-0 flex flex-col" style={{ height: 200 }}>
                  <div className="flex items-center gap-2 px-2.5 py-1 bg-muted/30 border-b border-border/60 shrink-0">
                    <Terminal className="size-3.5 text-muted-foreground" />
                    <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
                      {t('cluster.console')}
                    </span>
                    <span className="text-[10px] text-muted-foreground tabular-nums">{clusterLines.length}</span>
                    <div className="flex-1" />
                    <button className="text-[11px] text-muted-foreground hover:text-foreground"
                      onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
                    <button className="text-muted-foreground hover:text-foreground"
                      onClick={() => setClusterConsoleOpen(false)} title={t('cluster.hideConsole')}>
                      <X className="size-3.5" />
                    </button>
                  </div>
                  <div ref={clusterConsoleRef} className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
                    {clusterLines.length === 0 ? (
                      <p className="text-muted-foreground italic">{t('cluster.consoleEmpty')}</p>
                    ) : clusterLines.map((l, i) => (
                      <div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
                        <span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
                        {l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
                        {l.text}
                      </div>
                    ))}
                  </div>
                </div>
              )}

              {/* Command input — sends to the master server. */}
              <div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
                {!clusterConsoleOpen && (
                  <button onClick={() => setClusterConsoleOpen(true)} title={t('cluster.showConsole')}
                    className="inline-flex items-center justify-center size-8 rounded-md border border-border hover:bg-muted shrink-0">
                    <Terminal className="size-3.5" />
                  </button>
                )}
                <span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
                <Input
                  className="font-mono text-xs h-8"
                  placeholder='sh/dx 30, set/needsdxcc, …'
                  value={clusterCmd}
                  onChange={(e) => setClusterCmd(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === 'Enter' && clusterCmd.trim()) {
                      SendClusterCommand(clusterCmd.trim())
                        .then(() => setClusterCmd(''))
                        .catch((err) => setError(String(err?.message ?? err)));
                    }
                  }}
                />
                <Button
                  variant="outline" size="sm"
                  onClick={() => {
                    if (!clusterCmd.trim()) return;
                    SendClusterCommand(clusterCmd.trim())
                      .then(() => setClusterCmd(''))
                      .catch((err) => setError(String(err?.message ?? err)));
                  }}
                  disabled={!clusterCmd.trim()}
                >
                  Send
                </Button>
              </div>
              </div>{/* /left column */}

              {/* Right-side filter panel (Log4OM style) — shared with the
                  Main-view cluster pane; toggle hides it in both places. */}
              {clusterShowFilters && renderClusterFilters()}
            </TabsContent>

            <TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
              <WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
                onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
                onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
            </TabsContent>

            {/* Opened on demand from Tools → QSL Manager; closable via the
                tab's × . forceMount keeps its state (and a running download
                updating) while you work on other tabs. */}
            {qslTabOpen && (
              <TabsContent value="qsl" forceMount className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                <QSLManagerPanel onEditQSO={openEdit} />
              </TabsContent>
            )}

            <TabsContent value="main" className="flex-1 min-h-0 p-0">
              {/* Two configurable panes (per-profile, Settings → Main view).
                  Each side shows one of: great-circle map, locator map, cluster
                  or worked-before. */}
              <div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2">
                <div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
                <div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
              </div>
            </TabsContent>

            <TabsContent value="awards" className="flex-1 min-h-0 p-0">
              <AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
            </TabsContent>

            {statsTabOpen && (
              <TabsContent value="stats" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                <StatsPanel />
              </TabsContent>
            )}

            {stationTabOpen && (
              <TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
                <StationControlPanel
                  centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
                  centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
                  bearing={dxPath?.bearingShort ?? null}
                />
              </TabsContent>
            )}

            {contestTabEnabled && (
              <TabsContent value="contest" className="flex-1 min-h-0 p-0">
                <ContestPanel session={contest} onChange={updateContest} />
              </TabsContent>
            )}

            {/* FlexRadio SmartSDR-style control panel — only present when the CAT
                backend is a FlexRadio. */}
            {catState.backend === 'flex' && (
              <TabsContent value="flex" className="flex-1 min-h-0 p-0">
                <FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
              onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
              </TabsContent>
            )}

            {/* Icom CI-V receive-DSP control panel — only when the CAT backend
                is an Icom. */}
            {catState.backend === 'icom' && (
              <TabsContent value="icom" className="flex-1 min-h-0 p-0">
                <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
              </TabsContent>
            )}

            {/* Band Map: several bands shown side-by-side (panadapter-style
                strips). Pick bands with the chips; each strip is clickable to
                tune the rig. */}
            {netEnabled && (
              <TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
                <NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
              </TabsContent>
            )}

            <TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
              <div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
                <span className="text-xs text-muted-foreground mr-1">{t('bmp.bandsLabel')}</span>
                {bands.map((b) => {
                  const on = bandMapBands.includes(b);
                  return (
                    <button key={b} type="button" onClick={() => toggleBandMapBand(b)}
                      className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
                        on ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
                      {b}
                    </button>
                  );
                })}
                <span className="ml-auto flex items-center gap-1">
                  <button type="button" onClick={toggleBandMapHideFt} title={t('bmp.hideFtTitle')}
                    className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
                      bandMapHideFt ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
                    {t('bmp.hideFt')}
                  </button>
                  <button type="button" onClick={toggleBandMapFit} title={t('bmp.fitTitle')}
                    className={cn('px-2 py-0.5 rounded-full border text-[11px] font-medium transition-colors',
                      bandMapFit ? 'border-primary bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:bg-muted')}>
                    {t('bmp.fitBand')}
                  </button>
                </span>
              </div>
              <div className="flex-1 min-h-0 flex gap-2 p-2 overflow-x-auto">
                {bandMapBands.length === 0 ? (
                  <div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
                    Pick one or more bands above to show their band maps side by side.
                  </div>
                ) : bandMapBands.map((b) => (
                  <div key={b} className="w-[260px] shrink-0 min-h-0 border border-border rounded-lg overflow-hidden flex flex-col">
                    <BandMap
                      band={b}
                      spots={spots.filter((s) => s.band === b)}
                      spotStatus={spotStatus}
                      currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
                      onSpotClick={handleSpotClick}
                      onClose={() => toggleBandMapBand(b)}
                      hideDigital={bandMapHideFt}
                      fitToBand={bandMapFit}
                    />
                  </div>
                ))}
              </div>
            </TabsContent>
          </Tabs>
        </section>

        {showBandMap && (
          <div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
            <BandMap
              side={bandMapSide}
              onToggleSide={toggleBandMapSide}
              band={band}
              spots={spots.filter((s) => s.band === band)}
              spotStatus={spotStatus}
              currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
              onSpotClick={handleSpotClick}
              onClose={() => setBandMapShown(false)}
            />
          </div>
        )}
      </div>
      </>}

      {/* ===== STATUS BAR ===== */}
      {!compact && (() => {
        const clusterUp = clusterServerStatuses.some((s) => s.state === 'connected');
        const catUp = catState.enabled && catState.connected;
        const Chip = ({ on, label, title, onClick, disabled }: { on: boolean; label: React.ReactNode; title?: string; onClick?: () => void; disabled?: boolean }) => (
          <button
            type="button"
            onClick={onClick}
            disabled={disabled}
            title={title}
            className={cn(
              'inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors',
              disabled ? 'opacity-50 cursor-default border-transparent'
                : 'border-border hover:bg-muted cursor-pointer',
            )}
          >
            <span className={cn('size-2 rounded-full', on ? 'bg-success' : 'bg-muted-foreground/40')} />
            {label}
          </button>
        );
        return (
          <footer className="relative flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
            <span className="text-[11px] text-muted-foreground">
              QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
            </span>
            <div className="w-px h-4 bg-border mx-1" />
            <Chip on={clusterUp} label="Cluster" title={clusterUp ? 'Cluster connected' : 'Cluster offline'} onClick={() => setActiveTab('cluster')} />
            <Chip
              on={catUp}
              label={<span className="inline-flex items-center gap-1"><RadioTower className="size-3" />{catUp ? (catState.rig || 'CAT') : (catState.enabled ? (shortCatError(catState.error) || 'CAT off') : 'CAT')}</span>}
              title={catUp ? `CAT: ${catState.rig || catState.backend || 'connected'}` : (catState.error || 'CAT')}
              onClick={() => { setSettingsSection('cat'); setShowSettings(true); }}
            />
            <Chip
              on={rotatorHeading.enabled && rotatorHeading.ok}
              label={rotatorHeading.enabled && rotatorHeading.ok ? `Rotator ${rotatorHeading.azimuth}°` : 'Rotator'}
              title={rotatorHeading.enabled ? (rotatorHeading.ok ? `Antenna heading ${rotatorHeading.azimuth}°` : 'Rotator: no position') : 'Rotator disabled'}
              disabled={!rotatorHeading.enabled}
              onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
            />
            {/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline.
                CLICK toggles OPERATE ↔ STANDBY on every backend (PGXL included —
                its direct TCP link takes operate=0/1); optimistic flip, the 2s
                poll reconciles. Offline → click opens the settings instead. */}
            {ampCfg.enabled && (() => {
              const isPGXL = ampCfg.type === 'pgxl';
              const name = isPGXL ? 'PGXL'
                : ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim()
                : `SPE ${ampSt.model || ''}`.trim();
              const dot = !ampSt.connected ? 'bg-danger' : ampSt.operate ? 'bg-success' : 'bg-warning';
              const state = !ampSt.connected ? (ampSt.last_error || 'offline') : ampSt.operate ? 'OPERATE' : 'STANDBY';
              const toggle = () => {
                // Offline — nothing to command; open the settings to fix the link.
                if (!ampSt.connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
                const want = !ampSt.operate;
                setAmpSt((s: any) => ({ ...s, operate: want }));
                (isPGXL ? PGXLSetOperate(want)
                  : ampCfg.type.startsWith('acom') ? ACOMSetOperate(want)
                  : SPESetOperate(want)).catch(() => {});
              };
              return (
                <button
                  type="button"
                  title={`${name}: ${state}${ampSt.connected ? (ampSt.operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
                  onClick={toggle}
                  className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
                >
                  <span className={cn('size-2 rounded-full', dot)} />
                  {name}
                </button>
              );
            })()}
            {/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
                logbook backend (only the live_status PUBLISHING is MySQL-specific),
                so it is always shown. Gating it on MySQL made it vanish for
                local-SQLite operators when the Settings toggle was removed. */}
            <div
              className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
                onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
              title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
            >
              <span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
              {onAir ? t('live.onAir') : t('live.offline')}
            </div>
            {/* Toasts / errors: the status bar's free space is far wider than the
                header band they used to sit in. Still one line (the bar is 28px),
                but CLICK opens the full text wrapped — long messages (a TQSL or
                Club Log failure) are no longer cut off with no way to read them. */}
            <div className="flex-1 min-w-0 flex items-center gap-1 px-1">
              {(error || toast) && (
                <>
                  <button
                    type="button"
                    onClick={() => setMsgOpen((o) => !o)}
                    title={t('msg.expand')}
                    className={cn(
                      'flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] min-w-0 max-w-full animate-in fade-in',
                      error
                        ? 'border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20'
                        : 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted/70',
                    )}
                  >
                    {error ? <AlertCircle className="size-3.5 shrink-0" /> : <Satellite className="size-3.5 shrink-0" />}
                    <span className="truncate">{error || toast}</span>
                  </button>
                  <button
                    type="button"
                    className="shrink-0 text-muted-foreground hover:text-foreground"
                    onClick={() => { setMsgOpen(false); if (error) setError(''); else dismissToast(); }}
                  >
                    <X className="size-3" />
                  </button>
                  {msgOpen && (
                    <>
                      <div className="fixed inset-0 z-40" onClick={() => setMsgOpen(false)} />
                      <div className="absolute bottom-7 left-2 z-50 w-[min(70vw,760px)] rounded-lg border border-border bg-card shadow-xl p-3">
                        <p className={cn('text-xs whitespace-pre-wrap break-words leading-relaxed',
                          error ? 'text-destructive' : 'text-foreground')}>
                          {error || toast}
                        </p>
                      </div>
                    </>
                  )}
                </>
              )}
            </div>
            {dbConn && (
              <button
                type="button"
                onClick={() => { setSettingsSection('database'); setShowSettings(true); }}
                title={dbConn.backend === 'mysql' ? `Shared MySQL logbook — ${dbConn.label}` : `Local SQLite logbook — ${dbConn.label}`}
                className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground max-w-[340px]"
              >
                <Database className={cn('size-3 shrink-0', dbConn.backend === 'mysql' ? 'text-success' : 'text-muted-foreground')} />
                <span className="font-mono truncate">{dbConn.label}</span>
              </button>
            )}
          </footer>
        );
      })()}

      {editingQSO && (
        <QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
      )}

      <BulkEditModal
        open={bulkEditOpen}
        ids={bulkEditIds}
        onClose={() => setBulkEditOpen(false)}
        onApplied={(n) => { afterBulkUpdate(n, 'in bulk'); }}
      />

      <SendSpotModal
        open={showSpotModal}
        onClose={() => setShowSpotModal(false)}
        // Callsign: the QSO-entry call, else the last logged QSO.
        defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
        // Freq (0.1 kHz): the LIVE CAT frequency in Hz when connected — NOT the
        // freqMhz display string, which the manual-edit freeze / field locks can
        // leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
        // string spotted 14134). Fall back to the entry field, then the last QSO.
        defaultFreqKHz={
          catState.connected && (catState.freq_hz ?? 0) > 0
            ? Math.round(((catState.freq_hz ?? 0) / 1000) * 10) / 10
            : parseFloat(freqMhz) > 0
              ? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
              : (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
        }
        defaultMode={mode || qsos[0]?.mode || ''}
        targetName={
          clusterServers
            .filter((s) => s.enabled)
            .sort((a, b) => a.sort_order - b.sort_order)[0]?.name
        }
        recent={qsos.slice(0, 8).map((q): RecentSpotQSO => ({
          callsign: q.callsign,
          freqKHz: q.freq_hz ? Math.round((q.freq_hz / 1000) * 10) / 10 : 0,
          mode: q.mode ?? '',
          band: q.band,
        }))}
        onSend={async (call, freqKHz, comment) => {
          await SendClusterSpot(call, freqKHz, comment);
          const target = clusterServers
            .filter((s) => s.enabled)
            .sort((a, b) => a.sort_order - b.sort_order)[0]?.name;
          showToast(`Spot ${call} sent${target ? ` on ${target}` : ''}`);
        }}
      />

      {showSettings && (
        <SettingsModal
          initialSection={settingsSection}
          onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
          onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
          onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
          flexAvailable={catState.backend === 'flex'}
          icomAvailable={catState.backend === 'icom'}
        />
      )}

      {alertsOpen && (
        <AlertsModal onClose={() => setAlertsOpen(false)} bands={bands} modes={modes} countries={countries} />
      )}

      {showDuplicates && (
        <DuplicatesModal onClose={() => setShowDuplicates(false)} onDeleted={() => refresh()} />
      )}

      <AutoEQSL
        onSent={(call) => showToast(`OpsLog QSL sent to ${call}`)}
        onError={(msg) => showToast(msg)}
      />
      <QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
      <SendEQSLModal
        open={eqslQsoId !== null}
        qsoId={eqslQsoId}
        onClose={() => setEqslQsoId(null)}
        onOpenDesigner={() => setQslDesignerOpen(true)}
      />

      {deletingIds.length > 0 && (() => {
        const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null;
        return (
          <ConfirmDialog
            title={deletingIds.length > 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'}
            message={single
              ? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.`
              : `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`}
            confirmLabel="Delete"
            danger
            onConfirm={confirmDelete}
            onCancel={() => setDeletingIds([])}
          />
        );
      })()}
      {showDeleteAll && (
        <ConfirmDialog
          title="Delete ALL QSOs?"
          message={`This will permanently wipe every QSO from the logbook (${total.toLocaleString('en-US')} contacts). This cannot be undone. Consider exporting an ADIF backup first.`}
          confirmLabel={deletingAll ? 'Deleting…' : `Delete ${total.toLocaleString('en-US')} QSOs`}
          confirmPhrase="DELETE ALL"
          danger
          onConfirm={confirmDeleteAll}
          onCancel={() => { if (!deletingAll) setShowDeleteAll(false); }}
        />
      )}
      <FilterBuilder
        open={filterOpen}
        initial={activeFilter}
        onApply={(f) => { setActiveFilter(f); setFilterOpen(false); }}
        onClose={() => setFilterOpen(false)}
      />
      {showExportChoice && (
        <Dialog open onOpenChange={(o) => { if (!o) setShowExportChoice(false); }}>
          <DialogContent className="max-w-lg px-6">
            <DialogHeader className="px-2">
              <DialogTitle>Export ADIF</DialogTitle>
              <DialogDescription>
                Choose which fields to include. OpsLog writes ADIF 3.1.7.
              </DialogDescription>
            </DialogHeader>
            <div className="px-2 py-1 space-y-2.5">
              <button
                type="button"
                onClick={() => runExport(false)}
                className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
              >
                <div className="font-semibold text-sm">Standard ADIF only</div>
                <div className="text-xs text-muted-foreground mt-0.5">
                  Only fields defined in the ADIF 3.1.7 spec — portable to other loggers (Log4OM, N1MM, LoTW…).
                  Application-specific <span className="font-mono">APP_*</span> and any non-standard / vendor tags are stripped.
                </div>
              </button>
              <button
                type="button"
                onClick={() => runExport(true)}
                className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
              >
                <div className="font-semibold text-sm">All fields (OpsLog round-trip)</div>
                <div className="text-xs text-muted-foreground mt-0.5">
                  Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags —
                  a lossless backup you'll re-import into OpsLog.
                </div>
              </button>
            </div>
            <DialogFooter className="px-2 bg-transparent border-t-0">
              <Button variant="outline" onClick={() => setShowExportChoice(false)}>Cancel</Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      )}
      {pendingImportPath && (
        <Dialog open onOpenChange={(o) => { if (!o) setPendingImportPath(null); }}>
          <DialogContent className="max-w-lg px-6">
            <DialogHeader className="px-2">
              <DialogTitle>Import ADIF</DialogTitle>
              <DialogDescription className="text-xs break-all">
                {pendingImportPath}
              </DialogDescription>
            </DialogHeader>
            <div className="py-2 px-2 space-y-2">
              <div className="text-xs text-muted-foreground">
                Duplicate = same <span className="font-medium text-foreground">callsign + UTC minute + band + mode</span> as a QSO already in the log.
              </div>
              {([
                { id: 'skip', title: 'Skip duplicates', desc: 'Leave existing QSOs untouched, only add new ones. Safe default.' },
                { id: 'update', title: 'Update duplicates', desc: 'Refresh existing QSOs with this file — merges its non-empty fields (QSL/LoTW/eQSL/QRZ statuses & dates, etc.) onto the matching QSO. Use this to re-sync from Log4OM or LoTW. Fields the file omits are kept.' },
                { id: 'all', title: 'Import everything', desc: 'Insert every record, duplicates included. For intentionally merging two overlapping logs.' },
              ] as const).map((o) => (
                <button
                  key={o.id}
                  type="button"
                  onClick={() => setImportDupMode(o.id)}
                  className={`w-full text-left rounded-lg border p-2.5 transition-colors ${
                    importDupMode === o.id
                      ? 'border-primary bg-accent/40 ring-1 ring-primary/40'
                      : 'border-border hover:border-primary/60 hover:bg-accent/20'
                  }`}
                >
                  <div className="flex items-center gap-2">
                    <span className={`size-3.5 rounded-full border flex items-center justify-center ${importDupMode === o.id ? 'border-primary' : 'border-muted-foreground/50'}`}>
                      {importDupMode === o.id && <span className="size-1.5 rounded-full bg-primary" />}
                    </span>
                    <span className="font-semibold text-sm">{o.title}</span>
                  </div>
                  <div className="text-xs text-muted-foreground mt-0.5 pl-5.5">{o.desc}</div>
                </button>
              ))}
              <label className="flex items-start gap-2 text-sm cursor-pointer pt-1 border-t mt-1">
                <Checkbox
                  checked={importApplyCty}
                  onCheckedChange={(c) => setImportApplyCty(!!c)}
                  className="mt-0.5"
                />
                <span>
                  Fix country &amp; zones (cty.dat + ClubLog)
                  <span className="block text-xs text-muted-foreground mt-0.5">
                    Recompute Country, DXCC &amp; CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use <strong>Update duplicates</strong> to re-fix QSOs already in your log.
                  </span>
                </span>
              </label>
              <label className="flex items-start gap-2 text-sm cursor-pointer pt-1">
                <Checkbox
                  checked={importApplyStation}
                  onCheckedChange={(c) => setImportApplyStation(!!c)}
                  className="mt-0.5"
                />
                <span>
                  Fill my station fields from my profile
                  <span className="block text-xs text-muted-foreground mt-0.5">
                    Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log.
                  </span>
                </span>
              </label>
            </div>
            <DialogFooter className="px-2 bg-transparent border-t-0">
              <Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
              <Button onClick={runImport}>Import</Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
      )}

      {importing && (
        <Dialog open>
          <DialogContent className="max-w-sm px-6" hideClose>
            <DialogHeader className="px-2">
              <DialogTitle>Importing ADIF…</DialogTitle>
            </DialogHeader>
            <div className="px-2 pb-4 space-y-2">
              {(() => {
                const done = importProgress?.processed ?? 0;
                const tot = importProgress?.total ?? 0;
                const pct = tot > 0 ? Math.min(100, Math.round((done / tot) * 100)) : 0;
                return (
                  <>
                    <div className="h-2 w-full rounded-full bg-muted overflow-hidden">
                      <div
                        className={cn('h-full bg-primary rounded-full transition-[width] duration-150', tot === 0 && 'w-1/3 animate-pulse')}
                        style={tot > 0 ? { width: `${pct}%` } : undefined}
                      />
                    </div>
                    <div className="text-xs text-muted-foreground text-center font-mono">
                      {tot > 0
                        ? `${done.toLocaleString()} / ${tot.toLocaleString()} records · ${pct}%`
                        : `${done.toLocaleString()} records…`}
                    </div>
                  </>
                );
              })()}
            </div>
          </DialogContent>
        </Dialog>
      )}
    </div>
  );
}
