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

import {
  AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
  OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
  GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
  UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
  LookupCallsign, GetStationSettings, GetListsSettings,
  GetStartupStatus,
  WorkedBefore,
  SetCompactMode,
  GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
  RefreshCtyDat,
  RotatorGoTo, RotatorStop, GetRotatorHeading,
  OpenExternalURL,
  ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
  ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
  GetCATSettings,
  OperatingDefaultForBand,
  LogUDPLoggedADIF,
  ListCountries,
  GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
  WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
  GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
  QSOAudioBegin, QSOAudioCancel,
  GetAwardDefs,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
import { EventsOn } 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 { QSLManagerPanel } from '@/components/QSLManagerModal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
import { MainMap } from '@/components/MainMap';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel';
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 { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
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 } 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 (voice + CW). Mirrors recordableMode() in
// app.go — digital modes carry no useful audio and are never recorded.
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']);

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: undefined, stx: undefined,
  email: '',
  award_refs: '',
};

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 '';
}

// 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';
}
// 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() {
  // === 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 };
      // 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 (wasLocked) {
        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);
        }
      }
      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-amber-600 hover:text-amber-700' : '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('');
  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);
  const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
  // 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.
  function onBandUserChange(v: string) {
    setBand(v);
    noteManualEdit();
    if (catState.enabled && catState.connected) {
      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) {
      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]);
  // Bearing/distance from operator's home grid to the remote station —
  // shown live in the entry strip (SP azimuth) and Info tab (LP + dist).

  // === Logbook list ===
  const [qsos, setQsos] = useState<QSO[]>([]);
  const [total, setTotal] = useState<number>(0);
  const [error, setError] = useState('');
  const [migratedBanner, setMigratedBanner] = useState(false);
  // Transient success toast (bottom-right, auto-dismiss). Used for things
  // like "spot sent" where a blocking error banner would be overkill.
  const [toast, setToast] = useState('');
  const showToast = useCallback((msg: string) => {
    setToast(msg);
    window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
  }, []);
  // Error banners auto-dismiss after a few seconds (longer than toasts since
  // they may be multi-line). The X button still closes them immediately.
  useEffect(() => {
    if (!error) return;
    const t = window.setTimeout(() => setError(''), 6000);
    return () => window.clearTimeout(t);
  }, [error]);
  // 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);
  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]);
  const [saving, setSaving] = useState(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 [activeTab, setActiveTab] = useState('recent');
  // QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
  const [qslTabOpen, setQslTabOpen] = useState(false);
  function closeQslTab() {
    setQslTabOpen(false);
    setActiveTab((t) => (t === 'qsl' ? '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(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);

  // === 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);
  // "You have been spotted" banner — set when a cluster spot's DX call is our
  // own station callsign. Ref holds our call for the (one-shot) spot listener.
  const [selfSpot, setSelfSpot] = useState<{ spotter: string; freqKHz: number; band?: string; comment?: string; at: number } | null>(null);
  const myCallRef = useRef('');
  const selfSpotTimerRef = useRef<number | null>(null);

  // === 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
  // F1-F12 macro shortcuts active only when the keyer is enabled + connected.
  const wkActiveRef = useRef(false);
  const wkEscClearsRef = useRef(true);
  useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]);
  useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);

  // === Digital Voice Keyer (DVK) ===
  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;
  const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
  const [clusterGroup, setClusterGroup] = useState(true);
  const [clusterCmd, setClusterCmd] = useState('');
  // Multi-band filter: empty set = all bands. The user toggles chips.
  const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
  // 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(false);
  const [clusterLockMode, setClusterLockMode] = useState(false);
  // Status filter chips. Empty set = show every status (including
  // already-worked). Otherwise only matching spots pass.
  type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
  const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
  // 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>>(new Set());
  const [clusterSearch, setClusterSearch] = useState('');
  const [showBandMap, setShowBandMap] = useState(false);
  // Which side the band map docks to (persisted). Toggled from its header.
  const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
    () => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
  );
  const toggleBandMapSide = useCallback(() => {
    setBandMapSide((s) => {
      const next = s === 'right' ? 'left' : 'right';
      localStorage.setItem('bandmap.side', next);
      return next;
    });
  }, []);
  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 }>>({});

  // === Modals ===
  const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
  const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const [showSettings, setShowSettings] = useState(false);
  // 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 [deletingAll, setDeletingAll] = useState(false);
  const [ctyRefreshing, setCtyRefreshing] = 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);
  // 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);
  const [wb, setWb] = useState<WB | null>(null);
  const [wbBusy, setWbBusy] = useState(false);
  // 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]);

  // 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: '',
  });
  myCallRef.current = (station.callsign || '').toUpperCase();

  // 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]);

  const refresh = useCallback(async () => {
    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;
      setQsos(list);
      setTotal(n);
      setMatchCount(matched);
      setError('');
    } catch (e: any) {
      setError(String(e?.message ?? e));
    }
  }, [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);
    return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
  }, [refresh]);

  // 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); };
  }, []);

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

  // 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;
    } 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
  }, []);

  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);
  }

  useEffect(() => { refresh(); }, [refresh]);
  useEffect(() => {
    (async () => {
      try {
        const st = await GetStartupStatus();
        if (!st.ok) { setError(`Startup failed: ${st.err}\nDB path: ${st.db_path}`); return; }
        if ((st as any).migrated_from_app_data) setMigratedBanner(true);
      } catch {}
      loadStation();
      loadLists();
      loadCATCfg();
      // Hydrate CAT state on mount (the backend may already be polling).
      try { setCatState(await GetCATState()); } catch {}
    })();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // 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) => {
      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.
      // In split we take the rig's real RX freq; otherwise RX mirrors TX
      // (the user can still override it by hand). The freq lock covers both.
      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 priority:
      //   1. If freq matches a known digital watering hole, pick the specific
      //      mode for that hole (FT8 / FT4) — beats whatever CAT reports.
      //   2. Else if CAT reports DATA (generic), use the user's configured
      //      default digital mode (FT8 by default).
      //   3. Else trust CAT (SSB, CW, AM, FM…).
      if (!lk.mode) {
        const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
        if (inferred) {
          setMode(inferred);
        } else if (s.mode === 'DATA') {
          setMode(digitalDefaultRef.current || 'FT8');
        } else if (s.mode) {
          setMode(s.mode);
        }
      }
    });
    return () => { unsub?.(); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // 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)));
    });
    const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
      setSpots((arr) => {
        const next = [sp, ...arr];
        return next.length > SPOTS_CAP ? next.slice(0, SPOTS_CAP) : next;
      });
      // Self-spot: someone spotted OUR callsign on the cluster.
      const mine = myCallRef.current;
      if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
        setSelfSpot({ spotter: cleanSpotter(sp.spotter ?? ''), freqKHz: sp.freq_khz, band: sp.band, comment: sp.comment, at: Date.now() });
        // Auto-hide 3 s after the last self-spot; a new one resets the timer.
        if (selfSpotTimerRef.current) window.clearTimeout(selfSpotTimerRef.current);
        selfSpotTimerRef.current = window.setTimeout(() => setSelfSpot(null), 3000);
      }
    });
    return () => { unsubState?.(); unsubSpot?.(); };
    // 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(() => {
    const unsubDX = EventsOn('udp:dx_call', (p: any) => {
      const call = String(p?.call ?? '').trim();
      if (!call) return;
      // Don't clobber what the user is currently typing — only update
      // when the entry field is empty or matches a previous broadcast.
      onCallsignInput(call);
    });
    const unsubRC = EventsOn('udp:remote_call', (call: string) => {
      if (call) onCallsignInput(String(call).trim());
    });
    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);
      }
    });
    return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); };
    // 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);
    } catch { /* keyer not configured */ }
  }, []);
  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 != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '',
    };
    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();
  }
  function wkSend(rawText: string) { setWkSent(''); WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); }
  function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); }
  wkSendMacroRef.current = wkSendMacro;
  // send-on-type: key the typed chars verbatim (no variable substitution).
  function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
  function wkBackspace() { 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 }[] = [];
      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 });
      }
      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,
            };
          }
          return next;
        });
      } catch {}
    }, 400);
    return () => window.clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [spots]);

  async function save() {
    if (!callsign.trim()) { setError('Callsign required'); return; }
    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 start = qsoStartedAt ?? now;
      const end = (locks.end && qsoEndedAt) ? qsoEndedAt : now;
      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: details.srx, stx: details.stx,
        email: details.email,
      };
      applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
      await AddQSO(payload);
      resetEntry();
      await refresh();
    } catch (e: any) {
      setError(String(e?.message ?? e));
    } finally { setSaving(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() {
    // Discard any in-progress QSO recording (no-op if it was already saved on
    // log, or if the recorder is off).
    QSOAudioCancel(); setRecording(false);
    setCallsign(''); setComment(''); setNote('');
    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) {
    const q = editingQSO; setEditingQSO(null);
    if (q) setDeletingQSO(q); else askDelete(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}`);
  }
  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)); }
  }
  function askDelete(id: number) {
    const q = qsos.find((x) => x.id === id);
    if (q) setDeletingQSO(q);
  }
  async function confirmDelete() {
    if (!deletingQSO) return;
    try {
      await DeleteQSO(deletingQSO.id);
      if (selectedId === deletingQSO.id) setSelectedId(null);
      setDeletingQSO(null);
      await refresh();
    } catch (err: any) {
      setError(String(err?.message ?? err));
      setDeletingQSO(null);
    }
  }
  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();
    setLookupBusy(true);
    try {
      const r = await LookupCallsign(call);
      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')    && (r.grid ?? '') !== '')    setGrid(r.grid ?? '');
      // 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);
    } catch (e: any) {
      setLookupResult(null);
      setLookupError(String(e?.message ?? e));
    } finally { setLookupBusy(false); }
  }
  function scheduleLookup(value: string) {
    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;
    }
    lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400);
    wbTimerRef.current     = window.setTimeout(() => runWorkedBefore(call), 150);
  }
  function onCallsignInput(v: string) {
    // 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); }
    const wasEmpty = callsign.trim() === '';
    const isEmpty = v.trim() === '';
    if (wasEmpty && !isEmpty && !locks.start) {
      // First keystroke of a new QSO — freeze the start time so it doesn't
      // drift even if the lookup or typing takes 30 seconds. Skip when
      // start is locked: the user is back-entering a past QSO and set a
      // specific time manually.
      setQsoStartedAt(new Date());
    } else if (isEmpty && !locks.start) {
      // Callsign wiped → user abandoned this QSO; reset the timer.
      setQsoStartedAt(null);
    }
    setCallsign(v);
    scheduleLookup(v);
  }
  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);
      // Reuse the error banner area for a brief success note (4s auto-dismiss).
      const msg = `ADIF exported${includeAppFields ? ' (full)' : ' (standard)'}: ${res.count.toLocaleString()} QSOs → ${res.path} (${res.size_kb.toLocaleString()} KB)`;
      setError(msg);
      setTimeout(() => setError((e) => e === msg ? '' : e), 4000);
    } 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);
      setImportResult(res);
      await refresh();
    } catch (e: any) {
      setError(String(e?.message ?? e));
    } finally {
      setImporting(false);
      setImportProgress(null);
    }
  }

  const menus: Menu[] = useMemo(() => [
    { name: 'file', label: 'File', items: [
      { type: 'item', label: 'Import ADIF…', action: 'file.import', shortcut: 'Ctrl+O' },
      { type: 'item', label: exporting ? 'Exporting…' : 'Export ADIF…', action: 'file.export', shortcut: 'Ctrl+E', disabled: exporting || total === 0 },
      { type: 'separator' },
      { type: 'item', label: 'Delete all QSOs…', action: 'file.deleteall', disabled: total === 0 },
      { type: 'separator' },
      { type: 'item', label: 'Exit', action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true },
    ]},
    { name: 'edit', label: 'Edit', items: [
      { type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
      { type: 'item', label: 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
      { type: 'separator' },
      { type: 'item', label: 'Preferences…', action: 'edit.prefs' },
    ]},
    { name: 'view', label: 'View', items: [
      { type: 'item', label: 'Refresh', action: 'view.refresh', shortcut: 'F5' },
      { type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
    ]},
    { name: 'tools', label: 'Tools', items: [
      { type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
      { type: 'separator' },
      { type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
      { type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
      { 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 },
    ]},
    { name: 'help', label: 'Help', items: [
      { type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
    ]},
  ], [total, selectedId, ctyRefreshing, exporting, wkEnabled, dvkEnabled]);

  function handleMenu(action: string) {
    switch (action) {
      case 'file.import':       importAdif(); break;
      case 'file.export':       exportAdif(); break;
      case 'file.deleteall':    setShowDeleteAll(true); 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':       if (selectedId !== null) askDelete(selectedId); break;
      case 'edit.prefs':        setShowSettings(true); break;
      case 'tools.qslmanager':  setQslTabOpen(true); setActiveTab('qsl'); break;
      case 'tools.winkeyer':    wkSetEnabled(!wkEnabled); break;
      case 'tools.dvk':         setDvkEnabled((v) => !v); break;
      case 'tools.refreshCty':  refreshCtyDat(); break;
    }
  }

  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;
        if (keyerLive) 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(); askDelete(selectedId); 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, 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-36">
      <Label className="mb-1 flex items-center gap-2 h-3.5">
        Callsign
        {lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up…</Badge>}
        {!lookupBusy && lookupResult && (
          <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
            <CheckCircle2 className="size-2.5 mr-1" />{lookupResult.source}
          </Badge>
        )}
        {!lookupBusy && !lookupResult && lookupError && (
          <Badge variant="destructive" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider">{lookupError}</Badge>
        )}
      </Label>
      <div className="relative">
      {recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
        <span 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-red-600 whitespace-nowrap pointer-events-none">
          <span className="size-2 rounded-full bg-red-600 animate-pulse" />
          {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
        </span>
      )}
      <Input
        ref={callsignRef}
        className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
        value={callsign}
        onChange={(e) => onCallsignInput(e.target.value)}
        // Start the QSO recording when leaving the callsign field (the pre-roll
        // covers the seconds before). No-op when the recorder is off.
        onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
      />
      </div>
    </div>
  );
  const rstTxBlock = (
    <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
      <Combobox value={rstSent} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
    </div>
  );
  const rstRxBlock = (
    <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
      <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
    </div>
  );
  const startBlock = (
    <div className="flex flex-col w-28">
      <Label className="mb-1 h-3.5 flex items-center gap-1 text-emerald-700">Start UTC <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-rose-700">End UTC <LockBtn k="end" title="end time" /></Label>
      <Input
        readOnly={!locks.end}
        tabIndex={locks.end ? 0 : -1}
        value={endFocused ? endInputStr : (locks.end ? (qsoEndedAt ? fmtHMSUTC(qsoEndedAt) : '') : (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">Name</Label>
      <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
    </div>
  );
  const qthBlock = (
    <div className="flex flex-col flex-1 min-w-[90px]"><Label className="mb-1 h-3.5">QTH</Label>
      <Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
    </div>
  );
  const gridBlock = (
    <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">Grid</Label>
      <Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
    </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">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">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">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 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 mr-0.5"
            referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
        )}
      </Label>
      <div className="flex-1 min-w-0">
        <Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
      </div>
    </div>
  );
  const cqBlock = (
    <div className="flex flex-col w-10"><Label className="mb-1 h-3.5 text-[10px]">CQ</Label>
      <Input inputMode="numeric" maxLength={2} className="font-mono text-center px-0.5 text-xs h-7" value={details.cqz ?? ''} placeholder="—"
        onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ cqz: v === '' ? undefined : parseInt(v, 10) }); }} />
    </div>
  );
  const ituBlock = (
    <div className="flex flex-col w-10"><Label className="mb-1 h-3.5 text-[10px]">ITU</Label>
      <Input inputMode="numeric" maxLength={2} className="font-mono text-center px-0.5 text-xs h-7" value={details.ituz ?? ''} placeholder="—"
        onChange={(e) => { const v = e.target.value.replace(/\D/g, ''); updateDetails({ ituz: v === '' ? undefined : parseInt(v, 10) }); }} />
    </div>
  );
  const freqBlock = (
    <div className="flex flex-col w-32">
      <Label className="mb-1 h-3.5 flex items-center gap-1">{catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} <LockBtn k="freq" title="frequency" /></Label>
      <Input
        tabIndex={-1}
        className="font-mono"
        value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
        placeholder="14.250"
        onFocus={() => setFreqFocused(true)}
        onBlur={() => setFreqFocused(false)}
        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-rose-600')}>RX Freq (MHz)</Label>
      <Input
        tabIndex={-1}
        value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
        placeholder="14.255"
        onFocus={() => setFreqFocused(true)}
        onBlur={() => setFreqFocused(false)}
        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-rose-50/40 border-rose-200 focus:bg-card')}
      />
    </div>
  );
  const bandRxBlock = (
    <div className="flex flex-col w-28"><Label className="mb-1 h-3.5">RX Band</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">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">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-2">
        {clusterServerStatuses.some((s) => s.state === 'connected') && (
          <Button type="button" variant="outline" onClick={() => setShowSpotModal(true)} className="h-8" title="Send a DX spot to the master cluster">
            <Satellite className="size-3.5" />
            Spot
          </Button>
        )}
        <Button type="button" variant="outline" onClick={() => { resetEntry(); callsignRef.current?.focus(); }} className="h-8"
          title="Clear the QSO entry (always — unlike Esc which may be reserved for the CW keyer)">
          <Eraser className="size-3.5" />
          Clear
        </Button>
        <Button onClick={save} disabled={saving} className="h-8">
          <Send className="size-3.5" />
          {saving ? '…' : 'Log QSO'}
        </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 ? '…' : 'Log QSO'}
      </Button>
    </div>
  );

  return (
    <div className="flex flex-col h-screen 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-emerald-100 text-emerald-700 hover:bg-emerald-100 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] 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)]" />
            <span className="font-bold text-[15px] tracking-tight">OpsLog</span>
            <span className="text-[11px] text-muted-foreground">v0.1</span>
          </div>

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

          <div className="flex items-center justify-center gap-2 font-mono">
            <div className="flex flex-col items-end leading-none">
              <span className="text-[22px] 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-rose-600 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-amber-100 text-amber-800 border-amber-300 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 = pathBetween(station.my_grid, grid);
              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-sky-300 bg-sky-100 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-sky-800 hover:bg-sky-200 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-sky-300 text-[10px] transition-colors',
                      disabled
                        ? 'text-muted-foreground/60 cursor-not-allowed'
                        : 'text-sky-800 hover:bg-sky-200 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-sky-300 text-rose-700 hover:bg-rose-100 hover:text-rose-800 cursor-pointer transition-colors"
                  >
                    <Square className="size-2.5 fill-current" />
                  </button>
                </div>
              );
            })()}
          </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-white/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={() => setShowBandMap((v) => !v)}
              title="Toggle band map (visible across all tabs)"
              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>
      )}

      {/* Transient toasts (bottom-right). Errors stack on top of the green
          success toast; both auto-dismiss. */}
      {migratedBanner && (
        <div className="fixed top-4 left-1/2 -translate-x-1/2 z-[110] flex items-start gap-3 rounded-lg border border-emerald-400 bg-emerald-50 text-emerald-900 px-4 py-3 text-sm shadow-xl max-w-lg animate-in fade-in slide-in-from-top-2">
          <span className="flex-1">
            <strong>Migration complete.</strong> Your data has been copied to the data folder next to OpsLog.exe.
            Please <strong>restart OpsLog</strong> to use the new location.
          </span>
          <button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setMigratedBanner(false)}>×</button>
        </div>
      )}

      {(error || toast) && (
        <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
          {error && (
            <div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 text-destructive px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
              <AlertCircle className="size-4 mt-0.5 shrink-0" />
              <pre className="flex-1 font-sans whitespace-pre-wrap m-0 leading-snug">{error}</pre>
              <button className="ml-1 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3.5" /></button>
            </div>
          )}
          {toast && (
            <div className="flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
              <Satellite className="size-4 shrink-0" />
              <span>{toast}</span>
              <button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
            </div>
          )}
        </div>
      )}

      {/* "You have been spotted" banner — shows when our own callsign appears
          in a cluster spot (Log4OM-style). Floated as a bottom-center overlay
          so it never shifts the layout (push-down / spring-back) and never
          covers the entry fields; auto-hides 3s after the last self-spot. */}
      {!compact && selfSpot && (
        <div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2">
          <RadioTower className="size-3.5 shrink-0" />
          <span>
            You've been spotted by <strong className="font-mono">{selfSpot.spotter || '?'}</strong>
            {' '}on <strong className="font-mono">{selfSpot.freqKHz?.toFixed(1)} kHz</strong>
            {selfSpot.band ? ` (${selfSpot.band})` : ''}
            {selfSpot.comment ? <span className="text-amber-800"> — {selfSpot.comment}</span> : null}
          </span>
          <div className="flex-1" />
          <button className="text-amber-700 hover:text-amber-900" title="Dismiss" onClick={() => setSelfSpot(null)}>
            <X className="size-3.5" />
          </button>
        </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-2 px-3 py-2 flex-1 min-w-[560px] max-w-[760px] border rounded-lg')}
        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 + CQ/ITU zones, then Start/End at right. */}
            <div className="flex gap-2 items-end">
              {callsignBlock}
              {rstTxBlock}
              {rstRxBlock}
              {cqBlock}
              {ituBlock}
              <div className="ml-auto flex gap-2">
                {startBlock}
                {endBlock}
              </div>
            </div>

            {/* Row 2: wide Name + QTH + Grid across the full width. */}
            <div className="flex gap-2 items-end">
              {nameBlock}
              {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 — plus the action buttons. */}
            <div className="flex gap-2 items-end">
              {freqBlock}
              {rxFreqBlock}
              {bandRxBlock}
              <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 && (
        <div className="w-[560px] shrink-0 min-h-0 flex flex-col">
          <DetailsPanel
            callsign={callsign}
            prefix={prefix}
            operatorGrid={station.my_grid}
            remoteGrid={grid}
            details={details}
            onChange={updateDetails}
            wb={wb}
            wbBusy={wbBusy}
            band={band}
            mode={mode}
            tab={detailTab}
            onTab={setDetailTab}
            keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
          />
        </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 && (wkEnabled || dvkEnabled || lookupResult?.image_url) && (
        <div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
          {dvkEnabled && (
            <div className="flex-1 min-w-0 min-h-0">
              <DvkPanel
                messages={dvkMsgs}
                status={dvkStat}
                onPlay={dvkPlay}
                onStop={() => DVKStop()}
                onClose={() => setDvkEnabled(false)}
              />
            </div>
          )}
          {wkEnabled && (
            <div className="flex-1 min-w-0 min-h-0">
              <WinkeyerPanel
                status={wkStatus}
                ports={wkPorts}
                port={wkPort}
                wpm={wkWpm}
                macros={wkMacros}
                sent={wkSent}
                onSelectPort={wkSelectPort}
                onRefreshPorts={reloadWkPorts}
                onConnect={() => WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))}
                onDisconnect={() => WinkeyerDisconnect().catch(() => {})}
                onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
                onSend={wkSend}
                onSendMacro={wkSendMacro}
                onStop={() => WinkeyerStop().catch(() => {})}
                onClose={() => wkSetEnabled(false)}
                sendOnType={wkSendOnType}
                onToggleSendOnType={wkToggleSendOnType}
                onSendRaw={wkSendRaw}
                onBackspace={wkBackspace}
              />
            </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 */}

      {/* ===== LOWER: tabs+table | call history | (optional) 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-[260px_1fr]' : 'grid-cols-[1fr_260px]') : '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">Main</TabsTrigger>
              <TabsTrigger value="recent">
                Recent QSOs
                <Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">{qsos.length}</Badge>
              </TabsTrigger>
              <TabsTrigger value="cluster">Cluster</TabsTrigger>
              <TabsTrigger value="worked">
                Worked before
                {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">Awards</TabsTrigger>
              {/* Not a tab — QRZ blocks embedding, so this opens the call's
                  QRZ.com page in the system browser. Styled like a trigger. */}
              <button
                type="button"
                disabled={!callsign.trim()}
                title={callsign.trim() ? `Open ${callsign.trim().toUpperCase()} on QRZ.com` : 'Enter a callsign first'}
                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="inline-flex items-center justify-center gap-1 whitespace-nowrap px-3 py-1.5 text-xs font-medium text-muted-foreground border-b-2 border-transparent transition-all hover:text-foreground disabled:pointer-events-none disabled:opacity-50 -mb-px"
              >
                QRZ.com ↗
              </button>
              {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-amber-50 border-amber-300 text-amber-800'
                    : 'bg-emerald-50 border-emerald-300 text-emerald-800',
                )}>
                  <div className="flex items-center gap-3 flex-wrap">
                    <strong>Import complete.</strong>
                    <Badge variant="outline" className="bg-white/60 font-mono text-emerald-700 border-emerald-300">{importResult.imported} imported</Badge>
                    {importResult.updated > 0 && (
                      <Badge variant="outline" className="bg-white/60 font-mono text-violet-700 border-violet-300">{importResult.updated} updated</Badge>
                    )}
                    {importResult.duplicates > 0 && (
                      <Badge variant="outline" className="bg-white/60 font-mono text-sky-700 border-sky-300">{importResult.duplicates} duplicates</Badge>
                    )}
                    <Badge variant="outline" className="bg-white/60 font-mono text-amber-700 border-amber-300">{importResult.skipped} skipped</Badge>
                    <Badge variant="outline" className="bg-white/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
                rows={qsos as any}
                total={total}
                onRowDoubleClicked={(q) => openEdit(q.id as number)}
                onUpdateFromCty={bulkUpdateFromCty}
                onUpdateFromQRZ={bulkUpdateFromQRZ}
                onUpdateFromClublog={bulkUpdateFromClublog}
                onSendTo={bulkSendTo}
                onSendRecording={bulkSendRecording}
                onExportSelected={exportSelectedADIF}
                onExportFiltered={exportFilteredADIF}
                onRowSelected={(id) => setSelectedId(id)}
              />
              <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}
                  <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-amber-700">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-emerald-100 text-emerald-800 border-emerald-300' :
                        s.state === 'connecting' || s.state === 'reconnecting' ? 'bg-amber-100 text-amber-800 border-amber-300' :
                        s.state === 'error' ? 'bg-rose-100 text-rose-800 border-rose-300' :
                        'bg-muted text-muted-foreground border-border',
                      )}
                      title={`${s.host}:${s.port}${s.error ? ' — ' + s.error : ''}`}
                    >
                      {isMaster && <span className="text-amber-600" 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>
              </div>

              {/* Row 2: filters */}
              <div className="flex items-center gap-2 px-2.5 py-2 border-b border-border/60 flex-wrap text-xs">
                <Input
                  className="w-32 h-7 text-xs font-mono uppercase"
                  placeholder="Search call…"
                  value={clusterSearch}
                  onChange={(e) => setClusterSearch(e.target.value.toUpperCase())}
                />
                <span className="text-muted-foreground">Bands:</span>
                {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(
                        'px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors',
                        on
                          ? 'bg-primary text-primary-foreground border-primary'
                          : 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
                      )}
                    >
                      {b}
                    </button>
                  );
                })}
                {clusterBands.size > 0 && (
                  <button
                    type="button"
                    onClick={() => setClusterBands(new Set())}
                    className="text-[10px] text-muted-foreground hover:text-foreground underline"
                    title="Clear band filter"
                  >
                    clear
                  </button>
                )}
                <div className="w-px h-4 bg-border mx-1" />
                <button
                  type="button"
                  onClick={() => setClusterLockBand((v) => !v)}
                  className={cn(
                    'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] transition-colors',
                    clusterLockBand
                      ? 'bg-amber-100 text-amber-800 border-amber-300'
                      : 'bg-muted/40 text-muted-foreground border-border hover:bg-muted',
                  )}
                  title="Only show spots on the entry strip's current band"
                >
                  {clusterLockBand ? <Lock className="size-2.5" /> : <Unlock className="size-2.5" />}
                  Lock band ({band})
                </button>
                <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] transition-colors',
                    clusterLockMode
                      ? 'bg-amber-100 text-amber-800 border-amber-300'
                      : 'bg-muted/40 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>
                <div className="w-px h-4 bg-border mx-1" />
                <span className="text-muted-foreground">Status:</span>
                {([
                  { k: 'new'      as SpotStatusKey, label: 'NEW',       cls: 'bg-rose-100 text-rose-800 border-rose-300' },
                  { k: 'new-band' as SpotStatusKey, label: 'NEW BAND',  cls: 'bg-amber-100 text-amber-800 border-amber-300' },
                  { k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT',  cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
                  { k: 'worked'   as SpotStatusKey, label: 'WORKED',    cls: 'bg-muted text-muted-foreground border-border' },
                ]).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 className="w-px h-4 bg-border mx-1" />
                <span className="text-muted-foreground">Mode:</span>
                {([
                  { k: 'SSB'  as SpotModeCat, label: 'SSB',  cls: 'bg-sky-100 text-sky-800 border-sky-300' },
                  { k: 'CW'   as SpotModeCat, label: 'CW',   cls: 'bg-violet-100 text-violet-800 border-violet-300' },
                  { k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
                ]).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 className="flex-1" />
                <label className="flex items-center gap-1.5 text-xs cursor-pointer">
                  <Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
                  Group
                </label>
                <Select value={String(clusterFilterSource || '_')} onValueChange={(v) => setClusterFilterSource(v === '_' ? '' : parseInt(v, 10))}>
                  <SelectTrigger className="w-32 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>

              {(() => {
                // Apply every filter. `bandsActive` is the band set the
                // user clicked, OR the entry's locked band when Lock band
                // is on. Mode lock compares the spot's inferred mode to
                // the entry's current one.
                const bandsActive = clusterLockBand
                  ? new Set([band])
                  : clusterBands;
                const search = clusterSearch.trim().toUpperCase();
                let 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 st = spotStatus[k]?.status || '';
                    if (!clusterStatusFilter.has(st as SpotStatusKey)) 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());
                }
                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={(s) => {
                      const m = inferSpotMode(s.comment ?? '', s.freq_hz);
                      if (catState.connected) {
                        SetCATFrequency(s.freq_hz).catch(() => {});
                        if (m) SetCATMode(m).catch(() => {});
                      } else {
                        setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
                        if (s.band) setBand(s.band);
                        if (m) setMode(m);
                      }
                      onCallsignInput(s.dx_call);
                    }}
                  />
                );
              })()}

              {/* Command input — sends to the master server. */}
              <div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
                <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 */}
              {/* BandMap moved to a global side panel below — toggle is
                  now in the topbar, visible on every tab. */}
            </TabsContent>

            <TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
              <WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
                onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} />
            </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">
              <MainMap
                fromGrid={station.my_grid}
                toGrid={grid}
                fromLabel={station.callsign}
                toLabel={callsign}
              />
            </TabsContent>

            <TabsContent value="awards" className="flex-1 min-h-0 p-0">
              <AwardsPanel />
            </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={freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
              onSpotClick={(s) => {
                const m = inferSpotMode(s.comment ?? '', s.freq_hz);
                if (catState.connected) {
                  SetCATFrequency(s.freq_hz).catch(() => {});
                  if (m) SetCATMode(m).catch(() => {});
                } else {
                  setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
                  if (s.band) setBand(s.band);
                  if (m) setMode(m);
                }
                onCallsignInput(s.dx_call);
              }}
              onClose={() => setShowBandMap(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-emerald-500' : 'bg-muted-foreground/40')} />
            {label}
          </button>
        );
        return (
          <footer className="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); }}
            />
            <div className="flex-1" />
          </footer>
        );
      })()}

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

      <SendSpotModal
        open={showSpotModal}
        onClose={() => setShowSpotModal(false)}
        // Callsign: the QSO-entry call, else the last logged QSO.
        defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
        // Freq: the entry TX freq (kHz), else the last logged QSO's.
        defaultFreqKHz={
          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(); }}
        />
      )}

      {deletingQSO && (
        <ConfirmDialog
          title="Delete QSO?"
          message={`This will permanently delete the QSO with ${deletingQSO.callsign} on ${fmtDateUTC(deletingQSO.qso_date)} (${deletingQSO.band} ${deletingQSO.mode}). This cannot be undone.`}
          confirmLabel="Delete"
          danger
          onConfirm={confirmDelete}
          onCancel={() => setDeletingQSO(null)}
        />
      )}
      {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). If ClubLog exceptions are enabled, its date-ranged DXpedition overrides are applied on top (per QSO date). Everything else in the ADIF is kept as-is.
                  </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>
  );
}
