import { memo, useEffect, useMemo, useRef, useState } from 'react';
import {
  ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
  ChevronDown, ChevronRight,
  User, Database, Radio, Cog, Server, Antenna as AntennaIcon,
  Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
  Minimize2,
} from 'lucide-react';
import {
  GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
  GetListsSettings, SaveListsSettings,
  GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
  GetChaseSettings, SaveChaseSettings,
  GetAudioMonitorPref,
  ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
  GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
  GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
  GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, CompactDatabase, CheckHamlogKey, CompareRDASources, ApplyRDAChoices,
  GetAntGeniusSettings, SaveAntGeniusSettings,
  GetTunerGeniusSettings, SaveTunerGeniusSettings,
  GetPSUSettings, SavePSUSettings,
  GetSatSettings, SaveSatSettings, TestSatelliteRotator,
  GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds,
  GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
  GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
  GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
  GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
  GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted,
  GetSecretStatus, SetPassphrase, RemovePassphrase,
  GetEmailSettings, SaveEmailSettings, TestEmail,
  QSLGetEmailTemplates, QSLSaveEmailTemplates,
  GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
  AudioStartMonitor, AudioStopMonitor, AudioMonitorActive,
  AudioStartTX, AudioStopTX, AudioTXActive,
  ListClusterServers, SaveClusterServer, DeleteClusterServer,
  GetClusterAutoConnect, SetClusterAutoConnect, GetSelfSpotSettings, SaveSelfSpotSettings,
  GetWorkedCallVariants, SetWorkedCallVariants,
  ConnectClusterServer, DisconnectClusterServer,
  ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
  GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
  GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, RevealDataFolder, RenameLogbook,
  GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
  GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
  GetTelemetryEnabled, SetTelemetryEnabled,
  GetQSLDefaults, SaveQSLDefaults,
  SetWinkeyerTrace,
  SetCIVTrace,
  CIVTraceEnabled,
  WinkeyerTraceEnabled,
  GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
  GetPOTAToken, SavePOTAToken,
  TestLoTWUpload, ListTQSLStationLocations,
  DownloadLoTWUsers, GetLoTWUsersStatus,
  GetScpStatus, SetScpEnabled, SetScpClublogEnabled, DownloadScp,
  DownloadULSCounties, ULSStatus, BackfillUSCounties, BackfillRDA, RDADatabaseCount,
  GetCtyDatInfo, RefreshCtyDat, GetAwardReferenceMeta, UpdateAwardReferenceList,
  GetSpotColors, SaveSpotColors, ResetSpotColors, GetFlexZoom, SaveFlexZoom,
  ComputeStationInfo,
  GetUIPref, SetUIPref,
  GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexBandPower, SaveFlexBandPower,
  GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
  GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
  GetRelayAuto, SaveRelayAuto, GetStationDevices,
  GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
  GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetChaseNewBands, SetChaseNewBands, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax, 
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
import type { main as mainModels, cluster as clusterModels } from '../../wailsjs/go/models';
import { EventsOn, BrowserOpenURL } from '../../wailsjs/runtime/runtime';

import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { WebPublishPanel } from '@/components/WebPublishPanel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
  Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { setUseMiles } from '@/lib/units';
import { rotorStyle, setRotorStyle, type RotorStyle } from '@/lib/rotorStyle';
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
import { OperatingPanel } from '@/components/OperatingPanel';
import { AppearancePanel } from '@/components/AppearancePanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
import { loadClusterMacros, saveClusterMacros, type ClusterMacro } from '@/lib/clusterMacros';
import { CLUSTER_PRESETS } from '@/lib/clusterPresets';

type LookupSettings = LookupSettingsForm;
type StationSettings = StationSettingsForm;
type ListsSettings = ListsSettingsForm;
type ModePreset = ModePresetForm;
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
type RotatorDevice = Omit<mainModels.RotatorDevice, 'convertValues'>;
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
type Profile = Omit<profileModels.Profile, 'convertValues'>;

// Catalog of all standard ADIF bands, in natural frequency order. The user
// picks a subset on the right; everything else in the UI (entry strip,
// band-slot grid, band-map switcher) iterates that subset.
const BAND_CATALOG = [
  '2190m','630m','560m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m',
  '8m','6m','5m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm',
  '6mm','4mm','2.5mm','2mm','1mm',
];

// Catalog of common ADIF modes with sensible RST defaults. When the user
// picks one on the right, the RSTs are pre-filled but stay editable.
const MODE_CATALOG: Array<{ name: string; sent: string; rcvd: string }> = [
  { name: 'SSB',          sent: '59',  rcvd: '59'  },
  { name: 'CW',           sent: '599', rcvd: '599' },
  { name: 'AM',           sent: '59',  rcvd: '59'  },
  { name: 'FM',           sent: '59',  rcvd: '59'  },
  { name: 'DIGITALVOICE', sent: '59',  rcvd: '59'  },
  { name: 'FT8',          sent: '-10', rcvd: '-10' },
  { name: 'FT4',          sent: '-10', rcvd: '-10' },
  { name: 'FT2',          sent: '-10', rcvd: '-10' },
  { name: 'JS8',          sent: '-10', rcvd: '-10' },
  { name: 'MSK144',       sent: '+00', rcvd: '+00' },
  { name: 'JT65',         sent: '-15', rcvd: '-15' },
  { name: 'JT9',          sent: '-15', rcvd: '-15' },
  { name: 'Q65',          sent: '-15', rcvd: '-15' },
  { name: 'FST4',         sent: '-15', rcvd: '-15' },
  { name: 'FST4W',        sent: '-15', rcvd: '-15' },
  { name: 'WSPR',         sent: '-20', rcvd: '-20' },
  { name: 'RTTY',         sent: '599', rcvd: '599' },
  { name: 'PSK31',        sent: '599', rcvd: '599' },
  { name: 'PSK63',        sent: '599', rcvd: '599' },
  { name: 'PSK125',       sent: '599', rcvd: '599' },
  { name: 'OLIVIA',       sent: '599', rcvd: '599' },
  { name: 'CONTESTI',     sent: '599', rcvd: '599' },
  { name: 'MFSK',         sent: '599', rcvd: '599' },
  { name: 'THROB',        sent: '599', rcvd: '599' },
  { name: 'HELL',         sent: '599', rcvd: '599' },
  { name: 'PACKET',       sent: '599', rcvd: '599' },
  { name: 'PACTOR',       sent: '599', rcvd: '599' },
  { name: 'VARA',         sent: '599', rcvd: '599' },
  { name: 'VARA HF',      sent: '599', rcvd: '599' },
  { name: 'ARDOP',        sent: '599', rcvd: '599' },
  { name: 'ATV',          sent: '59',  rcvd: '59'  },
  { name: 'SSTV',         sent: '59',  rcvd: '59'  },
  { name: 'C4FM',         sent: '59',  rcvd: '59'  },
  { name: 'DSTAR',        sent: '59',  rcvd: '59'  },
  { name: 'DMR',          sent: '59',  rcvd: '59'  },
  { name: 'FUSION',       sent: '59',  rcvd: '59'  },
];

const emptyProfile = (): Profile => ({
  id: 0,
  name: '',
  callsign: '', operator: '', op_name: '', owner_callsign: '',
  my_grid: '', my_country: '',
  my_state: '', my_cnty: '',
  my_street: '', my_city: '', my_postal_code: '',
  my_sota_ref: '', my_pota_ref: '', my_iota: '',
  my_rig: '', my_antenna: '',
  tx_pwr: undefined,
  is_active: false,
  sort_order: 0,
  db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
  // Server-managed timestamps — send null (NOT "") so Go's time.Time unmarshal
  // leaves the zero value instead of failing to parse an empty RFC3339 string.
  created_at: null as any,
  updated_at: null as any,
});

interface Props {
  initialSection?: string;
  onClose: () => void;
  onSaved: () => void;
  onMainPaneChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; // live Main-view layout update
  flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
  icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
  yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane
  elecraftAvailable?: boolean; // CAT backend is Elecraft/Kenwood → the K3/K4 console
  tciAvailable?: boolean; // CAT backend is TCI → the SunSDR console
  // Opens a QSO in the editor. Settings is not where a log is edited — but the
  // RDA comparison lists contacts whose district is in dispute, and a list of
  // things to fix that cannot be acted on is a list to write down and look up
  // again later.
  onEditQSO?: (id: number) => void;
}

// Pretty little card showing what OpsLog will stamp on each QSO based on
// the callsign + grid in the Station Information form. Debounces the
// backend resolver so we don't fire on every keystroke; refreshes when
// inputs change. Empty card when no callsign yet.
/* ====== Tree definition ======
   Section IDs are stable strings — adding new ones means adding a panel below.
   `disabled: true` greys them out and shows the "coming soon" placeholder. */
type SectionId =
  | 'general'
  | 'appearance'
  | 'email'
  | 'station'
  | 'profiles'
  | 'operating'
  | 'confirmations'
  | 'external-services'
  | 'udp'
  | 'adifmon'
  | 'foldersync'
  | 'webpublish'
  | 'lookup'
  | 'lists-bands'
  | 'lists-modes'
  | 'cluster'
  | 'dxhunter'
  | 'backup'
  | 'database'
  | 'autostart'
  | 'uscounties'
  | 'databases'
  | 'awards'
  | 'cat'
  | 'rotator'
  | 'winkeyer'
  | 'antenna'
  | 'antgenius'
  | 'tunergenius'
  | 'psu'
  | 'satellite'
  | 'pgxl'
  | 'flex'
  | 'relayauto'
  | 'audio';

type TreeNode =
  | { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
  | { kind: 'item';  label: string; id: SectionId; disabled?: boolean; vendor?: 'o3a' };

// A compact 4O3A wordmark for the sidebar, drawn here rather than shipped as an
// image: an inline SVG scales with the row, follows the theme (currentColor), and
// adds no binary asset. It marks which panels drive 4O3A hardware — the same
// reason the language picker draws its flags inline.
//
// This is a plain wordmark, NOT the manufacturer's logo artwork. If you would
// rather show the official mark, drop the SVG in and swap this component out.
function VendorMark({ vendor }: { vendor: 'o3a' }) {
	if (vendor !== 'o3a') return null;
	// The 4O3A mark: "4", the green waveform, "3A". Drawn inline rather than
	// shipped as an image — it scales with the row, keeps the sidebar's own type
	// for the digits, and adds no binary asset. The waveform is the part that
	// carries the brand, so only it is SVG; the characters stay HTML text so they
	// match the surrounding menu at any theme or zoom.
	const lens = [3.2, 5.2, 7.4, 9.5, 7.4, 5.2, 3.2]; // half-heights, tallest in the middle
	return (
		<span className="ml-1.5 flex shrink-0 items-center gap-[1px] text-[10px] font-bold leading-none opacity-80" title="4O3A Signature">
			<span>4</span>
			<svg viewBox="0 0 26 22" className="h-[13px] w-[15px]" aria-hidden="true">
				{lens.map((ry, i) => (
					<ellipse key={i} cx={3 + i * 3.3} cy={11} rx={1.5} ry={ry} fill="#1f9d3a" />
				))}
			</svg>
			<span>3A</span>
		</span>
	);
}

// PanelHost renders the selected settings panel, and exists so a panel may hold
// hooks of its own.
//
// The panels are nested inside SettingsModal, closing over its state — so they
// cannot be rendered as <Panel />: a nested function is a NEW component type on
// every parent render, which would unmount and remount the panel on each
// keystroke. They were therefore CALLED, `PANELS[selected]()`, and a call runs
// any hook inside them in SettingsModal's own hook list — conditionally, since
// only the open section is called. React counts those, and the window stopped
// drawing the moment such a section was opened (error #310, "rendered more
// hooks than during the previous render"). It had happened once and was headed
// off by a comment; the comment did not survive contact with the next panel.
//
// This host is module-scope, so its identity is stable and calling `render()`
// inside it puts those hooks in a component context that persists. `key` is the
// section, so switching sections REMOUNTS it — a fresh, consistent hook list per
// section, and no panel state leaking into the next one. Within a section the
// render prop is a new closure each parent render, which is how the panel keeps
// seeing current values.
function PanelHost({ render }: { render?: () => JSX.Element }) {
  return render ? render() : null;
}

// buildTree returns the settings sidebar. The FlexRadio item only appears when
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[] {
  const hardware: TreeNode[] = [
    { kind: 'item', label: t('sec.cat'),          id: 'cat' },
    { kind: 'item', label: t('sec.rotator'),      id: 'rotator' },
    { kind: 'item', label: t('sec.winkeyer'),     id: 'winkeyer' },
    { kind: 'item', label: t('sec.antenna'),      id: 'antenna' },
    { kind: 'item', label: t('sec.antgenius'),    id: 'antgenius',   vendor: 'o3a' },
    { kind: 'item', label: t('sec.tunergenius'),  id: 'tunergenius', vendor: 'o3a' },
    { kind: 'item', label: t('sec.pgxl'),         id: 'pgxl' },
    { kind: 'item', label: t('sec.psu'),          id: 'psu' },
    ...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
    { kind: 'item', label: t('sec.relayauto'),    id: 'relayauto' },
    { kind: 'item', label: t('sec.audio'),        id: 'audio' },
  ];
  return [
  {
    kind: 'group', label: t('nav.user'), icon: User, defaultOpen: true, children: [
      { kind: 'item', label: t('sec.station'), id: 'station' },
      { kind: 'item', label: t('sec.profiles'), id: 'profiles' },
      { kind: 'item', label: t('sec.operating'), id: 'operating' },
      // Not hardware, whatever the rotator block inside it suggests: which
      // satellites you chase, where your antenna stands and how old your
      // elements are is operating, and it sits with the rest of it.
      { kind: 'item', label: t('sec.satellite'),    id: 'satellite' },
      { kind: 'item', label: t('sec.confirmations'), id: 'confirmations' },
      { kind: 'item', label: t('sec.awards'), id: 'awards' },
      { kind: 'item', label: t('sec.external'), id: 'external-services' },
    ],
  },
  {
    kind: 'group', label: t('nav.software'), icon: Cog, defaultOpen: true, children: [
      { kind: 'item', label: t('sec.general'), id: 'general' },
      { kind: 'item', label: t('sec.appearance'), id: 'appearance' },
      { kind: 'item', label: t('sec.email'), id: 'email' },
      { kind: 'item', label: t('sec.lookup'), id: 'lookup' },
      { kind: 'group', label: t('nav.lists'), icon: Database, defaultOpen: true, children: [
        { kind: 'item', label: t('sec.bands'),      id: 'lists-bands' },
        { kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
      ]},
      { kind: 'item', label: t('sec.cluster'),     id: 'cluster' },
      { kind: 'item', label: t('sec.dxhunter'),    id: 'dxhunter' },
      { kind: 'item', label: t('sec.udp'), id: 'udp' },
      { kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
      { kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
      { kind: 'item', label: t('sec.webpublish'), id: 'webpublish' },
      { kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
      { kind: 'item', label: t('sec.database'), id: 'database' },
      { kind: 'item', label: t('sec.autostart'), id: 'autostart' },
    ],
  },
  {
    // Maintenance is about DATA rather than settings: nothing in here changes
    // how OpsLog behaves, it refreshes what it knows. That is why the reference
    // lists moved out of the Awards manager — an operator updating SOTA is not
    // editing an award, and had to open one to do it.
    kind: 'group', label: t('nav.maintenance'), icon: Database, defaultOpen: true, children: [
      { kind: 'item', label: t('sec.databases'), id: 'databases' },
    ],
  },
  {
    kind: 'group', label: t('nav.hardware'), icon: Server, defaultOpen: true, children: hardware,
  },
  ];
}

// Map section id → i18n key (breadcrumb / placeholders).
const SECTION_KEY: Partial<Record<SectionId, string>> = {
  station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
  'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
  cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
  adifmon: 'sec.adifmon',
  foldersync: 'sec.foldersync',
  webpublish: 'sec.webpublish',
  uscounties: 'sec.uscounties',
  databases: 'sec.databases',
  awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
  antgenius: 'sec.antgenius', tunergenius: 'sec.tunergenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
  relayauto: 'sec.relayauto',
};

// Map section id → friendly name (used in breadcrumb / placeholders).
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
  station: 'Station Information',
  profiles: 'Profiles',
  operating: 'Operating conditions',
  confirmations: 'Confirmations',
  'external-services': 'External services',
  lookup: 'Callsign Lookup',
  'lists-bands': 'Bands',
  'lists-modes': 'Modes & default RST',
  cluster: 'DX Cluster',
  backup: 'Database backup',
  database: 'Database',
  autostart: 'Autostart',
  udp: 'UDP integrations',
  adifmon: 'ADIF monitor',
  webpublish: 'Web publishing',
  awards: 'Awards',
  cat: 'CAT interface',
  rotator: 'Rotator',
  winkeyer: 'CW Keyer',
  antenna: 'Ultrabeam / Steppir',
  antgenius: 'Antenna Genius',
  tunergenius: 'Tuner Genius',
  pgxl: 'Amplifier',
  flex: 'FlexRadio',
  relayauto: 'Relay auto-control',
  audio: 'Audio devices',
};

// ===== Tree component =====

interface TreeProps {
  selected: SectionId;
  onSelect: (id: SectionId) => void;
  flexAvailable?: boolean;
}

// The sidebar. Memoised and its tree built once per (language, radio): it is
// sixty-odd items that depend on nothing an operator types, and it was rebuilt
// and re-rendered on every keystroke in every field of every panel — the whole
// dialog holds its state in one component, so one character redraws all of it.
const Tree = memo(function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
  const { t } = useI18n();
  const nodes = useMemo(() => buildTree(!!flexAvailable, t), [flexAvailable, t]);
  return (
    <nav className="text-sm">
      {nodes.map((node, i) => (
        <TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
      ))}
    </nav>
  );
});

function TreeNodeView({
  node, depth, selected, onSelect,
}: { node: TreeNode; depth: number; selected: SectionId; onSelect: (id: SectionId) => void }) {
  if (node.kind === 'item') {
    const isActive = selected === node.id;
    return (
      <button
        onClick={() => { if (!node.disabled) onSelect(node.id); }}
        disabled={node.disabled}
        className={cn(
          'w-full text-left px-2 py-1.5 rounded-md text-[12.5px] transition-colors flex items-center',
          isActive ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60',
          node.disabled && 'opacity-50 cursor-not-allowed italic',
        )}
        style={{ paddingLeft: 8 + depth * 14 }}
      >
        <span className="truncate">{node.label}</span>
        {node.vendor && !node.disabled && <VendorMark vendor={node.vendor} />}
        {node.disabled && (
          <Construction className="ml-auto size-3 shrink-0 opacity-60" />
        )}
      </button>
    );
  }
  // group
  const [open, setOpen] = useState(node.defaultOpen ?? false);
  const Icon = node.icon;
  return (
    <div>
      <button
        onClick={() => setOpen((v) => !v)}
        className="w-full text-left px-2 py-1.5 rounded-md text-[12px] uppercase tracking-wider text-muted-foreground hover:text-foreground flex items-center gap-1.5 font-semibold"
        style={{ paddingLeft: 8 + depth * 14 }}
      >
        {open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
        {Icon && <Icon className="size-3.5 opacity-70" />}
        <span>{node.label}</span>
      </button>
      {open && (
        <div>
          {node.children.map((c, i) => (
            <TreeNodeView key={i} node={c} depth={depth + 1} selected={selected} onSelect={onSelect} />
          ))}
        </div>
      )}
    </div>
  );
}

// ===== Section content panels =====

// Representative surface/card/accent swatch for each theme (picker preview —
// the theme is not applied until selected). Module-scope so their component
// identity is STABLE across SettingsModal re-renders; defining them inside the
// component would give each render a fresh function, remounting the Radix
// Select and slamming the open dropdown shut on any ambient re-render.
// The bands the Chase new filter offers, in band-plan order. Mirrors
// ChaseNewBands in pskchase.go, which is what actually filters the feed —
// a band added there and not here is simply one nobody can switch off.
const CHASE_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];

const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
  'light-warm':    { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
  'light-cool':    { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
  'light-sage':    { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' },
  'sahara':        { bg: '#e6ded0', card: '#f3eee2', accent: '#c4501e' },
  'light-nordic':  { bg: '#eef1f7', card: '#ffffff', accent: '#4f46e5' },
  'dim-slate':     { bg: '#343b47', card: '#3d4552', accent: '#fb923c' },
  'dark-warm':     { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
  'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
  'dark-indigo':   { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' },
  'dark-teal':     { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' },
  'dark-plum':     { bg: '#180f1e', card: '#251830', accent: '#f472b6' },
  'dxhunter':      { bg: '#0f172a', card: '#1e293b', accent: '#3b82f6' },
  'dxhunter-orange': { bg: '#0f172a', card: '#1e293b', accent: '#f97316' },
  'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
};

function ThemeSwatch({ theme }: { theme: Exclude<ThemeChoice, 'auto'> }) {
  const s = THEME_SWATCH[theme];
  return (
    <span className="flex h-5 w-8 overflow-hidden rounded-[3px] border border-black/20 shrink-0" style={{ background: s.bg }}>
      <span className="m-0.5 flex flex-1 items-center justify-center rounded-[2px]" style={{ background: s.card }}>
        <span className="h-2 w-2 rounded-full" style={{ background: s.accent }} />
      </span>
    </span>
  );
}

// Auto swatch = split light/dark, since it follows the OS preference.
function ThemeOptionSwatch({ theme }: { theme: ThemeChoice }) {
  if (theme === 'auto') {
    return (
      <span className="flex h-5 w-8 overflow-hidden rounded-[3px] border border-black/20 shrink-0">
        <span className="flex-1" style={{ background: '#e8dfc9' }} />
        <span className="flex-1" style={{ background: '#16181d' }} />
      </span>
    );
  }
  return <ThemeSwatch theme={theme} />;
}

function ThemeSelector() {
  const { theme, setTheme } = useTheme();
  const { t } = useI18n();
  const options: ThemeChoice[] = ['auto', ...CONCRETE_THEMES];
  return (
    <div className="flex items-center gap-3">
      <Label className="text-sm w-40">{t('settings.theme')}</Label>
      <Select value={theme} onValueChange={(v) => setTheme(v as ThemeChoice)}>
        <SelectTrigger className="h-9 w-64" title={t('settings.themeHint')}>
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {options.map((opt) => (
            <SelectItem key={opt} value={opt}>
              <span className="flex items-center gap-2.5">
                <ThemeOptionSwatch theme={opt} />
                {t(`theme.${opt}`)}
              </span>
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  );
}

function SectionHeader({ title, hint }: { title: string; hint?: string }) {
  return (
    <header className="mb-4">
      <h3 className="text-base font-semibold text-foreground">{title}</h3>
      {hint && <p className="text-xs text-muted-foreground mt-0.5">{hint}</p>}
    </header>
  );
}

// ProfileScopeNote flags that the panel's settings are saved per-profile, so
// the user knows which operating identity (F4BPO / TM2Q / …) they're editing.
function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: string } }) {
  const { t } = useI18n();
  return (
    <div className="-mt-2 mb-4">
      <span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 text-primary text-xs px-2.5 py-1">
        <User className="size-3.5" />
        {t('profileScope.saved')} <strong className="font-semibold">{profile?.name || '—'}</strong>
        {profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
        {' '}{t('profileScope.switch')}
      </span>
    </div>
  );
}

// AutostartPanelComponent manages the per-profile list of external programs to
// launch when OpsLog starts. It's a self-contained component (its own state) so
// it can use hooks — rendered via the `() => <AutostartPanelComponent/>` wrapper
// in PANELS. Changes persist immediately (config is local SQLite, cheap writes).
type AutostartProg = { id: string; name: string; path: string; args: string; enabled: boolean };

function AutostartPanelComponent() {
  const { t } = useI18n();
  const [progs, setProgs] = useState<AutostartProg[]>([]);
  const [loaded, setLoaded] = useState(false);
  const [err, setErr] = useState('');
  const [launchMsg, setLaunchMsg] = useState<Record<string, string>>({});

  async function load() {
    try { setProgs(((await GetAutostartPrograms()) ?? []) as any); }
    catch (e: any) { setErr(String(e?.message ?? e)); }
    finally { setLoaded(true); }
  }
  useEffect(() => { load(); }, []);
  useEffect(() => {
    const off = EventsOn('profile:changed', () => load());
    return () => { if (typeof off === 'function') off(); };
  }, []);

  async function commit(next: AutostartProg[]) {
    setProgs(next);
    try { await SaveAutostartPrograms(next as any); setErr(''); }
    catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  const patch = (id: string, p: Partial<AutostartProg>) =>
    commit(progs.map((x) => (x.id === id ? { ...x, ...p } : x)));
  const remove = (id: string) => commit(progs.filter((x) => x.id !== id));

  async function addProgram() {
    try {
      const path = await BrowseExecutable();
      if (!path) return;
      const base = path.split(/[\\/]/).pop() || path;
      const name = base.replace(/\.(exe|bat|cmd)$/i, '');
      const id = (crypto as any)?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
      commit([...progs, { id, name, path, args: '', enabled: true }]);
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  async function rebrowse(id: string) {
    try { const path = await BrowseExecutable(); if (path) patch(id, { path }); }
    catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  async function launchNow(id: string) {
    try {
      const r: any = await LaunchAutostartProgram(id);
      const txt = r?.status === 'launched' ? '✓ launched'
        : r?.status === 'already_running' ? 'already running — not started again'
        : r?.status === 'missing' ? '✗ executable not found'
        : (r?.message || r?.status || 'done');
      setLaunchMsg((m) => ({ ...m, [id]: txt }));
    } catch (e: any) { setLaunchMsg((m) => ({ ...m, [id]: String(e?.message ?? e) })); }
  }

  return (
    <>
      <SectionHeader
        title={t('sec.autostart')}
        hint={t('autostart.hint')}
      />
      <div className="space-y-2 max-w-3xl">
        {loaded && progs.length === 0 && (
          <p className="text-sm text-muted-foreground italic">No programs yet — add one below.</p>
        )}
        {progs.map((p) => (
          <div key={p.id} className="rounded-lg border border-border bg-card p-3 space-y-2">
            <div className="flex items-center gap-2">
              <Checkbox checked={p.enabled} onCheckedChange={(c) => patch(p.id, { enabled: !!c })} title="Launch at startup" />
              <Input className="h-8 flex-1 font-medium" value={p.name} placeholder="Name"
                onChange={(e) => patch(p.id, { name: e.target.value })} />
              <Button size="sm" variant="outline" onClick={() => launchNow(p.id)} title="Launch now">
                <Play className="size-3.5" /> Launch
              </Button>
              <Button size="sm" variant="ghost" onClick={() => remove(p.id)} title="Remove">
                <Trash2 className="size-4 text-destructive" />
              </Button>
            </div>
            <div className="grid grid-cols-[78px_1fr] items-center gap-2">
              <Label className="text-xs text-muted-foreground">Program</Label>
              <div className="flex items-center gap-2">
                <Input className="h-8 flex-1 font-mono text-xs" value={p.path} readOnly title={p.path} />
                <Button size="sm" variant="outline" onClick={() => rebrowse(p.id)}>
                  <FolderOpen className="size-3.5" /> Browse
                </Button>
              </div>
              <Label className="text-xs text-muted-foreground">Arguments</Label>
              <Input className="h-8 font-mono text-xs" value={p.args} placeholder="optional command-line arguments"
                onChange={(e) => patch(p.id, { args: e.target.value })} />
            </div>
            {/* Only what OpsLog started is ever closed — a copy the operator
                opened themselves is theirs. The hint says so, because the
                difference is invisible from here. */}
            <label className="flex items-start gap-2 text-xs cursor-pointer">
              <Checkbox className="mt-0.5" checked={!!(p as any).close_on_exit}
                onCheckedChange={(c) => patch(p.id, { close_on_exit: !!c } as any)} />
              <span>
                Close it when OpsLog closes{' '}
                <span className="text-muted-foreground">
                  (asks it to close, the way clicking its cross would — and only if OpsLog started it)
                </span>
              </span>
            </label>
            {launchMsg[p.id] && <div className="text-xs text-muted-foreground pl-[86px]">{launchMsg[p.id]}</div>}
          </div>
        ))}
        <Button variant="outline" onClick={addProgram}>
          <Plus className="size-4" /> Add program…
        </Button>
        {err && <div className="text-xs text-destructive">{err}</div>}
      </div>
    </>
  );
}

// TelemetryToggle is a self-contained opt-out for the anonymous usage heartbeat
// (a random install ID + version + OS, sent once a day). Real component so it
// can own its state; embedded inside GeneralPanel.
function TelemetryToggle() {
  const { t } = useI18n();
  const [on, setOn] = useState(true);
  const [loaded, setLoaded] = useState(false);
  useEffect(() => {
    GetTelemetryEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
  }, []);
  return (
    <label className="flex items-center gap-2 text-sm cursor-pointer">
      <Checkbox checked={on} disabled={!loaded}
        onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
      <span title={t('settings.telemetryHint')}>{t('settings.telemetry')}</span>
    </label>
  );
}

// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
// contact that arrives is imported and uploaded automatically like any log entry.
type ADIFWatchFileUI = { path: string; enabled: boolean; offset: number };
type ADIFMonitorCfgUI = { enabled: boolean; files: ADIFWatchFileUI[] };
function ADIFMonitorPanel() {
  const { t } = useI18n();
  const [cfg, setCfg] = useState<ADIFMonitorCfgUI>({ enabled: false, files: [] });
  const [loaded, setLoaded] = useState(false);
  useEffect(() => {
    GetADIFMonitor()
      .then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, files: (c.files ?? []) as ADIFWatchFileUI[] }); })
      .catch(() => {})
      .finally(() => setLoaded(true));
  }, []);
  // Offsets are managed backend-side; SaveADIFMonitor ignores the ones we send and
  // keeps each existing file's read position (a new file starts at end-of-file).
  const persist = (next: ADIFMonitorCfgUI) => { setCfg(next); SaveADIFMonitor(next as any).catch(() => {}); };
  const addFile = async () => {
    try {
      const p = await PickADIFMonitorFile();
      if (!p || cfg.files.some((f) => f.path === p)) return;
      persist({ ...cfg, files: [...cfg.files, { path: p, enabled: true, offset: -1 }] });
    } catch { /* dialog cancelled */ }
  };
  return (
    <div className="space-y-4 max-w-2xl">
      <p className="text-xs text-muted-foreground">{t('adifmon.hint')}</p>
      <label className="flex items-center gap-2 text-sm cursor-pointer">
        <Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => persist({ ...cfg, enabled: !!c })} />
        {t('adifmon.enable')}
      </label>
      <div className="space-y-1.5">
        {cfg.files.length === 0 && <p className="text-xs text-muted-foreground italic">{t('adifmon.empty')}</p>}
        {cfg.files.map((f, i) => (
          <div key={i} className="flex items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
            <Checkbox checked={f.enabled}
              onCheckedChange={(c) => persist({ ...cfg, files: cfg.files.map((x, idx) => idx === i ? { ...x, enabled: !!c } : x) })} />
            <span className="flex-1 font-mono text-xs truncate" title={f.path}>{f.path}</span>
            <button type="button" title={t('adifmon.remove')}
              className="text-muted-foreground hover:text-destructive shrink-0"
              onClick={() => persist({ ...cfg, files: cfg.files.filter((_, idx) => idx !== i) })}>
              <Trash2 className="size-3.5" />
            </button>
          </div>
        ))}
      </div>
      <Button variant="outline" size="sm" onClick={addFile}>
        <FolderOpen className="size-3.5 mr-1" /> {t('adifmon.add')}
      </Button>
      <p className="text-[11px] text-muted-foreground">{t('adifmon.note')}</p>
    </div>
  );
}

// FolderSyncPanel: one operator, several PCs, one logbook through a folder they
// already synchronise (Seafile, OneDrive, Dropbox, a NAS share).
//
// The status half is the point of the panel. Every part of this runs on someone
// else's machine and on a sync client OpsLog cannot see, so "it is not working"
// has to be answerable from here: which other PCs have written to the folder,
// when each last did, and whether anything is sitting there unread.
function FolderSyncPanel() {
  const { t } = useI18n();
  const [cfg, setCfg] = useState<{ enabled: boolean; folder: string; machine: string }>({ enabled: false, folder: '', machine: '' });
  const [st, setSt] = useState<any>(null);
  const [loaded, setLoaded] = useState(false);
  const [err, setErr] = useState('');
  const [msg, setMsg] = useState('');
  const [busy, setBusy] = useState(false);
  useEffect(() => {
    GetFolderSync()
      .then((c: any) => { if (c) setCfg({ enabled: !!c.enabled, folder: c.folder ?? '', machine: c.machine ?? '' }); })
      .catch(() => {})
      .finally(() => setLoaded(true));
  }, []);
  // Polled while the panel is open: a first setup is verified by watching the
  // other PC appear in this list, and that happens on the sync client's clock,
  // not on any action taken here.
  useEffect(() => {
    let alive = true;
    const tick = () => GetFolderSyncStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
    tick();
    const h = window.setInterval(tick, 3000);
    return () => { alive = false; window.clearInterval(h); };
  }, []);
  const save = async (next: typeof cfg) => {
    setCfg(next);
    setErr(''); setMsg('');
    try {
      await SaveFolderSync(next as any);
      setMsg(t('sync.saved'));
      GetFolderSyncStatus().then(setSt).catch(() => {});
    } catch (e: any) {
      setErr(String(e?.message ?? e));
      // The switch goes back off rather than sitting on while nothing is
      // written: a folder that refused the write test would lose every contact
      // in silence, which is the one outcome this feature must never have.
      setCfg({ ...next, enabled: false });
    }
  };
  const pick = async () => {
    try {
      const p = await PickFolderSyncFolder();
      if (p) save({ ...cfg, folder: p });
    } catch { /* dialog cancelled */ }
  };
  const syncNow = async () => {
    setBusy(true); setErr(''); setMsg('');
    try {
      const n = await SyncFolderNow();
      setMsg(t('sync.applied').replace('{n}', String(n)));
      GetFolderSyncStatus().then(setSt).catch(() => {});
    } catch (e: any) {
      setErr(String(e?.message ?? e));
    } finally { setBusy(false); }
  };
  const when = (iso: string) => (iso ? new Date(iso).toLocaleString() : t('sync.never'));
  return (
    <div className="space-y-4 max-w-2xl">
      <p className="text-xs text-muted-foreground">{t('sync.hint')}</p>
      <label className="flex items-center gap-2 text-sm cursor-pointer">
        <Checkbox checked={cfg.enabled} disabled={!loaded} onCheckedChange={(c) => save({ ...cfg, enabled: !!c })} />
        {t('sync.enable')}
      </label>

      <div className="grid grid-cols-[130px_1fr] items-center gap-2">
        <span className="text-sm">{t('sync.machine')}</span>
        <Input value={cfg.machine} placeholder="shack" className="h-8"
          onChange={(e) => setCfg({ ...cfg, machine: e.target.value })}
          onBlur={() => save(cfg)} />
        <span className="text-sm">{t('sync.folder')}</span>
        <div className="flex items-center gap-2 min-w-0">
          <span className="flex-1 font-mono text-xs truncate" title={cfg.folder}>{cfg.folder || '—'}</span>
          <Button variant="outline" size="sm" onClick={pick}><FolderOpen className="size-3.5 mr-1" /> {t('sync.choose')}</Button>
        </div>
      </div>

      {err && <p className="text-xs text-destructive">{err}</p>}
      {msg && <p className="text-xs text-success">{msg}</p>}

      <SectionHeader title={t('sync.state')} />
      <div className="rounded-md border border-border bg-muted/20 p-3 space-y-2 text-xs">
        <div className="flex flex-wrap gap-x-6 gap-y-1 text-muted-foreground">
          <span>{t('sync.thisPc')}: <span className="font-mono text-foreground">{st?.machine_id || '—'}</span></span>
          <span>{t('sync.lastSync')}: <span className="text-foreground">{when(st?.last_sync ?? '')}</span></span>
          <span>{t('sync.sent')}: <span className="text-foreground">{st?.sent ?? 0}</span></span>
          <span>{t('sync.received')}: <span className="text-foreground">{st?.received ?? 0}</span></span>
        </div>
        {st?.error && <p className="text-destructive">{st.error}</p>}
        <div className="space-y-1">
          {(st?.peers ?? []).length === 0 && <p className="italic text-muted-foreground">{t('sync.noPeers')}</p>}
          {(st?.peers ?? []).map((p: any) => (
            <div key={p.machine} className="flex items-center gap-2">
              <span className="font-mono truncate">{p.machine}</span>
              <span className="text-muted-foreground">{when(p.last_change)}</span>
              {p.behind > 0 && <span className="text-warning">{t('sync.behind')}</span>}
            </div>
          ))}
        </div>
      </div>
      <Button variant="outline" size="sm" onClick={syncNow} disabled={busy || !cfg.enabled}>
        {busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
        {t('sync.now')}
      </Button>
    </div>
  );
}

// AmpUI mirrors the backend AmpConfig — one configured amplifier.
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
  // Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
  // frequency polls, pretending to be a Kenwood-format transceiver.
  freq_out?: boolean; freq_com_port?: string; freq_baud?: number; freq_broadcast_ms?: number };

// RelayAutoPanel configures automatic control of the Station Control relay boards
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
// off, a frequency window (kHz), or a set of bands.
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
// Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow
// filter is a subset of these. Must match motorBands in app.go, low → high.
const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
// Shown as placeholders only — the backend owns these values (motorBands in
// app.go) and resolves what a band button actually commands. Duplicated here
// purely so an empty box can say what leaving it empty will do.
const MOTOR_BAND_DEFAULT_KHZ: Record<string, number> = {
  '40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
  '15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
};
// Fallback only: the rule editor sizes itself from the device's labels, which
// SaveStationDevices already normalises to the real relay count. This is what it
// falls back to when a device somehow has none, so it only needs to be in the
// right neighbourhood — but it is the third place that counts relays, and the
// first two disagreeing is what pinned the HTTP board at four.
const relayCountUI = (type: string) =>
  type === 'kmtronic' || type === 'denkovi' ? 8 : type === 'httpgen' ? 4 : 5;

// The twelve cluster command macros.
//
// Module-scoped, with its own state, and that is the point rather than tidiness:
// nested inside SettingsModal every keystroke in one of these twenty-four boxes
// re-rendered the WHOLE preferences dialog — every list, every form, every
// panel — and the letters arrived visibly after the finger had left the key.
// Here a keystroke re-renders twelve rows.
//
// Written on every keystroke as before. This panel has no Save button, and a
// text box whose contents only take effect on some other button is how work
// gets lost; the database write is what waits (see saveClusterMacros).
function ClusterMacroEditor() {
  const { t } = useI18n();
  const [macros, setMacros] = useState<ClusterMacro[]>(loadClusterMacros);
  const setMacro = (i: number, patch: Partial<ClusterMacro>) => {
    setMacros((cur) => {
      const next = cur.map((m, j) => (j === i ? { ...m, ...patch } : m));
      saveClusterMacros(next);
      return next;
    });
  };
  return (
    <div className="border-t border-border/60 pt-3 space-y-2">
      <div>
        <span className="text-sm font-medium">{t('clu.macros')}</span>
      </div>
      {/* Two columns of six: twelve rows stacked would push everything else
          in this panel off the screen. */}
      <div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-1.5">
        {macros.map((m, i) => (
          <div key={i} className="flex items-center gap-1.5">
            <span className="text-[10px] text-muted-foreground tabular-nums w-4 text-right shrink-0">{i + 1}</span>
            <Input
              className="h-8 w-28 shrink-0 text-xs"
              placeholder={t('clu.macroLabel')}
              value={m.label}
              maxLength={24}
              onChange={(e) => setMacro(i, { label: e.target.value })}
            />
            {/* 500, not 120. A DXSpider filter is a list of prefixes and an
                operator's own list of wanted countries runs past a hundred
                characters easily — the field simply stopped accepting
                keystrokes, with nothing to say why, and the command was saved
                truncated. The title shows the whole thing, since the box
                cannot. */}
            <Input
              className="h-8 flex-1 min-w-0 font-mono text-xs"
              placeholder={t('clu.macroCmd')}
              value={m.cmd}
              title={m.cmd}
              maxLength={500}
              onChange={(e) => setMacro(i, { cmd: e.target.value })}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
// id) — SPE / ACOM / PGXL alike. Module-scoped (not a nested component) so it
// isn't remounted on every parent render. Polls once a second while shown.
function AmpStatusCard({ id }: { id: string }) {
  const [amp, setAmp] = useState<any>(null);
  useEffect(() => {
    let alive = true;
    const tick = () => GetAmpStatuses().then((all: any[]) => {
      if (alive) setAmp((all ?? []).find((a) => a.id === id) ?? null);
    }).catch(() => {});
    tick();
    const t = window.setInterval(tick, 1000);
    return () => { alive = false; window.clearInterval(t); };
  }, [id]);
  const st: any = amp?.spe ?? amp?.acom ?? amp?.kpa ?? amp?.pgxl ?? { connected: false };
  const isSPE = !!amp?.spe;
  const isACOM = !!amp?.acom;
  const isKPA = !!amp?.kpa;
  const operate = !!st.operate;
  return (
    <div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
      <div className="flex items-center gap-2">
        <span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
        <span className="font-semibold">{amp?.name || 'Amplifier'}{st.connected ? '' : ' — not connected'}</span>
        {!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
        <span className="flex-1" />
        <Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
          onClick={() => AmpOperate(id, !operate).catch(() => {})}
          title="Toggle OPERATE / STANDBY">
          {operate ? 'OPERATE' : 'STANDBY'}
        </Button>
      </div>
      {st.connected && isSPE && (
        <div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
          <div>{st.tx ? 'TX' : 'RX'}</div>
          <div>Band {st.band}</div>
          <div>Pwr {st.power_level}</div>
          <div>{st.output_w} W</div>
          <div>SWR {Number(st.swr_ant ?? 0).toFixed(2)}</div>
          <div>{st.temp_c}°C</div>
          <div>{st.volt_pa} V</div>
          <div>{st.curr_pa} A</div>
          {(st.warnings || st.alarms) && <div className="col-span-4 text-warning">⚠ {st.warnings} {st.alarms}</div>}
        </div>
      )}
      {st.connected && isACOM && (
        <div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
          <div>{st.state}</div>
          <div>Band {st.band || '—'}</div>
          <div>{st.fwd_w} W</div>
          <div>SWR {Number(st.swr ?? 0).toFixed(2)}</div>
          <div>Refl {st.refl_w} W</div>
          <div>Drive {st.drive_w} W</div>
          <div>{st.temp_c}°C</div>
          <div>Fan {st.fan}</div>
          {st.err_text && <div className="col-span-4 text-warning">⚠ {st.err_text} ({st.err_code})</div>}
        </div>
      )}
      {st.connected && isKPA && (
        <div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
          <div>{st.power_on ? 'ON' : 'OFF'}</div>
          <div>Band {st.band || '—'}</div>
          <div>{st.fwd_w} W</div>
          <div>SWR {Number(st.swr ?? 0).toFixed(1)}</div>
          <div>{st.volt_v} V</div>
          <div>{st.cur_a} A</div>
          <div>{st.temp_c}°C</div>
          <div>{st.tuning ? 'TUNING' : ''}</div>
          {/* A fault has already put the amplifier in standby by itself, so it
              is the one thing worth the width — and OPERATE is the way out of
              it, which the button above already is. */}
          {st.fault_text && <div className="col-span-4 text-warning">⚠ {st.fault_text}</div>}
        </div>
      )}
      {st.connected && !isSPE && !isACOM && !isKPA && (
        <div className="grid grid-cols-3 gap-x-3 gap-y-1 font-mono text-[11px]">
          <div>{st.state || ''}</div>
          <div>Fan {st.fan_mode || '—'}</div>
          <div>{st.temperature ? `${Math.round(st.temperature)}°C` : ''}</div>
        </div>
      )}
    </div>
  );
}
function RelayAutoPanel() {
  const { t } = useI18n();
  const [enabled, setEnabled] = useState(false);
  const [rules, setRules] = useState<RelayRuleUI[]>([]);
  const [devices, setDevices] = useState<StationDevUI[]>([]);
  const [loaded, setLoaded] = useState(false);
  useEffect(() => {
    Promise.all([GetRelayAuto(), GetStationDevices()])
      .then(([cfg, devs]: any[]) => {
        setEnabled(!!cfg?.enabled);
        setRules((cfg?.rules ?? []) as RelayRuleUI[]);
        setDevices((devs ?? []) as StationDevUI[]);
      })
      .catch(() => {})
      .finally(() => setLoaded(true));
  }, []);
  const save = (en: boolean, rs: RelayRuleUI[]) => { SaveRelayAuto({ enabled: en, rules: rs } as any).catch(() => {}); };
  const ruleFor = (dev: string, relay: number): RelayRuleUI =>
    rules.find((r) => r.device_id === dev && r.relay === relay) ?? { device_id: dev, relay, mode: 'off', freq_lo_khz: 0, freq_hi_khz: 0, bands: [] };
  // Apply a patch and persist (commit=true) or keep local only (commit=false, for
  // freq inputs that persist on blur so we don't switch relays on every keystroke).
  const patchRule = (dev: string, relay: number, patch: Partial<RelayRuleUI>, commit = true) => {
    const next = { ...ruleFor(dev, relay), ...patch };
    const others = rules.filter((r) => !(r.device_id === dev && r.relay === relay));
    const all = [...others, next];
    setRules(all);
    if (commit) save(enabled, all);
  };
  const toggleBand = (dev: string, relay: number, band: string) => {
    const cur = ruleFor(dev, relay);
    const has = cur.bands.includes(band);
    patchRule(dev, relay, { bands: has ? cur.bands.filter((b) => b !== band) : [...cur.bands, band] });
  };

  return (
    <div className="space-y-4 max-w-3xl">
      <p className="text-xs text-muted-foreground">{t('relayauto.hint')}</p>
      <label className="flex items-center gap-2 text-sm cursor-pointer">
        <Checkbox checked={enabled} disabled={!loaded} onCheckedChange={(c) => { const v = !!c; setEnabled(v); save(v, rules); }} />
        {t('relayauto.enable')}
      </label>

      {devices.length === 0 && loaded && (
        <p className="text-xs text-muted-foreground italic">{t('relayauto.noDevices')}</p>
      )}

      {devices.map((dev) => (
        <div key={dev.id} className="rounded-md border border-border">
          <div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
          <div className="divide-y divide-border/60">
            {Array.from({ length: (dev.labels?.length || relayCountUI(dev.type)) }, (_, i) => i + 1).map((relay) => {
              const r = ruleFor(dev.id, relay);
              const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
              return (
                <div key={relay} className="flex items-start gap-3 px-3 py-2">
                  <span className="w-28 shrink-0 text-xs font-mono pt-1.5 truncate" title={label}>{label}</span>
                  <Select value={r.mode || 'off'} onValueChange={(v) => patchRule(dev.id, relay, { mode: v })}>
                    <SelectTrigger className="h-8 w-32 text-xs shrink-0"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      <SelectItem value="off">{t('relayauto.modeOff')}</SelectItem>
                      <SelectItem value="freq">{t('relayauto.modeFreq')}</SelectItem>
                      <SelectItem value="band">{t('relayauto.modeBand')}</SelectItem>
                    </SelectContent>
                  </Select>
                  <div className="flex-1 min-w-0 pt-0.5">
                    {r.mode === 'freq' && (
                      <div className="flex items-center gap-1.5 text-xs">
                        <Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.from')}
                          defaultValue={r.freq_lo_khz || ''}
                          onChange={(e) => patchRule(dev.id, relay, { freq_lo_khz: parseFloat(e.target.value) || 0 }, false)}
                          onBlur={() => save(enabled, rules)} />
                        <span className="text-muted-foreground">–</span>
                        <Input type="number" className="h-8 w-24 text-xs" placeholder={t('relayauto.to')}
                          defaultValue={r.freq_hi_khz || ''}
                          onChange={(e) => patchRule(dev.id, relay, { freq_hi_khz: parseFloat(e.target.value) || 0 }, false)}
                          onBlur={() => save(enabled, rules)} />
                        <span className="text-muted-foreground">kHz</span>
                      </div>
                    )}
                    {r.mode === 'band' && (
                      <div className="flex flex-wrap gap-1">
                        {RELAY_BANDS.map((b) => {
                          const on = r.bands.includes(b);
                          return (
                            <button key={b} type="button" onClick={() => toggleBand(dev.id, relay, b)}
                              className={cn('px-1.5 py-0.5 rounded text-[11px] font-mono border transition-colors',
                                on ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
                              {b}
                            </button>
                          );
                        })}
                      </div>
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      ))}
    </div>
  );
}

// MainViewPanes lets the operator choose what the Main tab shows, in up to four
// panes. The first two are the view; the third and fourth can be left empty, and
// an empty one is not drawn at all. Per-profile (stored via SetUIPref, which is
// profile-prefixed). Self-contained so it owns its async-loaded state.
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol', 'decodes', 'watchlist'];
const PANE_NONE = 'none';
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable, elecraftAvailable, tciAvailable }: { onChanged?: (side: 'left' | 'right' | 'p3' | 'p4' | 'layout', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean; elecraftAvailable?: boolean; tciAvailable?: boolean }) {
  const { t } = useI18n();
  const [panes, setPanes] = useState<Record<string, string>>({ left: 'map1', right: 'map2', p3: PANE_NONE, p4: PANE_NONE });
  const [layout, setLayout] = useState('quad');
  // Radio-control panes are only offered when that CAT backend is active. Sorted A→Z.
  const options = [
    ...MAIN_PANE_VALUES,
    ...(flexAvailable ? ['flex'] : []),
    ...(icomAvailable ? ['icom'] : []),
    ...(yaesuAvailable ? ['yaesu'] : []),
    // The Elecraft console could be docked from the start — App has always had
    // the pane — but it was never offered here, so the only way to reach it was
    // the tab. Listed with the others now.
    ...(elecraftAvailable ? ['elecraft'] : []),
    ...(tciAvailable ? ['tci'] : []),
  ].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
    .sort((a, b) => a.label.localeCompare(b.label));
  const KEYS: Record<string, string> = { left: 'mainPaneLeft', right: 'mainPaneRight', p3: 'mainPane3', p4: 'mainPane4' };
  useEffect(() => {
    const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || v === 'elecraft' || v === 'tci' || MAIN_PANE_VALUES.includes(v);
    Promise.all([
      ...Object.values(KEYS).map((k) => GetUIPref(k).catch(() => '')),
      GetUIPref('mainPaneLayout').catch(() => ''),
    ]).then(([l, r, p3, p4, lay]) => {
      setPanes({
        left: valid(l) ? l : 'map1',
        right: valid(r) ? r : 'map2',
        p3: valid(p3) ? p3 : PANE_NONE,
        p4: valid(p4) ? p4 : PANE_NONE,
      });
      if (lay === 'cols' || lay === 'quad') setLayout(lay);
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const pick = (side: string, v: string) => {
    setPanes((p) => ({ ...p, [side]: v }));
    // Persist (per-profile) AND tell the parent the new value directly, so the
    // Main view updates from the chosen value — never a stale DB re-read.
    SetUIPref(KEYS[side], v).catch(() => {});
    onChanged?.(side as any, v);
  };
  const pickLayout = (v: string) => {
    setLayout(v);
    SetUIPref('mainPaneLayout', v).catch(() => {});
    onChanged?.('layout', v);
  };
  const filled = ['left', 'right', 'p3', 'p4'].filter((k) => panes[k] !== PANE_NONE).length;
  const SIDES = [
    { key: 'left', label: t('settings.leftPane'), canBeEmpty: false },
    { key: 'right', label: t('settings.rightPane'), canBeEmpty: false },
    { key: 'p3', label: t('settings.thirdPane'), canBeEmpty: true },
    { key: 'p4', label: t('settings.fourthPane'), canBeEmpty: true },
  ];
  return (
    <div className="border-t border-border/60 pt-4 space-y-2">
      <h4 className="text-sm font-semibold text-foreground">{t('settings.mainView')}</h4>
      <p className="text-xs text-muted-foreground">{t('settings.mainViewHint')}</p>
      <div className="grid grid-cols-2 gap-3 max-w-xl">
        {SIDES.map((sd) => (
          <label key={sd.key} className="flex flex-col gap-1 text-xs">
            <span className="text-muted-foreground">{sd.label}</span>
            <Select value={panes[sd.key]} onValueChange={(v) => pick(sd.key, v)}>
              <SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
              <SelectContent>
                {/* Only the third and fourth may be empty: the first two ARE the
                    Main view, and a tab with nothing in it is not a layout. */}
                {sd.canBeEmpty && <SelectItem value={PANE_NONE}>{t('settings.paneNone')}</SelectItem>}
                {options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
              </SelectContent>
            </Select>
          </label>
        ))}
      </div>
      {/* Only asked when it can matter. Three panes are columns and nothing
          else; four are the only case with two honest answers. */}
      {filled === 4 && (
        <div className="flex items-center gap-2 pt-1">
          <span className="text-xs text-muted-foreground">{t('settings.paneLayout')}</span>
          <div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
            {[{ v: 'quad', l: t('settings.paneQuad') }, { v: 'cols', l: t('settings.paneCols') }].map((o) => (
              <button key={o.v} type="button" onClick={() => pickLayout(o.v)}
                className={cn('px-2.5 py-1', layout === o.v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
                {o.l}
              </button>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
// (fills the IP/port). Self-contained so it can own its state (rendered inside
// the hook-less CATPanel).
function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }) {
  const [busy, setBusy] = useState(false);
  const [found, setFound] = useState<Array<{ ip: string; port: number; model?: string; nickname?: string }>>([]);
  const [msg, setMsg] = useState('');
  async function scan() {
    setBusy(true); setMsg('');
    try {
      const r = ((await DiscoverFlexRadios()) ?? []) as any[];
      setFound(r as any);
      if (r.length === 0) setMsg('No radio found — check it\'s on the same network, or enter the IP manually.');
    } catch (e: any) {
      setMsg(String(e?.message ?? e));
    } finally { setBusy(false); }
  }
  return (
    <div className="rounded-md border border-border bg-muted/20 p-2 space-y-1.5">
      <div className="flex items-center gap-2">
        <Button size="sm" variant="outline" onClick={scan} disabled={busy}>
          {busy ? <Loader2 className="size-3.5 animate-spin" /> : <Wifi className="size-3.5" />} Detect radios
        </Button>
        <span className="text-[11px] text-muted-foreground">listens for FlexRadio broadcast on the LAN</span>
      </div>
      {found.map((r) => (
        <button key={r.ip} type="button" onClick={() => onPick(r.ip, r.port || 4992)}
          className="w-full text-left text-xs rounded border border-border px-2 py-1 hover:bg-accent/50">
          <span className="font-mono font-semibold">{r.ip}</span>
          {r.model ? <span className="text-muted-foreground"> · {r.model}</span> : ''}
          {r.nickname ? <span className="text-muted-foreground"> ({r.nickname})</span> : ''}
        </button>
      ))}
      {msg && <div className="text-[11px] text-muted-foreground">{msg}</div>}
    </div>
  );
}

// AwardsSelectionPanel is a two-column transfer list: every defined award on the
// left, the ones the operator follows on the right. The Awards tab shows only the
// followed set (empty = all). Per-profile, saved immediately (local SQLite).
function AwardsSelectionPanel({ profile }: { profile?: { name?: string; callsign?: string } }) {
  const { t } = useI18n();
  const [all, setAll] = useState<{ code: string; name: string }[]>([]);
  const [tracked, setTracked] = useState<string[]>([]);
  const [err, setErr] = useState('');
  const [q, setQ] = useState('');

  useEffect(() => {
    (async () => {
      try {
        const [defs, tr] = await Promise.all([
          GetAwardDefs().then((d) => (d ?? []) as any[]),
          GetTrackedAwards().then((v) => (v ?? []) as string[]).catch(() => [] as string[]),
        ]);
        setAll(defs.map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code)));
        setTracked(tr);
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    })();
  }, []);

  async function persist(next: string[]) {
    setTracked(next);
    try { await SaveTrackedAwards(next); } catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  const trackedSet = new Set(tracked);
  const byCode = new Map(all.map((a) => [a.code, a] as const));
  const needle = q.trim().toLowerCase();
  const available = all.filter((a) => !trackedSet.has(a.code)
    && (needle === '' || `${a.code} ${a.name}`.toLowerCase().includes(needle)));
  const trackedItems = (tracked.map((c) => byCode.get(c)).filter(Boolean) as { code: string; name: string }[])
    .sort((a, b) => a.code.localeCompare(b.code));

  return (
    <div>
      <SectionHeader title={t('sec.awards')} hint={t('awards.followHint')} />
      <ProfileScopeNote profile={profile} />
      {err && <div className="mb-2 text-xs text-destructive">{err}</div>}
      <div className="grid grid-cols-2 gap-3">
        <div className="rounded-lg border border-border bg-card/40 flex flex-col">
          <div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
            <span className="text-sm font-medium">{t('awards.available')} <span className="text-muted-foreground">({available.length})</span></span>
            <button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
              disabled={available.length === 0} onClick={() => persist(all.map((a) => a.code))}>{t('awards.addAll')}</button>
          </div>
          <div className="p-2 border-b border-border/60">
            <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('awards.search')} className="h-8" />
          </div>
          <div className="max-h-[340px] overflow-y-auto p-1.5 space-y-0.5">
            {available.map((a) => (
              <button key={a.code} type="button" onClick={() => persist([...tracked, a.code])}
                className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
                <span className="font-mono text-xs shrink-0">{a.code}</span>
                <span className="text-xs text-muted-foreground truncate flex-1">{a.name}</span>
                <span className="text-primary opacity-0 group-hover:opacity-100">→</span>
              </button>
            ))}
            {available.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('awards.allTracked')}</div>}
          </div>
        </div>
        <div className="rounded-lg border border-primary/40 bg-primary/5 flex flex-col">
          <div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
            <span className="text-sm font-medium">{t('awards.followed')} <span className="text-muted-foreground">({tracked.length})</span></span>
            <button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
              disabled={tracked.length === 0} onClick={() => persist([])}>{t('awards.clear')}</button>
          </div>
          <div className="max-h-[392px] overflow-y-auto p-1.5 space-y-0.5">
            {trackedItems.map((a) => (
              <button key={a.code} type="button" onClick={() => persist(tracked.filter((c) => c !== a.code))}
                className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
                <span className="text-muted-foreground opacity-0 group-hover:opacity-100">←</span>
                <span className="font-mono text-xs shrink-0">{a.code}</span>
                <span className="text-xs text-muted-foreground truncate flex-1">{a.name}</span>
              </button>
            ))}
            {tracked.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('awards.noneFollowed')}</div>}
          </div>
        </div>
      </div>
    </div>
  );
}

// SatelliteElementsBlock is where the orbital elements are kept up to date.
//
// In Settings rather than in the tab because it is maintenance, not operating:
// during a pass an operator wants the frequencies and the countdown, not a
// download button. Module-scope so it may hold its own hooks (see PanelHost).
function SatelliteElementsBlock({ autoTle, onAutoTle }: { autoTle: boolean; onAutoTle: (v: boolean) => void }) {
  const { t } = useI18n();
  const [info, setInfo] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [paste, setPaste] = useState('');
  const [showPaste, setShowPaste] = useState(false);

  const read = async () => { try { setInfo(await GetSatelliteTLEInfo() as any); } catch { /* shown as unknown */ } };
  useEffect(() => { read(); }, []);

  const refresh = async () => {
    setBusy(true); setMsg('');
    try {
      const i: any = await RefreshSatelliteTLE();
      setInfo(i);
      setMsg(t('satset.tleFetched', { n: i?.count ?? 0 }));
    } catch (e: any) { setMsg(String(e?.message ?? e)); }
    setBusy(false);
  };

  const addPasted = async () => {
    setMsg('');
    try {
      const n: number = (await AddSatelliteElements(paste)) as any;
      setPaste(''); setShowPaste(false);
      await read();
      setMsg(t('satset.tleAdded', { n }));
    } catch (e: any) { setMsg(String(e?.message ?? e)); }
  };

  const age = !info ? '—'
    : info.count === 0 ? t('satset.tleNone')
    : t('satset.tleAge', { n: info.count, h: Math.round(info.age_h) });

  return (
    <div className="space-y-3">
      <h4 className="text-sm font-semibold text-foreground">{t('satset.elements')}</h4>
      <div className="flex items-center gap-3 flex-wrap">
        <span className={cn('text-sm tabular-nums', info?.stale ? 'text-warning' : 'text-muted-foreground')}>{age}</span>
        {!!info?.custom && <span className="text-xs text-muted-foreground">{t('satset.tleCustom', { n: info.custom })}</span>}
        <Button size="sm" variant="outline" onClick={refresh} disabled={busy}>
          {busy ? t('satset.tleFetching') : t('satset.tleFetch')}
        </Button>
        <Button size="sm" variant="ghost" onClick={() => setShowPaste((v) => !v)}>{t('satset.tlePaste')}</Button>
      </div>
      <label className="flex items-center gap-2 text-sm cursor-pointer">
        <Checkbox checked={autoTle} onCheckedChange={(c) => onAutoTle(!!c)} />
        {t('satset.autoTle')}
      </label>
      <p className="text-xs text-muted-foreground">{t('satset.tleHint')}</p>
      {showPaste && (
        <div className="space-y-2">
          <Textarea rows={5} className="font-mono text-xs" placeholder={t('satset.tlePastePlaceholder')}
            value={paste} onChange={(e) => setPaste(e.target.value)} />
          <div className="flex items-center gap-2">
            <Button size="sm" onClick={addPasted} disabled={!paste.trim()}>{t('satset.tlePasteAdd')}</Button>
            <span className="text-xs text-muted-foreground">{t('satset.tlePasteHint')}</span>
          </div>
        </div>
      )}
      {msg && <div className="text-xs text-muted-foreground">{msg}</div>}
    </div>
  );
}

// SatelliteFollowList chooses which satellites the tab shows and the tracker
// offers — the same two-column shape as the awards, for the same reason: a feed
// carries a couple of hundred birds and an operator works six.
//
// Following none means following every satellite that has both elements and a
// frequency plan, which is the sensible thing for somebody who has not chosen
// yet and the reason the list does not start out empty-handed.
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
  const { t } = useI18n();
  const [all, setAll] = useState<any[]>([]);
  const [q, setQ] = useState('');
  const [withPlanOnly, setWithPlanOnly] = useState(true);

  useEffect(() => {
    (async () => {
      try { setAll(((await GetSatelliteBirds()) ?? []) as any[]); } catch { /* an empty list says it itself */ }
    })();
  }, []);

  const followedSet = new Set(followed.map((s) => s.toUpperCase()));
  const byName = new Map(all.map((b) => [b.name as string, b] as const));
  const needle = q.trim().toLowerCase();
  const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
    && (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
    && (needle === '' || String(b.name).toLowerCase().includes(needle)));
  const chosen = followed.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] });

  const label = (b: any) => {
    const bits: string[] = [];
    if ((b.transponders?.length ?? 0) > 0) bits.push(b.transponders.map((x: any) => x.mode).filter((m: string, i: number, a: string[]) => a.indexOf(m) === i).join('/'));
    if (b.geostationary) bits.push(t('sat.geo'));
    if (!b.has_elements) bits.push(t('sat.noElements'));
    return bits.join(' · ');
  };

  return (
    <div className="space-y-2">
      <h4 className="text-sm font-semibold text-foreground">{t('satset.follow')}</h4>
      <p className="text-xs text-muted-foreground">{t('satset.followHint')}</p>
      <div className="grid grid-cols-2 gap-3">
        <div className="rounded-lg border border-border bg-card/40 flex flex-col">
          <div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
            <span className="text-sm font-medium">{t('satset.available')} <span className="text-muted-foreground">({available.length})</span></span>
            <button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
              disabled={available.length === 0}
              onClick={() => onChange([...followed, ...available.map((b) => b.name as string)])}>
              {t('awards.addAll')}
            </button>
          </div>
          <div className="p-2 border-b border-border/60 space-y-2">
            <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('satset.search')} className="h-8" />
            <label className="flex items-center gap-2 text-xs cursor-pointer text-muted-foreground">
              <Checkbox checked={withPlanOnly} onCheckedChange={(c) => setWithPlanOnly(!!c)} />
              {t('satset.withPlanOnly')}
            </label>
          </div>
          <div className="max-h-[300px] overflow-y-auto p-1.5 space-y-0.5">
            {available.map((b) => (
              <button key={b.name} type="button" onClick={() => onChange([...followed, b.name])}
                className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
                <span className="font-mono text-xs shrink-0">{b.name}</span>
                <span className="text-[11px] text-muted-foreground truncate flex-1">{label(b)}</span>
                <span className="text-primary opacity-0 group-hover:opacity-100">→</span>
              </button>
            ))}
            {available.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('satset.allFollowed')}</div>}
          </div>
        </div>
        <div className="rounded-lg border border-primary/40 bg-primary/5 flex flex-col">
          <div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
            <span className="text-sm font-medium">{t('satset.followed')} <span className="text-muted-foreground">({followed.length})</span></span>
            <button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
              disabled={followed.length === 0} onClick={() => onChange([])}>{t('awards.clear')}</button>
          </div>
          <div className="max-h-[352px] overflow-y-auto p-1.5 space-y-0.5">
            {chosen.map((b: any) => (
              <button key={b.name} type="button" onClick={() => onChange(followed.filter((n) => n !== b.name))}
                className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
                <span className="text-muted-foreground opacity-0 group-hover:opacity-100">←</span>
                <span className="font-mono text-xs shrink-0">{b.name}</span>
                <span className="text-[11px] text-muted-foreground truncate flex-1">{label(b)}</span>
              </button>
            ))}
            {followed.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('satset.noneFollowed')}</div>}
          </div>
        </div>
      </div>
    </div>
  );
}

function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
  const label = SECTION_LABELS[id] ?? id;
  const IconCmp = Icon ?? Construction;
  return (
    <div className="flex flex-col items-center justify-center text-center text-muted-foreground gap-2 py-12">
      <IconCmp className="size-10 opacity-40" />
      <div className="text-base font-semibold text-foreground/70">{label}</div>
      <div className="text-sm">Module coming soon.</div>
    </div>
  );
}

// The panadapter statuses, in the order the cluster ranks them: most wanted
// first, then the markers that are orthogonal to the entity, then the two
// "nothing here" cases. Must match spotColorOrder in spotcolors.go — the Go side
// is the authority, this list only decides the order of the rows.
const SPOT_COLOR_ROWS = [
  'new', 'new-band-mode', 'new-band', 'new-mode', 'new-slot',
  'new-pota', 'new-county', 'new-pfx',
  'my-call', 'worked', 'none',
];

// SmartSDR colours are #AARRGGBB — the alpha FIRST, which is not what CSS or an
// <input type="color"> expects. These two convert between the radio's order and
// the browser's, so the operator picks a colour in a normal picker and the radio
// gets what it understands.
function argbToCss(argb?: string): string {
  const v = (argb ?? '').trim();
  if (!/^#[0-9a-fA-F]{8}$/.test(v)) return '';
  return `#${v.slice(3)}${v.slice(1, 3)}`; // #AARRGGBB → #RRGGBBAA
}
function cssToArgb(rgb: string, alpha: string): string {
  const v = (rgb || '#000000').trim();
  if (!/^#[0-9a-fA-F]{6}$/.test(v)) return '';
  return `#${alpha}${v.slice(1)}`.toUpperCase();
}

// ArgbInput edits one #AARRGGBB value as what it really is: a colour and an
// opacity. A single text field would be asking an operator to type eight hex
// digits in an order no other program uses.
function ArgbInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
  const valid = /^#[0-9a-fA-F]{8}$/.test((value ?? '').trim());
  const alpha = valid ? value.slice(1, 3).toUpperCase() : 'FF';
  const rgb = valid ? `#${value.slice(3)}` : '#808080';
  return (
    <span className="flex items-center gap-1 shrink-0" title={label}>
      <input type="color" value={rgb}
        onChange={(e) => onChange(cssToArgb(e.target.value, alpha))}
        className="size-6 rounded border border-border bg-transparent p-0 cursor-pointer" />
      <select value={alpha}
        onChange={(e) => onChange(cssToArgb(rgb, e.target.value))}
        className="h-6 rounded border border-border bg-background text-[10px] px-0.5">
        {[['FF', '100%'], ['C0', '75%'], ['80', '50%'], ['40', '25%'], ['20', '12%'], ['00', 'off']].map(([a, l]) => (
          <option key={a} value={a}>{l}</option>
        ))}
      </select>
    </span>
  );
}

// FlexBandPanel — everything that follows the band, in ONE row per band:
// antennas and TX power.
//
// They were two tables listing the same bands one under the other, which made
// the operator match rows by eye between them. The two settings are stored and
// applied separately (antennas on band change, power on band or mode change) —
// that is a backend detail, and no reason to split what is one decision per
// band for the person configuring it.
function FlexBandPanel({ bands }: { bands: string[] }) {
  const { t } = useI18n();
  const [rxList, setRxList] = useState<string[]>([]);
  const [txList, setTxList] = useState<string[]>([]);
  const [ant, setAnt] = useState<Record<string, { rx: string; tx: string }>>({});
  const [pwr, setPwr] = useState<Record<string, { phone: number; cw: number; digi: number }>>({});
  const [msg, setMsg] = useState('');

  useEffect(() => {
    GetFlexState().then((s: any) => {
      setRxList((s?.ant_list ?? []) as string[]);
      setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
    }).catch(() => {});
    GetFlexBandAntennas().then((m: any) => setAnt(m ?? {})).catch(() => {});
    GetFlexBandPower().then((m: any) => setPwr(m ?? {})).catch(() => {});
  }, []);

  const saved = () => { setMsg(t('flxpw.saved')); window.setTimeout(() => setMsg(''), 1200); };

  const setAntenna = (band: string, side: 'rx' | 'tx', v: string) => {
    const key = band.toUpperCase();
    setAnt((m) => {
      const next = { ...m, [key]: { ...(m[key] ?? { rx: '', tx: '' }), [side]: v } };
      SaveFlexBandAntennas(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
      return next;
    });
  };

  const setPower = (band: string, kind: 'phone' | 'cw' | 'digi', v: string) => {
    const key = band.toUpperCase();
    const n = Math.max(0, Math.min(100, parseInt(v, 10) || 0));
    setPwr((m) => {
      const next = { ...m, [key]: { ...(m[key] ?? { phone: 0, cw: 0, digi: 0 }), [kind]: n } };
      SaveFlexBandPower(next as any).then(saved).catch((e: any) => setMsg(String(e?.message ?? e)));
      return next;
    });
  };

  const antCell = (b: string, side: 'rx' | 'tx', list: string[]) => {
    const e = ant[b.toUpperCase()] ?? { rx: '', tx: '' };
    return (
      <td className="px-2 py-1.5">
        <select value={e[side] ?? ''} onChange={(ev) => setAntenna(b, side, ev.target.value)}
          className="h-8 w-28 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
          <option value="">— none —</option>
          {list.map((a) => <option key={a} value={a}>{a}</option>)}
        </select>
      </td>
    );
  };

  // The rule marking where band-driven settings end and mode-driven ones begin
  // is drawn on the FIRST power cell, header and body alike. It used to be an
  // extra empty cell in the body only, which gave the two rows different column
  // counts and slid every heading one column left.
  const pwrCell = (b: string, kind: 'phone' | 'cw' | 'digi') => {
    const e = pwr[b.toUpperCase()] ?? { phone: 0, cw: 0, digi: 0 };
    return (
      <td className={cn('px-2 py-1.5', kind === 'phone' && 'border-l border-border/60')}>
        <Input
          type="number" min={0} max={100}
          className="h-8 w-16 text-xs"
          value={e[kind] ? String(e[kind]) : ''}
          placeholder="—"
          onChange={(ev) => setPower(b, kind, ev.target.value)}
        />
      </td>
    );
  };

  return (
    <div className="space-y-3">
      <div>
        <h3 className="text-base font-semibold text-foreground">{t('flxb.title')}</h3>
        <p className="text-xs text-muted-foreground">{t('flxb.hint')}</p>
      </div>
      {rxList.length === 0 && (
        <div className="text-xs text-warning-muted-foreground bg-warning-muted border border-warning-border rounded-md px-3 py-2 max-w-2xl">
          {t('flxb.noRadio')}
        </div>
      )}
      {bands.length === 0 ? (
        <p className="text-xs text-muted-foreground">{t('flxpw.noBands')}</p>
      ) : (
        <div className="rounded-md border border-border overflow-x-auto max-w-3xl">
          <table className="w-full text-sm">
            <thead className="bg-muted/40 text-muted-foreground text-xs">
              <tr>
                <th className="text-left px-3 py-1.5 font-semibold">{t('flxpw.band')}</th>
                <th className="text-left px-2 py-1.5 font-semibold">{t('flxb.rxAnt')}</th>
                <th className="text-left px-2 py-1.5 font-semibold">{t('flxb.txAnt')}</th>
                <th className="text-left px-2 py-1.5 font-semibold border-l border-border/60">{t('flxpw.phone')}</th>
                <th className="text-left px-2 py-1.5 font-semibold">CW</th>
                <th className="text-left px-2 py-1.5 font-semibold">{t('flxpw.digi')}</th>
              </tr>
            </thead>
            <tbody>
              {bands.map((b) => (
                <tr key={b} className="border-t border-border/50">
                  <td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
                  {antCell(b, 'rx', rxList)}
                  {antCell(b, 'tx', txList)}
                  {pwrCell(b, 'phone')}
                  {pwrCell(b, 'cw')}
                  {pwrCell(b, 'digi')}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
      {msg && <span className="text-[11px] text-muted-foreground">{msg}</span>}
    </div>
  );
}

// Known Icom models paired with their FACTORY default CI-V address. Picking a
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
// The IC-7700 and IC-7800 were listed at 0x88 and 0x80 — which are the IC-7100's
// and the IC-7410's. Picking one of them set an address the rig never answers on,
// and the backend then named it as the other model.
const ICOM_MODELS: { name: string; addr: number }[] = [
  { name: 'IC-705', addr: 0xA4 },
  { name: 'IC-7100', addr: 0x88 },
  { name: 'IC-7300', addr: 0x94 },
  { name: 'IC-7300MKII', addr: 0xB6 },
  { name: 'IC-7410', addr: 0x80 },
  { name: 'IC-7600', addr: 0x7A },
  { name: 'IC-7610', addr: 0x98 },
  { name: 'IC-7700', addr: 0x74 },
  { name: 'IC-7760', addr: 0xB2 },
  { name: 'IC-7800', addr: 0x6A },
  { name: 'IC-7851', addr: 0x8E },
  { name: 'IC-9100', addr: 0x7C },
  { name: 'IC-9700', addr: 0xA2 },
];

// The radios OpsLog talks to, by BRAND, and the ways each one can be reached.
//
// Three connections exist in the world and each brand has its own subset:
//   usb    — a COM port, the radio's own USB or a serial cable
//   bridge — RS-232 carried over Ethernet by a serial bridge (ser2net, an
//            Ethernet-serial box): the SAME CAT bytes, a socket instead of wire
//   native — the manufacturer's own network protocol, a session of its own
//
// The backend NAME stored in settings is unchanged — 'icom-net' is still
// 'icom-net' — because a settings file written by an older build has to keep
// working. This table is only how the two questions map onto it.
// OmniRig leads — it is the one that works with any radio, so it is where
// someone who does not find their rig below should land. The rest are
// alphabetical, because a list of brands has no other defensible order.
const CAT_BRANDS: { id: string; label: string; links: string[]; backend: (link: string) => string }[] = [
  { id: 'omnirig', label: 'OmniRig (any rig)', links: ['usb'], backend: () => 'omnirig' },
  { id: 'elecraft', label: 'Elecraft K3 / K4', links: ['usb', 'bridge'], backend: () => 'elecraft' },
  { id: 'tci', label: 'Expert Electronics / SunSDR (TCI)', links: ['native'], backend: () => 'tci' },
  { id: 'flex', label: 'FlexRadio (SmartSDR)', links: ['native'], backend: () => 'flex' },
  { id: 'icom', label: 'Icom', links: ['usb', 'native'], backend: (l) => (l === 'native' ? 'icom-net' : 'icom') },
  { id: 'kenwood', label: 'Kenwood', links: ['usb', 'bridge'], backend: () => 'kenwood' },
  { id: 'xiegu', label: 'Xiegu', links: ['usb'], backend: () => 'xiegu' },
  { id: 'yaesu', label: 'Yaesu', links: ['usb'], backend: () => 'yaesu' },
];

// brandOfBackend reads the stored backend back into the two questions.
function brandOfBackend(backend: string, kenwoodLink?: string): { brand: string; link: string } {
  switch (backend) {
    case 'icom-net': return { brand: 'icom', link: 'native' };
    case 'icom': return { brand: 'icom', link: 'usb' };
    case 'flex': return { brand: 'flex', link: 'native' };
    case 'tci': return { brand: 'tci', link: 'native' };
    case 'yaesu': return { brand: 'yaesu', link: 'usb' };
    case 'xiegu': return { brand: 'xiegu', link: 'usb' };
    case 'kenwood': case 'elecraft':
      return { brand: backend, link: kenwoodLink || 'usb' };
    default: return { brand: 'omnirig', link: 'usb' };
  }
}

// SETTINGS DOES NOT RE-RENDER WITH THE MAIN WINDOW.
//
// It is a child of App, and App re-renders on every cluster spot, every CAT
// status push, every decode — several times a second on a busy evening. Each of
// those re-rendered this entire panel, which is large enough that the rebuild
// takes longer than the gap between them: the page looked like it was
// refreshing ten times a second and buttons stopped responding, because the
// element under the pointer was replaced between the press and the release.
//
// memo() cuts that off. It only works if the props hold still, which is why
// App passes callbacks that do not change identity on every render — see the
// useCallback wrappers there.
function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable, elecraftAvailable, tciAvailable, onEditQSO }: Props) {
  const { t } = useI18n();
  const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [clearing, setClearing] = useState(false);
  const [msg, setMsg] = useState('');
  const [err, setErr] = useState('');

  const [lookup, setLookup] = useState<LookupSettings>({
    qrz_user: '', qrz_password: '',
    hamqth_user: '', hamqth_password: '',
    primary: '', failsafe: '',
    download_images: false,
    qrz_prefer_nickname: false,
    cache_ttl_days: 30,
  });
  // Per-provider Test state — keeps the success/error feedback adjacent
  // to the button. Cleared on the next test run for that provider.
  type TestResult = { ok: boolean; msg: string };
  const [lookupTest, setLookupTest] = useState<Record<string, TestResult | undefined>>({});
  const [lookupTesting, setLookupTesting] = useState<Record<string, boolean>>({});
  // The Station Information panel now edits the full active profile
  // (not a flat 6-field StationSettings). Profile selection happens in
  // the Profiles panel; any edit here saves back to whichever profile
  // is currently active.
  const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
  const updateActive = (patch: Partial<Profile>) =>
    setActiveProfile((p) => (p ? { ...p, ...patch } : p));
  const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [], rst_phone: [], rst_cw: [], rst_digital: [], satellites: [] });
  // RST report lists edited as free text (one/space-separated values).
  const [rstText, setRstText] = useState({ phone: '', cw: '', digital: '' });
  // Custom band drafts (catalog covers ADIF spec but the user may have
  // exotic or experimental bands not listed).
  const [bandDraft, setBandDraft] = useState('');
  const [modeDraft, setModeDraft] = useState('');
  // The saved radios. The CAT panel edits ONE of them — whichever is on the air
  // — and the list is what makes switching possible without touching profiles.
  const [radios, setRadios] = useState<any[]>([]);
  const [listenPref, setListenPref] = useState(true);
  const [activeRadio, setActiveRadioId] = useState('');
  const [radioBusy, setRadioBusy] = useState(false);
  // "Other (custom address)" has to STAY chosen. The dropdown is derived from
  // the address, so picking Other while the address still matched a listed rig
  // put the list straight back on that rig — the operator saw it jump back to
  // IC-7610 the moment they chose Other.
  const [icomCustom, setIcomCustom] = useState(false);
  const [catCfg, setCatCfg] = useState<CATSettings>({
    enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
    yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, yaesu_rtty_usb: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
    icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
    tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
    digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
    ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, digi_as_usb: false,
  });
  // Brand + connection, derived from the stored backend rather than held
  // separately: two sources for one fact drift apart the first time something
  // else writes the backend (loading a profile, an older settings file).
  const catBrand = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).brand;
  const catLink = brandOfBackend(catCfg.backend, (catCfg as any).kenwood_link).link;
  const applyCatBrand = (id: string) => {
    const b = CAT_BRANDS.find((x) => x.id === id);
    if (!b) return;
    // Keep the connection when the new brand offers it, otherwise take its
    // first — picking Flex from Kenwood-over-USB has to land on something.
    const link = b.links.includes(catLink) ? catLink : b.links[0];
    setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
  };
  const applyCatLink = (link: string) => {
    const b = CAT_BRANDS.find((x) => x.id === catBrand);
    if (!b || !b.links.includes(link)) return;
    setCatCfg((s) => ({ ...s, backend: b.backend(link), kenwood_link: link } as any));
  };

  // While true, the next key press is captured as the PTT hotkey.
  const [capturingPtt, setCapturingPtt] = useState(false);
  const [rotors, setRotors] = useState<RotatorDevice[]>([]);
  const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
  // Whether the presets have actually been READ back yet.
  //
  // An empty list is a legitimate answer — an operator may want no quick-turn
  // buttons at all — so the backend stores it as one. That makes "not loaded
  // yet" and "deliberately none" the same value, and a Save landing in the first
  // state wiped the buttons for good. Saving is gated on having read them, so
  // the only empty list that can ever reach the store is one the operator made.
  const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false);
  const [rotatorTesting, setRotatorTesting] = useState(false);
  const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);

  // Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
  const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; band_freqs: Record<string, number>; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
    enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', band_freqs: {}, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
  });
  const [ubTesting, setUbTesting] = useState(false);
  const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);

  // Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
  const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string; use_for_my_antenna?: boolean; ant1_port?: number }>({ enabled: false, host: '', password: '', use_for_my_antenna: false, ant1_port: 1 });
  const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
  const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
  // Satellites: the observer and the az/el rotator. The rest of the satellite
  // settings (favourites, the pass window) are set in the tab itself, where
  // they are used.
  const [satCfg, setSatCfg] = useState<any>({ min_el: 10, window_h: 24, auto_tle: true, grid: '', alt_m: 0, rot_on: false, rot_type: 'easycomm', rot_pst_port: 12000, rot_transport: 'serial', rot_host: '', rot_port: 4533, rot_com: '', rot_baud: 9600, rot_max_az: 360, rot_min_el: 0, rot_step: 5, rot_park: false });
  const [satTest, setSatTest] = useState('');

  // Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
  // each with its own connection. Saved as a whole via SaveAmplifiers.
  const [amps, setAmps] = useState<AmpUI[]>([]);
  const [linkedAmps, setLinkedAmps] = useState<string[]>([]);

  // WinKeyer CW keyer settings + macro editor.
  type WKMac = { label: string; text: string };
  type WKSettings = {
    enabled: boolean; engine: string; esc_clears_call: boolean; esm: boolean;
    port: string; baud: number; wpm: number; weight: number;
    lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
    sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
    use_ptt: boolean; serial_echo: boolean; cw_key_line: string; cw_invert: boolean; macros: WKMac[];
  };
  const [wk, setWk] = useState<WKSettings>({
    enabled: false, engine: 'winkeyer', esc_clears_call: true, esm: false,
    port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
    tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
    swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
  });
  const [wkPorts, setWkPorts] = useState<string[]>([]);
  // Session-only: the byte trace is a diagnostic, never a saved preference.
  //
  // But it must still SHOW its real state. These start false on every mount, so
  // a trace already running came back unticked; the operator ticks the box to
  // turn it on, which turns it off, and the log they send has no trace in it.
  // Reported by a Xiegu user. Hydrated from the backend below.
  const [dateFmtSel, setDateFmtSel] = useState<DateFormat>(getDateFormat);
  const [wkTrace, setWkTrace] = useState(false);
  const [civTrace, setCivTrace] = useState(false);
  useEffect(() => {
    CIVTraceEnabled().then((v) => setCivTrace(!!v)).catch(() => {});
    WinkeyerTraceEnabled().then((v) => setWkTrace(!!v)).catch(() => {});
  }, []);
  const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));

  // ── Audio (DVK + QSO recorder) ──
  type AudioSettings = {
    from_radio: string; to_radio: string; recording_device: string; listening_device: string;
    qso_record: boolean; qso_dir: string; preroll_seconds: number;
    ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; ptt_data?: boolean; format: 'wav' | 'mp3';
    from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
  };
  type AudioDev = { id: string; name: string; default: boolean };
  const [audioCfg, setAudioCfg] = useState<AudioSettings>({
    from_radio: '', to_radio: '', recording_device: '', listening_device: '',
    qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', ptt_data: false, format: 'wav',
    from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
  });
  const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
  const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
  const setAudioField = (patch: Partial<AudioSettings>) => setAudioCfg((s) => ({ ...s, ...patch }));
  const reloadAudioDevices = () => {
    ListAudioInputDevices().then((d) => setAudioInputs((d ?? []) as AudioDev[])).catch(() => {});
    ListAudioOutputDevices().then((d) => setAudioOutputs((d ?? []) as AudioDev[])).catch(() => {});
  };
  // DVK voice-keyer messages (F1–F6).
  type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
  type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
  const [chaseCfg, setChaseCfg] = useState<{ mode: string; sources: string[] }>({ mode: 'new', sources: ['lotw', 'card', 'eqsl'] });
  useEffect(() => { GetChaseSettings().then((c: any) => setChaseCfg({ mode: c?.mode ?? 'new', sources: c?.sources ?? ['lotw', 'card', 'eqsl'] })).catch(() => {}); }, []);
  const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
  const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
  const [dvkErr, setDvkErr] = useState('');
  const [monitorOn, setMonitorOn] = useState(false);
  const [txOn, setTxOn] = useState(false);
  useEffect(() => {
    AudioMonitorActive().then(setMonitorOn).catch(() => {});
    AudioTXActive().then(setTxOn).catch(() => {});
  }, []);
  const toggleMonitor = async () => {
    try {
      if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); }
      else { await AudioStartMonitor(); setMonitorOn(true); }
    } catch (err: any) { setDvkErr(String(err?.message ?? err)); }
  };
  const toggleTX = async () => {
    try {
      if (txOn) { await AudioStopTX(); setTxOn(false); }
      else { await AudioStartTX(); setTxOn(true); }
    } catch (err: any) { setDvkErr(String(err?.message ?? err)); }
  };

  // General behaviour prefs (mirrored to the DB so they travel with data/).
  const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
  const [checkUpdates, setCheckUpdates] = useState(() => localStorage.getItem('opslog.checkUpdates') !== '0');
  const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
  // Compact rotor widget: the dial and the SP/LP readout only. Off by default —
  // the full widget is what the operator has today, and a setting that changes
  // a panel the moment you upgrade is a setting that gets blamed for it.
  const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
  const [rotorDial, setRotorDial] = useState<RotorStyle>(() => rotorStyle());
  const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
  const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
  const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
  const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
  const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
  const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
  const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
  const [catModeBeforeFreq, setCatModeBeforeFreq] = useState(() => localStorage.getItem('opslog.catModeBeforeFreq') === '1');
  // Password-encryption (secret vault) state.
  const [secret, setSecret] = useState<{ has_passphrase: boolean; unlocked: boolean }>({ has_passphrase: false, unlocked: false });
  const [ppNew, setPpNew] = useState('');
  const [ppConfirm, setPpConfirm] = useState('');
  const [ppErr, setPpErr] = useState('');
  const [ppBusy, setPpBusy] = useState(false);
  const refreshSecret = async () => { try { setSecret(await GetSecretStatus() as any); } catch {} };
  useEffect(() => { refreshSecret(); }, []);
  const applyPassphrase = async () => {
    if (ppNew !== ppConfirm) { setPpErr(t('gen.pwMismatch')); return; }
    setPpBusy(true); setPpErr('');
    try { await SetPassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
    catch (e: any) { setPpErr(String(e?.message ?? e)); }
    finally { setPpBusy(false); }
  };
  const removePassphrase = async () => {
    setPpBusy(true); setPpErr('');
    try { await RemovePassphrase(ppNew); setPpNew(''); setPpConfirm(''); await refreshSecret(); }
    catch (e: any) { setPpErr(String(e?.message ?? e)); }
    finally { setPpBusy(false); }
  };

  // E-mail / SMTP (send QSO recordings).
  type EmailCfg = {
    enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
    from: string; reply_to: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
  };
  const [emailCfg, setEmailCfg] = useState<EmailCfg>({
    enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
    from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
  });
  const [emailMsg, setEmailMsg] = useState('');
  const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
  // eQSL card e-mail (subject/body templates + auto-send on log).
  type EQSLCfg = { subject: string; body: string; auto_send: boolean; default_message: string };
  const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false, default_message: '' });
  const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
  // ClubLog Country File (cty.xml) exception status.
  type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
  const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
  const [clubBusy, setClubBusy] = useState(false);
  const [clubErr, setClubErr] = useState('');
  useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
  // ClubLog Most Wanted (rank shown in the entry matrix).
  type MWInfo = { enabled: boolean; loaded: boolean; callsign: string; date: string; count: number };
  const [mwInfo, setMwInfo] = useState<MWInfo>({ enabled: false, loaded: false, callsign: '', date: '', count: 0 });
  const [mwBusy, setMwBusy] = useState(false);
  const [mwErr, setMwErr] = useState('');
  useEffect(() => { GetClublogMostWantedInfo().then((i) => setMwInfo(i as MWInfo)).catch(() => {}); }, []);
  const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
  useEffect(() => {
    GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
    const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
    return () => { off?.(); };
  }, []);

  type QSLDefaults = {
    qsl_sent: string; qsl_rcvd: string;
    lotw_sent: string; lotw_rcvd: string;
    eqsl_sent: string; eqsl_rcvd: string;
    clublog_status: string; clublog_confirmed: string; hrdlog_status: string; qrzcom_status: string;
    qrzcom_confirmed: string;
    hamlog_status: string; hamlog_confirmed: string;
    hamqth_status: string;
  };
  const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
    qsl_sent: '', qsl_rcvd: '',
    lotw_sent: '', lotw_rcvd: '',
    eqsl_sent: '', eqsl_rcvd: '',
    clublog_status: '', clublog_confirmed: '', hrdlog_status: '', qrzcom_status: '',
    qrzcom_confirmed: '',
    hamlog_status: '', hamlog_confirmed: '', hamqth_status: '',
  });

  // External services (logbook upload). One block per service; only QRZ is
  // wired today. upload_mode is 'immediate' | 'delayed' (per-service).
  type ExtServiceCfg = {
    api_key: string; email: string; username: string; password: string; callsign: string;
    code: string; qth_nickname: string;
    url: string; station_id: string; // Cloudlog/Wavelog: own instance + station profile
    force_station_callsign: string;
    tqsl_path: string; station_location: string; key_password: string;
    upload_flags: string[]; write_log: boolean;
    auto_upload: boolean; upload_mode: string;
    // HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
    on_air?: boolean;
  };
  type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; hamqth: ExtServiceCfg; delete_remote?: boolean };
  const emptyExtCfg = (): ExtServiceCfg => ({
    api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
    force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
    upload_flags: ['N', 'R'], write_log: false,
    auto_upload: false, upload_mode: 'immediate', on_air: false,
  });
  const [extSvc, setExtSvc] = useState<ExternalServices>({
    qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), hamqth: emptyExtCfg(), delete_remote: false,
  });
  const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
  const [qrzTesting, setQrzTesting] = useState(false);
  const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null);
  const [clublogTesting, setClublogTesting] = useState(false);
  const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
  const [lotwTesting, setLotwTesting] = useState(false);
  // LoTW user-activity list (for the callsign badge): count + last refresh.
  const [lotwUsers, setLotwUsers] = useState<{ count: number; updated?: string }>({ count: 0 });
  const [lotwUsersBusy, setLotwUsersBusy] = useState(false);
  useEffect(() => { GetLoTWUsersStatus().then((s) => setLotwUsers(s as any)).catch(() => {}); }, []);
  const downloadLotwUsers = async () => {
    setLotwUsersBusy(true);
    try { await DownloadLoTWUsers(); const s = await GetLoTWUsersStatus(); setLotwUsers(s as any); }
    catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
    finally { setLotwUsersBusy(false); }
  };
  // Super Check Partial / N+1 callsign helper: enabled flag + master-list status.
  const [scp, setScp] = useState<{ enabled: boolean; count: number; updated?: string }>({ enabled: false, count: 0 });
  const [scpBusy, setScpBusy] = useState(false);
  useEffect(() => { GetScpStatus().then((s) => setScp(s as any)).catch(() => {}); }, []);
  const toggleScp = async (on: boolean) => {
    setScp((s) => ({ ...s, enabled: on }));
    setScpBusy(true);
    try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
    finally { setScpBusy(false); }
  };
  const refreshScp = async () => { try { const s = await GetScpStatus(); setScp(s as any); } catch {} };
  const downloadScp = async () => {
    setScpBusy(true);
    try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
    finally { setScpBusy(false); }
  };
  // US Counties (offline FCC ULS) — download progress arrives via events.
  const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string; needs_refresh?: boolean }>({ count: 0 });
  const [ulsBusy, setUlsBusy] = useState(false);
  const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
  const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
  const [backfillBusy, setBackfillBusy] = useState(false);
  const [backfillMsg, setBackfillMsg] = useState<string | null>(null);
  useEffect(() => { ULSStatus().then((s) => setUlsStatus(s as any)).catch(() => {}); }, []);
  useEffect(() => {
    const off1 = EventsOn('uls:progress', (p: any) => setUlsProgress({ stage: p?.stage ?? '', pct: p?.pct ?? 0 }));
    const off2 = EventsOn('uls:done', async (r: any) => {
      setUlsBusy(false); setUlsProgress(null);
      setUlsMsg(r?.ok ? { ok: true, text: t('uscty.done', { n: r?.count ?? 0 }) } : { ok: false, text: r?.error || 'error' });
      try { const s = await ULSStatus(); setUlsStatus(s as any); } catch {}
    });
    return () => { off1(); off2(); };
  }, []);
  const downloadUls = async () => {
    setUlsBusy(true); setUlsMsg(null); setUlsProgress({ stage: '', pct: 0 });
    try { await DownloadULSCounties(); }
    catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
  };
  // cty.dat is not downloaded by OpsLog — it is shipped and reloaded from disk —
  // so the page reports it rather than offering a button it cannot honour.
  // Panadapter spot palette. Saved on its own — it is a table of colours, not a
  // form: waiting for the modal's Save to see a colour on the waterfall would
  // make picking one a guessing game.
  type SpotColor = { text: string; bg?: string };
  const [spotColors, setSpotColors] = useState<{ enabled: boolean; colors: Record<string, SpotColor & { hide?: boolean }> }>({ enabled: true, colors: {} });
  useEffect(() => { GetSpotColors().then((c: any) => setSpotColors(c ?? { enabled: true, colors: {} })).catch(() => {}); }, []);
  const saveSpotColors = (next: { enabled: boolean; colors: Record<string, SpotColor> }) => {
    setSpotColors(next);
    SaveSpotColors(next as any).catch(() => {});
  };
  const setSpotColor = (k: string, patch: Partial<SpotColor>) => saveSpotColors({
    ...spotColors,
    colors: { ...spotColors.colors, [k]: { ...(spotColors.colors[k] ?? { text: '' }), ...patch } },
  });
  // Panadapter auto-zoom. Saved immediately for the same reason as the palette:
  // the only way to judge a width is to click a spot and look at the radio.
  const [flexZoom, setFlexZoom] = useState<{ enabled: boolean; cw_khz: number; ssb_khz: number; digi_khz: number; centre: boolean }>(
    { enabled: false, cw_khz: 25, ssb_khz: 200, digi_khz: 50, centre: false });
  useEffect(() => { GetFlexZoom().then((z: any) => setFlexZoom(z)).catch(() => {}); }, []);
  const saveFlexZoom = (next: typeof flexZoom) => { setFlexZoom(next); SaveFlexZoom(next as any).catch(() => {}); };
  const [ctyInfo, setCtyInfo] = useState<{ entities: number; file_mod_time?: string }>({ entities: 0 });
  const [ctyBusy, setCtyBusy] = useState(false);
  useEffect(() => { GetCtyDatInfo().then((i) => setCtyInfo(i as any)).catch(() => {}); }, []);
  const refreshCty = async () => {
    setCtyBusy(true);
    try { const i: any = await RefreshCtyDat(); setCtyInfo(i); } catch { /* the line keeps its old count */ }
    finally { setCtyBusy(false); }
  };
  // Award reference lists, and which of them have an online updater.
  const [refMeta, setRefMeta] = useState<Array<{ code: string; count: number; updated_at?: string; can_update: boolean }>>([]);
  const [refBusy, setRefBusy] = useState('');
  const [refMsg, setRefMsg] = useState('');
  const reloadRefMeta = () => { GetAwardReferenceMeta().then((m) => setRefMeta((m ?? []) as any)).catch(() => {}); };
  useEffect(reloadRefMeta, []);
  const updateRefList = async (code: string) => {
    setRefBusy(code); setRefMsg('');
    try { const m: any = await UpdateAwardReferenceList(code); setRefMsg(t('db.refUpdated', { code, n: m?.count ?? 0 })); reloadRefMeta(); }
    catch (e: any) { setRefMsg(String(e?.message ?? e)); }
    finally { setRefBusy(''); }
  };
  // A date for a person: the day, not the timestamp the backend stores.
  const fmtDay = (v?: string) => (v ? String(v).slice(0, 10) : '—');
  // Binary units, one decimal, because the point of showing a size here is to
  // compare two of them: "412.7 MB → 38.4 MB" says what a bare byte count does not.
  const fmtBytes = (n: number) => {
    if (!n || n < 0) return '—';
    const u = ['B', 'KB', 'MB', 'GB'];
    let i = 0;
    let v = n;
    while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
    return `${i === 0 ? v : v.toFixed(1)} ${u[i]}`;
  };
  const [rdaCount, setRdaCount] = useState(0);
  const [rdaBusy, setRdaBusy] = useState(false);
  const [rdaUseCurrent, setRdaUseCurrent] = useState(true);
  const [rdaMsg, setRdaMsg] = useState<string | null>(null);
  useEffect(() => { RDADatabaseCount().then((n) => setRdaCount(Number(n) || 0)).catch(() => {}); }, []);
  // Measuring the two sources against each other, before deciding which wins.
  //
  // Read-only on purpose: the question "is HAMLOG's district independent
  // evidence or a copy of the same database?" has to be answered with the
  // operator's own log, and answering it must not modify that log.
  const [rdaCmp, setRdaCmp] = useState<any>(null);
  const [rdaCmpBusy, setRdaCmpBusy] = useState(false);
  // Its own message, next to its own button.
  //
  // A failed comparison used to write into rdaMsg — which is rendered beside
  // the FILL DISTRICTS button, a row above. The error appeared under a button
  // nobody had pressed, and the one that had been pressed showed nothing at
  // all, which reads as "the button does nothing".
  const [rdaCmpMsg, setRdaCmpMsg] = useState<string>('');
  // Which source the operator kept, per QSO id. By id and not by row: a second
  // comparison rebuilds the list, and a choice that followed a row number would
  // then be applied to somebody else's contact.
  const [rdaPick, setRdaPick] = useState<Record<number, 'log' | 'db'>>({});
  const [rdaApplyBusy, setRdaApplyBusy] = useState(false);
  const runRDACompare = async () => {
    setRdaCmpBusy(true); setRdaCmp(null); setRdaCmpMsg('');
    try {
      const r: any = await CompareRDASources();
      setRdaCmp(r);
      // A comparison that finds nothing is a RESULT, and the most likely one on
      // a tidy log. Without a word for it the panel looked like it had not run.
      if (!r || (r.scanned ?? 0) === 0) setRdaCmpMsg(t('rda.cmpNoRussian'));
      else if ((r.disagree ?? 0) === 0) setRdaCmpMsg(t('rda.cmpNoConflict'));
    } catch (e: any) { setRdaCmpMsg(String(e?.message ?? e)); }
    finally { setRdaCmpBusy(false); }
  };

  const runRDABackfill = async () => {
    setRdaBusy(true); setRdaMsg(null);
    try {
      const r: any = await BackfillRDA(rdaUseCurrent);
      setRdaMsg(t('rda.backfillDone', {
        d: r?.dated ?? 0, c: r?.current ?? 0, u: r?.unknown ?? 0, k: r?.skipped ?? 0, s: r?.scanned ?? 0,
      }));
    } catch (e: any) { setRdaMsg(String(e?.message ?? e)); }
    finally { setRdaBusy(false); }
  };
  const runBackfill = async () => {
    setBackfillBusy(true); setBackfillMsg(null);
    try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
    catch (e: any) { setBackfillMsg(String(e?.message ?? e)); }
    finally { setBackfillBusy(false); }
  };
  const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
  const [hrdlogTesting, setHrdlogTesting] = useState(false);
  const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
  const [cloudlogTesting, setCloudlogTesting] = useState(false);
  const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
  const [hamlogTesting, setHamlogTesting] = useState(false);
  const [hamqthTest, setHamqthTest] = useState<{ ok: boolean; msg: string } | null>(null);
  const [hamqthTesting, setHamqthTesting] = useState(false);
  const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
  const [eqslTesting, setEqslTesting] = useState(false);
  const [stationLocations, setStationLocations] = useState<string[]>([]);
  // Active tab in the External Services panel. Lifted here back when a panel
  // could not hold hooks at all; PanelHost lifted that restriction, and this
  // stays put because moving it down would reset the tab on every reopen —
  // a choice now, not a workaround.
  const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'hamqth' | 'pota'>('qrz');
  // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
  const [potaToken, setPotaToken] = useState('');
  const [potaBusy, setPotaBusy] = useState(false);
  const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string; unmatched?: any[] } | null>(null);
  useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []);

  const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
    enabled: false, folder: '', rotation: 5, zip: false,
    last_backup_at: '', default_folder: '',
  } as any);
  const [backupRunning, setBackupRunning] = useState(false);
  const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);

  const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean; logbook_default_path?: string }>({ path: '', default_path: '', is_custom: false });
  const [dbMsg, setDbMsg] = useState('');
  type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string; sqlite_path?: string };
  const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
  const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
  const [mysqlMsg, setMysqlMsg] = useState('');
  const [restartMsg, setRestartMsg] = useState(''); // backend switch / save → "restart to apply"
  const [backendStatus, setBackendStatus] = useState<{ active: string; fallback: boolean; error: string } | null>(null);

  const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
  const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
  // Defaults to true so the checkbox matches the backend before the read lands.
  const [workedVariants, setWorkedVariants] = useState(true);
  // Self-spot. SELF_SPOT_MIN_MIN mirrors the backend floor — the input clamps on
  // blur, not per keystroke, or typing "10" would be rewritten to "5" the moment
  // the "1" landed and the field would fight the operator.
  const SELF_SPOT_MIN_MIN = 5;
  // Band-opening watch. Saved through the backend rather than as a UI pref: it
  // has side effects there — adding the RBN nodes, bringing the PSK Reporter
  // feed up or down — so the write has to go where those live.
  const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
  // How a worked square is matched, and what counts as still wanted.
  const [gridScope, setGridScope] = useState<any>({ scope: 'mix_digi', hunt: 'new', scopes: [] });
  useEffect(() => { GetGridScopeSettings().then((g) => setGridScope(g as any)).catch(() => {}); }, []);
  const saveGridScope = async (next: any) => {
    setGridScope(next);
    try { await SaveGridScopeSettings(next); } catch { /* the panel keeps the choice either way */ }
  };
  const [chaseGrids, setChaseGrids] = useState(false);
  const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
  const [chaseCountyOn, setChaseCountyOn] = useState(() => localStorage.getItem('opslog.chaseCounty') !== '0');
  const [chasePfxOn, setChasePfxOn] = useState(() => localStorage.getItem('opslog.chasePfx') !== '0');
  const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
  const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
  const [chaseNew, setChaseNew] = useState(false);
  const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
  const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_periods: 1, on_screen_only: true });
  // The named contest callsigns. Raw text in state, written on blur: it is a
  // multi-line list, and normalising it on every keystroke would fight the
  // Return key — the one key this box is built around.
  const [contestCalls, setContestCalls] = useState('');
  // Which bands the Chase new panel shows. The station's own band list still
  // applies underneath — this one is "what am I watching tonight".
  const [chaseBands, setChaseBands] = useState<string[]>([]);
  const saveChaseBands = (next: string[]) => {
    setChaseBands(next);
    void SetChaseNewBands(next);
  };
  const [contestPattern, setContestPattern] = useState('');
  const saveAC = async (next: any) => {
    setAc(next);
    try { await SaveAutoCallSettings(next); } catch { /* the toolbar shows what the engine is doing */ }
  };
  const savePSKTgt = async (next: any) => {
    setPskTgt(next);
    try { await SavePSKTargetSettings(next); } catch { /* the panel itself reports what the feed is doing */ }
  };
  const [spotTTL, setSpotTTL] = useState(0);
  const [spotTTLText, setSpotTTLText] = useState('0');
  const [spotMaxText, setSpotMaxText] = useState('1000');
  // TCI receive-audio test bench. Polled only while the stream is open: a panel
  // that asks the backend twice a second for a stream nobody started is work
  // done for nothing.



  // Whether the QSO recorder takes its audio from the radio's own stream.
  const [gridStat, setGridStat] = useState<any>(null);
  const [pskrStatus, setPskrStatus] = useState<any>(null);
  const saveBandOpen = async (next: any) => {
    setBandOpen(next);
    try { await SaveBandOpenSettings(next); } catch { /* the status line shows the result */ }
  };
  useEffect(() => {
    (async () => {
      try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
      try {
        const g = await GetChaseNewGrids();
        setChaseGrids(g);
        // Mirror for the display layer (spotDisplay.chaseGrid) — the cluster
        // gates NEW GRID synchronously from localStorage.
        writeUiPref('opslog.chaseGrids', g ? '1' : '0');
      } catch { /* defaults stand */ }
      try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
      try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
      try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
      try { setChaseBands((await GetChaseNewBands()) ?? []); } catch { /* defaults stand */ }
      try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
      try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
      try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
      try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
      try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ }
    })();
  }, []);
  // Poll the feed only while the section that SHOWS it is open.
  //
  // A live count is the one thing that separates "connected" from "connected
  // and receiving nothing", so it has to be polled — but it was polled from
  // everywhere, re-rendering the whole dialog every three seconds whichever
  // panel was in front. That is a heartbeat through every list and every form
  // in Preferences for a number nobody is looking at.
  useEffect(() => {
    if (selected !== 'cluster') return;
    const t = window.setInterval(async () => {
      try { setPskrStatus(await GetPSKReporterStatus()); } catch { /* ignore */ }
      try { setGridStat(await GetGridCacheStatus()); } catch { /* ignore */ }
    }, 3000);
    return () => window.clearInterval(t);
  }, [selected]);
  const [selfSpot, setSelfSpot] = useState({ enabled: false, minutes: SELF_SPOT_MIN_MIN });
  const [selfSpotText, setSelfSpotText] = useState(String(SELF_SPOT_MIN_MIN));
  const [clusterStatuses, setClusterStatuses] = useState<ClusterServerStatus[]>([]);
  const [editingServer, setEditingServer] = useState<ClusterServer | null>(null);

  async function reloadClusterServers() {
    try {
      const [list, ac, st, ss] = await Promise.all([
        ListClusterServers(),
        GetClusterAutoConnect(),
        GetClusterStatus(),
        GetSelfSpotSettings(),
      ]);
      setClusterServers((list ?? []) as ClusterServer[]);
      setClusterAutoConnectState(ac);
      setClusterStatuses((st ?? []) as ClusterServerStatus[]);
      setSelfSpot(ss as any);
      setSelfSpotText(String((ss as any).minutes ?? SELF_SPOT_MIN_MIN));
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }

  // Live cluster status updates while Preferences is open — the user can
  // click Connect/Disconnect inside the modal and see the pills change
  // without saving + reopening.
  useEffect(() => {
    const unsub = EventsOn('cluster:state', async (st: any) => {
      setClusterStatuses((st ?? []) as ClusterServerStatus[]);
      try {
        const list = await ListClusterServers();
        setClusterServers((list ?? []) as ClusterServer[]);
      } catch {}
    });
    return () => { unsub?.(); };
  }, []);
  const [profiles, setProfiles] = useState<Profile[]>([]);
  // State for ProfilesPanel. Lifted here back when a panel could not hold hooks
  // — PanelHost lifted that restriction — and left here because the selection
  // then survives switching sections, which is what an operator expects of it.
  const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
  const [profileNameDraft, setProfileNameDraft] = useState<string>('');

  async function reloadProfiles() {
    try {
      const list = await ListProfiles();
      setProfiles(list);
      // Refresh the active-profile editor in case activation changed.
      const ap = await GetActiveProfile();
      setActiveProfile(ap as Profile);
    } catch (e: any) {
      setErr(String(e?.message ?? e));
    }
  }

  // Keep the ProfilesPanel selector in sync with the loaded list. If the
  // currently-selected profile is gone (post-delete) or none is selected
  // yet, default to the active one.
  useEffect(() => {
    if (!profiles.length) return;
    const stillThere = profiles.some((p) => (p.id as number) === profileSelectedId);
    if (!stillThere) {
      const next = profiles.find((p) => p.is_active) ?? profiles[0];
      setProfileSelectedId(next.id as number);
      setProfileNameDraft(next.name);
    }
  }, [profiles, profileSelectedId]);

  useEffect(() => {
    (async () => {
      try {
        const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
          GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
          GetRotators(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
        ]);
        setLookup(l);
        setActiveProfile(ap as Profile);
        setLists(ls);
        setRstText({
          phone: ((ls as any).rst_phone ?? []).join(' '),
          cw: ((ls as any).rst_cw ?? []).join(' '),
          digital: ((ls as any).rst_digital ?? []).join(' '),
        });
        await reloadProfiles();
        await reloadClusterServers();
        setCatCfg(c);
        setRotors((r ?? []) as any);
        // Loaded HERE, in the loader that runs on mount — not only in the
        // event-driven one below. Missing from this one, the state stayed empty
        // on a normal open and Save then wrote an empty list over the operator's
        // buttons. See rotorPresetsLoaded for the belt to this brace.
        try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
        try {
          try { setListenPref(!!(await GetAudioMonitorPref())); } catch {}
          const rl: any = await GetRadios();
          setRadios(rl ?? []);
          // The backend is the only one who knows which radio is on the air —
          // the list entries carry no 'active' flag, and guessing the first
          // meant reopening the panel showed Radio 1's name over the settings
          // of whichever radio was actually selected.
          let actId = '';
          try { actId = (await ActiveRadioID()) ?? ''; } catch {}
          if (!actId || !(rl ?? []).some((r: any) => r.id === actId)) actId = (rl ?? [])[0]?.id ?? '';
          setActiveRadioId(actId);
        } catch { /* one radio, never listed — the panel works as it always did */ }
        try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
        try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
        try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
        try { setPsuCfg(await GetPSUSettings() as any); } catch {}
        try { setSatCfg(await GetSatSettings() as any); } catch {}
        try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
        setBackupCfg(b as any);
        setQslDefaults(qd as any);
        setExtSvc(es as any);
        try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
        try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
        try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
        try {
          const locs: any = await ListTQSLStationLocations();
          setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
        } catch { /* TQSL not installed — leave the dropdown empty */ }
        try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {}
        try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
        try { setAudioCfg(await GetAudioSettings() as any); } catch {}
        try { setEmailCfg(await GetEmailSettings() as any); } catch {}
        try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
        try { setWorkedVariants(await GetWorkedCallVariants()); } catch {}
        reloadAudioDevices();
        reloadDvk();
      } catch (e: any) {
        setErr(String(e?.message ?? e));
      } finally {
        setLoading(false);
      }
    })();
  }, []);

  // Every setting is per-profile, so when the active profile changes WHILE this
  // dialog is open, re-read the panels (MySQL connection, CAT, audio, accounts…)
  // — otherwise they keep showing the previous profile's values until reopen.
  useEffect(() => {
    const off = EventsOn('profile:changed', () => {
      (async () => {
        try { setMysqlCfg(await GetMySQLSettings() as any); } catch {}
        try { setBackendStatus(await GetDBBackendStatus() as any); } catch {}
        try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
        try { setLookup(await GetLookupSettings() as any); } catch {}
        try { setCatCfg(await GetCATSettings() as any); } catch {}
        try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
        try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
        try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
        try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
        try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
        try { setPsuCfg(await GetPSUSettings() as any); } catch {}
        try { setSatCfg(await GetSatSettings() as any); } catch {}
        try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
        try { setBackupCfg(await GetBackupSettings() as any); } catch {}
        try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
        try { setExtSvc(await GetExternalServices() as any); } catch {}
        try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {}
        try { setAudioCfg(await GetAudioSettings() as any); } catch {}
        try { setEmailCfg(await GetEmailSettings() as any); } catch {}
        try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
        try {
          const ls: any = await GetListsSettings();
          setLists(ls);
          setRstText({
            phone: (ls.rst_phone ?? []).join(' '),
            cw: (ls.rst_cw ?? []).join(' '),
            digital: (ls.rst_digital ?? []).join(' '),
          });
        } catch {}
      })();
    });
    return () => { off(); };
  }, []);

  // Which (profile, callsign, grid) the auto-fill below last derived from.
  // Without it, simply OPENING the settings counted as "the source changed" and
  // overwrote the operator's own values.
  const derivedFrom = useRef<string>('');

  // Auto-fill the active profile's MY_* DXCC metadata from the station
  // callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon).
  //
  // These are derived values, so they recompute when the callsign or grid
  // changes. What they must NOT do is recompute on a plain load: cty.dat gives
  // the zones of the ENTITY, and a large country spans several — an operator in
  // CQ 14 / ITU 27 was handed 15 and 28 and corrected them by hand, and every
  // restart put the wrong pair back. cty.dat is a starting point, not an
  // authority, so on a load we only fill fields that are EMPTY; a value the
  // operator typed stays until the callsign or grid itself changes.
  // Debounced so we don't hammer cty.dat while typing.
  useEffect(() => {
    const call = (activeProfile?.callsign ?? '').trim();
    if (!call) return;
    const grid = (activeProfile?.my_grid ?? '').trim();
    const source = `${activeProfile?.id ?? 0}|${call.toUpperCase()}|${grid.toUpperCase()}`;
    // First sight of this profile — a load, not an edit.
    const isLoad = derivedFrom.current === '' || derivedFrom.current.split('|')[0] !== String(activeProfile?.id ?? 0);
    const t = window.setTimeout(async () => {
      try {
        const i: any = await ComputeStationInfo(call, grid);
        derivedFrom.current = source;
        setActiveProfile((p) => {
          if (!p) return p;
          const patch: any = {};
          // On a load, keep in patch only what the profile does not already have.
          const put = (k: string, v: any) => {
            if (!v) return;
            const cur = (p as any)[k];
            if (isLoad && cur !== undefined && cur !== null && cur !== '' && cur !== 0) return;
            patch[k] = v;
          };
          put('my_country', i.country);
          put('my_dxcc', i.dxcc);
          put('my_cqz', i.cqz);
          put('my_ituz', i.ituz);
          put('my_lat', i.lat);
          put('my_lon', i.lon);
          // Only re-render when a value actually changed (prevents loops).
          const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
          return changed ? { ...p, ...patch } : p;
        });
      } catch { /* offline / unknown prefix — leave fields as-is */ }
    }, 250);
    return () => window.clearTimeout(t);
  }, [activeProfile?.callsign, activeProfile?.my_grid]);

  // ── Band selection helpers (dual-list shuttle) ──────────────────────────
  function addBand(tag: string) {
    const b = tag.trim().toLowerCase();
    if (!b) return;
    setLists((l) => {
      if ((l.bands ?? []).includes(b)) return l;
      return { ...l, bands: [...(l.bands ?? []), b] };
    });
  }
  function removeBand(i: number) {
    setLists((l) => {
      const next = [...(l.bands ?? [])];
      next.splice(i, 1);
      return { ...l, bands: next };
    });
  }
  function moveBand(i: number, dir: -1 | 1) {
    setLists((l) => {
      const next = [...(l.bands ?? [])];
      const j = i + dir;
      if (j < 0 || j >= next.length) return l;
      [next[i], next[j]] = [next[j], next[i]];
      return { ...l, bands: next };
    });
  }

  // ── Mode helpers ────────────────────────────────────────────────────────
  function addMode() {
    setLists((l) => ({
      ...l,
      modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
    }));
  }
  function addModeFromCatalog(m: { name: string; sent: string; rcvd: string }) {
    setLists((l) => {
      if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === m.name)) return l;
      return {
        ...l,
        modes: [...(l.modes ?? []), { name: m.name, default_rst_sent: m.sent, default_rst_rcvd: m.rcvd }],
      };
    });
  }
  function addCustomMode(name: string) {
    const n = name.trim().toUpperCase();
    if (!n) return;
    setLists((l) => {
      if ((l.modes ?? []).some((x) => (x.name ?? '').toUpperCase() === n)) return l;
      return {
        ...l,
        modes: [...(l.modes ?? []), { name: n, default_rst_sent: '59', default_rst_rcvd: '59' }],
      };
    });
  }
  function removeMode(i: number) {
    setLists((l) => {
      const next = [...(l.modes ?? [])];
      next.splice(i, 1);
      return { ...l, modes: next };
    });
  }
  function moveMode(i: number, dir: -1 | 1) {
    setLists((l) => {
      const next = [...(l.modes ?? [])];
      const j = i + dir;
      if (j < 0 || j >= next.length) return l;
      [next[i], next[j]] = [next[j], next[i]];
      return { ...l, modes: next };
    });
  }
  function updateMode(i: number, patch: Partial<ModePreset>) {
    setLists((l) => {
      const next = [...(l.modes ?? [])];
      next[i] = { ...next[i], ...patch } as ModePreset;
      return { ...l, modes: next };
    });
  }

  async function save(close = true) {
    setSaving(true); setErr(''); setMsg('');
    try {
      // Bands: dedup, lowercase, trim. Order = user's drag order.
      const seen = new Set<string>();
      const bands: string[] = [];
      for (const raw of lists.bands ?? []) {
        const b = (raw ?? '').trim().toLowerCase();
        if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
      }
      const modes = (lists.modes ?? [])
        .map((m) => ({
          name: (m.name ?? '').trim().toUpperCase(),
          default_rst_sent: (m.default_rst_sent ?? '').trim(),
          default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(),
        }))
        .filter((m) => m.name !== '');
      const splitList = (s: string) => s.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
      await SaveListsSettings({
        bands, modes,
        rst_phone: splitList(rstText.phone),
        rst_cw: splitList(rstText.cw),
        rst_digital: splitList(rstText.digital),
        // Normalised HERE and not only on the field's blur: Save is reachable
        // without ever leaving the box, and a list that depends on where the
        // cursor went before the click is a list that sometimes saves.
        satellites: Array.from(new Set(((lists.satellites ?? []) as string[])
          .map((v) => v.trim().toUpperCase()).filter(Boolean))).sort(),
      } as any);

      if (activeProfile) {
        await SaveProfile({
          ...activeProfile,
          callsign:     (activeProfile.callsign     ?? '').trim().toUpperCase(),
          operator:     (activeProfile.operator     ?? '').trim().toUpperCase(),
          my_grid:      (activeProfile.my_grid      ?? '').trim().toUpperCase(),
          my_sota_ref:  (activeProfile.my_sota_ref  ?? '').trim().toUpperCase(),
          my_pota_ref:  (activeProfile.my_pota_ref  ?? '').trim().toUpperCase(),
          my_iota:      (activeProfile.my_iota      ?? '').trim().toUpperCase(),
        } as any);
      }
      await SaveLookupSettings(lookup as any);
      await SaveCATSettings(catCfg as any);
      await SaveRotators(rotors as any);
      // Only once they have been read back — see rotorPresetsLoaded.
      if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
      await SaveUltrabeamSettings(ultrabeam as any);
      await SaveAntGeniusSettings(antgenius as any);
      await SaveTunerGeniusSettings(tunergenius as any);
      await SavePSUSettings(psuCfg as any);
      await SaveSatSettings(satCfg as any);
      await SaveAmplifiers(amps as any);
      await SaveWinkeyerSettings(wk as any);
      await SaveAudioSettings(audioCfg as any);
      await SaveEmailSettings(emailCfg as any);
      await QSLSaveEmailTemplates(eqslCfg as any);
      await SaveBackupSettings(backupCfg as any);
      await SaveQSLDefaults(qslDefaults as any);
      await SaveExternalServices(extSvc as any);
      await SetClusterAutoConnect(clusterAutoConnect);
      await SaveSelfSpotSettings(selfSpot as any);

      setMsg('Settings saved.');
      onSaved();
      if (close) setTimeout(onClose, 500);
      else setTimeout(() => setMsg(''), 2000);
    } catch (e: any) {
      setErr(String(e?.message ?? e));
    } finally {
      setSaving(false);
    }
  }

  async function clearCache() {
    setClearing(true); setErr(''); setMsg('');
    try {
      await ClearLookupCache();
      setMsg('Cache cleared.');
    } catch (e: any) {
      setErr(String(e?.message ?? e));
    } finally {
      setClearing(false);
    }
  }

  async function testProvider(provider: 'qrz' | 'hamqth') {
    setLookupTesting((s) => ({ ...s, [provider]: true }));
    setLookupTest((s) => ({ ...s, [provider]: undefined }));
    const user = provider === 'qrz' ? lookup.qrz_user : lookup.hamqth_user;
    const pwd  = provider === 'qrz' ? lookup.qrz_password : lookup.hamqth_password;
    try {
      const r = await TestLookupProvider(provider, '', user ?? '', pwd ?? '');
      setLookupTest((s) => ({ ...s, [provider]: { ok: true, msg: `OK — ${r.callsign} (${r.name || r.country || 'no name'})` } }));
    } catch (e: any) {
      setLookupTest((s) => ({ ...s, [provider]: { ok: false, msg: String(e?.message ?? e) } }));
    } finally {
      setLookupTesting((s) => ({ ...s, [provider]: false }));
    }
  }

  const breadcrumb = useMemo(() => { const k = SECTION_KEY[selected]; return k ? t(k) : (SECTION_LABELS[selected] ?? selected); }, [selected, t]);

  // === Section content renderers ===

  function StationPanel() {
    if (!activeProfile) {
      return <div className="text-muted-foreground text-sm">{t('station.loading')}</div>;
    }
    const p = activeProfile;
    return (
      <>
        <SectionHeader
          title={t('sec.station')}
          hint={t('station.hint', { name: p.name })}
        />
        <div className="grid grid-cols-2 gap-3 max-w-2xl">
          <div className="space-y-1">
            <Label>{t('station.stationCall')}</Label>
            <Input className="font-mono uppercase" value={p.callsign ?? ''} onChange={(e) => updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
            <div className="text-[10px] text-muted-foreground">{t('station.stationCallHint')}</div>
          </div>
          <div className="space-y-1">
            <Label>{t('station.opCall')}</Label>
            <Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
            <div className="text-[10px] text-muted-foreground">{t('station.opCallHint')}</div>
          </div>
          <div className="space-y-1 col-span-2">
            <Label>{t('station.ownerCall')}</Label>
            <Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder={t('station.ownerBlank')} />
            <div className="text-[10px] text-muted-foreground">{t('station.ownerHint')}</div>
          </div>
          <div className="space-y-1 col-span-2">
            <Label>{t('station.opName')}</Label>
            <Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
            <div className="text-[10px] text-muted-foreground">{t('station.opNameHint')}</div>
          </div>
          <div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
            {t('station.autofill')}
          </div>
          <div className="space-y-1">
            <Label>{t('station.myGrid')}</Label>
            <Input className="font-mono uppercase" value={p.my_grid ?? ''} onChange={(e) => updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
          </div>
          <div className="space-y-1">
            <Label>{t('station.myCountry')}</Label>
            <Input value={p.my_country ?? ''} onChange={(e) => updateActive({ my_country: e.target.value })} placeholder="France" />
          </div>
          <div className="grid grid-cols-3 gap-2 col-span-2">
            <div className="space-y-1">
              <Label>{t('station.dxcc')}</Label>
              <Input type="number" className="font-mono" value={(p as any).my_dxcc ?? ''}
                onChange={(e) => updateActive({ my_dxcc: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
            </div>
            <div className="space-y-1">
              <Label>{t('station.cqz')}</Label>
              <Input type="number" className="font-mono" value={(p as any).my_cqz ?? ''}
                onChange={(e) => updateActive({ my_cqz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
            </div>
            <div className="space-y-1">
              <Label>{t('station.ituz')}</Label>
              <Input type="number" className="font-mono" value={(p as any).my_ituz ?? ''}
                onChange={(e) => updateActive({ my_ituz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" />
            </div>
            <div className="space-y-1">
              <Label>{t('station.lat')}</Label>
              <Input type="number" step="0.001" className="font-mono" value={(p as any).my_lat ?? ''}
                onChange={(e) => updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
            </div>
            <div className="space-y-1">
              <Label>{t('station.lon')}</Label>
              <Input type="number" step="0.001" className="font-mono" value={(p as any).my_lon ?? ''}
                onChange={(e) => updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
            </div>
          </div>
          <div className="space-y-1">
            <Label>{t('station.state')}</Label>
            <Input value={p.my_state ?? ''} onChange={(e) => updateActive({ my_state: e.target.value })} />
          </div>
          <div className="space-y-1">
            <Label>{t('station.county')}</Label>
            <Input value={p.my_cnty ?? ''} onChange={(e) => updateActive({ my_cnty: e.target.value })} />
          </div>
          <div className="space-y-1 col-span-2">
            <Label>{t('station.street')}</Label>
            <Input value={p.my_street ?? ''} onChange={(e) => updateActive({ my_street: e.target.value })} />
          </div>
          <div className="space-y-1">
            <Label>{t('station.postal')}</Label>
            <Input value={p.my_postal_code ?? ''} onChange={(e) => updateActive({ my_postal_code: e.target.value })} />
          </div>
          <div className="space-y-1">
            <Label>{t('station.city')}</Label>
            <Input value={p.my_city ?? ''} onChange={(e) => updateActive({ my_city: e.target.value })} />
          </div>
          <div className="space-y-1">
            <Label>{t('station.sota')}</Label>
            <Input className="font-mono uppercase" value={p.my_sota_ref ?? ''} onChange={(e) => updateActive({ my_sota_ref: e.target.value })} placeholder="F/AB-001" />
          </div>
          <div className="space-y-1">
            <Label>{t('station.pota')}</Label>
            <Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
          </div>
          <div className="space-y-1">
            <Label>{t('station.iota')}</Label>
            <Input className="font-mono uppercase" value={p.my_iota ?? ''} onChange={(e) => updateActive({ my_iota: e.target.value })} placeholder="EU-005" />
          </div>
        </div>
      </>
    );
  }

  // Profile actions — kept at the SettingsModal level so the ProfilesPanel
  // renderer can stay hooks-free (the PANELS map calls it as a plain
  // function, not as a JSX component).
  const activeProfileObj = profiles.find((p) => p.is_active) ?? profiles[0];
  const currentProfile = profiles.find((p) => (p.id as number) === profileSelectedId);

  async function profileActivate() {
    if (!currentProfile) return;
    try {
      await ActivateProfile(currentProfile.id as number);
      await reloadProfiles();
      // Per-profile settings follow the active identity — reload the panels
      // that are now scoped to the newly-active profile.
      const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]);
      setActiveProfile(ap as Profile);
      setQslDefaults(qd as any);
      setExtSvc(es as any);
      onSaved();
    }
    catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  async function profileRemove() {
    if (!currentProfile) return;
    if (!confirm(t('prof.deleteConfirm', { name: currentProfile.name }))) return;
    try { await DeleteProfile(currentProfile.id as number); await reloadProfiles(); }
    catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  async function profileDuplicate() {
    if (!currentProfile) return;
    const name = prompt(t('prof.dupPrompt', { name: currentProfile.name }), t('prof.dupSuffix', { name: currentProfile.name }));
    if (!name?.trim()) return;
    try {
      const dup = await DuplicateProfile(currentProfile.id as number, name.trim());
      await reloadProfiles();
      setProfileSelectedId(dup.id as number);
      setProfileNameDraft(dup.name);
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  async function profileCreateBlank() {
    const name = prompt(t('prof.newPrompt'), t('prof.newDefault'));
    if (!name?.trim()) return;
    try {
      const blank = emptyProfile();
      blank.name = name.trim();
      const saved = await SaveProfile(blank as any);
      await reloadProfiles();
      setProfileSelectedId(saved.id as number);
      setProfileNameDraft(saved.name);
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  async function profileRenameCurrent() {
    if (!currentProfile || profileNameDraft.trim() === currentProfile.name) return;
    try {
      await SaveProfile({ ...currentProfile, name: profileNameDraft.trim() } as any);
      await reloadProfiles();
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }

  function ProfilesPanel() {
    const current = currentProfile;
    const active = activeProfileObj;
    return (
      <>
        <SectionHeader
          title={t('sec.profiles')}
          hint={t('prof.hint')}
        />
        <div className="space-y-4">
          <div className="grid grid-cols-[140px_1fr] items-center gap-x-3 gap-y-3">
            <Label>{t('prof.configId')}</Label>
            <Select
              value={String(profileSelectedId)}
              onValueChange={(v) => {
                const id = parseInt(v, 10);
                setProfileSelectedId(id);
                setProfileNameDraft(profiles.find((p) => (p.id as number) === id)?.name ?? '');
              }}
            >
              <SelectTrigger><SelectValue /></SelectTrigger>
              <SelectContent>
                {profiles.map((p) => (
                  <SelectItem key={p.id as number} value={String(p.id)}>
                    {p.name}{p.callsign ? ` — ${p.callsign}` : ''}{p.is_active ? t('prof.activeSuffix') : ''}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>

            <Label>{t('prof.description')}</Label>
            <div className="flex items-center gap-2">
              <Input
                value={profileNameDraft}
                onChange={(e) => setProfileNameDraft(e.target.value)}
                onBlur={profileRenameCurrent}
                placeholder={t('prof.profileName')}
                disabled={!current}
              />
              {current?.is_active && (
                <span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-success-muted text-success-muted-foreground border border-success-border">
                  {t('prof.active')}
                </span>
              )}
            </div>
          </div>

          <div className="flex items-center gap-2 pt-2 border-t border-border">
            <Button variant="outline" size="sm" onClick={profileCreateBlank} title={t('prof.newTitle')}>
              <Plus className="size-3.5 mr-1" /> {t('prof.new')}
            </Button>
            <Button variant="outline" size="sm" onClick={profileDuplicate} disabled={!current} title={t('prof.dupTitle')}>
              <Copy className="size-3.5 mr-1" /> {t('prof.duplicate')}
            </Button>
            <Button
              variant="outline" size="sm"
              onClick={profileActivate}
              disabled={!current || current.is_active}
              title={t('prof.setActiveTitle')}
            >
              <Star className="size-3.5 mr-1" /> {t('prof.setActive')}
            </Button>
            <Button
              variant="outline" size="sm"
              onClick={profileRemove}
              disabled={!current || profiles.length <= 1}
              className="text-destructive hover:text-destructive ml-auto"
              title={profiles.length <= 1 ? t('prof.cantDeleteLast') : t('prof.deleteTitle')}
            >
              <Trash2 className="size-3.5 mr-1" /> {t('prof.delete')}
            </Button>
          </div>

          {current && !current.is_active && (
            <div className="text-xs text-muted-foreground bg-muted/30 border border-border rounded-md p-2.5">
              {t('prof.viewingNote', { name: current.name, active: active?.name ?? '' })}
            </div>
          )}
        </div>
      </>
    );
  }

  function LookupPanel() {
    // The cache lifetime as TYPED, so the box can be emptied and re-filled.
    // Re-seeded when the settings arrive from the backend — which is after the
    // first render, so it cannot simply be the initial value.
    const [ttlText, setTtlText] = useState(String(lookup.cache_ttl_days));
    useEffect(() => { setTtlText(String(lookup.cache_ttl_days)); }, [lookup.cache_ttl_days]);
    // Per-row provider editor — kept inline because it's only used twice
    // and needs closure access to the parent state.
    const row = (
      key: 'qrz' | 'hamqth', label: string, userField: 'qrz_user' | 'hamqth_user',
      pwdField: 'qrz_password' | 'hamqth_password',
    ) => {
      const test = lookupTest[key];
      const testing = lookupTesting[key];
      const hasCreds = !!(lookup[userField] && lookup[pwdField]);
      return (
        <tr className="border-t border-border align-middle">
          <td className="px-3 py-2 font-semibold whitespace-nowrap">{label}</td>
          <td className="px-2 py-2 text-center">
            <input
              type="radio"
              name="lookup-primary"
              checked={lookup.primary === key}
              onChange={() => setLookup((s) => ({ ...s, primary: key, failsafe: s.failsafe === key ? '' : s.failsafe }))}
              disabled={!hasCreds}
            />
          </td>
          <td className="px-2 py-2 text-center">
            <input
              type="radio"
              name="lookup-failsafe"
              checked={lookup.failsafe === key}
              onChange={() => setLookup((s) => ({ ...s, failsafe: key, primary: s.primary === key ? '' : s.primary }))}
              disabled={!hasCreds || lookup.primary === key}
            />
          </td>
          <td className="px-2 py-2">
            <Input
              className="h-8"
              value={lookup[userField] ?? ''}
              onChange={(e) => setLookup((s) => ({ ...s, [userField]: e.target.value }))}
              placeholder={t('lk.user')}
              autoComplete="off"
            />
          </td>
          <td className="px-2 py-2">
            <Input
              className="h-8"
              type="password"
              value={lookup[pwdField] ?? ''}
              onChange={(e) => setLookup((s) => ({ ...s, [pwdField]: e.target.value }))}
              placeholder={t('lk.password')}
              autoComplete="off"
            />
          </td>
          <td className="px-2 py-2 whitespace-nowrap">
            <Button
              variant="outline" size="sm"
              onClick={() => testProvider(key)}
              disabled={!hasCreds || testing}
              title={t('lk.testTitle')}
            >
              {testing ? t('lk.testing') : t('lk.test')}
            </Button>
          </td>
          <td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-success' : 'text-destructive')}>
            {test?.msg}
          </td>
        </tr>
      );
    };
    return (
      <>
        <SectionHeader
          title={t('sec.lookup')}
          hint={t('lk.hint')}
        />
        <div className="rounded-md border border-border overflow-hidden">
          <table className="w-full text-sm">
            <thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
              <tr>
                <th className="text-left px-3 py-2">{t('lk.provider')}</th>
                <th className="px-2 py-2 w-20">{t('lk.primary')}</th>
                <th className="px-2 py-2 w-20">{t('lk.failsafe')}</th>
                <th className="text-left px-2 py-2">{t('lk.user')}</th>
                <th className="text-left px-2 py-2">{t('lk.password')}</th>
                <th className="px-2 py-2 w-20"></th>
                <th className="text-left px-2 py-2">{t('lk.result')}</th>
              </tr>
            </thead>
            <tbody>
              {row('qrz',    'QRZ.com', 'qrz_user',    'qrz_password')}
              {row('hamqth', 'HamQTH',  'hamqth_user', 'hamqth_password')}
            </tbody>
          </table>
        </div>
        <p className="text-xs text-muted-foreground mt-2">
          {t('lk.failsafeNote')}
        </p>

        <div className="mt-6 pt-4 border-t border-border">
          <h3 className="text-sm font-semibold mb-2">{t('lk.display')}</h3>
          <label className="flex items-start gap-2 text-sm cursor-pointer">
            <Checkbox
              checked={lookup.download_images}
              onCheckedChange={(c) => setLookup((s) => ({ ...s, download_images: !!c }))}
              className="mt-0.5"
            />
            <span>
              {t('lk.showPics')}
              <span className="block text-xs text-muted-foreground mt-0.5">
                {t('lk.showPicsHint')}
              </span>
            </span>
          </label>

          {/* QRZ only. HamQTH's <nick> already fills the Name field with the
              on-air name, so the same switch there would toggle a behaviour it
              has no way to turn off. */}
          <label className="flex items-start gap-2 text-sm cursor-pointer mt-3">
            <Checkbox
              checked={!!(lookup as any).qrz_prefer_nickname}
              onCheckedChange={(c) => setLookup((s) => ({ ...s, qrz_prefer_nickname: !!c } as any))}
              className="mt-0.5"
            />
            <span>
              {t('lk.qrzNickname')}
              <span className="block text-xs text-muted-foreground mt-0.5">
                {t('lk.qrzNicknameHint')}
              </span>
            </span>
          </label>
        </div>

        <div className="mt-6 pt-4 border-t border-border">
          <h3 className="text-sm font-semibold mb-2">{t('lk.cache')}</h3>
          <p className="text-xs text-muted-foreground mb-3">
            {t('lk.cacheHint')}
          </p>
          <div className="flex gap-3 items-end">
            <div className="space-y-1 w-40">
              <Label>{t('lk.ttl')}</Label>
              {/* Raw text, not the stored number. Deriving the value from the
                  number on every keystroke made the box impossible to empty —
                  and "0" itself unreachable, since parseInt('0') || 30 is 30.
                  Zero is now a real setting, so it has to be typeable. */}
              <Input
                type="number" min={0} max={3650}
                value={ttlText}
                onChange={(e) => {
                  const raw = e.target.value;
                  setTtlText(raw);
                  const n = parseInt(raw, 10);
                  if (Number.isFinite(n) && n >= 0) {
                    setLookup((s) => ({ ...s, cache_ttl_days: Math.min(n, 3650) }));
                  }
                }}
                onBlur={() => setTtlText(String(lookup.cache_ttl_days))}
              />
            </div>
            <Button variant="outline" onClick={clearCache} disabled={clearing}>
              {clearing ? t('lk.clearing') : t('lk.clearCache')}
            </Button>
          </div>
          {lookup.cache_ttl_days === 0 && (
            <p className="text-[11px] text-warning mt-2">{t('lk.cacheOff')}</p>
          )}
        </div>
      </>
    );
  }

  function BandsPanel() {
    const selected = lists.bands ?? [];
    const selectedSet = new Set(selected.map((b) => (b ?? '').toLowerCase()));
    const available = BAND_CATALOG.filter((b) => !selectedSet.has(b.toLowerCase()));
    return (
      <>
        <SectionHeader
          title={t('sec.bands')}
          hint={t('bnd.hint')}
        />
        <div className="grid grid-cols-[1fr_auto_1fr] gap-3 max-w-3xl">
          {/* Left: available catalog */}
          <div className="rounded-md border border-border overflow-hidden">
            <div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
              {t('bnd.available')}
            </div>
            <div className="max-h-[320px] overflow-y-auto divide-y divide-border">
              {available.length === 0 ? (
                <div className="px-3 py-2 text-xs text-muted-foreground italic">{t('bnd.allSelected')}</div>
              ) : (
                available.map((b) => (
                  <button
                    key={b}
                    type="button"
                    onDoubleClick={() => addBand(b)}
                    onClick={() => addBand(b)}
                    className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors"
                  >
                    {b}
                  </button>
                ))
              )}
            </div>
            <div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
              <Input
                value={bandDraft}
                onChange={(e) => setBandDraft(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') {
                    addBand(bandDraft);
                    setBandDraft('');
                  }
                }}
                placeholder={t('bnd.customPh')}
                className="font-mono h-7 text-xs"
              />
              <Button
                size="sm"
                variant="outline"
                className="h-7"
                onClick={() => { addBand(bandDraft); setBandDraft(''); }}
                disabled={!bandDraft.trim()}
              >
                <Plus className="size-3" />
              </Button>
            </div>
          </div>

          {/* Center: shuttle hint */}
          <div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
            <ArrowRight className="size-4" />
            <ArrowLeft className="size-4" />
          </div>

          {/* Right: selected */}
          <div className="rounded-md border border-border overflow-hidden">
            <div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground flex items-center justify-between">
              <span>{t('bnd.selected', { n: selected.length })}</span>
            </div>
            <div className="max-h-[360px] overflow-y-auto divide-y divide-border">
              {selected.length === 0 ? (
                <div className="px-3 py-2 text-xs text-muted-foreground italic">
                  {t('bnd.none')}
                </div>
              ) : (
                selected.map((b, i) => (
                  <div key={`${b}-${i}`} className="grid grid-cols-[auto_1fr_auto] items-center gap-1 px-2 py-1">
                    <div className="flex gap-0.5">
                      <Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, -1)} disabled={i === 0}>
                        <ArrowUp className="size-3" />
                      </Button>
                      <Button size="icon" variant="ghost" className="size-6" onClick={() => moveBand(i, 1)} disabled={i === selected.length - 1}>
                        <ArrowDown className="size-3" />
                      </Button>
                    </div>
                    <span className="font-mono text-sm">{b}</span>
                    <Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeBand(i)}>
                      <Trash2 className="size-3" />
                    </Button>
                  </div>
                ))
              )}
            </div>
          </div>
        </div>
      </>
    );
  }


  function ModesPanel() {
    const selected = lists.modes ?? [];
    const selectedSet = new Set(selected.map((m) => (m.name ?? '').toUpperCase()));
    const available = MODE_CATALOG.filter((m) => !selectedSet.has(m.name));
    return (
      <>
        <SectionHeader
          title={t('sec.modes')}
          hint={t('mds.hint')}
        />
        <div className="grid grid-cols-[1fr_auto_1.5fr] gap-3 max-w-4xl">
          {/* Left: available catalog */}
          <div className="rounded-md border border-border overflow-hidden">
            <div className="px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
              {t('bnd.available')}
            </div>
            <div className="max-h-[360px] overflow-y-auto divide-y divide-border">
              {available.length === 0 ? (
                <div className="px-3 py-2 text-xs text-muted-foreground italic">{t('mds.allSelected')}</div>
              ) : (
                available.map((m) => (
                  <button
                    key={m.name}
                    type="button"
                    onDoubleClick={() => addModeFromCatalog(m)}
                    onClick={() => addModeFromCatalog(m)}
                    className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-accent/40 transition-colors flex items-center justify-between gap-2"
                    title={t('mds.defaultRstTitle', { sent: m.sent, rcvd: m.rcvd })}
                  >
                    <span>{m.name}</span>
                    <span className="text-[10px] text-muted-foreground">{m.sent}/{m.rcvd}</span>
                  </button>
                ))
              )}
            </div>
            <div className="flex gap-1 px-2 py-1.5 border-t border-border bg-muted/40">
              <Input
                value={modeDraft}
                onChange={(e) => setModeDraft(e.target.value)}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') {
                    addCustomMode(modeDraft);
                    setModeDraft('');
                  }
                }}
                placeholder={t('mds.customPh')}
                className="font-mono uppercase h-7 text-xs"
              />
              <Button
                size="sm"
                variant="outline"
                className="h-7"
                onClick={() => { addCustomMode(modeDraft); setModeDraft(''); }}
                disabled={!modeDraft.trim()}
              >
                <Plus className="size-3" />
              </Button>
            </div>
          </div>

          {/* Center: shuttle hint */}
          <div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
            <ArrowRight className="size-4" />
            <ArrowLeft className="size-4" />
          </div>

          {/* Right: selected with editable RST */}
          <div className="rounded-md border border-border overflow-hidden">
            <div className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1.5 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
              <span className="w-12">{t('mds.order')}</span>
              <span>{t('mds.mode')}</span>
              <span>{t('mds.rstSnt')}</span>
              <span>{t('mds.rstRcv')}</span>
              <span className="w-6"></span>
            </div>
            <div className="max-h-[360px] overflow-y-auto divide-y divide-border">
              {selected.length === 0 ? (
                <div className="px-3 py-2 text-xs text-muted-foreground italic">
                  {t('mds.none')}
                </div>
              ) : (
                selected.map((m, i) => (
                  <div key={i} className="grid grid-cols-[auto_1fr_72px_72px_auto] gap-2 px-3 py-1 items-center">
                    <div className="flex gap-0.5 w-12">
                      <Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
                        <ArrowUp className="size-3" />
                      </Button>
                      <Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === selected.length - 1}>
                        <ArrowDown className="size-3" />
                      </Button>
                    </div>
                    <Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
                    <Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
                    <Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
                    <Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
                      <Trash2 className="size-3" />
                    </Button>
                  </div>
                ))
              )}
            </div>
            <div className="px-3 py-1.5 border-t border-border bg-muted/40">
              <Button variant="ghost" size="sm" onClick={addMode} className="h-6 text-xs">
                <Plus className="size-3" /> {t('mds.addBlank')}
              </Button>
            </div>
          </div>
        </div>

        {/* RST report lists — the dropdown choices in the entry form. */}
        <div className="mt-6 max-w-4xl">
          <div className="text-sm font-semibold mb-1">{t('mds.rstLists')}</div>
          <div className="text-[11px] text-muted-foreground mb-2">
            {t('mds.rstListsHint')}
          </div>
          <div className="grid grid-cols-3 gap-3">
            <div className="space-y-1">
              <Label className="text-xs">{t('mds.phone')}</Label>
              <Textarea rows={8} className="font-mono text-xs" value={rstText.phone} onChange={(e) => setRstText((s) => ({ ...s, phone: e.target.value }))} />
            </div>
            <div className="space-y-1">
              <Label className="text-xs">{t('mds.cw')}</Label>
              <Textarea rows={8} className="font-mono text-xs" value={rstText.cw} onChange={(e) => setRstText((s) => ({ ...s, cw: e.target.value }))} />
            </div>
            <div className="space-y-1">
              <Label className="text-xs">{t('mds.digital')}</Label>
              <Textarea rows={8} className="font-mono text-xs" value={rstText.digital} onChange={(e) => setRstText((s) => ({ ...s, digital: e.target.value }))} />
            </div>
          </div>
        </div>
      </>
    );
  }

  function CATPanel() {
    return (
      <>
        <SectionHeader
          title={t('sec.cat')}
          hint={t('cat.hint')}
        />
        <div className="space-y-4 max-w-3xl">
          {/* RADIOS. Everything below edits the one selected here, and the status
              bar's CAT chip switches between them without moving the rest of the
              station — which is what making a profile per radio used to do. */}
          <div className="rounded-lg border border-border p-3 space-y-2">
            <div className="flex items-center gap-2">
              <Label className="shrink-0">{t('cat.radio')}</Label>
              <Select value={activeRadio || undefined} onValueChange={(v) => {
                if (v === activeRadio || radioBusy) return;
                // Saving first: the panel may hold edits to the radio being left
                // behind, and switching without them would throw them away.
                setRadioBusy(true);
                SaveCATSettings(catCfg as any)
                  .then(() => SetActiveRadio(v))
                  .then(async () => {
                    setActiveRadioId(v);
                    setCatCfg(await GetCATSettings() as any);
                    setRadios(((await GetRadios()) ?? []) as any[]);
                  })
                  .catch((e: any) => setErr(String(e?.message ?? e)))
                  .finally(() => setRadioBusy(false));
              }}>
                <SelectTrigger className="h-9 flex-1"><SelectValue placeholder={t('cat.radio')} /></SelectTrigger>
                <SelectContent>
                  {radios.map((r: any, i: number) => (
                    <SelectItem key={r.id} value={r.id}>{r.name?.trim() || r.label || `Radio ${i + 1}`}</SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <Input className="h-9 w-44" placeholder={t('cat.radioNamePh')}
                value={(radios.find((r: any) => r.id === activeRadio)?.name) ?? ''}
                onChange={(e) => setRadios((l) => l.map((r: any) => r.id === activeRadio ? { ...r, name: e.target.value } : r))}
                onBlur={() => { SaveRadios(radios as any).catch(() => {}); }} />
              <Button type="button" variant="outline" size="sm" className="h-9 shrink-0" disabled={radioBusy}
                title={t('cat.radioAddHint')}
                onClick={() => {
                  // A new radio starts from the CURRENT one rather than from
                  // nothing: a second rig is usually the same shape as the first
                  // with one port changed, and an empty form is a form to fill in
                  // twice.
                  const next = [...radios, { id: '', name: '', backend: catCfg.backend, settings: catCfg }];
                  setRadioBusy(true);
                  SaveRadios(next as any)
                    .then(() => GetRadios())
                    .then((rl: any) => setRadios(rl ?? []))
                    .catch((e: any) => setErr(String(e?.message ?? e)))
                    .finally(() => setRadioBusy(false));
                }}>+</Button>
              <Button type="button" variant="ghost" size="sm" className="h-9 shrink-0 text-danger"
                disabled={radioBusy || radios.length < 2}
                title={t('cat.radioRemoveHint')}
                onClick={() => {
                  const next = radios.filter((r: any) => r.id !== activeRadio);
                  setRadioBusy(true);
                  SaveRadios(next as any)
                    .then(() => SetActiveRadio(next[0].id))
                    .then(async () => {
                      setActiveRadioId(next[0].id);
                      setCatCfg(await GetCATSettings() as any);
                      setRadios(((await GetRadios()) ?? []) as any[]);
                    })
                    .catch((e: any) => setErr(String(e?.message ?? e)))
                    .finally(() => setRadioBusy(false));
                }}>
                <Trash2 className="size-3.5" />
              </Button>
            </div>
            {/* MY_RIG follows the radio, not the profile.
                Operating conditions describe a PLAN — "on 20 m I use the beam
                and the 7300" — while this is the fact of which rig is keying.
                Empty leaves the old chain alone. */}
            <div className="flex items-center gap-2">
              <Label className="shrink-0 w-24">{t('cat.radioMyRig')}</Label>
              <Input className="h-9 flex-1" placeholder={t('cat.radioMyRigPh')}
                value={(radios.find((r: any) => r.id === activeRadio)?.my_rig) ?? ''}
                onChange={(e) => setRadios((l) => l.map((r: any) => r.id === activeRadio ? { ...r, my_rig: e.target.value } : r))}
                onBlur={() => { SaveRadios(radios as any).catch(() => {}); }} />
            </div>
            <p className="text-[11px] text-muted-foreground">{t('cat.radioHint')}</p>
            <p className="text-[11px] text-muted-foreground">{t('cat.radioMyRigHint')}</p>
          </div>

          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
            {t('cat.enable')}
          </label>

          {/* BRAND, then CONNECTION.
              The backend list mixed the two: "Icom (USB)" and "Icom (network)"
              were separate entries, while Kenwood and Elecraft hid the same
              choice in a field further down. An operator picks a radio, then
              says how it is plugged in — so that is what the panel asks, in
              that order, and the two answers together choose the backend. */}
          <div className="grid grid-cols-2 gap-3 items-start">
            <div className="space-y-1">
              <Label>{t('cat.brand')}</Label>
              <Select value={catBrand} onValueChange={(v) => applyCatBrand(v)}>
                <SelectTrigger><SelectValue /></SelectTrigger>
                <SelectContent>
                  {CAT_BRANDS.map((b) => <SelectItem key={b.id} value={b.id}>{b.label}</SelectItem>)}
                </SelectContent>
              </Select>
            </div>
            {/* Only where there IS a choice. A dropdown showing one entry that
                cannot be changed is a control pretending to be a decision — a
                Yaesu is reached over USB and that is the end of it. */}
            {(CAT_BRANDS.find((b) => b.id === catBrand)?.links ?? []).length > 1 ? (
              <div className="space-y-1">
                <Label>{t('cat.kwLink')}</Label>
                <Select value={catLink} onValueChange={applyCatLink}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    {(CAT_BRANDS.find((b) => b.id === catBrand)?.links ?? []).map((l) => (
                      <SelectItem key={l} value={l}>
                        {l === 'usb' ? t('cat.kwLinkUsb') : l === 'bridge' ? t('cat.kwLinkBridge') : t('cat.kwLinkNative')}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </div>
            ) : <div />}
            {catCfg.backend === 'omnirig' && (
            <div className="space-y-1">
              <Label>{t('cat.omnirigRig')}</Label>
              <Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
                <SelectTrigger><SelectValue /></SelectTrigger>
                <SelectContent>
                  <SelectItem value="1">Rig 1</SelectItem>
                  <SelectItem value="2">Rig 2</SelectItem>
                </SelectContent>
              </Select>
            </div>
            )}
            {catCfg.backend === 'omnirig' && (
            <div className="space-y-1">
              <Label>{t('cat.omnirigVfo')}</Label>
              <Select value={catCfg.omnirig_vfo || 'auto'}
                onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_vfo: v === 'auto' ? '' : v }))}>
                <SelectTrigger><SelectValue /></SelectTrigger>
                <SelectContent>
                  <SelectItem value="auto">{t('cat.omnirigVfoAuto')}</SelectItem>
                  <SelectItem value="A">{t('cat.omnirigVfoA')}</SelectItem>
                  <SelectItem value="B">{t('cat.omnirigVfoB')}</SelectItem>
                </SelectContent>
              </Select>
              <p className="text-[10px] text-muted-foreground">{t('cat.omnirigVfoHint')}</p>
            </div>
            )}
            {catCfg.backend === 'flex' && (
            <>
              <div className="space-y-1">
                <Label>{t('cat.flexIp')}</Label>
                <Input placeholder="192.168.1.50" value={catCfg.flex_host ?? ''}
                  onChange={(e) => setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} />
              </div>
              <div className="space-y-1">
                <Label>{t('cat.port')}</Label>
                <PortInput value={catCfg.flex_port || 4992}
                  onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
              </div>
              <div className="col-span-2">
                <FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
              </div>
              {/* What OpsLog DOES with a Flex — panadapter spots, decode spots,
                  the DAX switch for voice messages — moved to Settings →
                  FlexRadio, with the per-band antennas and power. What stays
                  here is the link itself: an address and a port, which is what
                  this page is about for every other backend too. */}
            </>
            )}
            {catCfg.backend === 'xiegu' && (
            <>
              <div className="space-y-1">
                <Label>{t('cat.xieguPort')}</Label>
                <div className="flex gap-2">
                  <Select value={catCfg.xiegu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_port: v }))}>
                    <SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
                    <SelectContent>
                      {wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
                      {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                    </SelectContent>
                  </Select>
                  <Button type="button" variant="outline" size="sm"
                    onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
                </div>
              </div>
              <div className="space-y-1">
                <Label>{t('cat.baud')}</Label>
                <Select value={String(catCfg.xiegu_baud || 19200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_baud: parseInt(v) || 19200 }))}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    {[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
                  </SelectContent>
                </Select>
                <span className="text-xs text-muted-foreground">{t('cat.xieguBaudHint')}</span>
              </div>
              <div className="space-y-1">
                <Label>{t('cat.civAddr')}</Label>
                <Input value={'0x' + (catCfg.xiegu_addr || 0x70).toString(16).toUpperCase().padStart(2, '0')}
                  onChange={(e) => {
                    const n = parseInt(e.target.value.replace(/^0x/i, ''), 16);
                    if (!isNaN(n) && n > 0 && n <= 0xFF) setCatCfg((s) => ({ ...s, xiegu_addr: n }));
                  }} />
                <span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
              </div>
              <div className="space-y-1">
                <Label>{t('cat.xieguPTTLine')}</Label>
                <Select value={catCfg.xiegu_ptt_line || 'civ'}
                  onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_ptt_line: v === 'civ' ? '' : v }))}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="civ">{t('cat.xieguPTTCiv')}</SelectItem>
                    <SelectItem value="rts">RTS</SelectItem>
                    <SelectItem value="dtr">DTR</SelectItem>
                  </SelectContent>
                </Select>
                <span className="text-xs text-muted-foreground">{t('cat.xieguPTTHint')}</span>
              </div>
            </>
            )}
            {catCfg.backend === 'yaesu' && (
            <>
              <div className="space-y-1">
                <Label>{t('cat.yaesuPort')}</Label>
                <div className="flex gap-2">
                  <Select value={catCfg.yaesu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_port: v }))}>
                    <SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
                    <SelectContent>
                      {wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
                      {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                    </SelectContent>
                  </Select>
                  <Button type="button" variant="outline" size="sm"
                    onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
                </div>
                <span className="text-xs text-muted-foreground">{t('cat.yaesuPortHint')}</span>
              </div>
              <div className="space-y-1">
                <Label>{t('cat.baud')}</Label>
                <Select value={String(catCfg.yaesu_baud || 38400)} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_baud: parseInt(v) || 38400 }))}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    {[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
                  </SelectContent>
                </Select>
                <span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span>
              </div>
              <div className="col-span-2 space-y-1">
                <label className="flex items-center gap-2 text-xs cursor-pointer">
                  <Checkbox checked={!!catCfg.yaesu_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_low_lines: !!c }))} />
                  {t('cat.lowerLines')}
                </label>
                <span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
              </div>
              {/* ADIF records "RTTY" and nothing more, and the rig has two
                  sidebands to put it on — so this is the operator's answer, not
                  something the log can supply. */}
              <div className="col-span-2 space-y-1">
                <label className="flex items-center gap-2 text-xs cursor-pointer">
                  <Checkbox checked={!!(catCfg as any).yaesu_rtty_usb}
                    onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_rtty_usb: !!c } as any))} />
                  {t('cat.yaesuRttyUsb')}
                </label>
                <span className="text-xs text-muted-foreground">{t('cat.yaesuRttyUsbHint')}</span>
              </div>
            </>
            )}
            {['icom', 'xiegu', 'kenwood', 'elecraft'].includes(catCfg.backend) && (
              <div className="col-span-2 border-t border-border/60 pt-3">
                <label className="flex items-center gap-2 text-sm cursor-pointer">
                  <Checkbox checked={civTrace} onCheckedChange={(c) => { setCivTrace(!!c); SetCIVTrace(!!c); }} />
                  {t('cat.civTrace')}
                </label>              </div>
            )}
            {(catCfg.backend === 'kenwood' || catCfg.backend === 'elecraft') && (
            <>
              {/* USB or network, as a choice.
                  The two were offered side by side with nothing to say which one
                  the backend would use — it prefers the network address whenever
                  the field is not empty, which is invisible from here. */}
              {(((catCfg as any).kenwood_link || 'usb') === 'usb') && (<>
              <div className="space-y-1">
                <Label>{t('cat.kenwoodPort')}</Label>
                <div className="flex gap-2">
                  <Select value={catCfg.kenwood_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_port: v }))}>
                    <SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
                    <SelectContent>
                      {wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
                      {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                    </SelectContent>
                  </Select>
                  <Button type="button" variant="outline" size="sm"
                    onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
                </div>              </div>
              <div className="space-y-1">
                <Label>{t('cat.baud')}</Label>
                <Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    {[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
                  </SelectContent>
                </Select>              </div>
              </>)}
              {((catCfg as any).kenwood_link === 'bridge') && (
              <div className="space-y-1">
                <Label>{t('cat.kenwoodHost')}</Label>
                <Input
                  value={catCfg.kenwood_host || ''}
                  placeholder="192.168.1.50:4999"
                  onChange={(e) => setCatCfg((s) => ({ ...s, kenwood_host: e.target.value }))}
                />
                <p className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</p>
              </div>
              )}
              <div className="col-span-2 space-y-1">
                <label className="flex items-center gap-2 text-xs cursor-pointer">
                  <Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
                  {t('cat.lowerLines')}
                </label>
                <span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
              </div>
              {catCfg.backend === 'kenwood' ? (
                <div className="space-y-1">
                  <Label>{t('cat.kwDataMode')}</Label>
                  <Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
                    <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      <SelectItem value="usb">{t('cat.kwDataUsb')}</SelectItem>
                      <SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
                      <SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
              ) : (
                <span className="text-xs text-muted-foreground">{t('cat.elecraftHint')}</span>
              )}
            </>
            )}
            {catCfg.backend === 'icom' && (
            <>
              <div className="space-y-1">
                <Label>{t('cat.icomPort')}</Label>
                <div className="flex gap-2">
                  <Select value={catCfg.icom_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_port: v }))}>
                    <SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
                    <SelectContent>
                      {wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
                      {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                    </SelectContent>
                  </Select>
                  <Button type="button" variant="outline" size="sm"
                    onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
                </div>
              </div>
              <div className="space-y-1">
                <Label>{t('cat.baud')}</Label>
                <Select value={String(catCfg.icom_baud || 115200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_baud: parseInt(v) || 115200 }))}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    {[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
                  </SelectContent>
                </Select>
              </div>
              <div className="space-y-1">
                <Label>{t('cat.icomModel')}</Label>
                <Select
                  value={icomCustom ? '_custom' : (ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom')}
                  onValueChange={(v) => {
                    const m = ICOM_MODELS.find((x) => x.name === v);
                    setIcomCustom(!m);
                    if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr }));
                  }}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    {ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
                    <SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>
              <div className="space-y-1">
                <Label>{t('cat.civAddr')}</Label>
                <Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
                  onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
              </div>
              <p className="col-span-2 text-xs text-muted-foreground">{t('cat.civHint')}</p>
            </>
            )}
            {catCfg.backend === 'icom-net' && (
            <>
              <div className="space-y-1">
                <Label>{t('cat.icomNetHost')}</Label>
                <Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
                  onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
              </div>
              <div className="space-y-1">
                <Label>{t('cat.icomModel')}</Label>
                <Select
                  value={icomCustom ? '_custom' : (ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom')}
                  onValueChange={(v) => {
                    const m = ICOM_MODELS.find((x) => x.name === v);
                    setIcomCustom(!m);
                    if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr }));
                  }}>
                  <SelectTrigger><SelectValue /></SelectTrigger>
                  <SelectContent>
                    {ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
                    <SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>
              <div className="space-y-1">
                <Label>{t('cat.civAddr')}</Label>
                <Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
                  onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
              </div>
              <div className="space-y-1">
                <Label>{t('cat.icomNetUser')}</Label>
                <Input value={catCfg.icom_net_user ?? ''}
                  onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_user: e.target.value }))} />
              </div>
              <div className="space-y-1">
                <Label>{t('cat.icomNetPass')}</Label>
                <Input type="password" value={catCfg.icom_net_pass ?? ''}
                  onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
              </div>
              <p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
              <label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
                <Checkbox checked={!!(catCfg as any).icom_net_audio}
                  onCheckedChange={(c) => setCatCfg((s) => ({ ...s, icom_net_audio: !!c } as any))} />
                <span>
                  {t('cat.icomNetAudio')}
                  <span className="block text-[11px] text-muted-foreground">{t('cat.icomNetAudioHint')}</span>
                </span>
              </label>
              {!!(catCfg as any).icom_net_audio && (
                <label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer pl-6">
                  {/* The speaker choice, right where the stream is enabled. Same
                      remembered preference as the console's speaker button —
                      applied immediately, no save needed. */}
                  <Checkbox checked={listenPref}
                    onCheckedChange={(c) => {
                      setListenPref(!!c);
                      (c ? AudioStartMonitor() : Promise.resolve(AudioStopMonitor())).catch(() => setListenPref(!c));
                    }} />
                  <span>
                    {t('cat.icomNetListen')}
                    <span className="block text-[11px] text-muted-foreground">{t('cat.icomNetListenHint')}</span>
                  </span>
                </label>
              )}
            </>
            )}
            {catCfg.backend === 'tci' && (
            <>
              <div className="space-y-1">
                <Label>{t('cat.tciHost')}</Label>
                <Input placeholder="127.0.0.1" value={catCfg.tci_host ?? ''}
                  onChange={(e) => setCatCfg((s) => ({ ...s, tci_host: e.target.value }))} />
              </div>
              <div className="space-y-1">
                <Label>{t('cat.port')}</Label>
                <PortInput value={catCfg.tci_port || 40001}
                  onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
              </div>
              <p className="col-span-2 text-xs text-muted-foreground">
                {t('cat.tciHint')}
              </p>
              <label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox checked={!!catCfg.tci_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, tci_spots: !!c }))} />
                {t('cat.tciSpots')} <span className="text-xs text-muted-foreground">{t('cat.tciSpotsHint')}</span>
              </label>
            </>
            )}
            {(catCfg.backend === 'omnirig' || catCfg.backend === 'icom' || catCfg.backend === 'icom-net') && (
            <>
            <div className="space-y-1">
              <Label>{t('cat.pollMs')}</Label>
              <Input
                type="number" min={50} max={2000} step={50}
                value={catCfg.poll_ms}
                onChange={(e) => setCatCfg((s) => ({ ...s, poll_ms: parseInt(e.target.value) || 250 }))}
              />
            </div>
            <div className="space-y-1">
              <Label>{t('cat.delayMs')}</Label>
              <Input
                type="number" min={0} max={500} step={10}
                value={catCfg.delay_ms}
                onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
              />
            </div>
            </>
            )}
            {/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a
                transverter hangs off the IF of whatever radio you own, and the
                offset is applied in the CAT manager, above every backend. Tucked
                under the OmniRig/Icom section it would have been invisible to the
                Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as
                much. */}
            <label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox className="mt-0.5" checked={!!catCfg.offset_on}
                onCheckedChange={(c) => setCatCfg((s) => ({ ...s, offset_on: !!c }))} />
              <span>{t('cat.offsetOn')} <span className="text-xs text-muted-foreground">{t('cat.offsetHint')}</span></span>
            </label>
            {catCfg.offset_on && (
              <div className="col-span-2 flex items-center gap-2 pl-6">
                <Label className="text-sm">{t('cat.offsetMhz')}</Label>
                <Input
                  type="number" step="0.000001" className="w-40"
                  value={(catCfg.offset_hz ?? 0) / 1e6}
                  onChange={(e) => setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))}
                />
                <span className="text-xs text-muted-foreground">{t('cat.offsetExample')}</span>
              </div>
            )}
            <div className="space-y-1 col-span-2">
              <Label>{t('cat.digitalDefault')}</Label>
              <Select
                value={catCfg.digital_default || 'FT8'}
                onValueChange={(v) => setCatCfg((s) => ({ ...s, digital_default: v }))}
              >
                <SelectTrigger><SelectValue /></SelectTrigger>
                <SelectContent>
                  {['FT8','FT4','RTTY','PSK31','MFSK','JS8','JT65','JT9','OLIVIA','DIGITALVOICE','DATA'].map(m => (
                    <SelectItem key={m} value={m}>{m}</SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
          </div>
          {/* CAT sharing. A native backend owns the rig's serial port, so without
              this WSJT-X and friends are locked out of the radio entirely. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox
                checked={!!catCfg.share_enabled}
                onCheckedChange={(c) => setCatCfg((s) => ({ ...s, share_enabled: !!c }))}
              />
              {t('cat.share')}
            </label>
            <p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
            {catCfg.share_enabled && (
              <div className="flex flex-wrap items-end gap-4">
                {/* One protocol or the other. They answer the same questions about
                    the same radio, and no program speaks both — so this is a
                    choice, not two switches. */}
                <div className="space-y-1">
                  <Label>{t('cat.shareProto')}</Label>
                  <Select
                    value={(catCfg as any).share_proto === 'tci' ? 'tci' : 'rigctl'}
                    onValueChange={(v) => setCatCfg((s) => ({ ...s, share_proto: v } as any))}
                  >
                    <SelectTrigger className="h-8 w-[240px]"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      <SelectItem value="rigctl">{t('cat.shareRigctl')}</SelectItem>
                      <SelectItem value="tci">{t('cat.shareTci')}</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
                <div className="space-y-1 max-w-[200px]">
                  <Label>{t('cat.sharePort')}</Label>
                  {(catCfg as any).share_proto === 'tci' ? (
                    <PortInput
                      value={(catCfg as any).share_tci_port || 40001}
                      fallback={40001}
                      onChange={(n) => setCatCfg((s) => ({ ...s, share_tci_port: n } as any))}
                    />
                  ) : (
                    <PortInput
                      value={catCfg.share_port || 4532}
                      fallback={4532}
                      onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
                    />
                  )}
                </div>
              </div>
            )}
            {catCfg.share_enabled && (catCfg as any).share_proto === 'tci' && catCfg.backend === 'tci' && (
              // Both ends TCI: ExpertSDR is almost certainly already holding
              // 40001 on this machine, and our server would fail to bind. Worth
              // saying here rather than leaving it in the log.
              <p className="text-[11px] text-warning">{t('cat.shareTciClash')}</p>
            )}
          </div>
          {/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
              Uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox
                checked={!!(catCfg as any).ptt_hotkey_enabled}
                onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_enabled: !!c } as any))}
              />
              {t('cat.pttKey')}
            </label>
            {(catCfg as any).ptt_hotkey_enabled && (
              <>
                <div className="flex items-center gap-2">
                  <button
                    type="button"
                    onKeyDown={(e) => {
                      if (!capturingPtt) return;
                      e.preventDefault();
                      if (e.code === 'Escape') { setCapturingPtt(false); return; }
                      setCatCfg((s) => ({ ...s, ptt_hotkey: e.code } as any));
                      setCapturingPtt(false);
                    }}
                    onClick={() => setCapturingPtt(true)}
                    className="min-w-[140px] rounded-md border border-border bg-muted/40 px-3 py-1.5 text-sm font-mono hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary"
                  >
                    {capturingPtt ? t('cat.pttKeyPress') : ((catCfg as any).ptt_hotkey || t('cat.pttKeyNone'))}
                  </button>
                  {(catCfg as any).ptt_hotkey && !capturingPtt && (
                    <button
                      type="button"
                      onClick={() => setCatCfg((s) => ({ ...s, ptt_hotkey: '' } as any))}
                      className="text-xs text-muted-foreground hover:text-foreground"
                    >
                      {t('cat.pttKeyClear')}
                    </button>
                  )}
                </div>
                <label className="flex items-center gap-2 text-sm cursor-pointer">
                  <Checkbox
                    checked={!!(catCfg as any).ptt_hotkey_toggle}
                    onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))}
                  />
                  {t('cat.pttKeyToggle')}
                </label>              </>
            )}
          </div>
          <label className="flex items-center gap-2 text-sm cursor-pointer" title={t('cat.digiUsbHint')}>
            <Checkbox
              checked={!!catCfg.digi_as_usb}
              onCheckedChange={(c) => setCatCfg((s) => ({ ...s, digi_as_usb: !!c }))}
            />
            {t('cat.digiUsb')}
          </label>
          {catCfg.backend === 'omnirig' && (
          <>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox
              checked={catModeBeforeFreq}
              onCheckedChange={(c) => { const v = !!c; setCatModeBeforeFreq(v); writeUiPref('opslog.catModeBeforeFreq', v ? '1' : '0'); }}
            />
            {t('cat.modeBeforeFreq')} <span className="text-xs text-muted-foreground">{t('cat.modeBeforeFreqHint')}</span>
          </label>
          <p className="text-xs text-muted-foreground">
            {t('cat.omnirigHint')}
          </p>
          </>
          )}
        </div>
      </>
    );
  }

  async function testRotator(dev: RotatorDevice, sub = 1) {
    setRotatorTesting(true);
    setRotatorTest(null);
    try {
      await TestRotatorDevice(dev as any, sub);
      // Say what the test ACTUALLY did, which is not the same on every backend.
      //
      // PstRotator and a Rotator Genius are sent a move to 0°; a SPID, an ARCO
      // and a DCU-1 are asked their heading and nothing turns. They all used to
      // report "Packet sent — the antenna should swing to north. If it didn't,
      // check PstRotator's UDP listener" — which named a program that is not in
      // the path, for a move that was never commanded. An operator on a tower
      // read that as the test having failed.
      const type = (dev as any).type;
      const msg = type === 'rotgenius' ? t('rot.testOkRG')
        : (type === 'spid' || type === 'arco' || type === 'dcu1') ? t('rot.testOkRead')
          : t('cat.rotatorOk');
      setRotatorTest({ ok: true, msg });
    } catch (e: any) {
      setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
    } finally {
      setRotatorTesting(false);
    }
  }

  async function testUltrabeam() {
    setUbTesting(true);
    setUbTest(null);
    try {
      await TestUltrabeam(ultrabeam as any);
      setUbTest({ ok: true, msg: t('cat.ubOk') });
    } catch (e: any) {
      setUbTest({ ok: false, msg: String(e?.message ?? e) });
    } finally {
      setUbTesting(false);
    }
  }

  function UltrabeamPanel() {
    const isSteppir = ultrabeam.type === 'steppir';
    // Serial is no longer a SteppIR-only route: an Ultrabeam controller has an
    // RS232 port too, and a plain FTDI cable reaches it without the Ethernet
    // adapter the TCP route needs.
    const isSerial = ultrabeam.transport === 'serial';
    return (
      <>
        <SectionHeader
          title={t('hw.motorAntenna')}
        />
        <div className="space-y-4 max-w-xl">
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
            {t('hw.motorEnable')}
          </label>
          <div className="grid grid-cols-2 gap-3">
            <div className="space-y-1">
              <Label>{t('hw.motorType')}</Label>
              {/* Both antennas take either route now, so picking a type no longer
                  forces the transport. */}
              <Select value={ultrabeam.type ?? 'ultrabeam'}
                onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v }))}>
                <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                <SelectContent>
                  <SelectItem value="ultrabeam">Ultrabeam</SelectItem>
                  <SelectItem value="steppir">SteppIR</SelectItem>
                </SelectContent>
              </Select>
            </div>
            {(
              <div className="space-y-1">
                <Label>{t('hw.motorTransport')}</Label>
                <Select value={ultrabeam.transport ?? 'tcp'}
                  onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
                  <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
                    <SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>
            )}
          </div>
          {isSerial ? (
            <div className="grid grid-cols-3 gap-3">
              <div className="space-y-1 col-span-2">
                <Label>{t('hw.motorCom')}{!isSteppir && <span className="ml-1.5 font-normal text-muted-foreground">{t('hw.motorComUb')}</span>}</Label>
                {/* THE SAME Select every other serial device uses.
                    This was a Combobox — a list you could also type into — so
                    that the port of an adapter currently unplugged could still
                    be configured. In practice the list could not be picked from
                    at all, which is a worse failure than the one it was avoiding:
                    the port an operator wants is nearly always one that is
                    plugged in and detected. A configured port that has since
                    gone missing is added to the list so it stays selected and
                    visible rather than silently disappearing. */}
                <div className="flex items-center gap-1">
                  <Select value={ultrabeam.com || undefined}
                    onValueChange={(v) => setUltrabeam((s) => ({ ...s, com: v }))}>
                    <SelectTrigger className="h-9 font-mono flex-1"><SelectValue placeholder="COM3" /></SelectTrigger>
                    <SelectContent>
                      {wkPorts.length === 0 && !ultrabeam.com && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
                      {[...wkPorts, ...(ultrabeam.com && !wkPorts.includes(ultrabeam.com) ? [ultrabeam.com] : [])]
                        .map((pt) => <SelectItem key={pt} value={pt}>{pt}</SelectItem>)}
                    </SelectContent>
                  </Select>
                  <Button type="button" variant="outline" size="sm" className="shrink-0"
                    onClick={() => ListSerialPorts().then((ps) => setWkPorts((ps ?? []) as string[])).catch(() => {})}>↻</Button>
                </div>
              </div>
              <div className="space-y-1">
                <Label>{t('hw.motorBaud')}</Label>
                {/* The Ultrabeam answers on 19200 and on nothing else, so there is
                    no choice to offer — an FTDI cable opens at any speed, and a
                    wrong one is indistinguishable from a dead controller. */}
                {!isSteppir ? (
                  <Input className="h-9 font-mono" value="19200" readOnly disabled />
                ) : (
                  <Select value={String(ultrabeam.baud || 9600)} onValueChange={(s2) => setUltrabeam((s) => ({ ...s, baud: parseInt(s2, 10) || 9600 }))}>
                    <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      {[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
                    </SelectContent>
                  </Select>
                )}
              </div>
            </div>
          ) : (
            <div className="grid grid-cols-3 gap-3">
              <div className="space-y-1 col-span-2">
                <Label>Host / IP</Label>
                <Input
                  value={ultrabeam.host ?? ''}
                  onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
                  placeholder="192.168.1.50"
                  className="font-mono"
                />
              </div>
              <div className="space-y-1">
                <Label>TCP port</Label>
                <PortInput
                  value={ultrabeam.port}
                  onChange={(n) => setUltrabeam((s) => ({ ...s, port: n }))} fallback={23}
                  className="font-mono"
                />
              </div>
            </div>
          )}
          {isSteppir && (
            <p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
          )}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
              {t('hw.motorFollow')}
            </label>
            {ultrabeam.follow && (
              <div className="space-y-2 pl-6">
                <div className="flex items-center gap-3">
                  <Label className="text-sm">{t('station.trackModeTip')}</Label>
                  <Select value={ultrabeam.track_mode || 'step'} onValueChange={(v) => setUltrabeam((s) => ({ ...s, track_mode: v }))}>
                    <SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      <SelectItem value="always">{t('station.trackAlways')}</SelectItem>
                      <SelectItem value="step">{t('station.trackStep')}</SelectItem>
                      <SelectItem value="band">{t('station.trackBand')}</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
                {/* The step is only a question in step mode — the other two modes
                    have nothing to threshold. */}
                {(ultrabeam.track_mode || 'step') === 'step' && (
                  <div className="flex items-center gap-3">
                    <Label className="text-sm">{t('hw.motorStep')}</Label>
                    <Select value={String(ultrabeam.step_khz)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, step_khz: parseInt(v, 10) || 50 }))}>
                      <SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
                      <SelectContent>
                        <SelectItem value="25">25 kHz</SelectItem>
                        <SelectItem value="50">50 kHz</SelectItem>
                        <SelectItem value="100">100 kHz</SelectItem>
                      </SelectContent>
                    </Select>
                  </div>
                )}
                <p className="text-xs text-muted-foreground">
                  {(ultrabeam.track_mode || 'step') === 'always' ? t('station.trackAlwaysTip')
                    : (ultrabeam.track_mode || 'step') === 'band' ? t('station.trackBandTip')
                      : t('station.trackStepTipMode')}
                </p>
              </div>
            )}
          </div>
          {(isSteppir || ultrabeam.type === 'ultrabeam') && (
            <div className="border-t border-border/60 pt-3 space-y-2">
              <Label className="text-sm">{t('hw.motorBands')}</Label>
              {/* Band, and under it the frequency its button tunes to — the
                  layout of the SteppIR controller's own Bands and Frequencies
                  table, which is where operators expect to find this. The box
                  only appears on a selected band: a tune frequency for a band the
                  antenna is not allowed on is a setting with no effect. Left
                  empty it shows the default as placeholder, so the field is
                  self-documenting and clearing it is how you go back. */}
              <div className="flex items-start gap-1.5 flex-wrap">
                {MOTOR_BANDS.map((b) => {
                  const on = ultrabeam.bands.includes(b);
                  return (
                    <div key={b} className="flex flex-col gap-1">
                      <button type="button"
                        onClick={() => setUltrabeam((s) => ({
                          ...s,
                          bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
                        }))}
                        className={`h-8 w-[4.5rem] rounded-md border px-2 text-sm font-medium transition-colors ${
                          on
                            ? 'border-primary bg-primary/15 text-primary'
                            : 'border-input bg-background text-muted-foreground hover:bg-muted'
                        }`}>
                        {b}
                      </button>
                      {on && (
                        <input
                          type="text" inputMode="numeric"
                          value={ultrabeam.band_freqs?.[b] ? String(ultrabeam.band_freqs[b]) : ''}
                          placeholder={String(MOTOR_BAND_DEFAULT_KHZ[b] ?? '')}
                          title={t('hw.motorBandFreqHint')}
                          onChange={(e) => {
                            // Keep only digits, and store nothing for an empty box
                            // so it round-trips to "use the default" rather than
                            // to a zero the backend would have to interpret.
                            const digits = e.target.value.replace(/[^0-9]/g, '');
                            setUltrabeam((s) => {
                              const next = { ...(s.band_freqs || {}) };
                              if (digits === '') delete next[b]; else next[b] = parseInt(digits, 10);
                              return { ...s, band_freqs: next };
                            });
                          }}
                          className="h-7 w-[4.5rem] rounded-md border border-input bg-background px-1.5 text-center text-xs font-mono outline-none focus:border-primary"
                        />
                      )}
                    </div>
                  );
                })}
              </div>
              <p className="text-xs text-muted-foreground">{t('hw.motorBandFreqHint')}</p>
            </div>
          )}
          <div className="border-t border-border/60 pt-3 space-y-1">
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
              {t('hw.motorTxInhibit')}
            </label>
            <p className="text-xs text-muted-foreground pl-6">{t('hw.motorTxInhibitHint')}</p>
          </div>
          <div className="flex items-center gap-2 pt-2">
            <Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !(ultrabeam.com || '').trim() : !(ultrabeam.host || '').trim())}>
              {ubTesting ? t('hw.connecting') : t('hw.testConn')}
            </Button>
          </div>
          {ubTest && (
            <div className={cn(
              'text-xs rounded-md p-2.5 border',
              ubTest.ok
                ? 'bg-success-muted text-success-muted-foreground border-success-border'
                : 'bg-destructive/10 text-destructive border-destructive/30',
            )}>
              {ubTest.msg}
            </div>
          )}
        </div>
      </>
    );
  }

  function AntGeniusPanelSettings() {
    return (
      <>
        <SectionHeader
          title="Antenna Genius (4O3A)"
          hint={t('ag2.hint')}
        />
        <div className="space-y-4 max-w-xl">
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={antgenius.enabled} onCheckedChange={(c) => setAntgenius((s) => ({ ...s, enabled: !!c }))} />
            Enable Antenna Genius control
          </label>
          <div className="space-y-1">
            <Label>Host / IP</Label>
            <Input
              value={antgenius.host ?? ''}
              onChange={(e) => setAntgenius((s) => ({ ...s, host: e.target.value }))}
              placeholder="192.168.1.60"
              className="font-mono"
            />
          </div>
          <div className="space-y-1">
            <Label>{t('ag2.password')}</Label>
            <Input
              type="password"
              value={antgenius.password ?? ''}
              onChange={(e) => setAntgenius((s) => ({ ...s, password: e.target.value }))}
              placeholder={t('ag2.passwordPh')}
              className="font-mono"
            />
            <p className="text-xs text-muted-foreground">{t('ag2.passwordHint')}</p>
          </div>

          {/* MY_ANTENNA from the switch.
              The working conditions hold what was PLANNED for the band; the
              switch knows what is connected. Off by default: a station that
              names its antennas differently in the two places would otherwise
              find its log quietly rewritten. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox className="mt-0.5" checked={!!antgenius.use_for_my_antenna}
                onCheckedChange={(c) => setAntgenius((s: any) => ({ ...s, use_for_my_antenna: !!c }))} />
              <span>
                {t('ag2.useForMyAnt')}
                <span className="block text-xs text-muted-foreground">{t('ag2.useForMyAntHint')}</span>
              </span>
            </label>
            {!!antgenius.use_for_my_antenna && (
              <div className="pl-6 space-y-1">
                <Label>{t('ag2.ant1Port')}</Label>
                <div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
                  {[1, 2].map((n) => (
                    <button key={n} type="button"
                      onClick={() => setAntgenius((s: any) => ({ ...s, ant1_port: n }))}
                      className={cn('px-3 py-1.5 font-medium',
                        (antgenius.ant1_port ?? 1) === n ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
                      {n === 1 ? t('ag2.portA') : t('ag2.portB')}
                    </button>
                  ))}
                </div>
                <p className="text-xs text-muted-foreground">{t('ag2.ant1PortHint')}</p>
              </div>
            )}
          </div>
        </div>
      </>
    );
  }

  function TunerGeniusPanelSettings() {
    return (
      <>
        <SectionHeader
          title="Tuner Genius XL (4O3A)"
          hint={t('tg2.hint')}
        />
        <div className="space-y-4 max-w-xl">
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={tunergenius.enabled} onCheckedChange={(c) => setTunergenius((s) => ({ ...s, enabled: !!c }))} />
            {t('tg2.enable')}
          </label>
          <div className="space-y-1">
            <Label>Host / IP</Label>
            <Input
              value={tunergenius.host ?? ''}
              onChange={(e) => setTunergenius((s) => ({ ...s, host: e.target.value }))}
              placeholder="192.168.1.61"
              className="font-mono"
            />
            <p className="text-xs text-muted-foreground">{t('tg2.portHint')}</p>
          </div>
          <div className="space-y-1">
            <Label>{t('tg2.password')}</Label>
            <Input
              type="password"
              value={tunergenius.password ?? ''}
              onChange={(e) => setTunergenius((s) => ({ ...s, password: e.target.value }))}
              placeholder={t('tg2.passwordPh')}
              className="font-mono"
            />
            <p className="text-xs text-muted-foreground">{t('tg2.passwordHint')}</p>
          </div>
        </div>
      </>
    );
  }

  // Bench power supply over Modbus RTU. OpsLog reads everything and writes one
  // register — the output on/off. The voltage and current SET points are shown
  // because they are worth seeing, and are not editable here: they belong to the
  // supply's front panel, and a logbook that can set them can set them wrong.
  // The COM ports come from the list SettingsModal already loads for the
  // Winkeyer panel — one machine, one set of serial ports, fetched once. A hook
  // of its own would be legal now (see PanelHost) and would fetch them twice.
  function PSUPanelSettings() {
    const ports = wkPorts;
    const setPorts = setWkPorts;
    return (
      <>
        <SectionHeader title={t('psu.title')} hint={t('psu.hint')} />
        <div className="space-y-4 max-w-xl">
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={psuCfg.enabled} onCheckedChange={(c) => setPsuCfg((s) => ({ ...s, enabled: !!c }))} />
            {t('psu.enable')}
          </label>
          <div className="grid grid-cols-3 gap-3">
            <div className="space-y-1">
              <Label>{t('psu.port')}</Label>
              <div className="flex items-center gap-2">
                <Select value={psuCfg.com_port || '_'} onValueChange={(v) => setPsuCfg((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
                  <SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
                  <SelectContent>
                    {ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
                    {ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                  </SelectContent>
                </Select>
                <Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
                  ↻
                </Button>
              </div>
            </div>
            <div className="space-y-1">
              <Label>{t('psu.baud')}</Label>
              <Input className="font-mono" value={String(psuCfg.baud ?? 9600)}
                onChange={(e) => setPsuCfg((s) => ({ ...s, baud: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
            </div>
            <div className="space-y-1">
              <Label>{t('psu.address')}</Label>
              <Input className="font-mono" value={String(psuCfg.address ?? 1)}
                onChange={(e) => setPsuCfg((s) => ({ ...s, address: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
            </div>
          </div>
          <p className="text-xs text-muted-foreground">{t('psu.wireHint')}</p>
          <p className="text-xs text-warning">{t('psu.writeScope')}</p>
        </div>
      </>
    );
  }

  // Satellites: where the antenna is, and the machine that points it.
  //
  // The observer belongs here rather than in the tab because it is a property
  // of the station, and the rotator because it is a second machine on a second
  // port — a station with an HF rotator and an az/el pair must be able to have
  // both, and choosing between them in one panel would be the wrong question.
  function SatellitePanelSettings() {
    const ports = wkPorts;
    const setPorts = setWkPorts;
    const set = (k: string, v: any) => setSatCfg((s: any) => ({ ...s, [k]: v }));
    const num = (v: string) => parseInt(v.replace(/[^0-9-]/g, ''), 10) || 0;
    return (
      <>
        <SectionHeader title={t('sec.satellite')} hint={t('satset.hint')} />
        <div className="space-y-5 max-w-xl mb-5">
          <SatelliteElementsBlock autoTle={!!satCfg.auto_tle} onAutoTle={(v) => set('auto_tle', v)} />
        </div>
        <div className="max-w-3xl mb-5">
          <SatelliteFollowList
            followed={satCfg.favorites ?? []}
            onChange={(next) => set('favorites', next)}
          />
        </div>
        <div className="space-y-5 max-w-xl">
          <div className="grid grid-cols-3 gap-3">
            <div className="space-y-1">
              <Label>{t('satset.altM')}</Label>
              <Input className="font-mono" value={String(satCfg.alt_m ?? 0)}
                onChange={(e) => set('alt_m', num(e.target.value))} />
            </div>
            <div className="space-y-1">
              <Label>{t('satset.minEl')}</Label>
              <Input className="font-mono" value={String(satCfg.min_el ?? 10)}
                onChange={(e) => set('min_el', num(e.target.value))} />
            </div>
            <div className="space-y-1">
              <Label>{t('satset.windowH')}</Label>
              <Input className="font-mono" value={String(satCfg.window_h ?? 24)}
                onChange={(e) => set('window_h', num(e.target.value))} />
            </div>
          </div>
          <p className="text-xs text-muted-foreground">{t('satset.altHint')}</p>

          {/* The locator is NOT repeated here: it is the station's, set once in
              Station information, and the passes are predicted from it. This is
              the exception — an antenna at another site — and it says so, so
              nobody has to wonder which of two locators is in use. */}
          <details className="text-sm">
            <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
              {t('satset.otherSite')}
            </summary>
            <div className="mt-2 flex items-center gap-3">
              <Input className="font-mono w-40" placeholder={t('satset.gridPlaceholder')}
                value={satCfg.grid ?? ''} onChange={(e) => set('grid', e.target.value.toUpperCase())} />
              <span className="text-xs text-muted-foreground">{t('satset.otherSiteHint')}</span>
            </div>
          </details>

          <div className="border-t border-border/60 pt-4 space-y-3">
            <h4 className="text-sm font-semibold text-foreground">{t('satset.rotor')}</h4>
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox checked={!!satCfg.rot_on} onCheckedChange={(c) => set('rot_on', !!c)} />
              {t('satset.rotEnable')}
            </label>
            <p className="text-xs text-muted-foreground">{t('satset.rotHint')}</p>

            {!!satCfg.rot_on && (
              <>
                {/* Who drives the mast. Not a detail: a station already running
                    PstRotator must NOT have OpsLog on the same cable as well. */}
                <div className="grid grid-cols-4 gap-3">
                  {/* Two columns wide: "OpsLog (EasyComm II)" does not fit in a
                      third of the row, and a truncated choice is a choice an
                      operator cannot read. */}
                  <div className="space-y-1 col-span-2">
                    <Label>{t('satset.rotType')}</Label>
                    <Select value={satCfg.rot_type || 'easycomm'} onValueChange={(v) => set('rot_type', v)}>
                      <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                      <SelectContent>
                        <SelectItem value="easycomm">{t('satset.rotEasycomm')}</SelectItem>
                        <SelectItem value="pstrotator">{t('satset.rotPst')}</SelectItem>
                      </SelectContent>
                    </Select>
                  </div>
                  {satCfg.rot_type === 'pstrotator' && (
                    <>
                      <div className="space-y-1">
                        <Label>{t('satset.rotHost')}</Label>
                        <Input className="font-mono" placeholder="127.0.0.1"
                          value={satCfg.rot_host ?? ''} onChange={(e) => set('rot_host', e.target.value)} />
                      </div>
                      <div className="space-y-1">
                        <Label>{t('satset.rotPstPort')}</Label>
                        <Input className="font-mono" value={String(satCfg.rot_pst_port ?? 12000)}
                          onChange={(e) => set('rot_pst_port', num(e.target.value))} />
                      </div>
                    </>
                  )}
                </div>
                {satCfg.rot_type === 'pstrotator' && (
                  <p className="text-xs text-muted-foreground">{t('satset.rotPstHint')}</p>
                )}
                <div className={cn('grid grid-cols-3 gap-3', satCfg.rot_type === 'pstrotator' && 'hidden')}>
                  <div className="space-y-1">
                    <Label>{t('satset.rotLink')}</Label>
                    <Select value={satCfg.rot_transport || 'serial'} onValueChange={(v) => set('rot_transport', v)}>
                      <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                      <SelectContent>
                        <SelectItem value="serial">{t('satset.rotSerial')}</SelectItem>
                        <SelectItem value="tcp">{t('satset.rotTcp')}</SelectItem>
                      </SelectContent>
                    </Select>
                  </div>
                  {satCfg.rot_transport === 'tcp' ? (
                    <>
                      <div className="space-y-1">
                        <Label>{t('satset.rotHost')}</Label>
                        <Input className="font-mono" placeholder="127.0.0.1"
                          value={satCfg.rot_host ?? ''} onChange={(e) => set('rot_host', e.target.value)} />
                      </div>
                      <div className="space-y-1">
                        <Label>{t('satset.rotPort')}</Label>
                        <Input className="font-mono" value={String(satCfg.rot_port ?? 4533)}
                          onChange={(e) => set('rot_port', num(e.target.value))} />
                      </div>
                    </>
                  ) : (
                    <>
                      <div className="space-y-1">
                        <Label>{t('satset.rotCom')}</Label>
                        <div className="flex items-center gap-2">
                          <Select value={satCfg.rot_com || '_'} onValueChange={(v) => set('rot_com', v === '_' ? '' : v)}>
                            <SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
                            <SelectContent>
                              {ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
                              {ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                            </SelectContent>
                          </Select>
                          <Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
                            ↻
                          </Button>
                        </div>
                      </div>
                      <div className="space-y-1">
                        <Label>{t('satset.rotBaud')}</Label>
                        <Select value={String(satCfg.rot_baud || 9600)} onValueChange={(v) => set('rot_baud', parseInt(v, 10) || 9600)}>
                          <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                          <SelectContent>
                            {[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
                              <SelectItem key={b} value={String(b)}>{b}</SelectItem>
                            ))}
                          </SelectContent>
                        </Select>
                      </div>
                    </>
                  )}
                </div>

                <div className="grid grid-cols-3 gap-3">
                  {/* The rotator's range is ours to know only when we drive the
                      controller. PstRotator knows which machine is on the other
                      end and does its own overlap; two programs each deciding to
                      go the long way round is how an antenna unwinds mid-pass. */}
                  {satCfg.rot_type !== 'pstrotator' && (
                    <div className="space-y-1">
                      <Label>{t('satset.rotRange')}</Label>
                      <Select value={String(satCfg.rot_max_az ?? 360)} onValueChange={(v) => set('rot_max_az', parseInt(v, 10))}>
                        <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          <SelectItem value="360">360°</SelectItem>
                          <SelectItem value="450">450°</SelectItem>
                        </SelectContent>
                      </Select>
                    </div>
                  )}
                  <div className="space-y-1">
                    <Label>{t('satset.rotMinEl')}</Label>
                    <Input className="font-mono" value={String(satCfg.rot_min_el ?? 0)}
                      onChange={(e) => set('rot_min_el', num(e.target.value))} />
                  </div>
                  <div className="space-y-1">
                    <Label>{t('satset.rotStep')}</Label>
                    <Input className="font-mono" value={String(satCfg.rot_step ?? 5)}
                      onChange={(e) => set('rot_step', num(e.target.value))} />
                  </div>
                </div>
                <p className="text-xs text-muted-foreground">{t('satset.rotRangeHint')}</p>

                <label className="flex items-center gap-2 text-sm cursor-pointer">
                  <Checkbox checked={!!satCfg.rot_park} onCheckedChange={(c) => set('rot_park', !!c)} />
                  {t('satset.rotPark')}
                </label>

                <div className="flex items-center gap-2">
                  <Button size="sm" variant="outline" onClick={async () => {
                    setSatTest(t('satset.rotTesting'));
                    try {
                      // Saved first: the test opens the port from the STORED
                      // settings, and testing what is on screen rather than what
                      // is stored is the classic way to prove a COM port that is
                      // not the one about to be used.
                      await SaveSatSettings(satCfg as any);
                      setSatTest(String(await TestSatelliteRotator()));
                    } catch (e: any) { setSatTest(String(e?.message ?? e)); }
                  }}>
                    {t('satset.rotTest')}
                  </Button>
                  {satTest && <span className="text-xs text-muted-foreground">{satTest}</span>}
                </div>
              </>
            )}
          </div>
        </div>
      </>
    );
  }

  function PGXLPanelSettings() {
    // The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
    // presents it as brand + model.
    const brandModels: Record<string, { value: string; label: string }[]> = {
      spe: [
        { value: 'spe13', label: 'Expert 1.3K-FA' },
        { value: 'spe15', label: 'Expert 1.5K-FA' },
        { value: 'spe2k', label: 'Expert 2K-FA' },
      ],
      acom: [
        { value: 'acom500', label: '500S' },
        { value: 'acom600', label: '600S' },
        { value: 'acom700', label: '700S' },
        { value: 'acom1200', label: '1200S' },
        { value: 'acom2020', label: '2020S' },
      ],
      kpa: [
        { value: 'kpa1500', label: 'KPA1500' },
        { value: 'kpa500', label: 'KPA500' },
      ],
    };
    const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl'
      : ty.startsWith('acom') ? 'acom'
      : ty.startsWith('kpa') ? 'kpa' : 'spe';
    const patchAmp = (i: number, patch: Partial<AmpUI>) => setAmps((l) => l.map((a, j) => (j === i ? { ...a, ...patch } : a)));
    // Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
    // 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
    // Each family has its own fixed serial speed and its own default port, so
    // switching model leaves a working configuration rather than one the
    // operator has to repair. A KPA500 has no network side at all — it is put
    // on serial here rather than being allowed to sit on a TCP setting that
    // could never connect.
    const applyType = (i: number, v: string) => patchAmp(i, {
      type: v,
      freq_out: v.startsWith('kpa') ? true : amps[i].freq_out,
      transport: v === 'pgxl' ? 'tcp' : v === 'kpa500' ? 'serial' : amps[i].transport,
      baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : v.startsWith('kpa') ? 38400 : amps[i].baud,
      port: v === 'kpa1500' ? 1500 : amps[i].port,
    });
    const addAmp = () => setAmps((l) => [...l, {
      id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
      freq_out: false, freq_com_port: '', freq_baud: 9600, freq_broadcast_ms: 0,
    }]);
    return (
      <>
        <SectionHeader title="Amplifier" hint={t('amp.hint')} />
        <div className="space-y-4 max-w-xl">
          {amps.length === 0 && (
            <p className="text-sm text-muted-foreground">{t('amp.none')}</p>
          )}
          {amps.map((amp, i) => {
            const brand = brandOf(amp.type);
            const isPGXL = brand === 'pgxl';
            const isACOM = brand === 'acom';
            const isKPA = brand === 'kpa';
            const isSerial = !isPGXL && amp.transport === 'serial';
            return (
              <div key={amp.id || `new-${i}`} className="rounded-lg border border-border p-3 space-y-3">
                <div className="flex items-center gap-2">
                  <Checkbox checked={amp.enabled} onCheckedChange={(c) => patchAmp(i, { enabled: !!c })} />
                  <Input className="h-8 flex-1" value={amp.name}
                    placeholder={t('amp.namePh')}
                    onChange={(e) => patchAmp(i, { name: e.target.value })} />
                  <Button variant="ghost" size="sm" className="h-8 text-danger" title={t('amp.remove')}
                    onClick={() => setAmps((l) => l.filter((_, j) => j !== i))}>
                    <Trash2 className="size-3.5" />
                  </Button>
                </div>

                {/* Connection gets the widest column — "Network (RS232-to-Ethernet)"
                    was truncated with 3 equal columns. */}
                <div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
                  <div className="space-y-1">
                    <Label>Brand</Label>
                    <Select value={brand} onValueChange={(b) => {
                      if (b === brand) return;
                      applyType(i, b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
                    }}>
                      <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                      <SelectContent>
                        <SelectItem value="pgxl">4O3A</SelectItem>
                        <SelectItem value="spe">SPE</SelectItem>
                        <SelectItem value="acom">Acom</SelectItem>
                        <SelectItem value="kpa">Elecraft</SelectItem>
                      </SelectContent>
                    </Select>
                  </div>
                  <div className="space-y-1">
                    <Label>Model</Label>
                    {isPGXL ? (
                      // The PowerGenius is the brand's single model — fixed, no choice.
                      <Select value="pgxl" disabled>
                        <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          <SelectItem value="pgxl">PowerGenius XL</SelectItem>
                        </SelectContent>
                      </Select>
                    ) : (
                      <Select value={amp.type} onValueChange={(v) => applyType(i, v)}>
                        <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          {brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
                        </SelectContent>
                      </Select>
                    )}
                  </div>
                  {/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial
                      or an RS232-to-Ethernet bridge, so they offer both. */}
                  {!isPGXL && (
                    <div className="space-y-1">
                      <Label>Connection</Label>
                      <Select value={amp.transport} onValueChange={(v) => patchAmp(i, { transport: v })}>
                        <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          <SelectItem value="serial">USB (serial COM)</SelectItem>
                          <SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
                        </SelectContent>
                      </Select>
                    </div>
                  )}
                </div>

                {isSerial ? (
                  <div className="grid grid-cols-3 gap-3">
                    <div className="space-y-1 col-span-2">
                      <Label>COM port</Label>
                      <div className="flex items-center gap-2">
                        <Select value={amp.com_port || '_'} onValueChange={(v) => patchAmp(i, { com_port: v === '_' ? '' : v })}>
                          <SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
                          <SelectContent>
                            {wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
                            {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                          </SelectContent>
                        </Select>
                        <Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
                          <ArrowDown className="size-3.5 rotate-90" />
                        </Button>
                      </div>
                    </div>
                    <div className="space-y-1">
                      <Label>Baud</Label>
                      {/* A list, not a free number: the KPA500 report that
                          began this had its operator wondering whether a typed
                          baud was the whole problem. These are the rates the
                          supported amplifiers actually speak. */}
                      <select value={String(amp.baud)}
                        onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) })}
                        className="h-9 w-full px-2 rounded-md border border-border bg-background text-sm font-mono">
                        {[4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
                          <option key={b} value={String(b)}>{b}</option>
                        ))}
                      </select>
                    </div>
                  </div>
                ) : (
                  <div className="grid grid-cols-3 gap-3">
                    <div className="space-y-1 col-span-2">
                      <Label>Host / IP</Label>
                      <Input value={amp.host ?? ''} onChange={(e) => patchAmp(i, { host: e.target.value })}
                        placeholder="192.168.1.70" className="font-mono" />
                    </div>
                    <div className="space-y-1">
                      <Label>TCP port</Label>
                      <PortInput value={amp.port}
                        onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
                    </div>
                  </div>
                )}
                {/* PowerGenius XL asks for a remote-access code when reached from
                    outside the LAN (banner "V… AUTH"), like the Tuner/Antenna Genius.
                    Blank on the LAN. */}
                {isPGXL && (
                  <div className="space-y-1 max-w-xs">
                    <Label>{t('amp.password')}</Label>
                    <Input type="password" value={(amp as any).password ?? ''}
                      onChange={(e) => patchAmp(i, { password: e.target.value } as any)}
                      placeholder={t('amp.passwordPh')} className="font-mono" />
                    <p className="text-[11px] text-muted-foreground">{t('amp.passwordHint')}</p>
                  </div>
                )}

                {/* Band-follow, for any amp that takes its band from a transceiver
                    CAT link (Acom and SPE both do) — never PowerGenius, which is
                    driven over its network protocol. A SECOND serial port,
                    separate from the metering one above.
                    Never a KPA either: it has a band command of its own (^BN),
                    so OpsLog tells it directly on the link it is already using.
                    Offering a second serial port and a transceiver emulator for
                    that would be a workaround for a problem this amplifier does
                    not have. */}
                {isKPA && (
                  <div className="border-t border-border/60 pt-3 space-y-1">
                    <label className="flex items-center gap-2 text-sm cursor-pointer">
                      <Checkbox
                        checked={!!amp.freq_out}
                        onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
                      />
                      {t('amp.kpaBandFollow')}
                    </label>
                    <p className="text-[11px] text-muted-foreground">{t('amp.kpaBandFollowHint')}</p>
                  </div>
                )}
                {!isPGXL && !isKPA && (
                  <div className="border-t border-border/60 pt-3 space-y-3">
                    <label className="flex items-center gap-2 text-sm cursor-pointer">
                      <Checkbox
                        checked={!!amp.freq_out}
                        onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
                      />
                      {t('amp.freqOut')}
                    </label>
                    {amp.freq_out && (
                      <>
                        <div className="grid grid-cols-3 gap-3">
                          <div className="space-y-1 col-span-2">
                            <Label>{t('amp.freqPort')}</Label>
                            <div className="flex items-center gap-2">
                              <Select value={amp.freq_com_port || '_'} onValueChange={(v) => patchAmp(i, { freq_com_port: v === '_' ? '' : v })}>
                                <SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
                                <SelectContent>
                                  {wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
                                  {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                                </SelectContent>
                              </Select>
                              <Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
                                <ArrowDown className="size-3.5 rotate-90" />
                              </Button>
                            </div>
                          </div>
                          <div className="space-y-1">
                            <Label>Baud</Label>
                            <Input type="number" min={1200} value={amp.freq_baud ?? 9600}
                              onChange={(e) => patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" />
                          </div>
                        </div>
                        <div className="grid grid-cols-3 gap-3">
                          <div className="space-y-1 col-span-2">
                            <Label>{t('amp.freqBroadcast')}</Label>
                            <Select value={String(amp.freq_broadcast_ms ?? 0)} onValueChange={(v) => patchAmp(i, { freq_broadcast_ms: parseInt(v) })}>
                              <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                              <SelectContent>
                                <SelectItem value="0">{t('amp.freqPollOnly')}</SelectItem>
                                <SelectItem value="500">{t('amp.freqEvery', { ms: 500 })}</SelectItem>
                                <SelectItem value="1000">{t('amp.freqEvery', { ms: 1000 })}</SelectItem>
                              </SelectContent>
                            </Select>
                          </div>
                        </div>
                        <p className="text-[10px] text-muted-foreground">{t('amp.freqHint')}</p>
                      </>
                    )}
                  </div>
                )}

                {amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
                {!isPGXL && !isACOM && (
                  <p className="text-[10px] text-muted-foreground">
                    SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
                  </p>
                )}
                {isACOM && (
                  <p className="text-[10px] text-muted-foreground">
                    ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
                  </p>
                )}
              </div>
            );
          })}
          <Button variant="outline" size="sm" onClick={addAmp}>
            <Plus className="size-3.5 mr-1" /> {t('amp.add')}
          </Button>

          {/* WHICH amplifiers are coupled, not whether coupling is on.
              A station can run a combiner pair AND a third amplifier that has
              nothing to do with it — two SPE on the combiner, a PowerGenius on
              another antenna — so a single switch would send that third one into
              OPERATE alongside them. Shown from two amplifiers up. */}
          {amps.length > 1 && (
            <div className="border-t border-border/60 pt-3 space-y-2">
              <div className="text-sm">{t('amp.linked')}</div>
              <p className="text-xs text-muted-foreground">{t('amp.linkedHint')}</p>
              <div className="flex flex-wrap gap-x-4 gap-y-1.5">
                {amps.filter((x) => (x.id ?? '') !== '').map((x) => {
                  const on = linkedAmps.includes(x.id!);
                  return (
                    <label key={x.id} className="flex items-center gap-1.5 text-sm cursor-pointer">
                      <Checkbox checked={on} onCheckedChange={(c) => {
                        const next = c ? [...linkedAmps, x.id!] : linkedAmps.filter((v) => v !== x.id);
                        setLinkedAmps(next);
                        SetLinkedAmps(next).catch(() => {});
                      }} />
                      {x.name?.trim() || x.type}
                    </label>
                  );
                })}
              </div>
              {/* An amplifier saved without an id cannot be a member — say so
                  rather than silently leaving it out of the list. */}
              {amps.some((x) => (x.id ?? '') === '') && (
                <p className="text-xs text-muted-foreground">{t('amp.linkedSaveFirst')}</p>
              )}
            </div>
          )}

        </div>
      </>
    );
  }

  function RotatorPanel() {
    // Rotors are a list, like amplifiers: add with +, remove with the trash icon.
    // A Rotator Genius with "two rotors" contributes 2 addressable rotors.
    const patch = (i: number, p: Partial<RotatorDevice>) => setRotors((l) => l.map((d, j) => (j === i ? { ...d, ...p } : d)) as any);
    const addRotor = () => setRotors((l) => [...l, {
      id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
      rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
      transport: 'tcp', com_port: '', baud: 9600,
    } as any]);
    const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
    const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
    // The motorized-antenna choice only matters once more than one rotor exists.
    const multi = rotors.length > 1 || rotors.some((d) => (d as any).type === 'rotgenius' && (d as any).dual);
    return (
      <>
        <SectionHeader title="Rotator" hint={anyPst ? t('rot.hint') : undefined} />
        <div className="space-y-4 max-w-xl">
          {rotors.length === 0 && <p className="text-sm text-muted-foreground">{t('rot.none')}</p>}
          {rotors.map((d, i) => {
            const dev = d as any;
            const isRG = dev.type === 'rotgenius';
            const isARCO = dev.type === 'arco';
            const isDCU1 = dev.type === 'dcu1';
            // A SPID has a COM port and nothing else — no network transport to
            // offer, which is the whole point of driving it without PstRotator.
            const isSPID = dev.type === 'spid';
            const isSerialCap = isARCO || isDCU1 || isSPID; // COM-port or serial-over-IP controllers
            const transport = dev.transport ?? 'tcp';
            return (
              <div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
                <div className="flex items-center gap-2">
                  <Compass className="size-4 text-primary shrink-0" />
                  <Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
                    onChange={(e) => patch(i, { name: e.target.value })} />
                  <Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
                    onClick={() => removeRotor(i)} title={t('rot.remove')}>
                    <Trash2 className="size-4" />
                  </Button>
                </div>
                <div className="grid grid-cols-2 gap-3">
                  <div className="space-y-1">
                    <Label>{t('rot.type')}</Label>
                    {/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
                        (placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
                    <Select value={dev.type ?? 'pst'}
                      onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : (v === 'arco' || v === 'dcu1') ? 4001 : 12000, ...(v === 'dcu1' || v === 'spid' ? { transport: 'serial' } : {}), ...(v === 'spid' ? { baud: 600, spid_model: 'rot2prog' } : {}) })}>
                      <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                      <SelectContent>
                        <SelectItem value="pst">PstRotator (UDP)</SelectItem>
                        <SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
                        <SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
                        <SelectItem value="dcu1">Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)</SelectItem>
                        <SelectItem value="spid">SPID / AlfaSpid (RAS, BIG-RAS, MD-01, MD-02)</SelectItem>
                      </SelectContent>
                    </Select>
                  </div>
                  {isRG && !dev.dual && (
                    <div className="space-y-1">
                      <Label>{t('rot.rotatorNum')}</Label>
                      <Select value={String(dev.rotator_num ?? 1)}
                        onValueChange={(v) => patch(i, { rotator_num: parseInt(v, 10) || 1 })}>
                        <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          <SelectItem value="1">1</SelectItem>
                          <SelectItem value="2">2</SelectItem>
                        </SelectContent>
                      </Select>
                    </div>
                  )}
                  {/* SPID: pick the dialect. They differ in reply length AND baud
                      rate, so this cannot be detected — a wrong choice is a
                      controller that never answers. */}
                  {isSPID && (
                    <div className="space-y-1">
                      <Label>{t('rot.spidModel')}</Label>
                      <Select value={dev.spid_model || 'rot2prog'}
                        onValueChange={(v) => patch(i, { spid_model: v as any, baud: v === 'rot1prog' ? 1200 : 600 })}>
                        <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          <SelectItem value="rot2prog">Rot2Prog (RAS, BIG-RAS/HR, MD-01, MD-02)</SelectItem>
                          <SelectItem value="rot1prog">Rot1Prog (azimuth only)</SelectItem>
                        </SelectContent>
                      </Select>
                    </div>
                  )}
                  {/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */}
                  {isSerialCap && !isSPID && (
                    <div className="space-y-1">
                      <Label>Connection</Label>
                      <Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
                        <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          <SelectItem value="tcp">Network (LAN)</SelectItem>
                          <SelectItem value="serial">USB (serial COM)</SelectItem>
                        </SelectContent>
                      </Select>
                    </div>
                  )}
                </div>
                {/* Rotator Genius drives both its ports — one entry = two rotors. */}
                {isRG && (
                  <label className="flex items-center gap-2 text-sm cursor-pointer">
                    <Checkbox checked={!!dev.dual} onCheckedChange={(c) => patch(i, { dual: !!c })} />
                    {t('rot.rgDual')}
                  </label>
                )}
                {isSerialCap && transport === 'serial' ? (
                  <div className="space-y-1 max-w-xs">
                    <Label>COM port</Label>
                    <div className="flex items-center gap-2">
                      <Select value={dev.com_port || '_'} onValueChange={(v) => patch(i, { com_port: v === '_' ? '' : v })}>
                        <SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
                        <SelectContent>
                          {wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
                          {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                        </SelectContent>
                      </Select>
                      <Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
                        <ArrowDown className="size-3.5 rotate-90" />
                      </Button>
                      {/* A SPID runs at 600 or 1200 baud — not a typo, a pulse
                          controller has nothing to say quickly. Offering only the
                          usual rates would have left it permanently mute. */}
                      <Select value={String(dev.baud || (isSPID ? 600 : 9600))} onValueChange={(v) => patch(i, { baud: Number(v) })}>
                        <SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          {(isSPID ? [600, 1200, 2400, 4800, 9600] : [4800, 9600, 19200, 38400, 57600]).map((b) => (
                            <SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
                          ))}
                        </SelectContent>
                      </Select>
                    </div>
                  </div>
                ) : (
                  <div className="grid grid-cols-3 gap-3">
                    <div className="space-y-1 col-span-2">
                      <Label>Host / IP</Label>
                      <Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
                        placeholder={isRG || isSerialCap ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
                    </div>
                    <div className="space-y-1">
                      <Label>{isRG || isSerialCap ? 'TCP port' : 'UDP port'}</Label>
                      <PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
                        fallback={isRG ? 9006 : isSerialCap ? 4001 : 12000} className="font-mono" />
                    </div>
                  </div>
                )}
                {!isRG && !isSerialCap && (
                  <label className="flex items-center gap-2 text-sm cursor-pointer">
                    <Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
                    This rotator supports elevation (VHF / satellite)
                  </label>
                )}
                {isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
                {isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
                {isDCU1 && <p className="text-xs text-muted-foreground">{t('rot.dcu1Hint')}</p>}
                {isSPID && <p className="text-xs text-muted-foreground">{t('rot.spidHint')}</p>}
                {/* Which antenna this rotor carries — only relevant with >1 rotor. */}
                {multi && (
                  <div className="space-y-1 max-w-xs">
                    <Label>{isRG && dev.dual ? t('rot.antenna1') : t('rot.antenna')}</Label>
                    <Select value={dev.motorized ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized: v === 'motor' })}>
                      <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                      <SelectContent>
                        <SelectItem value="std">{t('rot.antStd')}</SelectItem>
                        <SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
                      </SelectContent>
                    </Select>
                  </div>
                )}
                {/* Second logical rotor of a dual Rotator Genius: its own name + antenna. */}
                {isRG && dev.dual && (
                  <div className="grid grid-cols-2 gap-3">
                    <div className="space-y-1">
                      <Label>{t('rot.name2')}</Label>
                      <Input value={dev.name2 ?? ''} placeholder={`${dev.name || 'Rotor'} 2`}
                        onChange={(e) => patch(i, { name2: e.target.value })} />
                    </div>
                    <div className="space-y-1">
                      <Label>{t('rot.antenna2')}</Label>
                      <Select value={dev.motorized2 ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized2: v === 'motor' })}>
                        <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
                        <SelectContent>
                          <SelectItem value="std">{t('rot.antStd')}</SelectItem>
                          <SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
                        </SelectContent>
                      </Select>
                    </div>
                  </div>
                )}
                <div className="flex items-center gap-2">
                  <Button variant="outline" size="sm" onClick={() => testRotator(d, 1)} disabled={rotatorTesting}>
                    {rotatorTesting ? t('hw.sending') : (isRG && dev.dual ? t('rot.testRotor1') : t('hw.testRotator'))}
                  </Button>
                  {isRG && dev.dual && (
                    <Button variant="outline" size="sm" onClick={() => testRotator(d, 2)} disabled={rotatorTesting}>
                      {t('rot.testRotor2')}
                    </Button>
                  )}
                </div>
              </div>
            );
          })}
          <Button variant="outline" size="sm" onClick={addRotor}>
            <Plus className="size-3.5 mr-1" /> {t('rot.add')}
          </Button>
          {rotors.length > 0 && (
            <div className="flex items-center gap-2 pt-2">
              <Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))}>
                Stop
              </Button>
              {anyPst && (
                <Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))}>
                  Park
                </Button>
              )}
            </div>
          )}

          {/* How much of the rotor widget to draw. Compact keeps the dial and the
              SP/LP azimuths and drops the controls — for an operator who turns
              the antenna from the bearing pill or from PstRotator and wants the
              dial to take a column, not a panel. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <div className="text-sm font-semibold">{t('rot.widget')}</div>
            {/* Which dial. Both are kept: the new one reads at a glance across
                the shack, the old one is the compact dial operators learned
                first, and neither is wrong. */}
            <div className="flex items-center gap-2 text-sm">
              <span className="text-muted-foreground">{t('rot.dial')}</span>
              <select
                value={rotorDial}
                onChange={(e) => { const v = e.target.value as RotorStyle; setRotorDial(v); setRotorStyle(v); }}
                className="h-8 rounded-md border border-border bg-background px-2 text-sm"
              >
                <option value="modern">{t('rot.dialModern')}</option>
                <option value="classic">{t('rot.dialClassic')}</option>
              </select>
            </div>
            <label className="flex items-center gap-2 text-sm">
              <Checkbox checked={rotorCompact}
                onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} />
              {t('rot.compact')}
            </label>
            <p className="text-xs text-muted-foreground">{t('rot.compactHint')}</p>
          </div>

          {/* Quick-turn buttons for the rotor widget. Their azimuths start out
              computed from the station square, so they are right for THIS QTH
              rather than copied from someone else's. Hidden in compact mode —
              editing buttons that are not drawn is a trap. */}
          <div className={cn('border-t border-border/60 pt-3 space-y-2', rotorCompact && 'opacity-40')}>
            <div className="text-sm font-semibold">{t('rot.presets')}</div>
            <p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
            <div className="space-y-1.5">
              {rotorPresets.map((p, i) => (
                <div key={i} className="flex items-center gap-2">
                  <Input className="w-24" maxLength={6} value={p.label}
                    placeholder={t('rot.presetName')}
                    onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} />
                  <Input className="w-24" type="number" min={0} max={359} value={p.azimuth}
                    onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} />
                  <span className="text-xs text-muted-foreground">°</span>
                  <Button variant="ghost" size="sm" title={t('rot.remove')}
                    onClick={() => setRotorPresets((list) => list.filter((_, j) => j !== i))}>
                    <Trash2 className="size-3.5" />
                  </Button>
                </div>
              ))}
            </div>
            <div className="flex items-center gap-2">
              {rotorPresets.length < 8 && (
                <Button variant="outline" size="sm" onClick={() => setRotorPresets((l) => [...l, { label: '', azimuth: 0 }])}>
                  <Plus className="size-3.5 mr-1" /> {t('rot.presetAdd')}
                </Button>
              )}
              <Button variant="outline" size="sm"
                onClick={() => { ResetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch((e) => setErr(String(e?.message ?? e))); }}>
                {t('rot.presetsReset')}
              </Button>
            </div>
          </div>
          {rotatorTest && (
            <div className={cn(
              'text-xs rounded-md p-2.5 border',
              rotatorTest.ok
                ? 'bg-success-muted text-success-muted-foreground border-success-border'
                : 'bg-destructive/10 text-destructive border-destructive/30',
            )}>
              {rotatorTest.msg}
            </div>
          )}
          <p className="text-xs text-muted-foreground">
            From the main entry strip, click the bearing pill to rotate the active rotor to the short-path
            azimuth. Shift+click for long-path, Ctrl+click to stop. With several rotors, pick the active one
            on the compass widget.
          </p>
        </div>
      </>
    );
  }

  function WinkeyerPanel() {
    const setMacro = (i: number, patch: Partial<WKMac>) => setWk((s) => {
      const macros = [...s.macros];
      while (macros.length <= i) macros.push({ label: '', text: '' });
      macros[i] = { ...macros[i], ...patch };
      return { ...s, macros };
    });
    const num = (v: string, d: number) => { const n = parseInt(v, 10); return isNaN(n) ? d : n; };
    return (
      <>
        <SectionHeader
          title={t('sec.winkeyer')}
        />
        <div className="space-y-4 max-w-2xl">
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
            {t('wk.enable')}
          </label>

          <div className="grid grid-cols-4 gap-3 items-end">
            <div className="space-y-1">
              <Label>{t('wk.engine')}</Label>
              {/* Picking the serial engine defaults PTT-keying ON: without RTS held,
                  the rig drops to RX between words (semi-break-in). */}
              <Select value={wk.engine} onValueChange={(v) => setWkField(v === 'serial' ? { engine: v, use_ptt: true } : { engine: v })}>
                <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
                <SelectContent>
                  <SelectItem value="winkeyer">{t('wk.engWinkeyer')}</SelectItem>
                  <SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
                  <SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
                  <SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
                  <SelectItem value="kenwood">{t('wk.engKenwood')}</SelectItem>
                  <SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
                  <SelectItem value="tci">{t('wk.engTci')}</SelectItem>
                </SelectContent>
              </Select>
            </div>
            <label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
              <Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
              {t('wk.escClears')}
            </label>
            <label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
              <Checkbox checked={wk.esm} onCheckedChange={(c) => setWkField({ esm: !!c })} />
              {t('wk.esm')}
            </label>
          </div>

          {wk.engine === 'icom' ? (
            <>
              {(!catCfg.enabled || (catCfg.backend !== 'icom' && catCfg.backend !== 'icom-net')) && (
                <p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
                  <span aria-hidden>⚠</span>
                  <span>{t('wk.catWarnIcom', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
                </p>
              )}
              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1">
                  <Label>{t('wk.speed')}</Label>
                  <Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
                </div>
              </div>
            </>
          ) : wk.engine === 'yaesu' ? (
            <>
              {(!catCfg.enabled || catCfg.backend !== 'yaesu') && (
                <p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
                  <span aria-hidden>⚠</span>
                  <span>{t('wk.catWarnYaesu', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
                </p>
              )}
              <p className="text-xs text-muted-foreground -mt-1">{t('wk.yaesuHint')}</p>
              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1">
                  <Label>{t('wk.speed')}</Label>
                  <Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
                </div>
              </div>
            </>
          ) : wk.engine === 'kenwood' ? (
            <>
              {(!catCfg.enabled || catCfg.backend !== 'kenwood') && (
                <p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
                  <span aria-hidden>⚠</span>
                  <span>{t('wk.catWarnKenwood', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
                </p>
              )}
              <p className="text-xs text-muted-foreground -mt-1">{t('wk.kenwoodHint')}</p>
              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1">
                  <Label>{t('wk.speed')}</Label>
                  <Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
                </div>
              </div>
            </>
          ) : wk.engine === 'tci' ? (
            <>
              {(!catCfg.enabled || catCfg.backend !== 'tci') && (
                <p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
                  <span aria-hidden>⚠</span>
                  <span>{t('wk.catWarnTci', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
                </p>
              )}
              <p className="text-xs text-muted-foreground -mt-1">{t('wk.tciHint')}</p>
              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1">
                  <Label>{t('wk.speed')}</Label>
                  <Input type="number" min={5} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
                </div>
              </div>
            </>
          ) : wk.engine === 'flex' ? (
            <>
              {(!catCfg.enabled || catCfg.backend !== 'flex') && (
                <p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
                  <span aria-hidden>⚠</span>
                  <span>{t('wk.catWarnFlex', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
                </p>
              )}
              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1">
                  <Label>{t('wk.speed')}</Label>
                  <Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
                </div>
              </div>
            </>
          ) : wk.engine === 'serial' ? (
            <>
              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1 col-span-2">
                  <Label>{t('wk.keyingPort')}</Label>
                  <div className="flex items-center gap-2">
                    <Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
                      <SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('wk.comPort')} /></SelectTrigger>
                      <SelectContent>
                        {wkPorts.length === 0 && <SelectItem value="_" disabled>{t('wk.noPorts')}</SelectItem>}
                        {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                      </SelectContent>
                    </Select>
                    <Button variant="ghost" size="sm" className="h-8 px-2" title={t('wk.reloadPorts')}
                      onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
                      <ArrowDown className="size-3.5 rotate-90" />
                    </Button>
                  </div>
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.cwKeyLine')}</Label>
                  <Select value={wk.cw_key_line === 'rts' ? 'rts' : 'dtr'} onValueChange={(v) => setWkField({ cw_key_line: v })}>
                    <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      <SelectItem value="dtr">{t('wk.lineDtr')}</SelectItem>
                      <SelectItem value="rts">{t('wk.lineRts')}</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.speed')}</Label>
                  <Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
                </div>
              </div>
              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1">
                  <Label>{t('wk.farnsworth')}</Label>
                  <Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.leadIn')}</Label>
                  <Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.tail')}</Label>
                  <Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
                </div>
                <label className="flex items-center gap-2 text-sm cursor-pointer pb-1.5 self-end">
                  <Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> {t('wk.keyPtt')}
                </label>
              </div>
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox checked={wk.cw_invert} onCheckedChange={(c) => setWkField({ cw_invert: !!c })} />
                {t('wk.invert')}
              </label>
            </>
          ) : (
            <>
              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1 col-span-2">
                  <Label>{t('wk.serialPort')}</Label>
                  <div className="flex items-center gap-2">
                    <Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
                      <SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('wk.comPort')} /></SelectTrigger>
                      <SelectContent>
                        {wkPorts.length === 0 && <SelectItem value="_" disabled>{t('wk.noPorts')}</SelectItem>}
                        {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                      </SelectContent>
                    </Select>
                    <Button variant="ghost" size="sm" className="h-8 px-2" title={t('wk.reloadPorts')}
                      onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
                      <ArrowDown className="size-3.5 rotate-90" />
                    </Button>
                  </div>
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.baud')}</Label>
                  <Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
                    <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      {[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
                    </SelectContent>
                  </Select>
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.speed')}</Label>
                  <Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
                </div>
              </div>

              <div className="grid grid-cols-4 gap-3">
                <div className="space-y-1">
                  <Label>{t('wk.weight')}</Label>
                  <Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.leadIn')}</Label>
                  <Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.tail')}</Label>
                  <Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.ratio')}</Label>
                  <Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.farnsworth')}</Label>
                  <Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
                </div>
                <div className="space-y-1">
                  <Label>{t('wk.sidetone')}</Label>
                  <Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
                </div>
                <div className="space-y-1 col-span-2">
                  <Label>{t('wk.paddleMode')}</Label>
                  <Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
                    <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      <SelectItem value="iambic_b">Iambic B</SelectItem>
                      <SelectItem value="iambic_a">Iambic A</SelectItem>
                      <SelectItem value="ultimatic">Ultimatic</SelectItem>
                      <SelectItem value="bug">Bug</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
              </div>

              <div className="flex flex-wrap gap-x-5 gap-y-2">
                <label className="flex items-center gap-2 text-sm cursor-pointer">
                  <Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> {t('wk.swapPaddles')}
                </label>
                <label className="flex items-center gap-2 text-sm cursor-pointer">
                  <Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> {t('wk.autospace')}
                </label>
                <label className="flex items-center gap-2 text-sm cursor-pointer">
                  <Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> {t('wk.keyPttShort')}
                </label>
                <label className="flex items-center gap-2 text-sm cursor-pointer">
                  <Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> {t('wk.serialEcho')}
                </label>
              </div>

              {/* Protocol trace — for reporting a keyer that misbehaves. Session
                  only, deliberately not persisted: it is a diagnostic, and a log
                  full of hex bytes helps nobody who forgot it was on. */}
              <div className="border-t border-border/60 pt-3">
                <label className="flex items-center gap-2 text-sm cursor-pointer">
                  <Checkbox checked={wkTrace} onCheckedChange={(c) => { setWkTrace(!!c); SetWinkeyerTrace(!!c); }} />
                  {t('wk.trace')}
                </label>
                <p className="text-[10px] text-muted-foreground mt-1">{t('wk.traceHint')}</p>
              </div>
            </>
          )}

          {/* Macro editor */}
          <div className="border-t border-border/60 pt-3">
            <Label className="text-sm font-medium">{t('wk.macroTitle')}</Label>
            <p className="text-[11px] text-muted-foreground mb-2">
              {t('wk.macroVars')} <span className="font-mono">&lt;MY_CALL&gt; &lt;CALL&gt; &lt;STX&gt; &lt;STRX&gt; &lt;MY_NAME&gt; &lt;HIS_NAME&gt; &lt;MY_QTH&gt; &lt;GRID&gt; &lt;CONT_TX&gt; &lt;n&gt;</span> {t('wk.macroCut')} <span className="font-mono">*</span>={t('wk.macroMyCall')}, <span className="font-mono">!</span>={t('wk.macroHisCall')}. <span className="font-mono">&lt;LOGQSO&gt;</span> = {t('wk.macroLog')} (<span className="font-mono">73 TU &lt;LOGQSO&gt;</span>).
            </p>
            <div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
              {Array.from({ length: 12 }).map((_, i) => {
                const m = wk.macros[i] ?? { label: '', text: '' };
                return (
                  <div key={i} className="flex items-center gap-2">
                    <span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
                    <Input className="h-8 w-28 shrink-0 text-xs" placeholder={t('wk.label')} value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
                    <Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
                  </div>
                );
              })}
            </div>
          </div>
        </div>
      </>
    );
  }

  function statusForServer(id: number): ClusterServerStatus | undefined {
    return clusterStatuses.find((s) => (s.server_id as number) === id);
  }

  async function clusterToggleEnabled(srv: ClusterServer, on: boolean) {
    try {
      await SaveClusterServer({ ...srv, enabled: on } as any);
      await reloadClusterServers();
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  async function clusterDeleteServer(srv: ClusterServer) {
    if (!confirm(`Delete cluster "${srv.name}"? Active session will be closed.`)) return;
    try { await DeleteClusterServer(srv.id as number); await reloadClusterServers(); }
    catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  async function clusterMove(srv: ClusterServer, dir: -1 | 1) {
    const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
    const idx = sorted.findIndex((s) => s.id === srv.id);
    const j = idx + dir;
    if (idx < 0 || j < 0 || j >= sorted.length) return;
    const a = sorted[idx], b = sorted[j];
    try {
      await SaveClusterServer({ ...a, sort_order: b.sort_order } as any);
      await SaveClusterServer({ ...b, sort_order: a.sort_order } as any);
      await reloadClusterServers();
    } catch (e: any) { setErr(String(e?.message ?? e)); }
  }
  function clusterAddNew() {
    const next: ClusterServer = {
      id: 0, name: '', host: '', port: 7300,
      login_override: '', password: '', init_commands: '',
      enabled: true, sort_order: clusterServers.length,
    };
    setEditingServer(next);
  }

  // Chasing is not a cluster feature.
  //
  // These switches were born on the DX Cluster page because the cluster was the
  // only thing that drew their badges. They now decide what the FT decode list
  // and Chase new say as well, and settings that govern three screens do not
  // belong under the name of one of them. The page is called DXHunter because
  // that is what it is about — hunting DX — and because more of DXHunter's
  // ideas are meant to land beside them.
  function DXHunterPanel() {
    return (
      <>
        <h3 className="text-sm font-semibold">{t('sec.dxhunter')}</h3>
        <p className="text-xs text-muted-foreground -mt-1">{t('dxh.intro')}</p>
        <div className="space-y-3">
          <div className="border-t border-border/60 pt-3 space-y-2">
            {/* Chase switches: off, the marker stops being TOLD, everywhere at
                once — badge, colour, filter chip, reference column. A "new band
                + new POTA" spot then reads NEW BAND alone. */}
          {/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
              county, state, grid) judged as new-only or new-plus-unconfirmed,
              against the confirmation sources the operator trusts. */}
          <div className="rounded-lg border border-border p-3 space-y-2">
            <div className="flex items-center gap-2">
              <span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
              <Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
                <SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
                <SelectContent>
                  <SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
                  <SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
                </SelectContent>
              </Select>
            </div>
            {chaseCfg.mode === 'new_unconfirmed' && (
              <div className="flex items-center gap-4 flex-wrap pl-2">
                <span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
                {([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
                  <label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
                    <Checkbox checked={chaseCfg.sources.includes(k)}
                      onCheckedChange={(c) => {
                        const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
                        const next = { ...chaseCfg, sources };
                        setChaseCfg(next); void SaveChaseSettings(next as any);
                      }} />
                    {label}
                  </label>
                ))}
              </div>
            )}
          </div>

            <label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
              <Checkbox checked={chasePotaOn}
                onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
              {t('clu.chasePota')}
            </label>
            <label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
              <Checkbox checked={chaseSotaOn}
                onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
              {t('clu.chaseSota')}
            </label>
            <label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseCountyHint')}>
              <Checkbox checked={chaseCountyOn}
                onCheckedChange={(c) => { const v = !!c; setChaseCountyOn(v); writeUiPref('opslog.chaseCounty', v ? '1' : '0'); }} />
              {t('clu.chaseCounty')}
            </label>
            <label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePfxHint')}>
              <Checkbox checked={chasePfxOn}
                onCheckedChange={(c) => { const v = !!c; setChasePfxOn(v); writeUiPref('opslog.chasePfx', v ? '1' : '0'); }} />
              {t('clu.chasePfx')}
            </label>
            <label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseStateHint')}>
              <Checkbox checked={chaseStateOn}
                onCheckedChange={(c) => { const v = !!c; setChaseStateOn(v); writeUiPref('opslog.chaseState', v ? '1' : '0'); }} />
              {t('clu.chaseState')}
            </label>
            <label className="flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox checked={chaseGrids} className="mt-0.5"
                onCheckedChange={(c) => { setChaseGrids(!!c); writeUiPref('opslog.chaseGrids', c ? '1' : '0'); SetChaseNewGrids(!!c).catch(() => {}); }} />
              <span>{t('clu.chaseGrids')} <span className="text-xs text-muted-foreground">{t('clu.chaseGridsHint')}</span></span>
            </label>
            {chaseGrids && (
              <p className="pl-6 text-xs text-muted-foreground">
                {t('clu.chaseGridsStat', { n: gridStat?.known ?? 0, p: gridStat?.pending ?? 0 })}
                {pskrStatus?.running ? ` · ${t('bo.feedUp', { n: pskrStatus.received ?? 0 })}` : ''}
              </p>
            )}

            {/* What counts as "I already have this square". There is no single
                right answer — a VUCC chaser counts a square per band, someone
                filling a wall map counts it once — so it is a choice, and the
                same six GridTracker offers, because a square wanted in one and
                not the other is a bug report every time. */}
            <div className="pl-6 space-y-1.5 pt-1">
              <div className="flex items-center gap-2 flex-wrap">
                <span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.scope')}</span>
                <Select value={gridScope.scope} onValueChange={(v) => saveGridScope({ ...gridScope, scope: v })}>
                  <SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    {(gridScope.scopes ?? []).map((s: any) => (
                      <SelectItem key={s.key} value={s.key}>{t(`gsc.scope_${s.key}`)}</SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </div>

            </div>
          </div>


          {/* Chase new — its own option, NOT nested under grid chasing. Chasing
              squares and chasing entities are different wants; they only share
              the PSK Reporter feed, which either one brings up. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox checked={chaseNew} className="mt-0.5"
                onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
              <span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
            </label>
            {/* The same radius the band-opening watch uses — one feed, one
                circle — but reachable from here, because an operator who only
                wants the chase list would otherwise have to find it inside a
                watch they never switched on. It is the setting that decides
                whether this list has anything in it at all: where stations are
                far apart, 300 km can hold no receivers whatsoever. */}
            {chaseNew && (
              <div className="flex items-center gap-2 flex-wrap pl-6">
                <span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
                <Input
                  type="number" min={25} max={3000} step={25}
                  className="w-24 h-7 text-xs"
                  defaultValue={bandOpen.near_km ?? 300}
                  key={`cnk-${bandOpen.near_km ?? 300}`}
                  onBlur={(e) => {
                    const v = parseInt(e.target.value, 10);
                    if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
                  }}
                  onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
                />
                <span className="text-xs text-muted-foreground">km</span>
                <span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
              </div>
            )}
            {/* Which bands the panel lists. Not the station's band list — that
                one says what this station can work at all and still applies
                underneath. This says what is worth watching tonight, which is
                a different question and changes far more often. */}
            {chaseNew && (
              <div className="pl-6 space-y-1">
                <span className="text-xs text-muted-foreground">{t('chn.bands')}</span>
                <div className="flex flex-wrap items-center gap-1">
                  {CHASE_BANDS.map((b) => {
                    const on = chaseBands.includes(b);
                    return (
                      <button key={b} type="button"
                        onClick={() => saveChaseBands(on ? chaseBands.filter((x) => x !== b) : [...chaseBands, b])}
                        className={cn('h-6 px-2 rounded-full border text-[11px] font-medium transition-colors',
                          on ? 'border-primary bg-primary text-primary-foreground'
                             : 'border-border text-muted-foreground hover:bg-muted')}>
                        {b}
                      </button>
                    );
                  })}
                  <button type="button" onClick={() => saveChaseBands([...CHASE_BANDS])}
                    className="h-6 px-2 rounded-full border border-border text-[11px] text-muted-foreground hover:bg-muted">
                    {t('chn.bandsAll')}
                  </button>
                </div>
                <p className="text-xs text-muted-foreground">{t('chn.bandsHint')}</p>
              </div>
            )}
          </div>

          {/* PSK Reporter analysis of the station being called. Same service as
              the two options above, opposite question: those ask what is being
              heard around here, this asks whether ONE station can hear you. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox checked={pskTgt.enabled} className="mt-0.5"
                onCheckedChange={(c) => savePSKTgt({ ...pskTgt, enabled: !!c })} />
              <span>{t('psk.setEnable')} <span className="text-xs text-muted-foreground">{t('psk.setEnableHint')}</span></span>
            </label>
            {pskTgt.enabled && (
              <div className="pl-6 space-y-1">
                <div className="flex items-center gap-2 flex-wrap">
                  <span className="text-xs text-muted-foreground">{t('psk.setScope')}</span>
                  <Select value={pskTgt.scope} onValueChange={(v) => savePSKTgt({ ...pskTgt, scope: v })}>
                    <SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
                    <SelectContent>
                      <SelectItem value="target">{t('psk.setScopeTarget')}</SelectItem>
                      <SelectItem value="band">{t('psk.setScopeBand')}</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
                <p className="text-xs text-muted-foreground">{t('psk.setScopeHint')}</p>
              </div>
            )}
          </div>

          {/* Contest — how a special-event fleet finds its way onto the watch
              list on its own. Two halves, because a fleet has two kinds of
              member. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <div className="text-sm font-medium">{t('wlc.title')}</div>
            <div className="flex items-center gap-2 flex-wrap">
              <span className="text-xs text-muted-foreground">{t('wlc.pattern')}</span>
              <Input className="h-7 w-32 text-xs font-mono uppercase"
                defaultValue={contestPattern} key={`wlcp-${contestPattern}`}
                placeholder={t('wlc.patternPh')}
                onBlur={(e) => {
                  const v = e.target.value.toUpperCase().trim();
                  if (v !== contestPattern) { setContestPattern(v); void SetWatchlistContestPattern(v); }
                }}
                onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
              <span className="text-xs text-muted-foreground">{t('wlc.patternHint')}</span>
            </div>
            <div className="space-y-1">
              <span className="text-xs text-muted-foreground">{t('wlc.calls')}</span>
              <textarea
                className="w-full h-24 rounded-md border border-border bg-background p-2 text-xs font-mono uppercase"
                value={contestCalls}
                placeholder={t('wlc.callsPh')}
                onChange={(e) => setContestCalls(e.target.value)}
                onBlur={(e) => void SetWatchlistContestCalls(e.target.value)}
              />
              <p className="text-xs text-muted-foreground">{t('wlc.callsHint')}</p>
            </div>
          </div>

          {/* Auto-call. Last in this section, and behind a warning: it is the
              only setting in OpsLog that transmits without being asked to. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox checked={ac.enabled} className="mt-0.5"
                onCheckedChange={(c) => saveAC({ ...ac, enabled: !!c })} />
              <span>{t('ac.enable')} <span className="text-xs text-muted-foreground">{t('ac.enableHint')}</span></span>
            </label>
            <p className="text-xs text-warning">{t('ac.warn')}</p>
            {ac.enabled && (
              <div className="pl-6 space-y-2">
                <p className="text-xs text-muted-foreground">{t('ac.ladder')}</p>
                {/* A LIST: several callsigns, spaces or commas. Committed on
                    blur or Enter and kept as raw text while typing — binding the
                    box to the parsed value is what makes the space key look dead,
                    and space is the one key this field needs. */}
                <div className="flex items-start gap-2 flex-wrap">
                  <span className="text-xs text-muted-foreground mt-1.5">{t('ac.only')}</span>
                  <Input className="h-7 w-72 text-xs font-mono uppercase"
                    defaultValue={ac.only ?? ''} key={`aco-${ac.only ?? ''}`}
                    placeholder={t('ac.onlyPh')}
                    onBlur={(e) => saveAC({ ...ac, only: e.target.value.toUpperCase() })}
                    onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
                  <span className="text-xs text-muted-foreground mt-1.5">{t('ac.onlyHint')}</span>
                </div>
                <div className="flex items-center gap-2 flex-wrap">
                  {([
                    ['attempts', t('ac.attempts'), 1, 30],
                    ['watched_attempts', t('ac.watchedAttempts'), 1, 60],
                    ['misses', t('ac.misses'), 1, 10],
                    ['max_rounds', t('ac.rounds'), 1, 10],
                    ['rest_periods', t('ac.rest'), 1, 20],
                  ] as [string, string, number, number][]).map(([k, label, min, max]) => (
                    <span key={k} className="inline-flex items-center gap-1.5">
                      <span className="text-xs text-muted-foreground">{label}</span>
                      <Input type="number" min={min} max={max} className="h-7 w-16 text-xs"
                        defaultValue={(ac as any)[k]} key={`ac-${k}-${(ac as any)[k]}`}
                        onBlur={(e) => {
                          const v = parseInt(e.target.value, 10);
                          if (!isNaN(v) && v >= min && v <= max && v !== (ac as any)[k]) saveAC({ ...ac, [k]: v });
                        }}
                        onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
                    </span>
                  ))}
                </div>
                {/* A rule about what the transmitter may call, stated once. The
                    decodes panel has a LoTW chip too, but that one is a way of
                    READING the band — it is flicked on and off while looking
                    around, and the panel can be closed. */}
                <label className="flex items-center gap-2 text-xs cursor-pointer">
                  <Checkbox checked={ac.on_screen_only !== false}
                    onCheckedChange={(c) => saveAC({ ...ac, on_screen_only: !!c })} />
                  <span>{t('ac.onScreen')} <span className="text-muted-foreground">{t('ac.onScreenHint')}</span></span>
                </label>
                <label className="flex items-center gap-2 text-xs cursor-pointer">
                  <Checkbox checked={!!ac.trace}
                    onCheckedChange={(c) => saveAC({ ...ac, trace: !!c })} />
                  <span>{t('ac.trace')} <span className="text-muted-foreground">{t('ac.traceHint')}</span></span>
                </label>
              </div>
            )}
          </div>

        </div>
      </>
    );
  }

  function ClusterPanel() {
    const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
    return (
      <>
        <SectionHeader
          title={t('sec.cluster')}
          hint={t('clu.hint')}
        />
        <div className="space-y-4">
          <div className="rounded-md border border-border overflow-hidden">
            <table className="w-full text-sm">
              <thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
                <tr>
                  <th className="px-3 py-2 w-10"></th>
                  <th className="text-left px-3 py-2">{t('clu.name')}</th>
                  <th className="text-left px-3 py-2">{t('clu.hostPort')}</th>
                  <th className="text-left px-3 py-2 w-28">{t('clu.status')}</th>
                  <th className="px-3 py-2 w-40">{t('clu.actions')}</th>
                </tr>
              </thead>
              <tbody>
                {sorted.map((s, i) => {
                  const st = statusForServer(s.id as number);
                  const state = (st?.state ?? 'disconnected') as string;
                  const isMaster = i === sorted.findIndex((x) => x.enabled);
                  return (
                    <tr key={s.id as number} className="border-t border-border align-middle">
                      <td className="px-2 py-2 text-center">
                        <Checkbox
                          checked={s.enabled}
                          onCheckedChange={(c) => clusterToggleEnabled(s, !!c)}
                        />
                      </td>
                      <td className="px-3 py-2 font-medium">
                        {s.name}
                        {isMaster && s.enabled && (
                          <span className="ml-2 inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider bg-warning-muted text-warning-muted-foreground border border-warning-border">MASTER</span>
                        )}
                      </td>
                      <td className="px-3 py-2 font-mono text-xs">{s.host}:{s.port}</td>
                      <td className="px-3 py-2">
                        <span className={cn(
                          'inline-flex items-center px-1.5 py-0 rounded text-[9px] font-bold tracking-wider border',
                          state === 'connected' ? 'bg-success-muted text-success-muted-foreground border-success-border' :
                          state === 'connecting' || state === 'reconnecting' ? 'bg-warning-muted text-warning-muted-foreground border-warning-border' :
                          state === 'error' ? 'bg-danger-muted text-danger-muted-foreground border-danger-border' :
                          'bg-muted text-muted-foreground border-border',
                        )}>
                          {state.toUpperCase()}
                          {st?.retries ? ` #${st.retries}` : ''}
                        </span>
                      </td>
                      <td className="px-2 py-2">
                        <div className="flex items-center gap-0.5 justify-end">
                          {state === 'connected' || state === 'connecting' || state === 'reconnecting' ? (
                            <Button variant="ghost" size="icon" className="size-6 text-success hover:text-success"
                              onClick={async () => { await DisconnectClusterServer(s.id as number).catch(() => {}); await reloadClusterServers(); }}
                              title={t('clu.disconnect')}><Power className="size-3.5" /></Button>
                          ) : (
                            <Button variant="ghost" size="icon" className="size-6"
                              onClick={async () => { await ConnectClusterServer(s.id as number).catch(() => {}); await reloadClusterServers(); }}
                              title={t('clu.connect')}><Power className="size-3.5" /></Button>
                          )}
                          <Button variant="ghost" size="icon" className="size-6" disabled={i === 0} onClick={() => clusterMove(s, -1)} title={t('clu.moveUp')}><ArrowUp className="size-3" /></Button>
                          <Button variant="ghost" size="icon" className="size-6" disabled={i === sorted.length - 1} onClick={() => clusterMove(s, 1)} title={t('clu.moveDown')}><ArrowDown className="size-3" /></Button>
                          <Button variant="ghost" size="icon" className="size-6" onClick={() => setEditingServer(s)} title={t('clu.edit')}><Cog className="size-3.5" /></Button>
                          <Button variant="ghost" size="icon" className="size-6 text-destructive hover:text-destructive" onClick={() => clusterDeleteServer(s)} title={t('clu.delete')}><Trash2 className="size-3.5" /></Button>
                        </div>
                      </td>
                    </tr>
                  );
                })}
                {sorted.length === 0 && (
                  <tr><td colSpan={5} className="px-3 py-4 text-center text-muted-foreground text-xs">{t('clu.none')}</td></tr>
                )}
              </tbody>
            </table>
          </div>

          <div className="flex items-center gap-3 pt-2 border-t border-border">
            <Button variant="outline" size="sm" onClick={clusterAddNew}>
              <Plus className="size-3.5 mr-1" /> {t('clu.add')}
            </Button>
            <Button variant="outline" size="sm" onClick={async () => { await ConnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
              {t('clu.connectAll')}
            </Button>
            <Button variant="outline" size="sm" onClick={async () => { await DisconnectAllClusters().catch(() => {}); await reloadClusterServers(); }}>
              {t('clu.disconnectAll')}
            </Button>
            <label className="flex items-center gap-2 text-sm cursor-pointer ml-auto">
              <Checkbox checked={clusterAutoConnect} onCheckedChange={(c) => setClusterAutoConnectState(!!c)} />
              {t('clu.autoConnect')}
            </label>
          </div>
          <ClusterMacroEditor />
          <label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
            <Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
              onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
            <span>{t('clu.workedSameSlot')} <span className="text-xs text-muted-foreground">{t('clu.workedSameSlotHint')}</span></span>
          </label>
          {/* "No colour on worked" and "colour what is unworked here" used to live
              here. They moved to the Cluster tab's filter panel, next to Hide
              worked: they are things an operator changes while working a run, not
              things set up once. A preferences dialog you reopen every ten minutes
              is a filter in the wrong place. */}

          {/* Grid chasing. Here rather than in the filter panel because it is set
              up once: it decides whether locators learnt from decodes are KEPT
              across restarts, not what the list shows right now. */}
          {/* Spot lifetime. Applies to the cluster list AND every band map: the
              spots are removed from the shared list rather than hidden, so the
              setting cannot mean one thing in one view and another elsewhere. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <div className="flex items-center gap-3 flex-wrap">
              <span className="text-sm">{t('clu.spotTtl')}</span>
              <div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
                {[0, 5, 10, 15, 30, 60].map((v) => (
                  <button key={v} type="button"
                    onClick={() => { setSpotTTL(v); setSpotTTLText(String(v)); SetSpotTTLMinutes(v).catch(() => {}); }}
                    className={cn('px-2.5 py-1.5 font-medium', spotTTL === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
                    {v === 0 ? t('clu.spotTtlNever') : `${v}′`}
                  </button>
                ))}
              </div>
              {/* Raw text in local state: binding the input straight to the
                  clamped number makes an empty field impossible to type into. */}
              <Input className="h-8 w-20" value={spotTTLText}
                onChange={(e) => {
                  const raw = e.target.value.replace(/[^0-9]/g, '');
                  setSpotTTLText(raw);
                  const n = Math.min(720, parseInt(raw, 10) || 0);
                  setSpotTTL(n);
                  SetSpotTTLMinutes(n).catch(() => {});
                }} />
              <span className="text-xs text-muted-foreground">{t('clu.spotTtlHint')}</span>
            </div>
            {/* The list size, beside the lifetime because between them they decide
                the same thing: whichever runs out first removes the spot. */}
            <div className="flex items-center gap-3 flex-wrap">
              <span className="text-sm">{t('clu.spotMax')}</span>
              <div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
                {[500, 1000, 2500, 5000].map((v) => (
                  <button key={v} type="button"
                    onClick={() => { setSpotMaxText(String(v)); SetSpotMax(v).catch(() => {}); }}
                    className={cn('px-2.5 py-1.5 font-medium', Number(spotMaxText) === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
                    {v}
                  </button>
                ))}
              </div>
              <Input className="h-8 w-24" value={spotMaxText}
                onChange={(e) => {
                  const raw = e.target.value.replace(/[^0-9]/g, '');
                  setSpotMaxText(raw);
                  const n = parseInt(raw, 10);
                  if (Number.isFinite(n) && n > 0) SetSpotMax(Math.min(10000, n)).catch(() => {});
                }} />

            </div>
          </div>

          {/* Band-opening watch. It lives HERE, with the cluster nodes, because
              switching it on adds two of them — the operator should see that
              happen where it happens rather than find nodes they did not add. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox checked={bandOpen.enabled} className="mt-0.5"
                onCheckedChange={(c) => saveBandOpen({ ...bandOpen, enabled: !!c })} />
              <span>{t('bo.enable')} <span className="text-xs text-muted-foreground">{t('bo.enableHint')}</span></span>
            </label>
            {bandOpen.enabled && (
              <div className="pl-6 space-y-2">
                <div className="flex flex-wrap gap-1.5">
                  {(bandOpen.available ?? []).map((b: string) => {
                    const on = (bandOpen.bands ?? []).includes(b);
                    return (
                      <button key={b} type="button"
                        onClick={() => saveBandOpen({
                          ...bandOpen,
                          bands: on ? bandOpen.bands.filter((x: string) => x !== b) : [...(bandOpen.bands ?? []), b],
                        })}
                        className={cn('px-2 py-0.5 rounded-md border text-[11px] font-bold tracking-wider font-mono',
                          on ? 'bg-primary text-primary-foreground border-primary' : 'text-muted-foreground border-border hover:bg-muted')}>
                        {b}
                      </button>
                    );
                  })}
                </div>
                {/* How near a RECEIVER has to be. This is the whole premise of
                    the watch — a report is evidence about YOUR path only if it
                    was collected near you — and the right distance differs by
                    band, so it is the operator's call. Committed on blur/Enter,
                    not per keystroke: each save restarts the feed. */}
                <div className="flex items-center gap-2 flex-wrap">
                  <span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
                  <Input
                    type="number" min={25} max={3000} step={25}
                    className="w-24 h-7 text-xs"
                    defaultValue={bandOpen.near_km ?? 300}
                    key={`nk-${bandOpen.near_km ?? 300}`}
                    onBlur={(e) => {
                      const v = parseInt(e.target.value, 10);
                      if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
                    }}
                    onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
                  />
                  <span className="text-xs text-muted-foreground">km</span>
                </div>

                {/* A live count, because a feed that is connected but silent looks
                    exactly like one that is broken until a number moves. */}
                <p className="text-xs text-muted-foreground">
                  {pskrStatus?.running
                    ? t('bo.feedUp', { n: pskrStatus.received ?? 0 })
                    : t('bo.feedDown')}
                </p>
              </div>
            )}
          </div>

          {/* Self-spot. The interval only shows once it's on — an interval for
              something switched off is just a question the operator can't act on. */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <div className="flex items-center gap-3 flex-wrap">
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox checked={selfSpot.enabled}
                  onCheckedChange={(c) => setSelfSpot((s) => ({ ...s, enabled: !!c }))} />
                {t('clu.selfSpot')}
              </label>
              {selfSpot.enabled && (
                <div className="flex items-center gap-2 text-sm">
                  <span className="text-muted-foreground">{t('clu.selfSpotEvery')}</span>
                  <Input
                    type="number"
                    min={SELF_SPOT_MIN_MIN}
                    max={240}
                    className="w-20 h-8 font-mono text-xs"
                    value={selfSpotText}
                    onChange={(e) => setSelfSpotText(e.target.value)}
                    onBlur={() => {
                      const n = Math.floor(Number(selfSpotText));
                      const v = Number.isFinite(n) && n > SELF_SPOT_MIN_MIN ? Math.min(n, 240) : SELF_SPOT_MIN_MIN;
                      setSelfSpotText(String(v));
                      setSelfSpot((s) => ({ ...s, minutes: v }));
                    }}
                  />
                  <span className="text-muted-foreground">{t('clu.selfSpotMinutes')}</span>
                </div>
              )}
            </div>
          </div>
        </div>

        {editingServer && (
          <ClusterServerEditor
            value={editingServer}
            onCancel={() => setEditingServer(null)}
            onSave={async (srv) => {
              try {
                await SaveClusterServer(srv as any);
                await reloadClusterServers();
                setEditingServer(null);
              } catch (e: any) { setErr(String(e?.message ?? e)); }
            }}
          />
        )}
      </>
    );
  }

  function ConfirmationsPanel() {
    // ADIF status codes. FULL set for paper QSL/eQSL/Clublog/HRDLog,
    // SIMPLE Y/N set for LoTW (the only values the LoTW protocol returns).
    const FULL_OPTIONS = [
      { value: '_', label: t('conf.blank') },
      { value: 'Y', label: t('conf.y') },
      { value: 'N', label: t('conf.n') },
      { value: 'R', label: t('conf.r') },
      { value: 'Q', label: t('conf.q') },
      { value: 'I', label: t('conf.i') },
    ];
    // LoTW / Clublog / HRDLog also use ADIF-style status codes — keep
    // R (requested) available so users can mark "queued for upload"
    // and filter on it later.

    // Renderer inlined as a constant — declaring this as a function
    // INSIDE ConfirmationsPanel would re-instantiate the component on
    // every render, which unmounts and re-mounts the Radix Select
    // (closing it the moment you click the trigger).
    const renderSelect = (
      key: keyof QSLDefaults,
      options: { value: string; label: string }[],
    ) => (
      <Select
        value={qslDefaults[key] || '_'}
        onValueChange={(v) => setQslDefaults((d) => ({ ...d, [key]: v === '_' ? '' : v }))}
      >
        <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
        <SelectContent>
          {options.map((o) => (
            <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
          ))}
        </SelectContent>
      </Select>
    );

    return (
      <>
        <SectionHeader
          title={t('sec.confirmations')}
          hint={t('conf.hint')}
        />
        <ProfileScopeNote profile={activeProfileObj} />
        <div className="space-y-3 max-w-2xl">
          {/* Paper QSL */}
          <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
            <Label className="text-sm font-medium pb-1.5">{t('conf.paperQsl')}</Label>
            <div>
              <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
              {renderSelect('qsl_sent', FULL_OPTIONS)}
            </div>
            <div>
              <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
              {renderSelect('qsl_rcvd', FULL_OPTIONS)}
            </div>
          </div>
          {/* eQSL */}
          <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
            <Label className="text-sm font-medium pb-1.5">eQSL</Label>
            <div>
              <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
              {renderSelect('eqsl_sent', FULL_OPTIONS)}
            </div>
            <div>
              <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
              {renderSelect('eqsl_rcvd', FULL_OPTIONS)}
            </div>
          </div>
          {/* LoTW */}
          <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
            <Label className="text-sm font-medium pb-1.5">LoTW</Label>
            <div>
              <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
              {renderSelect('lotw_sent', FULL_OPTIONS)}
            </div>
            <div>
              <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
              {renderSelect('lotw_rcvd', FULL_OPTIONS)}
            </div>
          </div>

          <div className="border-t border-border/60 pt-3 space-y-3">
            {/* Clublog */}
            <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
              <Label className="text-sm font-medium pb-1.5">Clublog</Label>
              <div>
                <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
                {renderSelect('clublog_status', FULL_OPTIONS)}
              </div>
              <div>
                <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
                {renderSelect('clublog_confirmed', FULL_OPTIONS)}
              </div>
            </div>
            {/* HRDLog */}
            <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
              <Label className="text-sm font-medium pb-1.5">HRDLog</Label>
              <div>
                <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
                {renderSelect('hrdlog_status', FULL_OPTIONS)}
              </div>
              <div />
            </div>
            {/* QRZ.com */}
            <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
              <Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
              <div>
                <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
                {renderSelect('qrzcom_status', FULL_OPTIONS)}
              </div>
              <div>
                <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
                {renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
              </div>
            </div>
            {/* HamQTH — no received side: the site has no confirmation feed. */}
            <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
              <Label className="text-sm font-medium pb-1.5">HamQTH</Label>
              <div>
                <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
                {renderSelect('hamqth_status', FULL_OPTIONS)}
              </div>
              <div />
            </div>
            {/* HAMLOG.online */}
            <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
              <Label className="text-sm font-medium pb-1.5">HAMLOG.online</Label>
              <div>
                <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.sent')}</Label>
                {renderSelect('hamlog_status', FULL_OPTIONS)}
              </div>
              <div>
                <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">{t('conf.rcvd')}</Label>
                {renderSelect('hamlog_confirmed', FULL_OPTIONS)}
              </div>
            </div>
          </div>
        </div>
      </>
    );
  }

  function UDPIntegrationsPanelWrapper() {
    return (
      <>
        <SectionHeader title={t('sec.udp')} />
        <UDPIntegrationsPanel onError={(m) => setErr(m)} />
      </>
    );
  }

  function OperatingPanelWrapper() {
    return (
      <>
        <SectionHeader
          title={t('sec.operating')}
          hint={t('operating.hint')}
        />
        <OperatingPanel
          bands={lists.bands ?? []}
          onError={(m) => setErr(m)}
        />
      </>
    );
  }

  function BackupPanel() {
    const fmtLast = (iso: string) => {
      if (!iso) return t('bk.never');
      const d = new Date(iso);
      if (isNaN(d.getTime())) return iso;
      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())} UTC`;
    };
    const effectiveFolder = (backupCfg.folder || backupCfg.default_folder || '').replace(/\\/g, '/');
    async function backupNow() {
      setBackupRunning(true); setBackupResult(null);
      try {
        // Save current draft first so the backup runs with the values
        // the user just typed (folder, rotation, zip) — otherwise the
        // backend would use stale persisted config.
        await SaveBackupSettings(backupCfg as any);
        const path = await RunBackupNow();
        setBackupResult({ ok: true, msg: t('bk.writtenTo') + ' ' + path });
        const refreshed = await GetBackupSettings();
        setBackupCfg(refreshed as any);
      } catch (e: any) {
        setBackupResult({ ok: false, msg: String(e?.message ?? e) });
      } finally { setBackupRunning(false); }
    }
    return (
      <>
        {/* No hint: everyone knows what a backup is. */}
        <SectionHeader title={t('sec.backup')} />
        <div className="space-y-5 max-w-2xl">
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox
              checked={!!backupCfg.enabled}
              onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, enabled: !!c }))}
            />
            <span>{t('bk.auto')}</span>
          </label>

          <div className="space-y-1.5">
            <Label className="text-xs text-muted-foreground">{t('bk.folder')}</Label>
            <div className="flex gap-2">
              <Input
                className="font-mono text-xs flex-1"
                placeholder={backupCfg.default_folder || t('bk.folderPh')}
                value={backupCfg.folder ?? ''}
                onChange={(e) => setBackupCfg((b) => ({ ...b, folder: e.target.value }))}
              />
              <Button
                variant="outline"
                size="sm"
                onClick={async () => {
                  try {
                    const p = await PickBackupFolder();
                    if (p) setBackupCfg((b) => ({ ...b, folder: p }));
                  } catch (e: any) {
                    setBackupResult({ ok: false, msg: String(e?.message ?? e) });
                  }
                }}
              >
                {t('bk.browse')}
              </Button>
            </div>
            <div className="text-[10px] text-muted-foreground">
              {backupCfg.folder
                ? <>{t('bk.effective')} <span className="font-mono">{effectiveFolder}</span></>
                : <>{t('bk.defaultUse')} <span className="font-mono">{(backupCfg.default_folder ?? '').replace(/\\/g, '/')}</span></>
              }
            </div>
          </div>

          <div className="space-y-1.5">
            <Label className="text-xs text-muted-foreground">{t('bk.rotation')}</Label>
            <Input
              type="number"
              min={1}
              max={365}
              className="w-24 font-mono text-xs"
              value={backupCfg.rotation || 5}
              onChange={(e) => {
                const n = Number(e.target.value);
                if (Number.isFinite(n) && n > 0) setBackupCfg((b) => ({ ...b, rotation: Math.floor(n) }));
              }}
            />
          </div>

          {/* Stacked, not one flex row: these labels are full sentences and the
              "keep every backup" hint is two lines — side by side they wrapped
              into unreadable slivers. It also puts keep-all under the option it
              depends on rather than beside it. */}
          <div className="space-y-2.5">
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox
                checked={!!backupCfg.zip}
                onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, zip: !!c }))}
              />
              <span>{t('bk.zip')}</span>
            </label>
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox
                checked={!!(backupCfg as any).every_exit}
                onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, every_exit: !!c } as any))}
              />
              <span>{t('bk.everyExit')}</span>
            </label>
            {!!(backupCfg as any).every_exit && (
              <label className="flex items-start gap-2 text-sm cursor-pointer pl-6">
                <Checkbox
                  className="mt-0.5"
                  checked={!!(backupCfg as any).keep_all}
                  onCheckedChange={(c) => setBackupCfg((b) => ({ ...b, keep_all: !!c } as any))}
                />
                <span>
                  {t('bk.keepAll')}
                  <span className="block text-xs text-muted-foreground mt-0.5 leading-relaxed">{t('bk.keepAllHint')}</span>
                </span>
              </label>
            )}
          </div>

          <div className="border-t border-border/60 pt-3 flex items-center gap-3">
            <Button size="sm" onClick={backupNow} disabled={backupRunning}>
              {backupRunning ? t('bk.backingUp') : t('bk.backupNow')}
            </Button>
            <span className="text-xs text-muted-foreground">
              {t('bk.lastRun')} <strong className="text-foreground">{fmtLast(backupCfg.last_backup_at)}</strong>
            </span>
          </div>

          {backupResult && (
            <div className={cn(
              'text-xs px-3 py-2 rounded-md border',
              backupResult.ok
                ? 'bg-success-muted border-success-border text-success-muted-foreground'
                : 'bg-danger-muted border-danger-border text-danger-muted-foreground',
            )}>
              {backupResult.msg}
            </div>
          )}
        </div>
      </>
    );
  }

  function ExternalServicesPanel() {
    const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
      { k: 'qrz',     label: 'QRZ.COM', ready: true },
      { k: 'clublog', label: 'CLUBLOG', ready: true },
      { k: 'hrdlog',  label: 'HRDLOG.NET', ready: true },
      { k: 'eqsl',    label: 'EQSL', ready: true },
      { k: 'lotw',    label: 'LOTW', ready: true },
      { k: 'cloudlog', label: 'CLOUDLOG', ready: true },
      { k: 'hamlog',  label: 'HAMLOG.ONLINE', ready: true },
      { k: 'hamqth',  label: 'HAMQTH', ready: true },
      { k: 'pota',    label: 'POTA', ready: true },
    ];

    async function savePotaToken() {
      setPotaBusy(true);
      setPotaResult(null);
      try {
        await SavePOTAToken(potaToken);
        setPotaResult({ ok: true, msg: 'Token saved. Run the sync from the QSL Manager → POTA hunter log.' });
      } catch (e: any) {
        setPotaResult({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setPotaBusy(false);
      }
    }
    const qrz = extSvc.qrz;
    const setQrz = (patch: Partial<ExtServiceCfg>) =>
      setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
    const clublog = extSvc.clublog;
    const setClublog = (patch: Partial<ExtServiceCfg>) =>
      setExtSvc((s) => ({ ...s, clublog: { ...s.clublog, ...patch } }));

    async function testQrz() {
      setQrzTesting(true);
      setQrzTest(null);
      try {
        // Persist first so the backend test reads the key just typed.
        await SaveExternalServices(extSvc as any);
        const msg = await TestQRZUpload();
        setQrzTest({ ok: true, msg });
      } catch (e: any) {
        setQrzTest({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setQrzTesting(false);
      }
    }

    async function testClublog() {
      setClublogTesting(true);
      setClublogTest(null);
      try {
        await SaveExternalServices(extSvc as any);
        const msg = await TestClublogUpload();
        setClublogTest({ ok: true, msg });
      } catch (e: any) {
        setClublogTest({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setClublogTesting(false);
      }
    }

    const hrdlog = extSvc.hrdlog;
    const setHrdlog = (patch: Partial<ExtServiceCfg>) =>
      setExtSvc((s) => ({ ...s, hrdlog: { ...s.hrdlog, ...patch } }));

    async function testHrdlog() {
      setHrdlogTesting(true);
      setHrdlogTest(null);
      try {
        await SaveExternalServices(extSvc as any);
        const msg = await TestHRDLogUpload();
        setHrdlogTest({ ok: true, msg });
      } catch (e: any) {
        setHrdlogTest({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setHrdlogTesting(false);
      }
    }

    const hamqth = extSvc.hamqth ?? emptyExtCfg();
    const setHamqth = (patch: Partial<ExtServiceCfg>) =>
      setExtSvc((s) => ({ ...s, hamqth: { ...(s.hamqth ?? emptyExtCfg()), ...patch } }));
    async function testHamqth() {
      setHamqthTesting(true);
      setHamqthTest(null);
      try {
        const msg = await TestHamQTHUpload();
        setHamqthTest({ ok: true, msg });
      } catch (e: any) {
        setHamqthTest({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setHamqthTesting(false);
      }
    }
    const hamlog = extSvc.hamlog ?? emptyExtCfg();
    const setHamlog = (patch: Partial<ExtServiceCfg>) =>
      setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));

    // Checking the KEY, not the connection.
    //
    // HAMLOG answers KEYSTATUS with the callsign the key belongs to, which no
    // other service here does — so the answer is shown as the callsign rather
    // than a bare "OK". A key pasted from a club account or a second station is
    // then visible immediately, which is exactly the mistake a green tick would
    // have hidden.
    async function testHamlog() {
      setHamlogTesting(true);
      setHamlogTest(null);
      try {
        const call = await CheckHamlogKey(hamlog.api_key ?? '');
        setHamlogTest({ ok: true, msg: call ? t('es.hamlogKeyOkCall', { call }) : t('es.hamlogKeyOk') });
      } catch (e: any) {
        setHamlogTest({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setHamlogTesting(false);
      }
    }

    const cloudlog = extSvc.cloudlog;
    const setCloudlog = (patch: Partial<ExtServiceCfg>) =>
      setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } }));

    async function testCloudlog() {
      setCloudlogTesting(true);
      setCloudlogTest(null);
      try {
        // Save first: the backend test reads the stored config, so it must see
        // what was just typed (same as the other services' Test buttons).
        await SaveExternalServices(extSvc as any);
        const msg = await TestCloudlogUpload();
        setCloudlogTest({ ok: true, msg });
      } catch (e: any) {
        setCloudlogTest({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setCloudlogTesting(false);
      }
    }

    const eqsl = extSvc.eqsl;
    const setEqsl = (patch: Partial<ExtServiceCfg>) =>
      setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));

    async function testEqsl() {
      setEqslTesting(true);
      setEqslTest(null);
      try {
        await SaveExternalServices(extSvc as any);
        const msg = await TestEQSLUpload();
        setEqslTest({ ok: true, msg });
      } catch (e: any) {
        setEqslTest({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setEqslTesting(false);
      }
    }

    const lotw = extSvc.lotw;
    const setLotw = (patch: Partial<ExtServiceCfg>) =>
      setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));

    async function refreshLocations() {
      try {
        const locs: any = await ListTQSLStationLocations();
        setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
      } catch (e: any) {
        setLotwTest({ ok: false, msg: String(e?.message ?? e) });
      }
    }

    async function testLotw() {
      setLotwTesting(true);
      setLotwTest(null);
      try {
        await SaveExternalServices(extSvc as any);
        const msg = await TestLoTWUpload();
        setLotwTest({ ok: true, msg });
      } catch (e: any) {
        setLotwTest({ ok: false, msg: String(e?.message ?? e) });
      } finally {
        setLotwTesting(false);
      }
    }

    return (
      <>
        <SectionHeader
          title={t('sec.external')}
          hint={t('extsvc.hint')}
        />

        <ProfileScopeNote profile={activeProfileObj} />

        {/* Applies to BOTH services, so it sits above the tab strip rather than
            inside one tab where it would look like a QRZ-only setting (and be
            invisible to anyone who only opens the Club Log tab). */}
        <label className="flex items-start gap-2 text-sm cursor-pointer mb-4 max-w-2xl">
          <Checkbox
            checked={!!extSvc.delete_remote}
            onCheckedChange={(c) => setExtSvc((v) => ({ ...v, delete_remote: !!c }))}
            className="mt-0.5"
          />
          <span>
            {t('es.deleteRemote')}
            <span className="block text-xs text-muted-foreground mt-0.5">{t('es.deleteRemoteHint')}</span>
          </span>
        </label>

        {/* Tab strip */}
        <div className="flex flex-wrap gap-1 border-b border-border mb-4">
          {TABS.map((tab) => (
            <button
              key={tab.k}
              type="button"
              onClick={() => setExtSvcTab(tab.k)}
              className={cn(
                'px-3 py-1.5 text-xs font-semibold rounded-t-md border-x border-t -mb-px transition-colors',
                extSvcTab === tab.k
                  ? 'bg-card border-border text-foreground'
                  : 'bg-muted/40 border-transparent text-muted-foreground hover:text-foreground',
              )}
            >
              {tab.label}
              {!tab.ready && <span className="ml-1 text-[9px] opacity-60">{t('es.soon')}</span>}
            </button>
          ))}
        </div>

        {extSvcTab === 'qrz' ? (
          <div className="space-y-4 max-w-2xl">
            <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
              <Label className="text-sm">{t('es.apiKey')}</Label>
              <Input
                value={qrz.api_key}
                onChange={(e) => setQrz({ api_key: e.target.value })}
                placeholder={t('es.qrzApiPh')}
                className="font-mono text-xs"
              />
              <Label className="text-sm">{t('es.forceCall')}</Label>
              <Input
                value={qrz.force_station_callsign}
                onChange={(e) => setQrz({ force_station_callsign: e.target.value.toUpperCase() })}
                placeholder={t('es.forceCallPh')}
                className="font-mono text-xs"
              />
            </div>

            <div className="border-t border-border/60 pt-3 space-y-3">
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox
                  checked={qrz.auto_upload}
                  onCheckedChange={(c) => setQrz({ auto_upload: !!c })}
                />
                {t('es.autoUpload')}
              </label>

              <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
                <Label className="text-sm">{t('es.uploadTiming')}</Label>
                <Select
                  value={qrz.upload_mode || 'immediate'}
                  onValueChange={(v) => setQrz({ upload_mode: v })}
                >
                  <SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="immediate">{t('es.immediate')}</SelectItem>
                    <SelectItem value="delayed">{t('es.delayed')}</SelectItem>
                    <SelectItem value="on_close">{t('es.onClose')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>

              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={testQrz} disabled={qrzTesting || !qrz.api_key}>
                  <UploadCloud className="size-3.5" /> {qrzTesting ? t('es.testing') : t('es.testConn')}
                </Button>
                {qrzTest && (
                  <span className={cn('text-xs', qrzTest.ok ? 'text-success' : 'text-danger')}>
                    {qrzTest.msg}
                  </span>
                )}
              </div>
            </div>
          </div>
        ) : extSvcTab === 'clublog' ? (
          <div className="space-y-4 max-w-2xl">
            <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
              <Label className="text-sm">{t('es.accountEmail')}</Label>
              <Input
                type="email"
                value={clublog.email}
                onChange={(e) => setClublog({ email: e.target.value })}
                placeholder={t('es.clubEmailPh')}
                className="text-xs"
              />
              <Label className="text-sm">{t('es.password')}</Label>
              <Input
                type="password"
                value={clublog.password}
                onChange={(e) => setClublog({ password: e.target.value })}
                placeholder={t('es.clubPwPh')}
                className="text-xs"
              />
              <Label className="text-sm">{t('es.logbookCall')}</Label>
              <Input
                value={clublog.callsign}
                onChange={(e) => setClublog({ callsign: e.target.value.toUpperCase() })}
                placeholder={t('es.callDefaultPh')}
                className="font-mono text-xs"
              />
            </div>

            <div className="border-t border-border/60 pt-3 space-y-3">
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox
                  checked={clublog.auto_upload}
                  onCheckedChange={(c) => setClublog({ auto_upload: !!c })}
                />
                {t('es.autoUpload')}
              </label>

              <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
                <Label className="text-sm">{t('es.uploadTiming')}</Label>
                <Select
                  value={clublog.upload_mode || 'immediate'}
                  onValueChange={(v) => setClublog({ upload_mode: v })}
                >
                  <SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="immediate">{t('es.immediate')}</SelectItem>
                    <SelectItem value="delayed">{t('es.delayed')}</SelectItem>
                    <SelectItem value="on_close">{t('es.onClose')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>

              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={testClublog} disabled={clublogTesting}>
                  <UploadCloud className="size-3.5" /> {clublogTesting ? t('es.testing') : t('es.testConn')}
                </Button>
                {clublogTest && (
                  <span className={cn('text-xs', clublogTest.ok ? 'text-success' : 'text-danger')}>
                    {clublogTest.msg}
                  </span>
                )}
              </div>
            </div>
          </div>
        ) : extSvcTab === 'hrdlog' ? (
          <div className="space-y-4 max-w-2xl">
            <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
              <Label className="text-sm">{t('es.stationCall')}</Label>
              <Input
                value={hrdlog.callsign}
                onChange={(e) => setHrdlog({ callsign: e.target.value.toUpperCase() })}
                placeholder={t('es.callDefaultPh')}
                className="font-mono text-xs"
              />
              <Label className="text-sm">{t('es.uploadCode')}</Label>
              <Input
                type="password"
                value={hrdlog.code}
                onChange={(e) => setHrdlog({ code: e.target.value })}
                placeholder={t('es.hrdCodePh')}
                className="text-xs"
              />
            </div>
            <div className="text-[10px] text-muted-foreground -mt-1">
              {t('es.hrdHint')}
            </div>

            <div className="border-t border-border/60 pt-3 space-y-3">
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox
                  checked={hrdlog.auto_upload}
                  onCheckedChange={(c) => setHrdlog({ auto_upload: !!c })}
                />
                {t('es.autoUpload')}
              </label>

              {/* ON AIR is a live status, not an upload: it needs the rig
                  readable, not a QSO. Its own switch beside the upload one
                  rather than folded into it. */}
              <label className="flex items-start gap-2 text-sm cursor-pointer">
                <Checkbox className="mt-0.5"
                  checked={!!(hrdlog as any).on_air}
                  onCheckedChange={(c) => setHrdlog({ on_air: !!c } as any)}
                />
                <span>
                  {t('es.hrdOnAir')}
                  <span className="block text-xs text-muted-foreground mt-0.5">{t('es.hrdOnAirHint')}</span>
                </span>
              </label>

              <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
                <Label className="text-sm">{t('es.uploadTiming')}</Label>
                <Select
                  value={hrdlog.upload_mode || 'immediate'}
                  onValueChange={(v) => setHrdlog({ upload_mode: v })}
                >
                  <SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="immediate">{t('es.immediate')}</SelectItem>
                    <SelectItem value="delayed">{t('es.delayed')}</SelectItem>
                    <SelectItem value="on_close">{t('es.onClose')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>

              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={testHrdlog} disabled={hrdlogTesting}>
                  <UploadCloud className="size-3.5" /> {hrdlogTesting ? t('es.testing') : t('es.testConn')}
                </Button>
                {hrdlogTest && (
                  <span className={cn('text-xs', hrdlogTest.ok ? 'text-success' : 'text-danger')}>
                    {hrdlogTest.msg}
                  </span>
                )}
              </div>
            </div>
          </div>
        ) : extSvcTab === 'hamlog' ? (
          <div className="space-y-4 max-w-2xl">
            <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
              <Label className="text-sm">{t('es.apiKey')}</Label>
              <Input
                type="password"
                value={hamlog.api_key}
                onChange={(e) => setHamlog({ api_key: e.target.value })}
                className="text-xs"
              />
            </div>
            <div className="text-[10px] text-muted-foreground -mt-1">
              {t('es.hamlogHint')}{' '}
              <button type="button" className="underline hover:text-foreground"
                onClick={() => BrowserOpenURL('https://hamlog.online/account/agent.php')}>
                hamlog.online/account/agent.php
              </button>
            </div>

            <div className="border-t border-border/60 pt-3 space-y-3">
              {/* The site stopped issuing API keys and its upload API takes
                  nothing else, so an auto-upload switch here would arm something
                  that can only fail. Their confirmations still arrive as a file,
                  which never needed a key. */}
              <p className="text-xs rounded-md border border-warning/40 bg-warning/10 px-2 py-1.5 text-warning-muted-foreground">
                {t('es.hamlogClosed')}
              </p>
              <label className="flex items-center gap-2 text-sm cursor-not-allowed opacity-50">
                <Checkbox checked={false} disabled />
                {t('es.autoUpload')}
              </label>

              <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
                <Label className="text-sm">{t('es.uploadTiming')}</Label>
                <Select
                  value={hamlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
                  onValueChange={(v) => setHamlog({ upload_mode: v })}
                >
                  <SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="immediate">{t('es.immediate')}</SelectItem>
                    <SelectItem value="delayed">{t('es.delayed')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>

              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={testHamlog} disabled={hamlogTesting}>
                  <UploadCloud className="size-3.5" /> {hamlogTesting ? t('es.testing') : t('es.hamlogCheckKey')}
                </Button>
                {hamlogTest && (
                  <span className={cn('text-xs', hamlogTest.ok ? 'text-success' : 'text-danger')}>
                    {hamlogTest.msg}
                  </span>
                )}
              </div>
            </div>
          </div>
        ) : extSvcTab === 'hamqth' ? (
          <div className="space-y-4 max-w-2xl">
            <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
              <Label className="text-sm">{t('es.username')}</Label>
              <Input value={hamqth.username} onChange={(e) => setHamqth({ username: e.target.value })} className="text-xs w-64" />
              <Label className="text-sm">{t('es.password')}</Label>
              <Input type="password" value={hamqth.password} onChange={(e) => setHamqth({ password: e.target.value })} className="text-xs w-64" />
              <Label className="text-sm">{t('es.hamqthCall')}</Label>
              <Input value={hamqth.callsign} onChange={(e) => setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} />
            </div>
            <div className="text-[10px] text-muted-foreground -mt-1">{t('es.hamqthHint')}</div>

            <div className="border-t border-border/60 pt-3 space-y-3">
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox checked={hamqth.auto_upload} onCheckedChange={(c) => setHamqth({ auto_upload: !!c })} />
                {t('es.autoUpload')}
              </label>
              <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
                <Label className="text-sm">{t('es.uploadTiming')}</Label>
                <Select value={hamqth.upload_mode === 'delayed' ? 'delayed' : 'immediate'} onValueChange={(v) => setHamqth({ upload_mode: v })}>
                  <SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="immediate">{t('es.immediate')}</SelectItem>
                    <SelectItem value="delayed">{t('es.delayed')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>
              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={testHamqth} disabled={hamqthTesting}>
                  <UploadCloud className="size-3.5" /> {hamqthTesting ? t('es.testing') : t('es.testConn')}
                </Button>
                {hamqthTest && (
                  <span className={cn('text-xs', hamqthTest.ok ? 'text-success' : 'text-danger')}>{hamqthTest.msg}</span>
                )}
              </div>
            </div>
          </div>
        ) : extSvcTab === 'cloudlog' ? (
          <div className="space-y-4 max-w-2xl">
            <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
              <Label className="text-sm">{t('es.cloudlogUrl')}</Label>
              <Input
                value={cloudlog.url}
                onChange={(e) => setCloudlog({ url: e.target.value })}
                placeholder={t('es.cloudlogUrlPh')}
                className="font-mono text-xs"
              />
              <Label className="text-sm">{t('es.apiKey')}</Label>
              <Input
                type="password"
                value={cloudlog.api_key}
                onChange={(e) => setCloudlog({ api_key: e.target.value })}
                placeholder={t('es.cloudlogKeyPh')}
                className="text-xs"
              />
              <Label className="text-sm">{t('es.cloudlogStationId')}</Label>
              <Input
                value={cloudlog.station_id}
                onChange={(e) => setCloudlog({ station_id: e.target.value })}
                placeholder="1"
                className="font-mono text-xs w-24"
              />
            </div>
            <div className="text-[10px] text-muted-foreground -mt-1">
              {t('es.cloudlogHint')}
            </div>

            <div className="border-t border-border/60 pt-3 space-y-3">
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox
                  checked={cloudlog.auto_upload}
                  onCheckedChange={(c) => setCloudlog({ auto_upload: !!c })}
                />
                {t('es.autoUpload')}
              </label>

              <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
                <Label className="text-sm">{t('es.uploadTiming')}</Label>
                <Select
                  value={cloudlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
                  onValueChange={(v) => setCloudlog({ upload_mode: v })}
                >
                  <SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="immediate">{t('es.immediate')}</SelectItem>
                    <SelectItem value="delayed">{t('es.delayed')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>
              {/* No "on close" option: unlike the other services, Cloudlog has no
                  per-QSO status column in the logbook, so there is nothing to
                  select a backlog from at shutdown. */}

              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={testCloudlog} disabled={cloudlogTesting}>
                  <UploadCloud className="size-3.5" /> {cloudlogTesting ? t('es.testing') : t('es.testConn')}
                </Button>
                {cloudlogTest && (
                  <span className={cn('text-xs', cloudlogTest.ok ? 'text-success' : 'text-danger')}>
                    {cloudlogTest.msg}
                  </span>
                )}
              </div>
            </div>
          </div>
        ) : extSvcTab === 'eqsl' ? (
          <div className="space-y-4 max-w-2xl">
            <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
              <Label className="text-sm">{t('es.usernameCall')}</Label>
              <Input
                value={eqsl.username}
                onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
                placeholder={t('es.eqslUserPh')}
                className="font-mono text-xs"
              />
              <Label className="text-sm">{t('es.password')}</Label>
              <Input
                type="password"
                value={eqsl.password}
                onChange={(e) => setEqsl({ password: e.target.value })}
                placeholder={t('es.eqslPwPh')}
                className="text-xs"
              />
              <Label className="text-sm">{t('es.qthNick')}</Label>
              <Input
                value={eqsl.qth_nickname}
                onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
                placeholder={t('es.qthNickPh')}
                className="text-xs"
              />
            </div>
            <div className="text-[10px] text-muted-foreground -mt-1">
              {t('es.eqslHint')}
            </div>

            <div className="border-t border-border/60 pt-3 space-y-3">
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox
                  checked={eqsl.auto_upload}
                  onCheckedChange={(c) => setEqsl({ auto_upload: !!c })}
                />
                {t('es.autoUpload')}
              </label>

              <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
                <Label className="text-sm">{t('es.uploadTiming')}</Label>
                <Select
                  value={eqsl.upload_mode || 'immediate'}
                  onValueChange={(v) => setEqsl({ upload_mode: v })}
                >
                  <SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="immediate">{t('es.immediate')}</SelectItem>
                    <SelectItem value="delayed">{t('es.delayed')}</SelectItem>
                    <SelectItem value="on_close">{t('es.onClose')}</SelectItem>
                  </SelectContent>
                </Select>
              </div>

              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={testEqsl} disabled={eqslTesting}>
                  <UploadCloud className="size-3.5" /> {eqslTesting ? t('es.testing') : t('es.testConn')}
                </Button>
                {eqslTest && (
                  <span className={cn('text-xs', eqslTest.ok ? 'text-success' : 'text-danger')}>
                    {eqslTest.msg}
                  </span>
                )}
              </div>
            </div>
          </div>
        ) : extSvcTab === 'lotw' ? (
          <div className="space-y-4 max-w-2xl">
            <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
              <Label className="text-sm">{t('es.lotwUser')}</Label>
              <Input
                value={lotw.username}
                onChange={(e) => setLotw({ username: e.target.value })}
                placeholder={t('es.lotwUserPh')}
                className="font-mono text-xs"
              />
              <Label className="text-sm">{t('es.lotwPw')}</Label>
              <Input
                type="password"
                value={lotw.password}
                onChange={(e) => setLotw({ password: e.target.value })}
                placeholder={t('es.lotwPwPh')}
                className="text-xs"
              />
              <Label className="text-sm">{t('es.tqslPath')}</Label>
              <Input
                value={lotw.tqsl_path}
                onChange={(e) => setLotw({ tqsl_path: e.target.value })}
                placeholder="C:\Program Files (x86)\TrustedQSL\tqsl.exe"
                className="font-mono text-xs"
              />
              <Label className="text-sm">{t('es.stationLoc')}</Label>
              <div className="flex items-center gap-2">
                <Select value={lotw.station_location || '_'} onValueChange={(v) => setLotw({ station_location: v === '_' ? '' : v })}>
                  <SelectTrigger className="h-8 flex-1"><SelectValue placeholder={t('es.pickLoc')} /></SelectTrigger>
                  <SelectContent>
                    {stationLocations.length === 0 && <SelectItem value="_" disabled>{t('es.noLoc')}</SelectItem>}
                    {stationLocations.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
                  </SelectContent>
                </Select>
                <Button variant="ghost" size="sm" className="h-8 px-2" onClick={refreshLocations} title={t('es.reloadLoc')}>
                  <ArrowDown className="size-3.5 rotate-90" />
                </Button>
              </div>
              <Label className="text-sm">{t('es.forceCall')}</Label>
              <div>
                <Input
                  value={lotw.force_station_callsign}
                  onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
                  placeholder={t('es.lotwForcePh')}
                  className="font-mono uppercase w-64"
                />
                <div className="text-[10px] text-muted-foreground mt-1">
                  {t('es.lotwForceHint')}
                </div>
              </div>
              <Label className="text-sm">{t('es.keyPw')}</Label>
              <Input
                type="password"
                value={lotw.key_password}
                onChange={(e) => setLotw({ key_password: e.target.value })}
                placeholder={t('es.keyPwPh')}
                className="text-xs"
              />
              <Label className="text-sm">{t('es.considerUnsent')}</Label>
              <div>
                <div className="flex items-center gap-4">
                  {(['N', 'R'] as const).map((f) => {
                    const flags = lotw.upload_flags ?? [];
                    const checked = flags.includes(f);
                    return (
                      <label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
                        <Checkbox
                          checked={checked}
                          onCheckedChange={(c) => {
                            const next = c
                              ? Array.from(new Set([...flags, f]))
                              : flags.filter((x) => x !== f);
                            setLotw({ upload_flags: next });
                          }}
                        />
                        {f === 'N' ? t('es.flagN') : t('es.flagR')}
                      </label>
                    );
                  })}
                </div>
                <div className="text-[10px] text-muted-foreground mt-1">
                  {t('es.lotwUnsentHint')}
                </div>
              </div>
            </div>

            <div className="border-t border-border/60 pt-3 space-y-3">
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox
                  checked={lotw.auto_upload}
                  onCheckedChange={(c) => setLotw({ auto_upload: !!c, upload_mode: 'on_close' })}
                />
                {t('es.lotwAutoClose')}
              </label>
              <label className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox
                  checked={lotw.write_log}
                  onCheckedChange={(c) => setLotw({ write_log: !!c })}
                />
                {t('es.lotwWriteLog')}
              </label>

              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={testLotw} disabled={lotwTesting}>
                  <UploadCloud className="size-3.5" /> {lotwTesting ? t('es.testing') : t('es.testConn')}
                </Button>
                {lotwTest && (
                  <span className={cn('text-xs', lotwTest.ok ? 'text-success' : 'text-danger')}>
                    {lotwTest.msg}
                  </span>
                )}
              </div>
            </div>

            {/* LoTW user list — powers the colour-coded "LoTW" badge next to the
                entered callsign (green < 1 week · amber 1–4 weeks · red > 30 days). */}
            <div className="border-t border-border/60 pt-3 space-y-2">
              <Label className="text-sm font-medium">{t('lotw.usersTitle')}</Label>
              <p className="text-[11px] text-muted-foreground">{t('lotw.usersHint')}</p>
              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={downloadLotwUsers} disabled={lotwUsersBusy}>
                  <ArrowDown className="size-3.5" /> {lotwUsersBusy ? t('lotw.usersDownloading') : t('lotw.usersDownload')}
                </Button>
                <span className="text-xs text-muted-foreground">
                  {lotwUsers.count > 0
                    ? t('lotw.usersLoaded', { n: lotwUsers.count.toLocaleString(), date: lotwUsers.updated ? new Date(lotwUsers.updated).toLocaleDateString() : '?' })
                    : t('lotw.usersNone')}
                </span>
              </div>
            </div>
          </div>
        ) : extSvcTab === 'pota' ? (
          <div className="space-y-4 max-w-2xl">
            <p className="text-xs text-muted-foreground leading-relaxed">
              {t('es.potaHint')}
            </p>
            <div className="grid grid-cols-[170px_1fr] gap-3 items-start">
              <Label className="text-sm pt-2">{t('es.sessionToken')}</Label>
              <Textarea
                value={potaToken}
                onChange={(e) => setPotaToken(e.target.value)}
                placeholder="eyJ… (Authorization header from pota.app)"
                className="font-mono text-[11px] h-20"
              />
            </div>
            <div className="flex items-center gap-3">
              <Button onClick={savePotaToken} disabled={potaBusy}>
                {potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> {t('es.saving')}</> : t('es.saveToken')}
              </Button>
              <span className="text-[11px] text-muted-foreground">
                {t('es.potaThen')}
              </span>
            </div>
            {potaResult && (
              <div className={cn('text-xs rounded-md px-3 py-2 border',
                potaResult.ok ? 'border-success-border bg-success-muted text-success-muted-foreground' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
                {potaResult.msg}
              </div>
            )}
          </div>
        ) : (
          <div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
            <Construction className="size-10 opacity-30" />
            <div className="text-sm font-semibold text-foreground/70">
              {t('es.comingSoon', { name: TABS.find((tab) => tab.k === extSvcTab)?.label ?? '' })}
            </div>
            <div className="text-xs">{t('es.notWired')}</div>
          </div>
        )}
      </>
    );
  }

  function DatabasePanel() {
    // Compacting: which database is running, and what the last one saved.
    // SQLite frees pages inside the file and never shrinks it, so this is the
    // only thing that gives the disk space back after a large delete.
    const [compacting, setCompacting] = useState('');
    const [compactMsg, setCompactMsg] = useState<Record<string, string>>({});
    function compact(target: 'settings' | 'logbook') {
      setCompacting(target);
      setCompactMsg((m) => ({ ...m, [target]: '' }));
      CompactDatabase(target)
        .then((r: any) => {
          const msg = r?.backend === 'mysql'
            ? t('db.compactMysqlDone')
            : t('db.compactDone', { before: fmtBytes(r?.before ?? 0), after: fmtBytes(r?.after ?? 0) });
          setCompactMsg((m) => ({ ...m, [target]: msg }));
        })
        .catch((e: any) => setErr(String(e?.message ?? e)))
        .finally(() => setCompacting(''));
    }
    async function refreshDb() { try { setDbSettings(await GetDatabaseSettings() as any); } catch {} }
    async function refreshBackend() { try { setBackendStatus(await GetDBBackendStatus() as any); } catch {} }
    // The chosen file is persisted (config.json) the moment it's picked; dbMsg
    // holds the pending path so the banner can offer a one-click restart to load
    // it (the DB pointer is only read at startup).
    async function openExisting() {
      try {
        const p = await PickOpenDatabase();
        if (!p) return;
        await OpenDatabase(p);
        await refreshDb();
        setDbMsg(p);
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    }
    async function saveCopy() {
      try {
        const p = await PickSaveDatabase();
        if (!p) return;
        await MoveDatabase(p);
        await refreshDb();
        setDbMsg(p);
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    }
    async function createNew() {
      try {
        const p = await PickSaveDatabase();
        if (!p) return;
        await CreateDatabase(p);
        await refreshDb();
        setDbMsg(p);
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    }
    // Rename the CURRENT database (keeps all config), unlike New database which
    // starts empty. The old file is removed on the next launch.
    async function renameDb() {
      try {
        const p = await PickSaveDatabase();
        if (!p) return;
        await RenameDatabase(p);
        await refreshDb();
        setDbMsg(p);
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    }
    async function resetDefault() {
      try {
        await ResetDatabaseToDefault();
        await refreshDb();
        setDbMsg(dbSettings.default_path || '');
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    }
    function revealFolder() { RevealDataFolder().catch((e: any) => setErr(String(e?.message ?? e))); }
    // Rename/relocate THIS profile's logbook, carrying the QSOs across.
    async function renameLogbook() {
      try {
        const p = await PickSaveDatabase();
        if (!p) return;
        await RenameLogbook(p);
        setMysqlField({ enabled: false, sqlite_path: p });
        setRestartMsg(t('db.logbookRenamed'));
        await refreshBackend();
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    }
    // Switching the logbook backend applies immediately (no restart): the local
    // SQLite file always stays the config store; only the QSO logbook moves.
    function useLocalLogbook() {
      SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: '' } as any)
        .then(async () => { setMysqlField({ enabled: false, sqlite_path: '' }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
        .catch((e: any) => setErr(String(e?.message ?? e)));
    }
    // Point THIS profile's logbook at a SQLite file (settings stay in the
    // settings db). A NEW name is created + migrated empty; an existing file is
    // opened with its QSOs.
    async function newLogbook() {
      try {
        const p = await PickSaveDatabase();
        if (!p) return;
        await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
        setMysqlField({ enabled: false, sqlite_path: p });
        setRestartMsg(t('db.switchedSqliteFile'));
        await refreshBackend();
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    }
    async function openLogbook() {
      try {
        const p = await PickOpenDatabase();
        if (!p) return;
        await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
        setMysqlField({ enabled: false, sqlite_path: p });
        setRestartMsg(t('db.switchedSqliteFile'));
        await refreshBackend();
      } catch (e: any) { setErr(String(e?.message ?? e)); }
    }
    function connectMysql() {
      SaveMySQLSettings(mysqlCfg as any)
        .then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
        .catch((e: any) => setErr(String(e?.message ?? e)));
    }
    return (
      <>
        <SectionHeader title={t('sec.database')} />

        {/* Settings / application database (settings + profiles) — always shown,
            distinct from the QSO logbook so the two are never confused. */}
        <div className="space-y-2 max-w-2xl mb-5 border border-border/60 rounded-md p-3">
          <Label>{t('db.appDb')}</Label>
          <div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
            {dbSettings.path || '—'}
            {dbSettings.is_custom
              ? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
              : <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
          </div>
          <p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
          <div className="flex flex-wrap gap-2">
            <Button variant="outline" size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
            <Button variant="outline" size="sm" onClick={openExisting}><Database className="size-3.5" /> {t('db.openExisting')}</Button>
            <Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
            <Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
            {/* Pushed right: these two act on the file that is already there,
                while the four on the left change WHICH file is in use. */}
            <Button variant="outline" size="sm" className="ml-auto" onClick={() => compact('settings')} disabled={compacting !== ''}>
              {compacting === 'settings' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
            </Button>
            <Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
            {dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
          </div>
          {compactMsg.settings && <p className="text-[11px] text-success">{compactMsg.settings}</p>}
          {/* The DB pointer is only read at startup, so offer the restart inline. */}
          {dbMsg && (
            <div className="text-[11px] text-success space-y-1 pt-1">
              <div>{t('db.savedRestart')} <span className="font-mono break-all">{dbMsg}</span></div>
              <Button size="sm" className="h-7" onClick={() => RestartApp().catch((e: any) => setErr(String(e?.message ?? e)))}>
                <Power className="size-3.5" /> {t('db.restartNow')}
              </Button>
              <span className="ml-2 text-muted-foreground">{t('db.restartHint')}</span>
            </div>
          )}
        </div>

        {/* Logbook (QSOs) — this profile: default SQLite file, a dedicated file, or MySQL. */}
        <div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
          <Label className="text-sm">{t('db.logbookLabel')}</Label>
          <Select
            value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
            onValueChange={(v) => {
              setRestartMsg('');
              if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
              useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
            }}
          >
            <SelectTrigger className="h-8 w-72" title={t('gen.iaruHint')}><SelectValue /></SelectTrigger>
            <SelectContent>
              <SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
              <SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
            </SelectContent>
          </Select>
        </div>

        {/* Compact active-backend confirmation / MySQL-fallback warning. */}
        {backendStatus && (
          backendStatus.fallback ? (
            <div className="max-w-2xl mb-4 text-xs bg-warning-muted border border-warning-border text-warning-muted-foreground rounded-md px-3 py-2">
              {t('db.fallback')}
              <div className="font-mono text-[10px] mt-1 break-all">{backendStatus.error}</div>
            </div>
          ) : (
            <div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
              {t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
            </div>
          )
        )}

        {/* SQLite logbook file: default logbook.db, or a dedicated file for this profile. */}
        {!mysqlCfg.enabled && (
        <div className="space-y-3 max-w-2xl">
          <div className="space-y-1">
            <Label>{t('db.logbookFile')}</Label>
            <div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
              {mysqlCfg.sqlite_path || dbSettings.logbook_default_path || '—'}
              {mysqlCfg.sqlite_path
                ? <span className="ml-2 text-[10px] text-success">{t('db.dedicatedFile')}</span>
                : <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
            </div>
            <p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
          </div>
          <div className="flex flex-wrap gap-2">
            <Button variant="outline" size="sm" onClick={newLogbook}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
            <Button variant="outline" size="sm" onClick={openLogbook}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
            <Button variant="outline" size="sm" onClick={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
            <Button variant="outline" size="sm" className="ml-auto" onClick={() => compact('logbook')} disabled={compacting !== ''}>
              {compacting === 'logbook' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
            </Button>
            {mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
          </div>
          {compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
        </div>
        )}

        {/* MySQL: shared logbook connection (multi-operator) */}
        {mysqlCfg.enabled && (
        <div className="space-y-3 max-w-2xl">
          <div className="grid grid-cols-[130px_1fr] gap-2 items-center">
            <Label className="text-sm">{t('db.host')}</Label>
            <Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
            <Label className="text-sm">{t('db.port')}</Label>
            <PortInput className="h-8 w-28 font-mono" value={mysqlCfg.port} fallback={3306} onChange={(n) => setMysqlField({ port: n })} />
            <Label className="text-sm">{t('db.database')}</Label>
            <Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
            <Label className="text-sm">{t('db.user')}</Label>
            <Input className="h-8" value={mysqlCfg.user} onChange={(e) => setMysqlField({ user: e.target.value })} />
            <Label className="text-sm">{t('es.password')}</Label>
            <Input type="password" className="h-8" value={mysqlCfg.password} onChange={(e) => setMysqlField({ password: e.target.value })} />
          </div>
          <div className="flex items-center gap-3 flex-wrap">
            <Button variant="outline" size="sm" className="h-8"
              onClick={() => { setMysqlMsg(t('db.testing')); TestMySQLConnection(mysqlCfg as any).then(() => setMysqlMsg(t('db.connectedReady'))).catch((e: any) => setMysqlMsg(t('db.failed') + String(e?.message ?? e))); }}>
              {t('db.testCreate')}
            </Button>
            <Button size="sm" className="h-8" onClick={connectMysql}>{t('db.connectUse')}</Button>
            {/* Compacting a shared logbook is OPTIMIZE TABLE, and it rebuilds the
                table with everyone else waiting on it — hence the warning, and
                hence its place here rather than beside the connection buttons of
                a file only this operator uses. */}
            <Button variant="outline" size="sm" className="h-8 ml-auto" title={t('db.compactMysqlWarn')}
              onClick={() => compact('logbook')} disabled={compacting !== ''}>
              {compacting === 'logbook' ? <Loader2 className="size-3.5 animate-spin" /> : <Minimize2 className="size-3.5" />} {t('db.compact')}
            </Button>
            <span className="text-[11px] text-muted-foreground">{mysqlMsg}</span>
          </div>
          {compactMsg.logbook && <p className="text-[11px] text-success">{compactMsg.logbook}</p>}
        </div>
        )}

        {restartMsg && <div className="max-w-2xl mt-3 text-[11px] text-success">{restartMsg}</div>}

        {/* Backup settings, merged into this Database section. */}
        <div className="border-t border-border/60 mt-6 pt-5">
          {BackupPanel()}
        </div>
      </>
    );
  }

  function AudioPanel() {
    // Names the CAT backend the operator has configured, so the PTT dropdown can
    // say "CAT — Icom CI-V (USB)" rather than the flatly wrong "CAT (OmniRig)".
    const catBackendLabel = ({
      omnirig: t('cat.optOmnirig'),
      flex: t('cat.optFlex'),
      icom: t('cat.optIcom'),
      'icom-net': t('cat.optIcomNet'),
      tci: t('cat.optTci'),
      yaesu: t('cat.optYaesu'),
      xiegu: t('cat.optXiegu'),
    } as Record<string, string>)[catCfg.backend] ?? '';

    // The radio-over-network entry, which ListAudioInputDevices and
    // ListAudioOutputDevices add when the CAT link can carry audio. It is a
    // real choice for two of the four fields and nonsense for the other two.
    const NET_DEVICE = 'net:radio';
    const soundCardsOnly = (devs: AudioDev[]) => devs.filter((d) => d.id !== NET_DEVICE);
    const fromRadioIsNetwork = audioCfg.from_radio === NET_DEVICE;

    const deviceSelect = (
      field: keyof AudioSettings,
      devices: AudioDev[],
      placeholder: string,
    ) => (
      <Select
        value={(audioCfg[field] as string) || '_'}
        onValueChange={(v) => setAudioField({ [field]: v === '_' ? '' : v } as any)}
      >
        <SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
        <SelectContent>
          <SelectItem value="_">{t('aud.noneDefault')}</SelectItem>
          {devices.map((d) => (
            <SelectItem key={d.id} value={d.id}>{d.name}{d.default ? '  ' + t('aud.defaultTag') : ''}</SelectItem>
          ))}
        </SelectContent>
      </Select>
    );
    return (
      <>
        <div className="flex items-start justify-between">
          <SectionHeader
            title={t('hw.audioVoice')}/>
          <Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
            {t('aud.refreshDevices')}
          </Button>
        </div>

        <div className="space-y-3 max-w-2xl">
          <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
            <Label className="text-sm">{t('aud.fromRadio')}</Label>
            {deviceSelect('from_radio', audioInputs, t('aud.phFromRadio'))}
            <Label className="text-sm">{t('aud.toRadio')}</Label>
            {deviceSelect('to_radio', audioOutputs, t('aud.phToRadio'))}
            {/* The radio is offered for the two fields it can BE — where the
                received audio comes from, and where the voice keyer sends
                its messages — and taken out of the other two. It is not a
                microphone: choosing it here would record the station you are
                listening to instead of your own voice. And it is not a pair of
                speakers: the audio going that way is transmit audio. */}
            <Label className="text-sm">{t('aud.recMic')}</Label>
            {deviceSelect('recording_device', soundCardsOnly(audioInputs), t('aud.phRecMic'))}
            <Label className="text-sm">{t('aud.listening')}</Label>
            {deviceSelect('listening_device', soundCardsOnly(audioOutputs), t('aud.phListening'))}
          </div>
          <p className="text-[11px] text-muted-foreground">
            <strong>{t('aud.fromRadioShort')}</strong> {t('aud.explainFrom')}{' '}
            <strong>{t('aud.toRadioShort')}</strong> {t('aud.explainTo')}
          </p>

          {/* The radio is one of the devices above when it can carry its own
              audio — see ListAudioInputDevices. What used to be here was a test
              bench: open the stream, record ten seconds, key a tone. It settled
              how TCI works and has no business in front of an operator now that
              choosing the device is the whole of the setup. */}
          <div className="flex items-center gap-3">
            <Button
              variant={monitorOn ? 'default' : 'outline'}
              size="sm"
              className="h-8"
              onClick={toggleMonitor}
              disabled={!monitorOn && (!audioCfg.from_radio || fromRadioIsNetwork)
                && !(catCfg.backend === 'icom-net' && (catCfg as any).icom_net_audio)}
              title={fromRadioIsNetwork ? t('aud.monitorNoTci') : t('aud.monitorTitle')}
            >
              {monitorOn ? t('aud.stopListening') : t('aud.listenRadio')}
            </Button>
            <span className="text-[11px] text-muted-foreground">
              {monitorOn ? t('aud.monitorOn') : t('aud.monitorHint')}
            </span>
          </div>
          <div className="flex items-center gap-3">
            <Button
              variant={txOn ? 'destructive' : 'outline'}
              size="sm"
              className="h-8"
              onClick={toggleTX}
              disabled={!txOn && !audioCfg.to_radio}
              title={t('aud.txTitle')}
            >
              {txOn ? t('aud.stopTalk') : t('aud.talkRadio')}
            </Button>
            <span className="text-[11px] text-muted-foreground">
              {txOn ? t('aud.txOn') : t('aud.txHint')}
            </span>
          </div>
        </div>

        <div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
          <h4 className="text-sm font-semibold text-foreground">{t('aud.recorder')}</h4>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
            {t('aud.recordEvery')}
          </label>
          <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
            <Label className="text-sm">{t('aud.recFolder')}</Label>
            <div className="flex gap-2">
              <Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
                placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
              <Button variant="outline" size="sm" className="h-8 shrink-0"
                onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
                {t('aud.browse')}
              </Button>
            </div>
            <Label className="text-sm">{t('aud.preroll')}</Label>
            <Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
              onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
              className="h-8 w-24 font-mono" />
            <Label className="text-sm">{t('aud.format')}</Label>
            <Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
              <SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
              <SelectContent>
                <SelectItem value="wav">{t('aud.wav')}</SelectItem>
                <SelectItem value="mp3">{t('aud.mp3')}</SelectItem>
              </SelectContent>
            </Select>
            <Label className="text-sm">{t('aud.fromLevel')}</Label>
            <div className="flex items-center gap-2">
              <input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
                onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ from_gain: v }); AudioApplyLevels(v, audioCfg.mic_gain); }} className="w-48 accent-primary" />
              <span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
            </div>
            <Label className="text-sm">{t('aud.qsoPlayLevel')}</Label>
            <div className="flex items-center gap-2">
              <input type="range" min={10} max={300} step={5} value={audioCfg.qso_play_gain}
                onChange={(e) => setAudioField({ qso_play_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
              <span className="font-mono text-xs w-12 text-right">{audioCfg.qso_play_gain}%</span>
            </div>
            <Label className="text-sm">{t('aud.micLevel')}</Label>
            <div className="flex items-center gap-2">
              <input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
                onChange={(e) => { const v = parseInt(e.target.value, 10); setAudioField({ mic_gain: v }); AudioApplyLevels(audioCfg.from_gain, v); }} className="w-48 accent-primary" />
              <span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
            </div>
          </div>
          <p className="text-xs text-muted-foreground">{t('aud.levelHint')}</p>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
            {t('aud.autoSend')}
          </label>
        </div>

        <div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
          <h4 className="text-sm font-semibold text-foreground">{t('aud.dvkTitle')}</h4>
          <div className="rounded-md border border-border/60 p-2.5 space-y-2">
            <div className="grid grid-cols-[120px_1fr] gap-2 items-center">
              <Label className="text-sm">{t('aud.pttMethod')}</Label>
              <div className="flex gap-2 items-center">
                <Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
                  <SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
                  <SelectContent>
                    <SelectItem value="none">{t('aud.pttNone')}</SelectItem>
                    {/* This has ALWAYS driven whichever CAT backend is active —
                        it calls the generic manager, and every backend (OmniRig,
                        FlexRadio, Icom CI-V over USB and over the network, TCI)
                        implements SetPTT. The label said "CAT (OmniRig)", so
                        operators on a CI-V rig concluded there was no CAT PTT for
                        them and reached for RTS/DTR or VOX instead. Name the
                        backend actually configured. */}
                    <SelectItem value="cat">{t('aud.pttCat')}{catBackendLabel ? ` — ${catBackendLabel}` : ''}</SelectItem>
                    <SelectItem value="rts">{t('aud.pttRts')}</SelectItem>
                    <SelectItem value="dtr">{t('aud.pttDtr')}</SelectItem>
                  </SelectContent>
                </Select>
                {audioCfg.ptt_method !== 'none' && (
                  <Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT(audioCfg as any).catch((e: any) => setDvkErr(t('aud.errPttTest') + String(e?.message ?? e))); }}>
                    {t('aud.testPtt')}
                  </Button>
                )}
              </div>
              {/* Kenwood only, because only a Kenwood acts on it: TX1 is that
                  family's second transmit command. Every other backend keys the
                  one way it knows, so showing the box there would be a switch
                  that changes nothing — the same dead furniture as ANT2 on a
                  radio with one socket. */}
              {audioCfg.ptt_method === 'cat' && catCfg.backend === 'kenwood' && (
                <>
                  <span />
                  <label className="flex items-start gap-2 text-sm cursor-pointer" title={t('aud.pttDataHint')}>
                    <Checkbox className="mt-0.5" checked={!!audioCfg.ptt_data}
                      onCheckedChange={(c) => setAudioField({ ptt_data: !!c })} />
                    <span>{t('aud.pttData')}</span>
                  </label>
                </>
              )}
              {(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
                <>
                  <Label className="text-sm">{t('aud.pttPort')}</Label>
                  <div className="flex gap-2 items-center">
                    <Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
                      <SelectTrigger className="h-8 w-44"><SelectValue placeholder={t('aud.pickPort')} /></SelectTrigger>
                      <SelectContent>
                        <SelectItem value="_">{t('aud.selectPort')}</SelectItem>
                        {wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
                      </SelectContent>
                    </Select>
                    <Button variant="ghost" size="sm" className="h-8 text-[11px]"
                      onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
                      {t('aud.refresh')}
                    </Button>
                  </div>
                </>
              )}
            </div>
          </div>
          {/* Playback level. Nothing else could raise it: the message went to the
              rig exactly as recorded, so a quietly recorded mic drove the radio
              quietly and the only remedies were the rig's own USB input menu or
              the Windows mixer. */}
          <div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2">
            <Label className="text-sm">{t('aud.txLevel')}</Label>
            <div className="flex items-center gap-2">
              <input type="range" min={10} max={400} step={5} value={audioCfg.tx_gain}
                onChange={(e) => setAudioField({ tx_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
              <span className="font-mono text-xs w-12 text-right">{audioCfg.tx_gain}%</span>
            </div>
          </div>
          <p className="text-xs text-muted-foreground">{t('aud.txLevelHint')}</p>
          {dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
          <div className="space-y-1.5">
            {dvkMsgs.map((m) => {
              const recHere = dvkStat.recording && dvkStat.rec_slot === m.slot;
              const recBusy = dvkStat.recording && !recHere;
              return (
                <div key={m.slot} className="flex items-center gap-2">
                  <span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
                  <Input
                    className="h-8 flex-1"
                    placeholder={t('aud.msgPlaceholder', { n: m.slot })}
                    value={m.label}
                    onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
                    onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
                  />
                  <span className="w-16 text-[11px] text-muted-foreground text-right">
                    {m.has_audio ? `✓ ${m.duration_sec.toFixed(1)}s` : '—'}
                  </span>
                  <Button
                    type="button"
                    variant={recHere ? 'destructive' : 'outline'} size="sm" className="h-8 w-28 shrink-0 select-none touch-none"
                    disabled={recBusy}
                    onPointerDown={(e) => {
                      e.preventDefault();
                      (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
                      setDvkErr('');
                      DVKStartRecord(m.slot).catch((err) => setDvkErr(t('aud.errRecord') + String(err?.message ?? err)));
                    }}
                    onPointerUp={() => {
                      DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr(t('aud.errSave') + String(err?.message ?? err)));
                    }}
                  >
                    {recHere ? t('aud.recordingNow') : t('aud.holdRec')}
                  </Button>
                  <Button
                    type="button"
                    variant="outline" size="sm" className="h-8 w-20 shrink-0"
                    disabled={!m.has_audio || dvkStat.recording}
                    onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr(t('aud.errPlay') + String(err?.message ?? err))))}
                  >
                    {dvkStat.playing ? t('aud.stop') : t('aud.play')}
                  </Button>
                  <Button
                    type="button"
                    variant="outline" size="sm" className="h-8 w-9 shrink-0 px-0 text-danger hover:text-danger"
                    title={t('aud.deleteMsg')}
                    disabled={!m.has_audio || dvkStat.recording || dvkStat.playing}
                    onClick={() => DVKDelete(m.slot).then(reloadDvk).catch((err) => setDvkErr(String(err?.message ?? err)))}
                  >
                    <Trash2 className="size-3.5" />
                  </Button>
                </div>
              );
            })}
          </div>
        </div>
      </>
    );
  }

  function GeneralPanel() {
    const { lang, setLang, t } = useI18n();
    return (
      <>
        <SectionHeader title={t('sec.general')} />
        <div className="space-y-3 max-w-3xl">
          <div className="flex items-center gap-3">
            <Label className="text-sm w-40">{t('settings.language')}</Label>
            <div className="inline-flex rounded-md border border-border overflow-hidden">
              {([['en', FlagGB, 'English'], ['fr', FlagFR, 'Français']] as [Lang, typeof FlagGB, string][]).map(([code, Flag, label]) => (
                <button key={code} type="button" onClick={() => setLang(code)}
                  className={cn('flex items-center gap-2 px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0',
                    lang === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
                  <Flag className="w-5 rounded-[2px] border border-border/30" />
                  {label}
                </button>
              ))}
            </div>
          </div>

          {/* Display only. The log is stored in ISO/ADIF whatever is chosen here
              — that is what ADIF specifies and what sorts correctly, and a
              stored format that followed a preference would make the log
              unreadable the day the preference changed. */}
          <div className="flex items-center gap-3">
            <Label className="text-sm w-40">{t('gen.dateFormat')}</Label>
            <div className="inline-flex rounded-md border border-border overflow-hidden">
              {([['iso', t('gen.dateStandard'), '2026-07-30 14:25'],
                 ['fr', t('gen.dateFR'), '30-07-2026 14:25'],
                 ['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
                <button key={code} type="button" title={sample}
                  onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
                  className={cn('px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0',
                    dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
                  {label}
                </button>
              ))}
            </div>
          </div>

          <div className="flex items-center gap-2 text-sm">
            {/* The IARU region drives the band edges and mode segments the band
                maps draw — Region 2's 40 m runs to 7300, Region 1's stops at
                7200 — and nothing else: spots and logging are region-blind. */}
            <Label className="text-sm w-40 shrink-0">{t('gen.iaruRegion')}</Label>
            <Select value={String(region)} onValueChange={(v) => {
              const r = (v === '2' ? 2 : v === '3' ? 3 : 1) as IaruRegion;
              setRegion(r); setIaruRegion(r);
            }}>
              <SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
              <SelectContent>
                <SelectItem value="1">{t('gen.iaruR1')}</SelectItem>
                <SelectItem value="2">{t('gen.iaruR2')}</SelectItem>
                <SelectItem value="3">{t('gen.iaruR3')}</SelectItem>
              </SelectContent>
            </Select>
          </div>

          <ThemeSelector />

          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={autofocusWB} onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }} />
            {t('gen.autofocusWB')}
          </label>
          {/* Backend setting, not a UI pref: the fold happens in the SQL. Saved
              instantly like the rest of this panel. */}
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={workedVariants}
              onCheckedChange={(c) => { const v = !!c; setWorkedVariants(v); SetWorkedCallVariants(v).catch((e: any) => setErr(String(e?.message ?? e))); }} />
            {t('gen.workedVariants')} <span className="text-xs text-muted-foreground">{t('gen.workedVariantsHint')}</span>
          </label>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} />
            {t('gen.showBeam')}
          </label>

          {/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
              shows a two-column callsign helper (partial matches + one-edit calls). */}
          <div className="border-t border-border/60 pt-3 space-y-2">
            <label className="flex items-center gap-2 text-sm cursor-pointer" title={t('scp.settingsHint')}>
              <Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
              {t('scp.enable')}
            </label>
            {scp.enabled && (
              <label className="flex items-center gap-2 text-sm cursor-pointer pl-6" title={t('scp.clublogHint')}>
                <Checkbox checked={!!(scp as any).clublog} disabled={scpBusy}
                  onCheckedChange={(c) => { void SetScpClublogEnabled(!!c).then(() => refreshScp()); }} />
                {t('scp.clublog')}
              </label>
            )}
            {scp.enabled && (
              <div className="flex items-center gap-3">
                <Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
                  <ArrowDown className="size-3.5" /> {scpBusy ? t('scp.downloading') : t('scp.download')}
                </Button>
                <span className="text-xs text-muted-foreground">
                  {scp.count > 0
                    ? t('scp.loaded', { n: scp.count.toLocaleString(), date: scp.updated ? new Date(scp.updated).toLocaleDateString() : '?' })
                    : t('scp.notLoaded')}
                </span>
              </div>
            )}
          </div>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
            {t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
          </label>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={showQsoRate} onCheckedChange={(c) => { const v = !!c; setShowQsoRate(v); writeUiPref('opslog.showQsoRate', v ? '1' : '0'); }} />
            {t('gen.showQsoRate')} <span className="text-xs text-muted-foreground">{t('gen.showQsoRateHint')}</span>
          </label>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={lookupOnBlur} onCheckedChange={(c) => { const v = !!c; setLookupOnBlur(v); writeUiPref('opslog.lookupOnBlur', v ? '1' : '0'); }} />
            {t('gen.lookupOnBlur')} <span className="text-xs text-muted-foreground">{t('gen.lookupOnBlurHint')}</span>
          </label>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
            <span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span>
          </label>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            {/* Distances are computed in km everywhere and converted at display
                time — see lib/units. Changing this repaints the columns that
                already carry a distance; nothing stored moves. */}
            <Checkbox checked={milesUnit} onCheckedChange={(c) => { const v = !!c; setMilesUnit(v); setUseMiles(v); }} />
            {t('gen.miles')}
          </label>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
            {t('gen.checkUpdates')}
          </label>
          <TelemetryToggle />

          <MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} yaesuAvailable={yaesuAvailable} elecraftAvailable={elecraftAvailable} tciAvailable={tciAvailable} />

          <div className="border-t border-border/60 pt-4 space-y-2">
            <h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4>
            {secret.has_passphrase ? (
              <>
                <p className="text-xs text-muted-foreground">
                  {t('gen.pwEncrypted')}{' '}
                  {secret.unlocked
                    ? <span className="text-success font-medium">{t('gen.pwUnlocked')}</span>
                    : <span className="text-warning font-medium">{t('gen.pwLocked')}</span>}.
                </p>
                {secret.unlocked && (
                  <>
                    <div className="flex items-center gap-2 max-w-md">
                      <Input type="password" placeholder={t('gen.pwNewPh')} value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
                      <Input type="password" placeholder={t('gen.pwConfirm')} value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
                      <Button size="sm" disabled={!ppNew || ppBusy} onClick={applyPassphrase}>{t('gen.pwChange')}</Button>
                    </div>
                    <Button variant="outline" size="sm" disabled={ppBusy} onClick={removePassphrase}>{t('gen.pwRemove')}</Button>
                  </>
                )}
              </>
            ) : (
              <>
                <p className="text-xs text-muted-foreground">
                  {t('gen.pwHint')}
                </p>
                <div className="flex items-center gap-2 max-w-md">
                  <Input type="password" placeholder={t('gen.pwPassphrase')} value={ppNew} onChange={(e) => { setPpNew(e.target.value); setPpErr(''); }} className="h-8" />
                  <Input type="password" placeholder={t('gen.pwConfirm')} value={ppConfirm} onChange={(e) => { setPpConfirm(e.target.value); setPpErr(''); }} className="h-8" />
                  <Button size="sm" disabled={!ppNew || ppNew !== ppConfirm || ppBusy} onClick={applyPassphrase}>{t('gen.pwEncrypt')}</Button>
                </div>
              </>
            )}
            {ppErr && <div className="text-xs text-destructive">{ppErr}</div>}
          </div>

          <div className="border-t border-border/60 pt-4 space-y-2">
            <h4 className="text-sm font-semibold text-foreground">{t('gen.clubTitle')}</h4>
            <label className="flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox
                checked={clubInfo.enabled}
                onCheckedChange={async (c) => {
                  const v = !!c; setClubInfo((s) => ({ ...s, enabled: v })); setClubErr('');
                  try {
                    await SetClublogCtyEnabled(v);
                    let info = (await GetClublogCtyInfo()) as ClubInfo;
                    // First enable with no cached data → download it now.
                    if (v && !info.loaded) {
                      setClubBusy(true);
                      try { info = (await DownloadClublogCty()) as ClubInfo; }
                      finally { setClubBusy(false); }
                    }
                    setClubInfo(info);
                  } catch (e: any) { setClubErr(String(e?.message ?? e)); }
                }}
                className="mt-0.5"
              />
              <span title={t('gen.clubDesc')}>{t('gen.clubUse')}</span>
            </label>
            <div className="flex items-center gap-3 pl-6">
              <Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
                onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
                {clubBusy ? t('gen.downloading') : (clubInfo.loaded ? t('gen.clubUpdate') : t('gen.clubDownload'))}
              </Button>
              <span className="text-[11px] text-muted-foreground">
                {clubInfo.loaded
                  ? t('gen.clubCount', { n: clubInfo.count.toLocaleString() }) + (clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : '')
                  : t('gen.notDownloaded')}
              </span>
            </div>
            {clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
          </div>

          <div className="border-t border-border/60 pt-4 space-y-2">
            <h4 className="text-sm font-semibold text-foreground">{t('gen.mwTitle')}</h4>
            <label className="flex items-start gap-2 text-sm cursor-pointer">
              <Checkbox
                checked={mwInfo.enabled}
                onCheckedChange={async (c) => {
                  const v = !!c; setMwInfo((s) => ({ ...s, enabled: v })); setMwErr('');
                  try {
                    await SetClublogMostWantedEnabled(v);
                    let info = (await GetClublogMostWantedInfo()) as MWInfo;
                    // First enable with no cached list → fetch it now (for the active call).
                    if (v && !info.loaded) {
                      setMwBusy(true);
                      try { info = (await DownloadClublogMostWanted()) as MWInfo; }
                      finally { setMwBusy(false); }
                    }
                    setMwInfo(info);
                  } catch (e: any) { setMwErr(String(e?.message ?? e)); }
                }}
                className="mt-0.5"
              />
              {/* No explanatory line: every operator knows what Most Wanted is. */}
              <span>{t('gen.mwShow')}</span>
            </label>
            <div className="flex items-center gap-3 pl-6">
              <Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
                onClick={() => { setMwBusy(true); setMwErr(''); DownloadClublogMostWanted().then((i) => setMwInfo(i as MWInfo)).catch((e: any) => setMwErr(String(e?.message ?? e))).finally(() => setMwBusy(false)); }}>
                {mwBusy ? t('gen.downloading') : (mwInfo.loaded ? t('gen.mwUpdate') : t('gen.mwDownload'))}
              </Button>
              <span className="text-[11px] text-muted-foreground">
                {mwInfo.loaded
                  ? t('gen.mwCount', { n: mwInfo.count.toLocaleString() }) + (mwInfo.callsign ? ' · ' + mwInfo.callsign : '') + (mwInfo.date ? ' · ' + mwInfo.date : '')
                  : t('gen.notDownloaded')}
              </span>
            </div>
            {mwErr && <p className="text-[11px] text-destructive pl-6">{mwErr}</p>}
          </div>
        </div>
      </>
    );
  }

  function EmailPanel() {
    return (
      <>
        <SectionHeader title={t('email.title')}/>
        <div className="space-y-3 max-w-2xl">
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={emailCfg.enabled} onCheckedChange={(c) => setEmailField({ enabled: !!c })} />
            Enable e-mail sending
          </label>
          <div className="grid grid-cols-[130px_1fr] gap-2 items-center">
            <Label className="text-sm">SMTP server</Label>
            <Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
            <Label className="text-sm">Port / encryption</Label>
            <div className="flex gap-2 items-center">
              <PortInput className="h-8 w-24 font-mono" value={emailCfg.smtp_port} fallback={587} onChange={(n) => setEmailField({ smtp_port: n })} />
              <Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
                <SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
                <SelectContent>
                  <SelectItem value="starttls">STARTTLS (587)</SelectItem>
                  <SelectItem value="ssl">SSL/TLS (465)</SelectItem>
                  <SelectItem value="none">{t('em.none')}</SelectItem>
                </SelectContent>
              </Select>
            </div>
            <div />
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox checked={emailCfg.auth} onCheckedChange={(c) => setEmailField({ auth: !!c })} />
              {t('em.smtpAuth')}
            </label>
            <Label className="text-sm">{t('em.username')}</Label>
            <Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
            <Label className="text-sm">{t('es.password')}</Label>
            {/* No reveal button: the settings window is often open while the
                screen is shared or shown to someone in the shack. */}
            <Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
            <Label className="text-sm">{t('em.fromAddr')}</Label>
            <Input className="h-8" placeholder="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
            <Label className="text-sm">{t('em.replyTo')}</Label>
            <div>
              <Input className="h-8" placeholder={t('em.replyToPh')} value={emailCfg.reply_to} onChange={(e) => setEmailField({ reply_to: e.target.value })} />
              <div className="text-[10px] text-muted-foreground mt-1">{t('em.replyToHint')}</div>
            </div>
          </div>
          <div className="flex items-center gap-3">
            <Button variant="outline" size="sm" className="h-8"
              onClick={() => { setEmailMsg(t('em.sendingTest')); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg(t('em.testSent'))).catch((e: any) => setEmailMsg(t('em.testFailed') + String(e?.message ?? e))); }}>
              {t('em.sendTest')}
            </Button>
            <span className="text-[11px] text-muted-foreground">{emailMsg}</span>
          </div>

          <div className="pt-2 mt-2 border-t border-border space-y-2">
            <Label className="text-sm font-semibold">{t('em.recEmail')}</Label>
            <div className="text-[11px] text-muted-foreground">
              {t('em.recVarsHint')} {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
            </div>
            <Input className="h-8" placeholder={t('em.subject')} value={emailCfg.subject}
              onChange={(e) => setEmailField({ subject: e.target.value })} />
            <Textarea rows={3} className="text-sm" placeholder={t('em.body')} value={emailCfg.body}
              onChange={(e) => setEmailField({ body: e.target.value })} />
          </div>

          <div className="pt-2 mt-2 border-t border-border space-y-2">
            <Label className="text-sm font-semibold">{t('em.qslCardEmail')}</Label>
            <div className="text-[11px] text-muted-foreground">
              {t('em.qslVarsHint')} {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
            </div>
            <Input className="h-8" placeholder={t('em.subject')} value={eqslCfg.subject}
              onChange={(e) => setEqslField({ subject: e.target.value })} />
            <Textarea rows={3} className="text-sm" placeholder={t('em.body')} value={eqslCfg.body}
              onChange={(e) => setEqslField({ body: e.target.value })} />
            <label className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
              {t('em.autoSend')}
            </label>
            <div className="text-[11px] text-muted-foreground">
              {t('em.autoSendHint')}
            </div>
            <div className="pt-2 space-y-1">
              <Label className="text-sm">{t('em.qslDefaultMsg')}</Label>
              <Input className="h-8" placeholder={t('em.qslDefaultMsgPh')} value={eqslCfg.default_message ?? ''}
                onChange={(e) => setEqslField({ default_message: e.target.value })} />
              <div className="text-[11px] text-muted-foreground">{t('em.qslDefaultMsgHint')}</div>
            </div>
          </div>
        </div>
      </>
    );
  }

  function USCountiesPanel() {
    const loaded = ulsStatus.count > 0;
    return (
      <div className="max-w-2xl space-y-5">
        <div>
          <h3 className="text-sm font-semibold mb-1">{t('uscty.title')}</h3>
          <p className="text-xs text-muted-foreground leading-relaxed">{t('uscty.intro')}</p>
        </div>

        {/* Required-download notice */}
        <div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground/90 leading-relaxed">
          {t('uscty.needDownload')}
        </div>

        {/* Status + download */}
        <div className="rounded-md border border-border p-3 space-y-3">
          <div className="flex items-center justify-between gap-3">
            <div className="text-xs">
              <div className="font-medium">{t('uscty.dbStatus')}</div>
              <div className="text-muted-foreground">
                {loaded
                  ? t('uscty.loaded', { n: ulsStatus.count.toLocaleString(), date: ulsStatus.updated_at ? new Date(ulsStatus.updated_at).toLocaleDateString() : '—' })
                  : t('uscty.notLoaded')}
              </div>
            </div>
            <Button size="sm" onClick={downloadUls} disabled={ulsBusy}>
              {ulsBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : <ArrowDown className="size-3.5 mr-1.5" />}
              {loaded ? t('uscty.update') : t('uscty.download')}
            </Button>
          </div>

          {/* A database downloaded by an older OpsLog still holds Connecticut's
              planning regions instead of its counties. The download date cannot
              show that, so say it outright. */}
          {loaded && ulsStatus.needs_refresh && !ulsBusy && (
            <div className="text-[11px] rounded border border-warning-border bg-warning-muted text-warning-muted-foreground px-2 py-1.5 leading-relaxed">
              {t('uscty.stale')}
            </div>
          )}

          {ulsProgress && (
            <div className="space-y-1">
              <div className="text-[11px] text-muted-foreground flex justify-between">
                <span>{ulsProgress.stage}</span><span>{ulsProgress.pct}%</span>
              </div>
              <div className="h-1.5 w-full rounded bg-muted overflow-hidden">
                <div className="h-full bg-primary transition-all" style={{ width: `${ulsProgress.pct}%` }} />
              </div>
            </div>
          )}
          {ulsMsg && (
            <div className={cn('text-xs', ulsMsg.ok ? 'text-success' : 'text-destructive')}>{ulsMsg.text}</div>
          )}
        </div>

        {/* Backfill existing QSOs */}
        <div className="rounded-md border border-border p-3 space-y-2">
          <div className="text-xs font-medium">{t('uscty.backfillTitle')}</div>
          <p className="text-[11px] text-muted-foreground leading-relaxed">{t('uscty.backfillIntro')}</p>
          <div className="flex items-center gap-3">
            <Button size="sm" variant="secondary" onClick={runBackfill} disabled={backfillBusy || !loaded}>
              {backfillBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
              {t('uscty.backfillRun')}
            </Button>
            {backfillMsg && <span className="text-xs text-muted-foreground">{backfillMsg}</span>}
          </div>
        </div>
      </div>
    );
  }

  // One line per database: what it holds, when it was refreshed, and the button
  // that refreshes it. Every one of these already had an updater somewhere —
  // buried in the Awards manager, in a corner of the Lookup page, behind the
  // US Counties section — and an operator wanting them all current had to know
  // where each one lived. The point of this page is that they are in one place,
  // named, with their own line.
  function DatabasesPanel() {
    const rows: Array<{
      key: string; name: string; detail: string; busy: boolean; run?: () => void; note?: string;
    }> = [
      {
        key: 'cty', name: t('db.cty'),
        detail: ctyInfo?.entities ? t('db.ctyDetail', { n: ctyInfo.entities, d: fmtDay(ctyInfo.file_mod_time) }) : t('db.never'),
        busy: ctyBusy, run: refreshCty,
      },
      {
        key: 'clublog', name: t('db.clublog'),
        detail: clubInfo?.count ? t('db.clublogDetail', { n: clubInfo.count, d: clubInfo.date || '—' }) : t('db.never'),
        busy: clubBusy,
        run: () => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); },
      },
      {
        key: 'lotw', name: t('db.lotwUsers'),
        detail: lotwUsers?.count ? t('db.lotwDetail', { n: lotwUsers.count, d: fmtDay(lotwUsers.updated) }) : t('db.never'),
        busy: lotwUsersBusy, run: downloadLotwUsers,
      },
      {
        key: 'scp', name: t('db.scp'),
        detail: scp?.count ? t('db.scpDetail', { n: scp.count, d: fmtDay(scp.updated) }) : t('db.never'),
        busy: scpBusy, run: downloadScp,
      },
      {
        key: 'uls', name: t('db.uls'),
        detail: ulsStatus?.count ? t('db.ulsDetail', { n: ulsStatus.count, d: fmtDay(ulsStatus.updated_at) }) : t('db.never'),
        busy: ulsBusy, run: downloadUls,
      },
      {
        key: 'rda', name: t('db.rda'),
        detail: t('db.rdaDetail', { n: rdaCount.toLocaleString() }),
        busy: false, note: t('db.rdaNote'),
      },
    ];
    return (
      <div className="space-y-4 max-w-3xl">
        <SectionHeader title={t('sec.databases')} hint={t('db.hint')} />
        <div className="rounded-md border border-border divide-y divide-border">
          {rows.map((r) => (
            <div key={r.key} className="flex items-center gap-3 p-3">
              <div className="min-w-0 flex-1">
                <div className="text-sm font-medium">{r.name}</div>
                <div className="text-[11px] text-muted-foreground">{r.detail}</div>
              </div>
              {r.run ? (
                <Button size="sm" variant="secondary" onClick={r.run} disabled={r.busy}>
                  {r.busy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
                  {t('db.update')}
                </Button>
              ) : (
                <span className="text-[11px] text-muted-foreground italic shrink-0">{r.note}</span>
              )}
            </div>
          ))}
        </div>

        {/* The award reference lists. Their own block because they are one kind
            of thing with one updater each, and because the list depends on which
            awards are defined — a shared WAPC brought in by an operator appears
            here the day it can be updated. */}
        <div className="space-y-2">
          <div className="text-xs font-medium">{t('db.refLists')}</div>
          <p className="text-[11px] text-muted-foreground">{t('db.refListsHint')}</p>
          <div className="rounded-md border border-border divide-y divide-border">
            {refMeta.filter((m) => m.can_update).map((m) => (
              <div key={m.code} className="flex items-center gap-3 p-3">
                <div className="min-w-0 flex-1">
                  <div className="text-sm font-medium font-mono">{m.code}</div>
                  <div className="text-[11px] text-muted-foreground">
                    {m.count ? t('db.refDetail', { n: m.count, d: fmtDay(m.updated_at) }) : t('db.never')}
                  </div>
                </div>
                <Button size="sm" variant="secondary" disabled={refBusy === m.code}
                  onClick={() => updateRefList(m.code)}>
                  {refBusy === m.code ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
                  {t('db.update')}
                </Button>
              </div>
            ))}
            {refMeta.filter((m) => m.can_update).length === 0 && (
              <div className="p-3 text-[11px] text-muted-foreground">{t('db.noRefLists')}</div>
            )}
          </div>
          {refMsg && <p className="text-[11px] text-muted-foreground">{refMsg}</p>}
        </div>
      </div>
    );
  }

  // Russian districts: the offline RDA database and the backfill it feeds.
  //
  // Its own section rather than a corner of the Awards manager: it is a
  // database and a bulk operation on the log, which is what every other item in
  // this part of the sidebar is.
  function RDAPanel() {
    return (
      <div className="space-y-4 max-w-2xl">
        <SectionHeader title={t('sec.rda')} hint={t('rda.hint')} />
        <div className="rounded-md border border-border p-3 space-y-1">
          <div className="text-xs font-medium">{t('rda.dbTitle')}</div>
          <p className="text-[11px] text-muted-foreground leading-relaxed">
            {t('rda.dbCount', { n: rdaCount.toLocaleString() })}
          </p>
        </div>
        <div className="rounded-md border border-border p-3 space-y-2">
          <div className="text-xs font-medium">{t('rda.backfillTitle')}</div>
          <p className="text-[11px] text-muted-foreground leading-relaxed">{t('rda.backfillIntro')}</p>
          {/* The choice is the whole point of the panel, so it is a checkbox and
              not a hidden default: a dated record is a fact, a current district
              on a ten-year-old QSO is an assumption. */}
          <label className="flex items-start gap-2 text-xs cursor-pointer">
            <Checkbox checked={rdaUseCurrent} className="mt-0.5" onCheckedChange={(c) => setRdaUseCurrent(!!c)} />
            <span>{t('rda.useCurrent')} <span className="text-muted-foreground">{t('rda.useCurrentHint')}</span></span>
          </label>
          <div className="flex items-center gap-3">
            <Button size="sm" variant="secondary" onClick={runRDABackfill} disabled={rdaBusy}>
              {rdaBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
              {rdaBusy ? t('rda.backfillRunning') : t('rda.backfillRun')}
            </Button>
            {rdaMsg && <span className="text-xs text-muted-foreground">{rdaMsg}</span>}
          </div>
          <p className="text-[11px] text-muted-foreground">{t('rda.neverOverwrites')}</p>
        </div>

        {/* Comparing the two sources. Read-only: it answers a question about the
            log without touching it. */}
        <div className="rounded-md border border-border p-3 space-y-2">
          <div className="text-xs font-medium">{t('rda.cmpTitle')}</div>
          <div className="flex items-center gap-3">
            <Button size="sm" variant="secondary" onClick={runRDACompare} disabled={rdaCmpBusy}>
              {rdaCmpBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
              {rdaCmpBusy ? t('rda.cmpRunning') : t('rda.cmpRun')}
            </Button>
            {!!rdaCmpMsg && <span className="text-xs text-muted-foreground">{rdaCmpMsg}</span>}
            {rdaCmp && (
              <span className="text-xs text-muted-foreground">
                {t('rda.cmpDone', {
                  s: rdaCmp.scanned ?? 0, a: rdaCmp.agree ?? 0, d: rdaCmp.disagree ?? 0,
                  l: rdaCmp.only_log ?? 0, b: rdaCmp.only_db ?? 0,
                })}
              </span>
            )}
          </div>
          {/* The disagreements themselves. A count alone would say "there is a
              problem" and leave the operator with no way to look at it. */}
          {rdaCmp?.conflicts?.length > 0 && (
            <>
            <p className="text-[11px] text-muted-foreground">
              {t('rda.cmpListHint', { n: rdaCmp.conflicts.length, d: rdaCmp.disagree ?? 0 })}
            </p>
            {/* Tall enough to work through. It was capped at 224 px — about six
                rows of a list that can hold two hundred — inside a panel that
                does not scroll to reveal what the box could not show. */}
            {/* NO overscroll-contain here. It stops the wheel from chaining to
                the settings pane behind, so with a short list — nothing to
                scroll inside the box — the pointer over the table froze the
                whole panel. The box scrolls when it has to; the page scrolls
                the rest of the time. */}
            <div className="max-h-[28rem] overflow-y-auto rounded border border-border">
              <table className="w-full text-[11px]">
                <thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
                  <tr>
                    <th className="py-1 px-2 font-medium">{t('rda.cmpCall')}</th>
                    <th className="py-1 pr-2 font-medium">{t('rda.cmpDate')}</th>
                    <th className="py-1 pr-2 font-medium">{t('rda.cmpFromLog')}</th>
                    <th className="py-1 pr-2 font-medium">{t('rda.cmpFromDb')}</th>
                    <th className="py-1 pr-2 font-medium">{t('rda.cmpKind')}</th>
                    <th className="py-1 pr-2 font-medium text-center">{t('rda.cmpKeep')}</th>
                  </tr>
                </thead>
                <tbody>
                  {rdaCmp.conflicts.map((c: any) => (
                    <tr key={c.qso_id} className="border-b border-border/30">
                      {/* The callsign opens the contact. A list of things to fix
                          that cannot be acted on is a list of things to write
                          down and look up again later. */}
                      <td className="py-1 px-2 font-mono font-semibold">
                        {onEditQSO ? (
                          <button type="button"
                            onClick={() => { onEditQSO(c.qso_id); onClose(); }}
                            title={t('rda.cmpOpen')}
                            className="underline decoration-dotted underline-offset-2 hover:text-primary">
                            {c.callsign}
                          </button>
                        ) : c.callsign}
                      </td>
                      <td className="py-1 pr-2 font-mono">{c.date}</td>
                      <td className="py-1 pr-2 font-mono">{c.from_log}{c.confirmed ? ' ✓' : ''}</td>
                      <td className="py-1 pr-2 font-mono">{c.from_db}</td>
                      <td className="py-1 pr-2 text-muted-foreground">
                        {c.dated ? t('rda.cmpDated') : t('rda.cmpCurrent')}
                      </td>
                      {/* Which one wins, on the row that shows the two values.
                          Two buttons rather than a tick box: a tick box would
                          have to mean one of the sources silently, and an
                          operator arbitrating between "RO-19" and "KO-05"
                          should be picking the value they can read, not
                          remembering what a checked box stands for. */}
                      <td className="py-1 pr-2 whitespace-nowrap text-center">
                        <div className="inline-flex rounded border border-border overflow-hidden">
                          {(['log', 'db'] as const).map((side) => (
                            <button
                              key={side}
                              type="button"
                              onClick={() => setRdaPick((p) => {
                                const next = { ...p };
                                if (next[c.qso_id] === side) delete next[c.qso_id];
                                else next[c.qso_id] = side;
                                return next;
                              })}
                              className={`px-2 py-0.5 font-mono text-[10px] ${rdaPick[c.qso_id] === side
                                ? 'bg-primary text-primary-foreground'
                                : 'hover:bg-muted'}`}
                              title={side === 'log' ? t('rda.cmpKeepLog') : t('rda.cmpKeepDb')}
                            >
                              {side === 'log' ? c.from_log : c.from_db}
                            </button>
                          ))}
                        </div>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
            {/* Applying is a separate, deliberate act. Clicking through two
                hundred rows and having each one written as it is clicked would
                make a slip permanent before the operator had finished
                reading. */}
            <div className="flex items-center gap-3 flex-wrap">
              <Button size="sm" variant="secondary" disabled={rdaApplyBusy || Object.keys(rdaPick).length === 0}
                onClick={async () => {
                  setRdaApplyBusy(true);
                  try {
                    const choices = rdaCmp.conflicts
                      .filter((c: any) => rdaPick[c.qso_id])
                      .map((c: any) => ({
                        qso_id: c.qso_id,
                        district: rdaPick[c.qso_id] === 'log' ? c.from_log : c.from_db,
                      }));
                    const r: any = await ApplyRDAChoices(choices);
                    setRdaCmpMsg(r?.message || '');
                    setRdaPick({});
                    // The settled rows leave the list, and the counters follow.
                    //
                    // They used to stay, which made a working button look like a
                    // broken one: the operator decided a hundred contacts, the
                    // hundred rows stayed exactly as they were, and there was
                    // nothing anywhere to say the writing had happened. Dropping
                    // them here rather than re-running the comparison, because
                    // that reads the whole log — seconds of silence on a remote
                    // database, to be told what is already known.
                    if (r?.applied > 0) {
                      const settled = new Set(choices.map((c: any) => c.qso_id));
                      setRdaCmp((prev: any) => prev && ({
                        ...prev,
                        disagree: Math.max(0, (prev.disagree ?? 0) - r.applied),
                        agree: (prev.agree ?? 0) + r.applied,
                        conflicts: (prev.conflicts ?? []).filter((c: any) => !settled.has(c.qso_id)),
                      }));
                    }
                  } catch (e: any) {
                    setRdaCmpMsg(String(e?.message ?? e));
                  } finally {
                    setRdaApplyBusy(false);
                  }
                }}>
                {rdaApplyBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
                {t('rda.cmpApply', { n: Object.keys(rdaPick).length })}
              </Button>
              <button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
                onClick={() => setRdaPick(Object.fromEntries(rdaCmp.conflicts.map((c: any) => [c.qso_id, 'db'])))}>
                {t('rda.cmpAllDb')}
              </button>
              <button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
                onClick={() => setRdaPick(Object.fromEntries(rdaCmp.conflicts.map((c: any) => [c.qso_id, 'log'])))}>
                {t('rda.cmpAllLog')}
              </button>
              {Object.keys(rdaPick).length > 0 && (
                <button type="button" className="text-[11px] underline decoration-dotted hover:text-primary"
                  onClick={() => setRdaPick({})}>
                  {t('rda.cmpClear')}
                </button>
              )}
            </div>
            <p className="text-[11px] text-muted-foreground">{t('rda.cmpApplyHint')}</p>
            </>
          )}
        </div>
      </div>
    );
  }

  // Map sections to their content + icon (for placeholder).
  const PANELS: Record<SectionId, () => JSX.Element> = {
    general:        GeneralPanel,
    appearance:     () => <AppearancePanel />,
    email:          EmailPanel,
    station:        StationPanel,
    profiles:       ProfilesPanel,
    operating:      OperatingPanelWrapper,
    confirmations:  ConfirmationsPanel,
    'external-services': ExternalServicesPanel,
    lookup:         LookupPanel,
    'lists-bands':  BandsPanel,
    'lists-modes':  ModesPanel,
    cluster:        ClusterPanel,
    dxhunter:       DXHunterPanel,
    udp:            UDPIntegrationsPanelWrapper,
    // Module-scope components, wrapped so their props can be passed. The nested
    // panels below go through PanelHost instead — which is what now lets either
    // kind hold hooks.
    adifmon:        () => <ADIFMonitorPanel />,
    foldersync:     () => <FolderSyncPanel />,
    webpublish:     () => <WebPublishPanel />,
    relayauto:      () => <RelayAutoPanel />,
    backup:         BackupPanel,
    database:       DatabasePanel,
    uscounties:     USCountiesPanel,
    databases:      DatabasesPanel,
    autostart:      () => <AutostartPanelComponent />,
    // RDAPanel is CALLED, not written as <RDAPanel />.
    //
    // It is nested inside this component, so as an element it would be a new
    // component TYPE on every render — React cannot know it is the same panel,
    // so it unmounts the old tree and mounts a fresh one. A fresh scroll
    // container starts at the top, which is what threw the district comparison
    // back to the first row every three seconds. Calling it produces the same
    // elements in place, and the scroll position is simply never disturbed.
    // (Safe because RDAPanel holds no hooks of its own — see PanelHost.)
    awards:         () => (<div className="space-y-6"><AwardsSelectionPanel profile={activeProfile ?? undefined} />{RDAPanel()}</div>),
    cat:            CATPanel,
    rotator:        RotatorPanel,
    winkeyer:       WinkeyerPanel,
    antenna:        UltrabeamPanel,
    antgenius:      AntGeniusPanelSettings,
    tunergenius:    TunerGeniusPanelSettings,
    psu:            PSUPanelSettings,
    satellite:      SatellitePanelSettings,
    pgxl:           PGXLPanelSettings,
    flex:           () => (
      <div className="space-y-6">
        <FlexBandPanel bands={lists.bands ?? []} />
        {/* Rendered here rather than inside FlexBandPanel so these keep writing
            to the modal's own CAT settings, saved by its Save button. A panel
            that saved them itself would be overwritten by that same Save a
            moment later, using the values it had read on opening. */}
        <div className="border-t border-border/60 pt-4 space-y-2">
          <h4 className="text-sm font-semibold text-foreground">{t('cat.flexOptions')}</h4>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
            {t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
          </label>
          {catCfg.flex_decode_spots && (
            <div className="flex items-center gap-2 pl-6">
              <Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
              <Input type="number" className="w-24" min={10} max={3600}
                value={catCfg.flex_decode_secs || 120}
                onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
              <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
            </div>
          )}
          <label className="flex items-start gap-2 text-sm cursor-pointer">
            <Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
            <span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
          </label>
        </div>

        {/* Auto-zoom on a spot click. Its own block: it changes the RADIO's
            display, where everything above changes what OpsLog draws on it. */}
        <div className="border-t border-border/60 pt-4 space-y-2">
          <h4 className="text-sm font-semibold text-foreground">{t('flexzoom.title')}</h4>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={flexZoom.enabled} onCheckedChange={(c) => saveFlexZoom({ ...flexZoom, enabled: !!c })} />
            {t('flexzoom.enable')}
          </label>
          {flexZoom.enabled && (
            <div className="pl-6 space-y-2">
              <div className="flex items-center gap-3 flex-wrap">
                {([['cw_khz', 'flexzoom.cw'], ['ssb_khz', 'flexzoom.ssb'], ['digi_khz', 'flexzoom.digi']] as const).map(([k, lbl]) => (
                  <label key={k} className="flex items-center gap-1.5 text-xs">
                    <span className="text-muted-foreground">{t(lbl)}</span>
                    <Input type="number" min={1} max={14000} className="h-8 w-20"
                      value={String((flexZoom as any)[k] ?? '')}
                      onChange={(e) => saveFlexZoom({ ...flexZoom, [k]: parseInt(e.target.value, 10) || 0 } as any)} />
                    <span className="text-muted-foreground">kHz</span>
                  </label>
                ))}
              </div>
              <label className="flex items-start gap-2 text-xs cursor-pointer">
                <Checkbox className="mt-0.5" checked={flexZoom.centre} onCheckedChange={(c) => saveFlexZoom({ ...flexZoom, centre: !!c })} />
                <span>{t('flexzoom.centre')} <span className="text-muted-foreground">{t('flexzoom.centreHint')}</span></span>
              </label>
            </div>
          )}
        </div>

        {/* Spot colours, one row per status.
            
            Saved on the spot rather than on the modal's Save: this is a table of
            colours, and having to close a dialog to see what a colour looks like
            on the waterfall turns picking one into a guessing game. */}
        <div className="border-t border-border/60 pt-4 space-y-2">
          <div className="flex items-center justify-between gap-2">
            <h4 className="text-sm font-semibold text-foreground">{t('spotcol.title')}</h4>
            <button type="button" className="text-[11px] text-muted-foreground hover:text-foreground underline"
              onClick={() => ResetSpotColors().then((c: any) => setSpotColors(c)).catch(() => {})}>
              {t('spotcol.reset')}
            </button>
          </div>
          {/* Whether spots are drawn at all comes first: colouring them is the
              next question, and the two were on different pages. */}
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
            {t('cat.flexSpots')}
          </label>
          <label className="flex items-center gap-2 text-sm cursor-pointer">
            <Checkbox checked={spotColors.enabled}
              onCheckedChange={(c) => saveSpotColors({ ...spotColors, enabled: !!c })} />
            {t('spotcol.enable')}
          </label>
          {spotColors.enabled && (
            <div className="rounded-md border border-border divide-y divide-border max-w-2xl">
              {SPOT_COLOR_ROWS.map((k) => {
                const c = spotColors.colors[k] ?? { text: '', bg: '' };
                return (
                  <div key={k} className="flex items-center gap-3 px-3 py-1.5">
                    {/* Sent to the radio at all. First in the row because it
                        decides whether the colours next to it mean anything. */}
                    <Checkbox checked={!c.hide} title={t('spotcol.send')}
                      onCheckedChange={(v) => setSpotColor(k, { hide: !v } as any)} />
                    <span className={cn('text-xs flex-1 min-w-0', c.hide && 'opacity-40 line-through')}>{t('spotcol.s_' + k)}</span>
                    {/* The preview is the point of the row: two colour boxes say
                        nothing about what the pair looks like together. */}
                    <span className="text-xs font-mono px-2 py-0.5 rounded"
                      style={{ color: argbToCss(c.text) || undefined, background: argbToCss(c.bg) || undefined }}>
                      DL1ABC
                    </span>
                    <ArgbInput label={t('spotcol.text')} value={c.text} onChange={(v) => setSpotColor(k, { text: v })} />
                    <ArgbInput label={t('spotcol.bg')} value={c.bg ?? ''} onChange={(v) => setSpotColor(k, { bg: v })} />
                  </div>
                );
              })}
            </div>
          )}
        </div>
      </div>
    ),
    audio:          AudioPanel,
  };

  return (
    <Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
      <DialogContent className="max-w-[1180px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
        <DialogHeader>
          <DialogTitle>{t('settings.title')}</DialogTitle>
          <DialogDescription className="sr-only">Configure OpsLog modules — station, lookup, hardware…</DialogDescription>
        </DialogHeader>

        {loading ? (
          <div className="p-6 text-muted-foreground">Loading…</div>
        ) : (
          <div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
            {/* Left sidebar tree */}
            <aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
              <Tree selected={selected} onSelect={setSelected} flexAvailable={flexAvailable} />
            </aside>

            {/* Right content pane */}
            <div className="overflow-y-auto p-6">
              <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
                {breadcrumb}
              </div>
              <PanelHost key={selected} render={PANELS[selected]} />

              {err && (
                <div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">
                  {err}
                </div>
              )}
              {msg && (
                <div className="mt-6 text-xs rounded-md px-3 py-2 max-w-2xl text-[color:var(--color-ok)] bg-[color:var(--color-ok)]/10 border border-[color:var(--color-ok)]/30">
                  {msg}
                </div>
              )}
            </div>
          </div>
        )}

        <DialogFooter>
          <Button variant="outline" onClick={onClose} disabled={saving}>{t('btn.cancel')}</Button>
          <Button variant="outline" onClick={() => save(false)} disabled={saving || loading}>{saving ? t('btn.savingLong') : t('btn.save')}</Button>
          <Button onClick={() => save(true)} disabled={saving || loading}>{saving ? t('btn.savingLong') : t('btn.saveClose')}</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

export const SettingsModal = memo(SettingsModalImpl);

// PortInput — a TCP port field you can actually clear.
//
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
// the contents and parseInt gives NaN, so the default is written straight back:
// the digits reappear under the cursor and the only way to enter a different
// port is to overwrite character by character. Reported on the cluster editor;
// the same line existed in eight other places.
//
// The raw TEXT is the state here, and the parent is only told about a value that
// is actually a port. An empty box stays empty while you type; on blur it falls
// back to the last good value, so nothing is ever saved as 0 or NaN.
function PortInput({ value, onChange, fallback, className, min = 1, max = 65535 }: {
  value: number; onChange: (n: number) => void; fallback: number; className?: string; min?: number; max?: number;
}) {
  const [text, setText] = useState(value ? String(value) : '');
  const typed = useRef(false);
  // Only adopt the incoming value while the operator has NOT started typing —
  // settings arrive from the backend after mount, and re-syncing mid-edit is
  // precisely the behaviour being fixed.
  useEffect(() => {
    if (!typed.current) setText(value ? String(value) : '');
  }, [value]);
  return (
    <Input
      type="text"
      inputMode="numeric"
      className={className}
      value={text}
      onChange={(e) => {
        typed.current = true;
        const digits = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
        setText(digits);
        const n = parseInt(digits, 10);
        if (n >= min && n <= max) onChange(n);
      }}
      onBlur={() => {
        typed.current = false;
        const n = parseInt(text, 10);
        if (!(n >= min && n <= max)) {
          setText(String(fallback));
          onChange(fallback);
        }
      }}
    />
  );
}

// ClusterServerEditor edits one row of cluster_servers. Init commands are
// free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps {
  value: Omit<clusterModels.ServerConfig, 'convertValues'>;
  onCancel: () => void;
  onSave: (s: Omit<clusterModels.ServerConfig, 'convertValues'>) => void | Promise<void>;
}

function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
  const { t } = useI18n();
  const [s, setS] = useState(value);
  const update = (patch: Partial<typeof s>) => setS((cur) => ({ ...cur, ...patch }));
  // Which preset the fields currently match, so reopening a node created from
  // one shows it selected rather than blank.
  const presetIdx = CLUSTER_PRESETS.findIndex(
    (p) => p.host.toLowerCase() === (s.host ?? '').trim().toLowerCase() && p.port === s.port);
  return (
    <Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
      <DialogContent className="max-w-[640px] px-6">
        <DialogHeader className="px-2">
          <DialogTitle>{s.id ? `Edit cluster · ${s.name || 'unnamed'}` : 'New cluster'}</DialogTitle>
          <DialogDescription className="text-xs">
            Telnet endpoint + optional login override and init commands. Init commands are sent one per line, 0.5s apart, right after login.
          </DialogDescription>
        </DialogHeader>
        <div className="grid grid-cols-2 gap-3 py-2 px-2">
          {/* Start from a node that is known to work. The host and the port are
              two pieces of information nobody has to hand, and a typo in either
              looks exactly like a node that is down. Everything stays editable
              afterwards. */}
          <div className="space-y-1 col-span-2">
            <Label>{t('clu.preset')}</Label>
            <Select
              value={presetIdx >= 0 ? String(presetIdx) : '_'}
              onValueChange={(v) => {
                const p = CLUSTER_PRESETS[Number(v)];
                if (!p) return;
                update({
                  host: p.host, port: p.port,
                  // The name is only filled when the operator has not chosen one
                  // of their own: renaming a node is the first thing anybody with
                  // two of them does.
                  name: s.name.trim() ? s.name : p.name,
                  init_commands: s.init_commands?.trim() ? s.init_commands : (p.init ?? ''),
                });
              }}
            >
              <SelectTrigger className="h-9"><SelectValue placeholder={t('clu.presetPick')} /></SelectTrigger>
              <SelectContent>
                <SelectItem value="_" disabled>{t('clu.presetPick')}</SelectItem>
                {CLUSTER_PRESETS.map((p, i) => (
                  <SelectItem key={`${p.host}:${p.port}`} value={String(i)}>
                    {p.name} — {p.about}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
            <p className="text-[11px] text-muted-foreground">{t('clu.presetHint')}</p>
          </div>
          <div className="space-y-1 col-span-2">
            <Label>Display name</Label>
            <Input autoFocus value={s.name} onChange={(e) => update({ name: e.target.value })} placeholder="VE7CC, F4BPO home…" />
          </div>
          <div className="space-y-1">
            <Label>Host</Label>
            <Input className="font-mono" value={s.host} onChange={(e) => update({ host: e.target.value })} placeholder="dxc.k0xm.net" />
          </div>
          <div className="space-y-1">
            <Label>Port</Label>
            <PortInput className="font-mono" value={s.port} fallback={7300} onChange={(n) => update({ port: n })} />
          </div>
          <div className="space-y-1">
            <Label>Login callsign (optional)</Label>
            <Input className="font-mono uppercase" value={s.login_override} onChange={(e) => update({ login_override: e.target.value })} placeholder="Active profile if empty" />
          </div>
          <div className="space-y-1">
            <Label>Password (optional)</Label>
            <Input type="password" value={s.password} onChange={(e) => update({ password: e.target.value })} autoComplete="off" />
          </div>
          <div className="space-y-1 col-span-2">
            <Label>Init commands (one per line, // for comments)</Label>
            <Textarea
              className="font-mono text-xs min-h-[120px]"
              value={s.init_commands}
              onChange={(e) => update({ init_commands: e.target.value })}
              placeholder={`// turn on DXCC info\nset/needsdxcc\nsh/dx 30`}
            />
          </div>
          <label className="flex items-center gap-2 text-sm cursor-pointer col-span-2">
            <Checkbox checked={s.enabled} onCheckedChange={(c) => update({ enabled: !!c })} />
            Enabled (will be connected at startup if Auto-connect is on)
          </label>
        </div>
        <DialogFooter className="px-2">
          <Button variant="outline" onClick={onCancel}>Cancel</Button>
          <Button
            onClick={() => onSave({ ...s, name: s.name.trim(), host: s.host.trim(), login_override: s.login_override.trim().toUpperCase() })}
            disabled={!s.name.trim() || !s.host.trim()}
          >
            {s.id ? 'Save changes' : 'Create cluster'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
