package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"runtime/debug"
	"sort"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"hamlog/internal/acom"
	"hamlog/internal/adif"
	"hamlog/internal/alerts"
	"hamlog/internal/antgenius"
	"hamlog/internal/applog"
	"hamlog/internal/audio"
	"hamlog/internal/autocall"
	"hamlog/internal/award"
	"hamlog/internal/awardref"
	"hamlog/internal/backup"
	"hamlog/internal/cabrillo"
	"hamlog/internal/cat"
	"hamlog/internal/catemu"
	"hamlog/internal/clublog"
	"hamlog/internal/cluster"
	"hamlog/internal/contest"
	"hamlog/internal/cwdecode"
	"hamlog/internal/db"
	"hamlog/internal/dxcc"
	"hamlog/internal/dxped"
	"hamlog/internal/email"
	"hamlog/internal/extsvc"
	"hamlog/internal/geo"
	"hamlog/internal/gridcache"
	"hamlog/internal/integrations/udp"
	"hamlog/internal/kpa"
	"hamlog/internal/lookup"
	"hamlog/internal/lotwusers"
	"hamlog/internal/netctl"
	"hamlog/internal/offlineq"
	"hamlog/internal/operating"
	"hamlog/internal/pota"
	"hamlog/internal/powergenius"
	"hamlog/internal/profile"
	"hamlog/internal/pskr"
	"hamlog/internal/pskrtgt"
	"hamlog/internal/psu"
	"hamlog/internal/qslcard"
	"hamlog/internal/qso"
	"hamlog/internal/rda"
	"hamlog/internal/relaydev"
	"hamlog/internal/rigctld"
	"hamlog/internal/rotator/dcu1"
	"hamlog/internal/rotator/gs232"
	"hamlog/internal/rotator/pst"
	"hamlog/internal/rotator/spid"
	"hamlog/internal/rotgenius"
	"hamlog/internal/sat"
	"hamlog/internal/scp"
	"hamlog/internal/settings"
	"hamlog/internal/solar"
	"hamlog/internal/spe"
	"hamlog/internal/steppir"
	"hamlog/internal/syncfolder"
	"hamlog/internal/tciserver"
	"hamlog/internal/tunergenius"
	"hamlog/internal/uls"
	"hamlog/internal/ultrabeam"
	"hamlog/internal/watchlist"
	"hamlog/internal/winkeyer"

	wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
	"go.bug.st/serial"
)

// Setting keys.
const (
	keyQRZUser     = "lookup.qrz.user"
	keyQRZPassword = "lookup.qrz.password"
	keyQRZNickname = "lookup.qrz.prefer_nickname" // "1" → QRZ <nickname> over first+last name
	keyHQUser      = "lookup.hamqth.user"
	keyHQPassword  = "lookup.hamqth.password"
	keyCacheTTL    = "lookup.cache.ttl_days"
	// Provider routing. Each value is a provider name (qrz | hamqth)
	// or empty to disable that slot. Primary is consulted first;
	// Failsafe is the fallback when Primary returns not-found or errs.
	keyLookupPrimary  = "lookup.primary"
	keyLookupFailsafe = "lookup.failsafe"
	keyLookupImages   = "lookup.download_images" // 1 = expose QRZ ImageURL to UI

	keyStationCallsign         = "station.callsign"
	keyStationOperator         = "station.operator"
	keyWatchlistContestPattern = "watchlist.contest_pattern" // auto-add contest calls containing this
	keyWatchlistContestCalls   = "watchlist.contest_calls"   // …and these, named one by one
	keyStationMyGrid           = "station.my_grid"
	keyStationCountry          = "station.my_country"
	keyStationSOTA             = "station.my_sota_ref"
	keyStationPOTA             = "station.my_pota_ref"

	keyListsBands      = "lists.bands"
	keyListsModes      = "lists.modes"
	keyListsRSTPhone   = "lists.rst_phone"
	keyListsRSTCW      = "lists.rst_cw"
	keyListsRSTDigital = "lists.rst_digital"
	keyListsSatellites = "lists.satellites" // the satellites this station works (SAT_NAME dropdown)

	keyCATEnabled    = "cat.enabled"
	keyCATBackend    = "cat.backend"     // "omnirig" | "flex"
	keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
	// Which VFO to believe when OmniRig names one. "" = trust the rig file.
	keyCATOmniRigVFO = "cat.omnirig.vfo" // "" | "A" | "B"
	// Put the RADIO in USB for a digital mode instead of asking for the mode by
	// name. Every backend, because the reason is the radio, not the link.
	keyCATDigiUSB         = "cat.digi_usb"
	keyCATFlexHost        = "cat.flex.host"         // FlexRadio IP (native backend)
	keyCATFlexPort        = "cat.flex.port"         // FlexRadio TCP port (default 4992)
	keyCATFlexSpots       = "cat.flex.spots"        // push cluster spots to the panadapter
	keyCATFlexDVKDax      = "cat.flex.dvk_dax"      // raise the transmit-bar DAX button while a voice message plays
	keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
	keyCATFlexDecodeSecs  = "cat.flex.decode_secs"  // decode spot display duration (seconds) before auto-removal
	keyCATPollMs          = "cat.poll_ms"
	keyCATDelayMs         = "cat.delay_ms"        // pause between commands
	keyCATDigitalDefault  = "cat.digital_default" // mode to use when CAT reports DATA
	keyCATShareEnabled    = "cat.share.enabled"   // expose CAT to other programs (Hamlib NET rigctl)
	keyCATSharePort       = "cat.share.port"      // TCP port for that server (rigctld default 4532)
	keyCATShareProto      = "cat.share.proto"     // which sharing server runs: "rigctl" (Hamlib NET) or "tci"
	keyCATShareTCIPort    = "cat.share.tci_port"  // WebSocket port for the TCI server (TCI default 40001)
	keyCATXieguPort       = "cat.xiegu.port"      // Xiegu CI-V serial port (G90/X6100…)
	keyCATXieguBaud       = "cat.xiegu.baud"      // Xiegu CI-V baud (G90 default 19200)
	keyCATXieguAddr       = "cat.xiegu.addr"      // Xiegu CI-V address (factory 0x70)
	keyCATXieguPTTLine    = "cat.xiegu.ptt_line"  // "" | rts | dtr — G90 does not key over CI-V
	keyCATYaesuPort       = "cat.yaesu.port"      // Yaesu CAT serial port (e.g. COM4)
	keyCATYaesuBaud       = "cat.yaesu.baud"      // Yaesu CAT baud (FTDX10/101 default 38400)
	keyCATKenwoodHost     = "cat.kenwood.host"    // Kenwood CAT over a network serial bridge (ser2net), "host:port"
	// keyCATKenwoodLink says HOW the radio is reached: "usb" (a COM port),
	// "bridge" (the same CAT bytes over TCP — ser2net, an Ethernet-serial
	// adapter), or "native" (the radio's own network protocol). Kept apart from
	// the address because the three are different transports, and inferring the
	// choice from whether a field happened to be filled in is how an operator
	// ends up with a host typed in and a radio that never answers.
	keyCATKenwoodLink = "cat.kenwood.link"
	keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
	keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200)
	// One key PER BACKEND, deliberately not shared. A Xiegu fix that reached
	// into the Yaesu and Kenwood backends is what broke them in 0.22.7/0.22.8;
	// the rule since is that nothing a backend does may be steered by another
	// backend's setting.
	keyCATYaesuLowLines    = "cat.yaesu.low_dtr_rts"   // deassert DTR/RTS on connect
	keyCATYaesuRTTYUSB     = "cat.yaesu.rtty_usb"      // set RTTY on USB rather than the older LSB
	keyCATKenwoodLowLines  = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
	keyCATKenwoodDataMode  = "cat.kenwood.data_mode"   // data modes → "usb" | "data" (MD6) | "keep"
	keyCATIcomPort         = "cat.icom.port"           // Icom USB CI-V serial port (e.g. COM5)
	keyCATIcomBaud         = "cat.icom.baud"           // Icom CI-V baud (default 115200)
	keyCATIcomAddr         = "cat.icom.addr"           // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
	keyCATIcomNetHost      = "cat.icom.net.host"       // Icom network remote: rig IP/hostname
	keyCATIcomNetUser      = "cat.icom.net.user"       // Icom network: Network User1 ID
	keyCATIcomNetPass      = "cat.icom.net.pass"       // Icom network: Network User1 password
	keyCATIcomNetAudio     = "cat.icom.net.audio"      // Icom network: stream RX audio on 50003 (experimental)
	keyChaseMode           = "chase.mode"              // "new" | "new_unconfirmed" — the global hunt, every category
	keyChaseConfirm        = "chase.confirm"           // CSV of confirmation sources: lotw,card,eqsl,qrz
	keyAudioMonitorOn      = "audio.monitor.on"        // play the network RX audio through the Listening device (the stream itself stays open for the recorder either way)
	keyCATTCIHost          = "cat.tci.host"            // TCI host (Expert Electronics SunSDR / ExpertSDR2)
	keyCATTCIPort          = "cat.tci.port"            // TCI WebSocket port (default 40001)
	keyCATTCISpots         = "cat.tci.spots"           // push cluster spots to the TCI panorama
	keyCATPttHotkeyEnabled = "cat.ptt_hotkey.enabled"  // keyboard PTT key active
	keyCATPttHotkey        = "cat.ptt_hotkey.code"     // bound key (KeyboardEvent.code)
	keyCATPttHotkeyToggle  = "cat.ptt_hotkey.toggle"   // toggle mode vs hold-to-talk

	// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
	// global (not per-profile) like CAT/rotator. Device fields store the
	// WASAPI endpoint id; the UI resolves it to a friendly name.
	keyAudioFromRadio    = "audio.from_radio"      // capture: rig RX audio in
	keyAudioToRadio      = "audio.to_radio"        // render: DVK plays into rig
	keyAudioRecDevice    = "audio.rec_device"      // capture: your mic (record DVK msgs)
	keyAudioListenDevice = "audio.listen_device"   // render: local preview speakers
	keyAudioQSORecord    = "audio.qso_record"      // "1" → auto-record every QSO
	keyAudioQSODir       = "audio.qso_dir"         // folder for QSO recordings
	keyAudioPreroll      = "audio.preroll_seconds" // rolling-buffer pre-roll length
	keyAudioTXGain       = "audio.tx_gain"         // voice-keyer playback level % (100 = as recorded)
	// Replaying a QSO recording needs its OWN level, and by a wide margin.
	//
	// A voice-keyer message is a microphone at speaking distance; a QSO
	// recording is a receiver's line output, which is far hotter. Sharing one
	// setting meant the 195% an operator needs for their F-key messages was
	// also applied to a recording, which then hit the transmitter flat out —
	// visible on the panadapter as a signal several times the size of the S5
	// station being answered.
	keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
	keyAudioPTTMethod   = "audio.ptt_method"    // "none" (VOX) | "rts" | "dtr"
	keyAudioPTTPort     = "audio.ptt_port"      // COM port for serial PTT
	keyAudioPTTData     = "audio.ptt_data"      // keyer audio arrives on the rig DATA/USB input
	keyAudioFormat      = "audio.qso_format"    // "wav" | "mp3"
	keyAudioFromGain    = "audio.from_gain"     // From Radio (RX) mix level, percent
	keyAudioMicGain     = "audio.mic_gain"      // mic mix level, percent

	keyAwardDefs        = "awards.defs"          // JSON array of award definitions (editable)
	keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp
	keyAwardRefsSeeded  = "awards.refs.seeded"   // built-in reference-list seed version
	keyAwardDefsFixed   = "awards.defs.fixed"    // built-in award def correction version
	keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag

	keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
	keyClublogMostWanted = "clublog.most_wanted"    // "1" → show ClubLog Most Wanted rank in the entry matrix

	// E-mail / SMTP — send QSO recordings to the correspondent.
	keyEmailEnabled    = "email.enabled"
	keyEmailHost       = "email.smtp_host"
	keyEmailPort       = "email.smtp_port"
	keyEmailUser       = "email.smtp_user"
	keyEmailPassword   = "email.smtp_password"
	keyEmailFrom       = "email.from"
	keyEmailReplyTo    = "email.reply_to"   // optional Reply-To: replies go here, not the From sender
	keyEmailEncryption = "email.encryption" // "ssl" | "starttls" | "none"
	keyEmailAuth       = "email.auth"       // "1" → SMTP requires authorization (send user/password)
	keyEmailAutoSend   = "email.auto_send"  // "1" → auto-send recording on log when an e-mail is known
	keyEmailSubject    = "email.subject"
	keyEmailBody       = "email.body"

	// clublogAppAPIKey is OpsLog's ClubLog API key, also used for the country
	// file download. Visible in the binary but must not be exposed publicly.
	clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"

	keyRotatorEnabled      = "rotator.enabled"
	keyRotatorHost         = "rotator.host"
	keyRotatorPort         = "rotator.port"
	keyRotatorHasElevation = "rotator.has_elevation"
	keyRotatorType         = "rotator.type"      // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
	keyRotatorNum          = "rotator.num"       // Rotator Genius rotator index: 1 or 2
	keyRotatorDual         = "rotator.dual"      // Rotator Genius drives both rotors; compass shows a Rotor 1/2 toggle
	keyRotatorActive       = "rotator.active"    // currently selected rotor when dual: 1 or 2 (set from the compass toggle, not the settings panel)
	keyRotatorName1        = "rotator.name1"     // optional label for rotor 1 (e.g. "Yagi HF")
	keyRotatorName2        = "rotator.name2"     // optional label for rotor 2
	keyRotatorMotor1       = "rotator.motor1"    // rotor 1 carries the motorized antenna (Ultrabeam/SteppIR) → show pattern paths
	keyRotatorMotor2       = "rotator.motor2"    // rotor 2 carries the motorized antenna
	keyRotatorTransport    = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
	keyRotatorComPort      = "rotator.com_port"  // GS-232 serial transport
	keyRotatorBaud         = "rotator.baud"      // GS-232 serial baud (an ERC needs it; an ARCO ignores it)
	// Rotor 2 — a fully independent second rotor (any type), so an ARCO and a
	// Rotator Genius can run side by side. Mirrors the rotor-1 device keys.
	keyRotatorType2         = "rotator.type2"
	keyRotatorHost2         = "rotator.host2"
	keyRotatorPort2         = "rotator.port2"
	keyRotatorHasElevation2 = "rotator.has_elevation2"
	keyRotatorNum2          = "rotator.num2" // Rotator Genius internal index when rotor 2 is a RG
	keyRotatorTransport2    = "rotator.transport2"
	keyRotatorComPort2      = "rotator.com_port2"
	keyRotatorBaud2         = "rotator.baud2"

	// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
	// "ultrabeam." prefix for backward compatibility with configs saved before the
	// SteppIR support; keyMotorType / keyMotorTransport default to the old Ultrabeam
	// TCP behaviour when absent, so an existing install keeps working untouched.
	keyUltrabeamEnabled = "ultrabeam.enabled"
	keyUltrabeamHost    = "ultrabeam.host"
	keyUltrabeamPort    = "ultrabeam.port"
	keyUltrabeamFollow  = "ultrabeam.follow"    // "1" → re-tune to the rig frequency
	keyUltrabeamStep    = "ultrabeam.step_khz"  // re-tune hysteresis: 25 | 50 | 100 kHz
	keyMotorTrackMode   = "motor.track_mode"    // "always" | "step" | "band"
	keyMotorBandFreqs   = "motor.band_freqs"    // per-band tune frequency: "40m=7100,20m=14150"
	keyChaseNewGrids    = "cluster.chase_grids" // "1" → persist learnt locators across restarts
	keyRowColors        = "appearance.row_colors"
	keyRotorPresets     = "rotator.presets"          // quick-turn buttons on the rotor widget
	keyCATOffsetHz      = "cat.offset_hz"            // transverter offset: real frequency − rig frequency
	keyCATOffsetOn      = "cat.offset_on"            // "1" → apply the transverter offset
	keyMatrixColors     = "appearance.matrix_colors" // band/mode matrix palette overrides
	keyMotorType        = "ultrabeam.type"           // "ultrabeam" | "steppir" (default ultrabeam)
	keyMotorTransport   = "ultrabeam.transport"      // "tcp" | "serial" (default tcp)
	keyMotorCOM         = "ultrabeam.com"            // serial device name (COM3, /dev/ttyUSB0)
	keyMotorBaud        = "ultrabeam.baud"           // serial baud (SteppIR default 9600)
	keyMotorTXInhibit   = "ultrabeam.tx_inhibit"     // "1" → block Flex TX while the antenna is moving
	keyMotorFreqMin     = "ultrabeam.freq_min"       // SteppIR tunable range low edge (MHz); out-of-range = don't follow/inhibit
	keyMotorFreqMax     = "ultrabeam.freq_max"       // SteppIR tunable range high edge (MHz)
	keyMotorBands       = "ultrabeam.bands"          // CSV of bands the antenna covers (e.g. "40m,20m,17m,…"); the follow filter
	keyStationDevices   = "station.devices"          // JSON list of relay boards for the Station Control tab

	// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
	// port is fixed at 9007, so only the IP is configurable.
	keyAntGeniusEnabled  = "antgenius.enabled"
	keyAntGeniusHost     = "antgenius.host"
	keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)

	// Tuner Genius XL (4O3A) — Hardware → Tuner Genius. TCP port is fixed at 9010
	// on the device, so only the IP + optional remote code are configurable.
	keyTunerGeniusEnabled  = "tunergenius.enabled"
	keyTunerGeniusHost     = "tunergenius.host"
	keyTunerGeniusPassword = "tunergenius.password" // remote/AUTH code (blank on LAN)

	// Amplifier control — Hardware → Amplifier (PowerGenius XL over TCP; SPE Expert
	// over USB serial or an RS232-to-Ethernet bridge). Keys keep the pgxl.* prefix
	// for backward compatibility with existing saved settings.
	keyPGXLEnabled   = "pgxl.enabled"
	keyPGXLHost      = "pgxl.host"
	keyPGXLPort      = "pgxl.port"
	keyPGXLType      = "pgxl.type"      // "pgxl" | "spe13" | "spe15" | "spe2k"
	keyPGXLTransport = "pgxl.transport" // "tcp" | "serial"
	keyPGXLComPort   = "pgxl.com_port"
	keyPGXLBaud      = "pgxl.baud"

	// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
	keyWKEnabled    = "winkeyer.enabled"
	keyWKPort       = "winkeyer.port"
	keyWKBaud       = "winkeyer.baud"
	keyWKWPM        = "winkeyer.wpm"
	keyWKWeight     = "winkeyer.weight"
	keyWKLeadIn     = "winkeyer.lead_in_ms"
	keyWKTail       = "winkeyer.tail_ms"
	keyWKRatio      = "winkeyer.ratio"
	keyWKFarnsworth = "winkeyer.farnsworth"
	keyWKSidetone   = "winkeyer.sidetone_hz"
	keyWKMode       = "winkeyer.mode"
	keyWKSwap       = "winkeyer.swap"
	keyWKAutoSpace  = "winkeyer.autospace"
	keyWKUsePTT     = "winkeyer.use_ptt"
	keyWKSerialEcho = "winkeyer.serial_echo"
	keyWKMacros     = "winkeyer.macros"          // JSON array of {label,text}
	keyWKEngine     = "winkeyer.engine"          // "winkeyer" | "k3ng" | "serial" | "icom" | "flex" | "tci"
	keyWKEscClears  = "winkeyer.esc_clears_call" // ESC also clears the callsign
	keyWKSendOnType = "winkeyer.send_on_type"    // key characters live as typed
	keyWKEsm        = "winkeyer.esm"             // Enter-Sends-Message (N1MM-style CW flow)
	keyWKCWLine     = "winkeyer.cw_key_line"     // serial engine: "dtr" (CW) / "rts" (PTT) or swapped
	keyWKCWInvert   = "winkeyer.cw_invert"       // serial engine: invert line polarity (active-LOW)

	keyClusterAutoConnect = "cluster.auto_connect"      // open every enabled server at app start
	keyClusterSelfSpot    = "cluster.self_spot"         // "1" → announce ourselves on the cluster as we log
	keyClusterSelfSpotMin = "cluster.self_spot_minutes" // shortest gap between two self-spots
	keyClusterSpotTTL     = "cluster.spot_ttl_min"      // drop spots older than this; 0 = keep them
	keyClusterSpotMax     = "cluster.spot_max"          // how many spots the list holds at once

	keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
	keyScpClublog = "scp.clublog" // merge Club Log's weekly SCP list (~180k calls)

	// Web publishing: the whole config as one JSON blob. A single key rather than
	// twenty: it is read and written as a unit by one settings panel, and the FTP
	// password rides inside it — see isSensitiveSetting.
	keyWebPublish = "webpublish.config"

	// Worked-before: fold an operator's portable forms (X, X/3, X/P) together.
	// Stored inverted — "0" means OFF — so the feature is ON for an existing
	// install that has never seen the key, which is the behaviour operators asked
	// for. See GetWorkedCallVariants.
	keyWorkedCallVariants = "worked.call_variants"

	keyBackupEnabled   = "backup.enabled"
	keyBackupFolder    = "backup.folder"
	keyBackupRotation  = "backup.rotation"
	keyBackupZip       = "backup.zip"
	keyBackupEveryExit = "backup.every_exit" // "1" → back up on every quit, not just once/day
	keyBackupKeepAll   = "backup.keep_all"   // "1" → each backup is a distinct time-stamped file (no same-day overwrite)
	keyBackupLast      = "backup.last_at"

	keyQSLDefaultQSLSent       = "qsl.qsl_sent"
	keyQSLDefaultQSLRcvd       = "qsl.qsl_rcvd"
	keyQSLDefaultLOTWSent      = "qsl.lotw_sent"
	keyQSLDefaultLOTWRcvd      = "qsl.lotw_rcvd"
	keyQSLDefaultEQSLSent      = "qsl.eqsl_sent"
	keyQSLDefaultEQSLRcvd      = "qsl.eqsl_rcvd"
	keyQSLDefaultClublogStatus = "qsl.clublog_status"
	keyQSLDefaultClublogCfm    = "qsl.clublog_confirmed"
	keyQSLDefaultHRDLogStatus  = "qsl.hrdlog_status"
	keyQSLDefaultQRZComStatus  = "qsl.qrzcom_status"
	keyQSLDefaultQRZComCfm     = "qsl.qrzcom_confirmed"
	keyQSLDefaultHamqthStatus  = "qsl.hamqth_status"
	keyQSLDefaultHamlogStatus  = "qsl.hamlog_status"
	keyQSLDefaultHamlogCfm     = "qsl.hamlog_confirmed"

	// External services (logbook upload). QRZ.com first; Clublog / LoTW
	// will add their own keys under the same extsvc.* prefix.
	keyExtQRZAPIKey = "extsvc.qrz.api_key"
	// Opt-in, and off by default: withdrawing a QSO from QRZ or Club Log cannot
	// be undone, and a local delete is not always meant to reach the world.
	keyExtDeleteRemote  = "extsvc.delete_remote" // also delete from QRZ/Club Log when a QSO is deleted
	keyExtQRZForceCall  = "extsvc.qrz.force_station_callsign"
	keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
	keyExtQRZUploadMode = "extsvc.qrz.upload_mode"

	keyExtClublogEmail      = "extsvc.clublog.email"
	keyExtClublogPassword   = "extsvc.clublog.password"
	keyExtClublogCallsign   = "extsvc.clublog.callsign"
	keyExtClublogAPIKey     = "extsvc.clublog.api_key"
	keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
	keyExtClublogUploadMode = "extsvc.clublog.upload_mode"

	keyExtHRDLogCallsign   = "extsvc.hrdlog.callsign"
	keyExtHRDLogCode       = "extsvc.hrdlog.code" // HRDLog account upload code
	keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
	keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
	keyExtHRDLogOnAir      = "extsvc.hrdlog.on_air" // publish frequency/mode/rig live on hrdlog.net

	keyExtEQSLUsername   = "extsvc.eqsl.username"
	keyExtEQSLPassword   = "extsvc.eqsl.password"
	keyExtEQSLQTHNick    = "extsvc.eqsl.qth_nickname"
	keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
	keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"

	// Cloudlog and its fork Wavelog are self-hosted, so the instance URL is part
	// of the configuration (there is no fixed endpoint).
	keyExtCloudlogURL        = "extsvc.cloudlog.url"
	keyExtCloudlogAPIKey     = "extsvc.cloudlog.api_key"
	keyExtCloudlogStationID  = "extsvc.cloudlog.station_id"
	keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
	keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"

	keyExtHamqthUsername   = "extsvc.hamqth.username"
	keyExtHamqthPassword   = "extsvc.hamqth.password"
	keyExtHamqthCallsign   = "extsvc.hamqth.callsign"
	keyExtHamqthAutoUpload = "extsvc.hamqth.auto_upload"
	keyExtHamqthUploadMode = "extsvc.hamqth.upload_mode"

	keyExtHamlogAPIKey     = "extsvc.hamlog.api_key"
	keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
	keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"

	keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync

	keyExtLoTWTQSLPath     = "extsvc.lotw.tqsl_path"
	keyExtLoTWStationLoc   = "extsvc.lotw.station_location"
	keyExtLoTWQSLDetail    = "extsvc.lotw.qsl_detail"             // ask LoTW for the QSL dates and station details (an order of magnitude slower)
	keyExtLoTWAllCalls     = "extsvc.lotw.download_all_calls"     // download confirmations for EVERY call on the account, not just this profile's
	keyExtLoTWForceCall    = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
	keyExtLoTWKeyPassword  = "extsvc.lotw.key_password"
	keyExtLoTWUploadFlag   = "extsvc.lotw.upload_flag"  // legacy single flag (migrated to upload_flags)
	keyExtLoTWUploadFlags  = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
	keyExtLoTWWriteLog     = "extsvc.lotw.write_log"
	keyExtLoTWAutoUpload   = "extsvc.lotw.auto_upload"
	keyExtLoTWUploadMode   = "extsvc.lotw.upload_mode"
	keyExtLoTWUsername     = "extsvc.lotw.username"      // LoTW website login (download)
	keyExtLoTWWebPassword  = "extsvc.lotw.web_password"  // LoTW website password (download)
	keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
	keyExtQRZLastDownload  = "extsvc.qrz.last_download"  // YYYY-MM-DD of last QRZ confirmation pull
	keyExtEQSLLastDownload = "extsvc.eqsl.last_download" // YYYY-MM-DD of last eQSL inbox pull

	keyExtClublogLastDownload = "extsvc.clublog.last_download" // YYYY-MM-DD of last Club Log matches pull
)

// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
// status fields. Applied to every QSO when the corresponding field is
// empty — both manual entry and UDP auto-log. Values are ADIF status
// codes: "Y" yes, "N" no, "R" requested, "Q" queued, "I" ignore, ""
// (empty) leaves the field untouched.
type QSLDefaults struct {
	QSLSent       string `json:"qsl_sent"`
	QSLRcvd       string `json:"qsl_rcvd"`
	LOTWSent      string `json:"lotw_sent"`
	LOTWRcvd      string `json:"lotw_rcvd"`
	EQSLSent      string `json:"eqsl_sent"`
	EQSLRcvd      string `json:"eqsl_rcvd"`
	ClublogStatus string `json:"clublog_status"`
	ClublogCfm    string `json:"clublog_confirmed"` // Club Log match/download status
	HRDLogStatus  string `json:"hrdlog_status"`
	QRZComStatus  string `json:"qrzcom_status"`
	QRZComCfm     string `json:"qrzcom_confirmed"` // QRZ.com download/confirmed status
	// HAMLOG.online lives in the ADIF extras rather than a column — the standard
	// names a field for hamlog.EU and none for hamlog.ONLINE — but a default is
	// a default: an operator who uploads everything wants new contacts to start
	// at "N" here exactly as they do for Club Log.
	HamlogStatus string `json:"hamlog_status"`
	HamlogCfm    string `json:"hamlog_confirmed"`
	// HamQTH, same extras story — and SENT only: the site publishes no
	// confirmation feed, so there is no received side to default.
	HamqthStatus string `json:"hamqth_status"`
}

// CATSettings is the user-tweakable rig-control configuration. Stored as
// individual key/value pairs to keep the settings table flat.
type CATSettings struct {
	Enabled    bool   `json:"enabled"`
	Backend    string `json:"backend"`     // "omnirig" | "flex" | "icom" | "icom-net" | "tci"
	OmniRigNum int    `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
	// OmniRigVFO overrides which VFO OpsLog reads: "" follows what the rig file
	// reports, "A"/"B" force one. Needed because that report is only as good as
	// the .ini: an IC-7610 file was seen declaring VFO B permanently while the
	// operator worked on the main VFO, so OpsLog wrote to A and read B.
	OmniRigVFO string `json:"omnirig_vfo"` // "" | "A" | "B"
	// DigiAsUSB puts the radio in USB when a digital mode is selected, rather
	// than naming the mode. What the QSO is LOGGED as never changes.
	DigiAsUSB       bool   `json:"digi_as_usb"`
	FlexHost        string `json:"flex_host"`         // FlexRadio IP (native backend)
	FlexPort        int    `json:"flex_port"`         // FlexRadio TCP port (default 4992)
	FlexSpots       bool   `json:"flex_spots"`        // push cluster spots to the panadapter
	FlexDVKDax      bool   `json:"flex_dvk_dax"`      // raise transmit DAX while a voice message plays, lower it after
	FlexDecodeSpots bool   `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
	FlexDecodeSecs  int    `json:"flex_decode_secs"`  // decode spot display duration (s) before removal (default 120)
	XieguPort       string `json:"xiegu_port"`        // Xiegu CI-V serial port (G90/X6100…)
	XieguBaud       int    `json:"xiegu_baud"`        // Xiegu CI-V baud (G90 default 19200)
	XieguAddr       int    `json:"xiegu_addr"`        // Xiegu CI-V address (factory 0x70)
	XieguPTTLine    string `json:"xiegu_ptt_line"`    // "", "rts" or "dtr" — how the rig is keyed
	YaesuPort       string `json:"yaesu_port"`        // Yaesu CAT serial port (e.g. COM4)
	YaesuBaud       int    `json:"yaesu_baud"`        // Yaesu CAT baud (FTDX10/101 default 38400)
	KenwoodHost     string `json:"kenwood_host"`      // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
	// adapter). NOT the radio’s own RJ45, which speaks Kenwood’s KNS/ARCP.
	KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
	// KenwoodLink: "usb" | "bridge" | "native". Empty means usb, unless a host
	// was already configured by an older build — see GetCATSettings.
	KenwoodLink string `json:"kenwood_link"`
	KenwoodBaud int    `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
	// What a DATA/digital mode (FT8, PSK…) sets on the rig: "usb" (default), "data"
	// (MD6 — Elecraft K3/K4 DATA mode) or "keep" (leave the rig's mode untouched,
	// for a TS-590SG/TS-990S whose data mode is a USB modifier set on the rig).
	KenwoodDataMode string `json:"kenwood_data_mode"`
	// Per-backend: deassert DTR and RTS after opening the CAT port, for
	// interfaces that read either line as PTT. Off by default: lowering them
	// stops some USB-serial interfaces transmitting at all.
	YaesuLowLines bool `json:"yaesu_low_lines"`
	// YaesuRTTYUSB: ADIF says "RTTY" and Yaesu has both sidebands, so the rig
	// cannot be driven from the logged mode alone. Off = RTTY-L, the older
	// convention.
	YaesuRTTYUSB    bool   `json:"yaesu_rtty_usb"`
	KenwoodLowLines bool   `json:"kenwood_low_lines"`
	IcomPort        string `json:"icom_port"`      // Icom USB CI-V serial port (e.g. COM5)
	IcomBaud        int    `json:"icom_baud"`      // Icom CI-V baud (default 115200)
	IcomAddr        int    `json:"icom_addr"`      // Icom CI-V address, decimal (IC-7610 = 152)
	IcomNetHost     string `json:"icom_net_host"`  // Icom network remote: rig IP/hostname (built-in LAN server)
	IcomNetUser     string `json:"icom_net_user"`  // Icom network Network User1 ID
	IcomNetPass     string `json:"icom_net_pass"`  // Icom network Network User1 password
	IcomNetAudio    bool   `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
	TCIHost         string `json:"tci_host"`       // TCI host (Expert Electronics SunSDR)
	TCIPort         int    `json:"tci_port"`       // TCI WebSocket port (default 40001)
	TCISpots        bool   `json:"tci_spots"`      // push cluster spots to the TCI panorama
	PollMs          int    `json:"poll_ms"`        // poll interval in ms (default 250)
	DelayMs         int    `json:"delay_ms"`       // pause between commands (default 0)
	// Transverter offset: real frequency − rig frequency, in Hz. A 28 MHz IF
	// driving a 144 MHz transverter is +116000000. Kept as a separate on/off so
	// the number survives switching the transverter out of line for an evening.
	OffsetOn       bool   `json:"offset_on"`
	OffsetHz       int64  `json:"offset_hz"`
	DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
	ShareEnabled   bool   `json:"share_enabled"`   // serve CAT to other programs
	SharePort      int    `json:"share_port"`      // TCP port for the rigctl server (default 4532)
	// ShareProto picks WHICH server runs — "rigctl" or "tci". One or the other,
	// not both: they are two ways of asking the same radio the same questions,
	// and a second listener is only a second thing to go wrong.
	ShareProto   string `json:"share_proto"`
	ShareTCIPort int    `json:"share_tci_port"` // WebSocket port for the TCI server (default 40001)
	// PTT hotkey — a keyboard key that keys the transmitter while OpsLog is
	// focused (hold-to-talk, or toggle). Uses the configured Audio → PTT method,
	// falling back to CAT keying when that is VOX/none.
	PTTHotkeyEnabled bool   `json:"ptt_hotkey_enabled"`
	PTTHotkey        string `json:"ptt_hotkey"`        // KeyboardEvent.code, e.g. "Backslash", "Pause"
	PTTHotkeyToggle  bool   `json:"ptt_hotkey_toggle"` // press to key / press again to unkey (vs hold)
}

// ModePreset is a mode entry with default RST values to auto-populate
// the entry form when the user picks this mode.
type ModePreset struct {
	Name           string `json:"name"`
	DefaultRSTSent string `json:"default_rst_sent,omitempty"`
	DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"`
}

// ListsSettings holds the user-customisable dropdown lists used by the
// entry form. Default values match common HF/VHF practice.
type ListsSettings struct {
	Bands      []string     `json:"bands"`
	Modes      []ModePreset `json:"modes"`
	RSTPhone   []string     `json:"rst_phone"`   // RS reports for phone modes
	RSTCW      []string     `json:"rst_cw"`      // RST reports for CW/RTTY/PSK
	RSTDigital []string     `json:"rst_digital"` // dB reports for FT8/FT4/JT…
	// Satellites the station works, offered as a dropdown on the satellite
	// fields. A list rather than free text because SAT_NAME is matched
	// character for character by the awards and by LoTW: "AO-91" and "AO91"
	// are two different satellites to everything downstream, and typing it
	// afresh on every pass is how one of them appears in a log.
	Satellites []string `json:"satellites"`
}

var defaultBands = []string{
	"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m",
	"12m", "10m", "6m", "2m", "70cm", "23cm",
}

// defaultModes is what a fresh install starts with — the modes most operators
// actually use, in the order they are offered. AM and DIGITALVOICE are NOT here
// on purpose: they stay in the Available catalogue for whoever wants them, but
// a list that opens with nine entries when two are never touched costs a scroll
// on every mode change.
var defaultModes = []ModePreset{
	{Name: "SSB", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
	{Name: "CW", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
	{Name: "FT8", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
	{Name: "FT4", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
	{Name: "FT2", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
	{Name: "RTTY", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
	{Name: "PSK31", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
	{Name: "FM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
}

// Default RST report lists, editable in Settings → Modes. Phone carries the
// over-S9 reports (59+10…59+60) plus the full RS grid; CW the full RST grid;
// digital the dB reports +30…-30.
var defaultRSTPhone = buildPhoneRST()
var defaultRSTCW = buildCWRST()
var defaultRSTDigital = buildDigitalRST()

func buildPhoneRST() []string {
	out := []string{"59+60", "59+50", "59+40", "59+30", "59+20", "59+10"}
	for r := 5; r >= 1; r-- {
		for s := 9; s >= 1; s-- {
			out = append(out, fmt.Sprintf("%d%d", r, s))
		}
	}
	return out
}
func buildCWRST() []string {
	var out []string
	for r := 5; r >= 1; r-- {
		for s := 9; s >= 1; s-- {
			for t := 9; t >= 1; t-- {
				out = append(out, fmt.Sprintf("%d%d%d", r, s, t))
			}
		}
	}
	return out
}
func buildDigitalRST() []string {
	var out []string
	for db := 30; db >= -30; db-- {
		sign := "+"
		if db < 0 {
			sign = "-"
		}
		n := db
		if n < 0 {
			n = -n
		}
		out = append(out, fmt.Sprintf("%s%02d", sign, n))
	}
	return out
}

// StationSettings holds the active operator profile. Used to stamp every
// new QSO so we don't ask the user to retype it for each contact.
// Multi-profile support (portable / SOTA …) will layer on top of this.
type StationSettings struct {
	Callsign  string `json:"callsign"`
	Operator  string `json:"operator"`
	MyGrid    string `json:"my_grid"`
	MyCountry string `json:"my_country"`
	MySOTARef string `json:"my_sota_ref"`
	MyPOTARef string `json:"my_pota_ref"`
	// MyIOTA is the island the station operates FROM (ADIF MY_IOTA), e.g. EU-005.
	MyIOTA string `json:"my_iota"`
}

// LookupSettings is the JSON shape exchanged with the frontend.
// Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to
// route lookups: primary first, failsafe on not-found / error.
type LookupSettings struct {
	QRZUser        string `json:"qrz_user"`
	QRZPassword    string `json:"qrz_password"`
	HamQTHUser     string `json:"hamqth_user"`
	HamQTHPassword string `json:"hamqth_password"`
	Primary        string `json:"primary"`
	Failsafe       string `json:"failsafe"`
	DownloadImages bool   `json:"download_images"` // show QRZ profile pictures in the UI
	// QRZPreferNickname logs the name an operator goes BY rather than their
	// registered first+last. QRZ only: HamQTH's <nick> already serves that role
	// there, so the option would be a second switch for a behaviour it has.
	QRZPreferNickname bool `json:"qrz_prefer_nickname"`
	CacheTTLDays      int  `json:"cache_ttl_days"`
}

// App is the application context bound to the Wails runtime.
type App struct {
	ctx      context.Context
	db       *sql.DB
	qso      *qso.Repo
	settings *settings.Store
	profiles *profile.Repo
	lookup   *lookup.Manager
	cache    *lookup.Cache
	cat      *cat.Manager
	// catShare serves OpsLog's CAT link to other programs over the Hamlib NET
	// rigctl protocol. It exists because a native backend OWNS the rig's serial
	// port: without it, choosing native CAT locks WSJT-X and friends out of the
	// radio entirely. nil when the operator has not enabled sharing.
	catShare *rigctld.Server
	// catShareTCI serves the same link to programs built around Expert
	// Electronics' TCI instead. One or the other runs, never both — they answer
	// the same questions about the same radio, and nothing speaks both.
	catShareTCI *tciserver.Server
	dxcc        *dxcc.Manager
	cluster     *cluster.Manager
	// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
	// spot (DXCC/POTA), emitting it to the UI, running alert rules — which can hit
	// a remote MySQL via isWorkedBandMode — and mirroring it to the Flex all used
	// to run inline in the read loop, so a single slow step stopped draining the
	// TCP socket and the whole feed fell behind the node (visible as the grid
	// lagging telnet). The read loop now just enqueues here; one worker does the work.
	// Unbounded FIFO: a burst grows the backlog instead of dropping spots.
	clusterEvents *clusterQueue
	// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
	// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
	// Loaded once, appended to on each log, rebuilt after bulk changes.
	wcbm   map[string]struct{}
	wcbmMu sync.RWMutex
	// clusterStatusIdx caches the whole-logbook maps ClusterSpotStatuses colours
	// spots against (worked entities/calls/counties/POTA/prefixes). Building them
	// per spot batch re-scanned the entire logbook ~20×/second under an RBN
	// firehose — the dominant CPU cost on a large log. Built lazily, treated as an
	// immutable snapshot, and dropped on any logbook change (invalidateAwardStats)
	// or when a setting that shapes the maps flips.
	clusterStatusIdx *clusterStatusCache
	clusterStatusMu  sync.Mutex
	// qsoNumbers maps a QSO id to its chronological position, oldest = 1. Built
	// on demand from one ordered id query and dropped whenever the log changes,
	// alongside the other derived indexes.
	qsoNumbers map[int64]int
	// qsoNumMax is the date of the newest contact the map has numbered, so a QSO
	// logged now can be appended instead of forcing a rebuild.
	qsoNumMax time.Time
	qsoNumMu  sync.Mutex
	// decodeGrids maps a callsign to the 4-character grid it announced in a CQ
	// heard over the WSJT-X UDP link. It is the ONLY source of grids we have for
	// a spot: a DX-cluster line carries the spotter's grid at best, never the
	// DX's, and a per-callsign QRZ lookup under an RBN firehose is out of the
	// question. So grids are known for the stations this station's own receiver
	// decoded — which is exactly the FT8/FT4 watering hole an operator is looking
	// at when grid chasing.
	//
	// In memory only, and bounded: it is a session-local view of who is on the
	// air now, not a database.
	//
	// Bounded in TWO generations. The cap used to drop the whole map, which was
	// survivable while the only source was this station's own decodes — it never
	// reached the ceiling. It is a cliff for any larger feed: every locator in the
	// list would vanish at once, periodically. Keeping the previous generation
	// means a rotation costs the older half and nothing more, and it needs no
	// insertion order, no per-entry timestamp and no bookkeeping on the write
	// path — which "evict the oldest thousand" would all require.
	decodeGrids    map[string]string // current generation, written to
	decodeGridsOld map[string]string // previous generation, still readable
	decodeGridsMu  sync.RWMutex
	// gridStore persists the map across restarts when grid chasing is on. With
	// it, rotation is switched OFF: rotating would drop callsigns the database
	// still holds, and a lookup would then miss something we know. The store
	// bounds itself by age instead, so memory follows how many distinct stations
	// have actually been heard in two years rather than a made-up ceiling.
	gridStore *gridcache.Store

	// chaseNew holds the "new against the log" stations PSK Reporter is hearing
	// near here; chaseNewOn is the option cached for the MQTT goroutine, which
	// consults it once per message and must not reach the settings store.
	chaseNew   *chaseNewStore
	chaseNewOn atomic.Bool
	// chaseBands is the operator's own band list, cached for the same reason:
	// it decides whether a decode is an opportunity or a row in the way, and it
	// is consulted once per message.
	chaseBandsMu sync.RWMutex
	chaseBands   map[string]bool
	// The Chase new panel's OWN band filter, as the set switched off. Read per
	// message like the list above, so an atomic rather than a settings lookup.
	chaseNewBandsOff atomic.Value // map[string]bool
	// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
	// It is the source that makes VHF detection work at all: the cluster and RBN
	// carry a handful of 6 m spots where PSK Reporter carries hundreds.
	pskr *pskr.Watcher
	// pskTgt is the SECOND PSK Reporter connection: the per-station analysis behind
	// the FT decodes panel. Separate from a.pskr because the two want opposite
	// slices of the same feed — everything heard near here, against everything
	// about one callsign anywhere. Built on first use, nil while the panel is
	// closed.
	pskTgtMu sync.Mutex
	pskTgt   *pskrtgt.Watcher
	// Auto-call: the engine, the period being collected, and the last transmit
	// state the decoder reported. One mutex — every field here is touched by the
	// UDP event loop and by the two-second sweeper, and never for long.
	acMu sync.Mutex
	ac   *autocall.Engine
	// Keyed by RECEIVER. Two decoders have two independent slot clocks — an
	// FT8 window and an FT4 one do not even share a period length — and one
	// buffer for both cut every slot into fragments, each judged as a period of
	// its own. The engine then counted a missed period several times a slot.
	acPeriod map[string]string
	acAt     map[string]time.Time
	// acFed is when the last decode of the open period ARRIVED, wall clock. The
	// period's own timestamp cannot answer "has it gone quiet" — it is the start
	// of the slot, thirteen seconds before its decodes exist.
	acFed       map[string]time.Time
	acTR        map[string]int
	acBuf       map[string][]acDecode
	acTX        autocall.TXState
	acReason    string
	acLastJudge time.Time
	// acTXPeriod is the last transmit period already counted — see autoCallNoteTX.
	acTXPeriod string
	// acJudged is the last period key each receiver has been judged on, and
	// acDirty says decodes have arrived for it since. Together they let a period
	// be judged AGAIN when a straggler turns up, with the whole period in hand.
	acJudged map[string]string
	acDirty  map[string]bool
	// What the decodes panel is SHOWING, and whether it is publishing at all.
	// The panel owns the filters; this is its answer, not a second copy of them.
	acVisible   map[string]bool
	acVisibleOn bool
	// Self-spot throttle: when and on what frequency we last announced ourselves.
	// Held in memory only — a restart legitimately re-announces the station.
	selfSpotMu   sync.Mutex
	selfSpotAt   time.Time
	selfSpotHz   int64
	pota         *pota.Cache
	uls          *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
	awardRefs    *awardref.Repo
	qslTemplates *qslcard.Repo
	watchlist    *watchlist.Store     // Tools → Watchlist (global watchlist.json)
	watchAlertMu sync.Mutex           // throttles watchlist alerts…
	watchAlertAt map[string]time.Time // …per entry

	// WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go.
	wsjtHighlightOn atomic.Bool
	// Greying out what is already worked is a sub-option of the same feature —
	// read on the decode path, so an atomic rather than a settings lookup per
	// decode.
	wsjtHLWorkedOn atomic.Bool
	wsjtHLMu       sync.Mutex
	wsjtHLSent     map[string]string
	watchPattern   atomic.Value // auto-contest pattern (string), loaded at startup
	// The named contest callsigns, as a set, loaded with the pattern and read on
	// the same hot path — one spot per station on the band, so no settings
	// lookup and no split per spot.
	watchCalls atomic.Value // map[string]struct{}
	operating  *operating.Repo
	udp        *udp.Manager
	udpRepo    *udp.Repo
	// Program id of the last decoding application that reported its status.
	// Halt Tx is routed by id, and the panel's Halt button must work even when
	// nothing is transmitting at that instant — so the id is remembered from
	// every Status rather than read off a live transmission.
	lastTxID     atomic.Value // string
	extsvc       *extsvc.Manager
	winkeyer     *winkeyer.Manager
	clublog      *clublog.Manager
	clublogMW    *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
	motorAnt     motorAntenna        // motorized antenna (Ultrabeam or SteppIR); nil when disabled
	ubFollowStop chan struct{}       // stops the "follow frequency" loop; nil when off
	// qsoRecPushed records which SOURCE the recorder was started on: the network
	// stream, or a sound device. Switching CAT backends changes the answer, and a
	// recorder left capturing a device that no longer carries the radio records
	// silence without saying so.
	qsoRecPushed   bool
	motorStartMu   sync.Mutex          // serialises startUltrabeam: two restarts at once left two poll loops on one COM port
	motorInhibStop chan struct{}       // stops the "inhibit TX while moving" loop; nil when off
	motorMoveCmdNs atomic.Int64        // unixnano of the last commanded antenna move (grace window)
	motorInhibited atomic.Bool         // TX currently inhibited by the motor-antenna watcher
	antgenius      *antgenius.Client   // Antenna Genius (4O3A) switch (TCP); nil when disabled
	tunergenius    *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
	psu            *psu.Client         // bench power supply over Modbus RTU (serial); nil when disabled
	pgxl           *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
	spe            *spe.Client         // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
	acom           *acom.Client        // legacy pointer: FIRST enabled ACOM amp
	ampsMu         sync.Mutex          // guards ampInsts
	ampInsts       map[string]*ampInst // one running client per enabled configured amplifier, by config ID
	audioMgr       *audio.Manager
	qsoRec         *audio.Recorder // continuous QSO recorder (rolling pre-roll)
	// qsoRecManual marks a take the operator started by hand while automatic
	// recording is OFF. Such a take opened the sound devices itself, so it must
	// close them again when it ends — an operator who records one contact does
	// not expect their microphone held open for the rest of the session.
	qsoRecManual atomic.Bool
	solar        *solar.Manager     // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
	lotwUsers    *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
	scp          *scp.Manager       // Super Check Partial / N+1 callsign master list
	// dxped reads the ADXO announcements and the DX-World feed for the
	// DXpeditions tab. Built lazily: nothing fetches until the tab is opened.
	dxped *dxped.Manager

	// NET Control: persistent net definitions/rosters (global JSON) + the live
	// session (in-memory only — active stations currently in QSO).
	netStore  *netctl.Store
	netMu     sync.Mutex
	netOpenID string     // id of the currently open net ("" = none)
	netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order
	netSeq    int64      // transient-id counter for on-air drafts (decrements: -1, -2, …)

	alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)

	// udpFocus arbitrates the entry field between several decoders running at
	// once — see app_udp_focus.go.
	udpFocus udpFocus

	// Satellites. The elements (where the birds are) and the frequency plan
	// (what to do with the radio) are held apart because they come from
	// different places and change for different reasons — a feed every few
	// days, an operator's correction when a transponder is switched.
	satMu    sync.Mutex
	satStore *sat.Store   // orbital elements, by satellite name
	satBirds *sat.Birds   // uplink/downlink plan
	satFetch *sat.Fetcher // element feeds + the on-disk cache
	// satTrack is the live tracker: the goroutine that walks the radio through a
	// pass. nil when nothing is being tracked.
	satTrackMu sync.Mutex
	satTrack   *satTracker

	cwMu      sync.Mutex        // guards the CW decoder lifecycle
	cwStop    chan struct{}     // stops the CW decoder capture loop; nil when off
	cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
	cwPitchHz int               // manual pitch override (0 = auto / follow Flex)

	startupProfile string // --profile <name> from the command line (activate at startup)
	dvkRecSlot     int    // slot currently being recorded (DVKStartRecord → DVKStopRecord)
	dvkPttKeyed    bool   // we keyed PTT for a voice message; unkey when it ends
	flexDaxRaised  bool   // we pressed the Flex transmit DAX button for it; restore when it ends
	pttMu          sync.Mutex
	// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
	// so closing the QSL Manager — or starting another download — stops the previous
	// one instead of leaving it running against the app-lifetime context (which made
	// a still-running QRZ sync bleed its log into a freshly started LoTW download).
	confDLMu     sync.Mutex
	confDLCancel context.CancelFunc

	// hamlogUnmatched holds the confirmations the last HAMLOG.online import
	// could not place onto a QSO, kept so they can be exported and worked
	// through — see ExportHamlogUnmatched. Replaced by each import, never
	// accumulated: it describes one run, not a history.
	hamlogUnmatchedMu sync.Mutex
	hamlogUnmatched   []qso.QSO
	udpLogMu          sync.Mutex             // serialises UDP auto-log so concurrent packets can't both pass the dedup check
	adifMonMu         sync.Mutex             // guards the ADIF-monitor config (file list + per-file read offsets)
	syncMu            sync.Mutex             // serialises folder synchronisation: config, the seq counter, and the append to our own file
	syncSent          int64                  // changes written to the folder this session
	syncReceived      int64                  // changes taken from the other machines this session
	syncLast          time.Time              // last completed pass, for the status panel
	syncErr           string                 // last folder error, shown in settings — a share that dropped is otherwise invisible
	relayAutoMu       sync.Mutex             // serialises relay auto-control evaluation
	relayAutoLast     map[string]bool        // deviceID|relay → last applied on/off, so we only switch on a real change
	relayAutoOn       atomic.Bool            // cached "auto-control enabled" so the CAT hot path skips work when off
	relayDrvMu        sync.Mutex             // guards the cached relay drivers below
	relayDrv          map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call)
	pttPort           serial.Port            // open serial port while PTT (RTS/DTR) is asserted
	pttKeyedMethod    string                 // "cat" | "rts" | "dtr" while keyed; "" when idle
	pttGen            int64                  // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
	startupErr        string                 // captured for surfacing to the frontend
	settingsScoped    atomic.Bool            // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme
	dbPath            string                 // settings/config database file (settings + profiles); may be a user-chosen location
	logbookPath       string                 // default SQLite logbook file (QSOs), next to the settings db — used when a profile doesn't point elsewhere
	logDbPath         string                 // resolved SQLite file actually backing logDb ("" on MySQL, or when the settings db serves as the logbook) — the file the backup snapshots
	logDb             *sql.DB                // QSO logbook connection — MySQL, a per-profile SQLite file, or the default logbook.db (never the settings db, except on fallback)
	dbBackend         string                 // "sqlite" | "mysql" — the logbook backend actually opened at startup
	dbBackendErr      string                 // non-empty when a configured MySQL backend failed and we fell back to SQLite
	offlineQ          *offlineq.Queue        // ADIF outbox: QSOs logged while the DB was unreachable
	offlineMode       bool                   // last write failed because the DB was unreachable

	catFlexSpots       bool // push cluster spots to the FlexRadio panadapter
	catFlexDVKDax      bool // raise the Flex transmit DAX button around a voice message
	catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
	catFlexDecodeSecs  int  // decode spot display duration (seconds)
	// The panadapter palette, parsed once and re-read when it is saved. Guarded
	// because the cluster read loop touches it on every spot.
	spotColorsMu    sync.Mutex
	spotColorsCache *SpotColors
	// catSig / catShareSig fingerprint the last APPLIED link and share
	// configuration, so re-applying an identical one is a no-op instead of a
	// reconnection. Empty at startup, which is what makes the first call connect.
	catSig           string
	catShareSig      string
	liveActMu        sync.Mutex // guards the entry-strip activity reported for live status
	liveFreqHz       int64      // last freq/band/mode the UI reported (fallback when CAT is off)
	liveBand         string
	liveMode         string
	livePublishTimer *time.Timer    // debounced live-status publish on activity change
	liveLastQSOAt    time.Time      // when this operator last logged a NEW contact — drives online/offline
	liveTableMu      sync.Mutex     // guards liveTableFor
	liveTableFor     *sql.DB        // logbook whose live_status DDL has been ensured (once per connection, not per call)
	awardSnapMu      sync.Mutex     // guards the award QSO snapshot
	awardSnapBuild   sync.Mutex     // serialises BUILDING it — see awardSnapshot
	awardSnapCap     int            // rows the last build produced, the capacity hint for the next
	awardSnap        []qso.QSO      // light-scanned + enriched logbook snapshot reused across award computations
	awardSnapRev     string         // logbook revision the snapshot was built at ("" = none)
	awardSnapUsed    time.Time      // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
	workedRefMu      sync.Mutex     // guards the worked-reference sets
	workedRef        workedRefIndex // per-award references already in the log — see awardnew.go
	webpub           webPublisher   // log-to-website publishing: debounce timer, periodic ticker, last result
	bandOpen         bandOpenState  // sporadic-E / band-opening detector over the spot stream
	dataDir          string         // <exeDir>/data — holds config.json, logs, cty.dat

	// shuttingDown gates beforeClose re-entry: the first user attempt to
	// close fires shutdown tasks (backup, future LoTW upload, ...) while
	// blocking the window close; the subsequent programmatic Quit() call
	// must be allowed through.
	shuttingDown bool
	compact      bool // window is in the compact one-row mode (skip saving its tiny size as the normal geometry)
	// Geometry captured when ENTERING compact mode, restored on the way back —
	// otherwise leaving compact snapped the window to a hardcoded 1400×900 at
	// wherever the compact strip had been dragged.
	preCompactW, preCompactH int
	preCompactX, preCompactY int
	preCompactMax            bool
	preCompactValid          bool

	// Cached operator location used to compute distance/bearing for
	// cluster spots. Refreshed on profile activation; zero means
	// "unknown" and we skip the per-spot computation.
	opLat  float64
	opLon  float64
	opSet  bool
	opCall string // active profile callsign, cached for outbound UDP emitters
	opGrid string // active profile locator, cached the same way and for the same reason

	// Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only
	// send when the frequency or mode actually changes.
	udpLastFreqHz int64
	udpLastMode   string
}

// gridToLatLon and haversineKm live in internal/geo, which the internal
// packages can import — package main cannot be imported by anything. These
// forward so there is exactly ONE implementation: the PSK Reporter watcher and
// the web publisher measure the same path the cluster does, and a bearing that
// disagrees with itself between two panels is a fault nobody reports, because
// each screen looks plausible alone.
func gridToLatLon(grid string) (lat, lon float64, ok bool) { return geo.GridToLatLon(grid) }

func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
	return geo.HaversineKm(lat1, lon1, lat2, lon2)
}

// initialBearingDeg returns the initial great-circle bearing (azimuth) in
// degrees [0, 360) from (lat1, lon1) towards (lat2, lon2). This is the
// "short path" heading.
func initialBearingDeg(lat1, lon1, lat2, lon2 float64) float64 {
	rad := math.Pi / 180.0
	dLon := (lon2 - lon1) * rad
	y := math.Sin(dLon) * math.Cos(lat2*rad)
	x := math.Cos(lat1*rad)*math.Sin(lat2*rad) -
		math.Sin(lat1*rad)*math.Cos(lat2*rad)*math.Cos(dLon)
	deg := math.Atan2(y, x) / rad
	if deg < 0 {
		deg += 360
	}
	return deg
}

// refreshOperatorGrid reloads the active profile and caches its grid as
// lat/lon. Called at startup and after profile activation so the cluster
// onSpot callback can compute distance/bearing without hitting the DB
// per spot.
func (a *App) refreshOperatorGrid() {
	a.opSet = false
	if a.profiles == nil || a.ctx == nil {
		return
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return
	}
	a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign))
	a.opGrid = strings.ToUpper(strings.TrimSpace(p.MyGrid))
	lat, lon, ok := gridToLatLon(p.MyGrid)
	if !ok {
		return
	}
	a.opLat = lat
	a.opLon = lon
	a.opSet = true
}

// emitRadioUDP forwards the rig's frequency/mode to any enabled outbound UDP
// emitters (PstRotator, N1MM RadioInfo). Deduped so it only fires when the
// operating frequency or mode actually changes. The send runs off the CAT
// goroutine so a slow/failed datagram can't stall polling.
func (a *App) emitRadioUDP(s cat.RigState) {
	if a.udp == nil {
		return
	}
	rx := s.FreqHz
	if s.RxFreqHz != 0 { // split: RxFreqHz is the active/listening VFO
		rx = s.RxFreqHz
	}
	if rx <= 0 {
		return
	}
	if rx == a.udpLastFreqHz && s.Mode == a.udpLastMode {
		return
	}
	a.udpLastFreqHz = rx
	a.udpLastMode = s.Mode
	rs := udp.RadioState{
		StationName: a.opCall,
		OpCall:      a.opCall,
		RxFreqHz:    rx,
		TxFreqHz:    s.FreqHz,
		Mode:        s.Mode,
	}
	go a.udp.EmitRadioState(rs)
}

// dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface
// without making the lookup package import dxcc.
type dxccAdapter struct{ m *dxcc.Manager }

func (a dxccAdapter) Resolve(call string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool) {
	if a.m == nil {
		return
	}
	mm, found := a.m.Lookup(call)
	if !found || mm.Entity == nil {
		return
	}
	return dxcc.EntityDXCC(mm.Entity.Name), mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
}

func NewApp() *App { return &App{} }

func (a *App) startup(ctx context.Context) {
	a.ctx = ctx
	// One line in the startup log too, not only in OpsLog's own: the two
	// together say whether a launch that produced no window got as far as our
	// code at all, which is the fork every "it does not start" report turns on.
	startupReached.Store(true)
	bootLog("OnStartup reached")

	dataDir, err := userDataDir()
	if err != nil {
		a.startupErr = "cannot resolve data dir: " + err.Error()
		fmt.Println("OpsLog:", a.startupErr)
		return
	}
	if err := os.MkdirAll(dataDir, 0o755); err != nil {
		a.startupErr = "cannot create data dir: " + err.Error()
		fmt.Println("OpsLog:", a.startupErr)
		return
	}
	a.dataDir = dataDir
	// The watchlist needs nothing but its file, so it opens FIRST. It used to be
	// created after the logbook connect, and a remote MySQL dial of 10-30 s left
	// the tab saying "Watchlist: 0" until the next refresh tick — twenty seconds
	// of an empty list for a JSON file that reads in a millisecond. (The
	// auto-contest pattern still loads later, once the settings store is open.)
	a.watchlist = watchlist.New(filepath.Join(dataDir, "watchlist.json"))
	a.watchAlertAt = map[string]time.Time{}
	// Settings/config database (settings + profiles). Fresh installs use
	// settings.db; existing installs keep their opslog.db (which also held the
	// QSOs before they were split into a dedicated logbook file — see below).
	settingsDefault := filepath.Join(dataDir, "settings.db")
	legacyOpslog := filepath.Join(dataDir, "opslog.db")
	if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
		a.dbPath = legacyOpslog
	} else {
		a.dbPath = settingsDefault
	}
	usingDefault := true
	// config.json (in the data dir) may point the database to a user-chosen
	// location — e.g. another drive or a synced folder, so it survives a
	// Windows reinstall. It lives OUTSIDE the DB since we must know the path
	// before opening it.
	if custom := readDBPointer(dataDir); custom != "" {
		// Honour any chosen database that still exists on disk. Only fall back to
		// this folder's own default when the pointed-to file is genuinely GONE —
		// the portable folder was copied to a machine without the original drive,
		// or the path was deleted — and clear the now-dangling pointer so it stops
		// trying. (The old heuristic guessed "folder moved" from the file name and
		// wrongly discarded valid logbooks named …/data/opslog.db, sending the user
		// back to the default every launch — the save appeared not to stick.)
		if _, err := os.Stat(custom); err != nil {
			fmt.Printf("OpsLog: chosen database %q not found (%v) — using %s\n", custom, err, a.dbPath)
			_ = writeDBPointer(dataDir, "")
		} else {
			a.dbPath = custom
			usingDefault = false
		}
	}
	// A rename in a previous session left the OLD file to delete now that it's no
	// longer open. Only ever delete a file that ISN'T the one we're about to use.
	if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" {
		old := strings.TrimSpace(boot.DeletePending)
		if old != a.dbPath {
			for _, p := range []string{old, old + "-wal", old + "-shm"} {
				if err := os.Remove(p); err == nil {
					fmt.Printf("OpsLog: removed old database file %s\n", p)
				}
			}
		}
		boot.DeletePending = ""
		_ = writeBootstrap(dataDir, boot)
	}
	if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
		a.startupErr = "cannot create db folder: " + err.Error()
		fmt.Println("OpsLog:", a.startupErr)
		return
	}
	// One-shot rename for users coming from the HamLog era (default location only).
	if usingDefault {
		if _, err := os.Stat(a.dbPath); os.IsNotExist(err) {
			oldDB := filepath.Join(dataDir, "hamlog.db")
			if _, err := os.Stat(oldDB); err == nil {
				_ = os.Rename(oldDB, a.dbPath)
			}
		}
	}
	if _, err := applog.Init(dataDir); err != nil {
		fmt.Println("OpsLog: log init:", err)
	}
	// Route CAT/OmniRig debug lines into the unified app log (they used to go
	// to a separate cat.log in the old HamLog folder, which users couldn't find).
	cat.LogSink = applog.Printf
	audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
	// A recorder that captures nothing must reach the OPERATOR, not just the log:
	// they are mid-QSO, and by the time they notice at save time the audio is
	// gone for good.
	audio.AlertSink = func(format string, args ...any) { a.toast(fmt.Sprintf(format, args...)) }
	extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
	lookup.LogSink = applog.Printf // which call was queried, and why a portable lookup fell back
	audio.Logf = applog.Printf     // why the Listening device stayed silent, above all
	db.LogSink = applog.Printf     // which schema migrations ran, and how long they took
	// Version and executable FIRST, before anything else can fail. Diagnosing a
	// report means knowing which build produced the log, and that was previously
	// impossible: the copy someone is actually running is not always the one they
	// think they installed, and the version shown in the UI is the only clue.
	exe, _ := os.Executable()
	// Everything before this line is outside our control: Windows mapping a 30 MB
	// binary, an antivirus reading all of it, and Wails creating the WebView2
	// environment. Logging it separates "OpsLog is slow" from "starting OpsLog is
	// slow", which are two different problems with two different fixes.
	applog.Printf("startup: OpsLog %s — %s (%.0f ms before startup: exe load + WebView2)",
		appVersion, exe, float64(time.Since(processStart).Microseconds())/1000)
	applog.Printf("startup: data dir = %s", dataDir)
	// The local SQLite file ALWAYS holds per-operator configuration — settings,
	// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
	// lists, the lookup cache. Only the QSO logbook itself may live on a shared
	// MySQL server (the multi-operator feature). Keeping config local means it
	// stays instant even when the shared logbook is on a far-away MySQL.
	conn, err := db.Open(a.dbPath)
	if err != nil {
		a.startupErr = "cannot open db: " + err.Error()
		// In the rotating log too: the GUI subsystem discards stdout, and this
		// exact failure once hid for a whole morning behind a println.
		applog.Printf("startup: %s", a.startupErr)
		return
	}
	a.db = conn

	// The QSO logbook lives in its OWN file (logbook.db) next to the settings db,
	// so QSOs never share the settings/profiles database. One-time split for
	// existing installs: if there's no logbook file yet but the settings db
	// already holds QSOs (the legacy combined opslog.db), seed logbook.db with a
	// clean copy (VACUUM INTO) — the contacts move to the logbook while the
	// originals stay in the settings db as an untouched backup. Non-destructive.
	a.logbookPath = filepath.Join(filepath.Dir(a.dbPath), "logbook.db")
	if !fileExists(a.logbookPath) && sqliteHasQSOs(a.db) {
		esc := strings.ReplaceAll(a.logbookPath, "'", "''")
		if _, verr := a.db.Exec("VACUUM INTO '" + esc + "'"); verr != nil {
			applog.Printf("logbook split: VACUUM INTO %s failed (%v) — the settings db will serve as the logbook", a.logbookPath, verr)
			a.logbookPath = "" // fall back to using the settings db as the logbook
		} else {
			fmt.Printf("OpsLog: split logbook — seeded %s from the existing database (originals kept as backup)\n", a.logbookPath)
		}
	}

	// Wire the LOCAL config repos first — they're backed by the already-open
	// SQLite file, so the station/profiles/settings are ready instantly. Doing
	// this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
	// doesn't briefly think the station is unconfigured while MySQL is dialing.
	a.settings = settings.NewStore(conn)
	a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set
	a.profiles = profile.NewRepo(conn)
	// Determine the active profile and scope the settings store to it FIRST:
	// every setting is per-profile, so all settings-dependent wiring below must
	// read the active profile's values.
	active, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{
		Callsign: keyStationCallsign,
		Operator: keyStationOperator,
		MyGrid:   keyStationMyGrid,
		Country:  keyStationCountry,
		SOTA:     keyStationSOTA,
		POTA:     keyStationPOTA,
	})
	if err != nil {
		fmt.Println("OpsLog: EnsureDefault profile:", err)
	}
	// A "--profile <name>" command-line argument selects which profile to start
	// on (so a desktop shortcut can launch OpsLog straight into F4BPO or TM2Q).
	// Match by name, case-insensitive; activate it before any per-profile wiring.
	if want := strings.TrimSpace(a.startupProfile); want != "" {
		if list, lerr := a.profiles.List(a.ctx); lerr == nil {
			for _, p := range list {
				if strings.EqualFold(p.Name, want) {
					if serr := a.profiles.SetActive(a.ctx, p.ID); serr == nil {
						active = p
						fmt.Printf("OpsLog: started on profile %q (from --profile)\n", p.Name)
					}
					break
				}
			}
		}
	}
	a.settings.SetProfile(active.ID)
	a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
	// Evaluate the build call gate on the ACTIVE profile's station callsign, before
	// any further wiring. A denied call exits here, silently. Only the active
	// profile is checked (not every profile) so that "--profile <other>" is always
	// a way back in — a blocked call is otherwise unrecoverable, since the process
	// quits before the operator can change it.
	enforceCallGate(active.Callsign)
	// US county resolver — its own local SQLite (data/uls.db), populated on demand
	// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
	// fatal: county resolution simply stays inert until the operator downloads it.
	if store, err := uls.Open(filepath.Join(a.dataDir, "uls.db")); err != nil {
		applog.Printf("uls: open failed (county resolution disabled): %v", err)
	} else {
		a.uls = store
	}
	a.awardRefs = awardref.NewRepo(conn)
	a.qslTemplates = qslcard.NewRepo(conn)
	a.migrateAwardDefs()      // upgrade legacy award definitions (enable + new fields)
	a.seedBuiltinReferences() // first-run: populate built-in award reference lists
	a.mirrorAwards()          // keep <data>/awards/*.json in step with the database
	a.operating = operating.NewRepo(conn)
	a.udpRepo = udp.NewRepo(conn)
	a.udp = udp.NewManager(a.udpRepo)
	// A program heard for the first time is asked to replay the decodes already
	// on its screen, so the FT decodes panel starts full instead of waiting a
	// period. Replayed decodes arrive marked not-new and are shown but never
	// auto-answered.
	a.udp.SetOnNewInstance(func(id string) { _ = a.udp.SendReplay(id) })
	go a.consumeUDPEvents()
	a.cache = lookup.NewCache(conn, 30*24*time.Hour)
	a.lookup = lookup.NewManager(a.cache)
	a.reloadLookupProviders()

	// CAT manager: emit pushes state to the frontend via Wails events, and
	// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
	// Started HERE — before the (possibly slow, remote) MySQL logbook connect
	// below — because the rig link only needs the local settings, and blocking it
	// behind a 10–30s MySQL dial made the FlexRadio (and every CAT backend) take
	// that long to come up at launch.
	a.cat = cat.NewManager(func(s cat.RigState) {
		// DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a
		// burst of these lines = the frontend is being re-rendered rapidly (the
		// "screen flickers" symptom). Shows WHAT is churning — connection flap,
		// or freq/split/mode oscillating between slices during FT8.
		// The error goes in the line too. Without it a disconnect read
		// "connected=false freq=0" and said nothing about WHY — the reason was in
		// RigState.Error, which only ever reached a tooltip, so every report of
		// "it stopped connecting" arrived without the one fact that explains it.
		applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s%s",
			s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band,
			func() string {
				if s.Error == "" {
					return ""
				}
				return " err=" + s.Error
			}())
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "cat:state", s)
		}
		a.emitRadioUDP(s)
		// Custom outbound rows bound to a band change (antenna switches). The
		// comparison against the last band lives in the helper, so this callback
		// firing on every user-relevant change costs one string compare.
		a.udpTriggerBandChange(s)
		// Feed the frequency to any amplifier we are pretending to be a radio
		// for. Just two atomic stores per amp — the reply itself is built when
		// the amp polls, so a fast-tuning VFO costs nothing here.
		a.feedAmpBandFollow(s)
		// Drive station relays by the current frequency/band (PstRotator-style
		// automatic control). Cheap cached-flag check keeps this a no-op when the
		// feature is off; when on, run off this callback so a slow relay board never
		// stalls rig-state processing.
		if a.relayAutoOn.Load() {
			go a.applyRelayAuto(s.FreqHz, s.Band)
		}
	})
	// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used. Created
	// BEFORE the CAT link comes up, and it matters: a network Icom with RX
	// audio enabled builds its audio sink inside reloadCAT, gated on audioMgr —
	// when the manager was created later, every fresh launch connected with
	// audio silently off, and the operator had to untick/save/retick the option
	// to hear anything.
	a.audioMgr = audio.NewManager(func() {
		st := a.dvkStatus()
		// When a voice message finishes (or is stopped), drop the PTT we keyed
		// for it — but tag the release with the current key generation so it
		// can't cut a transmission a newer message already started.
		if !st.Playing {
			a.pttMu.Lock()
			keyed := a.dvkPttKeyed
			gen := a.pttGen
			if keyed {
				a.dvkPttKeyed = false
			}
			a.pttMu.Unlock()
			if keyed {
				go a.dvkUnkeyPTT(gen)
			}
			// And give the microphone back, if we took it (see raiseFlexDVKDax).
			// Off the callback's goroutine: this talks to the radio, and the
			// audio manager is reporting a state change, not waiting on us.
			go a.lowerFlexDVKDax()
		}
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "audio:status", st)
		}
	})
	a.qsoRec = audio.NewRecorder()
	if a.audioMgr != nil {
		// A running monitor picks the new level up immediately: the operator is
		// listening while they move the slider, and asking them to stop and
		// restart it to hear the change is how a working control gets reported
		// as broken.
		if cfg, err := a.GetAudioSettings(); err == nil {
			a.audioMgr.SetMonitorGain(cfg.FromGain)
		}
	}
	a.startQSORecorderIfEnabled()

	a.reloadCAT()

	// The QSO logbook lives where the ACTIVE PROFILE points it: the local SQLite
	// file, or a per-profile shared MySQL database. Switching profiles switches
	// the logbook (see switchLogbook). One-time: adopt any legacy config.json
	// MySQL config into the active profile so existing setups keep working.
	a.adoptBootstrapMySQL(&active)
	logbookConn, backend, lerr := a.connectLogbook(active.DB)
	if lerr != nil {
		applog.Printf("startup: logbook open failed (%v) — falling back to SQLite logbook", lerr)
		a.dbBackendErr = strings.TrimPrefix(lerr.Error(), "")
		logbookConn, backend = conn, "sqlite"
		a.logDbPath = "" // fell back to the settings db as the logbook — backup snapshots a.db
	}
	a.dbBackend = backend
	// db.Dialect describes the LOGBOOK backend — the only place SQL actually
	// varies (qso JSON extraction). Config repos always run on SQLite.
	db.SetDialect(backend)
	applog.Printf("startup: logbook backend = %s", backend)
	a.logDb = logbookConn
	a.qso = qso.NewRepo(logbookConn)
	a.tidyLogbookSchema(logbookConn)
	a.backfillAwardRefsOnce()                     // one-time: materialise award_refs for pre-existing QSOs
	go a.rebuildWorkedIndex()                     // in-memory worked-index for per-spot alert checks
	go a.adifMonitorLoop()                        // watch external ADIF files (fldigi, N1MM…) for new QSOs
	go a.folderSyncLoop()                         // one operator, several PCs: read the other machines' change logs
	a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag

	// cty.dat for offline DXCC / country resolution. Cached on disk; first
	// run downloads it from country-files.com in the background so startup
	// stays fast even if the network is slow.
	a.dxcc = dxcc.NewManager(dataDir)
	a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc})
	go func() {
		if err := a.dxcc.EnsureLoaded(a.ctx); err != nil {
			fmt.Println("OpsLog: cty.dat unavailable —", err)
			return
		}
		fmt.Println("OpsLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
	}()
	// Offline safety net: with the shared MySQL logbook, losing the network would
	// otherwise mean you simply cannot log. Instead, a QSO that can't reach the DB
	// is parked in a local ADIF outbox and replayed automatically once the DB
	// answers again. Anything already waiting from a previous session (a crash, a
	// quit while offline) is replayed at startup.
	a.offlineQ = newOfflineQueue(dataDir)
	if n := a.offlineQ.Count(); n > 0 {
		a.offlineMode = true
		applog.Printf("offline: %d QSO(s) waiting in %s", n, a.offlineQ.Path())
	}
	go a.offlineReplayLoop()
	go func() {
		if a.offlineQ.Count() > 0 {
			if n, err := a.replayOfflineQueue(); err == nil && n > 0 {
				applog.Printf("offline: replayed %d QSO(s) left over from a previous session", n)
			}
		}
	}()

	// ClubLog Country File (cty.xml) — date-ranged callsign exceptions that
	// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
	// demand. Resolution applied only when the user enables it.
	a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir)

	// LoTW user-activity list (who's a LoTW user + last upload). Loads the cached
	// CSV if present, and auto-downloads in the background when it's missing or
	// older than a DAY — the list carries each station's last-upload date, so a
	// stale cache would misreport recency (a station that uploaded 2 days ago must
	// show as recent, which only works if we refresh roughly daily). Manual
	// refresh is in Settings → LoTW.
	a.lotwUsers = lotwusers.NewManager(dataDir)
	go func() {
		if a.lotwUsers.Count() == 0 || time.Since(a.lotwUsers.Updated()) > 24*time.Hour {
			if n, err := a.lotwUsers.Download(context.Background()); err == nil {
				applog.Printf("lotwusers: auto-downloaded %d LoTW users", n)
				if a.ctx != nil {
					wruntime.EventsEmit(a.ctx, "lotwusers:updated")
				}
			} else {
				applog.Printf("lotwusers: auto-download failed: %v", err)
			}
		}
	}()

	// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is
	// enabled, auto-(re)download it if missing or older than a week.
	a.scp = scp.NewManager(dataDir)
	if v, _ := a.settings.Get(a.ctx, keyScpClublog); v == "1" {
		a.scp.SetClublogEnabled(true)
	}
	go func() {
		if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
			return
		}
		if a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour {
			if n, err := a.scp.Download(context.Background()); err == nil {
				applog.Printf("scp: auto-downloaded %d callsigns", n)
				if a.ctx != nil {
					wruntime.EventsEmit(a.ctx, "scp:updated")
				}
			} else {
				applog.Printf("scp: auto-download failed: %v", err)
			}
		}
	}()
	go func() {
		_ = a.clublog.EnsureLoaded()
		// Auto-refresh a missing/stale country file (ClubLog adds date-ranged
		// exceptions constantly — e.g. event calls like IR0WWA get new SARDINIA
		// periods). Without this a stale cache resolves recent event QSOs to the
		// base country. Best-effort; the on-disk copy still serves if it fails.
		if a.clublog.NeedsRefresh(3 * 24 * time.Hour) {
			if err := a.clublog.Download(context.Background()); err != nil {
				applog.Printf("clublog: auto-refresh failed: %v", err)
			}
		}
		if a.clublog.Loaded() {
			d, n := a.clublog.Info()
			fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
		}
	}()

	// ClubLog "Most Wanted" DXCC ranking (opt-in, Settings → General). Loaded from
	// the cached JSON, then refreshed daily and whenever the operator's callsign
	// changes (the ranking is personalised per callsign). Best-effort.
	a.clublogMW = clublog.NewMostWanted(dataDir)
	go func() {
		if a.settings == nil {
			return
		}
		if v, _ := a.settings.Get(a.ctx, keyClublogMostWanted); v != "1" {
			return // feature off — don't touch the network
		}
		_ = a.clublogMW.LoadFromDisk()
		call := a.activeCallsign()
		if a.clublogMW.NeedsRefresh(call, 24*time.Hour) {
			if err := a.clublogMW.Download(context.Background(), call); err != nil {
				applog.Printf("clublog most-wanted: auto-refresh failed: %v", err)
			}
		}
		if a.clublogMW.Loaded() {
			c, d, n := a.clublogMW.Info()
			applog.Printf("clublog most-wanted: loaded %d entities for %s (%s)", n, c, d)
		}
	}()

	// POTA: background poller of api.pota.app so cluster spots can be tagged
	// when the DX station is currently activating a park. Best-effort.
	a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) })
	a.startWatchlistClubLog()
	a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, ""))))
	a.watchCalls.Store(parseContestCalls(a.settingOr(keyWatchlistContestCalls, "")))
	a.wsjtHighlightOn.Store(a.settingOr(keyWsjtHighlight, "0") == "1")
	a.wsjtHLWorkedOn.Store(a.settingOr(keyWsjtHLWorked, "0") == "1")
	go a.pota.Run(a.ctx)

	// DX Cluster (multi-server): the spot callback enriches each spot
	// with country + continent via cty.dat BEFORE emitting it, so the UI
	// renders the row with all metadata already filled (no flicker of
	// empty Country / Cont columns while the batch status fetch runs).
	// Cluster events are processed OFF the socket-read goroutine (see clusterEvent /
	// clusterEventWorker). The queue is UNBOUNDED so no spot is ever dropped: an
	// SH/DX or RBN burst grows the backlog and drains once enrichment catches up,
	// while the read loop still never blocks (that was the cause of the feed falling
	// behind telnet).
	a.clusterEvents = newClusterQueue()
	go a.clusterEventWorker()
	go a.awardSnapshotJanitor() // give the award snapshot's memory back once it goes cold
	a.restartWebPublishTimer()  // periodic log-to-website refresh, if configured

	a.cluster = cluster.NewManager(
		// onSpot / onLine run on the session's socket-read goroutine, so they must
		// do nothing slow: just enqueue and return, keeping the TCP socket drained.
		func(s cluster.Spot) { a.enqueueClusterEvent(clusterEvent{spot: &s}) },
		func() {
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "cluster:state", a.cluster.Status())
			}
		},
		// Raw traffic → the cluster console. Spots are parsed out of this stream,
		// but SH/DX, WHO, the MOTD and error replies are NOT spots — without this
		// they were dropped on the floor and the command box looked inert.
		func(l cluster.Line) { a.enqueueClusterEvent(clusterEvent{line: &l}) },
	)
	a.refreshOperatorGrid()
	if cs, _ := a.clusterAutoConnect(); cs {
		a.startAllEnabledClusters()
	}
	if errs := a.udp.Reload(a.ctx); len(errs) > 0 {
		for _, e := range errs {
			fmt.Println("OpsLog: udp:", e)
		}
	}

	// External-service uploaders (QRZ.com …). The manager is fed config
	// from settings and host callbacks to build ADIF, stamp the upload
	// status and surface errors to the UI.
	a.extsvc = extsvc.NewManager(extsvc.Deps{
		BuildADIF:      a.buildUploadADIF,
		MarkUploaded:   a.markExtUploaded,
		NotifyError:    a.notifyExtError,
		ShouldUpload:   a.extShouldUpload,
		StationCallOf:  a.stationCallOf,
		CloseUploadIDs: a.closeUploadIDs,
		Logf:           applog.Printf,
	})
	a.extsvc.SetConfig(a.loadExternalServices())

	// WinKeyer CW keyer (serial). Created idle; the UI connects on demand.
	a.winkeyer = winkeyer.NewManager(
		func(s winkeyer.Status) {
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "winkeyer:status", s)
			}
		},
		func(ch string) {
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "winkeyer:echo", ch)
			}
		},
	)
	a.winkeyer.OnSlowBoot(a.noteKeyerSlowBoot)

	// Live space-weather (solar flux, sunspots, A/K indices) for the header strip
	// and per-QSO stamping. Refreshes in the background; pushes a UI event on each
	// update so the strip re-reads.
	a.solar = solar.NewManager(func() {
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "solar:update")
		}
	})
	a.solar.Start()

	// NET Control store (global JSON, shared across logbooks).
	if ns, err := netctl.Open(filepath.Join(a.dataDir, "nets.json")); err != nil {
		applog.Printf("netctl: open failed: %v", err)
	} else {
		a.netStore = ns
	}

	// DX-cluster spot alert rules (global JSON).
	if as, err := alerts.Open(filepath.Join(a.dataDir, "alerts.json")); err != nil {
		applog.Printf("alerts: open failed: %v", err)
	} else {
		a.alertStore = as
	}

	// Satellites: the cached elements and the frequency plan. Local files only —
	// any element fetch it decides to make goes to the network on its own.
	a.startSatellites()

	// Ultrabeam antenna: connect in the background if enabled.
	a.startUltrabeam()
	// Antenna Genius switch: connect in the background if enabled.
	a.startAntGenius()
	// Tuner Genius XL ATU: connect in the background if enabled.
	a.startTunerGenius()
	// PowerGenius XL amp fan control: connect in the background if enabled.
	a.startAmps()
	// Bench power supply (Modbus RTU): connect in the background if enabled.
	a.startPSU()

	// Autostart: launch the active profile's configured external programs that
	// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
	// so a slow-launching app never delays the UI.
	go func() {
		for _, r := range a.LaunchAutostartPrograms() {
			applog.Printf("autostart: %s — %s (%s)", r.Name, r.Status, r.Message)
		}
	}()

	// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
	// when disabled in Preferences or until the PostHog key is configured.
	go a.sendTelemetryHeartbeat()
	go a.liveStatusLoop()  // multi-op: heartbeat current activity to shared MySQL
	go a.chatLoop()        // multi-op: poll the shared chat + heartbeat presence
	go a.hrdlogOnAirLoop() // publish frequency/mode/rig on hrdlog.net when enabled
	// Locator store BEFORE the feed: the feed asks whether it exists to decide
	// which bands to subscribe to. Same for the chase-new option, read into its
	// atomic here so the feed and the MQTT goroutine agree from the first message.
	a.startGridCache()
	a.chaseNew = newChaseNewStore()
	a.refreshChaseNew()
	a.refreshChaseNewBands()
	// PSK Reporter. After the operator's grid is known: without it there is no
	// distance to measure and no receiver squares to filter on, so it stays down.
	a.startBandOpenFeed()
	// Auto-call. After the watch list and the logbook: the ladder is meaningless
	// without either, and it must never call anybody on a log it cannot read.
	a.startAutoCall()
	// One-time tidy-up of a field nothing used to record. Background, once.
	a.backfillDistancesOnce()

	fmt.Println("OpsLog: db ready at", a.dbPath)
}

// domReady fires once the frontend has painted. The window was created hidden
// (StartHidden), so we place it at its saved geometry FIRST and only then show
// it — it appears already sized and positioned, never jumping into place after
// launch. Show is unconditional: whatever happens above, the window must appear.
func (a *App) domReady(ctx context.Context) {
	bootLog("OnDomReady reached — the window has painted")
	clearLaunchMarker()
	a.restoreWindowPosition()
	wruntime.WindowShow(ctx)
	// The one number that matches what the operator actually experiences: click
	// to window. Anything else measures a part of it.
	applog.Printf("startup: window visible %.0f ms after launch",
		float64(time.Since(processStart).Microseconds())/1000)
}

// StartupStatus returns a diagnostic snapshot for the frontend.
// dbPath is always populated; err is empty when the app is healthy.
type StartupStatus struct {
	OK     bool   `json:"ok"`
	Err    string `json:"err"`
	DBPath string `json:"db_path"`
}

// GetStartupStatus exposes whatever happened during startup so the UI
// can show a useful error instead of just "db not initialized".
// LogUIError records a crash or unhandled error from the interface in the app
// log, with whatever context the window can give.
//
// Until this existed, a render error emptied the window and left NOTHING: no
// line in opslog.log, no dialog. Two separate white-screen reports — one after
// logging a 10 GHz QSO, one on starting an auto-call — could not be diagnosed at
// all, because the only evidence lived in a console the operator had to know to
// open. A fault the user cannot report is a fault that cannot be fixed.
func (a *App) LogUIError(kind, message, stack string) {
	msg := strings.TrimSpace(message)
	if msg == "" {
		msg = "(no message)"
	}
	applog.Printf("ui %s: %s", strings.TrimSpace(kind), msg)
	if st := strings.TrimSpace(stack); st != "" {
		// The stack goes in whole: minified frames are still the difference between
		// "somewhere in the app" and one component.
		applog.Printf("ui stack: %s", st)
	}
}

func (a *App) GetStartupStatus() StartupStatus {
	return StartupStatus{
		OK:     a.startupErr == "",
		Err:    a.startupErr,
		DBPath: a.dbPath,
	}
}

// armExitWatchdog guarantees the process dies.
//
// A graceful shutdown is preferable and is what everything below attempts; this
// only fires when that has already failed. By then the log says which step never
// returned, and the databases are either closed or safe to leave — SQLite's WAL
// survives a hard exit, which a zombie process does not.
func (a *App) armExitWatchdog(after time.Duration) {
	go func() {
		time.Sleep(after)
		applog.Printf("shutdown: still not finished after %s — forcing exit "+
			"(the last 'shutdown:' line above names the step that hung)", after)
		applog.Close()
		os.Exit(0)
	}()
}

// beforeClose intercepts the window-close event so we can run shutdown
// tasks (backup, future LoTW upload, ...) while showing a progress modal
// to the user. Returns true the first time to block the close; the
// goroutine eventually calls wruntime.Quit() which re-enters this method
// with shuttingDown=true and we let the close proceed.
func (a *App) beforeClose(ctx context.Context) bool {
	if a.shuttingDown {
		return false
	}
	a.shuttingDown = true
	applog.Printf("shutdown: close requested")
	// From here the application MUST end. Everything below waits on hardware and
	// databases, and any one of them hanging used to leave a window that would
	// not close and a process the operator had to kill from the task manager —
	// which then blocked the next launch (single-instance guard) and the restart
	// after an update. The watchdog is the promise that it ends anyway.
	a.armExitWatchdog(30 * time.Second)

	// Capture geometry now, before any shutdown UI can resize the window, so the
	// next launch reopens exactly here.
	a.saveWindowState()

	steps := a.plannedShutdownSteps()
	if len(steps) == 0 {
		// Nothing to do — exit immediately, no need to flash a modal.
		return false
	}
	go a.runShutdownTasks(ctx, steps)
	return true
}

// shutdownStep is emitted to the frontend so the progress modal can
// render the task list and update each row's state as work progresses.
type shutdownStep struct {
	ID     string `json:"id"`
	Label  string `json:"label"`
	Status string `json:"status"` // "pending" | "running" | "done" | "error"
	Detail string `json:"detail,omitempty"`
}

// plannedShutdownSteps returns the tasks that will actually run, so the
// UI knows the full checklist up front. Right now that's just the backup
// (when enabled and not yet done today); LoTW upload, eQSL upload, etc.
// will append to this list as they land.
func (a *App) plannedShutdownSteps() []shutdownStep {
	var out []shutdownStep
	if s, err := a.GetBackupSettings(); err == nil && s.Enabled {
		folder := s.Folder
		if folder == "" {
			folder = s.DefaultFolder
		}
		// "Back up on every exit" bypasses the once-a-day gate.
		if s.EveryExit || !backup.HasBackupToday(folder) {
			out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
		}
	}
	if a.extsvc != nil {
		if n := a.extsvc.CloseUploadCount(); n > 0 {
			out = append(out, shutdownStep{
				ID:     "extsvc-upload",
				Label:  fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
				Status: "pending",
			})
		}
	}
	return out
}

func (a *App) emitShutdownEvent(name string, payload any) {
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, name, payload)
	}
}

// runShutdownTasks executes every planned shutdown task in order,
// emitting progress events at each transition so the frontend modal
// stays in sync. Errors don't abort the sequence — we still want to
// give later steps a chance and ultimately close the app.
func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
	a.emitShutdownEvent("shutdown:start", steps)
	for i := range steps {
		steps[i].Status = "running"
		a.emitShutdownEvent("shutdown:update", steps)
		var err error
		switch steps[i].ID {
		case "backup":
			err = a.runBackupForShutdown()
		case "extsvc-upload":
			n := a.extsvc.FlushOnClose()
			steps[i].Detail = fmt.Sprintf("%d uploaded", n)
		}
		if err != nil {
			steps[i].Status = "error"
			steps[i].Detail = err.Error()
		} else {
			steps[i].Status = "done"
		}
		a.emitShutdownEvent("shutdown:update", steps)
		applog.Printf("shutdown: step %q %s %s", steps[i].ID, steps[i].Status, steps[i].Detail)
	}
	a.emitShutdownEvent("shutdown:done", steps)
	// Cleanly tear down hardware links before quitting. Especially the CAT
	// backend: a FlexRadio keeps our API client registered until it notices the
	// TCP is gone, and just letting the process exit left the slot stale — the
	// NEXT launch then spent ~30s retrying the connect before the radio freed it.
	// cat.Stop() closes the socket with a proper FIN so the radio drops us at once.
	applog.Printf("shutdown: stopping CAT")
	if a.cat != nil {
		a.cat.Stop()
	}
	applog.Printf("shutdown: releasing relay drivers")
	a.closeRelayDrivers() // release FTDI/serial relay handles for the next launch
	time.Sleep(600 * time.Millisecond)
	applog.Printf("shutdown: calling Quit")
	wruntime.Quit(ctx)
}

// runBackupForShutdown is the same logic as maybeShutdownBackup but
// returns an error so the shutdown sequence can mark the step as failed.
func (a *App) runBackupForShutdown() error {
	if a.settings == nil || a.db == nil {
		return fmt.Errorf("db not ready")
	}
	s, err := a.GetBackupSettings()
	if err != nil {
		return err
	}
	folder := s.Folder
	if folder == "" {
		folder = s.DefaultFolder
	}
	mysql := a.dbBackend == "mysql"
	done := backup.HasBackupToday(folder)
	if mysql {
		done = backup.HasADIFBackupToday(folder)
	}
	if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
		return nil
	}
	if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll); err != nil {
		return err
	}
	return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}

// setSetting persists a key/value and logs (rather than silently swallows) a
// failure — used for non-critical settings writes where the caller can't
// surface the error but a lost write would still mislead (stale timestamps,
// seed markers…).
func (a *App) setSetting(key, val string) {
	if a.settings == nil {
		return
	}
	if err := a.settings.Set(a.ctx, key, val); err != nil {
		applog.Printf("settings: set %q failed: %v", key, err)
	}
}

// setSettingGlobal stores a value shared across all profiles (no profile prefix).
func (a *App) setSettingGlobal(key, val string) {
	if a.settings == nil {
		return
	}
	if err := a.settings.SetGlobal(a.ctx, key, val); err != nil {
		applog.Printf("settings: set global %q failed: %v", key, err)
	}
}

func (a *App) shutdown(ctx context.Context) {
	if a.watchlist != nil {
		a.watchlist.Flush() // the save debounce would lose the last edits
	}
	// If the user managed to skip beforeClose (force kill, OS shutdown,
	// crash recovery) we still try the backup here as a best-effort
	// safety net. HasBackupToday makes a double-run a no-op.
	applog.Printf("shutdown: teardown starting")
	// Covers the paths beforeClose never saw (force kill of the window, OS
	// shutdown) — and re-arms a shorter fuse for the teardown itself.
	a.armExitWatchdog(15 * time.Second)
	if !a.shuttingDown {
		a.maybeShutdownBackup()
	}
	// Before the hardware and the databases: a program asked to close with
	// OpsLog should get its request while everything is still up, not after the
	// logger has spent seconds tearing down ports.
	applog.Printf("shutdown: closing autostart programs")
	a.CloseAutostartPrograms()
	a.stopPSKTarget() // one TLS socket to a public broker; nothing to flush
	// Before CAT goes down: disarming satellite mode takes the radio out of full
	// duplex and puts the transmitter back where the operator is listening, and
	// that has to happen while the link is still up.
	a.StopSatelliteTracking()
	applog.Printf("shutdown: stopping UDP")
	if a.udp != nil {
		a.udp.StopAll()
	}
	applog.Printf("shutdown: stopping solar")
	if a.solar != nil {
		a.solar.Stop()
	}
	// Stop CAT so the backend disconnects cleanly. Critical for the Icom network
	// backend: without this the rig never gets a disconnect and holds its single
	// control session for minutes, refusing every new login (even from the Icom
	// Remote Utility) until it times out on its own.
	applog.Printf("shutdown: stopping CAT sharing")
	if a.catShare != nil {
		// Before the CAT stop, so no client is mid-command against a backend that
		// is disconnecting — and so the port is free for the next launch.
		a.catShare.Stop()
		a.catShare = nil
	}
	if a.catShareTCI != nil {
		a.catShareTCI.Stop()
		a.catShareTCI = nil
	}
	applog.Printf("shutdown: stopping CAT")
	if a.cat != nil {
		a.cat.Stop()
	}
	applog.Printf("shutdown: stopping WinKeyer")
	if a.winkeyer != nil {
		a.winkeyer.Disconnect()
	}
	applog.Printf("shutdown: stopping QSO recorder")
	if a.qsoRec != nil {
		a.qsoRec.Stop()
	}
	// Before the databases: Close flushes what the last minute learnt, and a
	// restart is exactly when the grid cache is worth the most.
	applog.Printf("shutdown: closing grid cache")
	if a.gridStore != nil {
		if err := a.gridStore.Close(); err != nil {
			applog.Printf("gridcache: close: %v", err)
		}
		a.gridStore = nil
	}
	applog.Printf("shutdown: closing databases")
	if a.logDb != nil && a.logDb != a.db {
		_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
	}
	if a.db != nil {
		_ = a.db.Close()
	}
	applog.Printf("shutdown: teardown done")
}

// userDataDir returns the OpsLog data directory: always "<exe dir>/data".
// All data (database, settings, cty.dat, logs) travels with the executable,
// making OpsLog fully portable for USB sticks and PC migrations.
func userDataDir() (string, error) {
	exe, err := os.Executable()
	if err != nil {
		return "", fmt.Errorf("cannot locate executable: %w", err)
	}
	return filepath.Join(filepath.Dir(exe), "data"), nil
}

// fileExists reports whether path exists and is a regular file.
func fileExists(path string) bool {
	fi, err := os.Stat(path)
	return err == nil && fi.Mode().IsRegular()
}

// GetDataDir returns the current data directory path.
func (a *App) GetDataDir() string { return a.dataDir }

// copyDirContents recursively copies all files and subdirectories from src to dst.
func copyDirContents(src, dst string) error {
	entries, err := os.ReadDir(src)
	if err != nil {
		return err
	}
	// Create the destination BEFORE writing into it. Without this the first
	// os.Create failed with ENOENT, which reads as "not exist" — indistinguishable
	// from a source folder that simply has nothing to copy, and callers testing
	// for that swallowed it.
	if err := os.MkdirAll(dst, 0o755); err != nil {
		return err
	}
	for _, e := range entries {
		srcPath := filepath.Join(src, e.Name())
		dstPath := filepath.Join(dst, e.Name())
		if e.IsDir() {
			if err := os.MkdirAll(dstPath, 0o755); err != nil {
				return err
			}
			if err := copyDirContents(srcPath, dstPath); err != nil {
				return err
			}
		} else {
			if err := copyFileData(srcPath, dstPath); err != nil {
				return err
			}
		}
	}
	return nil
}

// copyFileData copies a single file from src to dst, creating or overwriting dst.
func copyFileData(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer out.Close()
	_, err = io.Copy(out, in)
	return err
}

// ── Database location (config.json pointer) ────────────────────────────

// dbPointer is the tiny bootstrap config stored in the data dir. It must
// live outside the database because we read it BEFORE opening any DB to decide
// which backend to use: a local SQLite file (DBPath) or a shared MySQL server
// (MySQL). The MySQL connection lives here — not in the settings table — for
// the same reason: we need it to choose and open the backend at startup.
type dbPointer struct {
	DBPath string         `json:"db_path"`
	MySQL  *MySQLSettings `json:"mysql,omitempty"`
	// DeletePending is the previous database file to remove on the NEXT launch —
	// set by a rename, which can't delete the still-open old file in-process. The
	// startup path deletes it (with its -wal/-shm sidecars) once the new DB is the
	// one in use, then clears this.
	DeletePending string `json:"delete_pending,omitempty"`
}

func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }

// ── Portable paths ─────────────────────────────────────────────────────
//
// OpsLog is meant to be carried on a stick or copied between machines: the exe,
// its data folder and the databases travel together. Storing "C:\OpsLog\data\
// logbook.db" broke that — dropped into D:\OpsLog on another PC, the pointer
// still named C:, and the app either lost the database or (worse) silently
// created a NEW empty one at a C: path that happened to be creatable.
//
// So a path INSIDE the application folder is stored relative to it, and any
// stored path is resolved against the CURRENT location at read time. Absolute
// paths outside the folder (a deliberate choice — a synced folder, another
// drive) keep working exactly as before: they are only re-rooted if they have
// gone missing AND the same file exists in this install's data folder.

// appDir is the folder the running exe lives in — the anchor for relative paths.
func appDir() string {
	exe, err := os.Executable()
	if err != nil {
		return ""
	}
	return filepath.Dir(exe)
}

// portablePath prepares a path for STORAGE: relative to the app folder when it
// sits inside it, unchanged otherwise. Forward slashes so the value survives a
// round trip through a folder copied between machines.
func portablePath(p string) string {
	p = strings.TrimSpace(p)
	base := appDir()
	if p == "" || base == "" || !filepath.IsAbs(p) {
		return p
	}
	rel, err := filepath.Rel(base, p)
	if err != nil || strings.HasPrefix(rel, "..") {
		return p // outside the app folder — the user meant that exact place
	}
	return filepath.ToSlash(rel)
}

// resolvePath turns a stored path back into an absolute one for THIS install.
// dataDir is where the app's own data lives, used for the re-rooting rescue.
func resolvePath(dataDir, p string) string {
	p = strings.TrimSpace(p)
	if p == "" {
		return ""
	}
	if !filepath.IsAbs(p) {
		if base := appDir(); base != "" {
			return filepath.Join(base, filepath.FromSlash(p))
		}
		return filepath.FromSlash(p)
	}
	if fileExists(p) {
		return p
	}
	// An absolute path from another machine. Rescue it ONLY if a file of the same
	// name is sitting in this install's data folder — that is the copied-folder
	// case. Anything else is left alone so a genuinely-missing database is
	// reported rather than silently replaced by an unrelated file.
	if dataDir != "" {
		if cand := filepath.Join(dataDir, filepath.Base(p)); fileExists(cand) {
			applog.Printf("path: %q not found — using %q from this install (folder moved?)", p, cand)
			return cand
		}
	}
	return p
}

// ── Window geometry (window.json) ──────────────────────────────────────
//
// Remembered across restarts so the window reopens where and how you left it.
// Kept in its OWN local file, NOT the database: the DB may be a MySQL server
// shared by several machines, and each has its own screens — persisting geometry
// there would make two installs fight over one position.
type windowState struct {
	X         int  `json:"x"`
	Y         int  `json:"y"`
	Width     int  `json:"width"`
	Height    int  `json:"height"`
	Maximised bool `json:"maximised"`
}

func windowStatePath(dataDir string) string { return filepath.Join(dataDir, "window.json") }

func readWindowState(dataDir string) (windowState, bool) {
	var w windowState
	b, err := os.ReadFile(windowStatePath(dataDir))
	if err != nil {
		return w, false
	}
	if json.Unmarshal(b, &w) != nil {
		return w, false
	}
	return w, true
}

func writeWindowState(dataDir string, w windowState) {
	if b, err := json.MarshalIndent(w, "", "  "); err == nil {
		_ = os.WriteFile(windowStatePath(dataDir), b, 0o644)
	}
}

// windowIsMinimised reports whether the window is currently minimised.
//
// Two tests rather than one: Wails answers the question directly, and the
// -32000 corner is Windows' own marker for a minimised window — a window that
// is mid-close or hidden can report that corner while the flag has already been
// cleared, and either way the geometry is not worth storing.
func (a *App) windowIsMinimised() bool {
	if a.ctx == nil {
		return false
	}
	if wruntime.WindowIsMinimised(a.ctx) {
		return true
	}
	x, y := wruntime.WindowGetPosition(a.ctx)
	const minimisedCorner = -32000
	return x <= minimisedCorner || y <= minimisedCorner
}

// saveWindowState captures the current geometry. Called as the window closes.
// While in compact mode the window is pinned to a tiny fixed size, so we keep the
// previously-saved normal geometry rather than overwrite it with 1240×158 — only
// the maximised flag is meaningful to update there.
func (a *App) saveWindowState() {
	if a.ctx == nil || a.dataDir == "" {
		return
	}
	prev, _ := readWindowState(a.dataDir)
	// A MINIMISED window has no usable geometry: Windows parks it at -32000,-32000
	// with a stub size, and reports it as not-maximised. Closing OpsLog from the
	// taskbar while minimised captured exactly that — "saving -32000,-32000 237x39
	// maximised=false" — and window.json then held a position no monitor covers and
	// a size below the minimum. The restore side rejects both and falls back to the
	// default placement, so the operator silently lost the size, the position AND
	// the maximised state they had. Keep what was already stored instead.
	if a.windowIsMinimised() {
		applog.Printf("window: minimised at close — keeping the stored geometry %d,%d %dx%d maximised=%v",
			prev.X, prev.Y, prev.Width, prev.Height, prev.Maximised)
		return
	}
	max := wruntime.WindowIsMaximised(a.ctx)
	ws := prev
	ws.Maximised = max
	if !max && !a.compact {
		w, h := wruntime.WindowGetSize(a.ctx)
		x, y := wruntime.WindowGetPosition(a.ctx)
		if w > 0 && h > 0 {
			ws.Width, ws.Height, ws.X, ws.Y = w, h, x, y
		}
	} else if max {
		// The POSITION is recorded even when maximised — it is what identifies the
		// MONITOR. Without it a multi-screen operator who always runs maximised got
		// no position at all, and Windows reopened OpsLog on the primary screen
		// every time. The SIZE is deliberately not touched here: it would be the
		// maximised size, which must not become the restored-down size.
		x, y := wruntime.WindowGetPosition(a.ctx)
		ws.X, ws.Y = x, y
	}
	// Logged because this is the only evidence of WHAT was stored: an operator
	// reporting "it reopens on the wrong screen" cannot tell a position that was
	// never captured from one that was captured wrong, and the two need opposite
	// fixes.
	applog.Printf("window: saving %d,%d %dx%d maximised=%v compact=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised, a.compact)
	writeWindowState(a.dataDir, ws)
}

// restoreWindowPosition places the window at its saved X/Y. Size and maximised
// state are already applied at creation (main.go, from options), so only the
// position — which options can't express — remains, and it is set here while the
// window is still hidden, so there is no visible jump. Nothing to do for a
// maximised or first-run window.
// moveWindowTo places the window at an ABSOLUTE desktop coordinate — the same
// coordinate system WindowGetPosition reports and window.json stores.
//
// Wails' WindowSetPosition is relative to the current monitor's work area (see
// windowpos_windows.go), so on a monitor left of the primary one it added that
// monitor's negative origin to an already-absolute value and the window walked
// one screen further off the desktop at every launch. Fall back to it only when
// we cannot place the window ourselves — on the primary monitor the two agree.
func (a *App) moveWindowTo(x, y int) {
	if setWindowPosAbsolute(x, y) {
		return
	}
	wruntime.WindowSetPosition(a.ctx, x, y)
}

func (a *App) restoreWindowPosition() {
	if a.ctx == nil {
		return
	}
	logMonitorLayout()
	ws, ok := readWindowState(a.dataDir)
	if !ok {
		applog.Printf("window: no saved geometry — opening where Windows puts it")
		return
	}
	applog.Printf("window: restoring %d,%d %dx%d maximised=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised)
	// Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise,
	// so a two-screen operator lost OpsLog to the wrong monitor at every launch.
	// Un-maximise, move to the saved corner (which names the monitor), maximise
	// again — all while the window is still hidden, so nothing flickers.
	if ws.Maximised {
		if ws.X == 0 && ws.Y == 0 {
			// Either never recorded, or genuinely the primary monitor's corner —
			// indistinguishable, and both mean "leave it to Windows".
			applog.Printf("window: maximised with corner 0,0 — nothing to restore")
			return
		}
		if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
			applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
			return
		}
		wruntime.WindowUnmaximise(a.ctx)
		a.moveWindowTo(ws.X, ws.Y)
		wruntime.WindowMaximise(a.ctx)
		gx, gy := wruntime.WindowGetPosition(a.ctx)
		applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
		return
	}
	if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
		return // corrupt / absurd — leave the default placement
	}
	// The SIZE was sanity-checked above but the POSITION never was, and that is
	// the one that makes OpsLog unusable: close it on a second monitor, unplug
	// that monitor (or dock elsewhere, or change the layout), and the saved
	// coordinates put the window somewhere no screen covers. It opens, invisibly,
	// forever — with no way back short of deleting window.json, which nobody
	// knows to do. Fall back to the default placement instead.
	if !onSomeMonitor(ws.X, ws.Y, ws.Width, ws.Height) {
		// Moved onto the nearest monitor rather than handed back to Windows.
		// Giving up lost the size as well as the position and dropped the window
		// on the primary screen wherever Windows felt like; clamping keeps the
		// window the operator had, somewhere they can see it.
		nx, ny, moved := clampToVisible(ws.X, ws.Y, ws.Width, ws.Height)
		if !moved {
			applog.Printf("window: saved position %d,%d (%dx%d) is off every monitor and no monitor could be read — opening at the default placement",
				ws.X, ws.Y, ws.Width, ws.Height)
			return
		}
		applog.Printf("window: saved position %d,%d is off every monitor (%s) — moved to %d,%d",
			ws.X, ws.Y, describeMonitors(monitorRects()), nx, ny)
		a.moveWindowTo(nx, ny)
		return
	}
	a.moveWindowTo(ws.X, ws.Y)
	if gx, gy := wruntime.WindowGetPosition(a.ctx); gx != ws.X || gy != ws.Y {
		applog.Printf("window: asked for %d,%d and the window reports %d,%d", ws.X, ws.Y, gx, gy)
	}
}

// onSomeMonitor reports whether a window at these coordinates would land on the
// visible desktop. It demands a real slab of the title bar rather than a single
// pixel: a window overlapping the screen edge by 2 px is, in practice, as lost
// as one entirely outside it. When the desktop bounds can't be read, it says yes
// — better to honour the operator's saved position than to second-guess it.
func onSomeMonitor(x, y, w, h int) bool { return onSomeMonitorImpl(x, y, w, h) }

// overlapsEnough is the geometry behind onSomeMonitor, split out so it can be
// tested — the virtual desktop origin is NEGATIVE when a monitor sits left of or
// above the primary one, which is precisely the layout that produces a lost
// window and precisely where sign errors hide.
func overlapsEnough(x, y, w, h, vx, vy, vw, vh int) bool {
	const grabW, grabH = 160, 32 // enough of the title bar to see and drag
	overlapW := min(x+w, vx+vw) - max(x, vx)
	overlapH := min(y+h, vy+vh) - max(y, vy)
	return overlapW >= grabW && overlapH >= grabH
}

// readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero
// value if the file is missing/unreadable.
func readBootstrap(dataDir string) dbPointer {
	var c dbPointer
	b, err := os.ReadFile(dbPointerPath(dataDir))
	if err != nil {
		return c
	}
	_ = json.Unmarshal(b, &c)
	// Stored relative when it lives inside the app folder, so the pointer follows
	// the folder from C:OpsLog to D:OpsLog or to a stick.
	c.DBPath = resolvePath(dataDir, c.DBPath)
	c.DeletePending = resolvePath(dataDir, c.DeletePending)
	return c
}

func writeBootstrap(dataDir string, c dbPointer) error {
	c.DBPath = portablePath(c.DBPath)
	c.DeletePending = portablePath(c.DeletePending)
	b, _ := json.MarshalIndent(c, "", "  ")
	return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
}

// readDBPointer returns the user-chosen DB path, or "" for the default.
func readDBPointer(dataDir string) string {
	return readBootstrap(dataDir).DBPath
}

// writeDBPointer persists the chosen DB path ("" resets to default), keeping
// any saved MySQL config intact.
func writeDBPointer(dataDir, path string) error {
	c := readBootstrap(dataDir)
	c.DBPath = strings.TrimSpace(path)
	return writeBootstrap(dataDir, c)
}

// DatabaseSettings describes the active database file for the Settings UI.
type DatabaseSettings struct {
	Path               string `json:"path"`                 // settings/config database (settings + profiles)
	DefaultPath        string `json:"default_path"`         // where the settings db lives by default
	IsCustom           bool   `json:"is_custom"`            // config.json points it elsewhere
	LogbookDefaultPath string `json:"logbook_default_path"` // default SQLite logbook file (QSOs), when a profile doesn't point elsewhere
}

// GetDatabaseSettings returns where the active database lives.
func (a *App) GetDatabaseSettings() DatabaseSettings {
	// Default settings-db location: settings.db on fresh installs, opslog.db when
	// an existing one is present (mirrors the startup resolution).
	settingsDefault := filepath.Join(a.dataDir, "settings.db")
	legacyOpslog := filepath.Join(a.dataDir, "opslog.db")
	def := settingsDefault
	if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
		def = legacyOpslog
	}
	lp := a.logbookPath
	if lp == "" {
		lp = a.dbPath // split disabled → the settings db doubles as the logbook
	}
	return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def, LogbookDefaultPath: lp}
}

// RevealDataFolder opens the folder that holds the settings database (and the
// default logbook) in the OS file manager — the "where is my data" shortcut.
func (a *App) RevealDataFolder() error {
	dir := filepath.Dir(a.dbPath)
	return openInFileManager(dir)
}

// openInFileManager opens a folder in Windows Explorer (matches OpenAwardsFolder).
func openInFileManager(dir string) error {
	return exec.Command("explorer", dir).Start()
}

// MySQLSettings is the shared-database (multi-operator) connection config. When
// enabled, OpsLog logs to a central MySQL server so several operators see each
// other's QSOs live (à la Log4OM). SQLite stays the default.
type MySQLSettings struct {
	Enabled  bool   `json:"enabled"`
	Host     string `json:"host"`
	Port     int    `json:"port"`
	User     string `json:"user"`
	Password string `json:"password"`
	Database string `json:"database"`
	// SqlitePath, when set (and Enabled=false), routes this profile's logbook to
	// its OWN SQLite file instead of the shared app database. Empty = shared.
	SqlitePath string `json:"sqlite_path,omitempty"`
}

// DBBackendStatus reports which backend OpsLog actually opened at startup so
// the Settings UI can confirm the shared MySQL connection (or explain a
// fallback to SQLite when the configured server was unreachable).
type DBBackendStatus struct {
	Active   string `json:"active"`   // "sqlite" | "mysql"
	Fallback bool   `json:"fallback"` // MySQL was enabled but failed, so we used SQLite
	Error    string `json:"error"`    // the MySQL open error, when Fallback is true
}

// GetDBBackendStatus returns the active backend and any MySQL fallback error.
func (a *App) GetDBBackendStatus() DBBackendStatus {
	return DBBackendStatus{
		Active:   a.dbBackend,
		Fallback: a.dbBackendErr != "",
		Error:    a.dbBackendErr,
	}
}

// DBConnectionInfo is a compact description of where the QSO logbook lives, for
// the status bar: a MySQL server endpoint or the local SQLite file path.
type DBConnectionInfo struct {
	Backend string `json:"backend"` // "sqlite" | "mysql"
	Label   string `json:"label"`   // "host:port/database" or the .db path
}

// GetDBConnectionInfo reports the logbook connection for display in the status
// bar. For MySQL it shows host:port/database (the shared logbook); for SQLite
// it shows the local database file path.
func (a *App) GetDBConnectionInfo() DBConnectionInfo {
	if a.dbBackend == "mysql" {
		if p, err := a.profiles.Active(a.ctx); err == nil && p.DB.Backend == "mysql" {
			port := p.DB.Port
			if port == 0 {
				port = 3306
			}
			return DBConnectionInfo{Backend: "mysql", Label: fmt.Sprintf("%s:%d/%s", p.DB.Host, port, p.DB.Database)}
		}
		return DBConnectionInfo{Backend: "mysql", Label: "MySQL"}
	}
	// The LOGBOOK file, not the settings database.
	//
	// This reported a.dbPath — the settings/profile database — under a tooltip
	// reading "Local SQLite logbook". The two are separate files since the logbook
	// split, so an operator checking where their QSOs live, or which file to back
	// up, was pointed at the wrong one.
	//
	// Same resolution order as connectLogbook: the active profile's own file if it
	// names one, then the default logbook.db, and only if neither exists does the
	// settings db genuinely serve as the logbook.
	lp := ""
	if p, err := a.profiles.Active(a.ctx); err == nil {
		lp = strings.TrimSpace(p.DB.Path)
	}
	if lp == "" {
		lp = a.logbookPath
	}
	if lp == "" {
		lp = a.dbPath
	}
	return DBConnectionInfo{Backend: "sqlite", Label: lp}
}

// connectLogbook opens the logbook connection for a profile's DB target: a
// shared MySQL database, or the local SQLite file (which doubles as the logbook
// when no MySQL is configured). Returns the connection and the backend name.
func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
	if cfg.Backend == "mysql" {
		c, err := db.OpenMySQL(db.MySQLConfig{
			Host: cfg.Host, Port: cfg.Port, User: cfg.User, Password: cfg.Password, Database: cfg.Database,
		})
		if err != nil {
			return nil, "", err
		}
		a.logDbPath = "" // MySQL: no local file to snapshot (the log is exported to ADIF instead)
		return c, "mysql", nil
	}
	// SQLite logbook FILE, separate from the settings/config database. A profile
	// may point at its own file (cfg.Path, e.g. a visiting operator's log); with
	// no path it uses the default logbook.db beside the settings db. db.Open
	// creates + migrates the file if it doesn't exist yet. Only when there is no
	// default logbook path at all (VACUUM-INTO split failed) do we fall back to the
	// settings db itself as the logbook.
	lp := strings.TrimSpace(cfg.Path)
	if lp == "" {
		lp = a.logbookPath
	}
	if lp == "" {
		a.logDbPath = "" // settings db serves as the logbook (split failed) — backup snapshots a.db
		// It may have had its unused qso table dropped while a separate logbook
		// was in use (see tidyLogbookSchema); rebuild it before handing it over.
		if err := db.EnsureQSOTable(a.db); err != nil {
			return nil, "", err
		}
		return a.db, "sqlite", nil
	}
	// Resolve against THIS install before opening. Without it, a profile carried
	// from C:\OpsLog to D:\OpsLog kept naming the C: path — and since db.Open
	// CREATES what is missing, the operator silently got a brand-new empty
	// logbook instead of an error. Their QSOs were still on disk, in the folder
	// they had just copied.
	if r := resolvePath(a.dataDir, lp); r != lp {
		applog.Printf("logbook: profile path %q resolved to %q", lp, r)
		lp = r
	}
	c, err := db.OpenLogbook(lp)
	if err != nil {
		return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
	}
	a.logDbPath = lp // the SQLite file the backup snapshots for the contacts
	return c, "sqlite", nil
}

// sqliteHasQSOs reports whether the given (SQLite) database has at least one QSO
// row — used once at startup to decide whether to seed the split-out logbook
// file from a legacy combined database. Missing table / any error → false.
func sqliteHasQSOs(conn *sql.DB) bool {
	if conn == nil {
		return false
	}
	var n int
	if err := conn.QueryRow("SELECT EXISTS(SELECT 1 FROM qso)").Scan(&n); err != nil {
		return false
	}
	return n > 0
}

// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
// profile (one-time), so users who set up MySQL before it became per-profile
// keep their logbook. The bootstrap entry is then cleared.
func (a *App) adoptBootstrapMySQL(active *profile.Profile) {
	mb := readBootstrap(a.dataDir).MySQL
	if mb == nil || !mb.Enabled || active.ID == 0 || active.DB.Backend != "" {
		return
	}
	active.DB = profile.ProfileDB{
		Backend: "mysql", Host: mb.Host, Port: mb.Port,
		User: mb.User, Password: mb.Password, Database: mb.Database,
	}
	if err := a.profiles.SetDB(a.ctx, active.ID, active.DB); err != nil {
		applog.Printf("adopt bootstrap MySQL into profile: %v", err)
		return
	}
	c := readBootstrap(a.dataDir)
	c.MySQL = nil
	_ = writeBootstrap(a.dataDir, c)
}

// switchLogbook reconnects the live logbook to the given profile's DB target
// (called when the active profile changes or its DB config is saved), swaps the
// qso repo, and notifies the UI. The previous MySQL connection is closed.
func (a *App) switchLogbook(p profile.Profile) error {
	newConn, backend, err := a.connectLogbook(p.DB)
	if err != nil {
		a.dbBackendErr = "MySQL: " + err.Error()
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "logbook:changed")
		}
		return err
	}
	old := a.logDb
	a.qso = qso.NewRepo(newConn)
	a.logDb = newConn
	a.dbBackend = backend
	a.dbBackendErr = ""
	a.invalidateAwardStats() // different logbook → drop memoised award matrices
	db.SetDialect(backend)
	if old != nil && old != a.db && old != newConn {
		_ = old.Close()
	}
	applog.Printf("logbook switched to %s for profile %q", backend, p.Name)
	// EVERY worked/new verdict belonged to the OLD logbook.
	//
	// The award matrices above are only half of it: the in-memory worked-index
	// the alert engine reads, the chase-new list, and the frontend's own cache of
	// resolved spot verdicts all still hold the previous log's answers. An
	// operator switching to an empty profile and back found every decode on the
	// screen badged NEW — the empty log's verdicts, cached under the full one,
	// and nothing short of a restart cleared them.
	if a.chaseNew != nil {
		a.chaseNew.clear()
	}
	go a.rebuildWorkedIndex()
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
		wruntime.EventsEmit(a.ctx, "logbook:changed")
	}
	return nil
}

// GetMySQLSettings returns the ACTIVE profile's logbook DB config (Enabled =
// MySQL). Each profile can target its own database.
func (a *App) GetMySQLSettings() (MySQLSettings, error) {
	out := MySQLSettings{Port: 3306}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return out, nil
	}
	d := p.DB
	out.Enabled = d.Backend == "mysql"
	out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
	out.SqlitePath = d.Path
	if d.Port > 0 {
		out.Port = d.Port
	}
	return out, nil
}

// SaveMySQLSettings stores the DB target on the ACTIVE profile and switches the
// live logbook to it immediately (no restart). Enabled=false reverts to the
// local SQLite logbook.
func (a *App) SaveMySQLSettings(s MySQLSettings) error {
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return fmt.Errorf("no active profile: %w", err)
	}
	if s.Port <= 0 {
		s.Port = 3306
	}
	cfg := profile.ProfileDB{Database: strings.TrimSpace(s.Database)}
	if s.Enabled {
		cfg.Backend = "mysql"
		cfg.Host = strings.TrimSpace(s.Host)
		cfg.Port = s.Port
		cfg.User = strings.TrimSpace(s.User)
		cfg.Password = s.Password
	} else if sp := strings.TrimSpace(s.SqlitePath); sp != "" {
		// Separate per-profile SQLite logbook file (config stays in opslog.db).
		cfg.Backend = "sqlite"
		cfg.Path = sp
	}
	if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil {
		return err
	}
	p.DB = cfg
	return a.switchLogbook(p)
}

// RenameLogbook copies the ACTIVE profile's SQLite logbook to dest (with its
// QSOs), repoints the profile at it and switches the live logbook — no restart.
// Unlike "choose a dedicated file" (which points at a fresh/empty file), this
// carries the data across. The old file is deleted only when it was this
// profile's OWN dedicated file; the shared default logbook.db is left in place
// (other profiles may use it). Errors if the logbook is MySQL.
func (a *App) RenameLogbook(dest string) error {
	dest = strings.TrimSpace(dest)
	if dest == "" {
		return fmt.Errorf("no destination given")
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return fmt.Errorf("no active profile: %w", err)
	}
	if p.DB.Backend == "mysql" {
		return fmt.Errorf("this profile's logbook is MySQL — no file to rename")
	}
	old := strings.TrimSpace(p.DB.Path)
	wasDedicated := old != ""
	if old == "" {
		old = a.logbookPath
	}
	if old == "" || a.logDb == nil {
		return fmt.Errorf("no logbook file to rename")
	}
	if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
		return fmt.Errorf("that is already the current logbook name")
	}
	if _, err := os.Stat(dest); err == nil {
		return fmt.Errorf("a file already exists at %s — pick a new name", dest)
	}
	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
		return fmt.Errorf("create folder: %w", err)
	}
	safe := strings.ReplaceAll(dest, "'", "''")
	if _, err := a.logDb.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
		return fmt.Errorf("copy logbook: %w", err)
	}
	p.DB.Backend = "sqlite"
	p.DB.Path = dest
	if err := a.profiles.SetDB(a.ctx, p.ID, p.DB); err != nil {
		return err
	}
	if err := a.switchLogbook(p); err != nil { // opens dest, closes the old conn
		return err
	}
	if wasDedicated {
		for _, f := range []string{old, old + "-wal", old + "-shm"} {
			_ = os.Remove(f)
		}
	}
	return nil
}

// TestMySQLConnection pings the shared MySQL database with the given settings
// (no migrations) so the user can validate connectivity from the UI.
func (a *App) TestMySQLConnection(s MySQLSettings) error {
	return db.PingMySQL(db.MySQLConfig{
		Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, Database: s.Database,
	})
}

// PickOpenDatabase opens a file dialog to choose an existing .db file.
func (a *App) PickOpenDatabase() (string, error) {
	if a.ctx == nil {
		return "", fmt.Errorf("no app context")
	}
	return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
		Title:            "Open an OpsLog database",
		DefaultDirectory: filepath.Dir(a.dbPath),
		Filters:          []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
	})
}

// PickSaveDatabase opens a save dialog to choose where to put a copy.
func (a *App) PickSaveDatabase() (string, error) {
	if a.ctx == nil {
		return "", fmt.Errorf("no app context")
	}
	return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
		Title:            "Save the OpsLog database to…",
		DefaultDirectory: filepath.Dir(a.dbPath),
		DefaultFilename:  "opslog.db",
		Filters:          []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
	})
}

// OpenDatabase points OpsLog at an existing database file. Takes effect on
// the next launch.
func (a *App) OpenDatabase(path string) error {
	path = strings.TrimSpace(path)
	if path == "" {
		return fmt.Errorf("no path given")
	}
	if _, err := os.Stat(path); err != nil {
		return fmt.Errorf("database file not found: %w", err)
	}
	return writeDBPointer(a.dataDir, path)
}

// MoveDatabase writes a clean copy of the current database to dest (which
// must not exist yet) and switches OpsLog to it on the next launch. Uses
// VACUUM INTO so the copy is consistent even with an open WAL.
func (a *App) MoveDatabase(dest string) error {
	dest = strings.TrimSpace(dest)
	if dest == "" {
		return fmt.Errorf("no destination given")
	}
	if _, err := os.Stat(dest); err == nil {
		return fmt.Errorf("a file already exists at %s — pick a new name", dest)
	}
	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
		return fmt.Errorf("create folder: %w", err)
	}
	if a.db == nil {
		return fmt.Errorf("database not open")
	}
	// VACUUM INTO takes a string literal; escape single quotes in the path.
	safe := strings.ReplaceAll(dest, "'", "''")
	if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
		return fmt.Errorf("copy database: %w", err)
	}
	return writeDBPointer(a.dataDir, dest)
}

// RenameDatabase renames the current database file to dest — keeping ALL config
// (it is the same database under a new name), unlike "New database" which starts
// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the
// ORIGINAL file is scheduled for deletion on the next launch (it is open now and
// can't be removed in-process on Windows). dest must not already exist.
func (a *App) RenameDatabase(dest string) error {
	dest = strings.TrimSpace(dest)
	if dest == "" {
		return fmt.Errorf("no destination given")
	}
	if a.db == nil {
		return fmt.Errorf("database not open")
	}
	old := a.dbPath
	if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
		return fmt.Errorf("that is already the current database name")
	}
	if _, err := os.Stat(dest); err == nil {
		return fmt.Errorf("a file already exists at %s — pick a new name", dest)
	}
	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
		return fmt.Errorf("create folder: %w", err)
	}
	safe := strings.ReplaceAll(dest, "'", "''")
	if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
		return fmt.Errorf("copy database: %w", err)
	}
	boot := readBootstrap(a.dataDir)
	boot.DBPath = dest
	// Only schedule the old file for deletion when it's a real, on-disk file (a
	// custom path or the default opslog.db) — never something we somehow share
	// with the destination.
	if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) {
		boot.DeletePending = old
	}
	return writeBootstrap(a.dataDir, boot)
}

// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
// points OpsLog at it for the next launch. dest must not already exist.
func (a *App) CreateDatabase(dest string) error {
	dest = strings.TrimSpace(dest)
	if dest == "" {
		return fmt.Errorf("no path given")
	}
	if _, err := os.Stat(dest); err == nil {
		return fmt.Errorf("a file already exists at %s — pick a new name", dest)
	}
	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
		return fmt.Errorf("create folder: %w", err)
	}
	// db.Open creates the file and runs every migration → ready-to-use schema.
	conn, err := db.Open(dest)
	if err != nil {
		return fmt.Errorf("create database: %w", err)
	}
	_ = conn.Close()
	return writeDBPointer(a.dataDir, dest)
}

// ResetDatabaseToDefault clears the custom location (back to the data dir).
func (a *App) ResetDatabaseToDefault() error {
	return writeDBPointer(a.dataDir, "")
}

// GetUIPref / SetUIPref persist portable UI preferences (grid column layout,
// widths, sort…) in the DB settings table under a "ui." namespace, so they
// travel with the logbook and survive a reinstall — unlike the WebView's
// localStorage. Values are opaque JSON blobs owned by the frontend.
// groupDigitalSlots reports the "digital modes count as one" preference
// (Settings → General; stored as a portable ui.* pref so the frontend matrix
// and this backend cluster logic read the same switch).
func (a *App) groupDigitalSlots() bool {
	if a.settings == nil {
		return false
	}
	v, _ := a.settings.Get(a.ctx, "ui.opslog.groupDigitalSlots")
	return v == "1"
}

// clusterWorkedSameSlot reports the "consider a call already worked only if
// worked on the SAME band+mode slot" cluster preference (Settings → DX Cluster).
// Off (default) → a call worked on any band/mode reads as already worked. On →
// the WORKED-call flag needs the same band and mode (digital-grouped when that
// option is also on).
// clusterSlotHighlight reports the "colour the stations I have NOT worked on
// this band and mode" preference (Settings -> DX Cluster). It needs the same
// per-slot index as clusterWorkedSameSlot, which is why the index is built when
// EITHER is on: that map is one entry per worked call+band+mode, so on a large
// log it is not something to hold for an operator using neither.
func (a *App) clusterSlotHighlight() bool {
	if a.settings == nil {
		return false
	}
	v, _ := a.settings.Get(a.ctx, "ui.opslog.clusterSlotHighlight")
	return v == "1"
}

func (a *App) clusterWorkedSameSlot() bool {
	if a.settings == nil {
		return false
	}
	v, _ := a.settings.Get(a.ctx, "ui.opslog.clusterWorkedSameSlot")
	return v == "1"
}

func (a *App) GetUIPref(key string) (string, error) {
	if a.settings == nil || !a.settingsScoped.Load() {
		// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
		// isn't wired AND scoped to the active profile yet. There's a brief window at
		// launch where the frontend can call this before it's ready — reading then
		// returns the wrong profile's (empty) value, which stopped the theme
		// self-heal early ("dark theme reverts to light on reopen"). The UI
		// uses the error to keep RETRYING rather than treat it as "unset" and fall
		// back to a default — the "dark theme reverts to light after an update" bug
		// (the update cleared localStorage, and the DB read gave up too early while
		// the store was still coming up).
		return "", fmt.Errorf("settings store not ready")
	}
	return a.settings.Get(a.ctx, "ui."+key)
}

func (a *App) SetUIPref(key, value string) error {
	if a.settings == nil || !a.settingsScoped.Load() {
		return fmt.Errorf("settings store not ready") // avoid seeding the wrong profile scope
	}
	return a.settings.Set(a.ctx, "ui."+key, value)
}

// QuitApp closes OpsLog (used to apply a database change on next launch).
func (a *App) QuitApp() {
	if a.ctx != nil {
		wruntime.Quit(a.ctx)
	}
}

// RestartApp relaunches OpsLog and closes this instance, so a database change
// (the DB pointer is read once at startup) applies without the user manually
// reopening the app. The new process opens the SQLite files while this one is
// closing; SQLite's busy_timeout(5000) covers that brief overlap.
func (a *App) RestartApp() error {
	exe, err := os.Executable()
	if err != nil {
		return fmt.Errorf("locate executable: %w", err)
	}
	// --relaunch: the child waits for OUR mutex instead of declaring us a
	// duplicate — this instance is quitting, just not always fast enough.
	cmd := exec.Command(exe, "--relaunch")
	cmd.Dir = filepath.Dir(exe)
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("relaunch OpsLog: %w", err)
	}
	if a.ctx != nil {
		wruntime.Quit(a.ctx)
	}
	return nil
}

// reloadLookupProviders rebuilds the lookup chain from current settings.
// Called at startup and after the user saves new credentials.
//
// Provider order honours the user's primary/failsafe choice. If they
// haven't picked one yet (fresh install), we default to "primary = first
// provider with creds" so the app still works out of the box.
func (a *App) reloadLookupProviders() {
	if a.lookup == nil {
		return
	}
	m, err := a.settings.GetMany(a.ctx,
		keyQRZUser, keyQRZPassword, keyQRZNickname, keyHQUser, keyHQPassword,
		keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
	if err != nil {
		fmt.Println("OpsLog: settings load error:", err)
		return
	}
	// An EXPLICIT zero switches the cache off; an ABSENT key leaves the thirty-day
	// default the cache was built with. The difference matters: every operator who
	// has never opened this setting has no value stored, and reading that blank as
	// a zero would silently turn the cache off for all of them.
	if raw := strings.TrimSpace(m[keyCacheTTL]); raw != "" {
		if days, err := strconv.Atoi(raw); err == nil && days >= 0 {
			a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
		}
	}

	build := func(name string) lookup.Provider {
		switch name {
		case "qrz":
			if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
				p := lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword])
				p.PreferNickname = m[keyQRZNickname] == "1"
				return p
			}
		case "hamqth":
			if m[keyHQUser] != "" && m[keyHQPassword] != "" {
				return lookup.NewHamQTH(m[keyHQUser], m[keyHQPassword])
			}
		}
		return nil
	}

	primary, failsafe := m[keyLookupPrimary], m[keyLookupFailsafe]
	// Fresh install fallback: prefer QRZ over HamQTH when both creds exist.
	if primary == "" && failsafe == "" {
		if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
			primary = "qrz"
			if m[keyHQUser] != "" && m[keyHQPassword] != "" {
				failsafe = "hamqth"
			}
		} else if m[keyHQUser] != "" && m[keyHQPassword] != "" {
			primary = "hamqth"
		}
	}

	var providers []lookup.Provider
	if p := build(primary); p != nil {
		providers = append(providers, p)
	}
	if failsafe != "" && failsafe != primary {
		if p := build(failsafe); p != nil {
			providers = append(providers, p)
		}
	}
	a.lookup.SetProviders(providers...)
}

// --- QSO bindings ---

// fillRXDefaults stamps the receive side when the contact was not split.
//
// The ADIF importer has always done this — an absent BAND_RX/FREQ_RX means RX
// equals TX — but the LOGGING paths did not, so what a QSO carried depended on
// which door it came through. It shows up outside OpsLog: the record forwarded
// to another logger over UDP is written from the QSO as logged, and a receiver
// reading BAND_RX (Log4OM does) found nothing there for contacts logged by a
// path that left it blank.
//
// Applied at AddQSO, the one funnel every path goes through — manual entry, the
// WSJT-X/UDP log, CW, contest, net control, the ADIF monitor — so the database,
// the export and the forwarded copy all say the same thing.
func fillRXDefaults(q *qso.QSO) {
	if strings.TrimSpace(q.BandRX) == "" {
		q.BandRX = q.Band
	}
	if q.FreqRXHz == nil && q.FreqHz != nil {
		v := *q.FreqHz
		q.FreqRXHz = &v
	}
}

func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	// Never let a panic in the logging path crash the whole app — the user lost
	// QSOs that way (CW + WinKeyer). Surface it as an error instead.
	defer func() {
		if r := recover(); r != nil {
			applog.Printf("PANIC in AddQSO: %v\n%s", r, debug.Stack())
			if id == 0 {
				err = fmt.Errorf("internal error while logging: %v", r)
			}
		}
	}()
	a.applyStationDefaults(&q, true)
	// Before fillRXDefaults, which copies the transmit frequency into the receive
	// one: on a satellite the two are on different bands, and letting that copy
	// happen first would bury the downlink under the uplink.
	a.applySatellite(&q)
	fillRXDefaults(&q)
	fillDistance(&q)
	a.applyDXCCNumber(&q)
	a.applyULSCounty(&q)               // fill blank US county/grid from the offline ULS store
	a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
	a.refineDistrictZones(&q)          // W6 → CQ3/ITU6 for zone-split countries
	a.applyQSLDefaults(&q)
	a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
	// NOTE: the contacted operator's e-mail already rides in on the QSO — the entry
	// lookup (when you typed the call) fetched it along with name/QTH/grid and the
	// form carries it here. There is deliberately NO second lookup at log time: it
	// was redundant with the entry lookup and only slowed logging (a call not yet in
	// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
	// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
	insT0 := time.Now()
	id, err = a.qso.Add(a.ctx, q)
	if d := time.Since(insT0); d > 2*time.Second {
		// Surface a genuinely slow INSERT (DB lock / overloaded server) — this is the
		// value we need when someone reports "logging took N seconds".
		applog.Printf("log: SLOW qso insert took %s (call=%s band=%s mode=%s)", d.Round(time.Millisecond), q.Callsign, q.Band, q.Mode)
	}
	if err != nil && db.IsConnLost(err) {
		// The database is UNREACHABLE (not a data error) — park the QSO in the
		// offline outbox rather than lose it. Returns id = -1 so the UI can say
		// "saved, waiting to sync" instead of showing a failure. The post-save
		// side effects (recording, uploads, UDP) are skipped: they need a real
		// row id and will not be replayed — the operator can upload later.
		if a.queueOffline(q, err) {
			return -1, nil
		}
		// Couldn't even write the outbox → report the real error, never pretend.
	}
	if err == nil {
		q.ID = id
		// The contact is over, so no decoder holds the entry field any more: the
		// next station may come from whichever one hears it first.
		a.udpFocus.release("QSO logged")
		a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
		a.noteLiveQSO()                          // multi-op: flip this operator back "online" (publishes async)
		// Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the
		// qso:logged event clears the entry form, and clearing the callsign cancels
		// the recorder — so if this ran on the async path below it raced the cancel
		// and the audio was discarded (recordings silently stopped working). The
		// snapshot is an in-memory copy; the heavy file encode still runs async.
		a.saveQSORecording(&q)
		// Drop the cluster worked-index snapshot BEFORE announcing, so the refresh
		// the frontend fires on qso:logged rebuilds it and sees this QSO.
		//
		// Without this the spot colours never moved after a contact. Logging emitted
		// the event, the frontend dutifully re-queried every visible spot two
		// seconds later, and ClusterSpotStatuses answered out of a snapshot built
		// before the QSO — the same "new band" as before, for ever. Reported on an
		// E51 and again on a ZD7 that stayed yellow on the band map with the QSO
		// plainly in the log.
		//
		// Deliberately NOT invalidateAwardStats(): that also drops the award
		// matrices, which are expensive to rebuild on a large log, and a contest run
		// would pay for it once per QSO. This index is a handful of DISTINCT scans
		// and it is what the spot colours actually read.
		a.clusterStatusMu.Lock()
		a.clusterStatusIdx = nil
		a.clusterStatusMu.Unlock()
		// Give the contact its number without rereading the log — same reason as
		// above, a contest run must not pay a full scan per QSO.
		a.noteQSONumbered(id, q.QSODate)
		// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
		// form clears immediately — the operator is not made to wait on the DB.
		wruntime.EventsEmit(a.ctx, "qso:logged", id)
		// Everything below writes to (or reads from) the shared DB. Run it OFF the
		// critical path: on a busy multi-op MySQL the award_refs UPDATE alone could
		// block ~40s (and hit "Too many connections"), which used to freeze the log
		// (form stuck, "…" on the button) until it finished. The QSO row is already
		// safely inserted; these just enrich it, so they can lag a beat.
		qc := q
		go func() {
			a.materializeAwardRefs(qc) // fills the award_refs column
			if a.extsvc != nil {
				a.extsvc.OnQSOLogged(id)
			}
			a.maybeAutoSendEQSL(qc)
			a.maybeSelfSpot(qc)
			a.publishSoon() // refresh the published web page, debounced
			// Tell the operator's other PCs. Down here with the rest of the
			// after-the-fact work because a folder on a network share can block
			// for seconds, and a contact belongs in the database and on screen
			// long before another machine needs to hear about it.
			//
			// Read back rather than sent from `qc`: award_refs was materialised
			// a few lines above and is not on the copy taken at insert time.
			a.syncPublishAsync(syncfolder.OpAdd, id, nil)
			if a.udp != nil {
				rec := adif.ForwardRecordADIF(qc)
				a.udp.EmitLoggedADIF(rec)
				a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
				a.udpTriggerQSOLogged(qc)
			}
			// Nudge the grid again so the award_refs columns appear once materialised.
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "qso:logged", id)
			}
			// A successful write means the link is healthy — flush any parked QSOs.
			if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
				_, _ = a.replayOfflineQueue()
			}
		}()
	}
	return id, err
}

// applySolar stamps the current space-weather onto a QSO before it's saved: the
// standard ADIF SFI / A_INDEX / K_INDEX fields (only when the QSO doesn't already
// carry them), and the sunspot number as an APP_OPSLOG_SSN extra (SSN has no
// standard ADIF field). No-op when the feed hasn't loaded yet.
func (a *App) applySolar(q *qso.QSO) {
	if a.solar == nil {
		return
	}
	// Today's space weather belongs on today's QSO. The ADIF monitor and the UDP
	// path both feed contacts that are normally seconds old, but neither promises
	// it: a logger re-broadcasting its backlog, or an operator typing in last
	// month's contact by hand, would otherwise be given this morning's SFI as if
	// it had been measured at the time. A wrong number is worse than none — it
	// cannot be told from a real reading afterwards.
	if !q.QSODate.IsZero() && time.Since(q.QSODate) > 24*time.Hour {
		return
	}
	d := a.solar.Get()
	if !d.OK {
		return
	}
	if q.SFI == nil {
		if v, err := strconv.ParseFloat(strings.TrimSpace(d.SFI), 64); err == nil {
			q.SFI = &v
		}
	}
	if q.AIndex == nil {
		if v, err := strconv.ParseFloat(strings.TrimSpace(d.AIndex), 64); err == nil {
			q.AIndex = &v
		}
	}
	if q.KIndex == nil {
		if v, err := strconv.ParseFloat(strings.TrimSpace(d.KIndex), 64); err == nil {
			q.KIndex = &v
		}
	}
	if ssn := strings.TrimSpace(d.SSN); ssn != "" {
		if q.Extras == nil {
			q.Extras = map[string]string{}
		}
		if _, ok := q.Extras["APP_OPSLOG_SSN"]; !ok {
			q.Extras["APP_OPSLOG_SSN"] = ssn
		}
	}
}

// GetSolarData returns the latest space-weather snapshot for the header strip.
func (a *App) GetSolarData() solar.Data {
	if a.solar == nil {
		return solar.Data{}
	}
	return a.solar.Get()
}

// RefreshSolar forces an immediate re-fetch of the space-weather feed.
func (a *App) RefreshSolar() {
	if a.solar != nil {
		go a.solar.Refresh()
	}
}

// LoTWUserInfo reports whether a callsign is a LoTW user and how recently it
// uploaded — drives the colour-coded LoTW badge in the entry strip.
func (a *App) LoTWUserInfo(callsign string) lotwusers.Info {
	if a.lotwUsers == nil {
		return lotwusers.Info{DaysAgo: -1}
	}
	return a.lotwUsers.Lookup(callsign)
}

// LoTWUsersStatus is the loaded-list summary for Settings (count + last refresh).
type LoTWUsersStatus struct {
	Count   int    `json:"count"`
	Updated string `json:"updated,omitempty"` // RFC3339, empty if never
}

// GetLoTWUsersStatus returns how many LoTW callsigns are loaded and when.
func (a *App) GetLoTWUsersStatus() LoTWUsersStatus {
	if a.lotwUsers == nil {
		return LoTWUsersStatus{}
	}
	st := LoTWUsersStatus{Count: a.lotwUsers.Count()}
	if u := a.lotwUsers.Updated(); !u.IsZero() {
		st.Updated = u.UTC().Format(time.RFC3339)
	}
	return st
}

// DownloadLoTWUsers fetches ARRL's LoTW user-activity list and caches it.
// Returns the number of callsigns loaded.
func (a *App) DownloadLoTWUsers() (int, error) {
	if a.lotwUsers == nil {
		return 0, fmt.Errorf("not initialized")
	}
	return a.lotwUsers.Download(a.ctx)
}

// ── Super Check Partial / N+1 ────────────────────────────────────────────────

// ScpStatus is the loaded-list summary for Settings + the widget gate.
type ScpStatus struct {
	Enabled bool   `json:"enabled"`
	Count   int    `json:"count"`
	Updated string `json:"updated,omitempty"` // RFC3339, empty if never
	Clublog bool   `json:"clublog"`           // Club Log source merged in
}

// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
func (a *App) GetScpStatus() ScpStatus {
	st := ScpStatus{}
	if a.settings != nil {
		v, _ := a.settings.Get(a.ctx, keyScpEnabled)
		st.Enabled = v == "1"
	}
	if a.scp != nil {
		st.Count = a.scp.Count()
		st.Clublog = a.scp.ClublogEnabled()
		if u := a.scp.Updated(); !u.IsZero() {
			st.Updated = u.UTC().Format(time.RFC3339)
		}
	}
	return st
}

// SetScpClublogEnabled merges (or stops merging) Club Log's weekly SCP list
// into the Super Check Partial call list, and refreshes the download so the
// change is visible without waiting for the weekly cycle.
func (a *App) SetScpClublogEnabled(on bool) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.settings.Set(a.ctx, keyScpClublog, boolStr(on)); err != nil {
		return err
	}
	if a.scp != nil {
		a.scp.SetClublogEnabled(on)
		if on {
			go func() {
				if n, err := a.scp.Download(context.Background()); err == nil {
					applog.Printf("scp: downloaded %d callsigns (with Club Log list)", n)
					if a.ctx != nil {
						wruntime.EventsEmit(a.ctx, "scp:updated")
					}
				} else {
					applog.Printf("scp: download failed: %v", err)
				}
			}()
		}
	}
	return nil
}

// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
// download when the list is missing or stale.
func (a *App) SetScpEnabled(on bool) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.settings.Set(a.ctx, keyScpEnabled, boolStr(on)); err != nil {
		return err
	}
	if on && a.scp != nil && (a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour) {
		go func() {
			if n, err := a.scp.Download(context.Background()); err == nil {
				applog.Printf("scp: downloaded %d callsigns", n)
				if a.ctx != nil {
					wruntime.EventsEmit(a.ctx, "scp:updated")
				}
			} else {
				applog.Printf("scp: download failed: %v", err)
			}
		}()
	}
	return nil
}

// DownloadScp fetches the MASTER.SCP master file and caches it. Returns the
// number of callsigns loaded.
func (a *App) DownloadScp() (int, error) {
	if a.scp == nil {
		return 0, fmt.Errorf("not initialized")
	}
	return a.scp.Download(a.ctx)
}

// ScpLookup returns the Super Check Partial (substring) and N+1 (one-edit)
// suggestions for a typed fragment. Empty when SCP is disabled.
func (a *App) ScpLookup(fragment string) scp.Result {
	if a.scp == nil || a.settings == nil {
		return scp.Result{}
	}
	if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
		return scp.Result{}
	}
	return a.scp.Lookup(fragment, 60)
}

// StationInfoComputed bundles the data we resolve live from the
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
// lat/lon. Used by the Settings UI to show the "what will be stamped on
// each QSO" preview next to the editable fields.
type StationInfoComputed struct {
	Country string  `json:"country"`
	DXCC    int     `json:"dxcc"`
	CQZ     int     `json:"cqz"`
	ITUZ    int     `json:"ituz"`
	Lat     float64 `json:"lat"`
	Lon     float64 `json:"lon"`
}

// ListCountries returns the DXCC entity names for the Country picker, so the
// user selects from a fixed list instead of typing (avoids typos). Empty
// until cty.dat has loaded.
func (a *App) ListCountries() []string {
	if a.dxcc == nil {
		return nil
	}
	return a.dxcc.EntityNames()
}

// DXCCForCountry returns the ADIF DXCC entity number for a country/entity
// name (as listed by ListCountries), or 0 if unknown. The QSO editor uses it
// to keep the read-only DXCC field in sync when the user picks a Country.
func (a *App) DXCCForCountry(name string) int {
	return dxcc.EntityDXCC(name)
}

// DXCCName returns a display name for a DXCC entity number (or "" if unknown).
// Used by the award editor to label the DXCC-filter chips.
func (a *App) DXCCName(n int) string {
	return dxcc.NameForDXCC(n)
}

// ComputeStationInfo resolves a station's structured metadata from the
// callsign (via cty.dat) and grid (via Maidenhead → lat/lon). The
// frontend calls this whenever Callsign or Grid changes in the Station
// Information panel so the user sees the auto-filled values live.
func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed {
	var out StationInfoComputed
	if a.dxcc != nil && callsign != "" {
		if m, ok := a.dxcc.Lookup(callsign); ok && m.Entity != nil {
			out.Country = m.Entity.Name
			out.CQZ = m.CQZone
			out.ITUZ = m.ITUZone
			out.Lat = m.Lat
			out.Lon = m.Lon
			out.DXCC = dxcc.EntityDXCC(m.Entity.Name)
			// Refine zones by call district (W6 → CQ3/ITU6) so the entry strip
			// shows what will be logged.
			if cqz, ituz, ok := dxcc.ZoneByCallDistrict(out.DXCC, callsign); ok {
				out.CQZ, out.ITUZ = cqz, ituz
			}
		}
	}
	// Grid wins on lat/lon — it's user-set, finer than the DXCC centroid.
	if lat, lon, ok := gridToLatLon(grid); ok {
		out.Lat = lat
		out.Lon = lon
	}
	// 3 decimals is ~110 m — plenty for a station/grid coordinate, and keeps
	// the UI fields tidy.
	out.Lat = math.Round(out.Lat*1000) / 1000
	out.Lon = math.Round(out.Lon*1000) / 1000
	return out
}

// applyDXCCNumber fills DXCC (contacted station) from the cty.dat entity
// name when it's empty. Same lookup as applyStationDefaults does for
// MY_DXCC — uses our entity-name → ADIF DXCC# table since cty.dat itself
// doesn't store the ARRL number.
func (a *App) applyDXCCNumber(q *qso.QSO) {
	if q.DXCC == nil && q.Country != "" {
		if n := dxcc.EntityDXCC(q.Country); n != 0 {
			q.DXCC = &n
		}
	}
}

// refineDistrictZones sets the CQ/ITU zone from the call district for
// zone-split countries (USA, Australia), so every entry point — manual,
// UDP, import, re-stamp, award scan — agrees on W6 = CQ3/ITU6 instead of the
// coarse per-entity default. Call AFTER the DXCC is finalised.
func (a *App) refineDistrictZones(q *qso.QSO) {
	if q.DXCC == nil {
		return
	}
	if cqz, ituz, ok := dxcc.ZoneByCallDistrict(*q.DXCC, q.Callsign); ok {
		zc, zi := cqz, ituz
		q.CQZ, q.ITUZ = &zc, &zi
	}
}

// fillDistance computes DISTANCE from the two locators when nothing supplied one.
//
// Its own function, NOT part of applyStationDefaults, because the import only
// applies those when the operator ticks the box — and a distance is not a
// station default. It is derived from the QSO's own two grids and is true
// whatever the operator chose about profile fields.
//
// Nothing recorded it before, so the field went out empty in every ADIF export
// and left the same gap in whoever imported the file: a hole that travels. An
// imported value always wins, having come from the log that made the contact,
// which knew the real positions rather than two four-character squares.
func fillDistance(q *qso.QSO) {
	if q == nil || (q.Distance != nil && *q.Distance > 0) {
		return
	}
	if km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid); ok && km > 0 {
		v := math.Round(km)
		q.Distance = &v
	}
}

// applyStationDefaults fills any empty MY_* / station field on q with the
// currently-active profile's values. Multi-profile support means a user
// can be /P with a different callsign + grid + SOTA ref than home — the
// QSO carries whichever profile was selected at log time.
func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
	if a.profiles == nil {
		return
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return
	}
	// STATION_CALLSIGN drives upload routing, so it is filled only when the
	// caller asks for identity — and only when the record has none. A QSO that
	// already names its station keeps it, which is what stops a mixed-call log
	// being re-routed; a QSO with the field blank has nothing to protect.
	if includeIdentity && q.StationCallsign == "" {
		q.StationCallsign = p.Callsign
	}
	// OPERATOR and OWNER_CALLSIGN are descriptive (not used for routing), so fill
	// them whenever empty — including on import.
	if q.Operator == "" {
		q.Operator = p.Operator
	}
	if strings.TrimSpace(p.OwnerCallsign) != "" {
		if q.Extras == nil {
			q.Extras = map[string]string{}
		}
		if q.Extras["OWNER_CALLSIGN"] == "" {
			q.Extras["OWNER_CALLSIGN"] = p.OwnerCallsign
		}
	}
	if q.MyGrid == "" {
		q.MyGrid = p.MyGrid
	}
	if q.MyCountry == "" {
		q.MyCountry = p.MyCountry
	}
	if q.MyState == "" {
		q.MyState = p.MyState
	}
	if q.MyCounty == "" {
		q.MyCounty = p.MyCounty
	}
	if q.MyStreet == "" {
		q.MyStreet = p.MyStreet
	}
	if q.MyCity == "" {
		q.MyCity = p.MyCity
	}
	if q.MyPostalCode == "" {
		q.MyPostalCode = p.MyPostalCode
	}
	if q.MySOTARef == "" {
		q.MySOTARef = p.MySOTARef
	}
	if q.MyPOTARef == "" {
		q.MyPOTARef = p.MyPOTARef
	}
	if q.MyIOTA == "" {
		q.MyIOTA = p.MyIOTA
	}
	// MY_NAME = the operator's personal name (profile OpName, e.g. "Greg") — stamped
	// like the other My* fields so every QSO carries it, not just those edited by hand.
	if q.MyName == "" {
		q.MyName = p.OpName
	}
	// Per-band rig/antenna from Operating conditions (the antenna ticked as
	// DEFAULT for this band) — the same auto-fill the entry strip does, applied
	// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
	// The radio that is CONNECTED names itself first — see activeRadioMyRig.
	if q.MyRig == "" {
		q.MyRig = a.activeRadioMyRig()
	}
	if a.operating != nil && q.Band != "" && (q.MyRig == "" || q.MyAntenna == "") {
		if d, ok, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok {
			if q.MyRig == "" {
				q.MyRig = d.StationName
			}
			if q.MyAntenna == "" {
				q.MyAntenna = d.AntennaName
			}
		}
	}
	// Fall back to the profile's static rig/antenna for anything still empty.
	if q.MyRig == "" {
		q.MyRig = p.MyRig
	}
	if q.MyAntenna == "" {
		q.MyAntenna = p.MyAntenna
	}
	// The switch has the last word, when asked to: everything above is what was
	// PLANNED for this band, and the Antenna Genius knows what is connected.
	a.applyAntGeniusAntenna(&q.MyAntenna)
	if q.TXPower == nil && p.TxPower != nil {
		v := *p.TxPower
		q.TXPower = &v
	}
	// Profile-stored MY_* DXCC metadata wins (the user can override the
	// auto-filled values in Station Information).
	if q.MyDXCC == nil && p.MyDXCC != nil {
		v := *p.MyDXCC
		q.MyDXCC = &v
	}
	if q.MyCQZone == nil && p.MyCQZone != nil {
		v := *p.MyCQZone
		q.MyCQZone = &v
	}
	if q.MyITUZone == nil && p.MyITUZone != nil {
		v := *p.MyITUZone
		q.MyITUZone = &v
	}
	if q.MyLat == nil && p.MyLat != nil {
		v := *p.MyLat
		q.MyLat = &v
	}
	if q.MyLon == nil && p.MyLon != nil {
		v := *p.MyLon
		q.MyLon = &v
	}
	// Resolve any still-missing my zones / lat / lon via cty.dat using the
	// profile's callsign — the fallback when the profile didn't store them.
	if a.dxcc != nil && p.Callsign != "" {
		if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil {
			if q.MyCQZone == nil && m.CQZone != 0 {
				v := m.CQZone
				q.MyCQZone = &v
			}
			if q.MyITUZone == nil && m.ITUZone != 0 {
				v := m.ITUZone
				q.MyITUZone = &v
			}
			if q.MyCountry == "" && m.Entity.Name != "" {
				q.MyCountry = m.Entity.Name
			}
			if q.MyDXCC == nil {
				if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
					q.MyDXCC = &n
				}
			}
			// Lat/Lon: prefer the profile's grid (more precise than the
			// DXCC entity centroid). Fall back to cty.dat coordinates.
			if q.MyLat == nil || q.MyLon == nil {
				if lat, lon, gOK := gridToLatLon(p.MyGrid); gOK {
					if q.MyLat == nil {
						v := lat
						q.MyLat = &v
					}
					if q.MyLon == nil {
						v := lon
						q.MyLon = &v
					}
				} else {
					if q.MyLat == nil && m.Lat != 0 {
						v := m.Lat
						q.MyLat = &v
					}
					if q.MyLon == nil && m.Lon != 0 {
						v := m.Lon
						q.MyLon = &v
					}
				}
			}
		}
	}
}

func (a *App) ListQSO(f qso.ListFilter) ([]qso.QSO, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	return a.qso.List(a.ctx, f)
}

func (a *App) CountQSO() (int64, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	return a.qso.Count(a.ctx)
}

// GetLogbookRevision returns a cheap fingerprint of the logbook (count + max id)
// so the UI can poll a shared MySQL logbook and refresh when another OpsLog
// instance has added or removed QSOs.
func (a *App) GetLogbookRevision() (string, error) {
	if a.qso == nil {
		return "", fmt.Errorf("db not initialized")
	}
	return a.qso.Revision(a.ctx)
}

// awardDefs returns the user's stored award definitions, seeding the built-in
// defaults on first use.
func (a *App) awardDefs() []award.Def {
	if a.settings != nil {
		// Award definitions are GLOBAL (shared across every profile) — a custom
		// award is the operator's own work, not station config. Read the global
		// key first; fall back to a per-profile copy saved before awards became
		// global so existing customisations aren't lost (the next save promotes
		// them to global).
		s, _ := a.settings.GetGlobal(a.ctx, keyAwardDefs)
		if strings.TrimSpace(s) == "" {
			s, _ = a.settings.Get(a.ctx, keyAwardDefs)
		}
		if strings.TrimSpace(s) != "" {
			var defs []award.Def
			if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
				// Upgrade legacy defs (pre-rich-model) in memory on every load, and
				// reconcile with the catalog so a newly shipped (or newly fixed) award
				// is visible even before migrateAwardDefs has written it back.
				migrated, _ := award.Migrate(defs)
				migrated, _, _ = mergeCatalog(migrated)
				return migrated
			}
		}
	}
	return award.Defaults()
}

// mergeCatalog reconciles the stored award definitions with the catalog OpsLog
// ships. Two distinct jobs:
//
//   - ADD an award the catalog has and storage doesn't (FFMA in a new release).
//     Without this, awardDefs() would keep returning the stored copy for ever and
//     a newly shipped award would simply never appear.
//   - UPDATE a shipped award whose catalog Version is higher than the stored one
//     — that is how a FIX to an award we already distributed (a corrected OR
//     chain, a fixed reference list) actually reaches its users. Distributing an
//     award you can never afterwards correct is only half a mechanism.
//
// An award the operator has edited (UserEdited) is NEVER updated: their work
// outranks ours. It keeps its stored version, so if they later reset it the
// update applies then.
//
// Returns the reconciled defs and the codes whose DEFINITION was replaced — the
// caller re-seeds those references from the catalog.
func mergeCatalog(defs []award.Def) ([]award.Def, []string, bool) {
	idx := make(map[string]int, len(defs))
	for i, d := range defs {
		idx[strings.ToUpper(strings.TrimSpace(d.Code))] = i
	}
	var updated []string
	changed := false
	for _, cd := range award.Defaults() {
		code := strings.ToUpper(strings.TrimSpace(cd.Code))
		i, ok := idx[code]
		if !ok {
			defs = append(defs, cd)
			changed = true
			continue
		}
		stored := defs[i]
		if stored.UserEdited || cd.Version <= stored.Version {
			continue
		}
		// Keep the operator's own switch: an award they deliberately disabled must
		// not come back enabled just because we shipped a fix. That is a preference,
		// not a definition.
		cd.Valid = stored.Valid
		defs[i] = cd
		updated = append(updated, code)
		changed = true
	}
	return defs, updated, changed
}

func defByCode(defs []award.Def, code string) award.Def {
	for _, d := range defs {
		if strings.EqualFold(strings.TrimSpace(d.Code), code) {
			return d
		}
	}
	return award.Def{}
}

// reseedRefsFromCatalog replaces an award's reference list with the one the
// catalog ships. Only called for an award whose definition we just updated and
// that the operator has NOT edited, so nothing of theirs is overwritten. Awards
// whose list is generated (DXCC, French departments) or fetched online
// (POTA/SOTA/WWFF) ship none — those are left alone.
func (a *App) reseedRefsFromCatalog(code string) {
	if a.awardRefs == nil {
		return
	}
	raw, ok := award.CatalogRefs(code)
	if !ok {
		return
	}
	var refs []awardref.Ref
	if err := json.Unmarshal(raw, &refs); err != nil || len(refs) == 0 {
		if err != nil {
			applog.Printf("awards: %s catalog references are malformed: %v", code, err)
		}
		return
	}
	n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
	if err != nil {
		applog.Printf("awards: re-seeding %s from the catalog failed: %v", code, err)
		return
	}
	applog.Printf("awards: %s references re-seeded from the catalog — %d", code, n)
}

// GetAwardDefs returns the (editable) award definitions.
func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }

// AwardFields lists the scannable QSO fields for the award editor.
func (a *App) AwardFields() []string { return award.Fields() }

// migrateAwardDefs upgrades legacy award definitions in storage once, so the
// editor and persisted state reflect the new model (enabled awards + filled
// matching/confirmation fields). No-op when there is nothing to migrate.
func (a *App) migrateAwardDefs() {
	if a.settings == nil {
		return
	}
	s, _ := a.settings.GetGlobal(a.ctx, keyAwardDefs)
	if strings.TrimSpace(s) == "" {
		s, _ = a.settings.Get(a.ctx, keyAwardDefs) // legacy per-profile copy
	}
	if strings.TrimSpace(s) == "" {
		return // nothing saved yet → Defaults() (already on the new model)
	}
	var defs []award.Def
	if json.Unmarshal([]byte(s), &defs) != nil || len(defs) == 0 {
		return
	}
	migrated, changed := award.Migrate(defs)

	// Back-fill the user-edited flag, once. The flag is what stops a catalog update
	// from overwriting the operator's own work — but it only starts being recorded
	// from the release that introduced it, so an award customised BEFORE that would
	// look pristine and get silently clobbered by the first update we ship.
	//
	// No award version has ever been bumped yet, so today any shipped award whose
	// definition differs from the catalog can only have been changed by its owner.
	// That makes the back-fill exact, and it is only ever right to run it once —
	// after the first version bump, "differs from the catalog" would just mean
	// "older revision".
	if v, _ := a.settings.GetGlobal(a.ctx, keyAwardEditsSeeded); v != "1" {
		cat := map[string]award.Def{}
		for _, d := range award.Defaults() {
			cat[strings.ToUpper(strings.TrimSpace(d.Code))] = d
		}
		for i := range migrated {
			c, ok := cat[strings.ToUpper(strings.TrimSpace(migrated[i].Code))]
			if ok && !migrated[i].UserEdited && !c.SameContent(migrated[i]) {
				migrated[i].UserEdited = true
				changed = true
				applog.Printf("awards: %s differs from the catalog — marked as yours, catalog updates will leave it alone", migrated[i].Code)
			}
		}
		a.setSettingGlobal(keyAwardEditsSeeded, "1")
	}

	// One-time: the US Counties award was briefly carried under the code
	// "USCOUNTIES" before being renamed to the official "USA-CA". Drop the orphan
	// (def + references) so the operator isn't left with a stale, empty duplicate.
	// It never shipped beyond development, so this is unconditional.
	for i := 0; i < len(migrated); i++ {
		if strings.EqualFold(strings.TrimSpace(migrated[i].Code), "USCOUNTIES") {
			migrated = append(migrated[:i], migrated[i+1:]...)
			i--
			changed = true
			if a.awardRefs != nil {
				_, _ = a.awardRefs.ReplaceAll(a.ctx, "USCOUNTIES", nil)
			}
			applog.Printf("awards: removed legacy USCOUNTIES award (renamed to USA-CA)")
		}
	}

	// Reconcile with the catalog: awards ADDED since this operator last saved, and
	// shipped awards whose definition we have since FIXED (a higher Version). Both
	// must be written back, or the editor would keep showing the old set.
	merged, updated, mc := mergeCatalog(migrated)
	if mc {
		migrated, changed = merged, true
	}
	// A replaced definition gets the catalog's reference list back too — a fix is
	// usually BOTH (a new OR chain and the references its regexes rely on), and a
	// half-applied update is worse than none.
	for _, code := range updated {
		applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
		a.reseedRefsFromCatalog(code)
	}
	// A catalog definition changed (e.g. DDFM gained the postal-code OR-rule), so
	// the materialised award_refs on existing QSOs are now stale. Clear the
	// one-shot backfill flag; backfillAwardRefsOnce runs later in startup and will
	// re-materialise every row against the updated definitions. This makes ANY
	// future catalog bump self-heal the stored columns without a manual flag bump.
	if len(updated) > 0 {
		_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "")
	}
	// Version-gated correction of the built-in awards' Validate sources, which
	// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
	// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
	// from Defaults to protected/built-in awards once.
	const defsFixVersion = "2"
	if v, _ := a.settings.GetGlobal(a.ctx, keyAwardDefsFixed); v != defsFixVersion {
		byCode := map[string]award.Def{}
		for _, d := range award.Defaults() {
			byCode[strings.ToUpper(d.Code)] = d
		}
		for i := range migrated {
			if d, ok := byCode[strings.ToUpper(migrated[i].Code)]; ok && (migrated[i].Builtin || migrated[i].Protected) {
				migrated[i].Confirm = d.Confirm
				migrated[i].Validate = d.Validate
				changed = true
			}
		}
		a.setSettingGlobal(keyAwardDefsFixed, defsFixVersion)
	}
	if !changed {
		return
	}
	if b, err := json.Marshal(migrated); err == nil {
		a.setSettingGlobal(keyAwardDefs, string(b))
		applog.Printf("awards: migrated/fixed %d definitions", len(migrated))
	}
}

// markUserEdited flags every award whose definition differs from what was stored
// before this save. The flag is what makes a catalog update safe: it tells us
// which awards carry the operator's own work, so we never overwrite one with a
// newer shipped version. It is sticky — once edited, an award stays theirs until
// they reset it — and it is set here, on the save path, rather than trusted from
// the frontend, which has no reason to reason about it.
func markUserEdited(next, prev []award.Def) {
	before := make(map[string]award.Def, len(prev))
	for _, d := range prev {
		before[strings.ToUpper(strings.TrimSpace(d.Code))] = d
	}
	for i := range next {
		old, ok := before[strings.ToUpper(strings.TrimSpace(next[i].Code))]
		if !ok {
			continue // brand-new award: not a catalog award, nothing to protect it from
		}
		if old.UserEdited || !old.SameContent(next[i]) {
			next[i].UserEdited = true
		}
		next[i].Version = old.Version // the version says which SHIPPED revision this came from; a save never bumps it
	}
}

// SaveAwardDefs persists edited award definitions.
func (a *App) SaveAwardDefs(defs []award.Def) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	prev := a.awardDefs()
	markUserEdited(defs, prev)
	b, err := json.Marshal(defs)
	if err != nil {
		return err
	}
	if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
		return err
	}
	go a.mirrorAwardsToFolder(defs)
	// A change that only alters HOW a match is written — reference, description
	// or both in the grid column — cannot change WHICH QSOs match. Recomputing
	// every award for every contact then produces a known answer at the cost of
	// reading the whole logbook, and the operator waits minutes to see a column
	// they expected to change at once.
	if codes, ok := relabelOnly(prev, defs); ok {
		a.recomputeAwardRefsForCodesAsync(codes)
		return nil
	}
	a.recomputeAwardRefsAsync() // definitions changed → refresh every row's award_refs
	return nil
}

// relabelOnly reports whether the only difference between two definition sets is
// the RefDisplay of some awards, and names them.
//
// Conservative by construction: it compares the two definitions with RefDisplay
// blanked out, so ANY other difference — a new field, a pattern, a band, an
// award added or removed — fails the test and the full recompute runs. A wrong
// "yes" here would leave stale references in the log, which is far worse than a
// slow save.
func relabelOnly(prev, next []award.Def) ([]string, bool) {
	if len(prev) != len(next) {
		return nil, false
	}
	byCode := map[string]award.Def{}
	for _, d := range prev {
		byCode[strings.ToUpper(strings.TrimSpace(d.Code))] = d
	}
	var codes []string
	for _, n := range next {
		o, ok := byCode[strings.ToUpper(strings.TrimSpace(n.Code))]
		if !ok {
			return nil, false
		}
		if n.RefDisplay != o.RefDisplay {
			codes = append(codes, strings.ToUpper(strings.TrimSpace(n.Code)))
		}
		a, b := n, o
		a.RefDisplay, b.RefDisplay = "", ""
		ja, err1 := json.Marshal(a)
		jb, err2 := json.Marshal(b)
		if err1 != nil || err2 != nil || string(ja) != string(jb) {
			return nil, false
		}
	}
	return codes, len(codes) > 0
}

// mirrorAwards refreshes the awards folder from the database.
//
// It is called from EVERY path that can change an award — the definitions AND the
// references. Mirroring only on "save definitions" was the obvious thing to do and
// the wrong one: add a city regex to a WAPC reference and the definition never
// changes, so the file would silently go stale and you'd share yesterday's award
// believing it current. A mirror that is only sometimes a mirror is worse than no
// mirror, because you trust it.
func (a *App) mirrorAwards() {
	if a.settings == nil {
		return
	}
	go a.mirrorAwardsToFolder(a.awardDefs())
}

// mirrorAwardsToFolder writes one JSON per award into <data>/awards/, and removes
// the files of awards that no longer exist.
//
// EVERY award, built-in included. Skipping the built-ins seemed tidy — why mirror
// ten awards the user never wrote? — but it broke the one case that matters: fix
// DDFM's regex and there is no JSON to hand round or drop into the catalog. The
// rule has no exceptions now, so it needs no explaining: the folder holds your
// awards, as files, always current.
//
// The folder is a MIRROR — an output, never an input. Making it an input too (drop
// a file in and it installs) reads well until you delete an award in the UI, the
// file survives, and the award walks back in on the next start. Receiving an award
// is what Import is for, where you get a say.
func (a *App) mirrorAwardsToFolder(defs []award.Def) {
	dir := a.AwardsFolder()
	if dir == "" || a.awardRefs == nil {
		return
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		applog.Printf("awards: cannot create %s: %v", dir, err)
		return
	}
	keep := map[string]bool{}
	for _, d := range defs {
		code := strings.ToUpper(strings.TrimSpace(d.Code))
		if code == "" {
			continue
		}
		refs, err := a.awardRefs.List(a.ctx, d.Code)
		if err != nil {
			applog.Printf("awards: mirror %s: %v", code, err)
			continue
		}
		bundle := AwardBundle{
			Version:    1,
			ExportedAt: time.Now().UTC().Format(time.RFC3339),
			Awards:     []AwardBundleEntry{{Def: d, References: refs}},
		}
		b, err := json.MarshalIndent(bundle, "", "  ")
		if err != nil {
			continue
		}
		name := strings.ToLower(code) + ".json"
		keep[name] = true
		// Temp file + rename: a crash mid-write must not leave a truncated award.
		tmp := filepath.Join(dir, name+".tmp")
		if err := os.WriteFile(tmp, b, 0o644); err != nil {
			applog.Printf("awards: mirror %s: %v", code, err)
			continue
		}
		if err := os.Rename(tmp, filepath.Join(dir, name)); err != nil {
			applog.Printf("awards: mirror %s: %v", code, err)
		}
	}
	// Delete the file of an award that no longer exists — otherwise a deleted award
	// leaves a ghost file that looks current and would be shared by mistake.
	entries, err := os.ReadDir(dir)
	if err != nil {
		return
	}
	for _, f := range entries {
		n := strings.ToLower(f.Name())
		if f.IsDir() || !strings.HasSuffix(n, ".json") || keep[n] {
			continue
		}
		if err := os.Remove(filepath.Join(dir, f.Name())); err == nil {
			applog.Printf("awards: removed %s (award no longer exists)", f.Name())
		}
	}
}

// AwardExplain is one QSO run through one award's matching rules, step by step.
// It answers the question the awards grid cannot: not "did it match" but WHY.
type AwardExplain struct {
	QSO         qso.QSO           `json:"qso"`
	Explanation award.Explanation `json:"explanation"`
}

// ExplainAward replays an award's rules against the QSOs of one callsign and
// reports every step. Written because three award bugs in a week all presented
// identically — an empty column and no way to see which rule looked where — and
// each took far longer to find than it should have.
//
// Returns the matching QSOs most recent first (a call worked several times can
// match on one QSO and not another, which is itself the answer sometimes).
func (a *App) ExplainAward(code, callsign string) ([]AwardExplain, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	call := strings.ToUpper(strings.TrimSpace(callsign))
	if call == "" {
		return nil, fmt.Errorf("enter a callsign to test against")
	}
	var def *award.Def
	defs := a.awardDefs()
	for i := range defs {
		if strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) {
			def = &defs[i]
			break
		}
	}
	if def == nil {
		return nil, fmt.Errorf("unknown award %q", code)
	}
	qsos, err := a.qso.List(a.ctx, qso.ListFilter{Callsign: call, Limit: 20})
	if err != nil {
		return nil, err
	}
	if len(qsos) == 0 {
		return nil, fmt.Errorf("no QSO with %s in the log", call)
	}
	metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
	out := make([]AwardExplain, 0, len(qsos))
	for i := range qsos {
		out = append(out, AwardExplain{QSO: qsos[i], Explanation: award.Explain(*def, metas, &qsos[i])})
	}
	return out, nil
}

// AwardUpdate is a shipped fix an operator has NOT received, because they edited
// the award and we refuse to overwrite their work. Rather than drop the fix
// silently, we offer it: applying is their call, and it costs them their changes.
type AwardUpdate struct {
	Code string `json:"code"`
	Name string `json:"name"` // the catalog's name — theirs may differ
	From int    `json:"from"` // version they run
	To   int    `json:"to"`   // version we ship
}

// GetAwardUpdates lists the catalog fixes waiting on an operator's decision.
// Awards they have NOT edited are updated automatically at startup and never
// appear here.
func (a *App) GetAwardUpdates() []AwardUpdate {
	out := []AwardUpdate{}
	stored := map[string]award.Def{}
	for _, d := range a.awardDefs() {
		stored[strings.ToUpper(strings.TrimSpace(d.Code))] = d
	}
	for _, c := range award.Defaults() {
		s, ok := stored[strings.ToUpper(strings.TrimSpace(c.Code))]
		if !ok || !s.UserEdited || c.Version <= s.Version {
			continue
		}
		out = append(out, AwardUpdate{Code: c.Code, Name: c.Name, From: s.Version, To: c.Version})
	}
	return out
}

// ApplyAwardUpdate takes the catalog's version of an award, DISCARDING the
// operator's changes to it — definition and reference list both. Only ever called
// because they clicked through a warning saying exactly that.
func (a *App) ApplyAwardUpdate(code string) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	var fresh award.Def
	for _, c := range award.Defaults() {
		if strings.EqualFold(strings.TrimSpace(c.Code), strings.TrimSpace(code)) {
			fresh = c
		}
	}
	if fresh.Code == "" {
		return fmt.Errorf("%s is not an award OpsLog ships", code)
	}
	defs := a.awardDefs()
	found := false
	for i := range defs {
		if !strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) {
			continue
		}
		fresh.Valid = defs[i].Valid // still their choice whether it is switched on
		defs[i] = fresh             // UserEdited cleared: it is the catalog's award again
		found = true
	}
	if !found {
		return fmt.Errorf("%s not found", code)
	}
	b, err := json.Marshal(defs)
	if err != nil {
		return err
	}
	if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
		return err
	}
	a.reseedRefsFromCatalog(fresh.Code)
	a.mirrorAwards()
	applog.Printf("awards: %s updated to catalog v%d at the operator's request", fresh.Code, fresh.Version)
	return nil
}

// DismissAwardUpdate keeps the operator's award as it is and stops offering this
// fix. It records the version they turned down — not the content — so the award
// stays theirs, and a LATER revision is still offered.
func (a *App) DismissAwardUpdate(code string) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	var v int
	for _, c := range award.Defaults() {
		if strings.EqualFold(strings.TrimSpace(c.Code), strings.TrimSpace(code)) {
			v = c.Version
		}
	}
	defs := a.awardDefs()
	for i := range defs {
		if strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) {
			defs[i].Version = v
		}
	}
	b, err := json.Marshal(defs)
	if err != nil {
		return err
	}
	return a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b))
}

// ResetAwardDefs restores the built-in defaults.
func (a *App) ResetAwardDefs() ([]award.Def, error) {
	if a.settings == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	d := award.Defaults()
	// Written directly, NOT through SaveAwardDefs: that path marks whatever it
	// saves as user-edited, and a reset means the exact opposite — the operator is
	// handing the awards back to the catalog, flags cleared, so future updates
	// apply again. Their reference lists go back to the shipped ones too.
	b, err := json.Marshal(d)
	if err != nil {
		return nil, err
	}
	if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
		return nil, err
	}
	for _, def := range d {
		a.reseedRefsFromCatalog(def.Code)
	}
	go a.mirrorAwardsToFolder(d)
	return d, nil
}

// GetAwards computes progress for EVERY award (whole-log scan). Kept for
// callers that need all results at once; the UI now computes one award at a
// time via GetAward to stay responsive on large logs.
func (a *App) GetAwards() ([]award.Result, error) {
	return a.computeAwards(a.awardDefs())
}

// GetAward computes progress for a single award by code (one whole-log scan,
// matching only that award). This is what the awards UI calls when an award is
// selected, so opening the panel doesn't scan every award up front.
// GetAward computes one award. modeClass ("CW", "PHONE", "DIGI", or "" for all)
// narrows it to contacts of that class BEFORE the engine runs, so the bands, the
// counts and the confirmations all describe that mode.
//
// Filtering only the reference list was not enough: a reference kept because it
// has one CW contact still showed the band cells it earned on SSB, which is
// precisely what an operator chasing a CW award must not be told.
func (a *App) GetAward(code, modeClass string) (award.Result, error) {
	for _, d := range a.awardDefs() {
		if strings.EqualFold(d.Code, code) {
			results, err := a.computeAwardsForMode([]award.Def{d}, modeClass)
			if err != nil {
				return award.Result{}, err
			}
			if len(results) > 0 {
				return results[0], nil
			}
			return award.Result{}, nil
		}
	}
	return award.Result{}, fmt.Errorf("unknown award %q", code)
}

// computeAwards runs the engine for the given award definitions over the whole
// log and enriches dynamic awards (totals + worked-reference names).
func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
	return a.computeAwardsForMode(defs, "")
}

// computeAwardsForMode is computeAwards restricted to one mode class. The
// snapshot is shared and cached by log revision, so this costs a matching pass,
// not a database read.
func (a *App) computeAwardsForMode(defs []award.Def, modeClass string) ([]award.Result, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	all, err := a.awardSnapshot()
	if err != nil {
		return nil, err
	}
	if want := strings.ToUpper(strings.TrimSpace(modeClass)); want != "" && want != "ALL" {
		kept := make([]qso.QSO, 0, len(all))
		for _, q := range all {
			if award.ModeClass(q.Mode) == want {
				kept = append(kept, q)
			}
		}
		all = kept
	}
	nameOf := func(field, ref string) string {
		switch field {
		case "dxcc":
			if n, err := strconv.Atoi(ref); err == nil {
				return dxcc.NameForDXCC(n)
			}
		case "cont":
			return continentName(ref)
		}
		return ""
	}
	refMetas := a.awardRefMetas(defs)
	results := award.Compute(defs, all, refMetas, nameOf)
	// DXCC: fill each reference's Group with the entity's primary prefix (XE,
	// DL, F…). Sorting/searching a 340-row list by bare entity number is
	// hopeless — the prefix is how operators actually identify an entity.
	// Derived from the live cty.dat rather than stored on the reference row, so
	// it needs no re-seed of an existing installation.
	if a.dxcc != nil {
		var pfx map[int]string
		for i := range results {
			if !strings.EqualFold(results[i].Code, "DXCC") {
				continue
			}
			if pfx == nil {
				if pfx = a.dxcc.PrefixByDXCC(); pfx == nil {
					break
				}
			}
			for j := range results[i].Refs {
				if n, err := strconv.Atoi(results[i].Refs[j].Ref); err == nil {
					results[i].Refs[j].Group = pfx[n]
				}
			}
		}
	}
	// Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their
	// list can be huge. Enrich them after the fact: real Total from the stored
	// count, and reference names for the worked references only.
	if a.awardRefs != nil {
		counts, _ := a.awardRefs.Counts(a.ctx)
		for i := range results {
			r := &results[i]
			if _, predef := refMetas[strings.ToUpper(r.Code)]; predef {
				continue // predefined awards are already complete (totals + names)
			}
			if total := counts[strings.ToUpper(r.Code)]; total > 0 {
				r.Total = total
			}
			codes := make([]string, 0, len(r.Refs))
			for _, rf := range r.Refs {
				if rf.Name == "" {
					codes = append(codes, rf.Ref)
				}
			}
			if len(codes) == 0 {
				continue
			}
			if names, err := a.awardRefs.NamesFor(a.ctx, r.Code, codes); err == nil {
				for j := range r.Refs {
					if r.Refs[j].Name == "" {
						r.Refs[j].Name = names[strings.ToUpper(r.Refs[j].Ref)]
					}
				}
			}
		}
	}
	return results, nil
}

// AwardCellQSOs returns the QSOs that contribute to one award reference,
// optionally on a single band (band="" = all bands). Powers the award-grid
// cell drill-down ("show me every Canada contact on 20m").
// AwardCellQSOs lists the contacts behind one cell. modeClass ("CW", "PHONE",
// "DIGI", or "" for all) applies the SAME filter as the reference list above it
// — without it, filtering the list to CW and then opening a reference showed the
// FT8 and SSB contacts that filter exists to hide.
func (a *App) AwardCellQSOs(code, ref, band, modeClass string) ([]qso.QSO, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	defs := a.awardDefs()
	var def *award.Def
	for i := range defs {
		if strings.EqualFold(defs[i].Code, code) {
			def = &defs[i]
			break
		}
	}
	if def == nil {
		return nil, fmt.Errorf("unknown award %q", code)
	}
	metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
	wantRef := strings.ToUpper(strings.TrimSpace(ref))
	wantBand := strings.ToLower(strings.TrimSpace(band))
	wantMode := strings.ToUpper(strings.TrimSpace(modeClass))

	var out []qso.QSO
	err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
		if wantBand != "" && strings.ToLower(strings.TrimSpace(q.Band)) != wantBand {
			return nil
		}
		if wantMode != "" && wantMode != "ALL" && award.ModeClass(q.Mode) != wantMode {
			return nil
		}
		a.enrichQSOForAwards(&q)
		for _, c := range award.MatchQSO(*def, metas, &q) {
			if strings.ToUpper(c) == wantRef {
				out = append(out, q)
				break
			}
		}
		return nil
	})
	return out, err
}

// AwardMissingQSOs returns the contacts that fall within an award's scope but
// yield NO reference — so they're silently excluded from the award. Example:
// a French QSO (DXCC 227, in DDFM scope) whose note has no "Dxx" department.
// The operator can then open each and add the missing reference.
//
// Only awards with a DXCC scope are meaningful here: without it, "in scope" is
// the whole log, so e.g. POTA would report every non-POTA QSO. Such awards
// return an empty list (the UI explains why).
func (a *App) AwardMissingQSOs(code string) ([]qso.QSO, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	defs := a.awardDefs()
	var def *award.Def
	for i := range defs {
		if strings.EqualFold(defs[i].Code, code) {
			def = &defs[i]
			break
		}
	}
	if def == nil {
		return nil, fmt.Errorf("unknown award %q", code)
	}
	if len(def.DXCCFilter) == 0 {
		return []qso.QSO{}, nil // not meaningful without a DXCC scope
	}
	metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
	// Reuse the fast, already-enriched award snapshot (light column scan, cached
	// against the logbook revision) instead of a full IterateAll — the latter
	// pulled all ~150 columns and took ~20s over a remote MySQL.
	snapshot, err := a.awardSnapshot()
	if err != nil {
		return nil, err
	}
	var out []qso.QSO
	for i := range snapshot {
		q := snapshot[i]
		// In the award's scope, yet no reference extracted → a gap to fix.
		if award.InScope(*def, &q) && len(award.MatchQSO(*def, metas, &q)) == 0 {
			out = append(out, q)
		}
	}
	return out, nil
}

// GetPOTAToken returns the stored pota.app session token (for the settings UI).
func (a *App) GetPOTAToken() string {
	if a.settings == nil {
		return ""
	}
	t, _ := a.settings.Get(a.ctx, keyExtPotaToken)
	return t
}

// SavePOTAToken stores the pota.app session token used to sync the hunter log.
func (a *App) SavePOTAToken(token string) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	return a.settings.Set(a.ctx, keyExtPotaToken, strings.TrimSpace(token))
}

// POTAUnmatched is one hunter-log entry that found no local QSO, with a reason
// and (when a near-match exists) the id of the candidate QSO so the UI can open
// it for correction.
type POTAUnmatched struct {
	Activator string `json:"activator"`
	Date      string `json:"date"`
	Band      string `json:"band"`
	Reference string `json:"reference"`
	Reason    string `json:"reason"`
	QSOID     int64  `json:"qso_id"` // 0 = no candidate to open
}

// POTASyncResult summarises a hunter-log sync run for the UI.
type POTASyncResult struct {
	Fetched          int             `json:"fetched"`            // hunter-log entries downloaded
	Updated          int             `json:"updated"`            // QSOs stamped/appended with a park ref
	AlreadyTagged    int             `json:"already_tagged"`     // already carried the park
	Added            int             `json:"added"`              // new QSOs inserted (addMissing)
	Unmatched        int             `json:"unmatched"`          // no local QSO and not added
	UnmatchedList    []POTAUnmatched `json:"unmatched_list"`     // per-entry detail (capped)
	SkippedOtherCall int             `json:"skipped_other_call"` // hunts made under another callsign (onlyMyCall)
	MyCall           string          `json:"my_call"`            // the profile call used for the onlyMyCall filter
}

// SyncPOTAHunterLog downloads the user's POTA hunter log and stamps pota_ref on
// matching local QSOs. Matching is by callsign + band only — time skew between
// the activator's log and yours is ignored (we just need the park reference);
// when several QSOs share a call+band, the closest in time is used. n-fer parks
// (same QSO at several parks, logged within minutes) are appended.
// When addMissing is true, hunter-log entries whose callsign isn't in the log
// at all are inserted as new QSOs (callsign/date/band/mode/park, cty.dat-enriched).
// onlyMyCall, when true, processes only hunts made under the active profile's
// callsign — so hunts you made under another call (e.g. XV9Q, NQ2H) that aren't
// in this logbook are skipped rather than reported as "not in your log".
func (a *App) SyncPOTAHunterLog(addMissing bool, onlyMyCall bool) (POTASyncResult, error) {
	if a.qso == nil || a.settings == nil {
		return POTASyncResult{}, fmt.Errorf("db not initialized")
	}
	token, _ := a.settings.Get(a.ctx, keyExtPotaToken)
	entries, err := pota.FetchHunterLog(a.ctx, token, applog.Printf)
	if err != nil {
		return POTASyncResult{}, err
	}
	// The active profile's callsign drives the onlyMyCall filter (base call, so
	// F4BPO/P and F4BPO are the same identity).
	myCall := ""
	if a.profiles != nil {
		if p, perr := a.profiles.Active(a.ctx); perr == nil {
			myCall = pota.BaseCall(p.Callsign)
		}
	}
	var all []qso.QSO
	if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
		all = append(all, q)
		return nil
	}); err != nil {
		return POTASyncResult{}, err
	}
	idx := map[string][]int{}    // baseCall|band → QSO indices
	byCall := map[string][]int{} // baseCall → QSO indices
	for i := range all {
		idx[potaMatchKey(all[i].Callsign, all[i].Band)] = append(idx[potaMatchKey(all[i].Callsign, all[i].Band)], i)
		bc := pota.BaseCall(all[i].Callsign)
		byCall[bc] = append(byCall[bc], i)
	}

	const nferWindow = 15 * time.Minute // append a 2nd park only for the same physical QSO
	const maxDetail = 300
	res := POTASyncResult{Fetched: len(entries), MyCall: myCall}
	toUpdate := map[int]struct{}{}
	var toAdd []pota.HunterQSO
	addUnmatched := func(e pota.HunterQSO, reason string, qsoID int64) {
		res.Unmatched++
		if len(res.UnmatchedList) < maxDetail {
			d := ""
			if !e.Date.IsZero() {
				d = e.Date.Format("2006-01-02 15:04")
			}
			res.UnmatchedList = append(res.UnmatchedList, POTAUnmatched{
				Activator: e.Worked, Date: d, Band: e.Band, Reference: e.Reference, Reason: reason, QSOID: qsoID,
			})
		}
	}

	for _, e := range entries {
		// Skip hunts made under another of your callsigns (not this profile's).
		// They legitimately aren't in this logbook, so don't flag them as errors.
		if onlyMyCall && myCall != "" && e.Hunter != "" && pota.BaseCall(e.Hunter) != myCall {
			res.SkippedOtherCall++
			continue
		}
		if e.Date.IsZero() {
			addUnmatched(e, "POTA entry has no usable date", 0)
			continue
		}
		cands := idx[potaMatchKey(e.Worked, e.Band)]
		// Already covered? (any same call+band QSO carries this park)
		covered := false
		for _, i := range cands {
			if potaRefHas(all[i].POTARef, e.Reference) {
				covered = true
				break
			}
		}
		// Closest empty + closest non-empty (any time skew — we only need the ref).
		emptyBest, nonEmptyBest := -1, -1
		var emptyDiff, nonEmptyDiff time.Duration
		for _, i := range cands {
			diff := all[i].QSODate.Sub(e.Date)
			if diff < 0 {
				diff = -diff
			}
			if all[i].POTARef == "" {
				if emptyBest < 0 || diff < emptyDiff {
					emptyBest, emptyDiff = i, diff
				}
			} else if nonEmptyBest < 0 || diff < nonEmptyDiff {
				nonEmptyBest, nonEmptyDiff = i, diff
			}
		}
		switch {
		case covered:
			res.AlreadyTagged++
		case emptyBest >= 0:
			all[emptyBest].POTARef = e.Reference // stamp regardless of time skew
			toUpdate[emptyBest] = struct{}{}
		case nonEmptyBest >= 0 && nonEmptyDiff <= nferWindow:
			// n-fer: same physical QSO at another park.
			all[nonEmptyBest].POTARef += "," + e.Reference
			toUpdate[nonEmptyBest] = struct{}{}
		case len(byCall[pota.BaseCall(e.Worked)]) == 0 && addMissing:
			toAdd = append(toAdd, e)
		default:
			reason, candidate := potaUnmatchReason(e, idx, byCall, all)
			addUnmatched(e, reason, candidate)
		}
	}

	// Count only QSOs actually written, and log failures — so the report
	// reflects reality (a DB lock / constraint no longer inflates "updated").
	for i := range toUpdate {
		if err := a.qso.Update(a.ctx, all[i]); err != nil {
			applog.Printf("pota: update QSO %s failed: %v", all[i].Callsign, err)
			continue
		}
		res.Updated++
	}
	if len(toAdd) > 0 {
		res.Added = a.insertPOTAQSOs(toAdd)
	}
	applog.Printf("pota: hunter-log sync — %d fetched, %d updated, %d already, %d added, %d unmatched",
		res.Fetched, res.Updated, res.AlreadyTagged, res.Added, res.Unmatched)
	return res, nil
}

// insertPOTAQSOs inserts hunter-log entries that aren't in the log as new QSOs,
// grouping n-fer entries (same call+band+minute) into one QSO with several
// parks. Country/DXCC/zones are filled from cty.dat. Returns how many inserted.
func (a *App) insertPOTAQSOs(entries []pota.HunterQSO) int {
	type group struct {
		e     pota.HunterQSO
		parks []string
	}
	groups := map[string]*group{}
	var order []string
	for _, e := range entries {
		key := pota.BaseCall(e.Worked) + "|" + e.Band + "|" + e.Date.Format("2006-01-02T15:04")
		g := groups[key]
		if g == nil {
			g = &group{e: e}
			groups[key] = g
			order = append(order, key)
		}
		already := false
		for _, p := range g.parks {
			if p == e.Reference {
				already = true
			}
		}
		if !already {
			g.parks = append(g.parks, e.Reference)
		}
	}
	added := 0
	for _, key := range order {
		g := groups[key]
		q := qso.QSO{
			Callsign: g.e.Worked,
			QSODate:  g.e.Date,
			Band:     g.e.Band,
			Mode:     g.e.Mode,
			POTARef:  strings.Join(g.parks, ","),
			Comment:  "Added from POTA hunter log",
		}
		a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
		if _, err := a.qso.Add(a.ctx, q); err == nil {
			added++
		}
	}
	// Bulk insert, so the caches are dropped ONCE at the end rather than per row.
	// invalidateAwardStats is the right hammer here: a raw a.qso.Add leaves the
	// cluster worked-index, the award snapshot AND the QSO numbering all holding
	// a pre-import answer, and this import inserts into the MIDDLE of the order.
	if added > 0 {
		a.invalidateAwardStats()
	}
	return added
}

// potaMatchKey indexes a QSO by base callsign + band for hunter-log matching.
func potaMatchKey(call, band string) string {
	return pota.BaseCall(call) + "|" + strings.ToLower(strings.TrimSpace(band))
}

// potaRefHas reports whether a (possibly comma-separated) pota_ref already
// contains the given park reference.
func potaRefHas(existing, ref string) bool {
	ref = strings.ToUpper(strings.TrimSpace(ref))
	for _, p := range strings.Split(existing, ",") {
		if strings.ToUpper(strings.TrimSpace(p)) == ref {
			return true
		}
	}
	return false
}

// potaUnmatchReason explains why a hunter-log entry matched no local QSO and,
// when a near-match exists (right band but wrong time, or right call on another
// band), returns the candidate QSO id so the UI can open it for correction.
func potaUnmatchReason(e pota.HunterQSO, idx, byCall map[string][]int, all []qso.QSO) (string, int64) {
	closest := func(cands []int) (int, time.Duration) {
		best, bestDiff := -1, time.Duration(1<<62-1)
		for _, i := range cands {
			d := all[i].QSODate.Sub(e.Date)
			if d < 0 {
				d = -d
			}
			if d < bestDiff {
				best, bestDiff = i, d
			}
		}
		return best, bestDiff
	}
	if sameBand := idx[potaMatchKey(e.Worked, e.Band)]; len(sameBand) > 0 {
		// Same call+band exists but every QSO was outside the ±5 min window.
		i, diff := closest(sameBand)
		return fmt.Sprintf("same call+band logged, but closest is Δ%s away", roundDur(diff)), all[i].ID
	}
	others := byCall[pota.BaseCall(e.Worked)]
	if len(others) == 0 {
		return "this callsign isn't in your log", 0
	}
	bands := map[string]struct{}{}
	for _, i := range others {
		if b := strings.ToLower(strings.TrimSpace(all[i].Band)); b != "" {
			bands[b] = struct{}{}
		}
	}
	list := make([]string, 0, len(bands))
	for b := range bands {
		list = append(list, b)
	}
	sort.Strings(list)
	i, _ := closest(others)
	return fmt.Sprintf("logged on %s, not %s", strings.Join(list, "/"), e.Band), all[i].ID
}

// roundDur renders a duration compactly (e.g. "3m", "2h5m", "45s").
func roundDur(d time.Duration) string {
	if d < time.Minute {
		return fmt.Sprintf("%ds", int(d.Seconds()))
	}
	if d < time.Hour {
		return fmt.Sprintf("%dm", int(d.Minutes()))
	}
	return d.Round(time.Minute).String()
}

// AwardStatRow is one row of the award statistics matrix (e.g. "CONFIRMED CW"):
// distinct-reference counts per band, plus Total (distinct on any band) and
// GrandTotal (sum of the per-band band-slots).
type AwardStatRow struct {
	Label      string `json:"label"`
	Cells      []int  `json:"cells"`
	Total      int    `json:"total"`
	GrandTotal int    `json:"grand_total"`
}

// AwardStatsResult is the statistics matrix for one award (Log4OM "Statistics").
type AwardStatsResult struct {
	Code  string         `json:"code"`
	Bands []string       `json:"bands"`
	Rows  []AwardStatRow `json:"rows"`
}

var statsBands = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
	"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm", "9cm", "6cm", "3cm", "1.25cm",
	"6mm", "4mm", "2.5mm", "2mm", "1mm"}

// awardSnapshot returns the logbook as a light-scanned, award-enriched slice,
// reused across award computations. Pulling the whole logbook is the dominant
// cost of every award (≈4s for 27k rows over a remote MySQL link), while the
// matching itself is a few milliseconds — so we pull once and keep the result
// in memory, rebuilding only when the logbook revision moves (a QSO added,
// removed, or edited). The returned slice is treated as read-only by callers.
func (a *App) awardSnapshot() ([]qso.QSO, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	rev, revErr := a.qso.Revision(a.ctx)
	if revErr == nil {
		a.awardSnapMu.Lock()
		if a.awardSnap != nil && a.awardSnapRev == rev {
			qs := a.awardSnap
			a.awardSnapUsed = time.Now()
			a.awardSnapMu.Unlock()
			return qs, nil
		}
		a.awardSnapMu.Unlock()
	}

	// ONE BUILDER AT A TIME.
	//
	// The cache lock above is released before the pull, so every caller that
	// arrives while the logbook is being read used to miss and start its own.
	// Opening the Awards panel does that: a field log showed three pulls of the
	// same 123 615 QSOs within ten seconds, and three copies alive at once took
	// the heap from 725 MB to 2.5 GB. The work was identical each time.
	//
	// Waiting here costs the second and third caller the seconds the first was
	// going to take anyway — they were already paying that, plus a second and
	// third trip to the database.
	a.awardSnapBuild.Lock()
	defer a.awardSnapBuild.Unlock()
	// Re-check: whoever held the build lock has just finished, and their result
	// is what we came for.
	if revErr == nil {
		a.awardSnapMu.Lock()
		if a.awardSnap != nil && a.awardSnapRev == rev {
			qs := a.awardSnap
			a.awardSnapUsed = time.Now()
			a.awardSnapMu.Unlock()
			return qs, nil
		}
		a.awardSnapMu.Unlock()
	}

	t0 := time.Now()
	// Sized from the last build. Growing a slice to 123 000 structs by doubling
	// copies the whole thing a dozen times and holds the old and the new array
	// together at every step — on the biggest object OpsLog keeps, that transient
	// is worth avoiding.
	a.awardSnapMu.Lock()
	hint := a.awardSnapCap
	a.awardSnapMu.Unlock()
	all := make([]qso.QSO, 0, hint)
	if err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
		a.enrichQSOForAwards(&q)
		all = append(all, q)
		return nil
	}); err != nil {
		return nil, err
	}
	// Heap alongside the row count: this snapshot is the single largest thing
	// OpsLog holds, and "OpsLog is eating memory" reports are unanswerable
	// without a number. A 132 000-QSO logbook was the case that prompted it.
	var ms runtime.MemStats
	runtime.ReadMemStats(&ms)
	applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s) — go heap now %d MB",
		len(all), time.Since(t0).Round(time.Millisecond), rev, ms.HeapAlloc/(1024*1024))

	a.awardSnapMu.Lock()
	a.awardSnapCap = len(all) // next build starts the right size
	if revErr == nil {
		a.awardSnap = all
		a.awardSnapRev = rev
		a.awardSnapUsed = time.Now()
	}
	a.awardSnapMu.Unlock()
	return all, nil
}

// awardSnapIdleTTL is how long the snapshot survives without a reader.
//
// Generous on purpose: an operator working through the Awards panel triggers a
// computation every few seconds, and re-pulling costs seconds on a big remote
// logbook. This is only meant to catch the far commoner case — awards looked at
// once, then hours of logging with several hundred megabytes still held.
const awardSnapIdleTTL = 15 * time.Minute

// awardSnapshotJanitor drops the award snapshot once nothing has read it for a
// while, and returns the memory to the OS.
//
// The snapshot is a whole logbook of QSO structs, each carrying a decoded map of
// its ADIF extras: ~1.9 KB of struct plus strings and one map allocation per
// QSO. At 30 000 QSOs that is tens of megabytes and nobody notices; at 132 000
// it is several hundred, held for the rest of the session because the cache had
// no expiry — only invalidation when the logbook changed.
func (a *App) awardSnapshotJanitor() {
	for {
		time.Sleep(time.Minute)
		a.awardSnapMu.Lock()
		n := len(a.awardSnap)
		idle := !a.awardSnapUsed.IsZero() && time.Since(a.awardSnapUsed) > awardSnapIdleTTL
		if a.awardSnap != nil && idle {
			a.awardSnap = nil
			a.awardSnapRev = ""
		}
		a.awardSnapMu.Unlock()
		if n > 0 && idle {
			// FreeOSMemory, not just GC: Go hands pages back lazily, and the whole
			// point here is that the operator sees the memory come back.
			debug.FreeOSMemory()
			var ms runtime.MemStats
			runtime.ReadMemStats(&ms)
			applog.Printf("awardSnapshot: released %d cached qsos after %v idle — go heap now %d MB",
				n, awardSnapIdleTTL, ms.HeapAlloc/(1024*1024))
		}
	}
}

// GetAwardStats computes the worked/confirmed/validated reference counts of one
// award, broken down by band and by mode category (All/CW/Digital/Phone).
func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {
	if a.qso == nil {
		return AwardStatsResult{}, fmt.Errorf("db not initialized")
	}
	defs := a.awardDefs()
	var def *award.Def
	for i := range defs {
		if strings.EqualFold(defs[i].Code, code) {
			def = &defs[i]
			break
		}
	}
	if def == nil {
		return AwardStatsResult{}, fmt.Errorf("unknown award %q", code)
	}
	metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]

	bandIdx := make(map[string]int, len(statsBands))
	for i, b := range statsBands {
		bandIdx[b] = i
	}
	cats := []string{"ALL", "CW", "DIGITAL", "PHONE"}
	stats := []string{"WORKED", "CONFIRMED", "VALIDATED"}

	// acc[cat][stat]: per-band ref sets + an overall (any-band) ref set.
	type acc struct {
		perBand []map[string]struct{}
		overall map[string]struct{}
	}
	accs := map[string]map[string]*acc{}
	for _, c := range cats {
		accs[c] = map[string]*acc{}
		for _, s := range stats {
			pb := make([]map[string]struct{}, len(statsBands))
			for i := range pb {
				pb[i] = map[string]struct{}{}
			}
			accs[c][s] = &acc{perBand: pb, overall: map[string]struct{}{}}
		}
	}

	snapshot, err := a.awardSnapshot()
	if err != nil {
		return AwardStatsResult{}, err
	}
	for i := range snapshot {
		q := &snapshot[i] // read-only; already award-enriched in the snapshot
		refs := award.MatchQSO(*def, metas, q)
		if len(refs) == 0 {
			continue
		}
		bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))]
		isConf := award.Confirmed(q, *def, def.Confirm)
		isVal := award.Confirmed(q, *def, def.Validate)
		cat := strings.ToUpper(award.EmissionOf(q.Mode))

		record := func(c string) {
			put := func(stat string) {
				ac := accs[c][stat]
				for _, r := range refs {
					ac.overall[r] = struct{}{}
					if hasBand {
						ac.perBand[bi][r] = struct{}{}
					}
				}
			}
			put("WORKED")
			if isConf {
				put("CONFIRMED")
			}
			if isVal {
				put("VALIDATED")
			}
		}
		record("ALL")
		if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" {
			record(cat)
		}
	}

	res := AwardStatsResult{Code: def.Code, Bands: statsBands}
	for _, c := range cats {
		for _, s := range stats {
			ac := accs[c][s]
			cells := make([]int, len(statsBands))
			grand := 0
			for i := range ac.perBand {
				cells[i] = len(ac.perBand[i])
				grand += cells[i]
			}
			label := s
			if c != "ALL" {
				label = s + " " + c
			}
			res.Rows = append(res.Rows, AwardStatRow{Label: label, Cells: cells, Total: len(ac.overall), GrandTotal: grand})
		}
	}
	return res, nil
}

// invalidateAwardStats drops the in-memory award QSO snapshot. Called after QSO
// edits that don't change the logbook row count (e.g. QSL confirmation updates),
// since those aren't caught by the revision check in awardSnapshot.
func (a *App) invalidateAwardStats() {
	a.awardSnapMu.Lock()
	a.awardSnap = nil
	a.awardSnapRev = ""
	a.awardSnapMu.Unlock()
	// Drop the cluster worked-index snapshot too, so the Cluster tab's NEW/WORKED
	// colouring reflects the change on the next spot batch (it rebuilds lazily).
	a.clusterStatusMu.Lock()
	a.clusterStatusIdx = nil
	a.clusterStatusMu.Unlock()
	// The numbering shifts whenever a contact is added or removed — and an
	// imported ADIF inserts into the MIDDLE of the order, not at the end.
	a.qsoNumMu.Lock()
	a.qsoNumbers = nil
	a.qsoNumMu.Unlock()
	// Reference lists, award definitions and bulk edits all land here, and any of
	// them can change what counts as already worked.
	a.invalidateWorkedRefs()
	// Bulk QSO changes (import, delete, bulk edit) also land here — refresh the
	// worked-index so alert "needed" checks stay accurate. Async: never block the
	// mutation, and it's a single lightweight query.
	go a.rebuildWorkedIndex()
}

// RescanAwards forces the next award computation to re-pull the logbook from the
// database, bypassing the in-memory snapshot. Bound to the Awards panel's
// "Rescan" button so the operator can refresh after an external change the
// revision check can't see (e.g. a LoTW/QRZ confirmation download that only
// flips qsl_rcvd flags on existing rows).
func (a *App) RescanAwards() error {
	a.invalidateAwardStats()
	// Also refresh the materialised award_refs on every QSO (the grid columns read
	// from there). Manual trigger for when a definition/reference change should be
	// re-applied to existing rows on demand rather than waiting for the next event.
	a.recomputeAwardRefsAsync()
	return nil
}

// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
// are large and not needed for matching; their names are filled afterwards.
func (a *App) awardRefMetas(defs []award.Def) map[string][]award.RefMeta {
	out := map[string][]award.RefMeta{}
	if a.awardRefs == nil {
		return out
	}
	for _, d := range defs {
		if d.Dynamic {
			continue
		}
		code := strings.ToUpper(d.Code)
		refs, err := a.awardRefs.List(a.ctx, code)
		if err != nil || len(refs) == 0 {
			continue
		}
		metas := make([]award.RefMeta, 0, len(refs))
		for _, rf := range refs {
			dxccList := rf.DXCCList
			if len(dxccList) == 0 && rf.DXCC > 0 {
				dxccList = []int{rf.DXCC}
			}
			metas = append(metas, award.RefMeta{
				Code: rf.Code, Name: rf.Name, Group: rf.Group, SubGrp: rf.SubGrp,
				DXCCList: dxccList, Pattern: rf.Pattern, Valid: rf.Valid,
				ValidFrom: rf.ValidFrom, ValidTo: rf.ValidTo,
			})
		}
		out[code] = metas
	}
	return out
}

// AssignAwardRefToQSOs assigns one award reference to many QSOs at once — the
// bulk action in the award "missing references" view (e.g. tag every "Urumqi"
// contact as Xinjiang for WAPC). It writes the manual override the award engine
// honours, replacing any previous reference this award had on each QSO. Returns
// the number of QSOs updated.
func (a *App) AssignAwardRefToQSOs(code, ref string, ids []int64) (int, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	code = strings.ToUpper(strings.TrimSpace(code))
	ref = strings.ToUpper(strings.TrimSpace(ref))
	if code == "" || ref == "" {
		return 0, fmt.Errorf("award code and reference are required")
	}
	n := 0
	for _, id := range ids {
		q, err := a.qso.GetByID(a.ctx, id)
		if err != nil {
			continue
		}
		if q.Extras == nil {
			q.Extras = map[string]string{}
		}
		q.Extras[award.ManualRefsKey] = setOverrideRef(q.Extras[award.ManualRefsKey], code, ref)
		if err := a.qso.Update(a.ctx, q); err == nil {
			n++
		}
	}
	if n > 0 {
		a.invalidateAwardStats()
	}
	return n, nil
}

// setOverrideRef replaces (or adds) the reference for one award code in a
// "CODE@REF;CODE@REF" override string, leaving other awards' entries intact.
func setOverrideRef(existing, code, ref string) string {
	var out []string
	for _, entry := range strings.Split(existing, ";") {
		entry = strings.TrimSpace(entry)
		if entry == "" {
			continue
		}
		at := strings.IndexByte(entry, '@')
		if at > 0 && strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
			continue // drop this award's previous reference
		}
		out = append(out, entry)
	}
	out = append(out, code+"@"+ref)
	return strings.Join(out, ";")
}

// QSOAwardRef is one award reference a single QSO contributes to. Pickable
// marks awards backed by a reference list (POTA, SOTA, …) — those are assigned
// manually; the rest (DXCC, WAZ, WPX, DDFM, …) are computed from QSO fields.
type QSOAwardRef struct {
	Code     string `json:"code"`
	Ref      string `json:"ref"`
	Name     string `json:"name,omitempty"`
	Pickable bool   `json:"pickable"`
	// Ambiguous marks a reference the matcher found but did NOT assign, because
	// the award allows one reference per QSO and several matched. Offered as a
	// choice in the entry panel instead of being added silently — see
	// award.Def.OneRefPerQSO.
	Ambiguous bool `json:"ambiguous,omitempty"`
}

// enrichQSOForAwards fills in CQ/ITU zone, continent and DXCC entity from
// cty.dat when the QSO doesn't carry them, so computed awards (WAZ/WITUZ/WAC/
// DXCC) work even on records that were never enriched. Non-destructive: it
// mutates the in-memory copy used for award matching only, never the database.
func (a *App) enrichQSOForAwards(q *qso.QSO) {
	if a.dxcc == nil {
		return
	}
	// Recover the band from the frequency when missing, so per-band award
	// statistics aren't lost for QSOs that carry only a frequency.
	if strings.TrimSpace(q.Band) == "" && q.FreqHz != nil && *q.FreqHz > 0 {
		if b := bandForHz(*q.FreqHz); b != "" {
			q.Band = b
		}
	}
	needCQ := q.CQZ == nil || *q.CQZ == 0
	needITU := q.ITUZ == nil || *q.ITUZ == 0
	needCont := strings.TrimSpace(q.Continent) == ""
	needDXCC := q.DXCC == nil || *q.DXCC == 0
	if !needCQ && !needITU && !needCont && !needDXCC {
		return
	}
	m, ok := a.dxcc.Lookup(q.Callsign)
	if !ok {
		return
	}
	if needCQ && m.CQZone > 0 {
		z := m.CQZone
		q.CQZ = &z
	}
	if needITU && m.ITUZone > 0 {
		z := m.ITUZone
		q.ITUZ = &z
	}
	if needCont && m.Continent != "" {
		q.Continent = m.Continent
	}
	if needDXCC && m.Entity != nil {
		if n := dxcc.EntityDXCC(m.Entity.Name); n > 0 {
			q.DXCC = &n
		}
	}
	// Zone-split countries (USA, Australia): the per-entity default zone is too
	// coarse (W6 = CQ5 instead of 3). Apply the call-district rule so awards
	// (WAZ / WITUZ) match Log4OM. This OVERRIDES a stored entity-default zone.
	a.refineDistrictZones(q)
}

// awardBandPlan maps a frequency (Hz) to its ADIF band. Used to recover the
// band for award statistics when a QSO has a frequency but no band field.
var awardBandPlan = []struct {
	name   string
	lo, hi int64
}{
	{"2190m", 135700, 137800}, {"630m", 472000, 479000}, {"160m", 1800000, 2000000},
	{"80m", 3500000, 4000000}, {"60m", 5060000, 5450000}, {"40m", 7000000, 7300000},
	{"30m", 10100000, 10150000}, {"20m", 14000000, 14350000}, {"17m", 18068000, 18168000},
	{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
	{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
	{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
	{"13cm", 2300000000, 2450000000}, {"9cm", 3300000000, 3500000000},
	{"6cm", 5650000000, 5925000000}, {"3cm", 10000000000, 10500000000},
	{"1.25cm", 24000000000, 24250000000}, {"6mm", 47000000000, 47200000000},
	{"4mm", 75500000000, 81000000000}, {"2.5mm", 119980000000, 123000000000},
	{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
}

func bandForHz(hz int64) string {
	for _, b := range awardBandPlan {
		if hz >= b.lo && hz <= b.hi {
			return b.name
		}
	}
	return ""
}

// isComputedAwardField reports whether an award's field is auto-derived from
// structured QSO data (entity, zones, prefix, location) rather than a reference
// the operator assigns by hand. Such awards are read-only in the per-QSO editor.
func isComputedAwardField(field string) bool {
	switch field {
	// Purely derived from the callsign / cty.dat — never assigned by hand.
	case "dxcc", "cqz", "ituz", "prefix", "callsign", "cont", "country", "grid", "grid4":
		return true
	}
	// NB: "state" and "cnty" are deliberately NOT computed. They are QSO fields
	// the operator often sets by hand (a QRZ lookup rarely fills the JA
	// prefecture or VE province), and they drive predefined-list awards
	// (WAS / RAC / WAJA / JCC). So they must be pickable in the per-QSO editor.
	return false
}

// ComputeQSOAwardRefs returns every award reference a single QSO contributes to
// — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for
// the per-QSO Award Refs editor. Reuses the same engine as GetAwards.
func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
	nameOf := func(field, ref string) string {
		switch field {
		case "dxcc":
			if n, err := strconv.Atoi(ref); err == nil {
				return dxcc.NameForDXCC(n)
			}
		case "cont":
			return continentName(ref)
		}
		return ""
	}
	a.enrichQSOForAwards(&q)
	defs := a.awardDefs()
	fieldByCode := map[string]string{}
	for _, d := range defs {
		fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
	}
	results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf)
	var out []QSOAwardRef
	for i := range results {
		r := &results[i]
		// "Pickable" = the reference is manually assigned per QSO (POTA, notes…),
		// NOT auto-derived from a structured field. DXCC/WAZ/WAS/WAC/WPX are
		// computed and belong in the read-only panel even though they now have
		// reference lists.
		pickable := !isComputedAwardField(fieldByCode[strings.ToUpper(r.Code)])
		for _, rf := range r.Refs {
			if !rf.Worked {
				continue // a single QSO only contributes worked references
			}
			out = append(out, QSOAwardRef{Code: r.Code, Ref: rf.Ref, Name: rf.Name, Pickable: pickable})
		}
	}
	// A one-reference award that matched several candidates assigned none. Say so
	// HERE, while the QSO is still on screen: an operator who only learns about it
	// from the awards table months later has to go and find the contact again.
	for _, d := range defs {
		if !d.OneRefPerQSO {
			continue
		}
		metas := a.awardRefMetas([]award.Def{d})[strings.ToUpper(d.Code)]
		ex := award.Explain(d, metas, &q)
		if len(ex.Ambiguous) == 0 {
			continue
		}
		names := map[string]string{}
		for _, m := range metas {
			names[strings.ToUpper(m.Code)] = m.Name
		}
		for _, ref := range ex.Ambiguous {
			out = append(out, QSOAwardRef{
				Code: d.Code, Ref: ref, Name: names[strings.ToUpper(ref)],
				Pickable: true, Ambiguous: true,
			})
		}
	}
	return out, nil
}

// awardMatCtx bundles the precompiled award state needed to derive a QSO's
// materialised references. Built ONCE (newAwardMatCtx) and reused across a batch
// or a full recompute so award.Compute's per-pattern compilation isn't repeated.
type awardMatCtx struct {
	defs        []award.Def
	metas       map[string][]award.RefMeta
	fieldByCode map[string]string
	dispByCode  map[string]string
	nameOf      award.NameResolver
}

func (a *App) newAwardMatCtx() awardMatCtx {
	defs := a.awardDefs()
	fieldByCode := map[string]string{}
	dispByCode := map[string]string{}
	for _, d := range defs {
		fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
		dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
	}
	return awardMatCtx{
		defs:        defs,
		metas:       a.awardRefMetas(defs),
		fieldByCode: fieldByCode,
		dispByCode:  dispByCode,
		nameOf: func(field, ref string) string {
			switch field {
			case "dxcc":
				if n, err := strconv.Atoi(ref); err == nil {
					return dxcc.NameForDXCC(n)
				}
			case "cont":
				return continentName(ref)
			}
			return ""
		},
	}
}

// awardRefLabels derives, for one QSO, the map of award code → the reference(s)
// that QSO contributes to (joined when several), formatted per the award's
// RefDisplay choice (ref / name / both; DXCC shows the country name by default).
func (a *App) awardRefLabels(ac awardMatCtx, q qso.QSO) map[string]string {
	a.enrichQSOForAwards(&q)
	results := award.Compute(ac.defs, []qso.QSO{q}, ac.metas, ac.nameOf)
	m := map[string]string{}
	for i := range results {
		r := &results[i]
		code := strings.ToUpper(r.Code)
		dxccField := ac.fieldByCode[code] == "dxcc"
		var refs []string
		for _, rf := range r.Refs {
			if !rf.Worked {
				continue
			}
			label := rf.Ref
			switch ac.dispByCode[code] {
			case "name":
				if rf.Name != "" {
					label = rf.Name
				}
			case "both":
				if rf.Name != "" {
					label = rf.Ref + " — " + rf.Name
				}
			default: // "" or "ref"
				if dxccField && rf.Name != "" {
					label = rf.Name
				}
			}
			refs = append(refs, label)
		}
		if len(refs) > 0 {
			m[code] = strings.Join(refs, ", ")
		}
	}
	return m
}

// awardRefsJSONFor returns awardRefLabels marshalled to a compact JSON object
// keyed by award code, or "" when the QSO contributes to no award (blank column).
func (a *App) awardRefsJSONFor(ac awardMatCtx, q qso.QSO) string {
	m := a.awardRefLabels(ac, q)
	if len(m) == 0 {
		return ""
	}
	b, err := json.Marshal(m)
	if err != nil {
		return ""
	}
	return string(b)
}

// materializeAwardRefs computes ONE QSO's award references and stores them on the
// row (the award_refs column) so the grid columns read them straight from the DB.
// Cheap: an in-memory award.Compute plus one targeted UPDATE. Called on every
// log / edit / UDP-import. Never fatal — a failure just leaves the column stale
// until the next recompute.
func (a *App) materializeAwardRefs(q qso.QSO) {
	if a.qso == nil || q.ID == 0 {
		return
	}
	ac := a.newAwardMatCtx()
	refs := a.awardRefsJSONFor(ac, q)
	if err := a.qso.SetAwardRefs(a.ctx, q.ID, refs); err != nil {
		applog.Printf("award_refs: store for qso %d failed: %v", q.ID, err)
	}
	// Keep the NEW-badge sets in step with the contact just logged, rather than
	// letting the revision change discard them while the operator types the next
	// callsign. Off the logging path: it re-matches the QSO against every award.
	go a.noteWorkedQSO(q)
}

// materializeAwardRefsForIDs recomputes award_refs for a specific set of QSOs
// (e.g. after a bulk cty/QRZ/Club Log update or a bulk edit changed fields that
// feed awards). Writes only the changed rows, in one transaction.
func (a *App) materializeAwardRefsForIDs(ids []int64) {
	if a.qso == nil || len(ids) == 0 {
		return
	}
	ac := a.newAwardMatCtx()
	changes := map[int64]string{}
	err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
		if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
			changes[q.ID] = js
		}
		return nil
	})
	if err != nil {
		applog.Printf("award_refs: recompute for ids failed: %v", err)
		return
	}
	if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
		applog.Printf("award_refs: batch write for ids failed: %v", err)
	}
}

// RecomputeAllAwardRefs rebuilds every QSO's materialised award references. Run
// when an award definition or reference list changes (stored labels would
// otherwise go stale) and once after upgrade to backfill existing rows. Rows
// whose result is unchanged are skipped; the changed ones are written in a single
// transaction (SetAwardRefsBatch) so even a large logbook on a remote MySQL is
// one round-trip's worth of work rather than N. Returns how many rows changed.
func (a *App) RecomputeAllAwardRefs() (int, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	ac := a.newAwardMatCtx()
	// Collect updates during the scan; DON'T write mid-iteration (a nested query on
	// the same connection can deadlock SQLite / trip MySQL "commands out of sync").
	//
	// The LEAN projection: award computation reads twenty-seven columns and the
	// full record has a hundred and fifty. It used to take the whole row only
	// because it needed award_refs to compare against, which the projection now
	// carries — on a remote MySQL that was most of the wait.
	changes := map[int64]string{}
	err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
		if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
			changes[q.ID] = js
		}
		return nil
	})
	if err != nil {
		return 0, err
	}
	if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
		return 0, err
	}
	return len(changes), nil
}

// RecomputeAwardRefsForCode refreshes the stored label of ONE award, on the rows
// that already carry it.
//
// For a change that cannot alter which QSOs match — switching the grid column
// between the reference, its description and both — recomputing every award for
// every QSO is work with a known answer. This does the only part that can
// differ: it recomputes that one award for the rows whose award_refs already
// mention it, and merges the result into their stored object, leaving every
// other award's label exactly as it was.
func (a *App) RecomputeAwardRefsForCode(code string) (int, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	code = strings.ToUpper(strings.TrimSpace(code))
	if code == "" {
		return 0, nil
	}
	// One definition, so award.Compute does not re-run twenty awards per row.
	full := a.newAwardMatCtx()
	one := full
	one.defs = nil
	for _, d := range full.defs {
		if strings.EqualFold(d.Code, code) {
			one.defs = []award.Def{d}
			break
		}
	}
	if len(one.defs) == 0 {
		return 0, nil // award gone — a full recompute will clear the stale label
	}
	changes := map[int64]string{}
	err := a.qso.IterateForAwardsWithRef(a.ctx, code, func(q qso.QSO) error {
		var stored map[string]string
		if strings.TrimSpace(q.AwardRefs) != "" {
			if err := json.Unmarshal([]byte(q.AwardRefs), &stored); err != nil {
				return nil // unreadable value — leave it for the full recompute
			}
		}
		if stored == nil {
			stored = map[string]string{}
		}
		fresh := a.awardRefLabels(one, q)
		if fresh[code] == stored[code] {
			return nil
		}
		if lbl := fresh[code]; lbl != "" {
			stored[code] = lbl
		} else {
			delete(stored, code)
		}
		if len(stored) == 0 {
			changes[q.ID] = ""
			return nil
		}
		b, err := json.Marshal(stored)
		if err != nil {
			return nil
		}
		changes[q.ID] = string(b)
		return nil
	})
	if err != nil {
		return 0, err
	}
	if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
		return 0, err
	}
	return len(changes), nil
}

// recomputeAwardRefsForCodesAsync relabels a few awards and tells the frontend,
// the cheap counterpart of recomputeAwardRefsAsync.
func (a *App) recomputeAwardRefsForCodesAsync(codes []string) {
	go func() {
		total := 0
		for _, c := range codes {
			n, err := a.RecomputeAwardRefsForCode(c)
			if err != nil {
				applog.Printf("award_refs: relabel of %s failed: %v", c, err)
				continue
			}
			applog.Printf("award_refs: %s relabelled on %d QSO(s)", c, n)
			total += n
		}
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "awards:recomputed", total)
		}
	}()
}

// recomputeAwardRefsAsync runs a full recompute off the UI goroutine and, when
// done, tells the frontend to reload so the refreshed award columns show. Used
// wherever the set of matches could shift for MANY rows at once: an award
// definition / reference-list change, or a bulk ADIF import.
func (a *App) recomputeAwardRefsAsync() {
	go func() {
		n, err := a.RecomputeAllAwardRefs()
		if err != nil {
			applog.Printf("award_refs: bulk recompute failed: %v", err)
			return
		}
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
		}
	}()
}

// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs
// backfill has run, so it doesn't re-scan the whole logbook on every launch.
// keyAwardsMaterialized is versioned: bump the suffix to force every operator to
// re-materialise award_refs once on next launch. Needed when the stored values
// could be stale against the CURRENT award definitions — e.g. rows materialised
// before an award gained a new matching rule (DDFM's postal-code OR-rule), which
// the one-shot backfill would otherwise never revisit.
const keyAwardsMaterialized = "awards.materialized.v2"

// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature
// (or were logged by an older client that didn't materialise them). It runs at
// most once per logbook — guarded by a per-profile flag — in the background so it
// never delays startup, even on a large remote MySQL logbook.
func (a *App) backfillAwardRefsOnce() {
	if a.qso == nil || a.settings == nil {
		return
	}
	if done, _ := a.settings.Get(a.ctx, keyAwardsMaterialized); done == "1" {
		return
	}
	go func() {
		n, err := a.RecomputeAllAwardRefs()
		if err != nil {
			applog.Printf("award_refs: initial backfill failed: %v", err)
			return
		}
		_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "1")
		applog.Printf("award_refs: backfilled %d QSO(s)", n)
		if a.ctx != nil && n > 0 {
			wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
		}
	}()
}

// AwardRefsForQSOs returns, per QSO id, the award code → reference(s) map. It is
// the live (non-materialised) path, kept for callers that want fresh values
// without touching the DB. The grid now reads the stored award_refs column
// instead, but this stays available and is the single source of the label logic.
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
	out := map[int64]map[string]string{}
	if a.qso == nil || len(ids) == 0 {
		return out, nil
	}
	ac := a.newAwardMatCtx()
	err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
		if m := a.awardRefLabels(ac, q); len(m) > 0 {
			out[q.ID] = m
		}
		return nil
	})
	return out, err
}

// AwardRefMeta describes a reference list's state for the UI.
type AwardRefMeta struct {
	Code      string `json:"code"`
	Count     int    `json:"count"`
	UpdatedAt string `json:"updated_at"`
	CanUpdate bool   `json:"can_update"`
}

// GetAwardReferenceMeta returns the reference-list status for every defined
// award (count + last update + whether an online updater exists).
func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
	if a.awardRefs == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	counts, err := a.awardRefs.Counts(a.ctx)
	if err != nil {
		return nil, err
	}
	var out []AwardRefMeta
	for _, d := range a.awardDefs() {
		code := strings.ToUpper(d.Code)
		updated := ""
		if a.settings != nil {
			updated, _ = a.settings.Get(a.ctx, keyAwardRefsUpdated+code)
		}
		out = append(out, AwardRefMeta{
			Code:      d.Code,
			Count:     counts[code],
			UpdatedAt: updated,
			CanUpdate: awardref.CanUpdate(d.Code),
		})
	}
	return out, nil
}

// UpdateAwardReferenceList downloads the latest reference list for an award and
// replaces the stored set. Returns the new reference count.
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
	meta, err := a.updateAwardReferenceList(code)
	if err == nil {
		a.recomputeAwardRefsAsync() // new reference list → labels change → refresh rows
	}
	return meta, err
}

// updateAwardReferenceList is the recompute-free core, so DownloadAllReferenceLists
// can update several lists in a loop and trigger ONE bulk recompute at the end
// rather than one per list.
func (a *App) updateAwardReferenceList(code string) (AwardRefMeta, error) {
	if a.awardRefs == nil {
		return AwardRefMeta{}, fmt.Errorf("db not initialized")
	}
	if !awardref.CanUpdate(code) {
		return AwardRefMeta{}, fmt.Errorf("no online reference list for %q", code)
	}
	refs, err := awardref.Download(a.ctx, code)
	if err != nil {
		return AwardRefMeta{}, err
	}
	n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
	if err != nil {
		return AwardRefMeta{}, err
	}
	now := time.Now().Format("2006-01-02 15:04")
	if a.settings != nil {
		a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), now)
	}
	applog.Printf("award-refs: %s updated — %d references", strings.ToUpper(code), n)
	return AwardRefMeta{Code: strings.ToUpper(code), Count: n, UpdatedAt: now, CanUpdate: true}, nil
}

// DownloadAllReferenceLists downloads every online award reference list
// (IOTA/POTA/WWFF/SOTA) in one go and returns a per-award summary. Used by the
// first-launch dialog and the Tools → Maintenance entry so the user doesn't
// have to open each award and update it individually.
func (a *App) DownloadAllReferenceLists() (string, error) {
	if a.awardRefs == nil {
		return "", fmt.Errorf("db not initialized")
	}
	var parts []string
	var firstErr error
	for _, code := range []string{"IOTA", "POTA", "WWFF", "SOTA"} {
		if !awardref.CanUpdate(code) {
			continue
		}
		meta, err := a.updateAwardReferenceList(code)
		if err != nil {
			parts = append(parts, fmt.Sprintf("%s ✗", code))
			if firstErr == nil {
				firstErr = fmt.Errorf("%s: %w", code, err)
			}
			continue
		}
		parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
	}
	a.recomputeAwardRefsAsync() // one bulk recompute after all lists updated
	return strings.Join(parts, " · "), firstErr
}

// SearchAwardReferences finds references of an award by code/name (for the
// per-QSO reference picker). dxcc>0 restricts to one entity.
func (a *App) SearchAwardReferences(code, query string, dxcc, limit int) ([]awardref.Ref, error) {
	if a.awardRefs == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	return a.awardRefs.Search(a.ctx, code, query, dxcc, limit)
}

// ListAwardReferences returns every reference of an award (for the editor).
func (a *App) ListAwardReferences(code string) ([]awardref.Ref, error) {
	if a.awardRefs == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	return a.awardRefs.List(a.ctx, code)
}

// SaveAwardReference inserts or updates a single reference.
func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
	if a.awardRefs == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.awardRefs.Upsert(a.ctx, code, ref); err != nil {
		return err
	}
	a.markAwardEdited(code)
	a.mirrorAwards()
	// A reference's name is what the award column SHOWS for awards displaying by
	// name, and its pattern is part of what matches at all — so editing one
	// changes rows in the log, exactly as deleting or replacing the list does.
	// Those already recomputed; this did not, and left the grid showing the old
	// label until something else happened to trigger a pass.
	a.recomputeAwardRefsAsync()
	return nil
}

// RenameAwardReference changes a reference's code on an award the operator
// already runs.
//
// The one field the editor could not touch, and the one that was wrong: WAJA
// shipped numbered by the Japanese state rather than by the JARL. Correcting it
// meant deleting the whole list and importing another — throwing away anything
// the operator had adjusted in it.
func (a *App) RenameAwardReference(code, oldRef, newRef string) error {
	if a.awardRefs == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.awardRefs.Rename(a.ctx, code, oldRef, newRef); err != nil {
		return err
	}
	a.markAwardEdited(code)
	a.mirrorAwards()
	// The materialised award columns hold a LABEL computed from the definition —
	// the reference code for most awards, the name for those displaying by name.
	// A renumbered reference changes the first kind, so the log is recomputed
	// exactly as it is for every other reference-list change.
	a.recomputeAwardRefsAsync()
	applog.Printf("awards: %s reference %s renumbered to %s", code, oldRef, newRef)
	return nil
}

// DeleteAwardReference removes one reference from an award.
func (a *App) DeleteAwardReference(code, refCode string) error {
	if a.awardRefs == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.awardRefs.Delete(a.ctx, code, refCode); err != nil {
		return err
	}
	a.markAwardEdited(code)
	a.mirrorAwards()
	a.recomputeAwardRefsAsync()
	return nil
}

// markAwardEdited records that the operator has hand-modified an award's
// REFERENCE list, so a catalog update never overwrites it. Only the manual paths
// call this: the online refresh of POTA/SOTA/WWFF replaces the list too, but that
// is a data sync, not a customisation, and treating it as one would freeze those
// awards out of every future fix.
func (a *App) markAwardEdited(code string) {
	if a.settings == nil {
		return
	}
	defs := a.awardDefs()
	touched := false
	for i := range defs {
		if strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) && !defs[i].UserEdited {
			defs[i].UserEdited = true
			touched = true
		}
	}
	if !touched {
		return
	}
	if b, err := json.Marshal(defs); err == nil {
		a.setSettingGlobal(keyAwardDefs, string(b))
	}
}

// ReplaceAwardReferences atomically replaces an award's whole reference list
// (used by paste / CSV import and presets). Returns the new count.
func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, error) {
	if a.awardRefs == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs)
	if err != nil {
		return 0, err
	}
	if a.settings != nil {
		a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04"))
	}
	a.markAwardEdited(code)
	a.mirrorAwards()
	a.recomputeAwardRefsAsync() // reference list replaced → refresh every row's award_refs
	return n, nil
}

// GetAwardPresets returns the catalogue of built-in reference lists.
func (a *App) GetAwardPresets() []awardref.Preset { return awardref.Presets() }

// ApplyAwardPreset replaces an award's reference list with a built-in preset.
// Returns the new reference count.
func (a *App) ApplyAwardPreset(code, presetKey string) (int, error) {
	p, ok := awardref.PresetByKey(presetKey)
	if !ok {
		return 0, fmt.Errorf("unknown preset %q", presetKey)
	}
	return a.ReplaceAwardReferences(code, p.Refs)
}

// PopulateBuiltinReferences seeds an award's reference list from the built-in
// data (DXCC entities, CQ zones, continents, US states, French departments).
// Returns the new count; ok=false awards (online / custom) yield an error.
func (a *App) PopulateBuiltinReferences(code string) (int, error) {
	refs, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
	if !ok {
		return 0, fmt.Errorf("no built-in reference list for %q", code)
	}
	return a.ReplaceAwardReferences(code, refs)
}

// HasBuiltinReferences reports whether an award code ships a built-in list.
func (a *App) HasBuiltinReferences(code string) bool {
	_, ok := awardref.BuiltinRefs(strings.ToUpper(strings.TrimSpace(code)))
	return ok
}

// ── Award export / import ──────────────────────────────────────────────
//
// A self-contained JSON bundle of every award definition AND its reference
// list. This is the backup users need so a reinstall / PC change never loses
// the awards they built by hand. It is independent of the database file.

// AwardBundle is the on-disk format for an award export.
type AwardBundle struct {
	Version    int                `json:"version"`
	ExportedAt string             `json:"exported_at"`
	Awards     []AwardBundleEntry `json:"awards"`
}

// AwardBundleEntry pairs one award definition with its full reference list.
type AwardBundleEntry struct {
	Def        award.Def      `json:"def"`
	References []awardref.Ref `json:"references"`
}

// AwardImportResult summarises an award import for the UI.
type AwardImportResult struct {
	Awards     int `json:"awards"`     // definitions added or updated
	References int `json:"references"` // references imported across all awards
}

// ExportAwards shows a Save dialog and writes every award definition plus its
// reference list to a JSON bundle. Returns the path written, or "" if the user
// cancelled.
func (a *App) ExportAwards() (string, error) {
	return a.exportAwardBundle(nil, "OpsLog_awards_"+time.Now().UTC().Format("20060102_150405")+".json", "Export awards")
}

// awardsDirName is the drop folder for award JSON files, inside OpsLog's data
// directory.
//
// The embedded catalog is compiled into the binary, so adding an award to it means
// a rebuild and a release — fine for what OpsLog ships officially, useless for
// "someone made an award and wants to hand it round". This folder is the answer:
// drop a JSON in, restart, the award is installed. No recompile, nobody to ask.
//
// It lives in the DATA directory, never in a cloud-synced folder — replicating
// files byte-by-byte is the mess we're avoiding everywhere else.
const awardsDirName = "awards"

// AwardsFolder is where award JSON files can be dropped to install them.
func (a *App) AwardsFolder() string {
	if a.dataDir == "" {
		return ""
	}
	return filepath.Join(a.dataDir, awardsDirName)
}

// OpenAwardsFolder reveals the drop folder in the file manager, creating it if
// needed — telling someone a path they then have to build by hand is a poor
// substitute for opening it.
func (a *App) OpenAwardsFolder() error {
	dir := a.AwardsFolder()
	if dir == "" {
		return fmt.Errorf("data dir not initialized")
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	return exec.Command("explorer", dir).Start()
}

// catalogRefs returns the reference list a SHIPPED award carries, if any. Awards
// whose list is generated from code (DXCC entities, French departments) or fetched
// online (POTA/SOTA/WWFF) carry none.
func (a *App) catalogRefs(code string) (json.RawMessage, bool) {
	return award.CatalogRefs(code)
}

// GetCatalogCodes lists the awards SHIPPED with OpsLog. Anything in the database
// that is NOT in this list is YOURS — you created or imported it, and it is not
// part of what OpsLog delivers. The editor flags those so it's obvious at a glance
// which awards are your own work (and therefore which files in the awards folder
// are yours to share).
func (a *App) GetCatalogCodes() []string {
	cat := award.Catalog()
	out := make([]string, 0, len(cat))
	for _, e := range cat {
		out = append(out, strings.ToUpper(strings.TrimSpace(e.Def.Code)))
	}
	return out
}

// ExportAward writes ONE award to its own JSON file — the unit you actually share.
// The whole-catalogue bundle is a backup; a single award is what you send someone.
//
// It reads the DEFINITION AND ITS REFERENCES FROM THE DATABASE, not from the
// embedded catalog: your WAPC is only worth sharing because of the province list
// and the city regexes you added, and those live in the database. Exporting the
// catalog's version would hand over an empty shell.
func (a *App) ExportAward(code string) (string, error) {
	code = strings.ToUpper(strings.TrimSpace(code))
	if code == "" {
		return "", fmt.Errorf("no award selected")
	}
	return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
}

// ExportAwardForCatalog writes ONE award as a ready-to-ship CATALOG file — the
// exact shape internal/award/catalog/*.json use ({"def":{…},"references":[…]}).
//
// The workflow it enables: edit an award in the UI, call this with the NEXT
// version number, and paste the file over that award's catalog JSON. A new OpsLog
// release then carries the change to the whole team: every operator whose copy is
// unedited auto-upgrades to it (mergeCatalog), and those who edited it keep theirs
// but are offered the update.
//
// Two things a plain export/mirror can't do and this does, both essential for a
// catalog file: it STAMPS the award's Version (the UI save deliberately never
// bumps it) and it CLEARS user_edited, so a fresh install seeded from this file is
// NOT pre-flagged as the operator's own work — which would otherwise freeze it out
// of every future catalog update.
func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
	if a.awardRefs == nil || a.ctx == nil {
		return "", fmt.Errorf("db not initialized")
	}
	code = strings.ToUpper(strings.TrimSpace(code))
	var def award.Def
	found := false
	for _, d := range a.awardDefs() {
		if strings.EqualFold(strings.TrimSpace(d.Code), code) {
			def, found = d, true
			break
		}
	}
	if !found {
		return "", fmt.Errorf("unknown award %q", code)
	}
	def.Version = version
	def.UserEdited = false // a catalog seed is not "the operator's edit"
	def.Builtin = true
	refs, err := a.awardRefs.List(a.ctx, code)
	if err != nil {
		return "", fmt.Errorf("load references: %w", err)
	}
	entry := struct {
		Def        award.Def      `json:"def"`
		References []awardref.Ref `json:"references,omitempty"`
	}{Def: def, References: refs}
	b, err := json.MarshalIndent(entry, "", "  ")
	if err != nil {
		return "", err
	}
	path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
		DefaultFilename: strings.ToLower(code) + ".json",
		Title:           "Export " + code + " for the catalog",
		Filters:         []wruntime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}},
	})
	if err != nil || strings.TrimSpace(path) == "" {
		return "", err
	}
	if err := os.WriteFile(path, b, 0o644); err != nil {
		return "", err
	}
	return path, nil
}

// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
	if a.awardRefs == nil {
		return "", fmt.Errorf("db not initialized")
	}
	want := map[string]bool{}
	for _, c := range codes {
		want[strings.ToUpper(strings.TrimSpace(c))] = true
	}
	defs := a.awardDefs()
	bundle := AwardBundle{
		Version:    1,
		ExportedAt: time.Now().UTC().Format(time.RFC3339),
		Awards:     make([]AwardBundleEntry, 0, len(defs)),
	}
	for _, d := range defs {
		if len(want) > 0 && !want[strings.ToUpper(d.Code)] {
			continue
		}
		refs, err := a.awardRefs.List(a.ctx, d.Code)
		if err != nil {
			return "", fmt.Errorf("list references for %s: %w", d.Code, err)
		}
		bundle.Awards = append(bundle.Awards, AwardBundleEntry{Def: d, References: refs})
	}
	if len(bundle.Awards) == 0 {
		return "", fmt.Errorf("nothing to export")
	}
	data, err := json.MarshalIndent(bundle, "", "  ")
	if err != nil {
		return "", err
	}
	path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
		Title:           title,
		DefaultFilename: defaultName,
		Filters: []wruntime.FileFilter{
			{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
			{DisplayName: "All files (*.*)", Pattern: "*.*"},
		},
	})
	if err != nil || path == "" {
		return "", err
	}
	if err := os.WriteFile(path, data, 0o644); err != nil {
		return "", fmt.Errorf("write %s: %w", path, err)
	}
	return path, nil
}

// ImportAwards shows an Open dialog, reads an award bundle and merges it:
// definitions are upserted by code, and any entry that carries references
// replaces that award's list. Returns counts; the user cancelling yields a
// zero result and no error.
func (a *App) ImportAwards() (AwardImportResult, error) {
	// Kept for the old call sites: inspect, then apply with the default decision.
	// Anything that already exists is SKIPPED, never silently replaced.
	p, err := a.InspectAwardImport()
	if err != nil || p.Path == "" {
		return AwardImportResult{}, err
	}
	dec := map[string]string{}
	for _, e := range p.Awards {
		if e.Exists {
			dec[e.Code] = "skip"
		} else {
			dec[e.Code] = "replace"
		}
	}
	return a.ApplyAwardImport(p.Path, dec)
}

// AwardImportPreviewEntry describes one award found in a file to import.
type AwardImportPreviewEntry struct {
	Code       string `json:"code"`
	Name       string `json:"name"`
	References int    `json:"references"`
	Exists     bool   `json:"exists"`    // an award with this code is already installed
	MineName   string `json:"mine_name"` // the name of the one you already have
	MineRefs   int    `json:"mine_refs"` // how many references yours carries
	Protected  bool   `json:"protected"` // yours is a protected built-in
}

// AwardImportPreview is what the file holds, and what would collide.
type AwardImportPreview struct {
	Path   string                    `json:"path"`
	Awards []AwardImportPreviewEntry `json:"awards"`
}

// InspectAwardImport opens a bundle and reports what it contains WITHOUT touching
// anything — so the UI can ask before overwriting.
//
// The import used to merge by code with "imported wins", silently. Import a WAPC
// someone shared and YOUR WAPC — its province list, its city regexes, its band
// scope — was destroyed without a word. Sharing awards is precisely the feature we
// want people to use, so it must not be a data-loss trap: we look first, then ask.
func (a *App) InspectAwardImport() (AwardImportPreview, error) {
	var p AwardImportPreview
	if a.awardRefs == nil || a.settings == nil {
		return p, fmt.Errorf("db not initialized")
	}
	path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
		Title: "Import award(s)",
		Filters: []wruntime.FileFilter{
			{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
			{DisplayName: "All files (*.*)", Pattern: "*.*"},
		},
	})
	if err != nil || path == "" {
		return p, err
	}
	bundle, err := readAwardBundle(path)
	if err != nil {
		return p, err
	}
	mine := map[string]award.Def{}
	for _, d := range a.awardDefs() {
		mine[strings.ToUpper(d.Code)] = d
	}
	p.Path = path
	for _, e := range bundle.Awards {
		code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
		if code == "" {
			continue
		}
		entry := AwardImportPreviewEntry{
			Code: code, Name: e.Def.Name, References: len(e.References),
		}
		if d, ok := mine[code]; ok {
			entry.Exists = true
			entry.MineName = d.Name
			entry.Protected = d.Protected
			if refs, err := a.awardRefs.List(a.ctx, code); err == nil {
				entry.MineRefs = len(refs)
			}
		}
		p.Awards = append(p.Awards, entry)
	}
	if len(p.Awards) == 0 {
		return p, fmt.Errorf("no awards in file")
	}
	return p, nil
}

// ApplyAwardImport imports a bundle, applying the operator's decision per code:
//
//	"replace" — take theirs, overwriting mine
//	"skip"    — keep mine, ignore the import
//	"copy"    — install theirs under a free code (WAPC-2), so both exist and can be
//	            compared before one is deleted
//
// "copy" is the one that earns its keep: it lets you LOOK before choosing, without
// losing anything either way.
func (a *App) ApplyAwardImport(path string, decisions map[string]string) (AwardImportResult, error) {
	var res AwardImportResult
	if a.awardRefs == nil || a.settings == nil {
		return res, fmt.Errorf("db not initialized")
	}
	bundle, err := readAwardBundle(path)
	if err != nil {
		return res, err
	}

	defs := a.awardDefs()
	idx := map[string]int{}
	for i, d := range defs {
		idx[strings.ToUpper(d.Code)] = i
	}
	// refsToWrite is applied only after the definitions save cleanly — a half-done
	// import that swapped the reference list but not the definition would leave the
	// award quietly broken.
	type pending struct {
		code string
		refs []awardref.Ref
	}
	var refsToWrite []pending

	for _, e := range bundle.Awards {
		code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
		if code == "" {
			continue
		}
		decision := strings.ToLower(strings.TrimSpace(decisions[code]))
		_, exists := idx[code]
		if !exists {
			decision = "replace" // nothing to collide with
		}
		switch decision {
		case "skip", "":
			continue

		case "copy":
			newCode := freeAwardCode(code, idx)
			d := e.Def
			d.Code = newCode
			// An imported copy is NOT a protected built-in: the operator must be able
			// to delete it once they've decided which one they keep.
			d.Builtin, d.Protected = false, false
			idx[newCode] = len(defs)
			defs = append(defs, d)
			res.Awards++
			if len(e.References) > 0 {
				refsToWrite = append(refsToWrite, pending{newCode, e.References})
			}

		default: // "replace"
			d := e.Def
			d.Code = code
			if i, ok := idx[code]; ok {
				defs[i] = d
			} else {
				idx[code] = len(defs)
				defs = append(defs, d)
			}
			res.Awards++
			// An entry with NO references leaves the existing list alone — otherwise
			// importing a def-only export would wipe a seeded built-in list.
			if len(e.References) > 0 {
				refsToWrite = append(refsToWrite, pending{code, e.References})
			}
		}
	}
	if res.Awards == 0 {
		return res, nil // everything skipped
	}

	if migrated, changed := award.Migrate(defs); changed {
		defs = migrated
	}
	b, _ := json.Marshal(defs)
	if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
		return res, fmt.Errorf("save award defs: %w", err)
	}
	for _, p := range refsToWrite {
		n, err := a.ReplaceAwardReferences(p.code, p.refs)
		if err != nil {
			return res, fmt.Errorf("import references for %s: %w", p.code, err)
		}
		res.References += n
	}
	a.invalidateAwardStats()
	return res, nil
}

// readAwardBundle parses a bundle file. Size-capped: an award bundle is a few
// hundred kB at most, and an unbounded read of a file someone sent you is not a
// risk worth taking.
func readAwardBundle(path string) (AwardBundle, error) {
	var bundle AwardBundle
	fi, err := os.Stat(path)
	if err != nil {
		return bundle, fmt.Errorf("read %s: %w", path, err)
	}
	const maxBundle = 32 << 20 // 32 MB
	if fi.Size() > maxBundle {
		return bundle, fmt.Errorf("%s is too large for an award bundle (%d bytes)", path, fi.Size())
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return bundle, fmt.Errorf("read %s: %w", path, err)
	}
	if err := json.Unmarshal(data, &bundle); err != nil {
		return bundle, fmt.Errorf("parse award bundle: %w", err)
	}
	if len(bundle.Awards) == 0 {
		return bundle, fmt.Errorf("no awards in file")
	}
	return bundle, nil
}

// freeAwardCode returns the first unused "CODE-n" (WAPC-2, WAPC-3, …).
func freeAwardCode(code string, taken map[string]int) string {
	for n := 2; n < 100; n++ {
		c := fmt.Sprintf("%s-%d", code, n)
		if _, clash := taken[c]; !clash {
			return c
		}
	}
	return code + "-COPY"
}

// builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
const builtinRefsVersion = "5"

// seedBuiltinReferences populates the reference lists of built-in awards.
//   - First run (no version stored), or already up to date: seed only awards that
//     have NO references yet, so an online-loaded list (POTA…), a user list, or an
//     edited one isn't clobbered. This is also what gives an award newly added to
//     the catalog (FFMA and its 488 grids) its list on an existing install.
//   - Version bump (stored != current): RE-SEED the derived built-in lists
//     (DXCC, WAZ, WAC, WAS, DDFM) to push data corrections to existing installs.
//     These lists are canonical, not user-maintained, so overwriting is safe.
func (a *App) seedBuiltinReferences() {
	if a.awardRefs == nil || a.settings == nil {
		return
	}
	counts, err := a.awardRefs.Counts(a.ctx)
	if err != nil {
		return
	}
	ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
	firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
	if ver == "1" {
		firstRun = false // already seeded once → treat as a version upgrade
	}
	if ver == builtinRefsVersion {
		// Already on the current data. But an award ADDED to the catalog since the
		// last run (FFMA, say) still has an EMPTY list, and bumping the version to
		// seed it would re-seed every award and clobber lists the operator has
		// edited. So fill only the empty ones.
		firstRun = true
	}
	for _, d := range a.awardDefs() {
		code := strings.ToUpper(d.Code)
		if firstRun && counts[code] > 0 {
			continue // don't overwrite an existing list
		}
		// A catalog award that SHIPS its own reference list wins: that is how an
		// award added as a JSON file (a shared WAPC, with its provinces and their
		// city regexes) reaches every user without a line of Go. Awards whose list
		// is generated from code (DXCC entities, French departments) or fetched
		// online (POTA/SOTA/WWFF) carry none, and fall back below.
		if raw, ok := a.catalogRefs(code); ok {
			var refs []awardref.Ref
			if err := json.Unmarshal(raw, &refs); err == nil && len(refs) > 0 {
				if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
					applog.Printf("award-refs: seeded %s from the catalog — %d references", code, n)
					continue
				}
			} else if err != nil {
				applog.Printf("award-refs: %s catalog references are malformed: %v", code, err)
			}
		}
		if refs, ok := awardref.BuiltinRefs(code); ok {
			if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
				applog.Printf("award-refs: seeded %s — %d references", code, n)
			}
		}
	}
	a.setSetting(keyAwardRefsSeeded, builtinRefsVersion)
}

// ImportAwardReferencesText parses pasted lines or CSV into references and
// replaces the award's list. Accepted per line (comma/semicolon/tab separated):
//
//	CODE
//	CODE,Description
//	CODE,Description,Group
//	CODE,Description,Group,Subgroup
//	CODE,Description,Group,Subgroup,DXCC
//
// A leading header row (first field "code"/"ref"/"reference") is skipped.
func (a *App) ImportAwardReferencesText(code, text string) (int, error) {
	refs := parseRefLines(text)
	if len(refs) == 0 {
		return 0, fmt.Errorf("no references found in input")
	}
	return a.ReplaceAwardReferences(code, refs)
}

// parseRefLines turns pasted/CSV text into references (best-effort, tolerant of
// comma, semicolon or tab delimiters).
func parseRefLines(text string) []awardref.Ref {
	var out []awardref.Ref
	for i, raw := range strings.Split(text, "\n") {
		line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
		if line == "" {
			continue
		}
		var fields []string
		switch {
		case strings.Contains(line, "\t"):
			fields = strings.Split(line, "\t")
		case strings.Contains(line, ";"):
			fields = strings.Split(line, ";")
		default:
			fields = strings.Split(line, ",")
		}
		for j := range fields {
			fields[j] = strings.TrimSpace(fields[j])
		}
		code := strings.ToUpper(fields[0])
		if code == "" {
			continue
		}
		// Skip a header row.
		if i == 0 {
			switch strings.ToLower(fields[0]) {
			case "code", "ref", "reference", "ref_code":
				continue
			}
		}
		ref := awardref.Ref{Code: code, Valid: true}
		if len(fields) > 1 {
			ref.Name = fields[1]
		}
		if len(fields) > 2 {
			ref.Group = fields[2]
		}
		if len(fields) > 3 {
			ref.SubGrp = fields[3]
		}
		if len(fields) > 4 {
			if n, err := strconv.Atoi(fields[4]); err == nil {
				ref.DXCC = n
			}
		}
		out = append(out, ref)
	}
	return out
}

func continentName(code string) string {
	switch strings.ToUpper(code) {
	case "AF":
		return "Africa"
	case "AN":
		return "Antarctica"
	case "AS":
		return "Asia"
	case "EU":
		return "Europe"
	case "NA":
		return "North America"
	case "OC":
		return "Oceania"
	case "SA":
		return "South America"
	}
	return ""
}

// ListQSOFiltered returns QSOs matching the advanced filter builder.
func (a *App) ListQSOFiltered(f qso.QueryFilter) ([]qso.QSO, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	list, err := a.qso.ListFiltered(a.ctx, f)
	if err != nil {
		return nil, err
	}
	a.stampQSONumbers(list)
	return list, nil
}

// stampQSONumbers fills each QSO's position in the log, oldest = 1.
//
// Stamped here rather than computed in the query because the number has to be
// the rank in the WHOLE log: ranking inside the result would renumber every
// contact the moment a filter is applied, and the operator would see QSO #1
// change identity as they typed.
func (a *App) stampQSONumbers(list []qso.QSO) {
	idx := a.qsoNumberIndex()
	if idx == nil {
		return
	}
	for i := range list {
		list[i].Number = idx[list[i].ID]
	}
}

// countQSOForNumbering is Count with the nil-repo guard the numbering needs —
// it runs before the database is up on a fresh start.
func (a *App) countQSOForNumbering() (int64, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	return a.qso.Count(a.ctx)
}

// qsoNumberIndex returns id → chronological position, building it once and
// keeping it until the log changes (invalidateAwardStats drops it).
func (a *App) qsoNumberIndex() map[int64]int {
	a.qsoNumMu.Lock()
	defer a.qsoNumMu.Unlock()
	if a.qsoNumbers != nil {
		// Cheap consistency check rather than trusting every insert path to have
		// remembered to invalidate. Several bulk paths — the POTA hunter import,
		// the LoTW and QRZ "add contacts I was missing" passes — insert straight
		// through the repo, and they add contacts with OLD dates, which lands them
		// in the MIDDLE of the order and shifts every number after them. A stale
		// map there is not merely incomplete, it is wrong.
		//
		// One indexed COUNT against a map length, versus rereading every id.
		if n, err := a.countQSOForNumbering(); err == nil && int(n) != len(a.qsoNumbers) {
			a.qsoNumbers = nil
		} else {
			return a.qsoNumbers
		}
	}
	if a.qso == nil {
		return nil
	}
	ids, newest, err := a.qso.OrderedIDs(a.ctx)
	if err != nil {
		applog.Printf("qso numbering: %v — the column will be empty", err)
		return nil
	}
	m := make(map[int64]int, len(ids))
	for i, id := range ids {
		m[id] = i + 1
	}
	a.qsoNumbers = m
	a.qsoNumMax = newest
	return m
}

// noteQSONumbered keeps the numbering current for a contact just logged.
//
// A QSO logged from the entry form is the newest there is, so it simply takes
// the next number — a full rebuild would read every id in the log, which during
// a contest run would cost more than the three queries the grid refresh already
// makes. A contact entered with an OLDER date belongs in the middle of the
// order, so there the map is dropped and rebuilt correctly rather than
// mis-numbered.
func (a *App) noteQSONumbered(id int64, at time.Time) {
	a.qsoNumMu.Lock()
	defer a.qsoNumMu.Unlock()
	if a.qsoNumbers == nil {
		return // nothing built yet; the lazy build will see this QSO anyway
	}
	if at.Before(a.qsoNumMax) {
		a.qsoNumbers = nil
		return
	}
	a.qsoNumbers[id] = len(a.qsoNumbers) + 1
	a.qsoNumMax = at
}

// CountQSOFiltered returns how many QSOs match the filter (ignoring the row
// limit) so the UI can show "showing 500 of 1,234 matches".
func (a *App) CountQSOFiltered(f qso.QueryFilter) (int64, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	return a.qso.CountFiltered(a.ctx, f)
}

// FilterFields exposes the whitelisted filterable columns to the frontend.
func (a *App) FilterFields() []string {
	return qso.FilterableFields()
}

func (a *App) GetQSO(id int64) (qso.QSO, error) {
	if a.qso == nil {
		return qso.QSO{}, fmt.Errorf("db not initialized")
	}
	return a.qso.GetByID(a.ctx, id)
}

func (a *App) UpdateQSO(q qso.QSO) error {
	if a.qso == nil {
		return fmt.Errorf("db not initialized")
	}
	err := a.qso.Update(a.ctx, q)
	if err == nil {
		a.invalidateAwardStats()
		a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
		a.syncPublishAsync(syncfolder.OpUpdate, q.ID, nil)
	}
	return err
}

// GetLogStats aggregates the logbook for the Statistics dashboard (operators,
// modes, bands, entities, activity over time), restricted to a period.
//
// fromISO/toISO are "YYYY-MM-DD" (or RFC3339); either may be empty for "no
// bound", so two empty strings mean the whole log. `to` is inclusive: a bare date
// is stretched to 23:59:59 of that day, otherwise picking today as the end would
// silently drop today's QSOs.
func (a *App) GetLogStats(fromISO, toISO, contestID string, year int, operator string) (qso.Stats, error) {
	if a.qso == nil {
		return qso.Stats{}, fmt.Errorf("db not initialized")
	}
	from := parseStatsBound(fromISO, false)
	to := parseStatsBound(toISO, true)
	return a.qso.Stats(a.ctx, from, to, contestID, year, operator)
}

// GetOperators lists the distinct operators present in the log (upper-cased),
// for the Statistics operator picker. "—" stands for QSOs the station owner
// logged himself (empty OPERATOR). Sorted, owner-bucket last.
func (a *App) GetOperators() ([]string, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	return a.qso.Operators(a.ctx)
}

// QSORate is the live QSO-rate meter shown in the header: how many QSOs were
// logged in the trailing 10 and 60 minutes.
type QSORate struct {
	Last10     int `json:"last10"`      // active operator, last 10 min
	Last60     int `json:"last60"`      // active operator, last 60 min
	TeamLast10 int `json:"team_last10"` // ALL operators (the whole station), last 10 min
	TeamLast60 int `json:"team_last60"` // ALL operators, last 60 min
}

// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
// for the active operator (their own performance) AND for all operators combined
// (the team/station rate). Cheap (one scan of the most recent rows); polled by the
// header and refreshed on each qso:logged event.
func (a *App) GetQSORate() QSORate {
	if a.qso == nil {
		return QSORate{}
	}
	operator := ""
	if a.profiles != nil {
		if p, err := a.profiles.Active(a.ctx); err == nil {
			operator = p.Operator
		}
	}
	op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
	if err != nil || len(op) < 2 || len(all) < 2 {
		return QSORate{}
	}
	return QSORate{Last10: op[0], Last60: op[1], TeamLast10: all[0], TeamLast60: all[1]}
}

// GetContestRuns lists the (contest, year) pairs actually present in the log, so
// the Statistics picker only ever offers contests you really entered.
func (a *App) GetContestRuns() ([]qso.ContestRun, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	return a.qso.ContestRuns(a.ctx)
}

// parseStatsBound turns a UI date into a UTC bound. endOfDay stretches a bare
// date to 23:59:59 so an inclusive "to" really includes that day.
func parseStatsBound(s string, endOfDay bool) time.Time {
	s = strings.TrimSpace(s)
	if s == "" {
		return time.Time{}
	}
	if t, err := time.Parse("2006-01-02", s); err == nil {
		if endOfDay {
			return t.UTC().Add(24*time.Hour - time.Second)
		}
		return t.UTC()
	}
	if t, err := time.Parse(time.RFC3339, s); err == nil {
		return t.UTC()
	}
	return time.Time{}
}

func (a *App) DeleteQSO(id int64) error {
	if a.qso == nil {
		return fmt.Errorf("db not initialized")
	}
	a.deleteRemoteCopies([]int64{id})
	a.syncPublishDeletes([]int64{id})
	return a.qso.Delete(a.ctx, id)
}

// deleteRemoteCopies withdraws QSOs from QRZ.com and Club Log, when the
// operator has asked for that.
//
// Called BEFORE the local delete, because both services need the QSO's own
// fields to find their copy — Club Log matches on callsign, exact time and
// band, and QRZ needs the logid stored on the record. Once the row is gone
// there is nothing left to ask with.
//
// Failures never block the local delete. The operator asked for this QSO to go;
// a refusing website is not a reason to keep it, and the log says what happened
// so it can be sorted out on the site afterwards.
// deleteSnapshot reads the rows behind a set of ids in ONE query.
//
// The delete hooks used to fetch them one by one. That is a round trip per
// contact, and on a remote MySQL logbook 200 deletions became 400 sequential
// queries before the DELETE was even issued — the reason deleting a selection
// felt broken rather than slow.
func (a *App) deleteSnapshot(ids []int64) []qso.QSO {
	if a.qso == nil || len(ids) == 0 {
		return nil
	}
	out := make([]qso.QSO, 0, len(ids))
	if err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
		out = append(out, q)
		return nil
	}); err != nil {
		applog.Printf("delete: reading %d QSO(s) before deletion failed: %v", len(ids), err)
	}
	return out
}

func (a *App) deleteRemoteCopies(ids []int64) {
	if a.qso == nil || len(ids) == 0 {
		return
	}
	prefix := ""
	if a.profileHasGroup(markerExtsvc) {
		prefix = a.profileScope()
	}
	m, err := a.getManyScoped(prefix, keyExtDeleteRemote)
	if err != nil || m[keyExtDeleteRemote] != "1" {
		return
	}
	cfg := a.loadExternalServices()
	doQRZ := strings.TrimSpace(cfg.QRZ.APIKey) != ""
	doClublog := strings.TrimSpace(cfg.Clublog.Email) != "" && cfg.Clublog.Password != ""
	if !doQRZ && !doClublog {
		return
	}
	// Snapshot first — the rows are about to disappear — then withdraw them in
	// the background. Each service is an HTTP round trip PER CONTACT, so doing
	// this on the caller's goroutine held the whole UI for as long as the
	// websites took: minutes for a large selection, with nothing on screen
	// saying why. The local log is the operator's own copy and must not wait on
	// two remote sites to answer.
	rows := a.deleteSnapshot(ids)
	go a.withdrawRemoteCopies(rows, cfg, doQRZ, doClublog)
}

// maxClublogRefusals is how many consecutive refusals are tolerated before the
// withdrawals stop. Three is enough to tell a one-off from a blocked account.
const maxClublogRefusals = 3

// Club Log's delete endpoint is a REAL-TIME one: it exists for an operator
// removing a contact they just logged wrongly, at human pace. Club Log watches
// the rate and blocks the IP of anything that batches through it — they wrote
// to this station about 167 requests in four minutes, which was one deletion of
// a couple of hundred rows, not a pile-up.
//
// There is no bulk-delete API to move to, so the only honest answer is to go at
// the pace the endpoint is meant for and to stop rather than push a large
// deletion through it. Whoever needs to remove hundreds of QSOs from Club Log
// does it on their site, where the tool for it exists.
const (
	clublogDeletePace = 1200 * time.Millisecond
	maxClublogDeletes = 25
)

// clublogWasUploaded reports whether this QSO ever reached Club Log. "M"
// (modified since upload) counts: the copy is there, it is merely out of date.
func clublogWasUploaded(status string) bool {
	switch strings.ToUpper(strings.TrimSpace(status)) {
	case "Y", "M":
		return true
	}
	return false
}

func (a *App) withdrawRemoteCopies(rows []qso.QSO, cfg extsvc.ExternalServices, doQRZ, doClublog bool) {
	started := time.Now()
	clublogRefused := 0
	clublogSent := 0
	for i := range rows {
		q := rows[i]
		id := q.ID
		if q.Callsign == "" {
			continue
		}
		if doQRZ {
			logID := ""
			if q.Extras != nil {
				logID = q.Extras["APP_OPSLOG_QRZ_LOGID"]
			}
			if strings.TrimSpace(logID) == "" {
				// Uploaded before OpsLog kept the identifier, or never uploaded.
				// QRZ has no delete-by-callsign-and-date, so say so plainly rather
				// than let the operator believe it was withdrawn.
				applog.Printf("extsvc: QSO %d (%s) has no QRZ logid — remove it on qrz.com by hand", id, q.Callsign)
			} else if msg, err := extsvc.DeleteQRZ(a.ctx, nil, cfg.QRZ.APIKey, []string{logID}); err != nil {
				applog.Printf("extsvc: QRZ delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
			} else {
				applog.Printf("extsvc: QRZ delete of QSO %d (%s): %s", id, q.Callsign, msg)
			}
		}
		// Only ask Club Log about contacts it was actually given. A QSO that was
		// never uploaded has no copy to withdraw, and asking anyway is not merely
		// pointless: Club Log answers 403 and starts blocking the account, so a
		// selection of never-uploaded QSOs turned into hundreds of refused
		// requests, one after another, each waiting on the network.
		if doClublog && !clublogWasUploaded(q.ClublogUploadStatus) {
			applog.Printf("extsvc: QSO %d (%s) was never uploaded to Club Log — nothing to withdraw", id, q.Callsign)
		} else if doClublog {
			if clublogSent >= maxClublogDeletes {
				applog.Printf("extsvc: %d QSOs already withdrawn from Club Log — stopping there. Their delete endpoint is for one contact at a time; remove the rest on clublog.org, which has a tool for it.", clublogSent)
				doClublog = false
				continue
			}
			if clublogSent > 0 {
				// Paced deliberately: see clublogDeletePace. This runs in the
				// background, so the wait costs the operator nothing.
				time.Sleep(clublogDeletePace)
			}
			clublogSent++
			if msg, err := extsvc.DeleteClublog(a.ctx, nil, cfg.Clublog, q.Callsign, q.QSODate, q.Band); err != nil {
				clublogRefused++
				applog.Printf("extsvc: Club Log delete of QSO %d (%s) failed: %v", id, q.Callsign, err)
				// Club Log blocks an account that keeps sending requests it
				// refuses. Stop after a few in a row rather than work through
				// the whole selection earning a longer block.
				if clublogRefused >= maxClublogRefusals {
					applog.Printf("extsvc: Club Log refused %d deletions in a row — giving up on the rest", clublogRefused)
					doClublog = false
				}
			} else {
				clublogRefused = 0
				applog.Printf("extsvc: Club Log delete of QSO %d (%s): %s", id, q.Callsign, msg)
			}
		}
	}
	applog.Printf("extsvc: withdrew %d deleted QSO(s) from the remote services in %s", len(rows), time.Since(started).Round(time.Millisecond))
}

// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
// number actually deleted.
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	started := time.Now()
	a.deleteRemoteCopies(ids)
	a.syncPublishDeletes(ids)
	beforeDelete := time.Since(started)
	n, err := a.qso.DeleteMany(a.ctx, ids)
	// Logged because a delete that does nothing is otherwise indistinguishable
	// from a delete that worked: the rows leave the grid either way once it
	// reloads, and the only place the truth survives is here.
	if err != nil {
		applog.Printf("delete: %d QSO(s) requested, FAILED after %s: %v", len(ids), time.Since(started).Round(time.Millisecond), err)
	} else {
		// Both timings, because they blame different things: a long prelude is
		// the sync/withdraw hooks, a long DELETE is the database itself.
		applog.Printf("delete: %d QSO(s) requested, %d row(s) removed — %s before the delete, %s total",
			len(ids), n, beforeDelete.Round(time.Millisecond), time.Since(started).Round(time.Millisecond))
	}
	return n, err
}

// DuplicateGroup is a set of QSOs the log considers the same contact.
type DuplicateGroup struct {
	Key  string    `json:"key"`
	QSOs []qso.QSO `json:"qsos"`
}

// normDupeMode collapses SSB sidebands so USB/LSB count as the same slot; other
// modes (FT8/RTTY/CW…) stay distinct so they aren't wrongly grouped.
func normDupeMode(m string) string {
	u := strings.ToUpper(strings.TrimSpace(m))
	if u == "USB" || u == "LSB" {
		return "SSB"
	}
	return u
}

// FindDuplicates scans the whole log and returns groups of duplicate QSOs: same
// callsign + band + mode (USB/LSB folded to SSB) logged within windowMinutes of
// each other — the real signature of an accidental double-log, which lands a few
// minutes apart, not necessarily the same minute. QSOs of a call+band+mode are
// sorted by time and split into clusters wherever the gap to the next exceeds
// the window, so two genuinely separate contacts hours apart are NOT grouped.
// windowMinutes <= 0 falls back to same UTC day. Each group's QSOs are
// oldest-first and groups are ordered by date, so the UI can pre-select all but
// the first.
func (a *App) FindDuplicates(windowMinutes int) []DuplicateGroup {
	if a.qso == nil {
		return nil
	}
	byKey := map[string][]qso.QSO{}
	_ = a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
		call := strings.ToUpper(strings.TrimSpace(q.Callsign))
		if call == "" {
			return nil
		}
		key := call + "|" + strings.ToLower(strings.TrimSpace(q.Band)) + "|" + normDupeMode(q.Mode)
		byKey[key] = append(byKey[key], q)
		return nil
	})

	out := []DuplicateGroup{}
	for key, qs := range byKey {
		if len(qs) < 2 {
			continue
		}
		sort.Slice(qs, func(i, j int) bool { return qs[i].QSODate.Before(qs[j].QSODate) })
		if windowMinutes <= 0 {
			// Whole-day fallback: split on a change of UTC calendar day.
			i := 0
			for i < len(qs) {
				j := i + 1
				for j < len(qs) && qs[j].QSODate.UTC().Format("2006-01-02") == qs[i].QSODate.UTC().Format("2006-01-02") {
					j++
				}
				if j-i > 1 {
					out = append(out, DuplicateGroup{Key: fmt.Sprintf("%s|%d", key, i), QSOs: append([]qso.QSO(nil), qs[i:j]...)})
				}
				i = j
			}
			continue
		}
		win := time.Duration(windowMinutes) * time.Minute
		i := 0
		for i < len(qs) {
			j := i + 1
			for j < len(qs) && qs[j].QSODate.Sub(qs[j-1].QSODate) <= win {
				j++
			}
			if j-i > 1 {
				out = append(out, DuplicateGroup{Key: fmt.Sprintf("%s|%d", key, i), QSOs: append([]qso.QSO(nil), qs[i:j]...)})
			}
			i = j
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].QSOs[0].QSODate.Before(out[j].QSOs[0].QSODate) })
	return out
}

// QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty
// string leaves that field unchanged (so you can set only "received = Y + date"
// without touching the sent side).
type QSLBulkUpdate struct {
	SentStatus string `json:"sent_status"` // Y|N|R|I — "" = unchanged
	RcvdStatus string `json:"rcvd_status"` // Y|N|R|I — "" = unchanged
	SentDate   string `json:"sent_date"`   // YYYYMMDD — "" = unchanged
	RcvdDate   string `json:"rcvd_date"`   // YYYYMMDD — "" = unchanged
	// Via and RcvdVia are the ADIF QSL Via enumeration (B bureau / D direct /
	// E electronic), NOT the manager — that is QSL_VIA and belongs on the QSO,
	// not on a stack of cards being confirmed together. "" = unchanged.
	Via     string `json:"via"`      // → QSL_SENT_VIA
	RcvdVia string `json:"rcvd_via"` // → QSL_RCVD_VIA
	Notes   string `json:"notes"`    // NOTES — "" = unchanged
	Comment string `json:"comment"`  // COMMENT — "" = unchanged
}

// BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the
// given QSOs (used by the QSL Manager "Paper QSL" mode to confirm a stack of
// cards for one callsign at once). Returns how many rows were updated.
func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	up := func(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
	n := 0
	for _, id := range ids {
		q, err := a.qso.GetByID(a.ctx, id)
		if err != nil {
			continue
		}
		changed := false
		if v := up(u.SentStatus); v != "" {
			q.QSLSent, changed = v, true
		}
		if v := up(u.RcvdStatus); v != "" {
			q.QSLRcvd, changed = v, true
		}
		if v := strings.TrimSpace(u.SentDate); v != "" {
			q.QSLSentDate, changed = v, true
		}
		if v := strings.TrimSpace(u.RcvdDate); v != "" {
			q.QSLRcvdDate, changed = v, true
		}
		// The QSL Manager panel's "Via" is a routing method (bureau / direct /
		// electronic), so it belongs in QSL_SENT_VIA, not in QSL_VIA — which
		// holds the manager. It used to write the manager field, in full words,
		// which is half of why the two got mixed up in operators' logs.
		if v := adif.NormaliseQSLVia(u.Via); v != "" {
			q.QSLSentVia, changed = v, true
		}
		// How the card CAME BACK. A stack confirmed together usually arrived the
		// same way — a bureau delivery is exactly that — so it belongs here
		// beside the received date rather than one QSO at a time in the editor.
		if v := adif.NormaliseQSLVia(u.RcvdVia); v != "" {
			q.QSLRcvdVia, changed = v, true
		}
		if v := strings.TrimSpace(u.Notes); v != "" {
			q.Notes, changed = v, true
		}
		if v := strings.TrimSpace(u.Comment); v != "" {
			q.Comment, changed = v, true
		}
		if changed {
			if a.qso.Update(a.ctx, q) == nil {
				n++
			}
		}
	}
	if n > 0 {
		a.invalidateAwardStats()
	}
	return n, nil
}

// bulkFieldColumns maps the UI field ids to their QSO column. Kept in the app
// layer so the frontend works with stable ids, not raw column names.
var bulkFieldColumns = map[string]string{
	// QSL / upload status
	"lotw_sent":    "lotw_sent",
	"lotw_rcvd":    "lotw_rcvd",
	"eqsl_sent":    "eqsl_sent",
	"eqsl_rcvd":    "eqsl_rcvd",
	"qsl_sent":     "qsl_sent",
	"qsl_rcvd":     "qsl_rcvd",
	"qsl_via":      "qsl_via",
	"qsl_sent_via": "qsl_sent_via",
	"qsl_rcvd_via": "qsl_rcvd_via",
	"qrz_sent":     "qrzcom_qso_upload_status",
	"qrz_rcvd":     "qrzcom_qso_download_status",
	"clublog_sent": "clublog_qso_upload_status",
	"clublog_rcvd": "clublog_qso_download_status",
	"hrdlog_sent":  "hrdlog_qso_upload_status",
	// Old ids kept so anything holding one keeps working.
	"qrz_upload":     "qrzcom_qso_upload_status",
	"clublog_upload": "clublog_qso_upload_status",
	"hrdlog_upload":  "hrdlog_qso_upload_status",
	// Confirmation DATES. A status without its date is half the record: an
	// operator correcting a batch after an import needs both.
	"qsl_sent_date":     "qsl_sent_date",
	"qsl_rcvd_date":     "qsl_rcvd_date",
	"lotw_sent_date":    "lotw_sent_date",
	"lotw_rcvd_date":    "lotw_rcvd_date",
	"eqsl_sent_date":    "eqsl_sent_date",
	"eqsl_rcvd_date":    "eqsl_rcvd_date",
	"qrz_sent_date":     "qrzcom_qso_upload_date",
	"qrz_rcvd_date":     "qrzcom_qso_download_date",
	"clublog_sent_date": "clublog_qso_upload_date",
	"clublog_rcvd_date": "clublog_qso_download_date",
	"hrdlog_sent_date":  "hrdlog_qso_upload_date",
	// My station / operator
	"station_callsign": "station_callsign",
	"operator":         "operator",
	"my_grid":          "my_grid",
	"my_country":       "my_country",
	"my_state":         "my_state",
	"my_cnty":          "my_cnty",
	"my_iota":          "my_iota",
	"my_sota_ref":      "my_sota_ref",
	"my_pota_ref":      "my_pota_ref",
	"my_wwff_ref":      "my_wwff_ref",
	"my_street":        "my_street",
	"my_city":          "my_city",
	"my_postal_code":   "my_postal_code",
	"my_rig":           "my_rig",
	"my_antenna":       "my_antenna",
	"my_sig":           "my_sig",
	"my_sig_info":      "my_sig_info",
	"my_name":          "my_name",
	"my_arrl_sect":     "my_arrl_sect",
	"my_darc_dok":      "my_darc_dok",
	"my_vucc_grids":    "my_vucc_grids",
	// Contest
	"contest_id": "contest_id",
	"srx_string": "srx_string",
	"stx_string": "stx_string",
	"arrl_sect":  "arrl_sect",
	"precedence": "precedence",
	"class":      "class",
	// Propagation / satellite
	"prop_mode": "prop_mode",
	"sat_name":  "sat_name",
	"sat_mode":  "sat_mode",
	// Contacted station location + activation refs / SIG
	"grid":     "grid",
	"state":    "state",
	"cnty":     "cnty",
	"pota_ref": "pota_ref",
	"sota_ref": "sota_ref",
	"wwff_ref": "wwff_ref",
	"iota":     "iota",
	"sig":      "sig",
	"sig_info": "sig_info",
	// The contact itself — repair fields. Setting mode also clears submode, in
	// qso.BulkSetField: a submode left over from the old mode contradicts the new.
	"mode":     "mode",
	"submode":  "submode",
	"rst_sent": "rst_sent",
	"rst_rcvd": "rst_rcvd",
	// Misc text
	"comment": "comment",
	"notes":   "notes",
	"rig":     "rig",
	"ant":     "ant",
}

// BulkUpdateField sets one QSL/upload status field to value on the given QSOs
// (e.g. flip a filtered set from N to R so they upload). field is one of the
// ids in bulkFieldColumns; value is a status code (Y/N/R/I) or "" to clear.
// Returns how many rows were updated.
func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	// Frequency is numeric and drives the band, so it takes a dedicated path
	// (freq_hz + band updated together) rather than the generic text setter.
	if field == "freq" {
		return a.bulkSetFrequency(ids, value)
	}
	// The station-side numbers. Bounded so a slip cannot stamp CQ zone 400 on a
	// thousand rows: DXCC entities stop short of 1000, CQ zones at 40, ITU at 90.
	// Empty clears (NULL).
	if field == "my_dxcc" || field == "my_cq_zone" || field == "my_itu_zone" {
		var vp *int
		if t := strings.TrimSpace(value); t != "" {
			v, err := strconv.Atoi(t)
			max := map[string]int{"my_dxcc": 999, "my_cq_zone": 40, "my_itu_zone": 90}[field]
			if err != nil || v < 1 || v > max {
				return 0, fmt.Errorf("%s must be a number between 1 and %d (empty to clear)", field, max)
			}
			vp = &v
		}
		n, err := a.qso.BulkSetIntField(a.ctx, ids, field, vp)
		if err != nil {
			return 0, err
		}
		if n > 0 {
			a.invalidateAwardStats()
		}
		return n, nil
	}
	// Some ADIF fields have no promoted column and live in extras_json
	// (OWNER_CALLSIGN) — those take the JSON path so the rest of the extras on
	// each QSO survive the edit.
	if key := qso.BulkExtraKey(field); key != "" {
		n, err := a.qso.BulkSetExtra(a.ctx, ids, key, strings.TrimSpace(value))
		if err != nil {
			return 0, err
		}
		if n > 0 {
			a.invalidateAwardStats()
			a.materializeAwardRefsForIDs(ids)
		}
		return n, nil
	}
	col, ok := bulkFieldColumns[field]
	if !ok {
		return 0, fmt.Errorf("unknown field %q", field)
	}
	// Trim only — do NOT force case here: status codes arrive already upper from
	// the UI, while free-text fields (address, antenna, comment…) must keep
	// their case. Callsign/grid uppercasing is handled in the UI.
	n, err := a.qso.BulkSetField(a.ctx, ids, col, strings.TrimSpace(value))
	if err != nil {
		return 0, err
	}
	if n > 0 {
		a.invalidateAwardStats()
		a.materializeAwardRefsForIDs(ids) // the edited field may feed an award → refresh
	}
	return n, nil
}

// bulkSetFrequency parses a MHz frequency and sets freq_hz + band across the
// selected QSOs. Accepts a comma or dot decimal separator (fr locale) and
// rejects anything outside the ham bands so a typo can't corrupt the batch.
func (a *App) bulkSetFrequency(ids []int64, value string) (int64, error) {
	s := strings.ReplaceAll(strings.TrimSpace(value), ",", ".")
	if s == "" {
		return 0, fmt.Errorf("enter a frequency in MHz (e.g. 7.155)")
	}
	mhz, err := strconv.ParseFloat(s, 64)
	if err != nil || mhz <= 0 {
		return 0, fmt.Errorf("invalid frequency %q — enter MHz, e.g. 7.155", value)
	}
	hz := int64(math.Round(mhz * 1_000_000))
	band := cat.BandFromHz(hz)
	if band == "" {
		return 0, fmt.Errorf("%.4f MHz is not in a ham band", mhz)
	}
	n, err := a.qso.BulkSetFrequency(a.ctx, ids, hz, band)
	if err != nil {
		return 0, err
	}
	if n > 0 {
		a.invalidateAwardStats()
		a.materializeAwardRefsForIDs(ids) // band change may shift award slots → refresh
	}
	return n, nil
}

// GetWorkedCallVariants reports whether "worked before" folds an operator's
// portable forms together (RK3DWA ↔ RK3DWA/3 ↔ RK3DWA/P).
//
// Defaults to ON, hence the inverted storage: an install that predates the
// setting has no key at all, and reading that as OFF would leave every existing
// operator with the old narrow behaviour they asked us to change.
func (a *App) GetWorkedCallVariants() (bool, error) {
	if a.settings == nil {
		return true, fmt.Errorf("db not initialized")
	}
	v, err := a.settings.Get(a.ctx, keyWorkedCallVariants)
	if err != nil {
		return true, err
	}
	return v != "0", nil
}

// SetWorkedCallVariants persists the toggle.
func (a *App) SetWorkedCallVariants(on bool) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	return a.settings.Set(a.ctx, keyWorkedCallVariants, boolStr(on))
}

// WorkedBefore returns prior contacts with the given callsign at both
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
// will infer it from past QSOs with the same call when possible.
func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, error) {
	if a.qso == nil {
		return qso.WorkedBefore{}, fmt.Errorf("db not initialized")
	}
	// When the frontend lookup didn't carry a DXCC number (a QRZ cache hit may
	// have the country name but no number), resolve it from the callsign via
	// cty.dat + Clublog exceptions — the same source QSOs are logged with — so
	// the entity matrix populates even for a call we've never worked directly.
	if dxccHint == 0 && a.dxcc != nil {
		if m, ok := a.dxcc.Lookup(callsign); ok && m.Entity != nil {
			dxccHint = dxcc.EntityDXCC(m.Entity.Name)
		}
	}
	variants, _ := a.GetWorkedCallVariants()
	wb, err := a.qso.WorkedBefore(a.ctx, callsign, dxccHint, variants)
	// Attach the ClubLog Most Wanted rank for this entity (opt-in) so the entry
	// matrix can show it next to the country name.
	if err == nil && wb.DXCC > 0 && a.clublogMW != nil && a.clublogMostWantedEnabled() {
		wb.MWRank = a.clublogMW.Rank(wb.DXCC)
	}
	return wb, err
}

// BandSlotQSOs lists the contacts behind ONE cell of the entry Stats (F1)
// matrix — "everything I have on 20m CW with this entity". Same drill-down the
// Awards grid offers, for the panel an operator actually stares at while
// working a station.
//
// modeClass is the matrix column class ("PH", "CW", "DIG"); empty means all.
// Both the exact callsign and the entity are returned: a cell is amber for the
// entity and green for the call, and the useful answer to "who gave me this
// slot?" is the whole list.
func (a *App) BandSlotQSOs(callsign string, dxccNum int, band, modeClass string) ([]qso.QSO, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	all, err := a.qso.BandSlotQSOs(a.ctx, callsign, dxccNum, band)
	if err != nil {
		return nil, err
	}
	// "PH"/"DIG" are the matrix's column labels; award.ModeClass speaks
	// "PHONE"/"DIGI". Translate rather than teach one of them the other's names.
	want := map[string]string{"PH": "PHONE", "CW": "CW", "DIG": "DIGI"}[strings.ToUpper(strings.TrimSpace(modeClass))]
	if want == "" {
		return all, nil
	}
	out := make([]qso.QSO, 0, len(all))
	for _, q := range all {
		if award.ModeClass(q.Mode) == want {
			out = append(out, q)
		}
	}
	return out, nil
}

// FlexTXOnBand moves the FlexRadio's transmitter to the slice listening on the
// given band, and focuses it.
//
// Why this exists: two decoding instances on two slices — say 20 m on slice A
// and 80 m on slice B — both feed OpsLog, and answering a decode routes the
// Reply to the right INSTANCE. But the instance keys one radio, and the radio
// transmits on whichever slice carries the TX flag. Answer an 80 m decode with
// TX still on the 20 m slice and the exchange goes out on the wrong band, or
// nowhere at all.
//
// A no-op that reports success on any other backend: only a Flex has slices, and
// a caller that has to ask what radio is attached before answering a decode is a
// caller that will get it wrong somewhere.
func (a *App) FlexTXOnBand(band string) error {
	if a.cat == nil {
		return nil
	}
	st, ok := a.cat.FlexState()
	if !ok || len(st.Slices) == 0 {
		return nil // not a Flex, or no slices yet — nothing to move
	}
	want := strings.ToLower(strings.TrimSpace(band))
	if want == "" {
		return nil
	}
	// The whole band is examined BEFORE anything moves. Taking the first slice
	// that matches was wrong the moment two slices share a band — which is
	// exactly what split is: an RX slice and a TX slice, same band, a few kHz
	// apart. The loop would find the RX one first, see no TX flag on it, and move
	// the transmitter onto it — collapsing the split into simplex, and with it
	// the panel's split indicator.
	//
	// If any slice on this band already transmits, there is nothing to do: the
	// radio is already set up for this band, split or not, and it is not this
	// function's business to rearrange it.
	var target *cat.FlexSliceInfo
	for i := range st.Slices {
		s := &st.Slices[i]
		if strings.ToLower(s.Band) != want {
			continue
		}
		if s.TX {
			return nil
		}
		if target == nil {
			target = s
		}
	}
	if target == nil {
		// No slice on that band. Silent: the operator may simply be answering a
		// decode from a receiver that is not this radio.
		return nil
	}
	{
		s := target
		applog.Printf("flex: moving TX to slice %s (%s) to answer a %s decode", s.Letter, s.Band, band)
		// TX only. NOT SetActiveSlice — that sets a persistent PIN whose whole
		// contract is "the operator chose this slice in OpsLog", overriding the
		// radio's own active flag until the slice closes. Calling it from here
		// made every click on a decode silently re-pin the operating slice, and
		// split — which is built around whichever slice is active — then created
		// a new slice instead of pairing with the existing one, every time.
		//
		// Where the transmitter goes and which slice the operator is working from
		// are two different questions. This answers only the first.
		return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXSlice(s.Index) })
	}
}

// SendDecodeFreeText puts a message in the decoding application's free-text box
// and, with send, transmits it at once.
//
// The one transmit primitive that behaves the same across the whole family,
// because it matches against no decode. It does NOT start a QSO — FT8 free text
// is 13 characters and does not set the DX call, so no sequencing follows — and
// it is offered as "send this text", never as a way to call a station.
func (a *App) SendDecodeFreeText(instance, text string, send bool) error {
	if a.udp == nil {
		return fmt.Errorf("udp not initialized")
	}
	if strings.TrimSpace(instance) == "" {
		instance = a.lastTxInstance()
	}
	err := a.udp.SendFreeText(instance, text, send)
	if err != nil {
		applog.Printf("udp: free text on %q failed: %v", instance, err)
	}
	return err
}

// GridSquares returns the 4-character Maidenhead squares in the log, with
// whether each is confirmed.
//
// modeClass scopes it the way the rest of the app does: "DIGI", "CW", "PHONE",
// or "" for every mode. "DIGI" is the one this was built for — a map of the
// squares worked on FT8/FT4 answers "where have I actually been heard" in a way
// no list of callsigns does.
//
// Confirmed means LoTW, a card or eQSL — the same three the award engine counts,
// so a square cannot be green here and unconfirmed in the Awards panel.
func (a *App) GridSquares(modeClass string) ([]qso.GridSquare, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	want := strings.ToUpper(strings.TrimSpace(modeClass))
	keep := func(mode string) bool {
		switch want {
		case "", "ALL":
			return true
		case "FTX":
			// The FT family alone, which is narrower than digital and usually the
			// honest answer beside an FTx panel: a square worked on RTTY in a
			// contest is not a square worked on FT8.
			return ftxModes[strings.ToUpper(strings.TrimSpace(mode))]
		default:
			return award.ModeClass(mode) == want
		}
	}
	return a.qso.GridSquares(a.ctx, keep)
}

// SetCompactMode toggles a tiny always-on-top window that exposes just the
// QSO entry — useful when running on a single screen alongside WSJT-X,
// JT-Alert or the cluster.
//
// We can't easily spawn a real second OS window in Wails v2, but a resized
// always-on-top main window does the job from the user's perspective.
// Sizes tuned so the compact entry strip fits in a single row (no wrap).
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
// the previous (larger) min — and increased BEFORE resizing up.
const (
	compactW, compactH     = 1240, 158
	normalW, normalH       = 1400, 900
	normalMinW, normalMinH = 1100, 700
	// Large enough to never constrain a maximised window on big displays.
	maxW, maxH = 8000, 6000
)

func (a *App) SetCompactMode(on bool) {
	if a.ctx == nil {
		return
	}
	if on && !a.compact {
		// Capture the normal geometry BEFORE shrinking, so leaving compact puts
		// the window back exactly where and how big it was.
		a.preCompactMax = wruntime.WindowIsMaximised(a.ctx)
		if a.preCompactMax {
			// Unmaximise first: the size/position of a maximised window are the
			// monitor's, not the user's chosen geometry.
			wruntime.WindowUnmaximise(a.ctx)
		}
		a.preCompactW, a.preCompactH = wruntime.WindowGetSize(a.ctx)
		a.preCompactX, a.preCompactY = wruntime.WindowGetPosition(a.ctx)
		a.preCompactValid = a.preCompactW >= normalMinW && a.preCompactH >= normalMinH
	}
	a.compact = on
	if on {
		// Lock the window to the compact size by pinning min == max. Without
		// the max pin, dragging the frameless window (esp. across monitors /
		// DPI boundaries) makes Windows snap it back to a large size.
		wruntime.WindowSetMinSize(a.ctx, compactW, compactH)
		wruntime.WindowSetMaxSize(a.ctx, compactW, compactH)
		wruntime.WindowSetSize(a.ctx, compactW, compactH)
		wruntime.WindowSetAlwaysOnTop(a.ctx, true)
	} else {
		wruntime.WindowSetAlwaysOnTop(a.ctx, false)
		// Release the lock first (raise the max) before growing back.
		wruntime.WindowSetMaxSize(a.ctx, maxW, maxH)
		wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
		if a.preCompactValid {
			wruntime.WindowSetSize(a.ctx, a.preCompactW, a.preCompactH)
			// Absolute, like the capture — see moveWindowTo. Leaving compact mode on a
			// monitor left of the primary one moved the window a screen further out.
			a.moveWindowTo(a.preCompactX, a.preCompactY)
			if a.preCompactMax {
				wruntime.WindowMaximise(a.ctx)
			}
		} else {
			// Nothing captured (compact since launch) — fall back to the default.
			wruntime.WindowSetSize(a.ctx, normalW, normalH)
		}
	}
}

// DeleteAllQSO wipes every QSO. Returns the number of rows removed.
// The frontend MUST gate this behind a strong confirmation prompt.
func (a *App) DeleteAllQSO() (int64, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	return a.qso.DeleteAll(a.ctx)
}

// --- ADIF bindings ---

func (a *App) OpenADIFFile() (string, error) {
	return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
		Title: "Import ADIF",
		Filters: []wruntime.FileFilter{
			{DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"},
			{DisplayName: "All files (*.*)", Pattern: "*.*"},
		},
	})
}

// ImportADIF imports an ADIF file. dupMode controls how records matching an
// existing QSO (same call + UTC-minute + band + mode) are handled:
//   - "skip"   : leave the existing QSO untouched (default, safe)
//   - "update" : merge the file's non-empty fields onto the existing QSO —
//     refreshes QSL/confirmation statuses when re-syncing from
//     Log4OM / LoTW without clobbering fields the file omits
//   - "all"    : insert every record, duplicates included
//
// fieldMap moves ADIF fields before conversion (source → destination, ADIF tag
// names in any case). Contest exports put the exchange where their module keeps
// it rather than where ADIF says: the RSGB IOTA contest arrives with the island
// reference in STATE, which imports as a US state and leaves the IOTA award
// empty. The destination is only filled when the file left it blank.
//
// applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from
// cty.dat for every record, overriding what the file carries — corrects the
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
// Russia). Everything else in the ADIF is still preserved verbatim.
func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool, fieldMap map[string]string) (adif.ImportResult, error) {
	if a.qso == nil {
		return adif.ImportResult{}, fmt.Errorf("db not initialized")
	}
	if path == "" {
		return adif.ImportResult{}, fmt.Errorf("empty path")
	}
	// Import preserves the ADIF verbatim by default — confirmation/sent-status
	// defaults are NEVER applied (they'd flag old QSOs "LoTW requested" and try to
	// re-upload). When applyStation is on, we DO backfill empty MY_* station
	// fields (grid/rig/antenna/QTH/address…) from the active profile — those are
	// descriptive metadata and safe to fill (identity fields are still left
	// alone, see applyStationDefaults).
	im := &adif.Importer{Repo: a.qso}
	if len(fieldMap) > 0 {
		lower := make(map[string]string, len(fieldMap))
		for from, to := range fieldMap {
			from = strings.ToLower(strings.TrimSpace(from))
			to = strings.ToLower(strings.TrimSpace(to))
			if from != "" && to != "" && from != to {
				lower[from] = to
			}
		}
		im.FieldMap = lower
		applog.Printf("import: field mapping %v", lower)
	}
	switch dupMode {
	case "update":
		im.UpdateDuplicates = true
	case "all":
		// insert everything
	default: // "skip"
		im.SkipDuplicates = true
	}
	// When the user opts to fix countries on import, recompute from cty.dat and
	// then apply ClubLog's date-ranged exceptions, which take precedence (e.g.
	// TO2A on 2012-10-27 → French Guiana, not the cty.dat "TO" → France). We
	// apply ClubLog whenever its data is LOADED, regardless of the live
	// entry-form toggle: "apply cty" is an explicit request for the most
	// accurate entity, and skipping ClubLog would DOWNGRADE DXpedition QSOs the
	// source ADIF already had right. If the cache isn't loaded yet, try once.
	if applyCty && a.clublog != nil && !a.clublog.Loaded() {
		_ = a.clublog.EnsureLoaded()
	}
	clLoaded := a.clublog != nil && a.clublog.Loaded()
	// Counted rather than assumed: "did it fill the callsign?" is the first
	// question after an import, and the log is where it gets answered.
	stationStamped := 0
	if applyCty || applyStation {
		im.Enrich = func(q *qso.QSO) {
			if applyCty {
				a.enrichContactedFromCtyForce(q)
				if clLoaded {
					a.applyClublogException(q, true) // force: explicit import-time correction
				}
			}
			// Unconditional: see fillDistance.
			fillDistance(q)
			if applyStation {
				// Backfill every empty station field from the active profile,
				// STATION_CALLSIGN included.
				//
				// It used to be the one field held back, on the grounds that
				// stamping the active call could re-route a mixed-call log. That
				// protected nothing: the same option already writes this profile's
				// grid, rig, antenna and postal address onto every record it finds
				// blank, so it has assumed "this log is mine" long before reaching
				// the callsign — and a record that CARRIES a call is never touched,
				// which is what actually keeps a multi-op log intact. Withholding it
				// only meant the option quietly failed the one field an operator
				// checks afterwards.
				hadStation := strings.TrimSpace(q.StationCallsign) != ""
				a.applyStationDefaults(q, true)
				if !hadStation && strings.TrimSpace(q.StationCallsign) != "" {
					stationStamped++
				}
				// Also stamp the default QSL/LoTW/eQSL confirmation statuses on
				// any that are still empty (same defaults new QSOs get).
				a.applyQSLDefaults(q)
			}
		}
	}
	im.OnProgress = func(processed, total int) {
		wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
	}
	res, err := im.ImportFile(a.ctx, path)
	if stationStamped > 0 {
		applog.Printf("import: STATION_CALLSIGN filled from the active profile on %d record(s) that carried none", stationStamped)
	}
	if err == nil && (res.Imported > 0 || res.Updated > 0) {
		a.recomputeAwardRefsAsync() // materialise award_refs for the imported rows
	}
	return res, err
}

// SaveADIFFile shows a native Save-As dialog suggesting a timestamped
// OpsLog_YYYYMMDD_HHMMSS.adi filename. Returns "" if the user cancelled.
func (a *App) SaveADIFFile() (string, error) {
	suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".adi"
	return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
		Title:           "Export ADIF",
		DefaultFilename: suggested,
		Filters: []wruntime.FileFilter{
			{DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"},
			{DisplayName: "All files (*.*)", Pattern: "*.*"},
		},
	})
}

// ADIFFields returns the complete ADIF 3.1.7 QSO-field dictionary, for the
// generic "ADIF fields" editor (so any standard field can be viewed/edited)
// and for the export-mode help text.
func (a *App) ADIFFields() []adif.FieldDef { return adif.Fields }

// ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7").
func (a *App) ADIFVersion() string { return adif.ADIFVersion() }

// adifFieldSet turns a list of ADIF tag names into an allow-set (uppercased),
// or nil when the list is empty — nil means "all fields" (standard/full mode),
// so the two-mode export path is unchanged when no explicit field list is given.
func adifFieldSet(fields []string) map[string]bool {
	if len(fields) == 0 {
		return nil
	}
	set := make(map[string]bool, len(fields))
	for _, f := range fields {
		if t := strings.ToUpper(strings.TrimSpace(f)); t != "" {
			set[t] = true
		}
	}
	return set
}

// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
// Streams from DB so memory stays flat even with 100k+ records.
// includeAppFields=false → portable standard ADIF (for other loggers);
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
// fields (optional): when non-empty, export EXACTLY those ADIF tags — the
// "choose which fields to export" mode (overrides includeAppFields).
func (a *App) ExportADIF(path string, includeAppFields bool, fields []string) (adif.ExportResult, error) {
	if a.qso == nil {
		return adif.ExportResult{}, fmt.Errorf("db not initialized")
	}
	if path == "" {
		return adif.ExportResult{}, fmt.Errorf("empty path")
	}
	ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
	return ex.ExportFile(a.ctx, path)
}

// ExportADIFFiltered writes the QSOs matching the current filter to path, with
// NO row limit (the on-screen list is capped by the threshold; this is not).
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter, fields []string) (adif.ExportResult, error) {
	if a.qso == nil {
		return adif.ExportResult{}, fmt.Errorf("db not initialized")
	}
	if path == "" {
		return adif.ExportResult{}, fmt.Errorf("empty path")
	}
	ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
	return ex.ExportFileFiltered(a.ctx, path, f)
}

// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
// operator highlighted in the grid).
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64, fields []string) (adif.ExportResult, error) {
	if a.qso == nil {
		return adif.ExportResult{}, fmt.Errorf("db not initialized")
	}
	if path == "" {
		return adif.ExportResult{}, fmt.Errorf("empty path")
	}
	if len(ids) == 0 {
		return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
	}
	ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
	return ex.ExportFileByIDs(a.ctx, path, ids)
}

// ADIFExtraFields returns the distinct NON-standard ADIF tags actually present
// in the logbook's stored extras (APP_* and vendor tags) — the "OpsLog / extra
// fields" group in the export field picker, alongside the static official-ADIF
// list from ADIFFields(). Sampled + capped so it stays fast on a big remote log.
func (a *App) ADIFExtraFields() []string {
	if a.qso == nil {
		return nil
	}
	keys, err := a.qso.DistinctExtraKeys(a.ctx, 5000)
	if err != nil {
		return nil
	}
	out := make([]string, 0, len(keys))
	for _, k := range keys {
		if t := strings.ToUpper(strings.TrimSpace(k)); t != "" && !adif.IsStandardField(t) {
			out = append(out, t)
		}
	}
	sort.Strings(out)
	return out
}

// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
type CabrilloResult struct {
	Count int    `json:"count"`
	Path  string `json:"path"`
}

// cabrilloHeader fills the Cabrillo header from the active profile (callsign,
// operator, grid) plus the contest name (taken from the QSOs' ADIF CONTEST_ID).
func (a *App) cabrilloHeader(contest string) cabrillo.Header {
	h := cabrillo.Header{Contest: contest}
	if a.profiles != nil {
		if p, err := a.profiles.Active(a.ctx); err == nil {
			h.Callsign = p.Callsign
			h.Operators = p.Callsign
			h.GridLocator = p.MyGrid
			h.Name = p.OpName
		}
	}
	return h
}

// writeCabrillo collects the QSOs via the given iterator and writes a Cabrillo
// document to path. The contest name is taken from the QSOs' ADIF CONTEST_ID
// (the most common one), so no separate prompt is needed.
func (a *App) writeCabrillo(path string, iterate func(func(qso.QSO) error) error) (CabrilloResult, error) {
	if a.qso == nil {
		return CabrilloResult{}, fmt.Errorf("db not initialized")
	}
	if path == "" {
		return CabrilloResult{}, fmt.Errorf("empty path")
	}
	var qsos []qso.QSO
	contests := map[string]int{}
	if err := iterate(func(q qso.QSO) error {
		qsos = append(qsos, q)
		if c := strings.TrimSpace(q.ContestID); c != "" {
			contests[c]++
		}
		return nil
	}); err != nil {
		return CabrilloResult{}, err
	}
	// Pick the most-used CONTEST_ID across the exported QSOs.
	contest := ""
	best := 0
	for c, n := range contests {
		if n > best {
			contest, best = c, n
		}
	}
	f, err := os.Create(path)
	if err != nil {
		return CabrilloResult{}, err
	}
	defer f.Close()
	n, err := cabrillo.Generate(f, a.cabrilloHeader(contest), qsos)
	if err != nil {
		return CabrilloResult{}, err
	}
	return CabrilloResult{Count: n, Path: path}, nil
}

// ExportCabrillo writes every QSO to a Cabrillo file.
// ListContests returns the built-in contest catalogue (ADIF CONTEST_ID + default
// exchange), sorted by name, for the contest-mode picker.
func (a *App) ListContests() []contest.Def {
	return contest.List()
}

// ContestBandRow is one band's QSO count in the contest scoreboard.
type ContestBandRow struct {
	Band  string `json:"band"`
	Count int    `json:"count"`
}

// ContestStatsResult is the live contest scoreboard. Score is a best-effort
// ESTIMATE (qsos × mult): real contest scoring is per-contest and rule-heavy, so
// this is a working indicator, not an official claim.
type ContestStatsResult struct {
	QSOs      int              `json:"qsos"`
	ByBand    []ContestBandRow `json:"by_band"`
	Mult      int              `json:"mult"`
	MultLabel string           `json:"mult_label"`
	Score     int              `json:"score"`
	LastHour  int              `json:"last_hour"` // QSOs in the last 60 min (rate)
}

// parseTimeISO parses an RFC3339(/nano) timestamp; ok=false for an empty/invalid
// value (meaning "no bound").
func parseTimeISO(s string) (time.Time, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return time.Time{}, false
	}
	if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
		return t, true
	}
	if t, err := time.Parse(time.RFC3339, s); err == nil {
		return t, true
	}
	return time.Time{}, false
}

// ContestStats computes the scoreboard for the given CONTEST_ID within the
// [startISO, endISO] window (either bound may be empty = open). exchange is the
// active exchange kind, which decides what counts as a multiplier.
func (a *App) ContestStats(contestID, exchange, startISO, endISO string) ContestStatsResult {
	res := ContestStatsResult{}
	if a.qso == nil || strings.TrimSpace(contestID) == "" {
		return res
	}
	start, hasStart := parseTimeISO(startISO)
	end, hasEnd := parseTimeISO(endISO)
	f := qso.QueryFilter{
		Conditions: []qso.Condition{{Field: "contest_id", Op: "eq", Value: contestID}},
		Match:      "AND", Limit: 1_000_000,
	}
	bandCount := map[string]int{}
	multSet := map[string]struct{}{}
	label := "DXCC"
	cutoff := time.Now().Add(-time.Hour)
	_ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error {
		if hasStart && q.QSODate.Before(start) {
			return nil
		}
		if hasEnd && q.QSODate.After(end) {
			return nil
		}
		res.QSOs++
		if q.Band != "" {
			bandCount[q.Band]++
		}
		if !q.QSODate.IsZero() && q.QSODate.After(cutoff) {
			res.LastHour++
		}
		switch exchange {
		case "cq-zone":
			label = "Zones"
			if q.CQZ != nil && *q.CQZ > 0 {
				multSet[fmt.Sprintf("Z%d", *q.CQZ)] = struct{}{}
			}
		case "itu-zone":
			label = "Zones"
			if q.ITUZ != nil && *q.ITUZ > 0 {
				multSet[fmt.Sprintf("I%d", *q.ITUZ)] = struct{}{}
			}
		case "dept", "state", "name", "grid", "age", "section":
			label = "Mults"
			k := strings.ToUpper(strings.TrimSpace(q.SRXString))
			if k == "" && q.SRX != nil {
				k = fmt.Sprintf("%d", *q.SRX)
			}
			if k != "" {
				multSet[k] = struct{}{}
			}
		default: // serial and others
			if strings.Contains(strings.ToUpper(contestID), "WPX") {
				label = "Prefixes"
				if p := award.WPXPrefix(q.Callsign); p != "" {
					multSet[p] = struct{}{}
				}
			} else {
				label = "DXCC"
				if q.DXCC != nil && *q.DXCC > 0 {
					multSet[fmt.Sprintf("%d", *q.DXCC)] = struct{}{}
				}
			}
		}
		return nil
	})
	res.Mult = len(multSet)
	res.MultLabel = label
	if res.Mult > 0 {
		res.Score = res.QSOs * res.Mult
	} else {
		res.Score = res.QSOs
	}
	for b, c := range bandCount {
		res.ByBand = append(res.ByBand, ContestBandRow{Band: b, Count: c})
	}
	if res.ByBand == nil {
		res.ByBand = []ContestBandRow{} // never nil → marshals as [] not null (frontend guard)
	}
	sort.Slice(res.ByBand, func(i, j int) bool { return res.ByBand[i].Band < res.ByBand[j].Band })
	return res
}

// ContestDupe reports whether call was already worked in this contest on the
// same band + mode class since startISO (the usual dupe rule). Empty band/mode/
// startISO widens the check.
func (a *App) ContestDupe(call, contestID, band, mode, startISO string) bool {
	if a.qso == nil || strings.TrimSpace(call) == "" || strings.TrimSpace(contestID) == "" {
		return false
	}
	start, hasStart := parseTimeISO(startISO)
	f := qso.QueryFilter{
		Conditions: []qso.Condition{
			{Field: "contest_id", Op: "eq", Value: contestID},
			{Field: "callsign", Op: "eq", Value: strings.ToUpper(strings.TrimSpace(call))},
		},
		Match: "AND", Limit: 1000,
	}
	if strings.TrimSpace(band) != "" {
		f.Conditions = append(f.Conditions, qso.Condition{Field: "band", Op: "eq", Value: band})
	}
	want := contestModeClass(mode)
	dupe := false
	_ = a.qso.IterateFiltered(a.ctx, f, func(q qso.QSO) error {
		if hasStart && q.QSODate.Before(start) {
			return nil
		}
		if want == "" || contestModeClass(q.Mode) == want {
			dupe = true
		}
		return nil
	})
	return dupe
}

// contestModeClass collapses a mode to the CW/PH/DIG bucket contests dupe on.
func contestModeClass(mode string) string {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "":
		return ""
	case "CW":
		return "CW"
	case "SSB", "USB", "LSB", "AM", "FM", "PH", "PHONE":
		return "PH"
	default:
		return "DIG"
	}
}

func (a *App) ExportCabrillo(path string) (CabrilloResult, error) {
	return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateAll(a.ctx, fn) })
}

// ExportCabrilloFiltered writes the QSOs matching the current filter.
func (a *App) ExportCabrilloFiltered(path string, f qso.QueryFilter) (CabrilloResult, error) {
	return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateFiltered(a.ctx, f, fn) })
}

// ExportCabrilloSelected writes only the highlighted QSOs.
func (a *App) ExportCabrilloSelected(path string, ids []int64) (CabrilloResult, error) {
	if len(ids) == 0 {
		return CabrilloResult{}, fmt.Errorf("no QSOs selected")
	}
	return a.writeCabrillo(path, func(fn func(qso.QSO) error) error { return a.qso.IterateByIDs(a.ctx, ids, fn) })
}

// SaveCabrilloFile opens a save dialog for a Cabrillo (.log) file.
func (a *App) SaveCabrilloFile() (string, error) {
	suggested := "OpsLog_" + time.Now().UTC().Format("20060102_150405") + ".log"
	return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
		Title:           "Export Cabrillo",
		DefaultFilename: suggested,
		Filters: []wruntime.FileFilter{
			{DisplayName: "Cabrillo log (*.log, *.cbr)", Pattern: "*.log;*.cbr"},
			{DisplayName: "All files (*.*)", Pattern: "*.*"},
		},
	})
}

// --- Lookup bindings ---

// LookupCallsign returns the cached or freshly-fetched info for a callsign.
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
// "callsign not found".
// qsoDate is the entry form's date as "YYYY-MM-DD" (or empty for now). It is
// what the ClubLog exception is resolved AGAINST: an exception has a validity
// window, and a DXpedition's window closes. Entering 3Y0K by hand months later,
// with the activation's own date in the form, resolved the exception at TODAY's
// date, found none, and fell back to cty.dat — Antarctica instead of Bouvet
// Island. A backdated QSO must be enriched as of when it happened.
func (a *App) LookupCallsign(callsign string, qsoDate string) (lookup.Result, error) {
	return a.lookupCallsign(callsign, false, qsoDate)
}

// LookupCallsignFresh is the same, but SKIPS the cache and refreshes it.
//
// For a lookup the operator asked for by clicking, in the QSO editor. The cache
// is right for the automatic lookup that fires while typing, but it also freezes
// a wrong answer for its whole 30-day life: an operator who upgraded their QRZ
// subscription went on getting the thin free-account record, and deleting the
// cached row by hand was the only way out. A deliberate click must reach the
// provider and overwrite what was stored.
func (a *App) LookupCallsignFresh(callsign string, qsoDate string) (lookup.Result, error) {
	return a.lookupCallsign(callsign, true, qsoDate)
}

func (a *App) lookupCallsign(callsign string, force bool, qsoDate string) (lookup.Result, error) {
	if a.lookup == nil {
		return lookup.Result{}, fmt.Errorf("lookup not initialized")
	}
	// Bound the whole lookup, then let Lookup fall through to cty.dat
	// (country/zones). Without this a call that isn't in QRZ.com — or a slow
	// provider — left the "looking up" spinner turning for 10 s+ before the
	// cty.dat fallback showed. The providers respect the context, so they're
	// cancelled at the deadline and cty.dat answers instantly.
	//
	// A slashed/portable call needs a bigger slice, because it costs TWO provider
	// round trips rather than one: the full form is looked up first, and only when
	// that comes back not-found does the home call get tried (F4LYI/M → F4LYI).
	// Two seconds covered one request but not two, so /M and /P calls always ran
	// out of time on the second and fell back to cty.dat — even though the
	// operator's QRZ record was there and the plain call resolved fine.
	budget := 2 * time.Second
	if strings.Contains(callsign, "/") {
		budget = 6 * time.Second
	}
	ctx, cancel := context.WithTimeout(a.ctx, budget)
	defer cancel()
	if force {
		// A forced lookup has to reach the network, so it gets a longer leash than
		// the type-ahead one: giving up at 2 s would just fall back to cty.dat and
		// look like the click did nothing.
		cancel()
		ctx, cancel = context.WithTimeout(a.ctx, 15*time.Second)
		defer cancel()
		ctx = lookup.WithForce(ctx)
		applog.Printf("lookup: FORCED (cache bypassed) for %s", strings.ToUpper(strings.TrimSpace(callsign)))
	}
	r, err := a.lookup.Lookup(ctx, callsign)
	if errors.Is(err, lookup.ErrNotFound) {
		return lookup.Result{}, fmt.Errorf("callsign not found")
	}
	// Respect the user's "Download profile images" setting: even if the
	// cache holds the URL we hide it when the toggle is off so the
	// frontend doesn't render the <img> (which would still fetch from
	// QRZ). Cheap to check per call — settings is in-memory after init.
	if err == nil && r.ImageURL != "" {
		if s, _ := a.GetLookupSettings(); !s.DownloadImages {
			r.ImageURL = ""
		}
	}
	// ClubLog exception override, resolved AT THE QSO'S DATE.
	//
	// An exception carries a validity window and a DXpedition's window closes.
	// Resolving at today's date is right for a contact happening now and wrong
	// for one being entered afterwards: 3Y0K typed months later, with the
	// activation's date in the form, found no live exception and fell back to
	// cty.dat — Antarctica, where the log says Bouvet Island.
	if a.clublogCtyEnabled() && a.clublog != nil {
		if e, ok := a.clublog.Resolve(callsign, lookupWhen(qsoDate)); ok {
			r.Country = titleEntity(e.Entity)
			if e.Cont != "" {
				r.Continent = e.Cont
			}
			if e.ADIF != 0 {
				r.DXCC = e.ADIF
			}
			if e.CQZ != 0 {
				r.CQZ = e.CQZ
			}
			if r.Callsign == "" {
				r.Callsign = strings.ToUpper(strings.TrimSpace(callsign))
			}
		}
	}
	a.enrichFromULS(&r, callsign)
	// An entity that IS a single island group answers for itself. QRZ carries
	// <iota> only for the operators who filled it in, and most have not; but a
	// station in Ascension is on AF-003 whatever its callbook record says, and
	// the entity is known for every callsign from cty.dat alone.
	//
	// So this reaches the operator with no QRZ subscription, and the station
	// that has never touched a callbook — and it is right when the two
	// disagree only in the sense that the callbook wins: filled ONLY when the
	// callbook gave nothing, because an operator who typed their own reference
	// knows something a table cannot (an IOTA-heavy entity, a rare island).
	if r.IOTA == "" && r.DXCC != 0 {
		r.IOTA = awardref.IOTAForDXCC(r.DXCC)
	}
	// The Russian district, from the offline database, for the DAY of the QSO.
	//
	// It overrides what a callbook said rather than filling a blank, which is the
	// opposite of every other enrichment here — and deliberately. HamQTH carries
	// the district a station operates from TODAY and nothing more; this database
	// carries dated activity records, so on a contact from an expedition or from
	// a station that has since moved it is right where the callbook is wrong.
	// For everyone else the two agree.
	if when := lookupWhen(qsoDate); r.DXCC == 15 || r.DXCC == 54 || r.DXCC == 126 {
		if m, ok := rda.Lookup(callsign, when); ok {
			r.RDA = m.District
		}
	}
	// Custom outbound rows bound to a lookup. After the enrichment, so a
	// template sees the same grid and county the entry panel is about to show —
	// two answers for one callsign is how a rotator ends up pointed elsewhere
	// than the screen says.
	if err == nil {
		a.udpTriggerLookup(&r)
	}
	return r, err
}

// enrichFromULS fills a US station's state, county and grid from the offline FCC
// database while the operator is still typing.
//
// The ULS data was only ever applied at SAVE time (applyULSCounty), so an
// operator with the database downloaded and no QRZ.com/HamQTH account saw
// nothing but cty.dat during entry — country, zones, and a 4-character grid that
// is the ENTITY centroid, thousands of km off. The county and a 6-character grid
// were stamped silently after logging, too late to steer an antenna by.
//
// It runs LAST and only fills blanks, so an online provider always wins: ULS
// holds no name and no address, so it can complete a QRZ record but never
// replace one. The grid goes through refineGrid, which accepts the ULS square
// only when it EXTENDS what is already there (JN → JN05JG) and never when it
// would contradict it.
func (a *App) enrichFromULS(r *lookup.Result, callsign string) {
	if a.uls == nil {
		return
	}
	switch r.DXCC {
	case 291, 110, 6: // United States, Hawaii, Alaska
	default:
		return
	}
	call := strings.ToUpper(strings.TrimSpace(callsign))
	if r.Callsign != "" {
		call = strings.ToUpper(strings.TrimSpace(r.Callsign))
	}
	if call == "" || strings.Contains(call, "/") {
		// A portable call is not what the FCC licensed — W1AW/4 is not a row.
		return
	}
	loc, ok := a.uls.Resolve(call)
	if !ok {
		return
	}
	if strings.TrimSpace(r.State) == "" {
		r.State = loc.State
	}
	if strings.TrimSpace(r.County) == "" {
		r.County = loc.CNTY()
	}
	// Grid. refineGrid keeps the ULS square when it extends what is there. It
	// does NOT cover the case this feature exists for: cty.dat's 4-character
	// square is the ENTITY centroid, so for a US call it is usually a different
	// square altogether — refineGrid would keep it, and the operator would go on
	// pointing an antenna at the middle of the country. A per-callsign ULS square
	// beats any 4-character one, whatever its letters; a 6-character grid already
	// present (QRZ) is left alone.
	newGrid := ""
	if g := refineGrid(r.Grid, loc.Grid); g != "" && g != r.Grid {
		newGrid = g
	} else if len(strings.TrimSpace(r.Grid)) <= 4 && len(strings.TrimSpace(loc.Grid)) >= 6 {
		newGrid = loc.Grid
	}
	if newGrid != "" {
		r.Grid = newGrid
		// Lat/lon follow, or distance and azimuth would still be computed from the
		// centroid the grid no longer says. Only when WE changed the grid: a
		// provider's own coordinates are more precise than a square's centre.
		if lat, lon, ok := gridToLatLon(newGrid); ok {
			r.Lat, r.Lon = lat, lon
		}
	}
}

// OpenExternalURL opens a URL in the user's default browser. Wails ships
// runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon
// next to the callsign field, the future Clublog/HamQTH shortcuts, etc.
func (a *App) OpenExternalURL(url string) error {
	url = strings.TrimSpace(url)
	if url == "" {
		return fmt.Errorf("empty URL")
	}
	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
		return fmt.Errorf("only http(s) URLs allowed, got %q", url)
	}
	wruntime.BrowserOpenURL(a.ctx, url)
	return nil
}

// GetLookupSettings returns current credentials and cache TTL.
func (a *App) GetLookupSettings() (LookupSettings, error) {
	if a.settings == nil {
		return LookupSettings{}, fmt.Errorf("db not initialized")
	}
	m, err := a.settings.GetMany(a.ctx,
		keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
		keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages, keyQRZNickname)
	if err != nil {
		return LookupSettings{}, err
	}
	// Same rule as reloadLookupProviders: blank means "never set" and gets the
	// default, while a stored zero is the operator asking for no cache at all.
	ttl := 30
	if raw := strings.TrimSpace(m[keyCacheTTL]); raw != "" {
		if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
			ttl = n
		}
	}
	return LookupSettings{
		QRZUser:           m[keyQRZUser],
		QRZPassword:       m[keyQRZPassword],
		HamQTHUser:        m[keyHQUser],
		HamQTHPassword:    m[keyHQPassword],
		Primary:           m[keyLookupPrimary],
		Failsafe:          m[keyLookupFailsafe],
		DownloadImages:    m[keyLookupImages] == "1",
		QRZPreferNickname: m[keyQRZNickname] == "1",
		CacheTTLDays:      ttl,
	}, nil
}

// SaveLookupSettings persists credentials and rebuilds the provider chain.
func (a *App) SaveLookupSettings(s LookupSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	// Zero is kept — it means no cache. Only a negative number is nonsense.
	if s.CacheTTLDays < 0 {
		s.CacheTTLDays = 0
	}
	// Reject a primary == failsafe routing combo — would just hit the same
	// provider twice. Frontend should prevent this but defend in depth.
	if s.Primary != "" && s.Primary == s.Failsafe {
		s.Failsafe = ""
	}
	for k, v := range map[string]string{
		keyQRZUser:        s.QRZUser,
		keyQRZPassword:    s.QRZPassword,
		keyHQUser:         s.HamQTHUser,
		keyHQPassword:     s.HamQTHPassword,
		keyCacheTTL:       strconv.Itoa(s.CacheTTLDays),
		keyLookupPrimary:  s.Primary,
		keyLookupFailsafe: s.Failsafe,
		keyLookupImages:   boolStr(s.DownloadImages),
		keyQRZNickname:    boolStr(s.QRZPreferNickname),
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	a.reloadLookupProviders()
	return nil
}

// TestLookupProvider runs a one-shot lookup against a specific provider so
// the user can verify credentials before saving. callsign defaults to the
// active profile's callsign when empty (handy "test against my own call").
// Returns the result on success or a descriptive error.
func (a *App) TestLookupProvider(name, callsign, user, password string) (lookup.Result, error) {
	if user == "" || password == "" {
		return lookup.Result{}, fmt.Errorf("user and password required")
	}
	if callsign == "" {
		if a.profiles != nil {
			if p, err := a.profiles.Active(a.ctx); err == nil {
				callsign = p.Callsign
			}
		}
		if callsign == "" {
			callsign = "W1AW" // ARRL HQ — always present in every database
		}
	}
	var p lookup.Provider
	switch name {
	case "qrz":
		p = lookup.NewQRZ(user, password)
	case "hamqth":
		p = lookup.NewHamQTH(user, password)
	default:
		return lookup.Result{}, fmt.Errorf("unknown provider %q", name)
	}
	r, err := p.Lookup(a.ctx, callsign)
	if errors.Is(err, lookup.ErrNotFound) {
		// A "not found" (vs an auth error) means the login WORKED — the test
		// callsign just isn't in this database, common for special-event calls
		// (e.g. TM74TFR not on HamQTH). The credentials are valid, so lookups of
		// other callsigns will work: report success, not failure.
		return lookup.Result{Source: name, Callsign: callsign, Name: "credentials OK — " + callsign + " not in " + name}, nil
	}
	if err != nil {
		return lookup.Result{}, err
	}
	r.Source = name
	return r, nil
}

// --- CAT bindings ---

// GetCATSettings returns the stored CAT configuration (defaults applied).
func (a *App) GetCATSettings() (CATSettings, error) {
	if a.settings == nil {
		return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
	}
	m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATYaesuRTTYUSB, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort, keyCATDigiUSB)
	if err != nil {
		return CATSettings{}, err
	}
	out := CATSettings{
		Enabled:         m[keyCATEnabled] == "1",
		Backend:         m[keyCATBackend],
		OmniRigNum:      1,
		FlexHost:        m[keyCATFlexHost],
		FlexPort:        4992,
		FlexSpots:       m[keyCATFlexSpots] == "1",
		FlexDVKDax:      m[keyCATFlexDVKDax] == "1",
		FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
		FlexDecodeSecs:  120,
		XieguPort:       m[keyCATXieguPort],
		XieguBaud:       19200,
		XieguAddr:       cat.XieguDefaultAddr,
		YaesuPort:       m[keyCATYaesuPort],
		YaesuBaud:       38400,
		KenwoodPort:     m[keyCATKenwoodPort],
		KenwoodHost:     m[keyCATKenwoodHost],
		// An install that predates the setting is read from what it has: a host
		// filled in meant the bridge, since that is what the old code preferred.
		KenwoodLink:      kenwoodLinkOr(m[keyCATKenwoodLink], m[keyCATKenwoodHost]),
		KenwoodBaud:      9600,
		YaesuLowLines:    m[keyCATYaesuLowLines] == "1",
		YaesuRTTYUSB:     m[keyCATYaesuRTTYUSB] == "1",
		KenwoodLowLines:  m[keyCATKenwoodLowLines] == "1",
		KenwoodDataMode:  m[keyCATKenwoodDataMode],
		IcomPort:         m[keyCATIcomPort],
		IcomBaud:         115200,
		IcomAddr:         0x98, // IC-7610 default
		IcomNetHost:      m[keyCATIcomNetHost],
		IcomNetUser:      m[keyCATIcomNetUser],
		IcomNetPass:      m[keyCATIcomNetPass],
		IcomNetAudio:     m[keyCATIcomNetAudio] == "1",
		TCIHost:          m[keyCATTCIHost],
		TCIPort:          40001,
		TCISpots:         m[keyCATTCISpots] == "1",
		PTTHotkeyEnabled: m[keyCATPttHotkeyEnabled] == "1",
		PTTHotkey:        m[keyCATPttHotkey],
		PTTHotkeyToggle:  m[keyCATPttHotkeyToggle] == "1",
		PollMs:           250,
		DelayMs:          0,
		OffsetOn:         m[keyCATOffsetOn] == "1",
		DigitalDefault:   m[keyCATDigitalDefault],
		ShareEnabled:     m[keyCATShareEnabled] == "1",
		SharePort:        4532,
		ShareProto:       "rigctl",
		ShareTCIPort:     tciserver.DefaultPort,
	}
	if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
		out.FlexPort = n
	}
	// Signed: a transverter can sit either side of its IF (a 144 MHz IF feeding a
	// 28 MHz receive converter is a negative offset).
	if n, err := strconv.ParseInt(strings.TrimSpace(m[keyCATOffsetHz]), 10, 64); err == nil {
		out.OffsetHz = n
	}
	if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
		out.FlexDecodeSecs = n
	}
	if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
		out.TCIPort = n
	}
	if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
		out.SharePort = n
	}
	if p := strings.ToLower(strings.TrimSpace(m[keyCATShareProto])); p == "tci" {
		out.ShareProto = p
	}
	if n, _ := strconv.Atoi(m[keyCATShareTCIPort]); n > 0 && n <= 65535 {
		out.ShareTCIPort = n
	}
	if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
		out.XieguBaud = n
	}
	out.XieguPTTLine = m[keyCATXieguPTTLine]
	if n, _ := strconv.Atoi(m[keyCATXieguAddr]); n > 0 && n <= 0xFF {
		out.XieguAddr = n
	}
	if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
		out.YaesuBaud = n
	}
	if n, _ := strconv.Atoi(m[keyCATKenwoodBaud]); n > 0 {
		out.KenwoodBaud = n
	}
	if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
		out.IcomBaud = n
	}
	// 0x00 is a real CI-V address an operator may need (a bare interface, a rig
	// left at its factory broadcast address), and "> 0" silently sent them back
	// to the IC-7610 default with no way to say what they meant. An EMPTY
	// setting is what means "never configured" — so the error is what decides,
	// not the value.
	if v := strings.TrimSpace(m[keyCATIcomAddr]); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n >= 0 && n <= 0xFF {
			out.IcomAddr = n
		}
	}
	if out.Backend == "" {
		out.Backend = "omnirig"
	}
	if out.DigitalDefault == "" {
		out.DigitalDefault = "FT8"
	}
	if v := strings.ToUpper(strings.TrimSpace(m[keyCATOmniRigVFO])); v == "A" || v == "B" {
		out.OmniRigVFO = v
	}
	out.DigiAsUSB = m[keyCATDigiUSB] == "1"
	if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
		out.OmniRigNum = n
	}
	if n, _ := strconv.Atoi(m[keyCATPollMs]); n >= 50 && n <= 2000 {
		out.PollMs = n
	}
	if n, _ := strconv.Atoi(m[keyCATDelayMs]); n >= 0 && n <= 500 {
		out.DelayMs = n
	}
	return out, nil
}

// SaveCATSettings persists CAT config and restarts the manager accordingly.
func (a *App) SaveCATSettings(s CATSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if s.Backend == "" {
		s.Backend = "omnirig"
	}
	if s.OmniRigNum != 1 && s.OmniRigNum != 2 {
		s.OmniRigNum = 1
	}
	if s.FlexPort <= 0 || s.FlexPort > 65535 {
		s.FlexPort = 4992
	}
	if s.SharePort <= 0 || s.SharePort > 65535 {
		s.SharePort = 4532
	}
	if s.ShareProto != "tci" {
		s.ShareProto = "rigctl"
	}
	if s.ShareTCIPort <= 0 || s.ShareTCIPort > 65535 {
		s.ShareTCIPort = tciserver.DefaultPort
	}
	if s.XieguBaud <= 0 {
		s.XieguBaud = 19200
	}
	if s.XieguAddr <= 0 || s.XieguAddr > 0xFF {
		s.XieguAddr = cat.XieguDefaultAddr
	}
	if s.YaesuBaud <= 0 {
		s.YaesuBaud = 38400
	}
	if s.KenwoodBaud <= 0 {
		s.KenwoodBaud = 9600
	}
	if s.IcomBaud <= 0 {
		s.IcomBaud = 115200
	}
	// 0x00 IS an address. Rejecting it as "unset" is what sent an operator's
	// custom-address rig straight back to the IC-7610's 0x98 every time the
	// settings were saved — the model dropdown snapping back with it, since it
	// reads the address rather than a stored model.
	if s.IcomAddr < 0 || s.IcomAddr > 0xFF {
		s.IcomAddr = 0x98
	}
	if s.PollMs < 50 || s.PollMs > 2000 {
		s.PollMs = 250
	}
	if s.DelayMs < 0 || s.DelayMs > 500 {
		s.DelayMs = 0
	}
	enabled := "0"
	if s.Enabled {
		enabled = "1"
	}
	flexSpots := "0"
	if s.FlexSpots {
		flexSpots = "1"
	}
	flexDVKDax := "0"
	if s.FlexDVKDax {
		flexDVKDax = "1"
	}
	offsetOn := "0"
	if s.OffsetOn {
		offsetOn = "1"
	}
	flexDecodeSpots := "0"
	if s.FlexDecodeSpots {
		flexDecodeSpots = "1"
	}
	if s.FlexDecodeSecs < 10 || s.FlexDecodeSecs > 3600 {
		s.FlexDecodeSecs = 120
	}
	tciSpots := "0"
	if s.TCISpots {
		tciSpots = "1"
	}
	icomNetAudio := "0"
	if s.IcomNetAudio {
		icomNetAudio = "1"
	}
	if s.DigitalDefault == "" {
		s.DigitalDefault = "FT8"
	}
	b01 := func(v bool) string {
		if v {
			return "1"
		}
		return "0"
	}
	shareEnabled := "0"
	if s.ShareEnabled {
		shareEnabled = "1"
	}
	for k, v := range map[string]string{
		keyCATEnabled:          enabled,
		keyCATBackend:          s.Backend,
		keyCATOmniRigNum:       strconv.Itoa(s.OmniRigNum),
		keyCATOmniRigVFO:       strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
		keyCATDigiUSB:          boolStr(s.DigiAsUSB),
		keyCATFlexHost:         strings.TrimSpace(s.FlexHost),
		keyCATFlexPort:         strconv.Itoa(s.FlexPort),
		keyCATFlexSpots:        flexSpots,
		keyCATFlexDVKDax:       flexDVKDax,
		keyCATFlexDecodeSpots:  flexDecodeSpots,
		keyCATFlexDecodeSecs:   strconv.Itoa(s.FlexDecodeSecs),
		keyCATXieguPort:        strings.TrimSpace(s.XieguPort),
		keyCATXieguBaud:        strconv.Itoa(s.XieguBaud),
		keyCATXieguAddr:        strconv.Itoa(s.XieguAddr),
		keyCATXieguPTTLine:     strings.ToLower(strings.TrimSpace(s.XieguPTTLine)),
		keyCATYaesuPort:        strings.TrimSpace(s.YaesuPort),
		keyCATYaesuBaud:        strconv.Itoa(s.YaesuBaud),
		keyCATKenwoodPort:      strings.TrimSpace(s.KenwoodPort),
		keyCATKenwoodHost:      strings.TrimSpace(s.KenwoodHost),
		keyCATKenwoodLink:      kenwoodLinkOr(s.KenwoodLink, s.KenwoodHost),
		keyCATKenwoodBaud:      strconv.Itoa(s.KenwoodBaud),
		keyCATYaesuLowLines:    b01(s.YaesuLowLines),
		keyCATYaesuRTTYUSB:     b01(s.YaesuRTTYUSB),
		keyCATKenwoodLowLines:  b01(s.KenwoodLowLines),
		keyCATKenwoodDataMode:  strings.ToLower(strings.TrimSpace(s.KenwoodDataMode)),
		keyCATIcomPort:         strings.TrimSpace(s.IcomPort),
		keyCATIcomBaud:         strconv.Itoa(s.IcomBaud),
		keyCATIcomAddr:         strconv.Itoa(s.IcomAddr),
		keyCATIcomNetHost:      strings.TrimSpace(s.IcomNetHost),
		keyCATIcomNetUser:      strings.TrimSpace(s.IcomNetUser),
		keyCATIcomNetPass:      s.IcomNetPass,
		keyCATIcomNetAudio:     icomNetAudio,
		keyCATTCIHost:          strings.TrimSpace(s.TCIHost),
		keyCATTCIPort:          strconv.Itoa(s.TCIPort),
		keyCATTCISpots:         tciSpots,
		keyCATPttHotkeyEnabled: b01(s.PTTHotkeyEnabled),
		keyCATPttHotkey:        strings.TrimSpace(s.PTTHotkey),
		keyCATPttHotkeyToggle:  b01(s.PTTHotkeyToggle),
		keyCATPollMs:           strconv.Itoa(s.PollMs),
		keyCATDelayMs:          strconv.Itoa(s.DelayMs),
		keyCATOffsetOn:         offsetOn,
		keyCATOffsetHz:         strconv.FormatInt(s.OffsetHz, 10),
		keyCATDigitalDefault:   strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
		keyCATShareEnabled:     shareEnabled,
		keyCATSharePort:        strconv.Itoa(s.SharePort),
		keyCATShareProto:       s.ShareProto,
		keyCATShareTCIPort:     strconv.Itoa(s.ShareTCIPort),
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	// The saved radio follows what was just edited.
	//
	// Without this, an operator with two rigs edits the one on the air, switches
	// to the other and back, and finds the edit gone — the list would still hold
	// what that entry looked like when it was created. See app_radios.go.
	a.syncActiveRadio(s)
	a.restartAsync("cat", a.reloadCAT)
	return nil
}

// ── Audio (Digital Voice Keyer + QSO recorder) ────────────────────────

// AudioSettings is the machine-local audio config for the voice keyer and
// the QSO recorder.
type AudioSettings struct {
	FromRadio       string `json:"from_radio"`       // capture id: rig RX audio
	ToRadio         string `json:"to_radio"`         // render id: into the rig
	RecordingDevice string `json:"recording_device"` // capture id: your mic
	ListeningDevice string `json:"listening_device"` // render id: preview
	QSORecord       bool   `json:"qso_record"`       // auto-record every QSO
	QSODir          string `json:"qso_dir"`          // recordings folder
	PrerollSeconds  int    `json:"preroll_seconds"`  // rolling pre-roll (default 8)
	PTTMethod       string `json:"ptt_method"`       // "none" (VOX) | "rts" | "dtr"
	PTTPort         string `json:"ptt_port"`         // COM port for serial PTT
	// PTTData: the keyer's audio reaches the radio on its DATA/USB input, not
	// the microphone socket. CAT keying only — it changes which transmit
	// command is sent (a Kenwood TS-590 takes TX1 instead of TX).
	PTTData     bool   `json:"ptt_data"`
	Format      string `json:"format"`        // "wav" | "mp3"
	FromGain    int    `json:"from_gain"`     // From Radio (RX) mix level %, default 100
	MicGain     int    `json:"mic_gain"`      // mic mix level %, default 100
	TXGain      int    `json:"tx_gain"`       // voice-keyer playback level %, default 100
	QSOPlayGain int    `json:"qso_play_gain"` // QSO-recording playback level %, default 100
}

// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
// for the device dropdowns.
// ListAudioInputDevices lists the microphones and line inputs, plus THE RADIO
// when the CAT link carries its receive audio.
//
// Same reasoning as the output list: over TCI there is no sound device for
// Windows to show, so without this the one correct answer to "where does the
// received audio come from" could not be chosen at all.
func (a *App) ListAudioInputDevices() ([]audio.Device, error) {
	devs, err := audio.ListInputDevices()
	if err != nil {
		return devs, err
	}
	if a.tciAudioAvailable() {
		devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (network audio)"}}, devs...)
	}
	return devs, nil
}

// tciAudioAvailable says whether the active CAT backend is a radio that streams
// its audio over the CAT link.
func (a *App) tciAudioAvailable() bool {
	if a.cat == nil {
		return false
	}
	_, ok := a.cat.TCIAudioState()
	return ok
}

// ListAudioOutputDevices lists the sound cards, plus THE RADIO ITSELF when the
// CAT link can carry transmit audio.
//
// Offered only while it is actually available, and named as a radio rather than
// as a protocol: an operator choosing where their voice goes is picking between
// "my sound card" and "the radio", not between WASAPI and TCI.
func (a *App) ListAudioOutputDevices() ([]audio.Device, error) {
	devs, err := audio.ListOutputDevices()
	if err != nil {
		return devs, err
	}
	if audio.NetworkPlayerReady() {
		devs = append([]audio.Device{{ID: audio.NetworkDeviceID, Name: "Radio (network audio)"}}, devs...)
	}
	return devs, nil
}

// GetAudioSettings returns the stored audio config (preroll defaults to 8s).
func (a *App) GetAudioSettings() (AudioSettings, error) {
	out := AudioSettings{PrerollSeconds: 8, PTTMethod: "none", Format: "wav", FromGain: 100, MicGain: 100, TXGain: 100, QSOPlayGain: 100}
	if a.settings == nil {
		return out, nil
	}
	m, err := a.settings.GetMany(a.ctx,
		keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
		keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioPTTData, keyAudioFormat,
		keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
	if err != nil {
		return out, err
	}
	if v := m[keyAudioFormat]; v == "mp3" || v == "wav" {
		out.Format = v
	}
	if v := m[keyAudioPTTMethod]; v == "rts" || v == "dtr" || v == "cat" || v == "none" {
		out.PTTMethod = v
	}
	out.PTTPort = m[keyAudioPTTPort]
	out.PTTData = m[keyAudioPTTData] == "1"
	out.FromRadio = m[keyAudioFromRadio]
	out.ToRadio = m[keyAudioToRadio]
	out.RecordingDevice = m[keyAudioRecDevice]
	out.ListeningDevice = m[keyAudioListenDevice]
	out.QSORecord = m[keyAudioQSORecord] == "1"
	out.QSODir = m[keyAudioQSODir]
	if n, _ := strconv.Atoi(m[keyAudioPreroll]); n >= 0 && n <= 60 {
		if n > 0 {
			out.PrerollSeconds = n
		}
	}
	if n, _ := strconv.Atoi(m[keyAudioFromGain]); n > 0 && n <= 400 {
		out.FromGain = n
	}
	if n, _ := strconv.Atoi(m[keyAudioMicGain]); n > 0 && n <= 400 {
		out.MicGain = n
	}
	if n, _ := strconv.Atoi(m[keyAudioTXGain]); n > 0 && n <= 400 {
		out.TXGain = n
	}
	if n, _ := strconv.Atoi(m[keyAudioQSOPlayGain]); n > 0 && n <= 400 {
		out.QSOPlayGain = n
	}
	return out, nil
}

// SaveAudioSettings persists the audio config.
func (a *App) SaveAudioSettings(s AudioSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if s.PrerollSeconds < 0 || s.PrerollSeconds > 60 {
		s.PrerollSeconds = 8
	}
	qr := "0"
	if s.QSORecord {
		qr = "1"
	}
	pttMethod := s.PTTMethod
	if pttMethod != "rts" && pttMethod != "dtr" && pttMethod != "cat" {
		pttMethod = "none"
	}
	format := s.Format
	if format != "mp3" {
		format = "wav"
	}
	if s.FromGain <= 0 || s.FromGain > 400 {
		s.FromGain = 100
	}
	if s.MicGain <= 0 || s.MicGain > 400 {
		s.MicGain = 100
	}
	// Up to 400 %: a mic recorded quietly needs real amplification, and the
	// clamp in the player keeps it from wrapping into noise.
	if s.QSOPlayGain <= 0 || s.QSOPlayGain > 400 {
		s.QSOPlayGain = 100
	}
	if s.TXGain <= 0 || s.TXGain > 400 {
		s.TXGain = 100
	}
	for k, v := range map[string]string{
		keyAudioFromRadio:    s.FromRadio,
		keyAudioToRadio:      s.ToRadio,
		keyAudioRecDevice:    s.RecordingDevice,
		keyAudioListenDevice: s.ListeningDevice,
		keyAudioQSORecord:    qr,
		keyAudioQSODir:       strings.TrimSpace(s.QSODir),
		keyAudioPreroll:      strconv.Itoa(s.PrerollSeconds),
		keyAudioPTTMethod:    pttMethod,
		keyAudioPTTPort:      strings.TrimSpace(s.PTTPort),
		keyAudioPTTData:      boolStr(s.PTTData),
		keyAudioFormat:       format,
		keyAudioFromGain:     strconv.Itoa(s.FromGain),
		keyAudioMicGain:      strconv.Itoa(s.MicGain),
		keyAudioTXGain:       strconv.Itoa(s.TXGain),
		keyAudioQSOPlayGain:  strconv.Itoa(s.QSOPlayGain),
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	// Choosing the radio as the receive device opens its stream, and choosing
	// anything else closes it. Done HERE rather than left to the next restart:
	// a device chosen in a dropdown that only takes effect after a relaunch
	// reads as a device that does not work.
	// The transmit source follows the "To radio" device, and is applied now:
	// the radio is told which input to use at the moment it is keyed, so the
	// setting has to be right before the next message rather than after the
	// next restart.
	a.applyTCITXSource()
	if s.FromRadio == audio.NetworkDeviceID {
		a.startTCIRecording()
	} else if a.tciAudioAvailable() {
		_ = a.cat.TCIAudioDo(func(t cat.TCIAudioController) error { return t.StopTCIAudio() })
	}
	// Apply device/preroll/enable changes to the running recorder.
	a.startQSORecorderIfEnabled()
	// And to a monitor ALREADY RUNNING: the operator is listening while they
	// move the slider. Making them stop and restart the monitor to hear the
	// change is how a working control gets reported as doing nothing.
	if a.audioMgr != nil {
		a.audioMgr.SetMonitorGain(s.FromGain)
	}
	return nil
}

// PickAudioFolder opens a directory picker for the QSO-recordings folder.
func (a *App) PickAudioFolder() (string, error) {
	if a.ctx == nil {
		return "", fmt.Errorf("no app context")
	}
	cur, _ := a.GetAudioSettings()
	return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
		Title:            "Pick a folder for QSO recordings",
		DefaultDirectory: firstExistingAncestor(cur.QSODir),
	})
}

// ── QSO recorder ──────────────────────────────────────────────────────

// startQSORecorderIfEnabled (re)starts the continuous recorder per the current
// settings. Safe to call repeatedly — it stops any running instance first.
func (a *App) startQSORecorderIfEnabled() {
	if a.qsoRec == nil {
		return
	}
	a.qsoRec.Stop()
	cfg, _ := a.GetAudioSettings()
	if !cfg.QSORecord {
		return
	}
	// Over the network there IS no radio sound device.
	//
	// An IC-705 or IC-7610 reached by LAN streams its receive audio through the
	// Icom protocol, and Windows sees nothing: the operator's audio settings can
	// only offer the PC's own microphone and speakers, so "From radio" has
	// nothing right to point at. The stream is pushed into the recorder instead
	// — same samples, no sound card in the middle, and no virtual cable to set up.
	from := cfg.FromRadio
	a.qsoRecPushed = a.icomNetAudioActive() || cfg.FromRadio == audio.NetworkDeviceID
	if a.qsoRecPushed {
		from = audio.PushedSource
	}
	if err := a.qsoRec.Start(from, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
		applog.Printf("qso-rec: start failed: %v", err)
		return
	}
	fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
	if cfg.FromGain == 0 {
		fromGain = 1
	}
	if cfg.MicGain == 0 {
		micGain = 1
	}
	a.qsoRec.SetGains(fromGain, micGain)
	applog.Printf("qso-rec: running (preroll %ds, mix=%v, gains rx=%.2f mic=%.2f)", cfg.PrerollSeconds, cfg.RecordingDevice != "" && cfg.RecordingDevice != cfg.FromRadio, fromGain, micGain)
}

// icomNetAudioActive reports whether the receive audio is arriving over the
// network rather than from a sound card — the Icom LAN backend with its audio
// option on.
func (a *App) icomNetAudioActive() bool {
	s, err := a.GetCATSettings()
	if err != nil {
		return false
	}
	return s.Enabled && s.Backend == "icom-net" && s.IcomNetAudio
}

// qsoRecDir returns the configured recordings folder, defaulting to
// <dataDir>/Recordings, and ensures it exists.
func (a *App) qsoRecDir() string {
	cfg, _ := a.GetAudioSettings()
	d := strings.TrimSpace(cfg.QSODir)
	if d == "" {
		d = filepath.Join(a.dataDir, "Recordings")
	}
	_ = os.MkdirAll(d, 0o755)
	return d
}

// saveQSORecording finalises the active recording (if any) into a file named
// CALL_BAND_MODE_YYYYMMDD_HHMMSS.ext, stores the filename on the QSO (so it can
// be e-mailed later), and auto-sends it to the contacted operator when enabled
// and an e-mail is known. Called right after a QSO is inserted (manual + UDP);
// q must have its ID set.
// recordableMode reports whether a QSO mode is worth an audio recording: voice
// and CW. Digital modes (FT8/FT4/RTTY/PSK/JT…) carry only modem tones, which
// nobody will ever replay, so they are never recorded.
//
// CW was excluded for a while because SmartSDR does not route the operator's own
// sidetone through DAX, making the recording sound one-sided. The audio path is
// open all the same and the other station IS captured — which is the half worth
// keeping. Operator's call.
func recordableMode(mode string) bool {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "SSB", "USB", "LSB", "AM", "FM", "DV", "CW":
		return true
	}
	return false
}

func (a *App) saveQSORecording(q *qso.QSO) {
	// The logging path must never die because of the recorder. Recover any
	// Go-level panic in the snapshot/stamp work below (the encode already runs
	// in its own recovered goroutine).
	defer func() {
		if r := recover(); r != nil {
			applog.Printf("qso-rec: PANIC in saveQSORecording: %v", r)
		}
	}()
	// Every exit from here ends the take, including the digital-mode discard and
	// the snapshot failure below — so release the devices from one place rather
	// than remembering to do it at each return.
	defer a.stopManualQSORecorder()
	// Both of these were SILENT returns. When an operator reports "the recording
	// was not saved", the log has to say which of the two happened — no take was
	// running, or the mode disqualified it — because they are different problems
	// with different fixes and neither leaves a file behind to inspect.
	if a.qsoRec == nil || !a.qsoRec.Active() {
		applog.Printf("qso-rec: nothing saved for %s — no recording was running at log time", q.Callsign)
		return
	}
	if !recordableMode(q.Mode) {
		applog.Printf("qso-rec: nothing saved for %s — mode %q carries no audio worth keeping", q.Callsign, q.Mode)
		a.qsoRec.DiscardQSO() // digital mode — drop the buffered audio
		return
	}
	ext := "wav"
	if cfg, _ := a.GetAudioSettings(); cfg.Format == "mp3" {
		ext = "mp3"
	}
	parts := []string{sanitizeFilename(q.Callsign)}
	if b := strings.TrimSpace(q.Band); b != "" {
		parts = append(parts, sanitizeFilename(b))
	}
	if m := strings.TrimSpace(q.Mode); m != "" {
		parts = append(parts, sanitizeFilename(m))
	}
	parts = append(parts, time.Now().UTC().Format("20060102_150405"))
	name := strings.Join(parts, "_") + "." + ext
	path := filepath.Join(a.qsoRecDir(), name)

	// Snapshot the audio synchronously (fast — frees the recorder for the next
	// QSO). The slow part is encoding the file (a long MP3), which we defer to a
	// goroutine so logging stays snappy.
	pcm, err := a.qsoRec.TakeQSO()
	if err != nil {
		applog.Printf("qso-rec: snapshot failed: %v", err)
		return
	}

	// Stamp the recording's path on the in-memory QSO now (the encode goroutine
	// reads it). The DB write is deferred into that goroutine so this stays OFF
	// the critical logging path — saveQSORecording is now called synchronously.
	if q.ID != 0 {
		if q.Extras == nil {
			q.Extras = map[string]string{}
		}
		q.Extras["APP_OPSLOG_RECORDING"] = name
	}

	// Clone the QSO for the goroutine with its OWN copy of the Extras map: a
	// struct copy (qc := *q) would still share the underlying map, and a
	// concurrent write from a post-log action (eQSL/upload status) racing this
	// goroutine's read is a FATAL "concurrent map" error that no recover catches.
	qc := *q
	if q.Extras != nil {
		qc.Extras = make(map[string]string, len(q.Extras))
		for k, v := range q.Extras {
			qc.Extras[k] = v
		}
	}
	go func() {
		// A panic in the pure-Go MP3 encoder (or anywhere here) must NOT crash
		// the whole app — recover, log it, and drop just this recording.
		defer func() {
			if r := recover(); r != nil {
				applog.Printf("qso-rec: PANIC encoding %s: %v", path, r)
			}
		}()
		if err := audio.WritePCM(path, pcm); err != nil {
			applog.Printf("qso-rec: save failed: %v", err)
			return
		}
		// Persist the recording path (targeted extras write) now that the file
		// exists — off the critical path, so a busy MySQL never delays logging.
		if qc.ID != 0 {
			if err := a.qso.SetExtra(a.ctx, qc.ID, "APP_OPSLOG_RECORDING", name); err != nil {
				applog.Printf("qso-rec: store recording path: %v", err)
			}
		}
		applog.Printf("qso-rec: saved %s", path)
		// Auto-send the recording once the file exists. Gated ONLY on its own
		// "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
		// master "Enabled" flag, mirroring the eQSL-card auto-send. (Requiring
		// Enabled silently skipped sending with no log when only this box was on.)
		// Give the operator visible feedback either way.
		if es, _ := a.GetEmailSettings(); es.AutoSend {
			if strings.TrimSpace(qc.Email) == "" {
				applog.Printf("qso-rec: %s not e-mailed — no recipient address known", qc.Callsign)
				a.toast(fmt.Sprintf("Recording saved — no e-mail for %s, not sent", qc.Callsign))
			} else if err := a.sendRecordingEmail(qc, path); err != nil {
				a.toast(fmt.Sprintf("Recording e-mail to %s failed", qc.Callsign))
			} else {
				a.toast(fmt.Sprintf("Recording e-mailed to %s", qc.Callsign))
				a.markRecordingSent(qc.ID)
			}
		}
	}()
}

// toast emits a transient message the frontend shows as a toast notification.
// Used for background events (auto-send results) the UI can't otherwise surface.
func (a *App) toast(msg string) {
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "toast", msg)
	}
}

// ── DX-cluster spot alerts ─────────────────────────────────────────────────

const keyAlertEmailTo = "alerts.email_to" // where alert e-mails are sent (default: SMTP from)

// ListAlertRules returns every alert rule.
func (a *App) ListAlertRules() []alerts.Rule {
	if a.alertStore == nil {
		return nil
	}
	return a.alertStore.List()
}

// SaveAlertRule upserts a rule (empty id → create) and returns it.
func (a *App) SaveAlertRule(r alerts.Rule) (alerts.Rule, error) {
	if a.alertStore == nil {
		return alerts.Rule{}, fmt.Errorf("alert store unavailable")
	}
	return a.alertStore.Save(r)
}

// DeleteAlertRule removes a rule by id.
func (a *App) DeleteAlertRule(id string) error {
	if a.alertStore == nil {
		return fmt.Errorf("alert store unavailable")
	}
	return a.alertStore.Delete(id)
}

// GetAlertEmailTo returns the address alert e-mails go to (empty = use SMTP from).
func (a *App) GetAlertEmailTo() string {
	if a.settings == nil {
		return ""
	}
	v, _ := a.settings.GetGlobal(a.ctx, keyAlertEmailTo)
	return v
}

// SetAlertEmailTo stores the alert e-mail recipient.
func (a *App) SetAlertEmailTo(addr string) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	return a.settings.SetGlobal(a.ctx, keyAlertEmailTo, strings.TrimSpace(addr))
}

// evaluateAlerts runs a spot through the rules and fires notifications for every
// match (visual/sound via a frontend event, e-mail via SMTP). Cheap when no
// rules exist; the worked-before check only runs for rules that opt into it and
// already matched, so it's off the hot path.
// clusterEvent is one item off the cluster feed — a parsed spot OR a raw console
// line. A single union type on one channel preserves the order lines and spots
// arrived in (the console shows a line, then its parsed spot), which two separate
// channels could not guarantee.
type clusterEvent struct {
	spot *cluster.Spot
	line *cluster.Line
}

// clusterQueue is an unbounded FIFO between the socket-read goroutines (producers,
// which must never block or the TCP feed stalls) and the single clusterEventWorker
// (consumer). Unlike a bounded channel it NEVER drops an event: a burst just grows
// the backlog, which drains once enrichment catches up.
type clusterQueue struct {
	mu     sync.Mutex
	cond   *sync.Cond
	buf    []clusterEvent
	head   int // index of the next event to pop (waste reclaimed by compaction)
	closed bool
}

func newClusterQueue() *clusterQueue {
	q := &clusterQueue{}
	q.cond = sync.NewCond(&q.mu)
	return q
}

// push appends an event and wakes the worker. Amortised O(1); never blocks, never
// drops.
func (q *clusterQueue) push(ev clusterEvent) {
	q.mu.Lock()
	if q.closed {
		q.mu.Unlock()
		return
	}
	q.buf = append(q.buf, ev)
	q.mu.Unlock()
	q.cond.Signal()
}

// pop blocks until an event is available, returning ok=false only once the queue
// is closed AND fully drained.
func (q *clusterQueue) pop() (clusterEvent, bool) {
	q.mu.Lock()
	for q.head == len(q.buf) && !q.closed {
		q.cond.Wait()
	}
	if q.head == len(q.buf) { // closed and drained
		q.mu.Unlock()
		return clusterEvent{}, false
	}
	ev := q.buf[q.head]
	q.buf[q.head] = clusterEvent{} // release pointers held by the consumed slot
	q.head++
	switch {
	case q.head == len(q.buf):
		// Fully drained → reset to reuse the backing array from the front.
		q.buf = q.buf[:0]
		q.head = 0
	case q.head > 1024 && q.head*2 >= len(q.buf):
		// Head waste is large → compact so a long sustained burst doesn't grow
		// the backing array without bound.
		n := copy(q.buf, q.buf[q.head:])
		for i := n; i < len(q.buf); i++ {
			q.buf[i] = clusterEvent{}
		}
		q.buf = q.buf[:n]
		q.head = 0
	}
	q.mu.Unlock()
	return ev, true
}

// close wakes any waiting consumer so it can exit once the backlog is drained.
func (q *clusterQueue) close() {
	q.mu.Lock()
	q.closed = true
	q.mu.Unlock()
	q.cond.Broadcast()
}

// enqueueClusterEvent hands an event to the worker WITHOUT blocking and WITHOUT
// dropping. It runs on the cluster session's socket-read goroutine: blocking here
// would stop draining the TCP socket, the node's send buffer would fill, and the
// feed would fall behind — the exact bug this indirection fixes. The queue is
// unbounded, so a burst grows the backlog rather than losing a spot.
func (a *App) enqueueClusterEvent(ev clusterEvent) {
	a.clusterEvents.push(ev)
}

// clusterEventWorker drains clusterEventCh and does everything that used to run
// inline in the socket-read callback: enrich a spot (DXCC/POTA/distance), emit it
// to the UI, run alert rules (which may query a remote MySQL) and mirror it to the
// Flex panadapter — all serialised on this one goroutine, off the read path.
func (a *App) clusterEventWorker() {
	for {
		ev, ok := a.clusterEvents.pop()
		if !ok {
			return
		}
		if ev.line != nil {
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "cluster:line", *ev.line)
			}
			continue
		}
		if ev.spot == nil {
			continue
		}
		s := *ev.spot
		if a.dxcc != nil {
			// The SPOTTER's continent, resolved here rather than in the per-call
			// status cache. That cache is keyed by call|band|mode, and one DX call
			// is spotted by skimmers all over the world within the same minute —
			// so all of them collapsed onto whichever spotter happened to arrive
			// first, and the continent filter passed every one of them. It is a
			// property of the spot and now travels with it.
			//
			// The skimmer suffix goes first: RBN reports as "VU2OY-#" and cluster
			// nodes as "DL1ABC-2", which the prefix matcher cannot resolve.
			if sp := skimmerBase(s.Spotter); sp != "" {
				if m, ok := a.dxcc.Lookup(sp); ok {
					s.SpotterContinent = m.Continent
				}
			}
			if m, ok := a.dxcc.Lookup(s.DXCall); ok && m.Entity != nil {
				s.Country = m.Entity.Name
				s.Continent = m.Continent
				s.CQZone = m.CQZone
				s.ITUZone = m.ITUZone
				if a.opSet && (m.Lat != 0 || m.Lon != 0) {
					s.DistanceKm = int(haversineKm(a.opLat, a.opLon, m.Lat, m.Lon) + 0.5)
					sp := initialBearingDeg(a.opLat, a.opLon, m.Lat, m.Lon)
					s.ShortPath = int(sp + 0.5)
					s.LongPath = (s.ShortPath + 180) % 360
				}
			}
		}
		// SOTA: the summit is in the spot's own text — the SOTA feeds put it
		// there — so it costs a regex rather than an API lookup.
		if s.SOTARef == "" {
			s.SOTARef = cluster.SOTARefFrom(s.Comment)
		}
		// POTA: tag the spot when the DX station is currently activating a park.
		if a.pota != nil {
			if info, ok := a.pota.Lookup(s.DXCall); ok {
				s.POTARef = info.Reference
				s.POTAName = info.ParkName
			}
		}
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "cluster:spot", s)
		}
		// A HISTORICAL spot (recovered from a SH/DX table) goes to the grid and
		// stops there. It is a replay of the past: firing 100 alerts at once, or
		// painting 100 stale stations on the panadapter as if they were on the air
		// right now, would be actively misleading.
		if s.Historical {
			continue
		}
		// Band-opening detector. Deliberately AFTER the Historical guard above: a
		// SH/DX reply replays a hundred past spots in a second, which is exactly
		// the shape of a burst and would announce an opening that ended hours ago.
		a.detectBandOpening(s)
		// Fire any matching alert rules (sound / visual / e-mail).
		a.evaluateAlerts(s)
		// The watchlist sees the same live stream the alerts do — never the
		// SH/DX replay above, which would mark forty stations "just seen" with
		// spots from three hours ago.
		a.watchSpot(s.DXCall, s.Band, alerts.InferMode(s.Comment, s.FreqHz), s.Country, s.Comment, s.FreqHz)
		// Mirror the spot onto the FlexRadio panadapter when enabled. Infer the
		// mode (from the comment, else the band plan) so clicking the spot on the
		// panadapter tunes AND switches mode — a DX cluster line carries no mode,
		// so without this the radio only moved frequency.
		if a.catFlexSpots && a.cat != nil {
			mode := alerts.InferMode(s.Comment, s.FreqHz)
			// Coloured by what the spot IS, which is the only thing worth knowing
			// at a glance on a waterfall. Resolved from the same index the cluster
			// grid uses, so a spot is never one colour on screen and another on
			// the radio — and the index is cached, so this costs a map lookup.
			status := a.spotStatusName(s.DXCall, s.Band, mode, s.POTARef)
			// A status the operator has switched off never reaches the radio. A
			// skipped SEND rather than a transparent colour: a spot that is not
			// drawn should not occupy one of the panadapter's slots either.
			if !a.spotHidden(status) {
				col := a.spotColorFor(status)
				a.cat.SendSpot(cat.SpotInfo{
					FreqHz:          s.FreqHz,
					Callsign:        s.DXCall,
					Mode:            mode,
					Priority:        spotPriority(status),
					Comment:         spotComment(s.Comment, s.Spotter, s.Country, status),
					Color:           col.Text,
					BackgroundColor: col.Bg,
				})
			}
		}
	}
}

// skimmerBase strips the reporting suffix from a spotter callsign: RBN skimmers
// announce as "VU2OY-#" and cluster nodes as "DL1ABC-2". The DXCC prefix matcher
// sees an unknown callsign and gives up on those, which is how the spotter's
// continent came back empty for every RBN spot. A real callsign never contains a
// hyphen, so cutting at the first one is safe.
func skimmerBase(spotter string) string {
	s := strings.TrimSpace(spotter)
	if i := strings.IndexByte(s, '-'); i > 0 {
		s = s[:i]
	}
	return s
}

func (a *App) evaluateAlerts(s cluster.Spot) {
	if a.alertStore == nil {
		return
	}
	sp := alerts.Spot{
		DXCall:    s.DXCall,
		Band:      s.Band,
		Mode:      alerts.InferMode(s.Comment, s.FreqHz),
		Country:   s.Country,
		Continent: s.Continent,
		Spotter:   s.Spotter,
	}
	// The spotter's country/continent (cty.dat) — resolved lazily for Origin rules.
	// Through skimmerBase for the same reason the cluster filter needs it: an
	// Origin rule on "spotter continent = EU" never matched an RBN spot, because
	// "DL1ABC-#" resolves to nothing.
	if a.dxcc != nil {
		if base := skimmerBase(s.Spotter); base != "" {
			if m, ok := a.dxcc.Lookup(base); ok && m.Entity != nil {
				sp.SpotterCountry = m.Entity.Name
				sp.SpotterContinent = m.Continent
			}
		}
	}
	matches := a.alertStore.Evaluate(sp, time.Now(), a.isWorkedBandMode)
	for _, mt := range matches {
		if a.ctx != nil && (mt.Rule.Visual || mt.Rule.Sound) {
			wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{
				"rule":    mt.Rule.Name,
				"call":    s.DXCall,
				"band":    s.Band,
				"mode":    sp.Mode,
				"freq_hz": s.FreqHz,
				"country": s.Country,
				"comment": s.Comment,
				"sound":   mt.Rule.Sound,
				"visual":  mt.Rule.Visual,
			})
		}
		if mt.Rule.Email {
			go a.sendAlertEmail(mt.Rule, s, sp.Mode)
		}
	}
}

// isWorkedBandMode reports whether the exact callsign is already logged on the
// given band+mode (used by rules with "skip worked before").
// wcbmKey builds the worked-index key: "CALL|BAND|MODE", all upper-cased.
func wcbmKey(call, band, mode string) string {
	return strings.ToUpper(strings.TrimSpace(call)) + "|" +
		strings.ToUpper(strings.TrimSpace(band)) + "|" +
		strings.ToUpper(strings.TrimSpace(mode))
}

// rebuildWorkedIndex loads the whole "CALL|BAND|MODE" worked-index into memory in
// one pass. Called at startup and after bulk changes (import, delete, bulk edit).
func (a *App) rebuildWorkedIndex() {
	if a.qso == nil {
		return
	}
	m, err := a.qso.WorkedCallBandModeKeys(a.ctx)
	if err != nil {
		applog.Printf("worked-index: rebuild failed: %v", err)
		return
	}
	a.wcbmMu.Lock()
	a.wcbm = m
	a.wcbmMu.Unlock()
}

// noteWorked adds a just-logged QSO to the in-memory worked-index, so an alert
// rule sees it as worked immediately without a full rebuild.
func (a *App) noteWorked(call, band, mode string) {
	if strings.TrimSpace(call) == "" {
		return
	}
	a.wcbmMu.Lock()
	// nil means "not built yet", and isWorkedBandMode reads that as its cue to
	// load the whole log. Seeding a one-entry map here would look built and
	// answer "never worked" for everything else. The row is already inserted, so
	// the later rebuild picks this contact up anyway.
	if a.wcbm != nil {
		a.wcbm[wcbmKey(call, band, mode)] = struct{}{}
	}
	a.wcbmMu.Unlock()
	// The cluster worked-index snapshot is now stale (this call/slot just became
	// worked) — drop it so the next spot batch recolours with the new QSO.
	a.clusterStatusMu.Lock()
	a.clusterStatusIdx = nil
	a.clusterStatusMu.Unlock()
}

// isWorkedBandMode reports whether this exact call+band+mode is in the log,
// answered from the in-memory index (no DB round-trip per spot). If the index
// hasn't loaded yet it lazily builds it once.
func (a *App) isWorkedBandMode(call, band, mode string) bool {
	if a.qso == nil || strings.TrimSpace(call) == "" {
		return false
	}
	a.wcbmMu.RLock()
	ready := a.wcbm != nil
	a.wcbmMu.RUnlock()
	if !ready {
		a.rebuildWorkedIndex()
	}
	a.wcbmMu.RLock()
	_, ok := a.wcbm[wcbmKey(call, band, mode)]
	a.wcbmMu.RUnlock()
	return ok
}

// sendAlertEmail notifies the operator by e-mail that a wanted station was
// spotted. Best effort; requires SMTP configured (E-mail settings).
func (a *App) sendAlertEmail(r alerts.Rule, s cluster.Spot, mode string) {
	es, _ := a.GetEmailSettings()
	to := strings.TrimSpace(a.GetAlertEmailTo())
	if to == "" {
		to = strings.TrimSpace(es.From)
	}
	if to == "" {
		applog.Printf("alert: e-mail skipped (no recipient / SMTP from) for rule %q", r.Name)
		return
	}
	subject := fmt.Sprintf("OpsLog alert: %s spotted on %s", s.DXCall, s.Band)
	body := fmt.Sprintf("Rule: %s\n\nCall: %s\nBand: %s\nMode: %s\nFreq: %.1f kHz\nCountry: %s\nSpotter: %s\nComment: %s\n",
		r.Name, s.DXCall, s.Band, mode, float64(s.FreqHz)/1000, s.Country, s.Spotter, s.Comment)
	if err := email.Send(a.emailConfig(es), to, subject, body, ""); err != nil {
		applog.Printf("alert: e-mail to %s failed: %v", to, err)
	} else {
		applog.Printf("alert: e-mailed %s (rule %q) to %s", s.DXCall, r.Name, to)
	}
}

// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string {
	s = strings.ToUpper(strings.TrimSpace(s))
	if s == "" {
		s = "QSO"
	}
	repl := func(r rune) rune {
		switch r {
		case '/', '\\', ':', '*', '?', '"', '<', '>', '|', ' ':
			return '_'
		}
		return r
	}
	return strings.Map(repl, s)
}

// QSOAudioBegin starts accumulating a QSO recording (seeded with the pre-roll).
// Called by the entry strip when a callsign is first entered.
// QSOAudioBegin starts accumulating a recording for the current QSO. It
// returns true when a recording is actually running (recorder enabled and
// capturing), so the UI can show a "REC" indicator.
func (a *App) QSOAudioBegin() bool {
	if a.qsoRec == nil {
		return false
	}
	a.qsoRec.BeginQSO()
	return a.qsoRec.Active()
}

// QSOAudioRestart starts a fresh recording for a new target even if one is
// already in progress (new call+freq from a clicked spot or external app).
func (a *App) QSOAudioRestart() bool {
	if a.qsoRec == nil {
		return false
	}
	a.qsoRec.RestartQSO()
	return a.qsoRec.Active()
}

// QSOAudioResetClock restarts the in-progress recording from zero, dropping
// everything captured so far (pre-roll included). Lets the operator click the
// REC timer to record only their own exchange when the station was already in a
// long QSO before they entered the call. Returns whether a recording is active.
func (a *App) QSOAudioResetClock() bool {
	if a.qsoRec == nil {
		return false
	}
	a.qsoRec.ResetQSOClock()
	return a.qsoRec.Active()
}

// QSOAudioManualReady reports whether a recording can be started BY HAND: the
// capture devices are configured, but automatic recording is off.
//
// It is what decides whether the entry strip offers a record button. Without
// it the button would appear for operators who have no sound card configured
// and could only ever produce an error.
func (a *App) QSOAudioManualReady() bool {
	if a.qsoRec == nil {
		return false
	}
	cfg, _ := a.GetAudioSettings()
	// Over the network the radio needs no sound card: the 50003 stream is the
	// source, so the button is offered even with "From radio" empty.
	return !cfg.QSORecord && (strings.TrimSpace(cfg.FromRadio) != "" || a.icomNetAudioActive())
}

// QSOAudioManualStart begins a recording when automatic recording is off.
//
// With the automatic recorder disabled the capture engine is not merely idle,
// it is not running at all — so this starts it first. That costs the pre-roll:
// an automatic recording opens with the seconds BEFORE the callsign was typed,
// a manual one can only start now. Better to say that in a comment than to have
// an operator wonder why one file has the DX's call in it and the other does
// not.
func (a *App) QSOAudioManualStart() bool {
	if a.qsoRec == nil {
		return false
	}
	if !a.qsoRec.Running() {
		cfg, _ := a.GetAudioSettings()
		// Same source rule as the automatic recorder: when the RX audio arrives
		// over the network, record THAT — not the "From radio" sound card, which
		// on a mixed station is another radio entirely. A manual take on the
		// network IC-7760 was capturing the Flex's DAX: technically a recording,
		// just not of the QSO being made.
		from := cfg.FromRadio
		a.qsoRecPushed = a.icomNetAudioActive() || cfg.FromRadio == audio.NetworkDeviceID
		if a.qsoRecPushed {
			from = audio.PushedSource
		} else if strings.TrimSpace(from) == "" {
			return false
		}
		if err := a.qsoRec.Start(from, cfg.RecordingDevice, cfg.PrerollSeconds); err != nil {
			applog.Printf("qso-rec: manual start failed: %v", err)
			return false
		}
		fromGain, micGain := float64(cfg.FromGain)/100, float64(cfg.MicGain)/100
		if cfg.FromGain == 0 {
			fromGain = 1
		}
		if cfg.MicGain == 0 {
			micGain = 1
		}
		a.qsoRec.SetGains(fromGain, micGain)
		a.qsoRecManual.Store(true)
		applog.Printf("qso-rec: manual recording started by the operator")
	}
	a.qsoRec.BeginQSO()
	return a.qsoRec.Active()
}

// stopManualQSORecorder releases the sound devices after a hand-started take,
// but only if this session opened them. When automatic recording is on the
// engine belongs to the app and must keep running for the next contact.
func (a *App) stopManualQSORecorder() {
	if a.qsoRec == nil || !a.qsoRecManual.Swap(false) {
		return
	}
	a.qsoRec.Stop()
	applog.Printf("qso-rec: manual recording finished — sound devices released")
}

// QSOAudioStop freezes the recording without ending it, so it can be played
// back and still be saved with the QSO afterwards.
func (a *App) QSOAudioStop() bool {
	if a.qsoRec == nil {
		return false
	}
	return a.qsoRec.PauseQSO()
}

// QSOAudioResume continues a frozen recording, appending to what is there.
func (a *App) QSOAudioResume() bool {
	if a.qsoRec == nil {
		return false
	}
	return a.qsoRec.ResumeQSO()
}

// QSOAudioPlayOnAir transmits the recording made so far, through the DVK's
// output device and PTT.
//
// The situation this is for: you are working a station, you have been recording,
// and they ask to hear it. You stop, you send it to them, and the recording is
// still saved with the QSO.
//
// It goes out over the AIR. So it refuses unless the take has been stopped
// first: playing while still recording would feed the transmitted audio back
// into the same take, and on a rig monitoring its own transmission that is a
// loop. Stopping is the operator's statement that the take is finished, and it
// is one click they have already made.
func (a *App) QSOAudioPlayOnAir() error {
	// Every refusal below is LOGGED as well as returned. "I click play and
	// nothing happens" is what an operator sees when a function returns an error
	// into a promise the interface swallowed — and the difference between "no
	// output device configured" and "the take was empty" cannot be guessed from
	// the outside.
	if a.qsoRec == nil || a.audioMgr == nil {
		applog.Printf("qso-rec: play refused — audio subsystem not initialised")
		return fmt.Errorf("audio not initialized")
	}
	if !a.qsoRec.Paused() {
		applog.Printf("qso-rec: play refused — the recording is still running (stop it first)")
		return fmt.Errorf("stop the recording before playing it on the air")
	}
	pcm, err := a.qsoRec.PeekQSO()
	if err != nil {
		applog.Printf("qso-rec: play refused — %v", err)
		return err
	}
	cfgEarly, _ := a.GetAudioSettings()
	if strings.TrimSpace(cfgEarly.ToRadio) == "" {
		// The recorder needs an INPUT from the radio; playing back needs an
		// OUTPUT into it, which is a different device and may well be unset on a
		// station that only ever recorded.
		// Name the FIELD, not the concept. An operator who had just emptied
		// "Recording mic" read this as "you cannot play back unless you also
		// record yourself" — a restriction that does not exist. The mic is an
		// input; this is about the output that feeds the transmitter.
		applog.Printf("qso-rec: play refused — Settings → Audio → \"To Radio (TX out)\" is empty (from_radio=%q, mic=%q)",
			cfgEarly.FromRadio, cfgEarly.RecordingDevice)
		return fmt.Errorf("Settings → Audio → \"To Radio (TX out)\" is empty — that is the output that feeds the transmitter (the recording mic has nothing to do with playing back)")
	}
	// A plain WAV in the data dir, overwritten each time: the player takes a
	// path, and MP3 encoding would add seconds to something the operator is
	// waiting on with the transmitter keyed.
	path := filepath.Join(a.dataDir, "qso_playback.wav")
	if err := audio.WritePCM(path, pcm); err != nil {
		return fmt.Errorf("prepare playback: %w", err)
	}

	cfg := cfgEarly
	// Same as the voice keyer: this goes out over the air, so on a Flex the
	// transmit source has to be DAX before the carrier comes up.
	a.raiseFlexDVKDax()
	if err := a.pttKey(cfg); err != nil {
		applog.Printf("qso-rec: PTT on failed before playback: %v", err)
		// Keep going — the audio still reaches the rig and the operator may use VOX.
	} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
		a.pttMu.Lock()
		a.dvkPttKeyed = true
		a.pttMu.Unlock()
	}
	if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.QSOPlayGain); err != nil {
		applog.Printf("qso-rec: playback on %q failed: %v", cfg.ToRadio, err)
		a.pttMu.Lock()
		keyed := a.dvkPttKeyed
		gen := a.pttGen
		a.dvkPttKeyed = false
		a.pttMu.Unlock()
		if keyed {
			go a.dvkUnkeyPTT(gen)
		}
		a.lowerFlexDVKDax() // nothing went out — give the microphone straight back
		return err
	}
	applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
	return nil
}

// QSOAudioCancel drops the in-progress recording (callsign cleared, QSO
// abandoned without logging).
func (a *App) QSOAudioCancel() {
	if a.qsoRec != nil {
		// Say so when a take is actually thrown away. This fires when the callsign
		// is cleared — including when a clicked spot replaces it — and until now
		// it was silent, so a recording that vanished mid-QSO left the log showing
		// only its absence at save time.
		if a.qsoRec.Active() {
			applog.Printf("qso-rec: in-progress recording discarded (callsign cleared)")
		}
		a.qsoRec.DiscardQSO()
	}
	a.stopManualQSORecorder()
}

// RestartQSORecorder applies new audio settings to the running recorder.
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }

// ── NET Control ────────────────────────────────────────────────────────────
//
// A NET is a named net with a station roster (persisted globally in nets.json).
// Opening a net starts an in-memory live session: stations moved "on the air"
// (NetActivate) accumulate a time_on; moving one back off (NetDeactivate) logs
// the QSO into the active logbook with live CAT freq/mode and removes it from
// the session. The session is RAM-only — closing the app mid-net drops any
// active stations that were never logged.

// NetList returns all nets (with rosters), ordered by name.
func (a *App) NetList() []netctl.Net {
	if a.netStore == nil {
		return nil
	}
	return a.netStore.List()
}

// NetCreate adds a new named net (defaults 59/59).
func (a *App) NetCreate(name string) (netctl.Net, error) {
	if a.netStore == nil {
		return netctl.Net{}, fmt.Errorf("net store unavailable")
	}
	return a.netStore.Create(name)
}

// NetRename renames a net.
func (a *App) NetRename(id, name string) error {
	if a.netStore == nil {
		return fmt.Errorf("net store unavailable")
	}
	return a.netStore.Rename(id, name)
}

// NetSetDefaults updates a net's default report/comment values.
func (a *App) NetSetDefaults(id, rstSent, rstRcvd, comment string) error {
	if a.netStore == nil {
		return fmt.Errorf("net store unavailable")
	}
	return a.netStore.SetDefaults(id, rstSent, rstRcvd, comment)
}

// NetDelete removes a net and its roster (closing it first if it's open).
func (a *App) NetDelete(id string) error {
	if a.netStore == nil {
		return fmt.Errorf("net store unavailable")
	}
	a.netMu.Lock()
	if a.netOpenID == id {
		a.netOpenID, a.netActive = "", nil
	}
	a.netMu.Unlock()
	return a.netStore.Delete(id)
}

// NetRoster returns a net's roster, sorted by callsign.
func (a *App) NetRoster(id string) ([]netctl.Station, error) {
	if a.netStore == nil {
		return nil, fmt.Errorf("net store unavailable")
	}
	return a.netStore.Roster(id)
}

// NetRosterUpsert adds or updates a roster station.
func (a *App) NetRosterUpsert(id string, s netctl.Station) error {
	if a.netStore == nil {
		return fmt.Errorf("net store unavailable")
	}
	return a.netStore.RosterUpsert(id, s)
}

// NetRosterRemove deletes a callsign from a net's roster.
func (a *App) NetRosterRemove(id, callsign string) error {
	if a.netStore == nil {
		return fmt.Errorf("net store unavailable")
	}
	return a.netStore.RosterRemove(id, callsign)
}

// NetLookup resolves a callsign via the configured provider (QRZ) so the
// Add-contact dialog can pre-fill name/QTH/country/zones. Best-effort — returns
// just the callsign if no provider or no match.
func (a *App) NetLookup(callsign string) netctl.Station {
	st := netctl.Station{Callsign: strings.ToUpper(strings.TrimSpace(callsign))}
	if a.lookup == nil || st.Callsign == "" {
		return st
	}
	if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
		st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
		st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
		st.Grid, st.Address, st.State, st.Cnty = lr.Grid, lr.Address, lr.State, lr.County
		st.Cont, st.Lat, st.Lon, st.Email = lr.Continent, lr.Lat, lr.Lon, lr.Email
	}
	return st
}

// NetOpen starts a live session for the given net (clears any prior session).
func (a *App) NetOpen(id string) error {
	if a.netStore == nil {
		return fmt.Errorf("net store unavailable")
	}
	if _, ok := a.netStore.Get(id); !ok {
		return fmt.Errorf("net not found")
	}
	a.netMu.Lock()
	a.netOpenID, a.netActive = id, nil
	a.netMu.Unlock()
	return nil
}

// NetClose ends the live session (does NOT log remaining actives — the operator
// moves each station back to the roster side to log it before closing).
func (a *App) NetClose() {
	a.netMu.Lock()
	a.netOpenID, a.netActive = "", nil
	a.netMu.Unlock()
}

// NetOpenID returns the id of the currently open net ("" = none).
func (a *App) NetOpenID() string {
	a.netMu.Lock()
	defer a.netMu.Unlock()
	return a.netOpenID
}

// NetActiveList returns the stations currently on the air, in check-in order.
// Each is a full QSO *draft* (not yet in the DB) carrying a negative transient
// id so the same QSOEditModal as Recent QSOs can edit every field.
func (a *App) NetActiveList() []qso.QSO {
	a.netMu.Lock()
	defer a.netMu.Unlock()
	out := make([]qso.QSO, len(a.netActive))
	for i, e := range a.netActive {
		out[i] = *e
	}
	return out
}

// netLiveFreq returns the rig's live freq/band/mode, falling back to the last
// UI-reported values when CAT is off.
func (a *App) netLiveFreq() (freq int64, band, mode string) {
	var st cat.RigState
	if a.cat != nil {
		st = a.cat.State()
	}
	freq, band, mode = st.FreqHz, st.Band, st.Mode
	if freq == 0 {
		a.liveActMu.Lock()
		freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode
		a.liveActMu.Unlock()
	}
	if band == "" && freq > 0 {
		band = bandForHz(freq)
	}
	return
}

// NetActivate puts a station on the air: it builds a QSO draft (time_on now,
// live freq/mode, defaults + roster info) with a transient negative id and
// returns it. No-op (returns the existing draft) if already active.
func (a *App) NetActivate(callsign string) (qso.QSO, error) {
	call := strings.ToUpper(strings.TrimSpace(callsign))
	if call == "" {
		return qso.QSO{}, fmt.Errorf("callsign required")
	}
	a.netMu.Lock()
	defer a.netMu.Unlock()
	if a.netOpenID == "" {
		return qso.QSO{}, fmt.Errorf("no net open")
	}
	for _, e := range a.netActive {
		if strings.EqualFold(e.Callsign, call) {
			return *e, nil // already on the air
		}
	}
	a.netSeq--
	q := &qso.QSO{ID: a.netSeq, Callsign: call, QSODate: time.Now().UTC()}
	if net, ok := a.netStore.Get(a.netOpenID); ok {
		q.RSTSent, q.RSTRcvd, q.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment
		for _, st := range net.Stations {
			if strings.EqualFold(st.Callsign, call) {
				q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
				q.Grid, q.Address, q.State, q.County = st.Grid, st.Address, st.State, st.Cnty
				q.Continent, q.Email = st.Cont, st.Email
				if st.Lat != 0 {
					lat := st.Lat
					q.Lat = &lat
				}
				if st.Lon != 0 {
					lon := st.Lon
					q.Lon = &lon
				}
				if st.DXCC != 0 {
					d := st.DXCC
					q.DXCC = &d
				}
				if st.CQ != 0 {
					c := st.CQ
					q.CQZ = &c
				}
				if st.ITU != 0 {
					i := st.ITU
					q.ITUZ = &i
				}
				break
			}
		}
	}
	if q.RSTSent == "" {
		q.RSTSent = "59"
	}
	if q.RSTRcvd == "" {
		q.RSTRcvd = "59"
	}
	freq, band, mode := a.netLiveFreq()
	q.Band, q.Mode = band, mode
	if freq > 0 {
		f := freq
		q.FreqHz = &f
	}
	a.applyDXCCNumber(q) // fill country/dxcc/zones for display
	a.refineDistrictZones(q)
	a.netActive = append(a.netActive, q)
	return *q, nil
}

// NetUpdateActive replaces an on-air QSO draft (matched by its transient id)
// with the edited version from the QSOEditModal. Lets the operator change every
// field of a station before it's logged.
func (a *App) NetUpdateActive(q qso.QSO) error {
	a.netMu.Lock()
	defer a.netMu.Unlock()
	for i, cur := range a.netActive {
		if cur.ID == q.ID {
			qq := q
			a.netActive[i] = &qq
			return nil
		}
	}
	return fmt.Errorf("station not active")
}

// NetDiscardActive removes an on-air draft (by transient id) WITHOUT logging it
// — i.e. cancel a station added by mistake (the modal's Delete button).
func (a *App) NetDiscardActive(id int64) error {
	a.netMu.Lock()
	defer a.netMu.Unlock()
	for i, e := range a.netActive {
		if e.ID == id {
			a.netActive = append(a.netActive[:i], a.netActive[i+1:]...)
			return nil
		}
	}
	return nil
}

// NetDeactivate ends a station's QSO (by transient id): it logs the draft to the
// active logbook (time_off = now; freq/mode refreshed from the rig only if the
// draft still has none, so manual edits are respected) and removes it from the
// session. Returns the new QSO id.
func (a *App) NetDeactivate(id int64) (int64, error) {
	a.netMu.Lock()
	var draft *qso.QSO
	idx := -1
	for i, e := range a.netActive {
		if e.ID == id {
			draft, idx = e, i
			break
		}
	}
	if draft == nil {
		a.netMu.Unlock()
		return 0, fmt.Errorf("station not active")
	}
	a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...)
	a.netMu.Unlock()

	q := *draft
	q.ID = 0 // transient id must not reach the DB (AddQSO inserts a fresh row)
	q.QSODateOff = time.Now().UTC()
	if q.FreqHz == nil && q.Band == "" {
		freq, band, mode := a.netLiveFreq()
		q.Band, q.Mode = band, mode
		if freq > 0 {
			f := freq
			q.FreqHz = &f
		}
	}
	return a.AddQSO(q)
}

// ── E-mail / SMTP (send QSO recordings) ───────────────────────────────

const (
	defaultEmailSubject = "Our QSO recording — {CALL}"
	// Same credit line as the QSL e-mail, and for the same reason it lives in the
	// template rather than being appended at send time: it is a default, so an
	// operator deletes it once, and one who already saved their own body never
	// sees it — a stored template is returned verbatim.
	defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE}  ·  {BAND}  ·  {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
)

// EmailSettings is the user's SMTP config + auto-send + message templates.
type EmailSettings struct {
	Enabled    bool   `json:"enabled"`
	Host       string `json:"smtp_host"`
	Port       int    `json:"smtp_port"`
	User       string `json:"smtp_user"`
	Password   string `json:"smtp_password"`
	From       string `json:"from"`
	ReplyTo    string `json:"reply_to"`   // optional — where correspondents' replies go
	Encryption string `json:"encryption"` // "ssl" | "starttls" | "none"
	Auth       bool   `json:"auth"`       // SMTP requires authorization
	AutoSend   bool   `json:"auto_send"`
	Subject    string `json:"subject"`
	Body       string `json:"body"`
}

// GetEmailSettings returns the stored SMTP config (with sensible defaults).
func (a *App) GetEmailSettings() (EmailSettings, error) {
	out := EmailSettings{Port: 587, Encryption: "starttls", Auth: true, Subject: defaultEmailSubject, Body: defaultEmailBody}
	if a.settings == nil {
		return out, nil
	}
	m, err := a.settings.GetMany(a.ctx,
		keyEmailEnabled, keyEmailHost, keyEmailPort, keyEmailUser, keyEmailPassword,
		keyEmailFrom, keyEmailReplyTo, keyEmailEncryption, keyEmailAuth, keyEmailAutoSend, keyEmailSubject, keyEmailBody)
	if err != nil {
		return out, err
	}
	out.Enabled = m[keyEmailEnabled] == "1"
	out.Host = m[keyEmailHost]
	if p, _ := strconv.Atoi(m[keyEmailPort]); p > 0 {
		out.Port = p
	}
	out.User = m[keyEmailUser]
	out.Password = m[keyEmailPassword]
	out.From = m[keyEmailFrom]
	out.ReplyTo = m[keyEmailReplyTo]
	if e := m[keyEmailEncryption]; e == "ssl" || e == "starttls" || e == "none" {
		out.Encryption = e
	}
	out.Auth = m[keyEmailAuth] != "0" // default true (unset → required)
	out.AutoSend = m[keyEmailAutoSend] == "1"
	if s := m[keyEmailSubject]; s != "" {
		out.Subject = s
	}
	if b := m[keyEmailBody]; b != "" {
		out.Body = b
	}
	return out, nil
}

// SaveEmailSettings persists the SMTP config.
func (a *App) SaveEmailSettings(s EmailSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	enc := s.Encryption
	if enc != "ssl" && enc != "none" {
		enc = "starttls"
	}
	if s.Port <= 0 {
		s.Port = 587
	}
	b2s := func(b bool) string {
		if b {
			return "1"
		}
		return "0"
	}
	for k, v := range map[string]string{
		keyEmailEnabled:    b2s(s.Enabled),
		keyEmailHost:       strings.TrimSpace(s.Host),
		keyEmailPort:       strconv.Itoa(s.Port),
		keyEmailUser:       strings.TrimSpace(s.User),
		keyEmailPassword:   s.Password,
		keyEmailFrom:       strings.TrimSpace(s.From),
		keyEmailReplyTo:    strings.TrimSpace(s.ReplyTo),
		keyEmailEncryption: enc,
		keyEmailAuth:       b2s(s.Auth),
		keyEmailAutoSend:   b2s(s.AutoSend),
		keyEmailSubject:    s.Subject,
		keyEmailBody:       s.Body,
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	return nil
}

func (a *App) emailConfig(s EmailSettings) email.Config {
	return email.Config{Host: s.Host, Port: s.Port, User: s.User, Password: s.Password, From: s.From, ReplyTo: s.ReplyTo, Encryption: s.Encryption, Auth: s.Auth}
}

// TestEmail sends a test message to `to` (defaults to the From address) to
// validate the SMTP configuration.
func (a *App) TestEmail(to string) error {
	s, _ := a.GetEmailSettings()
	if to == "" {
		to = s.From
	}
	if to == "" {
		to = s.User
	}
	return email.Send(a.emailConfig(s), to,
		"OpsLog SMTP test", "This is a test message from OpsLog — your SMTP settings work. 73", "")
}

// fillTemplate substitutes {CALL} {DATE} {BAND} {MODE} {MYCALL} in a string.
func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
	myCall := ""
	if a.profiles != nil {
		if p, err := a.profiles.Active(a.ctx); err == nil {
			myCall = p.Callsign
		}
	}
	r := strings.NewReplacer(
		"{CALL}", q.Callsign,
		"{DATE}", q.QSODate.UTC().Format("2006-01-02 15:04 UTC"),
		"{BAND}", q.Band,
		"{MODE}", q.Mode,
		"{MYCALL}", myCall,
	)
	return r.Replace(tmpl)
}

// markRecordingSent stamps the QSO so the UI (and the operator) knows its audio
// recording was e-mailed — exposed as the APP_OPSLOG_RECORDING_SENT extra and a
// "Recording sent" column in Recent QSOs. Re-reads the row first so a concurrent
// status update (eQSL/upload) isn't clobbered by writing back the whole row.
func (a *App) markRecordingSent(id int64) {
	if a.qso == nil || id == 0 {
		return
	}
	// Targeted extras write — never a full-row Update, which (from a stale copy)
	// could revert a clublog/qrz upload-status another action just stamped.
	if err := a.qso.SetExtra(a.ctx, id, "APP_OPSLOG_RECORDING_SENT", time.Now().UTC().Format("2006-01-02")); err != nil {
		applog.Printf("qso-rec: mark sent %d: %v", id, err)
	}
}

// sendRecordingEmail e-mails a QSO recording to the contacted operator.
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
	s, _ := a.GetEmailSettings()
	to := strings.TrimSpace(q.Email)
	if to == "" {
		return fmt.Errorf("no e-mail address for %s", q.Callsign)
	}
	subject := s.Subject
	if subject == "" {
		subject = defaultEmailSubject
	}
	body := s.Body
	if body == "" {
		body = defaultEmailBody
	}
	err := email.Send(a.emailConfig(s), to, a.fillTemplate(subject, q), a.fillTemplate(body, q), attachPath)
	if err != nil {
		applog.Printf("email: send recording to %s failed: %v", to, err)
	} else {
		applog.Printf("email: recording sent to %s (%s)", to, q.Callsign)
	}
	return err
}

// SendQSORecordingEmail e-mails the stored recording for a QSO id (right-click
// "Send recording by e-mail"). Errors if the QSO has no recording or e-mail.
func (a *App) SendQSORecordingEmail(id int64) error {
	if a.qso == nil {
		return fmt.Errorf("db not initialized")
	}
	q, err := a.qso.GetByID(a.ctx, id)
	if err != nil {
		return err
	}
	name := ""
	if q.Extras != nil {
		name = q.Extras["APP_OPSLOG_RECORDING"]
	}
	if name == "" {
		return fmt.Errorf("no recording stored for this QSO")
	}
	path := filepath.Join(a.qsoRecDir(), name)
	if _, e := os.Stat(path); e != nil {
		return fmt.Errorf("recording file missing: %s", name)
	}
	if err := a.sendRecordingEmail(q, path); err != nil {
		return err
	}
	a.markRecordingSent(id)
	return nil
}

// developerEmail is where "Help → Send log to F4BPO" delivers diagnostic logs.
const developerEmail = "legreg002@hotmail.com"

// SMTPConfigured reports whether an SMTP server is set up, so the UI can show the
// "Send log to F4BPO" help entry only when there's actually a way to send.
func (a *App) SMTPConfigured() bool {
	s, _ := a.GetEmailSettings()
	return strings.TrimSpace(s.Host) != ""
}

// SendLogToDeveloper e-mails the app's diagnostic logs (opslog.log, the previous
// session's opslog.log.1, and any crash log) to the developer so problems users
// hit in the field can be diagnosed. Requires a configured SMTP server
// (Settings → E-mail). A short header identifies the sender's call and version.
func (a *App) SendLogToDeveloper() error {
	s, _ := a.GetEmailSettings()
	if strings.TrimSpace(s.Host) == "" {
		return fmt.Errorf("no SMTP server configured — set one in Settings → E-mail")
	}
	logPath := applog.Path()
	if logPath == "" {
		return fmt.Errorf("log file not available")
	}
	// The current log, plus the rotated one and the crash log when they exist.
	attach := []string{logPath}
	for _, extra := range []string{logPath + ".1", filepath.Join(filepath.Dir(logPath), "opslog-crash.log")} {
		if _, err := os.Stat(extra); err == nil {
			attach = append(attach, extra)
		}
	}
	call := ""
	if a.profiles != nil {
		if p, err := a.profiles.Active(a.ctx); err == nil {
			call = p.Callsign
		}
	}
	subject := fmt.Sprintf("OpsLog log — v%s", appVersion)
	if call != "" {
		subject = fmt.Sprintf("OpsLog log — %s v%s", call, appVersion)
	}
	body := fmt.Sprintf("OpsLog diagnostic log attached.\n\nCallsign: %s\nVersion: %s\n\n73", call, appVersion)
	if err := email.SendFiles(a.emailConfig(s), developerEmail, subject, body, attach); err != nil {
		applog.Printf("email: send log to developer failed: %v", err)
		return err
	}
	applog.Printf("email: diagnostic log sent to developer (%s)", developerEmail)
	return nil
}

// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────

// ClublogCtyInfo is the UI status of the ClubLog exception data.
type ClublogCtyInfo struct {
	Enabled bool   `json:"enabled"`
	Loaded  bool   `json:"loaded"`
	Date    string `json:"date"`
	Count   int    `json:"count"`
}

func (a *App) clublogCtyEnabled() bool {
	if a.settings == nil {
		return false
	}
	v, _ := a.settings.Get(a.ctx, keyClublogCtyEnabled)
	return v == "1"
}

// GetClublogCtyInfo returns the current ClubLog exception status.
func (a *App) GetClublogCtyInfo() ClublogCtyInfo {
	info := ClublogCtyInfo{Enabled: a.clublogCtyEnabled()}
	if a.clublog != nil {
		info.Loaded = a.clublog.Loaded()
		info.Date, info.Count = a.clublog.Info()
	}
	return info
}

// SetClublogCtyEnabled toggles ClubLog exception resolution, loading the cached
// file on first enable.
func (a *App) SetClublogCtyEnabled(on bool) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	v := "0"
	if on {
		v = "1"
	}
	if err := a.settings.Set(a.ctx, keyClublogCtyEnabled, v); err != nil {
		return err
	}
	if on && a.clublog != nil && !a.clublog.Loaded() {
		_ = a.clublog.EnsureLoaded() // ok if file not downloaded yet
	}
	return nil
}

// DownloadClublogCty fetches a fresh ClubLog country file.
func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) {
	if a.clublog == nil {
		return ClublogCtyInfo{}, fmt.Errorf("clublog not initialized")
	}
	if err := a.clublog.Download(a.ctx); err != nil {
		return a.GetClublogCtyInfo(), err
	}
	return a.GetClublogCtyInfo(), nil
}

// activeCallsign is the current profile's callsign (upper-cased), "" if none.
func (a *App) activeCallsign() string {
	if a.profiles == nil {
		return ""
	}
	if p, err := a.profiles.Active(a.ctx); err == nil {
		return strings.ToUpper(strings.TrimSpace(p.Callsign))
	}
	return ""
}

func (a *App) clublogMostWantedEnabled() bool {
	if a.settings == nil {
		return false
	}
	v, _ := a.settings.Get(a.ctx, keyClublogMostWanted)
	return v == "1"
}

// ClublogMostWantedInfo is the UI status of the ClubLog Most Wanted data.
type ClublogMostWantedInfo struct {
	Enabled  bool   `json:"enabled"`
	Loaded   bool   `json:"loaded"`
	Callsign string `json:"callsign"`
	Date     string `json:"date"`
	Count    int    `json:"count"`
}

// GetClublogMostWantedInfo returns the current Most Wanted status.
func (a *App) GetClublogMostWantedInfo() ClublogMostWantedInfo {
	info := ClublogMostWantedInfo{Enabled: a.clublogMostWantedEnabled()}
	if a.clublogMW != nil {
		info.Loaded = a.clublogMW.Loaded()
		info.Callsign, info.Date, info.Count = a.clublogMW.Info()
	}
	return info
}

// SetClublogMostWantedEnabled toggles the Most Wanted feature. On first enable it
// loads the cached list (the UI then calls Download to fetch a fresh one).
func (a *App) SetClublogMostWantedEnabled(on bool) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	v := "0"
	if on {
		v = "1"
	}
	if err := a.settings.Set(a.ctx, keyClublogMostWanted, v); err != nil {
		return err
	}
	if on && a.clublogMW != nil && !a.clublogMW.Loaded() {
		_ = a.clublogMW.LoadFromDisk() // ok if not downloaded yet
	}
	return nil
}

// DownloadClublogMostWanted fetches a fresh Most Wanted list for the active call.
func (a *App) DownloadClublogMostWanted() (ClublogMostWantedInfo, error) {
	if a.clublogMW == nil {
		return ClublogMostWantedInfo{}, fmt.Errorf("clublog not initialized")
	}
	if err := a.clublogMW.Download(a.ctx, a.activeCallsign()); err != nil {
		return a.GetClublogMostWantedInfo(), err
	}
	return a.GetClublogMostWantedInfo(), nil
}

// applyClublogException overrides a QSO's entity fields from a ClubLog
// exception matching its callsign at its date. force=true ignores the
// enable toggle (used by the explicit "Update from ClubLog" action).
// Returns true if something changed.
func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
	if a.clublog == nil || q.Callsign == "" {
		return false
	}
	if !force && !a.clublogCtyEnabled() {
		return false
	}
	date := q.QSODate
	if date.IsZero() {
		date = time.Now().UTC()
	}
	e, ok := a.clublog.Resolve(q.Callsign, date)
	if !ok {
		// No exception covers this QSO's date, so the question becomes which
		// country file knows the PREFIX better.
		//
		// ClubLog's prefix table is date-ranged and cty.dat's is not, and that
		// is not a detail: cty.dat describes the world as it is TODAY. Niue
		// moved to E6, so ZK2 went back to New Zealand there — and every ZK2
		// contact ever made was silently relabelled New Zealand, taking a
		// confirmed entity out of an operator's DXCC with it. ZK1 loses the
		// Cook Islands the same way. ClubLog still knows both.
		//
		// It only speaks when it HAS an answer (E6, TO5A and their like are not
		// in its prefix table at all), and never over an exact "=CALLSIGN" entry
		// in cty.dat: that is somebody having looked at this very callsign, and
		// a prefix rule does not overrule it.
		if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
			ctyExact := false
			if m, mok := a.dxcc.Lookup(q.Callsign); mok {
				ctyExact = m.Exact
			}
			if !ctyExact {
				e, ok = pe, true
			}
		}
		if !ok {
			return false
		}
	}
	q.Country = titleEntity(e.Entity)
	if e.Cont != "" {
		q.Continent = e.Cont
	}
	if e.ADIF != 0 {
		n := e.ADIF
		q.DXCC = &n
	}
	if e.CQZ != 0 {
		v := e.CQZ
		q.CQZ = &v
	}
	if e.Lat != 0 || e.Lon != 0 {
		lat, lon := e.Lat, e.Lon
		q.Lat, q.Lon = &lat, &lon
	}
	return true
}

// UpdateQSOsFromClublog re-resolves the selected QSOs against ClubLog
// exceptions (by their QSO date) and saves any that changed.
func (a *App) UpdateQSOsFromClublog(ids []int64) (int, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	if a.clublog == nil || !a.clublog.Loaded() {
		return 0, fmt.Errorf("ClubLog data not loaded — download it first")
	}
	changed := 0
	for _, id := range ids {
		q, err := a.qso.GetByID(a.ctx, id)
		if err != nil {
			continue
		}
		if a.applyClublogException(&q, true) {
			if err := a.qso.Update(a.ctx, q); err == nil {
				changed++
			}
		}
	}
	if changed > 0 {
		a.invalidateAwardStats()
	}
	return changed, nil
}

// titleCaseIfUpper title-cases a string ONLY when it's entirely upper-case
// (e.g. Log4OM/contest ADIF sends "SANTO DOMINGO"); mixed-case values are
// left untouched. Codes like state "DN" stay as-is (no lower-case letters
// to gain, but they're short — callers pick which fields to pass).
func titleCaseIfUpper(s string) string {
	t := strings.TrimSpace(s)
	if t == "" || t != strings.ToUpper(t) {
		return s
	}
	return titleEntity(t)
}

// titleEntity converts ClubLog's UPPERCASE entity names to title case
// ("LORD HOWE ISLAND" → "Lord Howe Island") for display consistency.
func titleEntity(s string) string {
	s = strings.TrimSpace(s)
	if s == "" {
		return s
	}
	words := strings.Fields(strings.ToLower(s))
	for i, w := range words {
		r := []rune(w)
		r[0] = []rune(strings.ToUpper(string(r[0])))[0]
		words[i] = string(r)
	}
	return strings.Join(words, " ")
}

// ── Digital Voice Keyer (DVK) ─────────────────────────────────────────
//
// Six voice-message slots (F1–F6, like the WinKeyer macros). Each message is a
// WAV file in <dataDir>/dvk/dvk<N>.wav; its label lives in settings. Record via
// the configured "Recording mic", transmit via "To Radio", preview via
// "Listening".

const dvkSlots = 12

// DVKMessage is one voice-keyer slot for the UI.
type DVKMessage struct {
	Slot        int     `json:"slot"`
	Label       string  `json:"label"`
	HasAudio    bool    `json:"has_audio"`
	DurationSec float64 `json:"duration_sec"`
}

// DVKStatus reflects the live record/playback state for the operating panel.
type DVKStatus struct {
	Recording bool `json:"recording"`
	Playing   bool `json:"playing"`
	RecSlot   int  `json:"rec_slot"`
}

func (a *App) dvkDir() string {
	d := filepath.Join(a.dataDir, "dvk")
	_ = os.MkdirAll(d, 0o755)
	return d
}

func (a *App) dvkPath(slot int) string {
	return filepath.Join(a.dvkDir(), fmt.Sprintf("dvk%d.wav", slot))
}

func dvkLabelKey(slot int) string { return fmt.Sprintf("audio.dvk.label%d", slot) }

func (a *App) dvkStatus() DVKStatus {
	st := DVKStatus{RecSlot: a.dvkRecSlot}
	if a.audioMgr != nil {
		st.Recording = a.audioMgr.IsRecording()
		st.Playing = a.audioMgr.IsPlaying()
	}
	return st
}

// GetDVKStatus returns the current record/playback state.
func (a *App) GetDVKStatus() DVKStatus { return a.dvkStatus() }

// GetDVKMessages returns the six voice-keyer slots with their labels, whether
// a recording exists, and its duration.
func (a *App) GetDVKMessages() []DVKMessage {
	out := make([]DVKMessage, 0, dvkSlots)
	for s := 1; s <= dvkSlots; s++ {
		m := DVKMessage{Slot: s}
		if a.settings != nil {
			if v, _ := a.settings.Get(a.ctx, dvkLabelKey(s)); v != "" {
				m.Label = v
			}
		}
		if fi, err := os.Stat(a.dvkPath(s)); err == nil && fi.Size() > 44 {
			m.HasAudio = true
			m.DurationSec = float64(fi.Size()-44) / 32000.0 // 16 kHz mono 16-bit
		}
		out = append(out, m)
	}
	return out
}

// DVKDelete removes a slot's recording AND its label — a deleted message is a
// free slot, per its operator.
func (a *App) DVKDelete(slot int) error {
	if slot < 1 || slot > dvkSlots {
		return fmt.Errorf("bad slot")
	}
	if err := os.Remove(a.dvkPath(slot)); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("delete message %d: %w", slot, err)
	}
	if a.settings != nil {
		_ = a.settings.Set(a.ctx, dvkLabelKey(slot), "")
	}
	applog.Printf("dvk: message F%d deleted (label cleared)", slot)
	return nil
}

// SetDVKLabel renames a voice-keyer slot.
func (a *App) SetDVKLabel(slot int, label string) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if slot < 1 || slot > dvkSlots {
		return fmt.Errorf("bad slot")
	}
	return a.settings.Set(a.ctx, dvkLabelKey(slot), strings.TrimSpace(label))
}

// DVKStartRecord begins recording a voice message into the given slot, using
// the configured Recording mic.
func (a *App) DVKStartRecord(slot int) error {
	if a.audioMgr == nil {
		return fmt.Errorf("audio not initialized")
	}
	if slot < 1 || slot > dvkSlots {
		return fmt.Errorf("bad slot")
	}
	cfg, _ := a.GetAudioSettings()
	a.dvkRecSlot = slot
	return a.audioMgr.StartRecording(cfg.RecordingDevice)
}

// DVKStopRecord ends the recording and writes it to the slot's WAV file.
func (a *App) DVKStopRecord() error {
	if a.audioMgr == nil {
		return fmt.Errorf("audio not initialized")
	}
	return a.audioMgr.StopRecording(a.dvkPath(a.dvkRecSlot))
}

// DVKCancelRecord aborts a recording without saving.
func (a *App) DVKCancelRecord() {
	if a.audioMgr != nil {
		a.audioMgr.CancelRecording()
	}
}

// DVKPlay transmits a slot's message to the rig ("To Radio"), asserting serial
// PTT (RTS/DTR) first unless the operator uses VOX. PTT is released
// automatically when playback ends (see the audio status callback).
func (a *App) DVKPlay(slot int) error {
	if a.audioMgr == nil {
		return fmt.Errorf("audio not initialized")
	}
	path := a.dvkPath(slot)
	if fi, err := os.Stat(path); err != nil || fi.Size() <= 44 {
		return fmt.Errorf("no recording in slot %d", slot)
	}
	cfg, _ := a.GetAudioSettings()
	// Before PTT: on a Flex the transmit audio comes from the microphone or from
	// DAX, so the source has to be right before the carrier goes up — otherwise
	// the first syllables are transmitted as silence.
	a.raiseFlexDVKDax()
	if err := a.pttKey(cfg); err != nil {
		applog.Printf("dvk: PTT on failed: %v", err)
		// Keep going — the audio still reaches the rig; the user may use VOX.
	} else if cfg.PTTMethod != "" && cfg.PTTMethod != "none" {
		a.pttMu.Lock()
		a.dvkPttKeyed = true
		a.pttMu.Unlock()
	}
	if err := a.audioMgr.Play(cfg.ToRadio, path, cfg.TXGain); err != nil {
		a.pttMu.Lock()
		keyed := a.dvkPttKeyed
		gen := a.pttGen
		a.dvkPttKeyed = false
		a.pttMu.Unlock()
		if keyed {
			go a.dvkUnkeyPTT(gen)
		}
		// Nothing is going out, so the microphone must come straight back — the
		// status callback that normally does it never fires for a play that
		// never started.
		a.lowerFlexDVKDax()
		return err
	}
	return nil
}

// raiseFlexDVKDax presses SmartSDR's transmit-bar DAX button before a voice
// message goes out, and remembers whether it was already down.
//
// On a FlexRadio the transmit audio comes from EITHER the microphone or DAX,
// never both. A voice keyer plays into the DAX TX stream, so with the button up
// the message is transmitted as silence; with it left down afterwards the
// operator's own microphone is dead. Pressing it by hand around every message is
// exactly the kind of thing that gets forgotten mid-pileup, hence the option.
//
// The PREVIOUS state is what gets restored, not a blanket "off": an operator
// running WSJT-X has DAX down on purpose, and lowering it for them after a voice
// message would break the digital setup to solve a problem they do not have. In
// the case this option exists for — microphone operating, DAX up — restoring the
// previous state is precisely "give me my microphone back".
//
// Silent no-op when the option is off or the radio is not a Flex: this sits on
// the voice-keyer path, which must not fail because a rig cannot do DAX.
func (a *App) raiseFlexDVKDax() {
	if a.cat == nil || !a.catFlexDVKDax {
		return
	}
	var was bool
	err := a.cat.FlexDo(func(fc cat.FlexController) error {
		was = fc.FlexState().TXDAX
		if was {
			return nil // already down — nothing to press, nothing to restore
		}
		return fc.SetTXDAX(true)
	})
	if err != nil {
		applog.Printf("dvk: could not raise the Flex TX DAX button: %v", err)
		return
	}
	if was {
		return
	}
	a.pttMu.Lock()
	a.flexDaxRaised = true
	a.pttMu.Unlock()
	applog.Printf("dvk: Flex TX DAX on for the voice message")
}

// lowerFlexDVKDax puts the transmit-bar DAX button back where it was, and only
// when raiseFlexDVKDax actually moved it. Called where the voice-message PTT is
// released, so the microphone comes back at the moment the message ends.
func (a *App) lowerFlexDVKDax() {
	a.pttMu.Lock()
	raised := a.flexDaxRaised
	a.flexDaxRaised = false
	a.pttMu.Unlock()
	if !raised || a.cat == nil {
		return
	}
	if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(false) }); err != nil {
		// Worth saying loudly: the microphone stays dead until it is pressed by
		// hand, and an operator whose voice is not going out has no other clue.
		applog.Printf("dvk: could not lower the Flex TX DAX button — the microphone stays off air until you press it in SmartSDR: %v", err)
		return
	}
	applog.Printf("dvk: Flex TX DAX off — microphone back on air")
}

// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
// of the message — but ONLY if no newer key happened since (gen unchanged). A
// rapid replay (or a Test PTT) starts a fresh transmission whose key must not
// be cut by this stale, delayed release.
func (a *App) dvkUnkeyPTT(gen int64) {
	time.Sleep(120 * time.Millisecond)
	a.unkeyIfCurrent(gen)
}

// pttGenNow returns the current PTT key generation.
func (a *App) pttGenNow() int64 {
	a.pttMu.Lock()
	defer a.pttMu.Unlock()
	return a.pttGen
}

// unkeyIfCurrent drops PTT only when the key generation hasn't advanced since
// gen was captured — so a delayed release never cuts a transmission the user
// (or a new DVK message) started in the meantime.
func (a *App) unkeyIfCurrent(gen int64) {
	a.pttMu.Lock()
	stale := a.pttGen != gen
	a.pttMu.Unlock()
	if stale {
		return
	}
	a.pttUnkey()
}

// pttKey keys the transmitter using the configured method:
//   - "cat" → whichever CAT backend is active (OmniRig, Flex, Icom, TCI,
//     Yaesu, Xiegu — they all implement SetPTT)
//   - "rts"/"dtr" → open the COM port and assert that line, held during TX
//   - "none" → VOX, nothing to do
func (a *App) pttKey(cfg AudioSettings) error {
	switch cfg.PTTMethod {
	case "cat":
		if a.cat == nil {
			return fmt.Errorf("CAT not initialized")
		}
		if err := a.cat.SetPTTSource(true, cfg.PTTData); err != nil {
			applog.Printf("ptt: CAT SetPTT failed: %v", err)
			return err
		}
		a.pttMu.Lock()
		a.pttKeyedMethod = "cat"
		a.pttGen++
		a.pttMu.Unlock()
		applog.Printf("dvk: PTT keyed (CAT via %s)", a.cat.State().Backend)
		return nil
	case "rts", "dtr":
		if strings.TrimSpace(cfg.PTTPort) == "" {
			return fmt.Errorf("no PTT COM port configured")
		}
		a.pttMu.Lock()
		defer a.pttMu.Unlock()
		if a.pttPort != nil {
			return nil // already keyed
		}
		port, err := serial.Open(cfg.PTTPort, &serial.Mode{BaudRate: 9600})
		if err != nil {
			return fmt.Errorf("open %s: %w", cfg.PTTPort, err)
		}
		var lerr error
		if cfg.PTTMethod == "rts" {
			lerr = port.SetRTS(true)
			_ = port.SetDTR(false)
		} else {
			lerr = port.SetDTR(true)
			_ = port.SetRTS(false)
		}
		if lerr != nil {
			_ = port.Close()
			return fmt.Errorf("assert %s on %s: %w", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort, lerr)
		}
		a.pttPort = port
		a.pttKeyedMethod = cfg.PTTMethod
		a.pttGen++
		applog.Printf("dvk: PTT keyed (%s on %s)", strings.ToUpper(cfg.PTTMethod), cfg.PTTPort)
		return nil
	}
	return nil // none / VOX
}

// pttUnkey releases whichever PTT was keyed (CAT back to RX, or drop the
// serial line + close the port).
func (a *App) pttUnkey() {
	a.pttMu.Lock()
	method := a.pttKeyedMethod
	a.pttKeyedMethod = ""
	port := a.pttPort
	a.pttPort = nil
	a.pttMu.Unlock()

	switch method {
	case "cat":
		if a.cat != nil {
			if err := a.cat.SetPTT(false); err != nil {
				applog.Printf("dvk: PTT off (CAT) failed: %v", err)
			}
		}
	case "rts", "dtr":
		if port != nil {
			_ = port.SetRTS(false)
			_ = port.SetDTR(false)
			_ = port.Close()
		}
	default:
		return
	}
	applog.Printf("dvk: PTT released")
}

// PTTHotkeyDown keys the transmitter for the keyboard PTT hotkey. It uses the
// configured Audio → PTT method (CAT / RTS / DTR); when that is VOX/none it
// falls back to CAT keying so the hotkey still works with a plain CAT rig.
func (a *App) PTTHotkeyDown() error {
	// This hotkey lives under CAT and is meant to key the rig over CAT — so prefer
	// CAT whenever a backend is active. Only a rig with no CAT link falls back to
	// the Audio-tab PTT method (RTS/DTR serial). Previously it always used the
	// Audio method, so a stale serial PTT setting (e.g. an RTS line on a COM port
	// that is no longer present) hijacked the hotkey and it failed with a serial
	// error — even though the operator only ever wanted CAT.
	if a.cat != nil {
		return a.pttKey(AudioSettings{PTTMethod: "cat"})
	}
	cfg, err := a.GetAudioSettings()
	if err != nil {
		return err
	}
	if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
		return fmt.Errorf("no PTT method configured and CAT not active")
	}
	return a.pttKey(cfg)
}

// PTTHotkeyUp releases the PTT hotkey.
func (a *App) PTTHotkeyUp() { a.pttUnkey() }

// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
// TestPTT keys the transmitter for ~600ms using the GIVEN settings (the live
// UI selection), so the user can test a method/port without saving first —
// matching TestRotator / TestUltrabeam.
func (a *App) TestPTT(cfg AudioSettings) error {
	if cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr" {
		applog.Printf("ptt: TestPTT method=%q port=%q", cfg.PTTMethod, cfg.PTTPort)
	} else {
		// Name the backend that will actually key — "via OmniRig" was left over
		// from when that was the only one, and it sent operators on a native
		// backend looking for a problem with OmniRig that they do not even run.
		backend := "no CAT backend"
		if a.cat != nil {
			if st := a.cat.State(); st.Backend != "" {
				backend = st.Backend
			}
		}
		applog.Printf("ptt: TestPTT method=%q (CAT via %s — serial port not used)", cfg.PTTMethod, backend)
	}
	if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
		return fmt.Errorf("PTT method is None (VOX) — pick CAT, RTS or DTR first")
	}
	if (cfg.PTTMethod == "rts" || cfg.PTTMethod == "dtr") && strings.TrimSpace(cfg.PTTPort) == "" {
		return fmt.Errorf("select a COM port for %s PTT", strings.ToUpper(cfg.PTTMethod))
	}
	if err := a.pttKey(cfg); err != nil {
		return err
	}
	// Hold long enough to be observable. OmniRig is async and coalesces rapid
	// writes to the same parameter: it stores the desired Tx value and flushes
	// on its own poll timer, so a key (PM_TX) followed too quickly by an unkey
	// (PM_RX) can land in ONE flush cycle — OmniRig then sends only the last
	// value (PM_RX) and the rig never actually transmits (while an already-MOX'd
	// rig gets dropped by that lone PM_RX). A longer hold guarantees the key
	// command is sent and held before the release. Serial RTS/DTR is instant,
	// so it keeps the short window.
	hold := 600 * time.Millisecond
	if cfg.PTTMethod == "cat" {
		hold = 1500 * time.Millisecond
	}
	gen := a.pttGenNow()
	go func() { time.Sleep(hold); a.unkeyIfCurrent(gen) }()
	return nil
}

// DVKPreview plays a slot's message locally on the "Listening" device.
func (a *App) DVKPreview(slot int) error {
	if a.audioMgr == nil {
		return fmt.Errorf("audio not initialized")
	}
	cfg, _ := a.GetAudioSettings()
	// Preview at the SAME level the rig will get, or the operator adjusts against
	// a sound that is not the one going on the air.
	return a.audioMgr.Play(cfg.ListeningDevice, a.dvkPath(slot), cfg.TXGain)
}

// DVKStop halts any voice-keyer playback.
func (a *App) DVKStop() {
	if a.audioMgr != nil {
		a.audioMgr.StopPlayback()
	}
}

// AudioApplyLevels applies the RX and mic levels IMMEDIATELY, without saving.
//
// A level is set by ear, by dragging: the sliders only touched the settings
// panel state, so the value reached the audio engine when the operator clicked
// Save — and while they were listening and dragging, 10%% sounded exactly like
// 300%%. The control was not weak, it was not connected to anything yet.
//
// Persisting still happens on Save. This is the live half.
func (a *App) AudioApplyLevels(fromPct, micPct int) {
	// PERSIST as well as apply. Starting a recording re-reads the stored
	// settings and calls SetGains with them, so a live-only value was thrown
	// away the moment a take began: the monitor followed the slider while two
	// recordings made at 10%% and 300%% came out identical.
	if a.settings != nil {
		if fromPct > 0 {
			_ = a.settings.Set(a.ctx, keyAudioFromGain, strconv.Itoa(fromPct))
		}
		if micPct > 0 {
			_ = a.settings.Set(a.ctx, keyAudioMicGain, strconv.Itoa(micPct))
		}
	}
	if a.audioMgr != nil {
		a.audioMgr.SetMonitorGain(fromPct)
	}
	if a.qsoRec != nil {
		f, m := float64(fromPct)/100, float64(micPct)/100
		if fromPct <= 0 {
			f = 1
		}
		if micPct <= 0 {
			m = 1
		}
		a.qsoRec.SetGains(f, m)
	}
}

// AudioStartMonitor pipes live RX audio from the rig into your speakers so you
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a
// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening"
// device. This is the USB half of the audio feature; the network 50003 stream
// will later Push into the same output path (see audio.Manager.PushMonitorAudio).
func (a *App) AudioStartMonitor() error {
	if a.audioMgr == nil {
		return fmt.Errorf("audio not initialized")
	}
	cfg, _ := a.GetAudioSettings()
	// Idempotent: asking to listen while a monitor is already running restarts
	// it instead of refusing. The refusal surfaced as a checkbox that unticked
	// itself the instant it was ticked, whenever the UI's idea of the state and
	// the real monitor had drifted apart.
	a.audioMgr.StopMonitor()
	// When the rig's audio arrives over the NETWORK (Icom 50003), the monitor
	// must be render-only: this button used to start a USB capture from the
	// "From radio" device as well, and that second producer — often another
	// radio's DAX — interleaved its chunks with the network stream's pushes.
	// The result was audio chopped to pieces the moment the operator toggled
	// Listening off and on while connected to a network Icom.
	if cs, err := a.GetCATSettings(); err == nil && cs.Enabled && cs.Backend == "icom-net" && cs.IcomNetAudio {
		a.setSetting(keyAudioMonitorOn, "1")
		applog.Printf("audio: RX monitor start (network sink only → listen=%q)", cfg.ListeningDevice)
		return a.audioMgr.StartMonitorSink(cfg.ListeningDevice)
	}
	// Only the USB path needs a capture device — checked AFTER the network
	// branch, which needs none: with From-radio empty (the normal network
	// setup) this refusal was un-ticking the speakers checkbox the instant it
	// was ticked.
	if strings.TrimSpace(cfg.FromRadio) == "" {
		return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
	}
	applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
	a.audioMgr.SetMonitorGain(cfg.FromGain)
	return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
}

// AudioStopMonitor stops the RX monitor passthrough.
func (a *App) AudioStopMonitor() {
	a.setSetting(keyAudioMonitorOn, "0")
	if a.audioMgr != nil {
		a.audioMgr.StopMonitor()
		applog.Printf("audio: RX monitor stopped")
	}
}

// AudioMonitorActive reports whether the RX monitor is running (for the toggle).
func (a *App) AudioMonitorActive() bool {
	return a.audioMgr != nil && a.audioMgr.MonitorActive()
}

// AudioStartTX keys PTT and pipes your live mic into the rig ("To Radio" device)
// so you can talk through the PC — the USB half of TX voice. PTT uses the
// configured method (CAT/RTS/DTR); if keying fails the audio route isn't started.
func (a *App) AudioStartTX() error {
	if a.audioMgr == nil {
		return fmt.Errorf("audio not initialized")
	}
	cfg, _ := a.GetAudioSettings()
	if strings.TrimSpace(cfg.ToRadio) == "" {
		return fmt.Errorf(`no "To radio" device set — pick the rig's USB Audio CODEC output in Settings → Audio`)
	}
	// Live mic over the radio's own link: the To-radio "device" is the radio.
	// Fetched before keying so a missing stream refuses cleanly with the PTT
	// never touched.
	var netSend func([]byte) error
	if cfg.ToRadio == audio.NetworkDeviceID {
		if strings.TrimSpace(cfg.RecordingDevice) == "" {
			return fmt.Errorf("pick your microphone as the Recording mic in Settings → Audio")
		}
		type sender interface {
			TXAudioSender() (func([]byte) error, error)
		}
		err := a.cat.IcomDo(func(ic cat.IcomController) error {
			p, ok := ic.(sender)
			if !ok {
				return fmt.Errorf("this radio cannot take live microphone audio over its link yet")
			}
			fn, err := p.TXAudioSender()
			if err != nil {
				return err
			}
			netSend = fn
			return nil
		})
		if err != nil {
			return err
		}
	}
	if err := a.pttKey(cfg); err != nil { // key first — no point streaming to a rig that isn't transmitting
		return err
	}
	if netSend != nil {
		if err := a.audioMgr.StartTXAudioNetwork(cfg.RecordingDevice, netSend); err != nil {
			a.pttUnkey()
			return err
		}
		applog.Printf("audio: TX start (mic=%q → the radio over the network, ptt=%q)", cfg.RecordingDevice, cfg.PTTMethod)
		return nil
	}
	if err := a.audioMgr.StartTXAudio(cfg.RecordingDevice, cfg.ToRadio); err != nil {
		a.pttUnkey()
		return err
	}
	applog.Printf("audio: TX start (mic=%q → to-radio=%q, ptt=%q)", cfg.RecordingDevice, cfg.ToRadio, cfg.PTTMethod)
	return nil
}

// AudioStopTX stops the TX passthrough and unkeys PTT.
func (a *App) AudioStopTX() {
	if a.audioMgr != nil {
		a.audioMgr.StopTXAudio()
	}
	a.pttUnkey()
	applog.Printf("audio: TX stopped")
}

// AudioTXActive reports whether the TX passthrough is running (for the toggle).
func (a *App) AudioTXActive() bool {
	return a.audioMgr != nil && a.audioMgr.TXAudioActive()
}

// GetLogFilePath returns where the diagnostic log file lives so the user
// can open it from the Settings UI. Empty when applog hasn't initialised.
func (a *App) GetLogFilePath() string {
	return applog.Path()
}

// TailLogFile returns the END of the diagnostic log — the last maxBytes, cut
// back to a line boundary so the first line is never a fragment.
//
// The tail, not the file: it rotates at 10 MB, and the live viewer polls this
// every second. Shipping megabytes across the Wails bridge on a timer would cost
// more than the whole rest of the UI.
func (a *App) TailLogFile(maxBytes int) (string, error) {
	p := applog.Path()
	if p == "" {
		return "", fmt.Errorf("log file not initialised")
	}
	if maxBytes <= 0 || maxBytes > 4*1024*1024 {
		maxBytes = 256 * 1024
	}
	f, err := os.Open(p)
	if err != nil {
		return "", err
	}
	defer f.Close()
	fi, err := f.Stat()
	if err != nil {
		return "", err
	}
	start := int64(0)
	if fi.Size() > int64(maxBytes) {
		start = fi.Size() - int64(maxBytes)
	}
	if _, err := f.Seek(start, io.SeekStart); err != nil {
		return "", err
	}
	buf, err := io.ReadAll(f)
	if err != nil {
		return "", err
	}
	s := string(buf)
	if start > 0 {
		if i := strings.IndexByte(s, '\n'); i >= 0 {
			s = s[i+1:]
		}
	}
	return s, nil
}

// UILog lets the frontend write to the same diagnostic log as the backend.
//
// Frontend-only logic (entry-strip auto-fill, debounce ordering, which field the
// operator has touched) was previously invisible in a bug report: console output
// dies with the window, and these problems reproduce on other operators'
// machines, not here. A line in opslog.log can simply be sent along with the
// report. Callers prefix their own subsystem, e.g. "backfill: …".
func (a *App) UILog(msg string) {
	if msg = strings.TrimSpace(msg); msg != "" {
		applog.Printf("ui: %s", msg)
	}
}

// ── QSL defaults ──────────────────────────────────────────────────────

// GetQSLDefaults returns the stored defaults — empty strings when the
// user hasn't configured anything (= leave QSO fields untouched).
// defaultQSLDefaults is the out-of-the-box confirmation status for a profile
// that hasn't customised them: request the online confirmations (eQSL/LoTW/
// Clublog/HRDLog/QRZ "sent"=R so OpsLog knows they still need uploading), and
// "N" for paper and everything received.
func defaultQSLDefaults() QSLDefaults {
	return QSLDefaults{
		QSLSent: "N", QSLRcvd: "N",
		EQSLSent: "R", EQSLRcvd: "N",
		LOTWSent: "R", LOTWRcvd: "N",
		ClublogStatus: "R", ClublogCfm: "N", HRDLogStatus: "R",
		HamqthStatus: "R",
		QRZComStatus: "R", QRZComCfm: "N",
	}
}

func (a *App) GetQSLDefaults() (QSLDefaults, error) {
	out := QSLDefaults{}
	if a.settings == nil {
		return out, nil
	}
	// Fresh profile (never saved confirmations) → sensible defaults.
	if !a.profileHasGroup(markerQSL) {
		return defaultQSLDefaults(), nil
	}
	prefix := a.profileScope()
	m, err := a.getManyScoped(prefix,
		keyQSLDefaultQSLSent, keyQSLDefaultQSLRcvd,
		keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
		keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
		keyQSLDefaultClublogStatus, keyQSLDefaultClublogCfm, keyQSLDefaultHRDLogStatus,
		keyQSLDefaultQRZComStatus, keyQSLDefaultQRZComCfm,
		keyQSLDefaultHamlogStatus, keyQSLDefaultHamlogCfm,
		keyQSLDefaultHamqthStatus,
	)
	if err != nil {
		return out, err
	}
	out.QSLSent = m[keyQSLDefaultQSLSent]
	out.QSLRcvd = m[keyQSLDefaultQSLRcvd]
	out.LOTWSent = m[keyQSLDefaultLOTWSent]
	out.LOTWRcvd = m[keyQSLDefaultLOTWRcvd]
	out.EQSLSent = m[keyQSLDefaultEQSLSent]
	out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
	out.ClublogStatus = m[keyQSLDefaultClublogStatus]
	out.ClublogCfm = m[keyQSLDefaultClublogCfm]
	out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
	out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
	out.QRZComCfm = m[keyQSLDefaultQRZComCfm]
	out.HamlogStatus = m[keyQSLDefaultHamlogStatus]
	out.HamqthStatus = m[keyQSLDefaultHamqthStatus]
	out.HamlogCfm = m[keyQSLDefaultHamlogCfm]
	return out, nil
}

// SaveQSLDefaults persists the configured defaults. Future QSO inserts
// pick them up automatically — no app restart needed.
func (a *App) SaveQSLDefaults(d QSLDefaults) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	scope := a.profileScope()
	for k, v := range map[string]string{
		keyQSLDefaultQSLSent:       strings.ToUpper(strings.TrimSpace(d.QSLSent)),
		keyQSLDefaultQSLRcvd:       strings.ToUpper(strings.TrimSpace(d.QSLRcvd)),
		keyQSLDefaultLOTWSent:      strings.ToUpper(strings.TrimSpace(d.LOTWSent)),
		keyQSLDefaultLOTWRcvd:      strings.ToUpper(strings.TrimSpace(d.LOTWRcvd)),
		keyQSLDefaultEQSLSent:      strings.ToUpper(strings.TrimSpace(d.EQSLSent)),
		keyQSLDefaultEQSLRcvd:      strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
		keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
		keyQSLDefaultClublogCfm:    strings.ToUpper(strings.TrimSpace(d.ClublogCfm)),
		keyQSLDefaultHRDLogStatus:  strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
		keyQSLDefaultQRZComStatus:  strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
		keyQSLDefaultQRZComCfm:     strings.ToUpper(strings.TrimSpace(d.QRZComCfm)),
		keyQSLDefaultHamlogStatus:  strings.ToUpper(strings.TrimSpace(d.HamlogStatus)),
		keyQSLDefaultHamqthStatus:  strings.ToUpper(strings.TrimSpace(d.HamqthStatus)),
		keyQSLDefaultHamlogCfm:     strings.ToUpper(strings.TrimSpace(d.HamlogCfm)),
	} {
		if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
			return err
		}
	}
	if err := a.settings.Set(a.ctx, scope+markerQSL, "1"); err != nil {
		return err
	}
	return nil
}

// applyQSLDefaults stamps the user-configured defaults onto a QSO when
// the corresponding fields are still empty. Called from every save path
// (manual entry via AddQSO, UDP auto-log via LogUDPLoggedADIF) so the
// confirmations columns always reflect the user's preferences.
func (a *App) applyQSLDefaults(q *qso.QSO) {
	if a.settings == nil {
		return
	}
	d, err := a.GetQSLDefaults()
	if err != nil {
		return
	}
	applyQSLDefaultsTo(q, d)
}

// applyQSLDefaultsTo stamps the operator's default confirmation statuses on the
// fields a QSO leaves EMPTY. Split from the settings lookup so the rule can be
// tested: fill the blanks, never touch a value that is already there.
//
// The distinction matters on import. A WSJT-X log carries almost no QSL fields,
// so without this every imported QSO would sit with blank statuses; a log
// exported from another program carries real ones, and overwriting those would
// erase confirmations the operator has actually received.
func applyQSLDefaultsTo(q *qso.QSO, d QSLDefaults) {
	fill := func(dst *string, def string) {
		if *dst == "" {
			*dst = def
		}
	}
	fill(&q.QSLSent, d.QSLSent)
	fill(&q.QSLRcvd, d.QSLRcvd)
	fill(&q.LOTWSent, d.LOTWSent)
	fill(&q.LOTWRcvd, d.LOTWRcvd)
	fill(&q.EQSLSent, d.EQSLSent)
	fill(&q.EQSLRcvd, d.EQSLRcvd)
	fill(&q.ClublogUploadStatus, d.ClublogStatus)
	fill(&q.ClublogDownloadStatus, d.ClublogCfm)
	fill(&q.HRDLogUploadStatus, d.HRDLogStatus)
	fill(&q.QRZComUploadStatus, d.QRZComStatus)
	fill(&q.QRZComDownloadStatus, d.QRZComCfm)
	// The extras, which fill() cannot reach: it writes through a pointer to a
	// string field, and these two are map entries. Only set when the QSO does not
	// already carry them, which is the same rule fill() applies.
	setExtraDefault(q, hamlogSentKey, d.HamlogStatus)
	setExtraDefault(q, hamqthSentKey, d.HamqthStatus)
	setExtraDefault(q, award.HamlogQSLKey, d.HamlogCfm)
}

// setExtraDefault writes a default into an ADIF extras key, leaving an existing
// value alone. A blank default means "say nothing", not "write an empty string":
// an empty extra would be carried into every export.
func setExtraDefault(q *qso.QSO, key, def string) {
	def = strings.ToUpper(strings.TrimSpace(def))
	if def == "" {
		return
	}
	if q.Extras == nil {
		q.Extras = map[string]string{}
	}
	if strings.TrimSpace(q.Extras[key]) == "" {
		q.Extras[key] = def
	}
}

// ── External services (logbook upload) ─────────────────────────────────

// loadExternalServices reads the configured external-service settings.
// ── Per-profile settings scoping ───────────────────────────────────────
//
// External Services and QSL Confirmations are scoped to the active profile
// so each operating identity (e.g. F4BPO vs TM2Q) uploads to its own
// accounts. They live under a "p<profileID>." key prefix. A per-group marker
// key records that a profile has saved its own copy; until then we
// transparently read the legacy un-prefixed (global) keys as the default —
// a lossless migration for logs created before profiles carried settings.
const (
	markerExtsvc = "extsvc._set"
	markerQSL    = "qsl._set"
)

// profileScope returns the active profile's settings-key prefix ("p<id>.").
func (a *App) profileScope() string {
	if a.profiles != nil {
		if p, err := a.profiles.Active(a.ctx); err == nil && p.ID > 0 {
			return fmt.Sprintf("p%d.", p.ID)
		}
	}
	return "p0."
}

// profileHasGroup reports whether the active profile has saved its own copy
// of a settings group (identified by its marker key).
func (a *App) profileHasGroup(marker string) bool {
	if a.settings == nil {
		return false
	}
	v, _ := a.settings.Get(a.ctx, a.profileScope()+marker)
	return v == "1"
}

// getManyScoped fetches base keys with the given prefix, returning a map
// keyed by the BASE key (so callers index with the plain constant).
func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, error) {
	out := make(map[string]string, len(keys))
	for _, k := range keys {
		v, err := a.settings.Get(a.ctx, prefix+k)
		if err != nil {
			return nil, err
		}
		out[k] = v
	}
	return out, nil
}

// parseUploadFlags resolves the LoTW "treat as unsent" status set: prefer the
// CSV (new multi-select), fall back to the legacy single flag, and default to
// N+R when nothing is configured (covers an imported ADIF still marked unsent).
func parseUploadFlags(csv, legacy string) []string {
	add := func(dst []string, seen map[string]bool, raw string) []string {
		for _, p := range strings.Split(raw, ",") {
			f := strings.ToUpper(strings.TrimSpace(p))
			if (f == "N" || f == "R") && !seen[f] {
				seen[f] = true
				dst = append(dst, f)
			}
		}
		return dst
	}
	seen := map[string]bool{}
	out := add(nil, seen, csv)
	if len(out) == 0 {
		out = add(out, seen, legacy)
	}
	if len(out) == 0 {
		return []string{"N", "R"}
	}
	return out
}

func (a *App) loadExternalServices() extsvc.ExternalServices {
	var out extsvc.ExternalServices
	if a.settings == nil {
		return out
	}
	// Read the active profile's scoped keys once it has saved them; otherwise
	// fall back to the legacy global keys as the shared default.
	prefix := ""
	if a.profileHasGroup(markerExtsvc) {
		prefix = a.profileScope()
	}
	m, err := a.getManyScoped(prefix,
		keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
		keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
		keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
		keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
		keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
		keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
		keyExtLoTWUsername, keyExtLoTWWebPassword,
		keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode, keyExtHRDLogOnAir,
		keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
		keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
		keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
		keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode,
		keyExtHamqthUsername, keyExtHamqthPassword, keyExtHamqthCallsign,
		keyExtHamqthAutoUpload, keyExtHamqthUploadMode)
	if err != nil {
		return out
	}
	out.DeleteRemote = m[keyExtDeleteRemote] == "1"
	out.QRZ = extsvc.ServiceConfig{
		APIKey:               m[keyExtQRZAPIKey],
		ForceStationCallsign: m[keyExtQRZForceCall],
		AutoUpload:           m[keyExtQRZAutoUpload] == "1",
		UploadMode:           extsvc.UploadMode(m[keyExtQRZUploadMode]),
	}
	out.Clublog = extsvc.ServiceConfig{
		Email:      m[keyExtClublogEmail],
		Password:   m[keyExtClublogPassword],
		Callsign:   m[keyExtClublogCallsign],
		APIKey:     m[keyExtClublogAPIKey],
		AutoUpload: m[keyExtClublogAutoUpload] == "1",
		UploadMode: extsvc.UploadMode(m[keyExtClublogUploadMode]),
	}
	// Default the Club Log logbook callsign to the active profile's call
	// when the user hasn't overridden it.
	if out.Clublog.Callsign == "" && a.profiles != nil {
		if p, perr := a.profiles.Active(a.ctx); perr == nil {
			out.Clublog.Callsign = p.Callsign
		}
	}
	out.LoTW = extsvc.ServiceConfig{
		TQSLPath:             m[keyExtLoTWTQSLPath],
		StationLocation:      m[keyExtLoTWStationLoc],
		ForceStationCallsign: m[keyExtLoTWForceCall],
		KeyPassword:          m[keyExtLoTWKeyPassword],
		UploadFlags:          parseUploadFlags(m[keyExtLoTWUploadFlags], m[keyExtLoTWUploadFlag]),
		WriteLog:             m[keyExtLoTWWriteLog] == "1",
		Username:             m[keyExtLoTWUsername],
		Password:             m[keyExtLoTWWebPassword],
		AutoUpload:           m[keyExtLoTWAutoUpload] == "1",
		// LoTW only ever uploads as an on-close batch (ARRL discourages per-QSO
		// uploads), so the UI offers no other timing. Force it here so configs
		// saved by older builds — which stored "immediate" — still batch at close.
		UploadMode: extsvc.ModeOnClose,
	}
	// Default the TQSL path to the standard install location when unset, so
	// the field is pre-populated if TQSL is present.
	if out.LoTW.TQSLPath == "" {
		out.LoTW.TQSLPath = extsvc.DefaultTQSLPath()
	}
	out.HRDLog = extsvc.ServiceConfig{
		Callsign:   m[keyExtHRDLogCallsign],
		Code:       m[keyExtHRDLogCode],
		AutoUpload: m[keyExtHRDLogAutoUpload] == "1",
		UploadMode: extsvc.UploadMode(m[keyExtHRDLogUploadMode]),
		OnAir:      m[keyExtHRDLogOnAir] == "1",
	}
	// Default the HRDLog callsign to the active profile's call when unset.
	if out.HRDLog.Callsign == "" && a.profiles != nil {
		if p, perr := a.profiles.Active(a.ctx); perr == nil {
			out.HRDLog.Callsign = p.Callsign
		}
	}
	out.EQSL = extsvc.ServiceConfig{
		Username:    m[keyExtEQSLUsername],
		Password:    m[keyExtEQSLPassword],
		QTHNickname: m[keyExtEQSLQTHNick],
		AutoUpload:  m[keyExtEQSLAutoUpload] == "1",
		UploadMode:  extsvc.UploadMode(m[keyExtEQSLUploadMode]),
	}
	// Default the eQSL username to the active profile's call when unset.
	if out.EQSL.Username == "" && a.profiles != nil {
		if p, perr := a.profiles.Active(a.ctx); perr == nil {
			out.EQSL.Username = p.Callsign
		}
	}
	out.Cloudlog = extsvc.ServiceConfig{
		URL:        m[keyExtCloudlogURL],
		APIKey:     m[keyExtCloudlogAPIKey],
		StationID:  m[keyExtCloudlogStationID],
		AutoUpload: m[keyExtCloudlogAutoUpload] == "1",
		UploadMode: extsvc.UploadMode(m[keyExtCloudlogUploadMode]),
	}
	out.Hamlog = extsvc.ServiceConfig{
		APIKey:     m[keyExtHamlogAPIKey],
		AutoUpload: m[keyExtHamlogAutoUpload] == "1",
		UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]),
	}
	out.HamQTH = extsvc.ServiceConfig{
		Username:   m[keyExtHamqthUsername],
		Password:   m[keyExtHamqthPassword],
		Callsign:   m[keyExtHamqthCallsign],
		AutoUpload: m[keyExtHamqthAutoUpload] == "1",
		UploadMode: extsvc.UploadMode(m[keyExtHamqthUploadMode]),
	}
	// The callbook lookup already knows these credentials; blanks fall back to
	// them so an operator who set up the lookup years ago is one checkbox away.
	if out.HamQTH.Username == "" && out.HamQTH.Password == "" {
		u, _ := a.settings.Get(a.ctx, keyHQUser)
		p, _ := a.settings.Get(a.ctx, keyHQPassword)
		out.HamQTH.Username = strings.TrimSpace(u)
		out.HamQTH.Password = p
	}
	return out
}

// GetExternalServices returns the saved external-service configuration.
func (a *App) GetExternalServices() (extsvc.ExternalServices, error) {
	return a.loadExternalServices(), nil
}

// SaveExternalServices persists the config and reloads the live manager so
// the next logged QSO uses the new settings (no restart needed).
func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	// Preserve the chosen upload timing — including "on_close", which the LoTW
	// batch flush at shutdown depends on. (A previous version collapsed anything
	// that wasn't "delayed" to "immediate", silently disabling on-close upload.)
	modeOf := func(m extsvc.UploadMode) string {
		switch m {
		case extsvc.ModeDelayed:
			return string(extsvc.ModeDelayed)
		case extsvc.ModeOnClose:
			return string(extsvc.ModeOnClose)
		default:
			return string(extsvc.ModeImmediate)
		}
	}
	mode := modeOf(cfg.QRZ.UploadMode)
	auto := "0"
	if cfg.QRZ.AutoUpload {
		auto = "1"
	}
	clMode := modeOf(cfg.Clublog.UploadMode)
	clAuto := "0"
	if cfg.Clublog.AutoUpload {
		clAuto = "1"
	}
	ltMode := modeOf(cfg.LoTW.UploadMode)
	ltAuto := "0"
	if cfg.LoTW.AutoUpload {
		ltAuto = "1"
	}
	ltFlags := strings.Join(parseUploadFlags(strings.Join(cfg.LoTW.UploadFlags, ","), ""), ",")
	ltWriteLog := "0"
	if cfg.LoTW.WriteLog {
		ltWriteLog = "1"
	}
	hlMode := modeOf(cfg.HRDLog.UploadMode)
	hlAuto := "0"
	if cfg.HRDLog.AutoUpload {
		hlAuto = "1"
	}
	eqMode := modeOf(cfg.EQSL.UploadMode)
	eqAuto := "0"
	if cfg.EQSL.AutoUpload {
		eqAuto = "1"
	}
	// Cloudlog has no per-QSO upload-status column in the logbook, so the
	// on-close sweep (which selects by that column) cannot apply — force any
	// stored on_close back to immediate rather than silently doing nothing.
	clgMode := modeOf(cfg.Cloudlog.UploadMode)
	if clgMode == string(extsvc.ModeOnClose) {
		clgMode = string(extsvc.ModeImmediate)
	}
	clgAuto := "0"
	if cfg.Cloudlog.AutoUpload {
		clgAuto = "1"
	}
	// Same reasoning as Cloudlog: HAMLOG has no per-QSO upload-status column to
	// select on, so an on-close sweep would have nothing to sweep.
	hamMode := modeOf(cfg.Hamlog.UploadMode)
	if hamMode == string(extsvc.ModeOnClose) {
		hamMode = string(extsvc.ModeImmediate)
	}
	hqMode := modeOf(cfg.HamQTH.UploadMode)
	hamAuto := "0"
	if cfg.Hamlog.AutoUpload {
		hamAuto = "1"
	}
	delRemote := "0"
	if cfg.DeleteRemote {
		delRemote = "1"
	}
	scope := a.profileScope() // write under the active profile's prefix
	for k, v := range map[string]string{
		keyExtDeleteRemote:  delRemote,
		keyExtQRZAPIKey:     strings.TrimSpace(cfg.QRZ.APIKey),
		keyExtQRZForceCall:  strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
		keyExtQRZAutoUpload: auto,
		keyExtQRZUploadMode: mode,

		keyExtClublogEmail:      strings.TrimSpace(cfg.Clublog.Email),
		keyExtClublogPassword:   cfg.Clublog.Password,
		keyExtClublogCallsign:   strings.ToUpper(strings.TrimSpace(cfg.Clublog.Callsign)),
		keyExtClublogAPIKey:     strings.TrimSpace(cfg.Clublog.APIKey),
		keyExtClublogAutoUpload: clAuto,
		keyExtClublogUploadMode: clMode,

		keyExtLoTWTQSLPath:    strings.TrimSpace(cfg.LoTW.TQSLPath),
		keyExtLoTWStationLoc:  strings.TrimSpace(cfg.LoTW.StationLocation),
		keyExtLoTWForceCall:   strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
		keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
		keyExtLoTWUploadFlags: ltFlags,
		keyExtLoTWWriteLog:    ltWriteLog,
		keyExtLoTWAutoUpload:  ltAuto,
		keyExtLoTWUploadMode:  ltMode,
		keyExtLoTWUsername:    strings.TrimSpace(cfg.LoTW.Username),
		keyExtLoTWWebPassword: cfg.LoTW.Password,

		keyExtHRDLogCallsign:   strings.ToUpper(strings.TrimSpace(cfg.HRDLog.Callsign)),
		keyExtHRDLogCode:       strings.TrimSpace(cfg.HRDLog.Code),
		keyExtHRDLogAutoUpload: hlAuto,
		keyExtHRDLogUploadMode: hlMode,
		keyExtHRDLogOnAir:      boolStr(cfg.HRDLog.OnAir),

		keyExtEQSLUsername:   strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)),
		keyExtEQSLPassword:   cfg.EQSL.Password,
		keyExtEQSLQTHNick:    strings.TrimSpace(cfg.EQSL.QTHNickname),
		keyExtEQSLAutoUpload: eqAuto,
		keyExtEQSLUploadMode: eqMode,

		keyExtCloudlogURL:        strings.TrimSpace(cfg.Cloudlog.URL),
		keyExtCloudlogAPIKey:     strings.TrimSpace(cfg.Cloudlog.APIKey),
		keyExtCloudlogStationID:  strings.TrimSpace(cfg.Cloudlog.StationID),
		keyExtCloudlogAutoUpload: clgAuto,
		keyExtCloudlogUploadMode: clgMode,

		keyExtHamlogAPIKey:     strings.TrimSpace(cfg.Hamlog.APIKey),
		keyExtHamlogAutoUpload: hamAuto,
		keyExtHamlogUploadMode: hamMode,
		keyExtHamqthUsername:   strings.TrimSpace(cfg.HamQTH.Username),
		keyExtHamqthPassword:   cfg.HamQTH.Password,
		keyExtHamqthCallsign:   strings.ToUpper(strings.TrimSpace(cfg.HamQTH.Callsign)),
		keyExtHamqthAutoUpload: boolStr(cfg.HamQTH.AutoUpload),
		keyExtHamqthUploadMode: hqMode,
	} {
		if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
			return err
		}
	}
	// Mark this profile as having its own External Services config (so future
	// loads read the scoped keys instead of falling back to the global ones).
	if err := a.settings.Set(a.ctx, scope+markerExtsvc, "1"); err != nil {
		return err
	}
	if a.extsvc != nil {
		a.extsvc.SetConfig(a.loadExternalServices())
	}
	return nil
}

// TestQRZUpload validates the configured QRZ key by querying the logbook's
// status (ACTION=STATUS). Returns a human-readable message for the UI.
func (a *App) TestQRZUpload() (string, error) {
	cfg := a.loadExternalServices().QRZ
	return extsvc.TestQRZ(a.ctx, nil, cfg.APIKey)
}

// TestClublogUpload validates that the Club Log credentials are complete.
func (a *App) TestClublogUpload() (string, error) {
	return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
}

// UploadFullLogHamQTH replaces the HamQTH log with this one, in one request.
//
// The per-QSO API is the only correct way to send a SELECTION, and at the pace
// it has to be driven a full backlog costs the better part of an hour. This is
// the other endpoint HamQTH offers: a whole log as one file, which is why it
// only ever runs on an explicit "replace my HamQTH log" — the site keeps
// nothing that is not in the file.
//
// Scoped to the callsign this profile uploads as: a database holding two
// operators' contacts must not push one operator's QSOs into the other's log.
func (a *App) UploadFullLogHamQTH() error {
	if a.qso == nil {
		return fmt.Errorf("db not initialized")
	}
	cfg := a.loadExternalServices().HamQTH
	if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
		return fmt.Errorf("set the HamQTH username and password first")
	}
	go a.runFullLogHamQTH(cfg)
	return nil
}

func (a *App) runFullLogHamQTH(cfg extsvc.ServiceConfig) {
	emit := func(line string) {
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
		}
	}
	done := func(n int) {
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": n, "total": n})
		}
	}
	ctx := a.ctx
	owner := a.uploadOwnerCall(extsvc.ServiceHamQTH)

	// Written to a temp file rather than a buffer so the ordinary, tested
	// exporter does the work — the same one the Export menu uses.
	tmp, err := os.CreateTemp("", "opslog-hamqth-*.adi")
	if err != nil {
		emit("Export failed: " + err.Error())
		done(0)
		return
	}
	path := tmp.Name()
	_ = tmp.Close()
	defer os.Remove(path)

	if owner != "" {
		emit("Station callsign: " + owner + " — QSOs logged under another of your callsigns are NOT included.")
	}
	emit("Exporting the log…")
	var res adif.ExportResult
	if owner != "" {
		// station_callsign empty OR the owner call — an old QSO logged before
		// the field existed belongs to whoever is uploading now.
		f := qso.QueryFilter{Match: "OR", Conditions: []qso.Condition{
			{Field: "station_callsign", Op: "eq", Value: ""},
			{Field: "station_callsign", Op: "eq", Value: owner},
		}}
		res, err = a.ExportADIFFiltered(path, false, f, nil)
	} else {
		res, err = a.ExportADIF(path, false, nil)
	}
	if err != nil {
		emit("Export failed: " + err.Error())
		done(0)
		return
	}
	data, rerr := os.ReadFile(path)
	if rerr != nil {
		emit("Export failed: " + rerr.Error())
		done(0)
		return
	}
	total, _ := a.qso.Count(ctx)
	if total > 0 && int64(res.Count) != total {
		emit(fmt.Sprintf("%d of %d QSOs in this logbook match %s and will be sent.", res.Count, total, owner))
	}
	emit(fmt.Sprintf("Exported %d QSO(s), %d KB of ADIF.", res.Count, res.SizeKB))
	emit(fmt.Sprintf("Uploading to HamQTH — this REPLACES the log there…"))

	up, uerr := extsvc.UploadHamQTHFullLog(ctx, nil, cfg, string(data))
	if uerr != nil || !up.OK {
		msg := up.Message
		if uerr != nil {
			msg = uerr.Error()
		}
		emit("Upload failed: " + msg)
		applog.Printf("hamqth: full-log upload failed: %s", msg)
		done(0)
		return
	}
	emit("HamQTH replied: " + up.Message)
	// Said plainly, because the numbers will not agree for a while and an
	// operator comparing them straight away has every reason to think the
	// upload failed: HamQTH ACCEPTS the file here and imports it later, at its
	// own pace. What it makes of each record is reported by E-MAIL, never in
	// this reply — so a count that stops short means the site rejected records,
	// and the mail says which.
	emit("Accepted — HamQTH imports the file in the BACKGROUND, so its QSO count will lag for a while.")
	emit("If the count stops short, HamQTH e-mails the ADIF errors to your account address — this reply cannot carry them.")

	// Everything is on HamQTH now, so nothing is still waiting to be sent. Only
	// the rows that are not already stamped need writing.
	pending, lerr := a.qso.ListMissingExtra(ctx, hamqthSentKey)
	if lerr != nil {
		applog.Printf("hamqth: marking sent: %v", lerr)
	} else if len(pending) > 0 {
		ids := make([]int64, 0, len(pending))
		for _, q := range pending {
			ids = append(ids, q.ID)
		}
		date := time.Now().UTC().Format("20060102")
		if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentKey, "Y"); e != nil {
			applog.Printf("hamqth: marking sent: %v", e)
		}
		if _, e := a.qso.BulkSetExtra(ctx, ids, hamqthSentDateKey, date); e != nil {
			applog.Printf("hamqth: marking sent date: %v", e)
		}
		emit(fmt.Sprintf("Marked %d QSO(s) as sent to HamQTH.", len(ids)))
	}
	applog.Printf("hamqth: full-log upload OK (%d QSOs)", res.Count)
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("HamQTH: %d QSO uploaded", res.Count))
	}
	done(res.Count)
}

// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
// authenticated, and unable to touch the log.
func (a *App) TestHamQTHUpload() (string, error) {
	cfg := a.loadExternalServices().HamQTH
	return extsvc.TestHamQTH(a.ctx, nil, cfg)
}

// TestHRDLogUpload validates that the HRDLog credentials are complete.
func (a *App) TestHRDLogUpload() (string, error) {
	return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
}

// TestEQSLUpload validates the eQSL credentials with a real (no-op) request.
func (a *App) TestEQSLUpload() (string, error) {
	return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
}

// TestCloudlogUpload checks the Cloudlog/Wavelog URL, API key and station id
// against the user's own instance, without inserting anything.
func (a *App) TestCloudlogUpload() (string, error) {
	return extsvc.TestCloudlog(a.ctx, nil, a.loadExternalServices().Cloudlog)
}

// ── QSL Manager (manual upload) ────────────────────────────────────────

// uploadColumnFor maps a service id to its QSO sent-status column.
// manualUploadPace is the shortest gap between two consecutive single-QSO
// uploads in a bulk "Send to …" run, for the services with no batch endpoint
// (QRZ.com, HRDLog). Selecting twenty-five thousand QSOs in the QSL Manager and
// firing them off as fast as the link allows is exactly the traffic a logbook
// service reads as a robot rather than an operator — Club Log threatens to block
// an IP for it. The gap is free in practice: a round trip to either already
// takes longer than it.
const manualUploadPace = 200 * time.Millisecond

func uploadColumnFor(service string) string {
	switch extsvc.Service(service) {
	case extsvc.ServiceQRZ:
		return "qrzcom_qso_upload_status"
	case extsvc.ServiceClublog:
		return "clublog_qso_upload_status"
	case extsvc.ServiceLoTW:
		return "lotw_sent"
	case extsvc.ServiceHRDLog:
		return "hrdlog_qso_upload_status"
	case extsvc.ServiceEQSL:
		return "eqsl_sent"
	}
	return ""
}

// FindQSOsForUpload returns QSOs whose sent status for the given service
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
// FindQSOsForUpload returns FULL QSO rows eligible for upload to a service, so
// the QSL Manager shows the same rich, column-pickable table as Recent QSOs.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
	if a.qso == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	// HAMLOG.online keeps its state in an ADIF extra rather than a column, so
	// its backlog is "the extra is absent" instead of "the column is blank".
	// The sent-status filter has nothing to select on — a QSO has gone or it has
	// not — so it is ignored rather than pretended to work.
	if extsvc.Service(service) == extsvc.ServiceHamlog {
		return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
	}
	if extsvc.Service(service) == extsvc.ServiceHamQTH {
		return a.qso.ListMissingExtra(a.ctx, hamqthSentKey)
	}
	col := uploadColumnFor(service)
	if col == "" {
		return nil, fmt.Errorf("unknown service %q", service)
	}
	return a.qso.ListForUploadFull(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
}

// UploadQSOsManual uploads the given QSO ids to a service on demand
// (regardless of their current sent status — the user picked them). Runs in
// the background, emitting "qslmgr:log" lines and a final "qslmgr:done".
func (a *App) UploadQSOsManual(service string, ids []int64) error {
	if a.qso == nil {
		return fmt.Errorf("db not initialized")
	}
	svc := extsvc.Service(service)
	// Cloudlog/Wavelog has no sent-status column ON PURPOSE — it dedupes
	// server-side, which is exactly why an explicit selection needs no column to
	// be safe. HAMLOG.online and HamQTH keep theirs in ADIF extras.
	if svc == extsvc.ServiceHamlog {
		return extsvc.ErrHamlogClosed
	}
	if uploadColumnFor(service) == "" &&
		svc != extsvc.ServiceHamQTH && svc != extsvc.ServiceCloudlog {
		return fmt.Errorf("unknown service %q", service)
	}
	cfg := a.loadExternalServices()
	// NOT CONFIGURED IS AN ANSWER, AND IT BELONGS HERE.
	//
	// The upload runs on its own goroutine and reports through the QSL Manager's
	// console, which is not open when the command was given from a right-click in
	// the QSO list: an upload to a service with no credentials looked exactly
	// like one that worked. Refused synchronously instead, where the caller can
	// show it.
	if err := uploadConfigured(svc, cfg); err != nil {
		return err
	}
	go a.runManualUpload(svc, ids, cfg)
	return nil
}

// uploadConfigured reports whether a service can be uploaded to at all.
//
// The rules live in internal/extsvc, beside the uploaders that enforce them.
// Written here instead they drifted at once: Club Log was refused for a missing
// API key that nobody has ever set — OpsLog carries its own application key —
// and an operator whose live upload had worked for months was told his service
// was not configured.
func uploadConfigured(svc extsvc.Service, cfg extsvc.ExternalServices) error {
	if err := extsvc.Configured(svc, cfg); err != nil {
		return fmt.Errorf("%w (Settings → External services)", err)
	}
	return nil
}

func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
	emit := func(line string) {
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
		}
	}
	ctx := a.ctx
	uploaded := 0

	if svc == extsvc.ServiceLoTW {
		emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids)))
		var recs []string
		for _, id := range ids {
			if rec, ok := a.buildUploadADIF(id, cfg.LoTW.ForceStationCallsign); ok {
				recs = append(recs, rec)
			}
		}
		res, err := extsvc.UploadLoTW(ctx, cfg.LoTW, "", strings.Join(recs, "\n"))
		if err != nil || !res.OK {
			// The DETAIL wins over the error string. UploadLoTW returns both: a
			// terse error ("no QSOs processed") and a Message carrying TQSL's own
			// account of what happened to the contacts ("…already uploaded", "…out
			// of date range"). Taking the error whenever there was one threw the
			// answer away and showed the operator the half that explains nothing.
			msg := strings.TrimSpace(res.Message)
			if msg == "" && err != nil {
				msg = err.Error()
			} else if err != nil && !strings.Contains(msg, err.Error()) {
				msg = msg + " (" + err.Error() + ")"
			}
			emit("LoTW upload failed: " + msg)
			emit("  The station location OpsLog signs with must match the callsign on these contacts, and their dates must fall inside the certificate's validity — TQSL refuses the whole batch otherwise.")
			// The qslmgr:log console is only visible in the QSL Manager — a failure
			// triggered from the Recent QSOs right-click was completely silent, which
			// read as "send to LoTW does nothing". Surface it as a toast too.
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "toast", "LoTW upload failed: "+msg)
			}
		} else {
			for _, id := range ids {
				a.markExtUploaded(svc, id, "")
				uploaded++
			}
			emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
			// TQSL accepted the batch but left contacts out of it — already
			// uploaded, or outside the certificate's date range. Said out loud:
			// the operator has no other way to learn that some of what they just
			// selected is still not on LoTW.
			if res.Ignored {
				emit(res.Message)
				if a.ctx != nil {
					wruntime.EventsEmit(a.ctx, "toast", "LoTW: "+res.Message)
				}
			}
		}
	} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog || svc == extsvc.ServiceEQSL {
		statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date"
		switch svc {
		case extsvc.ServiceHRDLog:
			statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date"
		case extsvc.ServiceEQSL:
			statusCol, dateCol = "eqsl_sent", "eqsl_sent_date"
		}
		type item struct {
			id   int64
			rec  string
			call string
		}
		// Fetch the selected QSOs in BULK (chunked IN queries) instead of one
		// GetByID per QSO — on a remote MySQL, 25k individual SELECTs is what made
		// this crawl.
		var items []item
		const fetchChunk = 1000
		for s := 0; s < len(ids); s += fetchChunk {
			e := s + fetchChunk
			if e > len(ids) {
				e = len(ids)
			}
			_ = a.qso.IterateByIDs(ctx, ids[s:e], func(q qso.QSO) error {
				items = append(items, item{id: q.ID, rec: adif.SingleRecordADIF(q), call: q.Callsign})
				return nil
			})
		}
		date := time.Now().UTC().Format("20060102")

		if svc == extsvc.ServiceClublog {
			// Club Log accepts a whole ADIF file (putlogs.php) and dedupes
			// server-side → upload in chunks, one HTTP request per 100 QSOs.
			const chunk = 100
			emit(fmt.Sprintf("Club Log: uploading %d QSO(s) in batches of %d…", len(items), chunk))
			for start := 0; start < len(items); start += chunk {
				end := start + chunk
				if end > len(items) {
					end = len(items)
				}
				batch := items[start:end]
				recs := make([]string, len(batch))
				batchIDs := make([]int64, len(batch))
				for i, it := range batch {
					recs[i] = it.rec
					batchIDs[i] = it.id
				}
				res, err := extsvc.UploadClublogADIF(ctx, nil, cfg.Clublog, adif.BatchRecordsADIF(recs))
				if err == nil && res.OK {
					if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, batchIDs); merr != nil {
						applog.Printf("extsvc: Club Log batch mark: %v", merr)
					}
					uploaded += len(batch)
					emit(fmt.Sprintf("Club Log: %d/%d uploaded", end, len(items)))
				} else {
					msg := res.Message
					if err != nil {
						msg = err.Error()
					}
					// Name the QSOs in the failing batch so a per-record rejection
					// (e.g. a field value nginx's WAF blocks with a 403) can actually
					// be located — otherwise "batch FAILED" hides which contact it is.
					who := make([]string, 0, len(batch))
					for _, it := range batch {
						who = append(who, fmt.Sprintf("%s#%d", it.call, it.id))
					}
					emit(fmt.Sprintf("Club Log: batch of %d FAILED: %s", len(batch), msg))
					applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
				}
			}
		} else if svc == extsvc.ServiceEQSL {
			// eQSL's ImportADIF.cfm is a file importer — it answers "X out of Y
			// records added" — so send a whole chunk per request instead of one
			// request per contact. eQSL asks that an upload stay under about a
			// thousand records; 100 keeps a single refused record from taking the
			// rest of the chunk with it.
			const chunk = 100
			emit(fmt.Sprintf("eQSL: uploading %d QSO(s) in batches of %d…", len(items), chunk))
			for start := 0; start < len(items); start += chunk {
				end := start + chunk
				if end > len(items) {
					end = len(items)
				}
				batch := items[start:end]
				recs := make([]string, len(batch))
				batchIDs := make([]int64, len(batch))
				for i, it := range batch {
					recs[i] = it.rec
					batchIDs[i] = it.id
				}
				res, err := extsvc.UploadEQSLBatch(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, recs)
				if err == nil && res.OK {
					if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, batchIDs); merr != nil {
						applog.Printf("extsvc: eQSL batch mark: %v", merr)
					}
					uploaded += len(batch)
					// eQSL took the file but left records out. It never says WHICH
					// — normally they are QSOs it already holds — so quote its own
					// count rather than claim a clean run.
					if res.Ignored {
						emit(fmt.Sprintf("eQSL: %d/%d uploaded — %s", end, len(items), res.Message))
					} else {
						emit(fmt.Sprintf("eQSL: %d/%d uploaded", end, len(items)))
					}
				} else {
					msg := res.Message
					if err != nil {
						msg = err.Error()
					}
					// Name the QSOs in the failing batch, same as Club Log: a
					// per-record rejection is otherwise impossible to locate.
					who := make([]string, 0, len(batch))
					for _, it := range batch {
						who = append(who, fmt.Sprintf("%s#%d", it.call, it.id))
					}
					emit(fmt.Sprintf("eQSL: batch of %d FAILED: %s", len(batch), msg))
					applog.Printf("extsvc: eQSL batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
				}
			}
		} else {
			// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
			// record ADIF, so upload ONE record per request. The DB stays cheap:
			// bulk fetch above + the marks flushed in batches (not one per QSO).
			// Paced: HRDLog is the one service here with no way to batch, and a
			// few thousand requests as fast as the link allows is what a logbook
			// reads as a robot.
			emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items)))
			var doneIDs []int64
			flush := func() {
				if len(doneIDs) == 0 {
					return
				}
				if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, doneIDs); merr != nil {
					applog.Printf("extsvc: HRDLog batch mark: %v", merr)
				}
				doneIDs = doneIDs[:0]
			}
			for i, it := range items {
				if i > 0 {
					time.Sleep(manualUploadPace)
				}
				res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec)
				if err == nil && res.OK {
					doneIDs = append(doneIDs, it.id)
					uploaded++
				} else {
					msg := res.Message
					if err != nil {
						msg = err.Error()
					}
					emit(it.call + " — FAILED: " + msg)
				}
				if len(doneIDs) >= 200 {
					flush()
				}
				if (i+1)%50 == 0 || i+1 == len(items) {
					emit(fmt.Sprintf("HRDLog: %d/%d uploaded", uploaded, len(items)))
				}
			}
			flush()
		}
	} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH || svc == extsvc.ServiceCloudlog {
		// One record per request, extras-stamped services. Hamlog used to fall
		// through to the QRZ branch below and got uploaded to the WRONG service
		// with the QRZ key — the context menu offered what the loop never handled.
		for i, id := range ids {
			if i > 0 {
				time.Sleep(manualUploadPace)
			}
			q, gerr := a.qso.GetByID(ctx, id)
			call := ""
			if gerr == nil {
				call = q.Callsign
			}
			rec, ok := a.buildUploadADIF(id, "")
			if !ok {
				emit(call + " — skipped (no record)")
				continue
			}
			var res extsvc.UploadResult
			var err error
			switch svc {
			case extsvc.ServiceHamlog:
				res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
			case extsvc.ServiceHamQTH:
				res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
			default:
				res, err = extsvc.UploadCloudlog(ctx, nil, cfg.Cloudlog, rec)
			}
			if err == nil && res.OK {
				a.markExtUploaded(svc, id, res.LogID)
				uploaded++
				emit(call + " — OK")
			} else {
				msg := res.Message
				if err != nil {
					msg = err.Error()
				}
				emit(call + " — FAILED: " + msg)
			}
		}
	} else {
		// QRZ.com: one record per request (its logbook API has no batch upload),
		// paced for the same reason as HRDLog above.
		for i, id := range ids {
			if i > 0 {
				time.Sleep(manualUploadPace)
			}
			q, gerr := a.qso.GetByID(ctx, id)
			call := ""
			if gerr == nil {
				call = q.Callsign
			}
			// QRZ rewrites STATION_CALLSIGN to the registered call.
			rec, ok := a.buildUploadADIF(id, cfg.QRZ.ForceStationCallsign)
			if !ok {
				emit(call + " — skipped (no record)")
				continue
			}
			// Only QRZ reaches this branch: LoTW, Club Log, HRDLog and eQSL are
			// all handled above, and UploadQSOsManual rejects anything else.
			res, err := extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
			if err == nil && res.OK {
				a.markExtUploaded(svc, id, "")
				uploaded++
				emit(call + " — OK")
			} else {
				msg := res.Message
				if err != nil {
					msg = err.Error()
				}
				emit(call + " — FAILED: " + msg)
			}
		}
	}
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
		// Always end with a visible summary — the right-click "Send to …" runs in
		// the background and the per-QSO log lives in the QSL Manager console.
		label := map[extsvc.Service]string{
			extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
			extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
			extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
			extsvc.ServiceCloudlog: "Cloudlog / Wavelog",
		}[svc]
		if label == "" {
			label = string(svc)
		}
		wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%s: %d/%d QSO uploaded", label, uploaded, len(ids)))
	}
}

// ConfirmationItem is one downloaded confirmation shown in the QSL Manager,
// with award-style NEW flags computed against the log's prior confirmations.
type ConfirmationItem struct {
	Callsign string `json:"callsign"`
	// Station is the callsign the QSO was made UNDER, which the download can now
	// span ("All my callsigns"): a list mixing F4BPO, F4BPO/P and TM2Q says
	// nothing useful unless each line says which of them it belongs to.
	Station string `json:"station"`
	QSODate string `json:"qso_date"` // ISO UTC
	Band    string `json:"band"`
	Mode    string `json:"mode"`
	Country string `json:"country"`
	NewDXCC bool   `json:"new_dxcc"`
	NewBand bool   `json:"new_band"`
	NewMode bool   `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
	NewSlot bool   `json:"new_slot"`
}

// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
// Manager. It counts over the SAME data path as the DXCC award, using the
// award's confirmation test (award.Confirmed with the DXCC sources lotw+qsl),
// so the numbers agree with the DXCC award matrix. A slot is a distinct
// DXCC × band (mode-agnostic) — i.e. the award's WORKED/CONFIRMED "Grand"
// totals, NOT split per emission.
func (a *App) GetSlotStats() qso.SlotStats {
	if a.qso == nil {
		return qso.SlotStats{}
	}
	// Delegate to the exact same matrix the DXCC award draws: the "WORKED"/
	// "CONFIRMED" (ALL-emission) rows give DXCC entities (Total) and slots =
	// entity × band (GrandTotal). Guaranteed to equal the award's Grand column.
	res, err := a.GetAwardStats("DXCC")
	if err != nil {
		return qso.SlotStats{}
	}
	var st qso.SlotStats
	for _, r := range res.Rows {
		switch r.Label {
		case "WORKED":
			st.SlotsWorked, st.DXCCWorked = r.GrandTotal, r.Total
		case "CONFIRMED":
			st.SlotsConfirmed, st.DXCCConfirmed = r.GrandTotal, r.Total
		}
	}
	return st
}

// --- US Counties (ULS) -----------------------------------------------------

// applyULSCounty fills a US QSO's blank county (and blank grid) from the offline
// ULS store. Non-US QSOs, an un-downloaded store, or already-filled fields make
// it a no-op — a value the operator or QRZ supplied is never overwritten, since
// the ZIP-derived county is only ~98% and the logged one is usually better. The
// award normalises whatever shape cnty holds, so stamping "ST,County" is safe.
func (a *App) applyULSCounty(q *qso.QSO) {
	if a.uls == nil || q.DXCC == nil {
		return
	}
	switch *q.DXCC {
	case 291, 110, 6: // United States, Hawaii, Alaska
	default:
		return
	}
	haveCounty := strings.TrimSpace(q.County) != ""
	haveGrid := strings.TrimSpace(q.Grid) != ""
	if haveCounty && haveGrid {
		return
	}
	loc, ok := a.uls.Resolve(q.Callsign)
	if !ok {
		return
	}
	if !haveCounty {
		q.County = loc.CNTY()
		if strings.TrimSpace(q.State) == "" {
			q.State = loc.State
		}
	}
	if !haveGrid && loc.Grid != "" {
		q.Grid = loc.Grid
	}
}

// UpdateQSOsCountyFromULS re-derives the county of the selected US contacts from
// the offline ULS database, and OVERWRITES what is there.
//
// That is the difference from applyULSCounty beside it, and it is deliberate.
// The automatic pass fills blanks only, because a value the operator or QRZ
// supplied is usually better than one derived from a ZIP code. This one is asked
// for by hand, on a chosen set of rows, precisely BECAUSE the stored county is
// believed wrong — the Connecticut planning regions are the case that prompted
// it: every CT contact logged before that correction holds a county the state
// abolished in 2022, and no amount of filling blanks reaches them.
//
// Only US entities are touched, only callsigns the database knows, and only when
// the answer actually differs — so re-running it on a mixed selection is safe
// and the count reported is the number of counties that really changed.
func (a *App) UpdateQSOsCountyFromULS(ids []int64) (int, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	if a.uls == nil || a.uls.Count() == 0 {
		return 0, fmt.Errorf("the US county database has not been downloaded yet — Settings › Awards › US counties")
	}
	changed := 0
	for _, id := range ids {
		q, err := a.qso.GetByID(a.ctx, id)
		if err != nil || q.DXCC == nil {
			continue
		}
		switch *q.DXCC {
		case 291, 110, 6: // United States, Hawaii, Alaska
		default:
			continue
		}
		loc, ok := a.uls.Resolve(q.Callsign)
		if !ok {
			continue
		}
		cnty := loc.CNTY()
		if cnty == "" {
			continue
		}
		if q.County == cnty && (loc.State == "" || q.State == loc.State) {
			continue
		}
		q.County = cnty
		if loc.State != "" {
			q.State = loc.State
		}
		if a.qso.Update(a.ctx, q) == nil {
			changed++
		}
	}
	if changed > 0 {
		// The county IS the reference for CQ USA-CA and the state for WAS, so the
		// stored award refs are stale the moment it changes.
		a.invalidateAwardStats()
		a.materializeAwardRefsForIDs(ids)
	}
	return changed, nil
}

// ULSStatusResult reports whether the county database is loaded, and how fresh.
type ULSStatusResult struct {
	Count     int    `json:"count"`
	UpdatedAt string `json:"updated_at"` // RFC3339, empty if never downloaded
	// NeedsRefresh is set when the stored data predates a correction to how a
	// callsign's county is derived. The download date alone cannot show this —
	// a database fetched yesterday by an older OpsLog still holds the wrong
	// Connecticut counties — so it is reported as its own flag.
	NeedsRefresh bool `json:"needs_refresh"`
}

// ULSStatus returns the state of the offline US county database.
func (a *App) ULSStatus() ULSStatusResult {
	if a.uls == nil {
		return ULSStatusResult{}
	}
	var updated string
	if t := a.uls.UpdatedAt(); !t.IsZero() {
		updated = t.Format(time.RFC3339)
	}
	return ULSStatusResult{Count: a.uls.Count(), UpdatedAt: updated, NeedsRefresh: a.uls.NeedsRefresh()}
}

// DownloadULSCounties downloads and (re)builds the offline US county database in
// the background, emitting "uls:progress" ({stage,pct}) and "uls:done" ({ok,
// error,count}) so the Settings panel can show progress and reuse one flow.
func (a *App) DownloadULSCounties() error {
	if a.uls == nil {
		return fmt.Errorf("county database unavailable")
	}
	go func() {
		defer func() {
			if r := recover(); r != nil {
				applog.Printf("PANIC in DownloadULSCounties: %v\n%s", r, debug.Stack())
				wruntime.EventsEmit(a.ctx, "uls:done", map[string]any{"ok": false, "error": fmt.Sprintf("%v", r)})
			}
		}()
		err := a.uls.Import(a.ctx, a.dataDir, func(stage string, pct int) {
			wruntime.EventsEmit(a.ctx, "uls:progress", map[string]any{"stage": stage, "pct": pct})
		})
		res := map[string]any{"ok": err == nil, "count": a.uls.Count()}
		if err != nil {
			applog.Printf("uls: import failed: %v", err)
			res["error"] = err.Error()
		} else {
			applog.Printf("uls: import complete — %d callsigns", a.uls.Count())
		}
		wruntime.EventsEmit(a.ctx, "uls:done", res)
	}()
	return nil
}

// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
// ── QSL routing repair (QSL_VIA vs QSL_SENT_VIA) ───────────────────────

// keyQSLViaRepairDone records that the operator has answered the offer to move
// routing words out of the manager field — whether they accepted or declined.
// Asked once per log, never again: a repair that keeps proposing itself after a
// "no" is a nag, and the answer does not change.
const keyQSLViaRepairDone = "migr.qsl_via_routing.v1"

// QSLViaRepairStatus counts the QSOs whose QSL_VIA holds a routing method
// rather than a manager, so the operator can be shown a number before anything
// is touched.
type QSLViaRepairStatus struct {
	Affected int  `json:"affected"` // QSOs holding a routing word in qsl_via
	Asked    bool `json:"asked"`    // the offer has already been answered
}

// QSLViaRepairStatus reports whether this log needs the QSL_VIA repair.
//
// OpsLog wrote "Bureau" / "Direct" / "Electronic" into QSL_VIA from its own QSL
// Manager panel, and ADIF imports folded QSL_SENT_VIA there as well, so the
// manager column in an existing log is a mixture of the two. Moving them is
// safe — a manager is a callsign and can never be one of those words — but it
// rewrites what an operator sees in their own log, so it is counted and offered
// rather than done.
func (a *App) QSLViaRepairStatus() (QSLViaRepairStatus, error) {
	var res QSLViaRepairStatus
	if a.qso == nil {
		return res, fmt.Errorf("db not initialized")
	}
	if a.settings != nil {
		if v, _ := a.settings.GetGlobal(a.ctx, keyQSLViaRepairDone); v == "1" {
			res.Asked = true
		}
	}
	n, err := a.qso.CountQSLViaRouting(a.ctx, adif.IsQSLViaRouting)
	if err != nil {
		return res, err
	}
	res.Affected = n
	return res, nil
}

// QSLViaRepairResult reports what the repair moved.
type QSLViaRepairResult struct {
	Moved int `json:"moved"`
}

// RepairQSLVia moves routing words out of QSL_VIA into QSL_SENT_VIA.
//
// Only where QSL_SENT_VIA is still empty: a QSO that already carries a real
// sent-via — from an import made after this was fixed — knows better than a
// word left in the manager column, and must not be overwritten by it.
func (a *App) RepairQSLVia() (QSLViaRepairResult, error) {
	var res QSLViaRepairResult
	if a.qso == nil {
		return res, fmt.Errorf("db not initialized")
	}
	n, err := a.qso.RepairQSLViaRouting(a.ctx, adif.NormaliseQSLVia)
	res.Moved = n
	if err != nil {
		return res, err
	}
	a.markQSLViaRepairAsked()
	applog.Printf("qsl via repair: moved %d routing values out of the manager field", n)
	return res, nil
}

// DismissQSLViaRepair records a "no" so the offer is not made again.
func (a *App) DismissQSLViaRepair() {
	a.markQSLViaRepairAsked()
}

func (a *App) markQSLViaRepairAsked() {
	if a.settings != nil {
		_ = a.settings.SetGlobal(a.ctx, keyQSLViaRepairDone, "1")
	}
}

// keyDistanceBackfilled marks the one-time distance fill as done.
//
// A migration, not a setting. It was briefly a button in Preferences, which was
// the wrong shape twice over: a maintenance chore does not belong beside the
// options an operator actually chooses, and nobody should have to be TOLD their
// log is missing a field before it gets filled in. It runs once, in the
// background, and never asks.
const keyDistanceBackfilled = "migr.distance_from_grids.v1"

// backfillDistancesOnce fills DISTANCE across the log the first time this
// version runs, then records that it is done.
//
// In the background: on a large log over a remote MySQL this is thousands of
// row updates, and startup must not wait for a tidy-up. Marked done only on
// success — a run cut short by a closed program should try again next time
// rather than leave half the log filled for ever.
func (a *App) backfillDistancesOnce() {
	if a.settings == nil || a.qso == nil {
		return
	}
	if v, _ := a.settings.GetGlobal(a.ctx, keyDistanceBackfilled); v == "1" {
		return
	}
	go func() {
		res, err := a.BackfillDistances()
		if err != nil {
			applog.Printf("distance backfill: %v — will try again next start", err)
			return
		}
		_ = a.settings.SetGlobal(a.ctx, keyDistanceBackfilled, "1")
		applog.Printf("distance backfill: done once for this log (%d filled)", res.Filled)
	}()
}

// BackfillDistancesResult reports what a distance backfill did.
type BackfillDistancesResult struct {
	Scanned int `json:"scanned"` // QSOs examined
	Filled  int `json:"filled"`  // QSOs that gained a distance
	NoGrid  int `json:"no_grid"` // skipped: one of the two locators is missing
}

// BackfillDistances computes DISTANCE for past QSOs that have both locators and
// no distance recorded.
//
// Nothing has ever computed it when logging — the column is only filled by an
// ADIF import that carried one — so a log made in OpsLog has it empty
// throughout. The published web page works around that by deriving the distance
// as it renders, but the column still travels empty into every ADIF export, and
// that is what leaves the gap in someone else's log.
//
// Only fills what is EMPTY. A stored distance came from the log that recorded
// the contact, which knew the real positions; two four-character squares are a
// worse answer and must not overwrite a better one.
func (a *App) BackfillDistances() (BackfillDistancesResult, error) {
	var res BackfillDistancesResult
	if a.qso == nil {
		return res, fmt.Errorf("db not initialized")
	}
	rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
	if err != nil {
		return res, err
	}
	for i := range rows {
		q := rows[i]
		res.Scanned++
		if q.Distance != nil && *q.Distance > 0 {
			continue
		}
		km, ok := geo.DistanceBetweenGrids(q.MyGrid, q.Grid)
		if !ok || km <= 0 {
			res.NoGrid++
			continue
		}
		// Whole kilometres: the squares are tens of kilometres across and a
		// decimal would assert an accuracy the grids do not carry.
		v := math.Round(km)
		q.Distance = &v
		if err := a.qso.Update(a.ctx, q); err != nil {
			applog.Printf("backfill distance: QSO %d: %v", q.ID, err)
			continue
		}
		res.Filled++
	}
	applog.Printf("backfill distance: %d scanned, %d filled, %d without both grids",
		res.Scanned, res.Filled, res.NoGrid)
	if res.Filled > 0 {
		a.invalidateAwardStats()
	}
	return res, nil
}

type BackfillUSCountiesResult struct {
	Scanned int `json:"scanned"` // US QSOs examined
	County  int `json:"county"`  // QSOs that gained a county
	Grid    int `json:"grid"`    // QSOs that gained a grid
}

// BackfillUSCounties resolves county (and grid) for existing US QSOs that lack
// them, using the offline ULS store. It only FILLS blanks — an existing county
// is left as the operator logged it. Returns how many rows were updated.
func (a *App) BackfillUSCounties() (BackfillUSCountiesResult, error) {
	var res BackfillUSCountiesResult
	if a.uls == nil || a.uls.Count() == 0 {
		return res, fmt.Errorf("county database not downloaded")
	}
	if a.qso == nil {
		return res, fmt.Errorf("db not initialized")
	}
	rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
	if err != nil {
		return res, err
	}
	// Set of valid USA-CA county keys, so we can tell a good county from a bad one
	// (a wrong county for the state, a bare city, "0", …) and re-resolve only the
	// bad ones.
	validRef := map[string]bool{}
	if refs, ok := awardref.BuiltinRefs("USA-CA"); ok {
		for _, r := range refs {
			validRef[r.Code] = true
		}
	}
	for i := range rows {
		q := &rows[i]
		if q.DXCC == nil {
			continue
		}
		switch *q.DXCC {
		case 291, 110, 6:
		default:
			continue
		}
		res.Scanned++
		// A county "needs" filling when it maps to NO valid USA-CA county: empty,
		// garbage, or a wrong county for the state. A legitimate DC operation
		// (state=DC) is left alone — DC simply isn't a USA-CA county, not an error.
		curKey := award.USCountyKey(q.State, q.County)
		stateUp := strings.ToUpper(strings.TrimSpace(q.State))
		needCounty := !validRef[curKey] && stateUp != "DC"
		needGrid := strings.TrimSpace(q.Grid) == ""
		if !needCounty && !needGrid {
			continue
		}
		loc, ok := a.uls.Resolve(q.Callsign)
		if !ok {
			continue
		}
		changed := false
		if needCounty {
			// The logged county maps to no real USA-CA county (wrong state, wrong
			// county, empty…). Fix it from the ULS, PREFERRING to keep the operator's
			// county and correct only the state — their county often came from QRZ
			// and is finer than the ZIP-derived one (e.g. K1LZ logged ME,Middlesex →
			// Middlesex is a MA county, so → MA,Middlesex). Fall back to the ULS
			// county when even that is invalid. Never stamp an unrecognised value
			// (e.g. a Connecticut 2022 "planning region" GeoNames now returns). A
			// portable op keeps a suffix that won't resolve in the ULS, so only
			// fixed-station bad data is ever touched.
			name := q.County
			if i := strings.IndexByte(name, ','); i >= 0 {
				name = name[i+1:]
			}
			name = strings.TrimSpace(name)
			var stState, stName string
			if name != "" && validRef[award.USCountyKey(loc.State, name)] {
				stState, stName = loc.State, name
			} else if validRef[award.USCountyKey(loc.State, loc.County)] {
				stState, stName = loc.State, loc.County
			}
			if stName != "" {
				q.State = stState
				q.County = stState + "," + stName
				res.County++
				changed = true
			}
		}
		if needGrid && loc.Grid != "" {
			q.Grid = loc.Grid
			res.Grid++
			changed = true
		}
		if changed {
			if err := a.qso.Update(a.ctx, *q); err != nil {
				applog.Printf("uls backfill: update qso %d failed: %v", q.ID, err)
			}
		}
	}
	a.invalidateAwardStats() // county changes affect the US Counties matrix
	return res, nil
}

// BackfillRDAResult reports what a district backfill did.
type BackfillRDAResult struct {
	Scanned int `json:"scanned"` // Russian QSOs examined
	Dated   int `json:"dated"`   // filled from an activity record covering the QSO day
	Current int `json:"current"` // filled from the callsign's current district (no history)
	Unknown int `json:"unknown"` // callsign not in the database, or no record for that day
	Skipped int `json:"skipped"` // already carried an RDA reference — never overwritten
}

// BackfillRDA stamps the Russian District on existing QSOs from the offline RDA
// database, which is the one source that knows WHERE a callsign was on a GIVEN
// DAY rather than only where it is now.
//
// useCurrent decides what happens for the 43 294 callsigns the database records
// with a district and no history at all. With it off, only a dated activity
// record covering the QSO's own day is written — every stamp is then a fact.
// With it on, a callsign the database knows of no move for also gets its
// district; that is true for the great majority of stations and an assumption
// for the rest, which is why it is the operator's decision and not ours.
//
// An existing reference is never overwritten. A district assigned by hand, or
// read from the far end at the time, outranks any database.
func (a *App) BackfillRDA(useCurrent bool) (BackfillRDAResult, error) {
	var res BackfillRDAResult
	if a.qso == nil {
		return res, fmt.Errorf("db not initialized")
	}
	rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
	if err != nil {
		return res, err
	}
	for i := range rows {
		q := &rows[i]
		// The three entities the award covers. Filtering on DXCC rather than on
		// the callsign shape keeps a Russian station worked under a foreign
		// prefix out, which is right: the database files them by callsign and a
		// non-Russian one is not in it.
		if q.DXCC == nil {
			continue
		}
		switch *q.DXCC {
		case 15, 54, 126:
		default:
			continue
		}
		res.Scanned++
		if strings.TrimSpace(manualRefFor(q.Extras[award.ManualRefsKey], "RDA")) != "" {
			res.Skipped++
			continue
		}
		m, ok := rda.Lookup(q.Callsign, q.QSODate)
		if !ok || (!m.Dated && !useCurrent) {
			res.Unknown++
			continue
		}
		if q.Extras == nil {
			q.Extras = map[string]string{}
		}
		q.Extras[award.ManualRefsKey] = setOverrideRef(q.Extras[award.ManualRefsKey], "RDA", m.District)
		if err := a.qso.Update(a.ctx, *q); err != nil {
			applog.Printf("rda backfill: update qso %d failed: %v", q.ID, err)
			continue
		}
		if m.Dated {
			res.Dated++
		} else {
			res.Current++
		}
	}
	applog.Printf("rda backfill: %d Russian QSOs — %d dated, %d current, %d unknown, %d already set",
		res.Scanned, res.Dated, res.Current, res.Unknown, res.Skipped)
	if res.Dated+res.Current > 0 {
		a.invalidateAwardStats()
	}
	return res, nil
}

// RDADatabaseCount is how many callsigns the embedded district database holds,
// so the panel offering the backfill can say what it is about to use.
func (a *App) RDADatabaseCount() int { return rda.Count() }

// manualRefFor returns the reference already assigned to one award code in a
// "CODE@REF;CODE@REF" override string, or "".
func manualRefFor(existing, code string) string {
	for _, entry := range strings.Split(existing, ";") {
		entry = strings.TrimSpace(entry)
		at := strings.IndexByte(entry, '@')
		if at > 0 && strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
			return strings.TrimSpace(entry[at+1:])
		}
	}
	return ""
}

// GetLoTWQSLDetail reports whether the download asks LoTW for the QSL detail.
func (a *App) GetLoTWQSLDetail() bool {
	return a.settingOr(keyExtLoTWQSLDetail, "") == "1"
}

// SetLoTWQSLDetail stores that choice.
func (a *App) SetLoTWQSLDetail(on bool) {
	a.setSetting(keyExtLoTWQSLDetail, map[bool]string{true: "1", false: "0"}[on])
}

// GetLoTWDownloadAllCalls reports whether the LoTW download ignores the
// profile's own call and pulls every callsign on the account.
func (a *App) GetLoTWDownloadAllCalls() bool {
	return a.settingOr(keyExtLoTWAllCalls, "") == "1"
}

// SetLoTWDownloadAllCalls stores that choice.
func (a *App) SetLoTWDownloadAllCalls(on bool) {
	a.setSetting(keyExtLoTWAllCalls, map[bool]string{true: "1", false: "0"}[on])
}

// DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same
// "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window.
// since controls the date window: "" = everything, "last" = incremental since
// the service's last successful download, or an explicit "YYYY-MM-DD".
func (a *App) DownloadConfirmations(service string, addNotFound bool, since string) error {
	if a.qso == nil {
		return fmt.Errorf("db not initialized")
	}
	svc := extsvc.Service(service)
	cfg := a.loadExternalServices()
	// Cancel any download still running (a slow QRZ sync, say) before starting this
	// one, so the two don't run at once and interleave their logs — and derive a
	// cancellable context so closing the QSL Manager can stop it too.
	a.confDLMu.Lock()
	if a.confDLCancel != nil {
		a.confDLCancel()
	}
	ctx, cancel := context.WithCancel(a.ctx)
	a.confDLCancel = cancel
	a.confDLMu.Unlock()
	go a.runDownloadConfirmations(ctx, svc, cfg, addNotFound, since)
	return nil
}

// CancelConfirmations stops the in-flight LoTW/QRZ download — the QSL Manager
// calls it when the window closes, so work doesn't keep running in the background.
func (a *App) CancelConfirmations() {
	a.confDLMu.Lock()
	if a.confDLCancel != nil {
		a.confDLCancel()
		a.confDLCancel = nil
	}
	a.confDLMu.Unlock()
}

func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
	emit := func(line string) {
		// Don't keep logging into a closed/cancelled window.
		if a.ctx != nil && ctx.Err() == nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
		}
	}
	done := func(matched, total int) {
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
		}
	}
	matched, total, added := 0, 0, 0

	// resolveSince turns the UI's request into a concrete date (or ""):
	//   ""      → all
	//   "last"  → the service's stored last-download date (incremental)
	//   "date"  → used verbatim (expected YYYY-MM-DD)
	resolveSince := func(lastKey string) string {
		s := strings.TrimSpace(since)
		if strings.EqualFold(s, "last") {
			if a.settings != nil {
				v, _ := a.settings.Get(ctx, a.profileScope()+lastKey)
				return strings.TrimSpace(v)
			}
			return ""
		}
		return s
	}

	switch svc {
	case extsvc.ServiceLoTW:
		sinceDate := resolveSince(keyExtLoTWLastDownload)
		ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
		// A LoTW account holds every call its owner operates — F4BPO, F4BPO/P,
		// TM2Q — and the download is normally scoped to the profile's own call so
		// one profile does not pull another's confirmations. That scope also
		// silently hides them: a QSO made as F4BPO/P is confirmed at LoTW and can
		// never be downloaded from the F4BPO profile, so it stays unconfirmed here
		// for good while the ARRL counts it. Off by default, because the scope is
		// right for anyone whose profiles are separate stations.
		if a.settingOr(keyExtLoTWAllCalls, "") == "1" {
			ownCall = ""
		}
		callLabel := ownCall
		// Unscoped, the report carries every station on the account — including
		// the ones belonging to ANOTHER profile's logbook (a Vietnam expedition,
		// say). Those confirmations have nothing to match here, and with "add the
		// ones not found" ticked they would pour a second log into this one. So
		// the station callsigns this logbook actually holds become the filter:
		// F4BPO/P is kept because it was worked here, XV9Q is skipped because it
		// never was.
		var ownStations map[string]bool
		if ownCall == "" {
			if st, e := a.qso.StationCallsigns(ctx); e == nil && len(st) > 0 {
				ownStations = st
			}
		}
		if callLabel == "" {
			callLabel = "all callsigns"
		}
		if sinceDate != "" {
			emit(fmt.Sprintf("Downloading LoTW confirmations for %s received since %s…", callLabel, sinceDate))
		} else {
			emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
		}
		emit(fmt.Sprintf("Window: since=%q → resolved=%q (scope owncall=%q)", since, sinceDate, ownCall))
		// The report arrives over minutes, and a window that says nothing while it
		// does is indistinguishable from one that has hung — which is what it was
		// being reported as. Every half-megabyte, say how much has landed.
		// Adding the QSOs LoTW knows and we do not is the one job that needs the
		// slow report: without the detail those records would come in with no
		// grid, state or county, and nothing else would ever fill them.
		detail := addNotFound || a.settingOr(keyExtLoTWQSLDetail, "") == "1"
		if detail {
			emit("Asking for the QSL details too — LoTW takes considerably longer to build that report.")
		}
		adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall, detail, emit)
		if err != nil {
			emit("Download failed: " + err.Error())
			done(matched, total)
			return
		}
		emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024))
		// A report far smaller than the account justifies is the one failure that
		// looks like success: LoTW answers 200 with a near-empty ADIF when it
		// disagrees with the query (an unknown callsign in qso_owncall, a login
		// that half-worked). Show its own header rather than leaving "matched 1 of
		// 1" to be read as "you have one confirmation".
		if len(adifText) < 4096 {
			head := strings.TrimSpace(adifText)
			if len(head) > 400 {
				head = head[:400]
			}
			emit("The report is unexpectedly small — what LoTW actually sent:")
			emit("  " + strings.Join(strings.Fields(head), " "))
		}
		keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
		if kerr != nil {
			emit("Error reading local log: " + kerr.Error())
			done(matched, total)
			return
		}
		// Second pass, at mode-CLASS granularity: LoTW answers with the mode
		// GROUP ("DATA"), never the submode it was given, so an FT4/FT8/FT2
		// contact can never match on the exact string and the operator is told
		// their own QSO is missing from their log.
		classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
		byClass := 0
		// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
		// that count for ARRL awards) so each incoming one is flagged NEW.
		sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
		var items []ConfirmationItem
		var unmatched []string
		skippedOtherStation := 0
		perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
			if ctx.Err() != nil {
				return ctx.Err() // window closed / superseded
			}
			q, ok := adif.RecordToQSO(rec)
			if !ok {
				return nil
			}
			// Another station's confirmation — see ownStations above. Counted so
			// the report says how many were left alone rather than silently
			// dropping a third of the file.
			if ownStations != nil {
				st := strings.ToUpper(strings.TrimSpace(rec["station_callsign"]))
				if st != "" && !ownStations[st] {
					skippedOtherStation++
					return nil
				}
			}
			total++
			date := rec["qslrdate"]
			if date == "" {
				date = time.Now().UTC().Format("20060102")
			}
			a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
			minute := q.QSODate.UTC().Format("2006-01-02T15:04")
			key := qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)
			classKey := qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)
			id, found := keyIDs[key]
			if !found {
				// The mode differs but the station, minute, band and mode class
				// agree — the same contact under LoTW's group name. An ambiguous
				// class key (id 0) is deliberately NOT taken: see DedupeClassKeyIDs.
				if cid, ok := classIDs[classKey]; ok && cid != 0 {
					id, found = cid, true
					byClass++
				}
			}
			if found {
				if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
					matched++
				}
			} else if addNotFound {
				q.LOTWSent = "Y"
				q.LOTWRcvd = "Y"
				q.LOTWRcvdDate = date
				if newID, e := a.qso.Add(ctx, q); e == nil {
					keyIDs[key] = newID // guard against dup records in the report
					classIDs[classKey] = newID
					added++
				}
			} else {
				// No local QSO matched this confirmation on (call, minute, band,
				// mode). Record the specifics so the user can see WHICH one and
				// why (time off by a minute, FT4 logged as MFSK, portable call…).
				unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
					q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
			}
			// Build the result row + NEW flags (vs the pre-download snapshot),
			// then fold this slot into the sets so a repeat in the same batch
			// isn't flagged twice.
			dxccNum := 0
			if q.DXCC != nil {
				dxccNum = *q.DXCC
			}
			it := ConfirmationItem{
				Callsign: q.Callsign,
				Station:  strings.ToUpper(strings.TrimSpace(rec["station_callsign"])),
				QSODate:  q.QSODate.UTC().Format(time.RFC3339),
				Band:     q.Band,
				Mode:     q.Mode,
				Country:  q.Country,
			}
			if dxccNum != 0 {
				// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
				// confirmation is "new mode/slot" only if no digital mode was
				// confirmed there before (RTTY and FT8 are the same class here).
				it.NewDXCC = !sets.DXCC[dxccNum]
				it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
				it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
				it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
				sets.DXCC[dxccNum] = true
				sets.Band[qso.BandKey(dxccNum, q.Band)] = true
				sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
				sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
			}
			items = append(items, it)
			return nil
		})
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
		}
		if perr != nil {
			emit("Parse error: " + perr.Error())
		}
		if addNotFound {
			emit(fmt.Sprintf("Matched %d, added %d (of %d confirmed QSO(s))", matched, added, total))
		} else {
			emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
		}
		if skippedOtherStation > 0 {
			emit(fmt.Sprintf("  (%d confirmation(s) skipped — made under a callsign this logbook has never used)", skippedOtherStation))
		}
		if byClass > 0 {
			// Said out loud rather than folded silently into the total: these
			// matched on the mode CLASS, not the mode. LoTW hands back "DATA" for
			// every digital submode, so this is the normal case for FT4/FT8 — but
			// an operator reconciling a log deserves to know the match was looser
			// than call+minute+band+mode.
			emit(fmt.Sprintf("  (%d matched on mode class — LoTW reports the group, e.g. DATA, where your log has the exact mode)", byClass))
		}
		// Surface confirmations with no local match so the user sees WHICH one
		// and why (time off by a minute, FT4 logged as MFSK, portable call, or
		// never logged). Tick "Add not-found" to import them instead.
		for _, u := range unmatched {
			emit("  ⚠ no local QSO for: " + u)
		}
		// Remember today so the next pull is incremental (per active profile).
		if a.settings != nil {
			a.setSetting(a.profileScope()+keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
		}

	case extsvc.ServiceQRZ:
		// QRZ's FETCH API has no server-side date filter, so we pull the logbook
		// and (when a window is requested) skip records older than sinceDate by
		// QSO date. sinceDate is "YYYY-MM-DD".
		sinceDate := resolveSince(keyExtQRZLastDownload)
		emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload))
		// An automatic run ("last") and an operator-chosen date do not mean the
		// same thing, so they ask QRZ two different questions:
		//   • "last"        → MODSINCE: everything TOUCHED since the last run,
		//                     which is what catches a 2015 QSO confirmed yesterday.
		//   • a typed date  → BETWEEN: the QSOs MADE from that date to today. The
		//                     operator asked for a period of operating, not for
		//                     whatever QRZ happened to edit in that period.
		autoWindow := strings.EqualFold(strings.TrimSpace(since), "last")
		// Ask QRZ for the WINDOW rather than the whole logbook. "ALL" made the
		// server serialise every QSO — 56 MB for a 117k-record log — which it
		// truncates mid-record, so the parse died two thirds of the way through
		// and everything past that point was never seen. AFTER: narrows it at the
		// source; the client-side date filter below stays as the backstop.
		//
		// The option is MODSINCE (QRZ FETCH documentation) — "AFTER:" was a guess
		// and QRZ rejected the whole fetch, which broke the download outright.
		// MODSINCE is also the RIGHT window for this job: it selects records
		// MODIFIED since the date, so a ten-year-old QSO confirmed yesterday comes
		// back — a QSO-date window would never return it, and confirmations are
		// exactly what this download is for.
		//
		// It stays an optimisation, never a requirement: if QRZ refuses it, fall
		// back to ALL rather than leaving the operator with no download at all.
		//
		// serverWindowed is set only for MODSINCE, because it alone returns QSOs
		// OLDER than the date — the client-side date filter would drop exactly
		// those. Under BETWEEN the filter agrees with the server and stays on as a
		// backstop.
		fetchOpt, serverWindowed := "ALL", false
		switch {
		case sinceDate == "":
			emit("Fetching QRZ.com logbook (full — no since date)…")
		case autoWindow:
			fetchOpt, serverWindowed = "MODSINCE:"+sinceDate, true
			emit("Fetching QRZ.com logbook (records modified since " + sinceDate + ")…")
		default:
			today := time.Now().UTC().Format("2006-01-02")
			fetchOpt = "BETWEEN:" + sinceDate + "+" + today
			emit("Fetching QRZ.com logbook (QSOs from " + sinceDate + " to " + today + ")…")
		}
		fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
		if err != nil && fetchOpt != "ALL" {
			emit("QRZ.com refused " + fetchOpt + " (" + err.Error() + ") — fetching the full logbook instead…")
			fetchOpt, serverWindowed = "ALL", false
			fr, err = extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
		}
		if err != nil {
			emit("Fetch failed: " + err.Error())
			done(matched, total)
			return
		}
		adifText := fr.ADIF
		emit(fmt.Sprintf("QRZ RESULT=%s COUNT=%s, ADIF %d bytes", fr.Result, fr.Count, len(adifText)))
		// The last-download date is stored at the END, and ONLY if the whole ADIF
		// parsed. It used to be written here, right after the fetch: when the
		// response was truncated (see above) the window advanced over records that
		// were never processed, and every later run skipped them for good. A
		// window that moves past unseen data is worse than one that re-reads.
		if snip := strings.TrimSpace(adifText); snip != "" {
			if len(snip) > 300 {
				snip = snip[:300]
			}
			emit("ADIF head: " + snip)
		}
		// Scope to THIS profile's / forced callsign so a QRZ logbook holding
		// several of the operator's calls (F4BPO, TM2Q…) only confirms/adds QSOs
		// for the active call. Windowed + phone-mode-tolerant matching (same as
		// eQSL/LoTW).
		qrzOwner := a.uploadOwnerCall(extsvc.ServiceQRZ)
		mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner)
		const qrzMatchWindow = 10 * time.Minute
		qrzSkippedOtherCall := 0
		unconfirmed := 0 // QSOs whose wrongly-set QRZ flag was taken back
		// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
		// judged only against other QRZ confirmations.
		sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
		// Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED",
		// without a per-record DB read.
		alreadyQrz := map[int64]bool{}
		if rs, e := a.logDb.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
			for rs.Next() {
				var id int64
				if rs.Scan(&id) == nil {
					alreadyQrz[id] = true
				}
			}
			rs.Close()
		}
		var items []ConfirmationItem
		parsed := 0
		allKeys := map[string]bool{} // union of field names seen, for diagnostics
		// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
		// parser treats the stream as records.
		perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
			if ctx.Err() != nil {
				return ctx.Err() // window closed / superseded — stop clearing flags
			}
			parsed++
			for k := range rec {
				allKeys[k] = true
			}
			if !qrzRecordConfirmed(rec) {
				// QRZ says this one is NOT confirmed. If our log says it is, that
				// is a mark OpsLog set wrongly before it learned which field
				// carries QRZ's answer — take it back rather than leave a QSO
				// counting towards an award it has not earned.
				//
				// Only on an EXPLICIT no: a record with no app_qrzlog_status at
				// all is QRZ saying nothing, and silence is not a retraction.
				if st := strings.TrimSpace(rec["app_qrzlog_status"]); st != "" {
					if q, ok := adif.RecordToQSO(rec); ok && q.Callsign != "" {
						if id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow); found && alreadyQrz[id] {
							if e := a.qso.ClearQRZConfirmed(ctx, id); e == nil {
								delete(alreadyQrz, id)
								unconfirmed++
								emit(fmt.Sprintf("Callsign: %s  Date: %s  Band: %s  Mode: %s  ### NOT CONFIRMED ON QRZ — flag cleared ###",
									q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode))
							}
						}
					}
				}
				return nil
			}
			q, ok := adif.RecordToQSO(rec)
			if !ok {
				return nil
			}
			// Date window (client-side): skip QSOs older than the requested date.
			// ONLY when the server did not window the fetch itself — MODSINCE
			// returns old QSOs whose CONFIRMATION is recent, and filtering those
			// out by QSO date would throw away the very records asked for.
			if !serverWindowed && sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
				return nil
			}
			// Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
			if qrzOwner != "" {
				if rc := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])); rc != "" && rc != qrzOwner {
					qrzSkippedOtherCall++
					return nil
				}
			}
			total++
			date := rec["qrzcom_qso_download_date"]
			if date == "" {
				date = time.Now().UTC().Format("20060102")
			}
			a.enrichContactedFromCty(&q)
			line := fmt.Sprintf("Callsign: %s  Date: %s  Band: %s  Mode: %s",
				q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)
			id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow)
			switch {
			case found:
				if alreadyQrz[id] {
					emit(line + "  ### ALREADY CONFIRMED ###")
				} else if e := a.qso.MarkQRZConfirmed(ctx, id, date); e == nil {
					alreadyQrz[id] = true
					matched++
					emit(line + "  ### UPDATED ###")
				}
			case addNotFound:
				q.QRZComUploadStatus = "Y"
				q.QRZComDownloadStatus = "Y"
				q.QRZComDownloadDate = date
				if q.StationCallsign == "" {
					q.StationCallsign = qrzOwner
				}
				if newID, e := a.qso.Add(ctx, q); e == nil {
					mIdx.Add(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), newID)
					added++
					emit(line + "  ### ADDED ###")
				}
			default:
				emit(line + "  ### NOT IN LOG ###")
			}
			// Result row + NEW flags.
			dxccNum := 0
			if q.DXCC != nil {
				dxccNum = *q.DXCC
			}
			it := ConfirmationItem{
				Callsign: q.Callsign,
				QSODate:  q.QSODate.UTC().Format(time.RFC3339),
				Band:     q.Band, Mode: q.Mode, Country: q.Country,
			}
			if dxccNum != 0 {
				// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
				// confirmation is "new mode/slot" only if no digital mode was
				// confirmed there before (RTTY and FT8 are the same class here).
				it.NewDXCC = !sets.DXCC[dxccNum]
				it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
				it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
				it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
				sets.DXCC[dxccNum] = true
				sets.Band[qso.BandKey(dxccNum, q.Band)] = true
				sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
				sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
			}
			items = append(items, it)
			return nil
		})
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
		}
		if perr != nil {
			emit("Parse error: " + perr.Error())
		}
		// Diagnostic: the union of every field name QRZ returned, so we can
		// pin the confirmation marker against real data.
		keys := make([]string, 0, len(allKeys))
		for k := range allKeys {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
		// Spell out the funnel: QRZ hands back the WHOLE logbook, but a confirmation
		// download only acts on the ones QRZ marks confirmed — the "26165 but only
		// 7950?" question. Nothing is lost; the rest simply aren't confirmed yet.
		emit(fmt.Sprintf("%d of your %d QRZ QSOs are confirmed by the other station — the rest aren't confirmed yet, so there's nothing to import for them.", total, parsed))
		emit(fmt.Sprintf("Of those %d confirmed: %d newly marked, %d added.", total, matched, added))
		if unconfirmed > 0 {
			// Worth its own line and not a footnote: these QSOs were showing as
			// confirmed a moment ago, and an award total may move because of it.
			emit(fmt.Sprintf("Cleared %d QSO(s) that QRZ.com does NOT confirm — they had been marked wrongly by an earlier version", unconfirmed))
		}
		if perr != nil {
			emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.")
			emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).")
		} else if a.settings != nil {
			a.setSetting(a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
		}
		if qrzSkippedOtherCall > 0 {
			emit(fmt.Sprintf("Skipped %d QSO(s) logged under another of your callsigns (scoped to %s).", qrzSkippedOtherCall, qrzOwner))
		}

	case extsvc.ServiceEQSL:
		sinceDate := resolveSince(keyExtEQSLLastDownload)
		if sinceDate != "" {
			emit("Downloading eQSL Inbox (confirmations received since " + sinceDate + ")…")
		} else {
			emit("Downloading full eQSL Inbox (all received confirmations)…")
		}
		adifText, err := extsvc.DownloadEQSLConfirmations(ctx, nil, cfg.EQSL, sinceDate)
		if err != nil {
			emit("Download failed: " + err.Error())
			done(matched, total)
			return
		}
		// Scope everything to THIS profile's / forced callsign so an eQSL account
		// holding several of the operator's calls (F4BPO, TM2Q…) only confirms/adds
		// QSOs for the active call.
		eqslOwner := a.uploadOwnerCall(extsvc.ServiceEQSL)
		// Tolerant match index: an eQSL confirmation carries the OTHER station's
		// logged time, often a minute or two off ours — so we match within a
		// ±10-minute window. The mode must still match, except the phone sidebands
		// SSB/USB/LSB are treated as one (a station may confirm USB where we logged
		// SSB); FT8/FT4/CW/… stay exact.
		mIdx, kerr := a.qso.BuildMatchIndex(ctx, eqslOwner)
		if kerr != nil {
			emit("Error reading local log: " + kerr.Error())
			done(matched, total)
			return
		}
		const eqslMatchWindow = 10 * time.Minute
		skippedOtherCall := 0
		// eQSL confirmations aren't ARRL-award-valid (only LoTW + paper QSL are),
		// so NEW is judged only against other eQSL confirmations.
		sets, _ := a.qso.ConfirmedSlots(ctx, []string{"eqsl_rcvd"})
		var items []ConfirmationItem
		var unmatched []string
		perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
			if ctx.Err() != nil {
				return ctx.Err() // window closed / superseded
			}
			q, ok := adif.RecordToQSO(rec)
			if !ok {
				return nil
			}
			// Skip a confirmation logged under a DIFFERENT one of the operator's
			// callsigns (the eQSL account may hold several) — it belongs to that
			// call's log, not this one.
			if eqslOwner != "" {
				if rc := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])); rc != "" && rc != eqslOwner {
					skippedOtherCall++
					return nil
				}
			}
			total++
			// eQSL stamps the confirmation date in QSLRDATE (fall back to today).
			date := rec["qslrdate"]
			if date == "" {
				date = time.Now().UTC().Format("20060102")
			}
			a.enrichContactedFromCty(&q)
			id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), eqslMatchWindow)
			if found {
				if e := a.qso.MarkEQSLConfirmed(ctx, id, date); e == nil {
					matched++
				}
			} else if addNotFound {
				q.EQSLSent = "Y"
				q.EQSLRcvd = "Y"
				q.EQSLRcvdDate = date
				if q.StationCallsign == "" {
					q.StationCallsign = eqslOwner // stamp the active call on adds
				}
				if newID, e := a.qso.Add(ctx, q); e == nil {
					mIdx.Add(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), newID)
					added++
				}
			} else {
				unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
					q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
			}
			dxccNum := 0
			if q.DXCC != nil {
				dxccNum = *q.DXCC
			}
			it := ConfirmationItem{
				Callsign: q.Callsign,
				QSODate:  q.QSODate.UTC().Format(time.RFC3339),
				Band:     q.Band, Mode: q.Mode, Country: q.Country,
			}
			if dxccNum != 0 {
				// Flags use mode CLASS (Phone/CW/Digital), not raw mode: a DIGI
				// confirmation is "new mode/slot" only if no digital mode was
				// confirmed there before (RTTY and FT8 are the same class here).
				it.NewDXCC = !sets.DXCC[dxccNum]
				it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
				it.NewMode = !sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)]
				it.NewSlot = !sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)]
				sets.DXCC[dxccNum] = true
				sets.Band[qso.BandKey(dxccNum, q.Band)] = true
				sets.Mode[qso.ModeClassKey(dxccNum, q.Mode)] = true
				sets.Slot[qso.SlotClassKey(dxccNum, q.Band, q.Mode)] = true
			}
			items = append(items, it)
			return nil
		})
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
		}
		if perr != nil {
			emit("Parse error: " + perr.Error())
		}
		if addNotFound {
			emit(fmt.Sprintf("Matched %d, added %d (of %d eQSL confirmation(s))", matched, added, total))
		} else {
			emit(fmt.Sprintf("Matched %d of %d eQSL confirmation(s)", matched, total))
		}
		for _, u := range unmatched {
			emit("  ⚠ no local QSO for: " + u)
		}
		if skippedOtherCall > 0 {
			emit(fmt.Sprintf("Skipped %d confirmation(s) logged under another of your callsigns (scoped to %s).", skippedOtherCall, eqslOwner))
		}
		if a.settings != nil {
			a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02"))
		}

	case extsvc.ServiceClublog:
		sinceDate := resolveSince(keyExtClublogLastDownload)
		if sinceDate != "" {
			emit("Downloading Club Log matches (matched since " + sinceDate + ")…")
		} else {
			emit("Downloading ALL Club Log matches (full pull — the date filter is faster next time)…")
		}
		matches, err := extsvc.DownloadClublogMatches(ctx, nil, cfg.Clublog, sinceDate)
		if err != nil {
			emit("Download failed: " + err.Error())
			done(matched, total)
			return
		}
		mIdx, kerr := a.qso.BuildMatchIndex(ctx, a.uploadOwnerCall(extsvc.ServiceClublog))
		if kerr != nil {
			emit("Error reading local log: " + kerr.Error())
			done(matched, total)
			return
		}
		// Club Log pairs QSOs within ±15 minutes, same as LoTW.
		const clublogMatchWindow = 15 * time.Minute
		today := time.Now().UTC().Format("20060102")
		// A Club Log match is Club Log's own kind of confirmation, so NEW is
		// judged only against other Club Log matches.
		sets, _ := a.qso.ConfirmedSlots(ctx, []string{"clublog_qso_download_status"})
		var items []ConfirmationItem
		var unmatched []string
		for _, m := range matches {
			if ctx.Err() != nil {
				return
			}
			total++
			id, found := int64(0), false
			if m.Band != "" && m.Mode != "" {
				id, found = mIdx.Match(m.Callsign, m.Band, m.Mode, m.When, clublogMatchWindow)
			}
			if !found && m.Band != "" {
				// Club Log reports "false" for modes it can't infer — fall back to
				// call+band+time, still unambiguous inside a 15-minute window.
				id, found = mIdx.MatchBand(m.Callsign, m.Band, m.When, clublogMatchWindow)
			}
			if found {
				if e := a.qso.MarkClublogConfirmed(ctx, id, today); e == nil {
					matched++
				}
			} else {
				unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
					m.Callsign, m.When.Format("2006-01-02 15:04Z"), m.Band, m.Mode))
			}
			it := ConfirmationItem{
				Callsign: m.Callsign,
				QSODate:  m.When.Format(time.RFC3339),
				Band:     m.Band, Mode: m.Mode,
			}
			if m.DXCC != 0 {
				it.Country = dxcc.NameForDXCC(m.DXCC)
				it.NewDXCC = !sets.DXCC[m.DXCC]
				it.NewBand = !sets.Band[qso.BandKey(m.DXCC, m.Band)]
				it.NewMode = !sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)]
				it.NewSlot = !sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)]
				sets.DXCC[m.DXCC] = true
				sets.Band[qso.BandKey(m.DXCC, m.Band)] = true
				sets.Mode[qso.ModeClassKey(m.DXCC, m.Mode)] = true
				sets.Slot[qso.SlotClassKey(m.DXCC, m.Band, m.Mode)] = true
			}
			items = append(items, it)
		}
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
		}
		emit(fmt.Sprintf("Matched %d of %d Club Log match(es)", matched, total))
		if addNotFound && len(unmatched) > 0 {
			// A match means BOTH sides uploaded this QSO — one Club Log can't find
			// locally was deleted or edited here, not missed; adding a skeleton
			// row from call/band/time alone would just create a duplicate shadow.
			emit("Club Log matches carry too little detail to add missing QSOs — unmatched ones are listed below.")
		}
		for _, u := range unmatched {
			emit("  ⚠ no local QSO for: " + u)
		}
		if a.settings != nil {
			a.setSetting(a.profileScope()+keyExtClublogLastDownload, time.Now().UTC().Format("2006-01-02"))
		}

	default:
		emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
	}
	// Confirmations flip lotw_rcvd/qsl_rcvd on EXISTING rows, which doesn't move
	// the logbook revision (count:maxID) — so the cached award snapshot would
	// stay stale. Drop it whenever anything was matched or added so the next
	// Awards view reflects the new confirmations.
	if matched > 0 || added > 0 {
		a.invalidateAwardStats()
	}
	done(matched+added, total)
}

// qrzRecordConfirmed reports whether a QRZ FETCH ADIF record represents a
// QSO confirmed BY QRZ.COM.
//
// It used to accept qsl_rcvd = Y as well, and that was wrong: qsl_rcvd is the
// operator's own PAPER QSL flag, which they uploaded to QRZ themselves. Any QSO
// with a paper card was therefore marked QRZ-confirmed on download — reported as
// "they all turn to Y" (2026-07-29), and on a log full of paper QSLs that is
// most of it.
//
// ONE field carries it: app_qrzlog_status, where C means confirmed.
//
// qrzcom_qso_download_status was accepted too, and that was wrong. It is not a
// confirmation: QRZ sets it to Y on everything it hands back. An operator's own
// fetch showed both fields on the same record —
//
//	<app_qrzlog_status:1>N  <qrzcom_qso_download_status:1>Y
//
// — a QSO QRZ says is NOT confirmed, which OpsLog then marked confirmed. That
// is how a whole download turned green (2026-07-31).
//
// This status feeds award slots, so a false Y is a QSO counted as confirmed when
// it is not — the one error worth being strict about here.
func qrzRecordConfirmed(rec adif.Record) bool {
	return strings.EqualFold(strings.TrimSpace(rec["app_qrzlog_status"]), "C")
}

// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
// from cty.dat (offline) — used when adding a not-found confirmation that
// only carries call/band/mode/date.
func (a *App) enrichContactedFromCty(q *qso.QSO) {
	if a.dxcc == nil || q.Callsign == "" {
		return
	}
	m, ok := a.dxcc.Lookup(q.Callsign)
	if !ok || m.Entity == nil {
		return
	}
	if q.Country == "" {
		q.Country = m.Entity.Name
	}
	if q.Continent == "" {
		q.Continent = m.Continent
	}
	if q.DXCC == nil {
		if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
			q.DXCC = &n
		}
	}
	if q.CQZ == nil && m.CQZone != 0 {
		v := m.CQZone
		q.CQZ = &v
	}
	if q.ITUZ == nil && m.ITUZone != 0 {
		v := m.ITUZone
		q.ITUZ = &v
	}
}

// enrichContactedFromCtyForce OVERWRITES the contacted-station country,
// continent, DXCC number and CQ/ITU zones from cty.dat. Unlike
// enrichContactedFromCty (which only fills blanks), this corrects values
// that are present-but-wrong — the case where contest software exports a
// bad COUNTRY/DXCC (e.g. RG2Y tagged "Asiatic Russia" instead of European).
// Returns true if cty.dat had a match.
func (a *App) enrichContactedFromCtyForce(q *qso.QSO) bool {
	if a.dxcc == nil || q.Callsign == "" {
		return false
	}
	// A DELETED ENTITY IS LEFT ALONE, whatever cty.dat says about the callsign
	// today.
	//
	// cty.dat answers "where is this call now". For a contact made before an
	// entity was deleted that is the wrong question: R1MVI was Malyj Vysotskij
	// (151) until 2012 and resolves to European Russia (54) today, so forcing
	// the lookup onto a 2004 QSO destroys a credit that can never be worked
	// again — the place does not exist to be worked. Reported from a Logger32
	// export whose <DXCC:3>151 came back as 54.
	//
	// Nothing is corrected here rather than only the number: the country name
	// and the zones of a deleted entity are equally beyond cty.dat's knowledge.
	if q.DXCC != nil && dxcc.IsDeleted(*q.DXCC) {
		return false
	}
	m, ok := a.dxcc.Lookup(q.Callsign)
	if !ok || m.Entity == nil {
		return false
	}
	q.Country = m.Entity.Name
	q.Continent = m.Continent
	if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
		q.DXCC = &n
	}
	if m.CQZone != 0 {
		v := m.CQZone
		q.CQZ = &v
	}
	if m.ITUZone != 0 {
		v := m.ITUZone
		q.ITUZ = &v
	}
	// Zone-split countries (USA, Australia): refine the per-entity default zone
	// to the call-district zone (W6 → CQ3/ITU6), matching Log4OM/DXKeeper.
	a.refineDistrictZones(q)
	return true
}

// UpdateQSOsFromCty recomputes country / continent / DXCC / CQ / ITU from
// cty.dat for the given QSO ids and saves them. Used by the grid's
// right-click "Update from cty.dat" on a multi-selection. Returns how many
// rows were actually changed.
func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	changed := 0
	for _, id := range ids {
		q, err := a.qso.GetByID(a.ctx, id)
		if err != nil {
			continue
		}
		before := fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ)
		if !a.enrichContactedFromCtyForce(&q) {
			continue
		}
		if fmt.Sprint(q.Country, q.Continent, q.DXCC, q.CQZ, q.ITUZ) == before {
			continue // no change
		}
		if err := a.qso.Update(a.ctx, q); err == nil {
			changed++
		}
	}
	if changed > 0 {
		a.invalidateAwardStats()
		a.materializeAwardRefsForIDs(ids) // entity fields changed → refresh award_refs
	}
	return changed, nil
}

// emitBulkProgress reports how far a bulk update has got.
//
// The QRZ pass is the one that needs it: it is a NETWORK round trip per QSO, so
// 102 contacts imported from a contest take a couple of minutes during which
// nothing moved on screen and the only honest reading was "it has frozen". The
// cty.dat and ClubLog passes are local and usually instant, but they report too
// — a progress bar that appears only when things are slow teaches people that
// its absence means trouble.
func (a *App) emitBulkProgress(op string, processed, total int, call string) {
	wruntime.EventsEmit(a.ctx, "bulkupdate:progress", map[string]any{
		"op":        op,
		"processed": processed,
		"total":     total,
		"call":      call,
	})
}

// UpdateQSOsFromQRZ re-queries the callsign database (QRZ.com / HamQTH per
// the configured providers) for each QSO id and overwrites the geographic
// + entity fields (country, continent, DXCC, zones, grid, state, county)
// plus name/QTH when the provider returns them. Used by the grid's
// right-click "Update from QRZ.com". Returns how many rows were saved.
func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
	if a.qso == nil || a.lookup == nil {
		return 0, fmt.Errorf("not initialized")
	}
	changed := 0
	total := len(ids)
	a.emitBulkProgress("qrz", 0, total, "")
	defer a.emitBulkProgress("qrz", total, total, "")
	for i, id := range ids {
		q, err := a.qso.GetByID(a.ctx, id)
		if err != nil || q.Callsign == "" {
			a.emitBulkProgress("qrz", i+1, total, "")
			continue
		}
		// Reported BEFORE the lookup, so the callsign on screen is the one being
		// waited on rather than the one already finished — with a slow provider
		// that is the difference between "working on F4BPO" and a name that
		// lags a QSO behind whatever is actually taking the time.
		a.emitBulkProgress("qrz", i, total, q.Callsign)
		r, err := a.lookup.Lookup(a.ctx, q.Callsign)
		if err != nil {
			a.emitBulkProgress("qrz", i+1, total, "")
			continue
		}
		if r.Country != "" {
			q.Country = r.Country
		}
		if r.Continent != "" {
			q.Continent = r.Continent
		}
		if r.DXCC != 0 {
			n := r.DXCC
			q.DXCC = &n
		}
		if r.CQZ != 0 {
			v := r.CQZ
			q.CQZ = &v
		}
		if r.ITUZ != 0 {
			v := r.ITUZ
			q.ITUZ = &v
		}
		if r.Grid != "" {
			q.Grid = strings.ToUpper(r.Grid)
		}
		if r.State != "" {
			q.State = r.State
		}
		if r.County != "" {
			q.County = r.County
		}
		if r.Name != "" {
			q.Name = r.Name
		}
		if r.QTH != "" {
			q.QTH = r.QTH
		}
		if r.Address != "" {
			q.Address = r.Address // full street address, not just the city (QTH)
		}
		if r.Email != "" {
			q.Email = r.Email
		}
		if r.QSLVia != "" {
			q.QSLVia = r.QSLVia
		}
		if r.Lat != 0 || r.Lon != 0 {
			lat, lon := r.Lat, r.Lon
			q.Lat, q.Lon = &lat, &lon
		}
		if err := a.qso.Update(a.ctx, q); err == nil {
			changed++
		}
		a.emitBulkProgress("qrz", i+1, total, "")
	}
	if changed > 0 {
		a.invalidateAwardStats()
		a.materializeAwardRefsForIDs(ids) // entity/geo fields changed → refresh award_refs
	}
	return changed, nil
}

// ListTQSLStationLocations returns the Station Locations defined in TQSL,
// for the LoTW settings dropdown.
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
	return extsvc.ListStationLocations(extsvc.DefaultStationDataPath())
}

// TestLoTWUpload validates the LoTW config (TQSL present + station location
// exists).
func (a *App) TestLoTWUpload() (string, error) {
	return extsvc.TestLoTW(a.loadExternalServices().LoTW, extsvc.DefaultStationDataPath())
}

// buildUploadADIF builds a single-record ADIF for QSO id, overriding the
// station callsign when forceCall is set (QRZ rejects QSOs whose station
// call differs from the logbook's registered call). ok=false → skip.
func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) {
	if a.qso == nil {
		return "", false
	}
	q, err := a.qso.GetByID(a.ctx, id)
	if err != nil {
		return "", false
	}
	if forceCall != "" {
		q.StationCallsign = forceCall
	}
	return adif.SingleRecordADIF(q), true
}

// stationCallOf returns the QSO's STATION_CALLSIGN (upper-cased), used by the
// uploader to verify a QSO belongs to the target logbook's callsign.
func (a *App) stationCallOf(id int64) string {
	if a.qso == nil {
		return ""
	}
	q, err := a.qso.GetByID(a.ctx, id)
	if err != nil {
		return ""
	}
	return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
}

// uploadOwnerCall returns the callsign OpsLog signs/uploads/downloads as for a
// service in the active profile: the configured Force/owner callsign, else the
// active profile's callsign. "" when nothing is known (don't scope by call).
func (a *App) uploadOwnerCall(svc extsvc.Service) string {
	cfg := a.loadExternalServices()
	owner := ""
	switch svc {
	case extsvc.ServiceLoTW:
		owner = cfg.LoTW.ForceStationCallsign
	case extsvc.ServiceQRZ:
		owner = cfg.QRZ.ForceStationCallsign
	case extsvc.ServiceClublog:
		owner = cfg.Clublog.Callsign
	case extsvc.ServiceHRDLog:
		owner = cfg.HRDLog.Callsign
	case extsvc.ServiceEQSL:
		// eQSL has no Force-callsign field; its Username is just the account login
		// (which may hold several of the operator's calls). Scope by the ACTIVE
		// PROFILE's callsign instead — the operating identity — via the fallback
		// below, so a download on the F4BPO profile only touches F4BPO QSOs.
	}
	owner = strings.ToUpper(strings.TrimSpace(owner))
	if owner == "" && a.profiles != nil {
		if p, perr := a.profiles.Active(a.ctx); perr == nil {
			owner = strings.ToUpper(strings.TrimSpace(p.Callsign))
		}
	}
	return owner
}

// UploadCallsign exposes uploadOwnerCall to the UI so the QSL Manager can show
// which of the operator's callsigns a download/upload targets in this profile.
func (a *App) UploadCallsign(service string) string {
	return a.uploadOwnerCall(extsvc.Service(service))
}

// closeUploadIDs returns the QSO ids to upload to a service at app close,
// scanning the whole logbook: LoTW matches the configured sent-status set
// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an
// imported ADIF (old QSOs still unsent) flush on close.
func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
	if a.qso == nil {
		return nil
	}
	col := uploadColumnFor(string(svc))
	if col == "" {
		return nil
	}
	// owner is the callsign this logbook signs/uploads as. Each external
	// logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q)
	// we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would
	// be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log
	// logbook.
	var statuses []string
	if svc == extsvc.ServiceLoTW {
		statuses = a.loadExternalServices().LoTW.UploadFlags
		if len(statuses) == 0 {
			return nil
		}
	}
	owner := a.uploadOwnerCall(svc)

	cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses)
	if err != nil {
		applog.Printf("extsvc: close-upload candidate scan for %s failed: %v", svc, err)
		return nil
	}
	out := make([]int64, 0, len(cands))
	skipped := 0
	for _, c := range cands {
		// Keep QSOs that belong to this logbook's call. A blank STATION_CALLSIGN
		// is assumed to be ours (it gets signed/labelled as owner on upload),
		// mirroring the per-QSO guard in extsvc.upload.
		if owner == "" || c.StationCallsign == "" || extsvc.SameBaseCall(c.StationCallsign, owner) {
			out = append(out, c.ID)
		} else {
			skipped++
		}
	}
	if skipped > 0 {
		applog.Printf("extsvc: %s close-upload skipped %d QSO(s) not matching logbook callsign %q", svc, skipped, owner)
	}
	return out
}

// extShouldUpload reports whether a QSO is eligible for upload to a service,
// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW
// uploads only QSOs whose lotw_sent matches the configured Upload flag
// ("N" or "R") — the Log4OM rule that must match the Confirmations default.
func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
	if a.qso == nil {
		return false
	}
	q, err := a.qso.GetByID(a.ctx, id)
	if err != nil {
		return false
	}
	switch svc {
	case extsvc.ServiceQRZ:
		if strings.EqualFold(q.QRZComUploadStatus, "Y") {
			applog.Printf("extsvc: QSO %d not eligible for qrz — QRZComUploadStatus already %q (set Confirmations default to N to upload)", id, q.QRZComUploadStatus)
			return false
		}
		return true
	case extsvc.ServiceClublog:
		if strings.EqualFold(q.ClublogUploadStatus, "Y") {
			applog.Printf("extsvc: QSO %d not eligible for clublog — ClublogUploadStatus already %q (set Confirmations default to N to upload)", id, q.ClublogUploadStatus)
			return false
		}
		return true
	case extsvc.ServiceHRDLog:
		if strings.EqualFold(q.HRDLogUploadStatus, "Y") {
			applog.Printf("extsvc: QSO %d not eligible for hrdlog — HRDLogUploadStatus already %q (set Confirmations default to N to upload)", id, q.HRDLogUploadStatus)
			return false
		}
		return true
	case extsvc.ServiceEQSL:
		if strings.EqualFold(q.EQSLSent, "Y") {
			applog.Printf("extsvc: QSO %d not eligible for eqsl — EQSLSent already %q (set Confirmations default to N to upload)", id, q.EQSLSent)
			return false
		}
		return true
	case extsvc.ServiceCloudlog:
		// No per-QSO status column for Cloudlog: it dedupes server-side (its API
		// checks each incoming record against the log), so re-sending is harmless
		// and every QSO is eligible. The cost is that a permanently failed upload
		// is not remembered — hence no on-close mode and no manual backlog.
		return true
	case extsvc.ServiceHamlog:
		// The stamp is an extra, not a column — see markExtUploaded. A stamp that
		// MEANS SENT is what stops an on-demand re-upload of a whole log from
		// sending every contact twice.
		if q.Extras != nil && extrasSaysSent(q.Extras[hamlogSentKey]) {
			applog.Printf("extsvc: QSO %d not eligible for hamlog — already sent on %s", id, q.Extras[hamlogSentKey])
			return false
		}
		return true
	case extsvc.ServiceHamQTH:
		// Same extras stamp as HAMLOG.online — ADIF names no HamQTH field.
		if q.Extras != nil && extrasSaysSent(q.Extras[hamqthSentKey]) {
			applog.Printf("extsvc: QSO %d not eligible for hamqth — already sent on %s", id, q.Extras[hamqthSentKey])
			return false
		}
		return true
	case extsvc.ServiceLoTW:
		for _, f := range a.loadExternalServices().LoTW.UploadFlags {
			if strings.EqualFold(q.LOTWSent, f) {
				return true
			}
		}
		return false
	}
	return false
}

// markExtUploaded stamps the per-service upload status on the QSO row and
// tells the frontend to refresh that row's confirmation columns.
// hamlogSentKey says a QSO has gone to HAMLOG.online; hamlogSentDateKey says
// when. Split the way every other service splits it — a status and a date —
// and shared with the row colours (lib/rowColors.ts), the QSL Manager's backlog
// and the bulk editor, so all four agree on what "already sent" means.
const (
	hamlogSentKey     = "APP_OPSLOG_HAMLOG_SENT"
	hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE"
	hamqthSentKey     = "APP_OPSLOG_HAMQTH_SENT"
	hamqthSentDateKey = "APP_OPSLOG_HAMQTH_SENT_DATE"
)

// extrasSaysSent reads an extras upload stamp as "this QSO has GONE".
//
// Mere presence will not do. markExtUploaded writes "Y" (older builds wrote the
// date), but the Confirmations page pre-fills the same key with a TO-DO status
// — "R", requested, is the natural default for a service you intend to upload
// to — and reading that as "already gone" would silently disable the very
// auto-upload the default was set to arm. So the pending statuses mean pending,
// and anything else means sent.
func extrasSaysSent(v string) bool {
	switch strings.ToUpper(strings.TrimSpace(v)) {
	case "", "R", "N", "Q", "I":
		return false
	}
	return true
}

func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
	date := time.Now().UTC().Format("20060102")
	// Use a fresh background context, NOT a.ctx: this stamp often runs during
	// the on-close upload, and a.ctx is cancelled as the app shuts down — which
	// would silently abort the UPDATE and leave the QSO at "R" forever despite a
	// successful upload.
	ctx := context.Background()
	if a.qso == nil {
		return
	}
	// Keep the identifier the service handed back. QRZ deletes ONLY by logid —
	// its API has no delete-by-callsign-and-date — so a QSO uploaded without
	// recording this can never be withdrawn from QRZ afterwards except by hand.
	// It costs one small write per upload and it is the only chance to get it.
	if strings.TrimSpace(logID) != "" && id != 0 {
		key := "APP_OPSLOG_" + strings.ToUpper(string(svc)) + "_LOGID"
		if err := a.qso.SetExtra(ctx, id, key, strings.TrimSpace(logID)); err != nil {
			applog.Printf("extsvc: store %s logid for QSO %d: %v", svc, id, err)
		}
	}

	var err error
	switch svc {
	case extsvc.ServiceQRZ:
		err = a.qso.MarkQRZUploaded(ctx, id, date)
	case extsvc.ServiceClublog:
		err = a.qso.MarkClublogUploaded(ctx, id, date)
	case extsvc.ServiceLoTW:
		err = a.qso.MarkLoTWUploaded(ctx, id, date)
	case extsvc.ServiceHRDLog:
		err = a.qso.MarkHRDLogUploaded(ctx, id, date)
	case extsvc.ServiceEQSL:
		err = a.qso.MarkEQSLSent(ctx, id, date)
	case extsvc.ServiceCloudlog:
		// Nothing to stamp — see extShouldUpload. Still logged and announced so
		// the upload is visible in the diagnostic log and the UI.
	case extsvc.ServiceHamlog:
		// An ADIF EXTRA, not a column: the standard names a field for hamlog.EU
		// and none for hamlog.ONLINE, and borrowing the other site's would write
		// a falsehood into every export. The date rather than a Y, so a log tells
		// WHEN a contact went — and so the same shape serves the QSL Manager's
		// backlog query, which asks "is this key present at all".
		if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
			err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date)
		}
	case extsvc.ServiceHamQTH:
		if err = a.qso.SetExtra(ctx, id, hamqthSentKey, "Y"); err == nil {
			err = a.qso.SetExtra(ctx, id, hamqthSentDateKey, date)
		}
	}
	if err != nil {
		applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
	} else {
		applog.Printf("extsvc: marked %s QSO %d as sent", svc, id)
	}
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
			"service": string(svc),
			"qso_id":  id,
			"log_id":  logID,
		})
	}
}

// notifyExtError surfaces a failed upload to the frontend.
func (a *App) notifyExtError(svc extsvc.Service, id int64, err error) {
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "extsvc:error", map[string]any{
			"service": string(svc),
			"qso_id":  id,
			"error":   err.Error(),
		})
	}
}

// ── UDP integrations ───────────────────────────────────────────────────

// ListUDPIntegrations returns every saved UDP connection row.
func (a *App) ListUDPIntegrations() ([]udp.Config, error) {
	if a.udpRepo == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	return a.udpRepo.List(a.ctx)
}

// SaveUDPIntegration upserts a UDP connection and reloads the manager so
// inbound listeners pick up the change without an app restart. Reload
// errors are surfaced — a "port already in use" failure should reach the
// user rather than be silently dropped.
func (a *App) SaveUDPIntegration(c udp.Config) (udp.Config, error) {
	if a.udpRepo == nil {
		return c, fmt.Errorf("db not initialized")
	}
	if err := a.udpRepo.Save(a.ctx, &c); err != nil {
		return c, err
	}
	if a.udp != nil {
		errs := a.udp.Reload(a.ctx)
		if len(errs) > 0 {
			return c, fmt.Errorf("listener errors: %s", strings.Join(errs, "; "))
		}
	}
	return c, nil
}

// DeleteUDPIntegration removes a row and reloads the manager.
func (a *App) DeleteUDPIntegration(id int64) error {
	if a.udpRepo == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.udpRepo.Delete(a.ctx, id); err != nil {
		return err
	}
	if a.udp != nil {
		a.udp.Reload(a.ctx)
	}
	return nil
}

// ReloadUDPIntegrations is a no-arg way for the UI to force a restart
// (e.g. after toggling Enabled on a row).
func (a *App) ReloadUDPIntegrations() []string {
	if a.udp == nil {
		return nil
	}
	return a.udp.Reload(a.ctx)
}

// refineGrid picks between the locator a QSO arrived with and the one the
// callsign lookup returned.
//
// WSJT-X and MSHV always send a FOUR-character grid — that is all the FT8
// protocol carries — so a QSO logged from them landed with JN05 while QRZ knew
// JN05JG. The enrichment rule everywhere else on this path is "fill only what is
// empty", which meant the coarse grid always won and the precise one was thrown
// away, silently costing the operator ~100 km of accuracy on every digital QSO.
//
// The upgrade is only taken when the lookup grid EXTENDS the received one (same
// first four characters). A lookup that disagrees outright — JN18 against JN05 —
// is not a refinement: the station may be portable, and what came over the air
// is then the better record. Case-insensitive, since ADIF grids arrive in both.
func refineGrid(have, found string) string {
	h := strings.ToUpper(strings.TrimSpace(have))
	f := strings.ToUpper(strings.TrimSpace(found))
	if h == "" {
		return f
	}
	if len(f) > len(h) && strings.HasPrefix(f, h) {
		return f
	}
	return h
}

// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
// first record into the local logbook. Returns the ID of the inserted
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
// N1MM — the latter via a synthesised ADIF record from its XML datagram).
func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
	if a.qso == nil {
		return 0, fmt.Errorf("db not initialized")
	}
	// Serialise the WHOLE operation — parse, callsign LOOKUP, dedup and insert —
	// so simultaneous UDP QSOs are processed one at a time (a queue). A multi-stream
	// MSHV finishing 4 QSOs at once used to fire 4 concurrent callsign lookups and DB
	// writes, which the QRZ/HamQTH service (and a remote DB) rejected with "too many
	// requests". Holding the lock across the lookup spaces them out; the UDP reader
	// runs on its own goroutine, so this never blocks packet reception — callers just
	// queue behind each other. (adifwatch shares this lock for the same reason.)
	a.udpLogMu.Lock()
	defer a.udpLogMu.Unlock()
	// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
	// always send a single QSO per UDP packet (no header) but we tolerate
	// either form via adif.Parse.
	// Pick the field decoder for this payload's encoding (UTF-8 as-is, else
	// Windows-1252) so accented NAME/QTH from Log4OM/JTAlert aren't mangled.
	// In UTF-8 mode the parser also repairs character-count field lengths.
	decode := adif.ValueDecoderFor([]byte(adifText))
	var record adif.Record
	err := adif.ParseWithDecoder(strings.NewReader(adifText), decode, func(rec adif.Record) error {
		if record == nil {
			record = rec
		}
		return nil
	})
	if err != nil {
		return 0, fmt.Errorf("parse adif: %w", err)
	}
	if record == nil {
		// Some senders skip the <EOH> header; try treating the whole
		// payload as a single record by prepending a fake header.
		err := adif.ParseWithDecoder(strings.NewReader("<EOH>"+adifText), decode, func(rec adif.Record) error {
			if record == nil {
				record = rec
			}
			return nil
		})
		if err != nil || record == nil {
			return 0, fmt.Errorf("no valid QSO record in payload")
		}
	}
	q, ok := adif.RecordToQSO(record)
	if !ok {
		return 0, fmt.Errorf("record missing required fields (call/band/mode/date)")
	}

	// The grid the station SENT beats any directory.
	//
	// A CQ in FT8 carries the operator's square, and it is where they are right
	// now. QRZ and HamQTH describe where they LIVE, which for an expedition is
	// the wrong side of the planet: RI0FA transmitted QN35 all evening and was
	// logged with its licence-holder's home square, because WSJT-X's ADIF carries
	// no GRIDSQUARE and the lookup was the only source left.
	//
	// Applied BEFORE the lookup, so refineGrid below sees a grid it must not
	// replace. It still upgrades a 4-character square to a 6-character one from
	// the same field.
	if g := a.lookupDecodeGrid(q.Callsign); g != "" {
		q.Grid = refineGrid(q.Grid, g)
	}

	// ── Lookup-based enrichment ──
	// WSJT sends only call/freq/mode/RST/date. Fill Name/QTH/Country/
	// Grid/CQZ/ITUZ/DXCC/Continent via the lookup chain (QRZ/HamQTH/
	// cty.dat). Best-effort: a network failure shouldn't block the log.
	if a.lookup != nil {
		if lr, lerr := a.lookup.Lookup(a.ctx, q.Callsign); lerr == nil {
			if q.Name == "" {
				q.Name = lr.Name
			}
			if q.QTH == "" {
				q.QTH = lr.QTH
			}
			if q.Country == "" {
				q.Country = lr.Country
			}
			q.Grid = refineGrid(q.Grid, lr.Grid)
			if q.Continent == "" {
				q.Continent = lr.Continent
			}
			if q.State == "" {
				q.State = lr.State
			}
			if q.County == "" {
				q.County = lr.County
			}
			if q.Address == "" {
				q.Address = lr.Address
			}
			if q.Email == "" {
				q.Email = lr.Email
			}
			if q.DXCC == nil && lr.DXCC != 0 {
				v := lr.DXCC
				q.DXCC = &v
			}
			if q.CQZ == nil && lr.CQZ != 0 {
				v := lr.CQZ
				q.CQZ = &v
			}
			if q.ITUZ == nil && lr.ITUZ != 0 {
				v := lr.ITUZ
				q.ITUZ = &v
			}
			if q.Lat == nil && lr.Lat != 0 {
				v := lr.Lat
				q.Lat = &v
			}
			if q.Lon == nil && lr.Lon != 0 {
				v := lr.Lon
				q.Lon = &v
			}
		}
	}

	// ── Name/city normalisation ──
	// Log4OM / contest loggers often send NAME and QTH in ALL CAPS. Title-case
	// them so UDP-logged QSOs match the manual + lookup paths ("SANTO DOMINGO"
	// → "Santo Domingo"). Only all-caps values are touched.
	q.Name = titleCaseIfUpper(q.Name)
	q.QTH = titleCaseIfUpper(q.QTH)
	q.Country = titleCaseIfUpper(q.Country)
	q.MyCity = titleCaseIfUpper(q.MyCity)

	// ── Operating-conditions stamp ──
	// Pre-fill MY_RIG / MY_ANTENNA / TX_PWR from the default antenna for
	// this band (if the user has configured Operating conditions) — after the
	// connected radio has had its say, since it knows which rig is keying and
	// the band default only knows which one was planned.
	if q.MyRig == "" {
		q.MyRig = a.activeRadioMyRig()
	}
	if a.operating != nil && a.profiles != nil {
		if p, err := a.profiles.Active(a.ctx); err == nil {
			if d, ok2, _ := a.operating.BandDefault(a.ctx, p.ID, q.Band); ok2 {
				if q.MyRig == "" {
					q.MyRig = d.StationName
				}
				if q.MyAntenna == "" {
					q.MyAntenna = d.AntennaName
				}
				if q.TXPower == nil && d.TXPower != nil {
					v := *d.TXPower
					q.TXPower = &v
				}
			}
		}
	}

	// ── Active-profile station stamp ──
	// Same as the manual AddQSO path: fill the operator's MY_* fields
	// (station callsign, grid, country, zones, and the profile's default
	// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP /
	// WSJT-X auto-logged QSO carried none of the operator's own data.
	a.applyStationDefaults(&q, true)

	// ── DXCC# + QSL defaults ──
	// applyDXCCNumber stamps the contacted-station DXCC# from the
	// entity-name table; QSL defaults are applied last so explicit ADIF
	// fields (or what the lookup gave us) always win.
	a.applyDXCCNumber(&q)
	a.applyClublogException(&q, false) // date-ranged DXpedition override
	a.refineDistrictZones(&q)          // W6 → CQ3/ITU6 for zone-split countries
	a.applyQSLDefaults(&q)

	// ── Space weather and path length ──
	// Also "same as the manual path", and they were missed when that comment was
	// written. A QSO auto-logged from WSJT-X went in with no SFI, no A, no K and
	// no distance, so an operator running digital — which is most of the traffic
	// on most stations — had those fields empty across the whole log while a
	// hand-logged contact carried them. Both are stamped only where the record
	// left them empty, so an ADIF that supplied its own still wins.
	a.applySolar(&q)
	fillDistance(&q)

	// ── Dedup (serialised) ──
	// Match by call + band + mode within a ±2-minute window: a QSO logged
	// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
	// a minute (the two apps stamp their own time), so a minute-exact key
	// missed it and the contact got duplicated.
	//
	// The check + insert is covered by udpLogMu (taken at the top of this function):
	// MSHV/WSJT can deliver the same logged-QSO packet twice in quick succession
	// (re-broadcast, or two listeners), and without serialisation both goroutines
	// read the dedup set BEFORE either inserts, both pass, and the QSO lands twice.
	seen, err := a.qso.ExistingDedupeKeys(a.ctx)
	if err == nil {
		base := q.QSODate.UTC()
		for d := -2; d <= 2; d++ {
			min := base.Add(time.Duration(d) * time.Minute).Format("2006-01-02T15:04")
			if _, dup := seen[qso.DedupeKey(q.Callsign, min, q.Band, q.Mode)]; dup {
				return 0, fmt.Errorf("duplicate (already in log within ±2 min)")
			}
		}
	}

	id, err := a.qso.Add(a.ctx, q)
	if err != nil {
		// DB UNREACHABLE (drop / timeout on a slow-or-broken MySQL) — park the QSO
		// in the offline outbox rather than lose it, exactly like the manual log
		// path. Without this, a laggy shared MySQL silently dropped UDP-logged QSOs
		// from a multi-stream MSHV. Returns -1 so the caller treats it as "saved,
		// waiting to sync", not a failure.
		if db.IsConnLost(err) && a.queueOffline(q, err) {
			return -1, nil
		}
		return 0, fmt.Errorf("insert qso: %w", err)
	}
	q.ID = id
	// Same as the manual path: give the contact its number without rereading the
	// log. This insert bypasses AddQSO entirely, which is why UDP-logged contacts
	// came out unnumbered while hand-logged ones did not.
	a.noteQSONumbered(id, q.QSODate)
	// And for the same reason, the worked indexes have to be told here too.
	// AddQSO does this; bypassing it left the cluster/decode status snapshot
	// holding its pre-QSO answer for the rest of the session — so a station
	// worked through WSJT-X / JTDX / MSHV went on wearing NEW GRID, NEW CALL and
	// NEW PFX after the QSO was in the log. Reported on RA3Y (KO73), still badged
	// NEW GRID two minutes after the contact. Hand-logged contacts never showed
	// it, which is exactly what made it look like a display bug.
	a.noteWorked(q.Callsign, q.Band, q.Mode)
	a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
	// Announce the log AT ONCE so the grid / ON-AIR badge / stations-on-air widget
	// refresh immediately, then run the DB-heavy enrichment off the critical path
	// (same reasoning as the manual path — award_refs on a busy shared MySQL blocked).
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "qso:logged", id)
		// A contact logged by WSJT-X / JTDX / MSHV is the one the operator has no
		// other confirmation of: the entry form did not clear, because they never
		// typed in it, and on the FT decodes tab the log grid is not even on
		// screen. Its own event, carrying enough to name the station — qso:logged
		// is an id, and fires twice per contact, so it can drive a refresh but not
		// a message.
		wruntime.EventsEmit(a.ctx, "qso:logged_external", map[string]any{
			"call": q.Callsign, "band": q.Band, "mode": q.Mode, "country": q.Country,
		})
	}
	qc := q
	go func() {
		a.materializeAwardRefs(qc)
		a.saveQSORecording(&qc)
		if a.extsvc != nil {
			a.extsvc.OnQSOLogged(id)
		}
		a.maybeAutoSendEQSL(qc)
		a.maybeSelfSpot(qc)
		a.publishSoon() // refresh the published web page, debounced
		// Forward to the outbound UDP integrations, exactly like the manual log
		// path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted
		// to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…).
		if a.udp != nil {
			rec := adif.ForwardRecordADIF(qc)
			a.udp.EmitLoggedADIF(rec)
			a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
			a.udpTriggerQSOLogged(qc)
		}
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "qso:logged", id) // refresh again so award_refs show
		}
		if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
			_, _ = a.replayOfflineQueue()
		}
	}()
	return id, nil
}

// consumeUDPEvents bridges parsed UDP events to the frontend over Wails'
// event bus. The frontend listens on:
//
//	udp:dx_call       → string callsign (also Grid/Mode/Freq when known)
//	udp:logged_qso    → ADIF text of a QSO that finished in WSJT-X/JTDX/MSHV
//	udp:remote_call   → string callsign from a remote-control source
func (a *App) consumeUDPEvents() {
	if a.udp == nil {
		return
	}
	// Rate-limit decode spots: an FT8 opening decodes the SAME stations every
	// cycle, so spot each call at most once per window (well within its display
	// lifetime) instead of blasting the radio with a burst of adds+removes every
	// 15 s. Single-goroutine loop → the map needs no lock.
	const decodeSpotWindow = 20 * time.Second
	lastDecodeSpot := map[string]time.Time{}
	// The sending application's transmit state, as last logged.
	//
	// "Enable Tx" is the one thing that decides whether a Reply we sent turns
	// into a transmission, and it lives entirely in WSJT-X/JTDX — we can ask for
	// a station, we cannot arm the transmitter. When an operator reports "OpsLog
	// called it and nothing went out", the only honest way to tell which side
	// stopped is to see whether the far end ever armed at all. Status carries it
	// every second; logging the CHANGES costs a line a QSO and answers the
	// question without watching a button.
	type txFlags struct{ enabled, transmitting bool }
	lastTxFlags := map[string]txFlags{}
	// Panadapter spots go out on their OWN goroutine.
	//
	// SendSpot writes to the radio's TCP socket, and this loop is the only
	// consumer of the UDP event channel — which drops on backpressure. A period
	// hands over twenty-odd decodes at once, so one slow write to the Flex was
	// enough to fill that queue and lose decodes the panel was meant to show.
	// The spot is an ornament; the decode is the point. Buffered and
	// non-blocking, so a radio that stops reading costs spots and nothing else.
	spotQ := make(chan cat.SpotInfo, 64)
	go func() {
		for si := range spotQ {
			if a.cat != nil {
				a.cat.SendSpot(si)
			}
		}
	}()
	defer close(spotQ)
	for ev := range a.udp.Events() {
		if a.ctx == nil {
			continue
		}
		// The operator's own transmit state, from Status. Emitted before the
		// switch because a Status carries BOTH a DX call and a transmit message,
		// and the switch below takes only one branch.
		//
		// Sent on EVERY Status, not only when there is a transmit message: the
		// field is EMPTY between overs — that is its normal state, not a sender
		// that withholds it — and the panel still has to say who is being called
		// and whether the carrier is up. A Status always carries de_call, so that
		// is the test.
		if ev.DECall != "" || ev.TxMessage != "" {
			if ev.ProgramID != "" {
				a.lastTxID.Store(ev.ProgramID)
				now := txFlags{enabled: ev.TxEnabled, transmitting: ev.Transmitting}
				if was, seen := lastTxFlags[ev.ProgramID]; !seen || was != now {
					lastTxFlags[ev.ProgramID] = now
					applog.Printf("udp: [%s] tx enable=%v transmitting=%v dx=%q%s",
						ev.ProgramID, now.enabled, now.transmitting, ev.DXCall,
						map[bool]string{true: "", false: " — nothing we send can start a transmission while this is false"}[now.enabled])
				}
			}
			// Auto-call counts what actually goes on the air from here: a reply
			// we sent is a request, and this is the answer to whether the
			// decoder acted on it.
			acTX := autocall.TXState{
				Transmitting: ev.Transmitting, Enabled: ev.TxEnabled,
				DXCall: ev.DXCall, Msg: ev.TxMessage, Instance: ev.ProgramID,
			}
			a.autoCallSetTX(acTX)
			if ev.Transmitting {
				a.autoCallNoteTX(acTX)
			}
			wruntime.EventsEmit(a.ctx, "udp:tx_state", map[string]any{
				"msg":          ev.TxMessage,
				"transmitting": ev.Transmitting,
				"tx_enabled":   ev.TxEnabled,
				"de_call":      ev.DECall,
				"dx_call":      ev.DXCall,
				"mode":         ev.Mode,
				"freq_hz":      ev.FreqHz,
				"band":         bandForHz(ev.FreqHz),
				"instance":     ev.ProgramID,
				"at":           time.Now().UTC().Format(time.RFC3339),
			})
		}
		switch {
		case ev.DecodeCall != "":
			// Remember the grid before anything else: a CQ is the one message that
			// carries it, and the station may never send another.
			if ev.DecodeGrid != "" {
				a.rememberDecodeGrid(ev.DecodeCall, ev.DecodeGrid, gridcache.SourceDecode)
			}
			// Hand every decode to the UI. Unconditional, and BEFORE the
			// panadapter block below, which skips a call it spotted moments ago:
			// that de-duplication exists to spare the radio, and applying it here
			// would silently drop most of a period from the panel that is meant to
			// show the period whole.
			at := ev.DecodeAt
			if at.IsZero() {
				at = time.Now().UTC() // sender gave no timestamp — arrival will do
			}
			wruntime.EventsEmit(a.ctx, "udp:decode", map[string]any{
				"call":      ev.DecodeCall,
				"grid":      ev.DecodeGrid,
				"snr":       ev.DecodeSNR,
				"freq_hz":   ev.DecodeFreqHz,
				"dial_hz":   ev.DecodeDial,
				"band":      bandForHz(ev.DecodeFreqHz),
				"mode":      ev.Mode,
				"msg":       ev.DecodeMsg,
				"cq":        ev.DecodeCQ,
				"at":        at.Format(time.RFC3339),
				"tr_period": ev.DecodeTRPeriod,
				"off_air":   ev.DecodeOffAir,
				"source":    ev.Source,
				"instance":  ev.ProgramID,
				"dt":        ev.DecodeDT,
				"audio_hz":  ev.DecodeAudioHz,
				// Carried so a click can answer the station: WSJT-X matches a
				// Reply against its own decode list, field for field. mode_raw is
				// the mode as the Decode sent it (the "~"/"+" marker) — "mode"
				// above is the resolved name, which the match rejects.
				"ms":       ev.DecodeMs,
				"low_conf": ev.DecodeLowConf,
				"mode_raw": ev.DecodeModeRaw,
				"msg_raw":  ev.DecodeMsgRaw,
				// false on a Replay's resent history — shown, never auto-answered.
				"is_new": ev.DecodeIsNew,
			})
			// Auto-call sees the decode after the panel, never before: the list
			// the operator reads must not wait on a decision about calling.
			a.autoCallFeed(autocall.Decode{
				Call: ev.DecodeCall, Band: bandForHz(ev.DecodeFreqHz), Mode: ev.Mode,
				Msg: ev.DecodeMsg, SNR: ev.DecodeSNR, At: at, TRPeriod: ev.DecodeTRPeriod,
				Instance: ev.ProgramID, CQ: ev.DecodeCQ,
				Ms: ev.DecodeMs, DT: ev.DecodeDT, AudioHz: int64(ev.DecodeAudioHz),
				ModeRaw: ev.DecodeModeRaw, MsgRaw: ev.DecodeMsgRaw, LowConf: ev.DecodeLowConf,
				IsNew: ev.DecodeIsNew,
			})
			// Log-aware colour in the decoder's own window (see
			// app_wsjt_highlight.go). After the emit: painting must never delay
			// the panel.
			a.maybeHighlightDecode(ev.ProgramID, ev.DecodeCall, bandForHz(ev.DecodeFreqHz), ev.Mode)
			// A WSJT-X decode (heard station). Render it on the FlexRadio
			// panadapter when the option is on; green + SNR comment, auto-expiring
			// after the configured duration. De-duped per call in the Flex backend.
			if a.catFlexDecodeSpots && a.cat != nil {
				now := time.Now()
				// Key by call AND 100 kHz slot, not by call alone: with two
				// receivers running (50.313 and 50.400, say) a station heard on
				// both is two legitimate spots on two panadapters, and a
				// call-only key threw the second away.
				key := fmt.Sprintf("%s@%d", ev.DecodeCall, ev.DecodeFreqHz/100_000)
				if t, seen := lastDecodeSpot[key]; seen && now.Sub(t) < decodeSpotWindow {
					continue // spotted this call very recently — don't re-hammer the radio
				}
				if len(lastDecodeSpot) > 4000 {
					lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions
				}
				secs := a.catFlexDecodeSecs
				if secs <= 0 {
					secs = 120
				}
				// The palette governs the PANADAPTER, not one of the two things
				// that feed it. A decode spot is a callsign on the waterfall
				// exactly as a cluster spot is, so it answers to the same
				// per-status switch and the same colours — an operator who turned
				// "entity not resolved" off and still watched unresolved calls
				// pile up on the radio was right to call that a bug.
				status := a.spotStatusName(ev.DecodeCall, bandForHz(ev.DecodeFreqHz), ev.Mode, "")
				if a.spotHidden(status) {
					continue
				}
				lastDecodeSpot[key] = now
				// Green when the palette is off — the colour this feature has always
				// used, and what still tells a decode from a cluster spot.
				col := a.spotColorFor(status)
				textCol, bgCol := col.Text, col.Bg
				if textCol == "" {
					textCol, bgCol = "#FF34C759", ""
				}
				select {
				case spotQ <- cat.SpotInfo{
					FreqHz:          ev.DecodeFreqHz,
					Callsign:        ev.DecodeCall,
					Mode:            ev.Mode,
					Color:           textCol,
					BackgroundColor: bgCol,
					Comment:         spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status),
					LifetimeSec:     secs,
					Priority:        spotPriority(status),
				}:
				default:
					// Radio not keeping up: skip the spot rather than stall the
					// decode stream behind it.
					delete(lastDecodeSpot, key) // not spotted after all — let the next one try
				}
			}
		case ev.LoggedADIF != "":
			// The RECORD, not just its size. "Was the grid in what WSJT-X sent, or
			// did OpsLog put it there?" is the first question asked when a contact
			// is logged in the wrong square, and a byte count cannot answer it —
			// an expedition logged at its licence holder's home QTH stayed
			// undiagnosable for exactly that reason. One line per QSO, ~280 bytes.
			applog.Printf("udp: emit udp:logged_qso (%d bytes) %s\n",
				len(ev.LoggedADIF), strings.Join(strings.Fields(ev.LoggedADIF), " "))
			wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{
				"config_id": ev.ConfigID,
				"service":   string(ev.Service),
				"source":    ev.Source,
				"adif":      ev.LoggedADIF,
			})
		case ev.ClearCall:
			// Only from the program the entry field belongs to. An idle decoder
			// alongside the one being worked clears its own DX Call for reasons
			// of its own, and that must not empty a field somebody else filled.
			if !a.udpFocus.holds(ev.ProgramID) {
				break
			}
			a.udpFocus.release("DX Call cleared")
			applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
			wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
				"service": string(ev.Service),
				"source":  ev.Source,
			})
		case (ev.DXCall != "" || ev.TuneFreqHz > 0) && ev.Service == udp.ServiceRemoteCall:
			// CAT tune riding along with the callsign (DXHunter spot click:
			// "<CALLSIGN>VP5G<FREQ>10.136<MODE>FT8"). Freq first so the rig is
			// already moving while the frontend fills the entry field.
			if ev.TuneFreqHz > 0 {
				applog.Printf("udp: remote_call tune request %.3f MHz mode=%q\n", float64(ev.TuneFreqHz)/1e6, ev.TuneMode)
				if err := a.SetCATFrequency(ev.TuneFreqHz); err != nil {
					applog.Printf("udp: remote_call tune failed: %v\n", err)
				} else if ev.TuneMode != "" {
					if err := a.SetCATMode(ev.TuneMode); err != nil {
						applog.Printf("udp: remote_call mode set failed: %v\n", err)
					}
				}
			}
			if ev.DXCall != "" {
				applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
				wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
			}
		case ev.DXCall != "":
			// With two or three decoders running, the one announcing a station
			// takes the entry field and keeps it until it lets go. See udpFocus.
			if !a.udpFocus.claim(ev.ProgramID) {
				a.udpFocus.noteIgnored(ev.ProgramID, ev.DXCall)
				break
			}
			applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
			wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
				"call":    ev.DXCall,
				"grid":    ev.DXGrid,
				"mode":    ev.Mode,
				"freq_hz": ev.FreqHz,
				"service": string(ev.Service),
				"source":  ev.Source,
			})
		}
	}
}

// ── Operating conditions ───────────────────────────────────────────────

// ListOperatingTree returns the stations/antennas/bands tree for the
// active profile. The UI renders the Settings tree from this.
func (a *App) ListOperatingTree() ([]operating.Station, error) {
	if a.operating == nil || a.profiles == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return nil, err
	}
	return a.operating.ListTree(a.ctx, p.ID)
}

// SaveOperatingStation upserts a station. profile_id is set from the
// active profile if zero so the frontend doesn't have to know about it.
func (a *App) SaveOperatingStation(s operating.Station) (operating.Station, error) {
	if a.operating == nil || a.profiles == nil {
		return s, fmt.Errorf("db not initialized")
	}
	if s.ProfileID == 0 {
		p, err := a.profiles.Active(a.ctx)
		if err != nil {
			return s, err
		}
		s.ProfileID = p.ID
	}
	if err := a.operating.SaveStation(a.ctx, &s); err != nil {
		return s, err
	}
	return s, nil
}

// DeleteOperatingStation cascades to antennas + bands.
func (a *App) DeleteOperatingStation(id int64) error {
	if a.operating == nil {
		return fmt.Errorf("db not initialized")
	}
	return a.operating.DeleteStation(a.ctx, id)
}

// SaveOperatingAntenna upserts an antenna and replaces its band list.
// Setting is_default on a band clears the flag from any other antenna
// on the same band within this profile.
func (a *App) SaveOperatingAntenna(ant operating.Antenna) (operating.Antenna, error) {
	if a.operating == nil {
		return ant, fmt.Errorf("db not initialized")
	}
	if err := a.operating.SaveAntenna(a.ctx, &ant); err != nil {
		return ant, err
	}
	return ant, nil
}

// DeleteOperatingAntenna cascades to bands.
func (a *App) DeleteOperatingAntenna(id int64) error {
	if a.operating == nil {
		return fmt.Errorf("db not initialized")
	}
	return a.operating.DeleteAntenna(a.ctx, id)
}

// OperatingDefaultForBand returns the (station, antenna) flagged default
// for `band` in the active profile. Used by the entry strip to auto-fill
// MY_RIG and MY_ANTENNA when the user picks a band.
func (a *App) OperatingDefaultForBand(band string) (operating.BandDefault, error) {
	if a.operating == nil || a.profiles == nil {
		return operating.BandDefault{}, fmt.Errorf("db not initialized")
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return operating.BandDefault{}, err
	}
	d, _, err := a.operating.BandDefault(a.ctx, p.ID, band)
	return d, err
}

// ── Backup ──────────────────────────────────────────────────────────────

// BackupSettings is the user-tweakable database backup configuration.
type BackupSettings struct {
	Enabled       bool   `json:"enabled"`
	Folder        string `json:"folder"`
	Rotation      int    `json:"rotation"`
	Zip           bool   `json:"zip"`
	EveryExit     bool   `json:"every_exit"` // back up on every quit, not just the first of the day
	KeepAll       bool   `json:"keep_all"`   // time-stamped file per backup instead of one-per-day (kept up to Rotation)
	LastBackupAt  string `json:"last_backup_at"`
	DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint
}

// GetBackupSettings returns stored backup config with safe defaults.
func (a *App) GetBackupSettings() (BackupSettings, error) {
	out := BackupSettings{
		Rotation:      5,
		DefaultFolder: backup.DefaultFolder(filepath.Dir(a.dbPath)),
	}
	if a.settings == nil {
		return out, nil
	}
	m, err := a.settings.GetMany(a.ctx,
		keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupEveryExit, keyBackupKeepAll, keyBackupLast)
	if err != nil {
		return out, err
	}
	out.Enabled = m[keyBackupEnabled] == "1"
	out.Folder = m[keyBackupFolder]
	if n, _ := strconv.Atoi(m[keyBackupRotation]); n > 0 {
		out.Rotation = n
	}
	out.Zip = m[keyBackupZip] == "1"
	out.EveryExit = m[keyBackupEveryExit] == "1"
	out.KeepAll = m[keyBackupKeepAll] == "1"
	out.LastBackupAt = m[keyBackupLast]
	return out, nil
}

// SaveBackupSettings persists backup config (no immediate backup —
// trigger it explicitly with RunBackupNow).
func (a *App) SaveBackupSettings(s BackupSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if s.Rotation <= 0 {
		s.Rotation = 5
	}
	enabled := "0"
	if s.Enabled {
		enabled = "1"
	}
	doZip := "0"
	if s.Zip {
		doZip = "1"
	}
	for k, v := range map[string]string{
		keyBackupEnabled:   enabled,
		keyBackupFolder:    strings.TrimSpace(s.Folder),
		keyBackupRotation:  strconv.Itoa(s.Rotation),
		keyBackupZip:       doZip,
		keyBackupEveryExit: boolStr(s.EveryExit),
		keyBackupKeepAll:   boolStr(s.KeepAll),
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	return nil
}

// runConfiguredBackup writes the backup set to folder: the CONTACTS as the
// primary "opslog-*" backup, plus a separate "opslogcfg-*" snapshot of the
// settings/config db, so neither is lost. The contacts are what the user means
// by "the log": on SQLite they live in the split-out logbook file (a.logDbPath),
// NOT the settings db — the old code snapshotted a.db and so silently stopped
// backing up the QSOs once the logbook was split out. On MySQL the contacts
// aren't in a local file, so they're exported to ADIF instead. Returns the path
// of the contacts backup.
func (a *App) runConfiguredBackup(folder string, rotation int, zip, unique bool) (string, error) {
	// Config snapshot (profiles, hardware, awards). Best-effort — a config-backup
	// failure must never stop the contacts from being protected.
	if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, rotation, zip, "opslogcfg", unique); err != nil {
		applog.Printf("backup: config snapshot failed: %v", err)
	}
	if a.dbBackend == "mysql" {
		// The live log is on MySQL; VACUUM INTO can't reach it — export to ADIF.
		return a.backupLogADIF(folder, rotation, zip, unique)
	}
	// SQLite: snapshot the logbook file that holds the contacts. Fall back to the
	// settings db only when it doubles as the logbook (rare split-failure case).
	conn, path := a.logDb, a.logDbPath
	if conn == nil || path == "" {
		conn, path = a.db, a.dbPath
	}
	return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog", unique)
}

// RunBackupNow forces an immediate backup using the persisted settings.
// Returns the destination path of the file that was written.
func (a *App) RunBackupNow() (string, error) {
	s, err := a.GetBackupSettings()
	if err != nil {
		return "", err
	}
	folder := s.Folder
	if folder == "" {
		folder = s.DefaultFolder
	}
	path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll)
	if err != nil {
		return path, err
	}
	a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
	return path, nil
}

// backupLogADIF writes a rotating ADIF export of the (MySQL) logbook into the
// backup folder. The full set of ADIF + app fields is included so the backup is
// a complete, re-importable copy of the log.
func (a *App) backupLogADIF(folder string, rotation int, zip, unique bool) (string, error) {
	if a.qso == nil {
		return "", fmt.Errorf("logbook not initialized")
	}
	return backup.RunADIF(folder, rotation, zip, unique, func(p string) error {
		ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: true}
		_, e := ex.ExportFile(a.ctx, p)
		return e
	})
}

// maybeShutdownBackup runs a backup at shutdown if the user enabled it
// and no backup for today already exists. Running at shutdown (not at
// startup) means the snapshot includes the QSOs the user just logged
// this session — exactly what we want to protect. Errors are printed
// but never block the close.
func (a *App) maybeShutdownBackup() {
	if a.settings == nil || a.db == nil {
		return
	}
	s, err := a.GetBackupSettings()
	if err != nil || !s.Enabled {
		return
	}
	folder := s.Folder
	if folder == "" {
		folder = s.DefaultFolder
	}
	mysql := a.dbBackend == "mysql"
	// In MySQL mode the ADIF log export is the backup that matters; gate the
	// "already done today" skip on whichever backup type applies.
	done := backup.HasBackupToday(folder)
	if mysql {
		done = backup.HasADIFBackupToday(folder)
	}
	if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
		return
	}
	if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll); err != nil {
		fmt.Println("OpsLog: shutdown backup failed:", err)
		return
	}
	a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}

// PickBackupFolder opens a native directory picker so the user can browse
// to a backup target rather than typing the path. Returns the absolute
// path (or empty string if the dialog was cancelled).
//
// Windows' shell dialog refuses to open when DefaultDirectory points at
// a path that doesn't exist yet (typical for our default backups folder
// on first launch). We walk up the path until we find an existing
// ancestor and use that as the dialog's starting point.
func (a *App) PickBackupFolder() (string, error) {
	if a.ctx == nil {
		return "", fmt.Errorf("no app context")
	}
	current, _ := a.GetBackupSettings()
	defaultDir := current.Folder
	if defaultDir == "" {
		defaultDir = current.DefaultFolder
	}
	defaultDir = firstExistingAncestor(defaultDir)
	return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
		Title:            "Pick a folder for OpsLog backups",
		DefaultDirectory: defaultDir,
	})
}

// firstExistingAncestor returns p if it exists, otherwise the closest
// parent directory that does. Returns "" if nothing valid is found (the
// dialog then opens at the OS default location).
func firstExistingAncestor(p string) string {
	p = strings.TrimSpace(p)
	for p != "" {
		if st, err := os.Stat(p); err == nil && st.IsDir() {
			return p
		}
		parent := filepath.Dir(p)
		if parent == p {
			break
		}
		p = parent
	}
	return ""
}

// GetCATState returns the current snapshot from the CAT manager. Used by the
// frontend on mount before any cat:state event has been emitted.
func (a *App) GetCATState() cat.RigState {
	if a.cat == nil {
		return cat.RigState{}
	}
	return a.cat.State()
}

// SetCATFrequency lets the frontend push a freq to the rig (cluster click,
// memory recall, …). Returns an error if CAT isn't running or the backend
// refuses (out-of-range, etc.).
func (a *App) SetCATFrequency(hz int64) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	applog.Printf("cat: SetCATFrequency %.3f MHz — deliberate frontend set (spot click / band / memory / entry)", float64(hz)/1e6)
	err := a.cat.SetFrequency(hz)
	if err != nil {
		applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
	}
	// Re-tune the Ultrabeam right away on a DELIBERATE freq set (spot click, band
	// button, memory recall) instead of waiting for the ~1.5 s follow poll + the
	// CAT echo — the reason it used to only follow after you nudged the VFO.
	go a.ultrabeamFollowNow(hz)
	return err
}

// ultrabeamFollowNow re-tunes the Ultrabeam to freqHz at once (best-effort),
// honouring the same enabled/follow/in-range/step-deadband rules as the follow
// loop. Called from SetCATFrequency so a spot click moves the antenna instantly.
func (a *App) ultrabeamFollowNow(freqHz int64) {
	c := a.motorAnt
	if c == nil || freqHz <= 0 {
		return
	}
	s, err := a.GetUltrabeamSettings()
	if err != nil || !s.Enabled || !s.Follow {
		return
	}
	applog.Printf("motor-antenna: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6)
	step := s.StepKHz
	if step <= 0 {
		step = 50
	}
	st := c.Status()
	if !st.Connected {
		return
	}
	if !motorBandAllowed(s.Bands, freqHz) {
		applog.Printf("ultrabeam: followNow %.3f MHz is %q — not in covered bands %v; no move",
			float64(freqHz)/1e6, bandForHz(freqHz), s.Bands)
		return // a band the antenna doesn't cover — leave it where it is
	}
	khz := int(freqHz / 1000)
	ref := st.Frequency
	if ref <= 0 {
		ref = c.LastSetKHz()
	}
	switch normMotorTrackMode(s.TrackMode) {
	case motorTrackAlways:
		// Every frequency change means every frequency change, including this one.
	case motorTrackBand:
		// The antenna is already resonant somewhere in this band — that is all the
		// operator asked for in band mode, so a spot click inside it moves nothing.
		if ref > 0 && bandForHz(int64(ref)*1000) == bandForHz(freqHz) {
			applog.Printf("ultrabeam: followNow stays in band %q (antenna at %d kHz) — no move", bandForHz(freqHz), ref)
			return
		}
	default:
		diff := khz - ref
		if diff < 0 {
			diff = -diff
		}
		if ref > 0 && diff < step {
			applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
			return // within the deadband — don't chase a tiny QSY
		}
	}
	a.noteMotorMoveCommanded()
	if err := c.SetFrequency(khz, st.Direction); err != nil {
		applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
	} else {
		applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d, was ref %d kHz)", khz, st.Direction, ref)
	}
}

// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
// translated to backend-specific values by the backend itself.
func (a *App) SetCATMode(mode string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	mode = a.catModeForRadio(mode)
	err := a.cat.SetMode(mode)
	if err != nil {
		applog.Printf("cat: SetMode(%q) dispatch error: %v", mode, err)
	}
	return err
}

// catModeForRadio translates a LOGGED mode into what the RADIO should be put
// in, for operators who have asked for USB on digital.
//
// The soundcard modes — FT8, FT4, PSK, JS8, Q65… — are all USB with audio in
// the microphone path, and that is what most rigs need. Asking for the mode by
// name is the better answer on a modern transceiver with a DATA/PKT position,
// and the wrong one on an older set where the CAT layer resolves "digital" to
// RTTY/FSK: the operator lands in FSK, which keys the radio from a mark/space
// generator and cannot pass FT8 at all. OmniRig does exactly this, per rig
// file, and there is no arguing with it from here — so the option sends what
// the radio can actually do.
//
// This is the RADIO's mode only. The QSO is still logged as FT8: the mode is
// what the contact WAS, not what the front panel says.
func (a *App) catModeForRadio(mode string) string {
	if strings.TrimSpace(mode) == "" || a.settingOr(keyCATDigiUSB, "") != "1" {
		return mode
	}
	if qso.ModeClass(mode) != "DIG" {
		return mode
	}
	applog.Printf("cat: %s → USB (digital modes set the radio to USB)", strings.ToUpper(mode))
	return "USB"
}

// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
// These are no-ops / errors unless the active CAT backend is a FlexRadio.

// GetFlexState returns the radio's transmit/ATU state for the FlexRadio tab.
// Available=false when the active backend isn't a connected Flex.
func (a *App) GetFlexState() cat.FlexTXState {
	if a.cat == nil {
		return cat.FlexTXState{}
	}
	st, _ := a.cat.FlexState()
	return st
}

// ── Icom CI-V control panel (receive DSP) ──────────────────────────────────

func (a *App) GetIcomState() cat.IcomTXState {
	if a.cat == nil {
		return cat.IcomTXState{}
	}
	st, _ := a.cat.IcomState()
	return st
}

func (a *App) IcomRefresh() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.RefreshIcom() })
}

func (a *App) IcomSetAFGain(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAFGain(p) })
}

func (a *App) IcomSetRFGain(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRFGain(p) })
}

func (a *App) IcomSetNB(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNB(on) })
}

func (a *App) IcomSetNBLevel(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNBLevel(p) })
}

func (a *App) IcomSetNR(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNR(on) })
}

func (a *App) IcomSetNRLevel(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNRLevel(p) })
}

func (a *App) IcomSetANF(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetANF(on) })
}

func (a *App) IcomSetAPF(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAPF(on) })
}

func (a *App) IcomSetAGC(mode string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAGC(mode) })
}

func (a *App) IcomSetPreamp(n int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPreamp(n) })
}

func (a *App) IcomSetAtt(db int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAtt(db) })
}

func (a *App) IcomSetFilter(n int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomFilter(n) })
}

func (a *App) IcomSetRFPower(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRFPower(p) })
}

func (a *App) IcomSetMicGain(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMicGain(p) })
}

func (a *App) IcomSetSplit(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomSplit(on) })
}

// IcomSetATU puts the internal tuner in or out of line.
func (a *App) IcomSetATU(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetATU(on) })
}

// IcomSetSplitOffset turns split on with a chosen TX offset (Hz) — the
// console's +1/+5/+10 kHz buttons.
func (a *App) IcomSetSplitOffset(hz int64) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomSplitOffset(true, hz) })
}

func (a *App) IcomSetAntenna(n int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAntenna(n) })
}

func (a *App) IcomSetPBTInner(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPBTInner(p) })
}

func (a *App) IcomSetPBTOuter(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPBTOuter(p) })
}

func (a *App) IcomSetManualNotch(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetManualNotch(on) })
}

func (a *App) IcomSetNotchPos(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNotchPos(p) })
}

func (a *App) IcomSetSquelch(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetSquelch(p) })
}

func (a *App) IcomSetComp(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetComp(on) })
}

func (a *App) IcomSetCompLevel(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetCompLevel(p) })
}

func (a *App) IcomSetMonitor(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMonitor(on) })
}

func (a *App) IcomSetMonLevel(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMonLevel(p) })
}

func (a *App) IcomSetVOX(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetVOX(on) })
}

func (a *App) IcomSetVOXGain(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetVOXGain(p) })
}

func (a *App) IcomSetAntiVOX(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAntiVOX(p) })
}

func (a *App) IcomTune() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.TuneATU() })
}

// IcomSetPower turns the radio on or off (manual — the app never wakes the rig
// on connect). ON sends a wake preamble + CI-V 0x18 01; the rig then boots ~15s.
func (a *App) IcomSetPower(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPower(on) })
}

// icomBandCodes maps a band label to the code the band stacking registers use
// (CI-V 0x1A 0x01). Deliberately partial.
//
// The HF codes 01-09 are the same on every Icom that has HF, and are safe. What
// is NOT the same is everything above them: 50 MHz is 10 on an HF+6 rig, while
// an IC-9700 has no HF at all and numbers its three bands 144/430/1200 as 1/2/3.
// A band missing here is not a failure — the caller falls back to sending a
// frequency, which is what the button did before — so an unlisted band or an
// unlisted model loses nothing, and a GUESSED code would land the operator on
// another band entirely.
func icomBandCodes(model string) map[string]int {
	m := strings.ToUpper(model)
	if strings.Contains(m, "9700") {
		return map[string]int{"2": 1, "70cm": 2, "23cm": 3}
	}
	// HF + 6 m: the 7300 / 7610 / 7760 / 7100 / 7851 / 705 / 9100 class. 60 m and
	// 4 m are absent on purpose — they are not a band key on these radios.
	return map[string]int{
		"160": 1, "80": 2, "40": 3, "30": 4, "20": 5,
		"17": 6, "15": 7, "12": 8, "10": 9, "6": 0x10,
	}
}

// IcomRecallBand puts the radio where its own band stacking register says the
// operator last was on that band — the front panel's band key, from here.
//
// reg is 1, 2 or 3, so pressing the same band button again walks through the
// registers exactly as the radio's key does. Returns the frequency landed on;
// an error means nothing was changed and the caller should send its own
// frequency instead.
func (a *App) IcomRecallBand(band string, reg int) (int64, error) {
	if a.cat == nil {
		return 0, fmt.Errorf("cat not initialized")
	}
	code, ok := icomBandCodes(a.cat.State().Rig)[strings.ToLower(strings.TrimSpace(band))]
	if !ok {
		return 0, fmt.Errorf("no band stacking register for %s on this radio", band)
	}
	var hz int64
	err := a.cat.IcomDo(func(ic cat.IcomController) error {
		var e error
		hz, e = ic.RecallBandStack(code, reg)
		return e
	})
	return hz, err
}

// IcomSetScope enables/disables the spectrum-scope waveform stream.
func (a *App) IcomSetScope(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScope(on) })
}

// IcomScopeData returns the latest reassembled spectrum sweep for the panadapter.
func (a *App) IcomScopeData() cat.ScopeSweep {
	if a.cat == nil {
		return cat.ScopeSweep{}
	}
	sw, _ := a.cat.IcomScope()
	return sw
}

// IcomSetScopeMode selects fixed-span (true) or center-on-VFO (false) scope mode.
func (a *App) IcomSetScopeMode(fixed bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
}

// IcomSetScopeEdges points the fixed-mode scope at [lowHz, highHz] — the panel's
// "centre on VFO" (VFO±50 kHz) and pan ◀/▶ buttons.
func (a *App) IcomSetScopeEdges(lowHz, highHz int64) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeEdges(lowHz, highHz) })
}

// IcomSetRIT sets the RIT/ΔTX offset in signed Hz.
func (a *App) IcomSetRIT(hz int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRIT(hz) })
}

// IcomSetRITOn toggles RIT.
func (a *App) IcomSetRITOn(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRITOn(on) })
}

// IcomSetXITOn toggles ΔTX (XIT).
func (a *App) IcomSetXITOn(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetXITOn(on) })
}

// IcomSendCW keys a CW message through the rig's internal keyer (CI-V 0x17).
func (a *App) IcomSendCW(text string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	err := a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SendCW(text) })
	if err != nil {
		applog.Printf("icom cw: IcomSendCW(%q) failed: %v", text, err)
	}
	return err
}

// FlexSendCW keys a CW message through the FlexRadio CWX keyer (SmartSDR), so a
// Flex needs no WinKeyer / SmartCAT. Text is already variable-resolved by the UI.
func (a *App) FlexSendCW(text string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SendCW(text) })
	if err != nil {
		applog.Printf("flex cw: FlexSendCW(%q) failed: %v", text, err)
	}
	return err
}

// FlexStopCW clears the CWX buffer, aborting whatever is being keyed.
func (a *App) FlexStopCW() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.StopCW() })
}

// FlexSetKeySpeed sets the CW keyer speed in WPM (the CWX keyer uses the radio's
// CW speed).
func (a *App) FlexSetKeySpeed(wpm int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
}

// FlexBackspaceCW removes the last n not-yet-keyed characters from the CWX buffer
// (type-ahead correction). n<1 deletes one.
func (a *App) FlexBackspaceCW(n int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.BackspaceCW(n) })
}

// TCISendCW keys a CW message through the SunSDR's own macro keyer, so a TCI
// station needs no WinKeyer and no second serial port. Text is already
// variable-resolved by the UI.
func (a *App) TCISendCW(text string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	err := a.cat.TCICWDo(func(tc cat.TCICWController) error { return tc.SendCW(text) })
	if err != nil {
		applog.Printf("tci cw: TCISendCW(%q) failed: %v", text, err)
	}
	return err
}

// TCIStopCW aborts whatever the keyer is sending.
func (a *App) TCIStopCW() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.TCICWDo(func(tc cat.TCICWController) error { return tc.StopCW() })
}

// TCISetKeySpeed sets the macro keyer speed in WPM.
func (a *App) TCISetKeySpeed(wpm int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.TCICWDo(func(tc cat.TCICWController) error { return tc.SetCWSpeed(wpm) })
}

// IcomStopCW aborts the CW message currently being sent.
func (a *App) IcomStopCW() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.StopCW() })
}

// IcomSetKeySpeed sets the CW keyer speed in WPM.
func (a *App) IcomSetKeySpeed(wpm int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetKeySpeed(wpm) })
}

// IcomSetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL).
func (a *App) IcomSetBreakIn(mode int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetBreakIn(mode) })
}

func (a *App) IcomSetPTT(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.SetPTT(on)
}

func (a *App) FlexSetPower(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(p) })
}

func (a *App) FlexSetTunePower(p int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTunePower(p) })
}

func (a *App) FlexTune(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTune(on) })
}

func (a *App) FlexSetVox(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOX(on) })
}

func (a *App) FlexSetVoxLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOXLevel(l) })
}

func (a *App) FlexSetVoxDelay(ms int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOXDelay(ms) })
}

func (a *App) FlexAmpOperate(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAmpOperate(on) })
}

func (a *App) FlexSetProcessor(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetProcessor(on) })
}

func (a *App) FlexSetProcessorLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetProcessorLevel(l) })
}

func (a *App) FlexSetMon(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMon(on) })
}

func (a *App) FlexSetMonLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMonLevel(l) })
}

func (a *App) FlexSetMic(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMic(l) })
}

func (a *App) FlexMox(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.SetPTT(on) // MOX = manual transmit (xmit 1/0)
}

func (a *App) FlexATUStart() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ATUStart() })
}

func (a *App) FlexATUBypass() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ATUBypass() })
}

func (a *App) FlexSetATUMemories(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetATUMemories(on) })
}

// RX slice DSP controls (target the active receive slice).

func (a *App) FlexSetAGCMode(m string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAGCMode(m) })
}

func (a *App) FlexSetAGCThreshold(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAGCThreshold(l) })
}

func (a *App) FlexSetAudioLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) })
}

func (a *App) FlexSetMute(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
}

func (a *App) FlexSetRXAntenna(ant string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRXAntenna(ant) })
}

func (a *App) FlexSetTXAntenna(ant string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXAntenna(ant) })
}

func (a *App) FlexSetSplit(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
}

// FlexSetActiveSlice focuses a slice (A/B/C/D…) so all commands target it.
func (a *App) FlexSetActiveSlice(idx int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetActiveSlice(idx) })
}

// FlexSetTXSlice makes a slice the transmitter (e.g. TX on the active slice).
func (a *App) FlexSetTXSlice(idx int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXSlice(idx) })
}

// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
const keyFlexBandAnt = "flex.band_antennas"

// FlexBandAnt is the antenna pair to select on a given band.
type FlexBandAnt struct {
	RX string `json:"rx"`
	TX string `json:"tx"`
}

// GetFlexBandAntennas returns the band→{rx,tx} antenna map (band key uppercased,
// e.g. "20M").
func (a *App) GetFlexBandAntennas() (map[string]FlexBandAnt, error) {
	out := map[string]FlexBandAnt{}
	if a.settings == nil {
		return out, nil
	}
	v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandAnt)
	if strings.TrimSpace(v) == "" {
		return out, nil
	}
	_ = json.Unmarshal([]byte(v), &out)
	return out, nil
}

// SaveFlexBandAntennas persists the band→antenna map.
func (a *App) SaveFlexBandAntennas(m map[string]FlexBandAnt) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	b, err := json.Marshal(m)
	if err != nil {
		return err
	}
	return a.settings.SetGlobal(a.ctx, keyFlexBandAnt, string(b))
}

// FlexApplyBandAntenna selects the configured RX/TX antennas for the given band
// (no-op if the backend isn't a Flex or the band has no mapping). Called on band
// changes and spot clicks so the right antennas follow the frequency.
func (a *App) FlexApplyBandAntenna(band string) error {
	if a.cat == nil {
		return nil
	}
	band = strings.ToUpper(strings.TrimSpace(band))
	if band == "" {
		return nil
	}
	m, _ := a.GetFlexBandAntennas()
	ant, ok := m[band]
	if !ok {
		return nil
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error {
		if strings.TrimSpace(ant.RX) != "" {
			_ = fc.SetRXAntenna(ant.RX)
		}
		if strings.TrimSpace(ant.TX) != "" {
			_ = fc.SetTXAntenna(ant.TX)
		}
		return nil
	})
}

// keyFlexBandPower stores the per-band, per-mode TX power map (global,
// machine-local — it describes this station's amplifier and antennas).
const keyFlexBandPower = "flex.band_power"

// FlexBandPower is the TX power to set on a band, per class of mode.
//
// Three classes rather than per-mode: what decides the power is the duty cycle
// and the amplifier's tolerance for it, not the mode's name. FT8 and RTTY hurt
// an amplifier the same way; SSB and AM do not.
//
// Zero means "leave the power alone" — an empty box must not silently mean
// zero watts, and an operator who configures 20 m only does not want every
// other band reset when they get there.
type FlexBandPower struct {
	Phone int `json:"phone"`
	CW    int `json:"cw"`
	Digi  int `json:"digi"`
}

// GetFlexBandPower returns the band→power map (band key uppercased, "20M").
func (a *App) GetFlexBandPower() (map[string]FlexBandPower, error) {
	out := map[string]FlexBandPower{}
	if a.settings == nil {
		return out, nil
	}
	v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandPower)
	if strings.TrimSpace(v) == "" {
		return out, nil
	}
	_ = json.Unmarshal([]byte(v), &out)
	return out, nil
}

// SaveFlexBandPower persists the band→power map.
func (a *App) SaveFlexBandPower(m map[string]FlexBandPower) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	b, err := json.Marshal(m)
	if err != nil {
		return err
	}
	return a.settings.SetGlobal(a.ctx, keyFlexBandPower, string(b))
}

// flexPowerClass sorts an ADIF mode into the three classes the power table uses.
// An unknown mode returns "" and changes nothing — guessing would be setting a
// transmit power from a name we do not recognise.
func flexPowerClass(mode string) string {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "SSB", "USB", "LSB", "AM", "FM", "DV":
		return "phone"
	case "CW", "CWR":
		return "cw"
	case "FT8", "FT4", "FT2", "JT65", "JT9", "JS8", "RTTY", "PSK", "PSK31", "PSK63",
		"BPSK", "QPSK", "MFSK", "OLIVIA", "CONTESTI", "DATA", "DIGITALVOICE",
		"DIGU", "DIGL", "Q65", "MSK144", "WSPR", "FST4", "FST4W", "ARDOP", "VARA":
		return "digi"
	}
	return ""
}

// FlexApplyBandPower sets the configured TX power for a band and mode.
//
// Called on band AND mode changes: the whole point is that switching to FT8 on
// a band where 1.5 kW is fine for SSB does not put 1.5 kW into a 100% duty-cycle
// signal. No mapping, or a zero, leaves the radio alone.
func (a *App) FlexApplyBandPower(band, mode string) error {
	if a.cat == nil {
		return nil
	}
	band = strings.ToUpper(strings.TrimSpace(band))
	class := flexPowerClass(mode)
	if band == "" || class == "" {
		return nil
	}
	m, _ := a.GetFlexBandPower()
	e, ok := m[band]
	if !ok {
		return nil
	}
	pct := 0
	switch class {
	case "phone":
		pct = e.Phone
	case "cw":
		pct = e.CW
	case "digi":
		pct = e.Digi
	}
	if pct <= 0 {
		return nil
	}
	if pct > 100 {
		pct = 100
	}
	applog.Printf("flex: %s %s → TX power %d%%", band, class, pct)
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(pct) })
}

// RIT/XIT on the active slice. The offset is kept by the radio when the switch is
// off, so turning it back on restores it — the UI must not zero it on toggle.
func (a *App) FlexSetRIT(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRIT(on) })
}

func (a *App) FlexSetRITFreq(hz int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRITFreq(hz) })
}

func (a *App) FlexSetXIT(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetXIT(on) })
}

func (a *App) FlexSetXITFreq(hz int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetXITFreq(hz) })
}

func (a *App) FlexSetNB(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNB(on) })
}

func (a *App) FlexSetNBLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNBLevel(l) })
}

func (a *App) FlexSetNR(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNR(on) })
}

func (a *App) FlexSetNRLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRLevel(l) })
}

func (a *App) FlexSetANF(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANF(on) })
}

func (a *App) FlexSetANFLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFLevel(l) })
}

func (a *App) FlexSetAPF(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPF(on) })
}
func (a *App) FlexSetAPFLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPFLevel(l) })
}
func (a *App) FlexSetWNB(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNB(on) })
}
func (a *App) FlexSetWNBLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) })
}

// ── SmartSDR v4 DSP (8000/Aurora series): NRL / ANFL / NRS / RNN / ANFT / NRF ──

func (a *App) FlexSetLMSNR(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNR(on) })
}
func (a *App) FlexSetLMSNRLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSNRLevel(l) })
}
func (a *App) FlexSetLMSANF(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANF(on) })
}
func (a *App) FlexSetLMSANFLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetLMSANFLevel(l) })
}
func (a *App) FlexSetSpeexNR(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNR(on) })
}
func (a *App) FlexSetSpeexNRLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSpeexNRLevel(l) })
}
func (a *App) FlexSetRNN(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRNN(on) })
}
func (a *App) FlexSetANFT(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFT(on) })
}
func (a *App) FlexSetNRF(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRF(on) })
}
func (a *App) FlexSetNRFLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRFLevel(l) })
}

// FlexSetDAX routes the active slice's audio to a DAX channel (0 = off, 1-8).
func (a *App) FlexSetDAX(ch int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetDAX(ch) })
}

// FlexSetTXDAX toggles DAX as the transmit audio source — SmartSDR's
// transmit-bar DAX button (what WSJT-X TX audio goes through).
func (a *App) FlexSetTXDAX(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(on) })
}
func (a *App) FlexSetTXFilter(low, high int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXFilter(low, high) })
}
func (a *App) FlexSetMicProfile(name string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMicProfile(name) })
}
func (a *App) FlexSetCWSpeed(wpm int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
}
func (a *App) FlexSetCWPitch(hz int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWPitch(hz) })
}
func (a *App) FlexSetCWBreakInDelay(ms int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWBreakInDelay(ms) })
}
func (a *App) FlexSetCWSidetone(on bool) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSidetone(on) })
}
func (a *App) FlexSetSidetoneLevel(l int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSidetoneLevel(l) })
}
func (a *App) FlexSetCWFilter(bw int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWFilter(bw) })
}
func (a *App) FlexSetFilter(lo int, hi int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetFilter(lo, hi) })
}

// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
// requiring a trip through the full Settings panel. Persists the choice
// so it survives restart.
func (a *App) SwitchCATRig(n int) error {
	if n != 1 && n != 2 {
		return fmt.Errorf("rig num must be 1 or 2, got %d", n)
	}
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
		return err
	}
	a.restartAsync("cat", a.reloadCAT)
	return nil
}

// catLinkSig fingerprints everything that shapes the PHYSICAL rig link.
//
// Two settings that produce the same string produce the same connection, so
// there is nothing to gain by tearing the old one down — and a great deal to
// lose: rebuilding the link drops the rig for a second or two, and with it
// every client of the shared CAT server. Pressing Save in Preferences (or
// switching profile, which re-applies the same settings) therefore threw
// WSJT-X and JTDX off their rig, mid-QSO, with an error box — for a change
// they had usually made somewhere else entirely.
//
// Deliberately does NOT include the poll interval, the command delay, the
// transverter offset or the spot options: those are applied to the running
// manager without reconnecting anything.
func catLinkSig(s CATSettings) string {
	if !s.Enabled {
		return "off"
	}
	return strings.Join([]string{
		s.Backend, s.DigitalDefault,
		strconv.Itoa(s.OmniRigNum), s.OmniRigVFO,
		s.FlexHost, strconv.Itoa(s.FlexPort), strconv.FormatBool(s.FlexSpots),
		s.XieguPort, strconv.Itoa(s.XieguBaud), strconv.Itoa(s.XieguAddr), s.XieguPTTLine,
		s.YaesuPort, strconv.Itoa(s.YaesuBaud), strconv.FormatBool(s.YaesuLowLines),
		s.KenwoodHost, s.KenwoodPort, strconv.Itoa(s.KenwoodBaud),
		s.KenwoodDataMode, strconv.FormatBool(s.KenwoodLowLines),
		s.IcomPort, strconv.Itoa(s.IcomBaud), strconv.Itoa(s.IcomAddr),
		s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, strconv.FormatBool(s.IcomNetAudio),
		s.TCIHost, strconv.Itoa(s.TCIPort), strconv.FormatBool(s.TCISpots),
	}, "|")
}

// catShareKeyed reports whether the running share server is holding the rig
// keyed for a client.
func (a *App) catShareKeyed() bool {
	if a.catShare != nil && a.catShare.Keyed() {
		return true
	}
	return a.catShareTCI != nil && a.catShareTCI.Keyed()
}

// reloadCATShareWhenUnkeyed waits for the transmission to end, then applies the
// change. Bounded, because a rig left keyed by a client that went away must not
// postpone the operator's setting for ever — after a minute the change wins and
// the emergency unkey in Stop() covers the radio.
func (a *App) reloadCATShareWhenUnkeyed(s CATSettings) {
	for i := 0; i < 120; i++ { // 120 × 500 ms = 1 minute
		time.Sleep(500 * time.Millisecond)
		if !a.catShareKeyed() {
			break
		}
	}
	// The signature was already recorded by the caller, so clear it: this IS the
	// rebuild it stood for, and leaving it set would make the call below a no-op.
	a.catShareSig = ""
	applog.Printf("cat share: transmission over — applying the deferred rebuild")
	a.reloadCATShare(s)
}

// catShareSig does the same for the shared-CAT server: which protocol, which
// port, on or off. Restarting it is what actually disconnects WSJT-X.
func catShareSig(s CATSettings) string {
	if !s.Enabled || !s.ShareEnabled {
		return "off"
	}
	return fmt.Sprintf("%s|%d|%d", s.ShareProto, s.SharePort, s.ShareTCIPort)
}

// reloadCAT (re)starts the CAT manager based on the current settings.
// Called at startup and after the user saves new CAT config.
// restartAsync runs a hardware (re)start off the caller's goroutine.
//
// Saving settings must never block the UI on a device. Restarting a link tears
// the old one down FIRST and waits for its poll goroutine to exit — and that
// goroutine can be tens of seconds deep inside a call that cannot be
// interrupted: OmniRig's COM Connect when another program (MSHV, a contest
// logger) is holding the rig takes ~45 s to give up. Save-and-close froze for
// exactly that long, because the Wails binding was waiting on it.
//
// The restart still takes as long; it just no longer holds the window. The
// duration is logged, since "the rig took 45 s to let go" is invisible
// otherwise and looks like OpsLog hanging.
func (a *App) restartAsync(name string, f func()) {
	go func() {
		t0 := time.Now()
		f()
		// Half a second, not two: "switching profile is not instant" is a report
		// that cannot be answered without knowing WHICH device took the time, and
		// a device that takes 1.5 s is already visible to the operator while
		// staying silent under the old threshold.
		if d := time.Since(t0); d > 500*time.Millisecond {
			applog.Printf("%s: restart took %s (device slow to release/connect)", name, d.Round(time.Millisecond))
		}
	}()
}

func (a *App) reloadCAT() {
	if a.cat == nil {
		return
	}
	s, err := a.GetCATSettings()
	if err != nil {
		return
	}
	a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
	a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
	// Transverter offset. Zero when switched off — the manager treats "no offset"
	// and "disabled" as the same thing, so the number stays in the settings for
	// the next time the transverter goes back in line.
	if s.OffsetOn {
		a.cat.SetFreqOffset(s.OffsetHz)
	} else {
		a.cat.SetFreqOffset(0)
	}
	a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
	a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
	a.catFlexDecodeSecs = s.FlexDecodeSecs
	a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax
	// The recorder's source depends on the backend: over the Icom LAN there is no
	// sound card to capture, the audio is pushed in. Restarted only when the
	// answer CHANGES, so an ordinary settings save never interrupts a recording.
	if want := s.Enabled && s.Backend == "icom-net" && s.IcomNetAudio; want != a.qsoRecPushed {
		applog.Printf("qso-rec: audio source changes (network=%v) — restarting the recorder", want)
		go a.startQSORecorderIfEnabled()
	}
	a.reloadCATShare(s)
	// Preferences that the LINK does not depend on, pushed to the rig that is
	// already connected. They are deliberately absent from catLinkSig — none of
	// them is worth dropping the CAT link (and with it WSJT-X's rigctl session)
	// to apply — so without this they waited for the next launch, and the
	// operator ticking "RTTY on USB" watched the rig go on choosing LSB.
	if s.Enabled && s.Backend == "yaesu" && a.cat != nil {
		_ = a.cat.YaesuDo(func(y cat.YaesuController) error { y.SetRTTYUpper(s.YaesuRTTYUSB); return nil })
	}
	// Nothing about the link changed → leave it connected. See catLinkSig.
	if sig := catLinkSig(s); sig == a.catSig {
		applog.Printf("cat: settings saved, link unchanged — staying connected")
		return
	} else {
		a.catSig = sig
	}
	// Withdraw the radio as an audio output before deciding anything else. The
	// TCI case below puts it back; every other backend, and a CAT link turned
	// off entirely, leaves it withdrawn — a voice keyer that still lists a radio
	// it can no longer reach would play a message to nowhere, and the operator
	// hears their own PTT click and assumes it went out.
	a.installTCITXPlayer(false)
	if !s.Enabled {
		a.cat.Stop()
		return
	}
	switch s.Backend {
	case "omnirig":
		// No explicit launch — COM auto-activates OmniRig.exe via its
		// LocalServer32 registration when we CreateObject in Connect().
		// Spawning OmniRig.exe ourselves (even with /Embedding) on every
		// reloadCAT raised the existing instance's window to the front,
		// which is what Log4OM avoids by relying entirely on COM activation.
		a.cat.Start(cat.NewOmniRig(s.OmniRigNum, s.OmniRigVFO))
	case "flex":
		// Native FlexRadio (SmartSDR) TCP API — no OmniRig needed.
		fb := cat.NewFlex(s.FlexHost, s.FlexPort, s.FlexSpots)
		// Clicking one of our spots on the panadapter fills the entry form.
		fb.OnSpotClick = func(call string, hz int64, mode string) {
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "flex:spot_clicked", map[string]any{
					"call": call, "freq_hz": hz, "pota_ref": a.potaRefFor(call),
				})
			}
			// A spot clicked ON the panadapter deserves the same zoom as one
			// clicked in the band map: the operator did the same thing and is
			// looking at the same signal, and only the click's origin differs.
			a.FlexZoomForSpot(mode, hz)
		}
		// Spots posted by ANOTHER program — a CW skimmer, in practice. See
		// flexrstchase.go: a marked report is where the DX was just listening.
		fb.OnForeignSpot = a.handleForeignSpot
		a.cat.Start(fb)
	case "xiegu":
		// Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope,
		// no DSP block, no data mode. Its own backend rather than the Icom one at
		// another address, so we never poll a G90 for answers it cannot give.
		xg := cat.NewXiegu(s.XieguPort, s.XieguBaud, s.XieguAddr, s.DigitalDefault)
		// A G90 ignores the CI-V PTT command; its interfaces key on a hardware
		// line instead. Selected here so rigctld — and therefore WSJT-X — can
		// actually put the rig on the air.
		xg.SetPTTLine(s.XieguPTTLine)
		a.cat.Start(xg)
	case "yaesu":
		// Native Yaesu CAT over the rig's USB/serial port — no OmniRig. Every
		// Yaesu fault reported so far came from OmniRig's interpretation layer
		// (a rig file that hides the VFO, a Freq property meaning A on one model
		// and B on another); talking to the radio directly removes it.
		yz := cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault)
		yz.SetRTTYUpper(s.YaesuRTTYUSB)
		yz.SetLowerLines(s.YaesuLowLines)
		a.cat.Start(yz)
	case "kenwood", "elecraft":
		// Native Kenwood CAT — TS-590/890/990/2000 and everything that speaks the
		// same dialect, the Elecraft K3/K4 included: the K3 emulates the Kenwood
		// command set, so one transport serves both and the Elecraft flag only
		// turns on its specifics (digital modes → DATA A via MD6+DT0).
		elecraft := s.Backend == "elecraft"
		switch kenwoodLinkOr(s.KenwoodLink, s.KenwoodHost) {
		case kenwoodLinkNative:
			// The radio's OWN network protocol — a session, its own framing, its
			// own authentication. Nothing here speaks it: it is not the CAT byte
			// stream with a socket in front, and pretending otherwise would open a
			// connection that answers nothing and blame the radio for it.
			applog.Printf("cat: %s over the radio's own network protocol is not implemented — use USB, or an RS-232-to-Ethernet bridge", s.Backend)
			a.cat.Stop()
			// Said on screen as well as in the log: a CAT panel that simply
			// stays disconnected sends the operator hunting a cable.
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "cat:state", cat.RigState{
					Error: "this radio's own network protocol is not supported yet — use USB, or an RS-232-to-Ethernet bridge",
				})
			}
			return
		case kenwoodLinkBridge:
			kw := cat.NewKenwoodTCP(strings.TrimSpace(s.KenwoodHost), s.DigitalDefault)
			kw.SetDataMode(s.KenwoodDataMode)
			kw.SetElecraft(elecraft)
			a.cat.Start(kw)
		default:
			kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)
			kw.SetLowerLines(s.KenwoodLowLines)
			kw.SetDataMode(s.KenwoodDataMode)
			kw.SetElecraft(elecraft)
			a.cat.Start(kw)
		}
	case "icom":
		// Native Icom CI-V over the radio's USB serial port (local control).
		// Same civ protocol the network backend reuses for remote.
		a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault))
	case "icom-net":
		// Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote
		// Utility). Reuses the whole IcomController over the network transport.
		var audioSink func([]byte)
		if s.IcomNetAudio && a.audioMgr != nil {
			// Experimental: stream RX audio over 50003. Start the render-only monitor
			// sink (no USB capture) and feed each decoded payload into it, so remote
			// RX audio plays through the "Listening" device. Decoding is via the PCM
			// codec (Opus later). The 50003 payload OFFSET is still pending on-rig
			// verification (see icomaudio.go) — hence experimental + opt-in.
			acfg, _ := a.GetAudioSettings()
			a.audioMgr.StopMonitor() // clear any prior monitor/sink so a re-save restarts cleanly
			// The SINK — decode + recorder feed — exists whenever the stream is
			// on. It used to be built only when the speakers were also wanted,
			// so "speakers off" (or a sink that failed to open) silently turned
			// the whole stream off: audio=false on the wire, no recordings, no
			// voice keyer, and a ticked RX-audio checkbox that did nothing.
			codec := audio.NewPCM16Codec()
			audioSink = func(payload []byte) {
				pcm, err := codec.Decode(payload)
				if err != nil {
					return
				}
				a.audioMgr.PushMonitorAudio(pcm)
				// And to the QSO recorder, which has no device to capture from
				// on this backend. It drops the samples unless a recording is
				// actually running, so this costs a function call when idle.
				if a.qsoRec != nil {
					a.qsoRec.PushRX(pcm)
				}
			}
			// Listening is a CHOICE, remembered: an operator sitting next to the
			// radio hears it in the room and wants the stream only for the
			// recorder and the voice keyer. Stop listening turns this off, the
			// Listen button turns it back on, and reconnects respect it instead
			// of switching the speakers back on every time.
			if a.settingOr(keyAudioMonitorOn, "1") != "1" {
				applog.Printf("icom-net audio: stream on, speakers off (Listening was stopped by the operator)")
			} else if err := a.audioMgr.StartMonitorSink(acfg.ListeningDevice); err != nil {
				applog.Printf("icom-net audio: stream on, but the output sink failed: %v", err)
			} else {
				applog.Printf("icom-net audio: RX audio streaming ENABLED (experimental) → %q", acfg.ListeningDevice)
			}
		}
		a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink))
		// With the audio session open the radio can also TAKE audio: offer it to
		// the voice keyer, the way a TCI radio is offered.
		a.installIcomTXPlayer(audioSink != nil)
	case "tci":
		// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
		// TCI-compatible server. The receive audio rides the same socket, so
		// the QSO recorder can take it without a virtual cable — see
		// app_tci_rec.go. Armed after the backend is up, since it is the
		// backend that carries the stream.
		defer a.startTCIRecording()
		// And the other direction: the voice keyer can send its messages over
		// the same link — see app_tci_dvk.go.
		defer a.installTCITXPlayer(true)
		tb := cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots)
		// Clicking one of our spots on the ExpertSDR panorama fills the entry form.
		tb.OnSpotClick = func(call string, hz int64) {
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "tci:spot_clicked", map[string]any{
					"call": call, "freq_hz": hz, "pota_ref": a.potaRefFor(call),
				})
			}
		}
		a.cat.Start(tb)
	default:
		// Unknown backend → stop and emit a dummy state so the UI shows it.
		a.cat.Stop()
	}
}

// DiscoverFlexRadios listens for FlexRadio discovery broadcasts on the LAN and
// returns the radios found (for the CAT settings "auto-detect" button).
func (a *App) DiscoverFlexRadios() ([]cat.FlexRadio, error) {
	return cat.DiscoverFlex(2500 * time.Millisecond)
}

// ClearLookupCache empties the local callsign cache.
func (a *App) ClearLookupCache() error {
	if a.cache == nil {
		return fmt.Errorf("cache not initialized")
	}
	return a.cache.Clear(a.ctx)
}

// CtyDatInfo describes the currently-loaded cty.dat file (or zero values
// if it hasn't been loaded yet). Exposed for the Maintenance menu so the
// user can see what they're working with before triggering a refresh.
type CtyDatInfo struct {
	Path        string `json:"path"`
	Entities    int    `json:"entities"`
	LoadedAt    string `json:"loaded_at,omitempty"`     // RFC3339, "" if not loaded
	FileModTime string `json:"file_mod_time,omitempty"` // RFC3339, "" if missing
}

// GetCtyDatInfo returns metadata about the on-disk cty.dat.
func (a *App) GetCtyDatInfo() CtyDatInfo {
	if a.dxcc == nil {
		return CtyDatInfo{}
	}
	src := a.dxcc.Info()
	out := CtyDatInfo{Path: src.Path, Entities: src.Entities}
	if !src.LoadedAt.IsZero() {
		out.LoadedAt = src.LoadedAt.UTC().Format(time.RFC3339)
	}
	if !src.FileModTime.IsZero() {
		out.FileModTime = src.FileModTime.UTC().Format(time.RFC3339)
	}
	return out
}

// RefreshCtyDat re-downloads cty.dat from country-files.com and reloads it
// into memory. Synchronous so the UI can show a spinner; ~1s typical.
func (a *App) RefreshCtyDat() (CtyDatInfo, error) {
	if a.dxcc == nil {
		return CtyDatInfo{}, fmt.Errorf("dxcc manager not initialized")
	}
	if err := a.dxcc.Refresh(a.ctx); err != nil {
		return CtyDatInfo{}, err
	}
	return a.GetCtyDatInfo(), nil
}

// --- Station bindings ---
//
// GetStationSettings/SaveStationSettings now operate on the **currently
// active profile** rather than a flat settings key set. Kept for the
// existing topbar/quick-edit code paths; the full profile CRUD lives in
// the Profile bindings below.

func (a *App) GetStationSettings() (StationSettings, error) {
	if a.profiles == nil {
		return StationSettings{}, fmt.Errorf("profiles not initialized")
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return StationSettings{}, err
	}
	return StationSettings{
		Callsign:  p.Callsign,
		Operator:  p.Operator,
		MyGrid:    p.MyGrid,
		MyCountry: p.MyCountry,
		MySOTARef: p.MySOTARef,
		MyPOTARef: p.MyPOTARef,
		MyIOTA:    p.MyIOTA,
	}, nil
}

// --- Lists bindings (bands + modes with default RST) ---

// GetListsSettings returns the user-customisable lists. Defaults are
// returned when the user has not customised anything.
func (a *App) GetListsSettings() (ListsSettings, error) {
	if a.settings == nil {
		return ListsSettings{Bands: defaultBands, Modes: defaultModes}, fmt.Errorf("db not initialized")
	}
	out := ListsSettings{}
	if raw, _ := a.settings.Get(a.ctx, keyListsBands); raw != "" {
		_ = json.Unmarshal([]byte(raw), &out.Bands)
	}
	if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" {
		_ = json.Unmarshal([]byte(raw), &out.Modes)
	}
	if raw, _ := a.settings.Get(a.ctx, keyListsRSTPhone); raw != "" {
		_ = json.Unmarshal([]byte(raw), &out.RSTPhone)
	}
	if raw, _ := a.settings.Get(a.ctx, keyListsRSTCW); raw != "" {
		_ = json.Unmarshal([]byte(raw), &out.RSTCW)
	}
	if raw, _ := a.settings.Get(a.ctx, keyListsRSTDigital); raw != "" {
		_ = json.Unmarshal([]byte(raw), &out.RSTDigital)
	}
	if raw, _ := a.settings.Get(a.ctx, keyListsSatellites); raw != "" {
		_ = json.Unmarshal([]byte(raw), &out.Satellites)
	}
	if len(out.Bands) == 0 {
		out.Bands = append([]string(nil), defaultBands...)
	}
	if len(out.Modes) == 0 {
		out.Modes = append([]ModePreset(nil), defaultModes...)
	}
	if len(out.RSTPhone) == 0 {
		out.RSTPhone = append([]string(nil), defaultRSTPhone...)
	}
	if len(out.RSTCW) == 0 {
		out.RSTCW = append([]string(nil), defaultRSTCW...)
	}
	if len(out.RSTDigital) == 0 {
		out.RSTDigital = append([]string(nil), defaultRSTDigital...)
	}
	// Satellites are NOT defaulted to a shipped list. An empty list means the
	// station does not work satellites, and filling it with two dozen birds
	// nobody here has heard would make the field harder to use, not easier —
	// the operator adds the ones they actually work.
	return out, nil
}

// SaveListsSettings persists the user-customised lists.
func (a *App) SaveListsSettings(l ListsSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	b, err := json.Marshal(l.Bands)
	if err != nil {
		return err
	}
	if err := a.settings.Set(a.ctx, keyListsBands, string(b)); err != nil {
		return err
	}
	m, err := json.Marshal(l.Modes)
	if err != nil {
		return err
	}
	if err := a.settings.Set(a.ctx, keyListsModes, string(m)); err != nil {
		return err
	}
	for k, v := range map[string][]string{
		keyListsRSTPhone:   l.RSTPhone,
		keyListsRSTCW:      l.RSTCW,
		keyListsRSTDigital: l.RSTDigital,
	} {
		b, err := json.Marshal(v)
		if err != nil {
			return err
		}
		if err := a.settings.Set(a.ctx, k, string(b)); err != nil {
			return err
		}
	}
	// The Chase New feed filters on this list per message and reads it from a
	// cache, so a band ticked here has to reach that cache now — otherwise it
	// takes effect at the next restart, which looks like the option not working.
	a.refreshChaseBands()
	sat, err := json.Marshal(l.Satellites)
	if err != nil {
		return err
	}
	if err := a.settings.Set(a.ctx, keyListsSatellites, string(sat)); err != nil {
		return err
	}

	return nil
}

// SaveStationSettings updates only the six "basic" fields on the active
// profile. Use the Profile bindings (ListProfiles / SaveProfile…) for
// full multi-profile management.
func (a *App) SaveStationSettings(s StationSettings) error {
	if a.profiles == nil {
		return fmt.Errorf("profiles not initialized")
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return err
	}
	p.Callsign = s.Callsign
	p.Operator = s.Operator
	p.MyGrid = s.MyGrid
	p.MyCountry = s.MyCountry
	p.MySOTARef = s.MySOTARef
	p.MyPOTARef = s.MyPOTARef
	// Uppercased on the way in: ADIF spells it EU-005, and an operator typing
	// "eu-005" would otherwise put a reference no award matcher recognises on
	// every QSO of an activation.
	p.MyIOTA = strings.ToUpper(strings.TrimSpace(s.MyIOTA))
	if err := a.profiles.Save(a.ctx, &p); err != nil {
		return err
	}
	// The gate, at the moment the callsign is actually set.
	//
	// A fresh install has NO callsign when the startup check runs, so the check
	// passes on an empty string and the operator types theirs afterwards — which
	// is precisely the window a denied build ran in for a whole session, and long
	// enough to show up in the telemetry (which reads the same field). Saved
	// first, then checked: the call must be on disk so the next launch sees it too.
	enforceCallGate(p.Callsign)
	return nil
}

// --- Profile bindings (multi-profile CRUD) ---

// ListProfiles returns every saved profile, active first.
func (a *App) ListProfiles() ([]profile.Profile, error) {
	if a.profiles == nil {
		return nil, fmt.Errorf("profiles not initialized")
	}
	return a.profiles.List(a.ctx)
}

// GetActiveProfile returns the currently-selected profile.
func (a *App) GetActiveProfile() (profile.Profile, error) {
	if a.profiles == nil {
		return profile.Profile{}, fmt.Errorf("profiles not initialized")
	}
	return a.profiles.Active(a.ctx)
}

// SaveProfile upserts a profile. Pass id=0 to create a new one.
func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
	if a.profiles == nil {
		return profile.Profile{}, fmt.Errorf("profiles not initialized")
	}
	// Store a logbook that lives inside the app folder RELATIVE to it, so the
	// profile keeps working when the folder is copied to another machine or
	// another drive letter.
	p.DB.Path = portablePath(p.DB.Path)
	if err := a.profiles.Save(a.ctx, &p); err != nil {
		return profile.Profile{}, err
	}
	// Only for the profile in use: editing some other profile's callsign is not
	// running under it, and "--profile <other>" has to stay a way back in.
	if act, aerr := a.profiles.Active(a.ctx); aerr == nil && act.ID == p.ID {
		enforceCallGate(p.Callsign)
	}
	a.refreshOperatorGrid()
	return p, nil
}

// DeleteProfile removes a profile. Refuses to delete the last remaining
// profile; promotes another to active if the deleted one was selected.
func (a *App) DeleteProfile(id int64) error {
	if a.profiles == nil {
		return fmt.Errorf("profiles not initialized")
	}
	return a.profiles.Delete(a.ctx, id)
}

// ActivateProfile switches the selected profile. Subsequent QSOs stamp
// MY_* fields from this one.
func (a *App) ActivateProfile(id int64) error {
	if a.profiles == nil {
		return fmt.Errorf("profiles not initialized")
	}
	if err := a.profiles.SetActive(a.ctx, id); err != nil {
		return err
	}
	// EVERY setting is per-profile: re-scope the settings store first, so all
	// the reloads below read this profile's values.
	a.settings.SetProfile(id)
	// The gate again, on the profile just activated.
	//
	// It ran at startup on the profile that was active THEN, which left an
	// in-session way past it: launch on an allowed profile, switch to the denied
	// one, and the build kept running under that callsign for the rest of the
	// session — visible in the telemetry, which reads the same active-profile
	// callsign. Startup is not the only moment the answer can change.
	if p, err := a.profiles.Get(a.ctx, id); err == nil {
		enforceCallGate(p.Callsign)
	}
	a.refreshOperatorGrid()
	// The logbook follows the active profile: reconnect to this profile's DB
	// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
	if p, err := a.profiles.Get(a.ctx, id); err == nil {
		if err := a.switchLogbook(p); err != nil {
			// The switch failed (typically: this profile's MySQL is unreachable right
			// now) and the PREVIOUS logbook stays live. Silently staying on the old
			// database is how QSOs end up in the wrong log — tell the operator.
			applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf(
					"Profil %q : connexion à sa base impossible — l'ancienne base reste active ! (%v)", p.Name, err))
			}
		}
	}
	// Re-apply every settings-dependent subsystem for the new profile.
	a.reloadAfterProfileSwitch()
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "profile:changed", id)
	}
	return nil
}

// reloadAfterProfileSwitch re-applies all settings-derived state for the newly
// active profile: lookup providers, upload-service accounts, CAT connection,
// the station hardware, and the QSO recorder (audio devices). The Winkeyer
// stays as-is (the operator connects it explicitly). The frontend reloads its
// panels via profile:changed.
func (a *App) reloadAfterProfileSwitch() {
	// Logged because this rebuilds EVERY settings-dependent subsystem, and a
	// shared operator's log showed it running 54 times in one session — each one
	// dropping WSJT-X's rigctl connection, three of them while the rig was
	// keyed. The teardown itself is now guarded (see catLinkSig), but a re-apply
	// arriving every few minutes still means something is asking for it, and
	// nothing in the log said so.
	applog.Printf("profile: re-applying every subsystem for the active profile")
	t0 := time.Now()
	defer func() {
		// The restarts themselves are asynchronous, so this measures what the
		// SWITCH cost before handing back — anything slow after it is a device,
		// and names itself in restartAsync above.
		applog.Printf("profile: re-apply issued in %s", time.Since(t0).Round(time.Millisecond))
	}()
	a.reloadLookupProviders()
	if a.extsvc != nil {
		a.extsvc.SetConfig(a.loadExternalServices())
	}
	// Off the caller's goroutine for the same reason as the settings save: this
	// runs from ActivateProfile, a click, and a rig that is slow to release would
	// otherwise freeze the switch.
	a.restartAsync("cat", a.reloadCAT)
	// Every device below is configured PER PROFILE, and none of them used to
	// follow the profile: they were built once at startup and again only when
	// Settings was saved. An operator with an SPE on COM9 for HF and another on
	// COM10 for 6 m — one amplifier per profile, which is the whole point of
	// having two — switched profile and stayed connected to the previous port,
	// then had to open Settings and press Save to get the right one. Save is not
	// a connect button; switching profile is what asks for this hardware.
	//
	// Unconditional, exactly as a save is: the alternative is comparing every
	// device's configuration between profiles, and a needless reconnect on a
	// profile switch costs a second, while a missed one costs the amplifier.
	a.restartAsync("amp", a.startAmps)
	a.restartAsync("antenna", a.startUltrabeam)
	a.restartAsync("antgenius", a.startAntGenius)
	a.restartAsync("tuner", a.startTunerGenius)
	a.restartAsync("psu", a.startPSU)
	a.startQSORecorderIfEnabled()
	// The watchlist's Club Log enrichment (OQRS/QSO-count badges) reads
	// per-profile settings too — rebuilt so a switch doesn't keep showing the
	// previous profile's view of who is worth chasing.
	a.startWatchlistClubLog()
	// Auto-call is per profile — attempts, the "call only" callsign, whether it
	// is on at all — and it TRANSMITS. A switch that left the previous profile's
	// settings running would have the wrong station calling on the wrong log.
	//
	// Off, exactly as at launch: a switch changes the callsign, the log and
	// usually the antenna, and none of that is a state in which a transmitter
	// should resume by itself because this profile's stored setting said on.
	// Re-applied rather than restarted: the loop is one goroutine for the life
	// of the process.
	a.disarmAutoCall("profile switch")
	a.autoCallEngine().Reset()
	// Same reasoning for the satellite tracker: it transmits, and the new profile
	// may be a different station on a different antenna.
	a.StopSatelliteTracking()
}

// DuplicateProfile clones an existing profile under newName. Useful when
// the user has a "Home" profile and wants to derive "Portable" from it
// without retyping every field.
func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error) {
	if a.profiles == nil {
		return profile.Profile{}, fmt.Errorf("profiles not initialized")
	}
	p, err := a.profiles.Duplicate(a.ctx, id, newName)
	if err != nil {
		return profile.Profile{}, err
	}
	// A profile is the sum of its identity + DB target (copied by Duplicate) +
	// its per-profile settings, its rig/antenna tree, and its QSL templates.
	// Copy all of them so the clone is a true, independent duplicate.
	if a.settings != nil {
		if err := a.settings.CopyProfile(a.ctx, id, p.ID); err != nil {
			applog.Printf("duplicate profile: copy settings: %v", err)
		}
	}
	if a.operating != nil {
		if err := a.operating.CopyProfile(a.ctx, id, p.ID); err != nil {
			applog.Printf("duplicate profile: copy operating: %v", err)
		}
	}
	if err := a.copyQSLTemplatesToProfile(id, p.ID); err != nil {
		applog.Printf("duplicate profile: copy qsl templates: %v", err)
	}
	return p, nil
}

// --- Rotator bindings ---

const keyRotatorsList = "rotators.json"

// RotatorDevice is one configured rotor in Settings → Rotator (a list, like the
// amplifiers). A Rotator Genius with Dual on drives BOTH its ports and so
// contributes TWO logical rotors — Name/Motorized for the first, Name2/Motorized2
// for the second.
type RotatorDevice struct {
	ID           string `json:"id"`
	Name         string `json:"name"`
	Type         string `json:"type"`          // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (GS-232A)
	Host         string `json:"host"`          // default 127.0.0.1
	Port         int    `json:"port"`          // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco)
	HasElevation bool   `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
	RotatorNum   int    `json:"rotator_num"`   // Rotator Genius internal index (1/2) when not Dual
	Dual         bool   `json:"dual"`          // Rotator Genius: drive both ports → two logical rotors
	Motorized    bool   `json:"motorized"`     // carries a motorized antenna (Ultrabeam/SteppIR) → show pattern paths
	Name2        string `json:"name2"`         // Dual: label for the second rotor
	Motorized2   bool   `json:"motorized2"`    // Dual: second rotor motorized
	Transport    string `json:"transport"`     // ARCO: "tcp" (LAN) | "serial" (USB COM)
	ComPort      string `json:"com_port"`      // GS-232 serial transport
	Baud         int    `json:"baud"`          // GS-232 serial baud (an ERC needs it; an ARCO ignores it)
	// SpidModel picks the SPID dialect: "rot2prog" (RAS/BIG-RAS/MD-01/MD-02,
	// azimuth + elevation) or "rot1prog" (the older azimuth-only controller).
	// They differ in reply length and baud rate, so guessing is not an option.
	SpidModel string `json:"spid_model,omitempty"`
}

// logicalRotor is one addressable rotor. Flattening the device list expands a
// Dual Rotator Genius into two.
type logicalRotor struct {
	Name      string
	Motorized bool
	Link      rotorLink
}

// normRotorType clamps a rotor type to a known backend.
func normRotorType(t string) string {
	if t == "rotgenius" || t == "arco" || t == "dcu1" || t == "spid" {
		return t
	}
	return "pst"
}

// rotatorDefaultPort is each backend's default network port.
func rotatorDefaultPort(typ string) int {
	switch typ {
	case "rotgenius":
		return 9006 // 4O3A native default
	case "arco":
		return 4001 // placeholder — the real number is set in ARCO's LAN menu
	case "dcu1":
		return 4001 // only used with a serial-over-IP bridge; DCU-1 has no standard
	default:
		return 12000 // PstRotator UDP
	}
}

// deviceLink builds the connection params for a device's rotor. sub selects the
// Rotator Genius internal port (1/2) for a Dual device; ignored otherwise.
func deviceLink(d RotatorDevice, sub int) rotorLink {
	l := rotorLink{
		Type: normRotorType(d.Type), Host: d.Host, Port: d.Port,
		Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: d.HasElevation,
		SpidModel: d.SpidModel,
	}
	if l.Host == "" {
		l.Host = "127.0.0.1"
	}
	if l.Port <= 0 || l.Port > 65535 {
		l.Port = rotatorDefaultPort(l.Type)
	}
	if l.Baud <= 0 {
		// A SPID runs at 600 or 1200 baud depending on the dialect; 0 lets its
		// driver pick, and forcing 9600 here would have made every controller
		// mute for a reason nobody would guess.
		if l.Type != "spid" {
			l.Baud = 9600
		}
	}
	if l.Transport != "serial" {
		l.Transport = "tcp"
	}
	if l.Type == "rotgenius" {
		if d.Dual {
			l.Num = 1
			if sub == 2 {
				l.Num = 2
			}
		} else if d.RotatorNum == 2 {
			l.Num = 2
		} else {
			l.Num = 1
		}
	}
	return l
}

// flattenRotors expands the device list into the ordered logical rotors the
// compass switches between (a Dual Rotator Genius yields two).
func flattenRotors(devs []RotatorDevice) []logicalRotor {
	var out []logicalRotor
	for _, d := range devs {
		if normRotorType(d.Type) == "rotgenius" && d.Dual {
			out = append(out,
				logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)},
				logicalRotor{Name: d.Name2, Motorized: d.Motorized2, Link: deviceLink(d, 2)},
			)
			continue
		}
		out = append(out, logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)})
	}
	return out
}

// GetRotators returns the configured rotor list, migrating the legacy single-
// rotor flat settings into the list on first read (persisted on next save).
func (a *App) GetRotators() ([]RotatorDevice, error) {
	if a.settings == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	raw, _ := a.settings.Get(a.ctx, keyRotatorsList)
	if strings.TrimSpace(raw) != "" {
		var list []RotatorDevice
		if err := json.Unmarshal([]byte(raw), &list); err == nil {
			return list, nil
		}
	}
	return a.migrateLegacyRotors(), nil
}

// migrateLegacyRotors builds a device list from the pre-list flat settings keys
// (the single rotor shipped in 0.23.2, plus the unreleased two-rotor keys).
func (a *App) migrateLegacyRotors() []RotatorDevice {
	m, _ := a.settings.GetMany(a.ctx,
		keyRotatorEnabled, keyRotatorType, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorNum,
		keyRotatorDual, keyRotatorName1, keyRotatorName2, keyRotatorMotor1, keyRotatorMotor2,
		keyRotatorTransport, keyRotatorComPort, keyRotatorBaud,
		keyRotatorType2, keyRotatorHost2, keyRotatorPort2, keyRotatorHasElevation2, keyRotatorNum2,
		keyRotatorTransport2, keyRotatorComPort2, keyRotatorBaud2)
	if m[keyRotatorEnabled] != "1" && m[keyRotatorType] == "" && m[keyRotatorHost] == "" {
		return []RotatorDevice{} // never configured
	}
	atoi := func(s string) int { n, _ := strconv.Atoi(s); return n }
	typ := normRotorType(m[keyRotatorType])
	dual := m[keyRotatorDual] == "1"
	d1 := RotatorDevice{
		ID: "rotor-1", Name: strings.TrimSpace(m[keyRotatorName1]), Type: typ,
		Host: m[keyRotatorHost], Port: atoi(m[keyRotatorPort]), HasElevation: m[keyRotatorHasElevation] == "1",
		RotatorNum: map[bool]int{true: 2, false: 1}[m[keyRotatorNum] == "2"],
		Motorized:  m[keyRotatorMotor1] != "0", Transport: m[keyRotatorTransport], ComPort: m[keyRotatorComPort], Baud: atoi(m[keyRotatorBaud]),
		// A legacy Rotator Genius "two rotors" was one Dual device.
		Dual: dual && typ == "rotgenius", Name2: strings.TrimSpace(m[keyRotatorName2]), Motorized2: m[keyRotatorMotor2] == "1",
	}
	devs := []RotatorDevice{d1}
	// The unreleased "two independent rotors" (dual with a distinct rotor 2 of any
	// type) becomes a second list entry.
	if dual && typ != "rotgenius" {
		devs = append(devs, RotatorDevice{
			ID: "rotor-2", Name: strings.TrimSpace(m[keyRotatorName2]), Type: normRotorType(m[keyRotatorType2]),
			Host: m[keyRotatorHost2], Port: atoi(m[keyRotatorPort2]), HasElevation: m[keyRotatorHasElevation2] == "1",
			RotatorNum: map[bool]int{true: 2, false: 1}[m[keyRotatorNum2] == "2"],
			Motorized:  m[keyRotatorMotor2] == "1", Transport: m[keyRotatorTransport2], ComPort: m[keyRotatorComPort2], Baud: atoi(m[keyRotatorBaud2]),
		})
	}
	return devs
}

// SaveRotators persists the rotor list. Connections are per-call (no socket to
// (re)open) so no reload step is needed.
func (a *App) SaveRotators(list []RotatorDevice) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	for i := range list {
		d := &list[i]
		if strings.TrimSpace(d.ID) == "" {
			d.ID = fmt.Sprintf("rotor-%d-%d", time.Now().Unix(), i)
		}
		d.Type = normRotorType(d.Type)
		if strings.TrimSpace(d.Host) == "" {
			d.Host = "127.0.0.1"
		}
		if d.Port <= 0 || d.Port > 65535 {
			d.Port = rotatorDefaultPort(d.Type)
		}
		if d.RotatorNum != 2 {
			d.RotatorNum = 1
		}
		if d.Transport != "serial" {
			d.Transport = "tcp"
		}
		if d.Baud <= 0 {
			d.Baud = 9600
		}
	}
	b, err := json.Marshal(list)
	if err != nil {
		return err
	}
	return a.settings.Set(a.ctx, keyRotatorsList, string(b))
}

// rotorLink is the connection params for one rotor, so the command methods don't
// branch on device shape.
type rotorLink struct {
	Type         string
	Host         string
	Port         int
	Num          int // Rotator Genius internal index (1/2)
	Transport    string
	ComPort      string
	Baud         int
	HasElevation bool
	SpidModel    string // SPID: "rot2prog" (default) | "rot1prog"
}

// activeRotorIndex returns the compass-selected rotor index, clamped to the
// available logical rotors (0-based). Persisted via SetActiveRotor.
func (a *App) activeRotorIndex(n int) int {
	if n <= 0 {
		return 0
	}
	raw, _ := a.settings.Get(a.ctx, keyRotatorActive)
	i, _ := strconv.Atoi(raw)
	if i < 0 || i >= n {
		return 0
	}
	return i
}

// SetActiveRotor selects which rotor the compass drives and displays. Persisted
// so it survives a restart. The index is into the flattened logical-rotor list.
func (a *App) SetActiveRotor(index int) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if index < 0 {
		index = 0
	}
	return a.settings.Set(a.ctx, keyRotatorActive, strconv.Itoa(index))
}

// arcoClient builds the GS-232 client for a rotor's ARCO transport:
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
func arcoClient(l rotorLink) *gs232.Client {
	if l.Transport == "serial" {
		return gs232.NewSerial(l.ComPort, l.Baud)
	}
	return gs232.New(l.Host, l.Port)
}

// dcu1Client builds the Hy-Gain DCU-1 client for a rotor's transport: the
// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ)
// or a serial-over-IP bridge on TCP.
func dcu1Client(l rotorLink) *dcu1.Client {
	if l.Transport == "serial" {
		return dcu1.NewSerial(l.ComPort, l.Baud)
	}
	return dcu1.New(l.Host, l.Port)
}

// spidClient builds the SPID (AlfaSpid) client for a rotor.
//
// Serial only, and that is the point: these controllers have a COM port and
// nothing else. The request this answers was to drive them WITHOUT PstRotator
// sitting in between, so there is no network transport to offer.
//
// Baud 0 lets the driver take the dialect's documented default — 600 baud for
// Rot2Prog, 1200 for Rot1Prog. Those numbers look wrong and are not.
func spidClient(l rotorLink) *spid.Client {
	m := spid.Rot2Prog
	if l.SpidModel == string(spid.Rot1Prog) {
		m = spid.Rot1Prog
	}
	return spid.New(l.ComPort, l.Baud, m)
}

// RotatorHeading is the live antenna heading for the status bar and compass.
type RotatorHeading struct {
	Enabled bool   `json:"enabled"`
	OK      bool   `json:"ok"`
	Azimuth int    `json:"azimuth"`
	Raw     string `json:"raw"`
	// The compass renders a rotor selector from these — one entry per logical
	// rotor — without an extra roundtrip.
	Rotors    []string `json:"rotors"`    // names of every logical rotor (may be empty strings)
	Active    int      `json:"active"`    // index this heading is for (0-based)
	Motorized bool     `json:"motorized"` // active rotor carries the motorized antenna → show pattern paths
}

// activeRotor resolves the currently selected logical rotor. ok is false when no
// rotor is configured.
func (a *App) activeRotor() (lr logicalRotor, rotors []logicalRotor, idx int, ok bool) {
	devs, err := a.GetRotators()
	if err != nil {
		return
	}
	rotors = flattenRotors(devs)
	if len(rotors) == 0 {
		return
	}
	idx = a.activeRotorIndex(len(rotors))
	return rotors[idx], rotors, idx, true
}

// GetRotatorHeading queries the active rotor for its azimuth. Returns
// Enabled=false when no rotator is configured. Polled by the status bar.
func (a *App) GetRotatorHeading() RotatorHeading {
	lr, rotors, idx, ok := a.activeRotor()
	if !ok {
		return RotatorHeading{Enabled: false}
	}
	names := make([]string, len(rotors))
	for i, r := range rotors {
		names[i] = r.Name
	}
	base := RotatorHeading{Enabled: true, Rotors: names, Active: idx, Motorized: lr.Motorized}
	link := lr.Link
	switch link.Type {
	case "rotgenius":
		st, raw, herr := rotgenius.New(link.Host, link.Port).Heading(link.Num)
		if herr != nil {
			base.Raw = herr.Error()
			return base
		}
		if !st.Connected {
			base.Raw = "sensor not connected (999)"
			return base
		}
		base.OK = true
		base.Azimuth = st.Azimuth
		base.Raw = raw
		return base
	case "arco":
		az, raw, herr := arcoClient(link).Heading()
		if herr != nil {
			base.Raw = herr.Error()
			return base
		}
		base.OK = true
		base.Azimuth = az
		base.Raw = raw
		return base
	case "spid":
		az, _, herr := spidClient(link).Heading()
		if herr != nil {
			base.Raw = herr.Error()
			return base
		}
		base.OK = true
		base.Azimuth = az
		base.Raw = fmt.Sprintf("%d°", az)
		return base
	case "dcu1":
		az, raw, herr := dcu1Client(link).Heading()
		if herr != nil {
			base.Raw = herr.Error()
			return base
		}
		base.OK = true
		base.Azimuth = az
		base.Raw = raw
		return base
	default:
		az, raw, herr := pst.New(link.Host, link.Port).Heading()
		if herr != nil {
			base.Raw = raw
			return base
		}
		base.OK = true
		base.Azimuth = az
		base.Raw = raw
		return base
	}
}

// RotatorGoTo points the active rotor at the given azimuth (and optional
// elevation if it is configured for it).
func (a *App) RotatorGoTo(az int, el int) error {
	return a.RotatorGoToPath(az, el, "")
}

// RotatorGoToPath is RotatorGoTo with the path that produced the bearing —
// "SP", "LP", or "" when the caller has no opinion (the compass, a spot click).
//
// It exists for the custom outbound rows: an operator wanted the message to say
// WHICH bearing was taken, and the azimuth alone cannot tell you — 137° is short
// path to one station and long path to another.
func (a *App) RotatorGoToPath(az int, el int, path string) error {
	// The trigger fires on the COMMAND, before any rotor is required: an
	// operator may drive a switch or a display from the bearing without owning a
	// rotator at all, and the early return below would swallow it.
	a.udpTriggerRotator(az, el, strings.ToUpper(strings.TrimSpace(path)))
	lr, _, _, ok := a.activeRotor()
	if !ok {
		return fmt.Errorf("no rotator configured")
	}
	link := lr.Link
	switch link.Type {
	case "rotgenius":
		return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az)
	case "arco":
		return arcoClient(link).GoTo(az)
	case "spid":
		return spidClient(link).GoTo(az, el)
	case "dcu1":
		return dcu1Client(link).GoTo(az)
	default:
		return pst.New(link.Host, link.Port).GoTo(az, link.HasElevation, el)
	}
}

// RotatorStop interrupts any in-progress rotation of the active rotor.
func (a *App) RotatorStop() error {
	lr, _, _, ok := a.activeRotor()
	if !ok {
		return fmt.Errorf("no rotator configured")
	}
	link := lr.Link
	switch link.Type {
	case "rotgenius":
		return rotgenius.New(link.Host, link.Port).Stop()
	case "arco":
		return arcoClient(link).Stop()
	case "spid":
		return spidClient(link).Stop()
	case "dcu1":
		return dcu1Client(link).Stop()
	default:
		return pst.New(link.Host, link.Port).Stop()
	}
}

// RotorPreset is one quick-turn button on the rotor widget: a short label and
// the azimuth it swings to.
type RotorPreset struct {
	Label   string `json:"label"`
	Azimuth int    `json:"azimuth"`
}

// rotorPresetMax bounds both the number of buttons and the label length. The
// widget lays them out in a fixed grid beside the dial, and a label that does not
// fit is a button whose meaning is guessed rather than read.
const (
	rotorPresetMax      = 8
	rotorPresetLabelMax = 6
)

// rotorPresetRegions are the out-of-the-box buttons, each with a representative
// point rather than a ready-made azimuth.
//
// The bearing is COMPUTED from the operator's own square. A bearing is only
// meaningful from somewhere: a table of degrees that suits one station points
// every other one at the wrong continent, and "EU 090" is worse than no default
// at all for a station in Japan. With no square known they come out at 0 and the
// operator fills them in — which is what the editor in Settings is for.
var rotorPresetRegions = []struct {
	label    string
	lat, lon float64
}{
	{"EU", 50, 15},   // central Europe
	{"NA", 40, -95},  // central United States
	{"SA", -15, -60}, // Brazil
	{"VK", -25, 135}, // central Australia
	{"JA", 36, 138},  // Japan
	{"AF", 0, 20},    // central Africa
}

// defaultRotorPresets builds the six regions as seen from the operator's grid.
func (a *App) defaultRotorPresets() []RotorPreset {
	out := make([]RotorPreset, 0, len(rotorPresetRegions))
	for _, r := range rotorPresetRegions {
		az := 0
		if a.opSet {
			az = int(initialBearingDeg(a.opLat, a.opLon, r.lat, r.lon) + 0.5)
		}
		out = append(out, RotorPreset{Label: r.label, Azimuth: az % 360})
	}
	return out
}

// normRotorPresets keeps the list within what the widget can draw and the rotor
// can accept. A blank label drops the button entirely — that is how an operator
// removes one without a delete control.
func normRotorPresets(in []RotorPreset) []RotorPreset {
	out := make([]RotorPreset, 0, len(in))
	for _, p := range in {
		label := strings.TrimSpace(p.Label)
		if label == "" {
			continue
		}
		if len(label) > rotorPresetLabelMax {
			label = label[:rotorPresetLabelMax]
		}
		az := p.Azimuth % 360
		if az < 0 {
			az += 360
		}
		out = append(out, RotorPreset{Label: label, Azimuth: az})
		if len(out) == rotorPresetMax {
			break
		}
	}
	return out
}

// GetRotorPresets returns the quick-turn buttons, falling back to the regions
// computed from the operator's square so the widget is useful before anyone
// visits the settings.
func (a *App) GetRotorPresets() []RotorPreset {
	if raw := a.settingOr(keyRotorPresets, ""); raw != "" {
		var saved []RotorPreset
		if err := json.Unmarshal([]byte(raw), &saved); err == nil {
			// A saved-but-empty list is a deliberate "no buttons", not a missing
			// setting — otherwise clearing them all would resurrect the defaults.
			return normRotorPresets(saved)
		}
	}
	return a.defaultRotorPresets()
}

// SaveRotorPresets persists the buttons.
func (a *App) SaveRotorPresets(list []RotorPreset) error {
	b, err := json.Marshal(normRotorPresets(list))
	if err != nil {
		return err
	}
	a.setSetting(keyRotorPresets, string(b))
	return nil
}

// ResetRotorPresets puts back the six regions, recomputed from the operator's
// current square — the way to fix them after moving, or after a first run with
// no grid set.
func (a *App) ResetRotorPresets() []RotorPreset {
	def := a.defaultRotorPresets()
	if b, err := json.Marshal(def); err == nil {
		a.setSetting(keyRotorPresets, string(b))
	}
	return def
}

// RotatorPark moves the active rotor to its parked position (PstRotator only;
// the 4O3A native and ARCO GS-232 protocols have no park command).
func (a *App) RotatorPark() error {
	lr, _, _, ok := a.activeRotor()
	if !ok {
		return fmt.Errorf("no rotator configured")
	}
	link := lr.Link
	switch link.Type {
	case "rotgenius":
		return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
	case "arco":
		return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
	case "spid":
		return fmt.Errorf("park is a PstRotator feature; a SPID controller has no park command")
	case "dcu1":
		return fmt.Errorf("park is a PstRotator feature; not available over the DCU-1 link")
	default:
		return pst.New(link.Host, link.Port).Park()
	}
}

// TestRotatorDevice verifies a rotor device's link (sub picks the Rotator Genius
// port 1/2 for a Dual device). Takes the live panel device so it can be tested
// before saving.
func (a *App) TestRotatorDevice(d RotatorDevice, sub int) error {
	return testRotorLink(deviceLink(d, sub))
}

// testRotorLink confirms a rotor's link without meaningfully moving the antenna.
func testRotorLink(l rotorLink) error {
	if l.Host == "" {
		l.Host = "127.0.0.1"
	}
	if l.Port <= 0 || l.Port > 65535 {
		l.Port = rotatorDefaultPort(l.Type)
	}
	switch l.Type {
	case "rotgenius":
		rn := l.Num
		if rn != 2 {
			rn = 1
		}
		// Match the button ("Test — point to 0°"): actually command the move, like
		// the PstRotator path does. GoTo confirms the link AND the accept ('K')
		// reply, so a rejected command surfaces as an error instead of a false OK.
		return rotgenius.New(l.Host, l.Port).GoTo(rn, 0)
	case "arco":
		if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" {
			return fmt.Errorf("select the ARCO's COM port first")
		}
		// A heading query proves the link AND that the ARCO port really speaks
		// GS-232 — without moving the antenna.
		_, _, err := arcoClient(l).Heading()
		return err
	case "spid":
		if strings.TrimSpace(l.ComPort) == "" {
			return fmt.Errorf("select the SPID controller's COM port first")
		}
		// A status read proves the port, the baud rate and the dialect at once,
		// without moving anything — and a wrong dialect shows up here as a reply
		// of the wrong length rather than as an antenna that turns oddly later.
		_, _, err := spidClient(l).Heading()
		return err
	case "dcu1":
		if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" {
			return fmt.Errorf("select the DCU-1 controller's COM port first")
		}
		// A bearing query (AI1) proves the link and the DCU-1 command set without
		// moving the antenna.
		_, _, err := dcu1Client(l).Heading()
		return err
	default:
		return pst.New(l.Host, l.Port).GoTo(0, false, -1)
	}
}

func boolStr(b bool) string {
	if b {
		return "1"
	}
	return "0"
}

// ── Station Control (relay boards) ─────────────────────────────────────

// StationDevice is one configured relay board for the Station Control tab.
type StationDevice struct {
	ID       string   `json:"id"`
	Type     string   `json:"type"` // "webswitch" | "kmtronic" | "dingtian" | "denkovi" | "usbrelay" | "httpgen"
	Name     string   `json:"name"`
	Host     string   `json:"host"`               // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
	User     string   `json:"user,omitempty"`     // KMTronic HTTP auth; Dingtian HTTP session ID (blank = none)
	Pass     string   `json:"pass,omitempty"`     // KMTronic HTTP auth; Dingtian relay password (0–9999)
	Channels int      `json:"channels,omitempty"` // usbrelay/denkovi/dingtian: number of relays
	Labels   []string `json:"labels"`             // per-relay label (index 0 = relay 1)

	// Generic HTTP board only. Per-relay URLs win over the patterns, which is
	// what lets a hand-made switch with nothing in common between its channels
	// be described at all.
	OnURLs  []string `json:"on_urls,omitempty"` // index 0 = relay 1
	OffURLs []string `json:"off_urls,omitempty"`
	OnPat   string   `json:"on_pattern,omitempty"` // fallback, {relay} substituted
	OffPat  string   `json:"off_pattern,omitempty"`
	// InsecureTLS accepts an HTTPS certificate that cannot be verified — which
	// is the only kind a relay board on the LAN can present, having signed it
	// itself. Off by default, because the other HTTPS case is the opposite one:
	// a board reached from outside through a proxy with a real certificate,
	// where verification is what stands between an antenna switch and the
	// internet.
	InsecureTLS bool `json:"insecure_tls,omitempty"`
}

// deviceRelayCount is the relay count for a configured device — fixed by type,
// except the Denkovi (4 or 8) and the generic USB-serial board, whose channel
// count the user picks.
func deviceRelayCount(d StationDevice) int {
	if d.Type == "usbrelay" || d.Type == "denkovi" || d.Type == "dingtian" || d.Type == "httpgen" {
		if d.Channels >= 1 {
			return d.Channels
		}
		if d.Type == "dingtian" {
			return 2 // the smallest board; the real count comes from its status reply
		}
		if d.Type == "httpgen" {
			// A hand-made switch is usually two to four ways, and its URLs are
			// typed one per relay — eight empty rows would be eight rows to read.
			return 4
		}
		return 8
	}
	return relayCountFor(d.Type)
}

// relayCountFor returns a device type's fixed relay count.
func relayCountFor(typ string) int {
	switch typ {
	case "kmtronic", "denkovi":
		return 8
	default:
		return 5 // webswitch 1216H
	}
}

// cachedRelay is a live driver plus the config signature it was built from, so a
// changed device (new COM port, serial or channel count) rebuilds it.
type cachedRelay struct {
	key string
	dev relaydev.Device
}

// buildDeviceDriver builds a fresh wire driver for a configured device.
func buildDeviceDriver(d StationDevice) relaydev.Device {
	switch d.Type {
	case "kmtronic":
		return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
	case "dingtian":
		// User carries the optional HTTP session ID, Pass the relay password.
		return relaydev.NewDingtian(d.Host, d.User, d.Pass, deviceRelayCount(d))
	case "denkovi":
		// Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname.
		return relaydev.NewDenkovi(d.Host, deviceRelayCount(d))
	case "usbrelay":
		// Host carries the COM port (e.g. "COM5"); CH340/LCUS "A0" serial protocol.
		return relaydev.NewSerialRelay(d.Host, deviceRelayCount(d))
	case "httpgen":
		// The whole board lives in its URLs — Host is not used at all, which is
		// why it may be left empty. Leaving this case out is what made the
		// generic board fall through to the WebSwitch driver below: it answered
		// the WebSwitch's own address, never sent one configured URL, and
		// reported itself offline so every relay button stayed greyed out.
		return relaydev.NewHTTPGeneric(d.OnURLs, d.OffURLs, d.OnPat, d.OffPat, d.User, d.Pass, deviceRelayCount(d), d.Labels, d.InsecureTLS)
	default:
		return relaydev.NewWebswitch(d.Host)
	}
}

// deviceKey is the config signature that, when unchanged, lets us reuse a device's
// open driver (and its OS handle) instead of rebuilding it every poll.
func deviceKey(d StationDevice) string {
	k := fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d))
	if d.Type == "httpgen" {
		// The generic board's entire configuration is its URLs, and none of it is
		// in the signature above. Correcting a typo in one of them would have
		// handed back the cached driver still holding the wrong address, so the
		// fix appeared to do nothing until OpsLog was restarted.
		k += "|" + d.OnPat + "|" + d.OffPat +
			"|" + strings.Join(d.OnURLs, "\x1f") + "|" + strings.Join(d.OffURLs, "\x1f") +
			// The labels are part of the wire format here: {value} sends them.
			// Renaming a relay re-addresses it, and the cached driver would keep
			// commanding the old name.
			"|" + strings.Join(d.Labels, "\x1f") +
			// Ticking the box has to rebuild the driver: the cached one holds the
			// verifying client and would go on refusing the certificate.
			fmt.Sprintf("|%t", d.InsecureTLS)
	}
	return k
}

// driverFor returns the cached, still-open driver for a device, building it once
// and reusing it thereafter. Stateful boards (Denkovi FTDI, USB-serial) hold an OS
// handle that only one opener can own, so rebuilding a driver every call — as the
// old code did — leaked the handle and made every poll after the first fail with
// "device in use", greying the relays out. Reuse fixes that.
func (a *App) driverFor(d StationDevice) relaydev.Device {
	key := deviceKey(d)
	a.relayDrvMu.Lock()
	defer a.relayDrvMu.Unlock()
	if a.relayDrv == nil {
		a.relayDrv = map[string]cachedRelay{}
	}
	if c, ok := a.relayDrv[d.ID]; ok {
		if c.key == key {
			return c.dev
		}
		_ = c.dev.Close() // config changed → release the old handle before rebuilding
		delete(a.relayDrv, d.ID)
	}
	dev := buildDeviceDriver(d)
	a.relayDrv[d.ID] = cachedRelay{key: key, dev: dev}
	return dev
}

// closeRelayDrivers closes and drops every cached driver (e.g. after the device
// list changes) so stale handles are released and rebuilt fresh on next use.
func (a *App) closeRelayDrivers() {
	a.relayDrvMu.Lock()
	defer a.relayDrvMu.Unlock()
	for id, c := range a.relayDrv {
		_ = c.dev.Close()
		delete(a.relayDrv, id)
	}
}

// ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI
// boards, for the settings picker. Windows-only (FTDI D2XX).
func (a *App) ListDenkoviDevices() ([]string, error) {
	return relaydev.ListDenkovi()
}

// StationTestResult is the outcome of probing one relay board, for the settings
// dialog's Connect/Test button so the user gets a clear connected/failed message.
type StationTestResult struct {
	OK     bool   `json:"ok"`
	Relays int    `json:"relays"`
	Error  string `json:"error,omitempty"`
}

// TestStationDevice opens the given board and reads its state, returning a clear
// success/failure so the settings UI can tell the user whether it's reachable.
// Reuses the cached driver (no double-open conflict with the live poll).
func (a *App) TestStationDevice(d StationDevice) StationTestResult {
	ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
	defer cancel()
	states, err := a.driverFor(d).Status(ctx)
	if err != nil {
		return StationTestResult{OK: false, Error: err.Error()}
	}
	return StationTestResult{OK: true, Relays: len(states)}
}

// GetStationDevices returns the configured relay boards (without live state).
func (a *App) GetStationDevices() []StationDevice {
	out := []StationDevice{}
	if a.settings == nil {
		return out
	}
	s, _ := a.settings.GetGlobal(a.ctx, keyStationDevices)
	if strings.TrimSpace(s) == "" {
		return out
	}
	if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil {
		return []StationDevice{}
	}
	return out
}

// SaveStationDevices persists the relay-board list. IDs and label lengths are
// normalized so the UI can rely on them.
func (a *App) SaveStationDevices(devs []StationDevice) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	for i := range devs {
		switch devs[i].Type {
		case "kmtronic", "dingtian", "denkovi", "usbrelay", "webswitch", "httpgen":
			// known type — keep
		default:
			devs[i].Type = "webswitch"
		}
		if strings.TrimSpace(devs[i].ID) == "" {
			devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano())
		}
		n := deviceRelayCount(devs[i])
		for len(devs[i].Labels) < n {
			devs[i].Labels = append(devs[i].Labels, "")
		}
		devs[i].Labels = devs[i].Labels[:n]
	}
	b, err := json.Marshal(devs)
	if err != nil {
		return err
	}
	// Release any open board handles so the new config reopens cleanly (a changed
	// COM port / FTDI serial must not stay held by a stale driver).
	a.closeRelayDrivers()
	return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b))
}

// StationRelay is one relay's live state for the UI.
type StationRelay struct {
	Number int    `json:"number"` // 1-based
	Label  string `json:"label"`
	On     bool   `json:"on"`
}

// StationDeviceStatus is a device plus its live relay states.
type StationDeviceStatus struct {
	ID        string         `json:"id"`
	Name      string         `json:"name"`
	Type      string         `json:"type"`
	Connected bool           `json:"connected"`
	Error     string         `json:"error,omitempty"`
	Relays    []StationRelay `json:"relays"`
}

// GetStationStatus polls every configured board for its live relay states.
// Boards are polled concurrently so one slow/offline device doesn't stall the rest.
func (a *App) GetStationStatus() []StationDeviceStatus {
	devs := a.GetStationDevices()
	out := make([]StationDeviceStatus, len(devs))
	var wg sync.WaitGroup
	for i := range devs {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			d := devs[i]
			n := deviceRelayCount(d)
			ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
			ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
			defer cancel()
			states, err := a.driverFor(d).Status(ctx)
			if err != nil {
				ds.Error = err.Error()
			} else {
				ds.Connected = true
			}
			ds.Relays = make([]StationRelay, n)
			for r := 0; r < n; r++ {
				label := ""
				if r < len(d.Labels) {
					label = d.Labels[r]
				}
				on := false
				if r < len(states) {
					on = states[r]
				}
				ds.Relays[r] = StationRelay{Number: r + 1, Label: label, On: on}
			}
			out[i] = ds
		}(i)
	}
	wg.Wait()
	return out
}

// StationSetRelay switches one relay on a configured board.
func (a *App) StationSetRelay(id string, relay int, on bool) error {
	for _, d := range a.GetStationDevices() {
		if d.ID == id {
			ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
			defer cancel()
			return a.driverFor(d).Set(ctx, relay, on)
		}
	}
	return fmt.Errorf("station device %q not found", id)
}

// ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────

// motorAntenna is the shared control surface of a motorized antenna. The
// Ultrabeam and SteppIR clients differ only in wire protocol; everything the app
// does — poll, follow the rig, switch pattern, retract — is the same, so it runs
// against this interface and neither the follow loop nor the UI cares which is on
// the other end.
type motorStatus struct {
	Connected bool
	Direction int // 0 normal, 1 180°, 2 bidirectional
	Frequency int // kHz
	Band      int
	Moving    bool
	FreqMin   int // MHz, 0 = unknown (SteppIR does not report a tunable range)
	FreqMax   int // MHz, 0 = unknown
}
type motorAntenna interface {
	Start() error
	Stop()
	SetFrequency(khz, dir int) error
	SetDirection(dir int) error
	Retract() error
	LastSetKHz() int
	Status() motorStatus
	// Elements returns the per-element lengths (mm) when the antenna exposes them
	// (Ultrabeam), or nil when it doesn't (SteppIR tunes its elements from the
	// frequency and has no individual-element command).
	Elements() []int
	SetElement(num, lengthMm int) error
	// ReadElements queries the controller for the current element lengths on demand
	// (Ultrabeam CMD_READ_BANDS); SteppIR returns an error.
	ReadElements() ([]int, error)
}

// ubAdapter / steppirAdapter wrap each concrete client to the shared interface,
// translating only the status shape (both already use the same 0/1/2 direction
// convention, so commands pass straight through).
type ubAdapter struct {
	c *ultrabeam.Client
	// Configured tunable range (MHz) from Settings → Antenna. The follow logic
	// uses this as a hard floor/ceiling: some controllers (e.g. an RCU-06 behind
	// an RS232-to-Ethernet bridge) answer with the short status frame that omits
	// FreqMax, which would disable the out-of-range guard and let OpsLog forward
	// an un-tunable frequency (80 m → the controller clamps the elements to its
	// lowest band, ~30 m). Trusting the operator's configured range instead keeps
	// the antenna put on bands it can't reach.
	freqMin, freqMax int
}

func (a ubAdapter) Start() error                 { return a.c.Start() }
func (a ubAdapter) Stop()                        { a.c.Stop() }
func (a ubAdapter) SetFrequency(k, d int) error  { return a.c.SetFrequency(k, d) }
func (a ubAdapter) SetDirection(d int) error     { return a.c.SetDirection(d) }
func (a ubAdapter) Retract() error               { return a.c.Retract() }
func (a ubAdapter) LastSetKHz() int              { return a.c.LastSetKHz() }
func (a ubAdapter) SetElement(n, mm int) error   { return a.c.ModifyElement(n, mm) }
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
func (a ubAdapter) Elements() []int {
	if st, err := a.c.GetStatus(); err == nil && st != nil {
		return st.ElementLengths
	}
	return nil
}
func (a ubAdapter) Status() motorStatus {
	st, err := a.c.GetStatus()
	if err != nil || st == nil {
		return motorStatus{}
	}
	// Prefer the operator's configured range; fall back to whatever the controller
	// self-reported only when a bound is left unset. This guarantees BOTH bounds
	// are present so the follow guard actually engages — a controller that reports
	// FreqMin but not FreqMax (short status frame) would otherwise disable it.
	fmin, fmax := a.freqMin, a.freqMax
	if fmin <= 0 {
		fmin = st.FreqMin
	}
	if fmax <= 0 {
		fmax = st.FreqMax
	}
	return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: fmin, FreqMax: fmax}
}

type steppirAdapter struct {
	c                *steppir.Client
	freqMin, freqMax int // MHz; tunable range (the SteppIR can't report its own)
}

func (a steppirAdapter) Start() error                { return a.c.Start() }
func (a steppirAdapter) Stop()                       { a.c.Stop() }
func (a steppirAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
func (a steppirAdapter) SetDirection(d int) error    { return a.c.SetDirection(d) }
func (a steppirAdapter) Retract() error              { return a.c.Retract() }
func (a steppirAdapter) LastSetKHz() int             { return a.c.LastSetKHz() }
func (a steppirAdapter) Elements() []int             { return nil } // SteppIR: no per-element control
func (a steppirAdapter) SetElement(_, _ int) error {
	return fmt.Errorf("individual element control is not available on the SteppIR")
}
func (a steppirAdapter) ReadElements() ([]int, error) {
	return nil, fmt.Errorf("individual element control is not available on the SteppIR")
}
func (a steppirAdapter) Status() motorStatus {
	st, err := a.c.GetStatus()
	if err != nil || st == nil {
		return motorStatus{}
	}
	return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: a.freqMin, FreqMax: a.freqMax}
}

// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel. The name
// is kept (bindings + frontend) though it now covers SteppIR too.
type UltrabeamSettings struct {
	Enabled   bool   `json:"enabled"`
	Type      string `json:"type"`      // "ultrabeam" | "steppir"
	Transport string `json:"transport"` // "tcp" | "serial"
	Host      string `json:"host"`      // tcp
	Port      int    `json:"port"`      // tcp
	COM       string `json:"com"`       // serial device
	Baud      int    `json:"baud"`      // serial baud
	Follow    bool   `json:"follow"`    // re-tune the antenna to the rig's frequency
	StepKHz   int    `json:"step_khz"`  // re-tune only when the freq moved this far (25/50/100)
	// When the follow loop is allowed to move the motors. The three choices the
	// SteppIR's own controller software offers, because operators arrive with
	// that mental model:
	//   "always" — every frequency change. Resonance is always right, at the cost
	//              of motors running constantly; on a SteppIR every move also
	//              inhibits transmit while the elements travel.
	//   "step"   — only past a threshold (StepKHz). The default, and the sane
	//              middle: the antenna follows a QSY but ignores tuning around.
	//   "band"   — only when the band changes. Motors move a handful of times a
	//              day; resonance is whatever the band-entry frequency gave.
	TrackMode string `json:"track_mode"`
	TXInhibit bool   `json:"tx_inhibit"` // block Flex transmission while the elements are moving
	// Bands the antenna covers — the follow filter. The follow loop only re-tunes
	// (and only lets TX-inhibit trigger) on a band in this set; on any other band
	// the antenna is left where it is. Expressed as a set rather than a min/max
	// range so a single band can be dropped (e.g. 30 m without its extension) while
	// its neighbours stay. Applies to BOTH the Ultrabeam and the SteppIR.
	Bands []string `json:"bands"`
	// Per-band tune frequency (kHz) — where a band button in Station Control
	// sends the antenna. Sparse: a band with no entry uses its default, so an
	// operator sets only the bands he cares about and an existing config needs no
	// migration.
	BandFreqs map[string]int `json:"band_freqs"`
	// Legacy tunable range (MHz). Superseded by Bands; kept so an older config
	// migrates cleanly (the range is converted to a band set on load) and so the
	// value round-trips. Not used by the follow filter once Bands is set.
	FreqMinMHz int `json:"freq_min_mhz"`
	FreqMaxMHz int `json:"freq_max_mhz"`
}

// Tracking modes. Stored as strings rather than an int so a settings row stays
// readable when diagnosing an antenna that moves too much or not at all.
const (
	motorTrackAlways = "always"
	motorTrackStep   = "step"
	motorTrackBand   = "band"
)

// normMotorTrackMode keeps an unknown or empty value on the threshold mode
// instead of guessing — a config written before this option existed then
// behaves exactly as it did.
func normMotorTrackMode(m string) string {
	switch strings.ToLower(strings.TrimSpace(m)) {
	case motorTrackAlways:
		return motorTrackAlways
	case motorTrackBand:
		return motorTrackBand
	default:
		return motorTrackStep
	}
}

// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
// to the pre-SteppIR behaviour (Ultrabeam over TCP) so an existing install is
// unchanged.
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
	out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50, TrackMode: motorTrackStep}
	if a.settings == nil {
		return out, fmt.Errorf("db not initialized")
	}
	m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
		keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands,
		keyMotorTrackMode, keyMotorBandFreqs)
	if err != nil {
		return out, err
	}
	out.Enabled = m[keyUltrabeamEnabled] == "1"
	if t := m[keyMotorType]; t == "steppir" || t == "ultrabeam" {
		out.Type = t
	}
	if tr := m[keyMotorTransport]; tr == "serial" || tr == "tcp" {
		out.Transport = tr
	}
	out.Host = m[keyUltrabeamHost]
	if p, _ := strconv.Atoi(m[keyUltrabeamPort]); p > 0 && p <= 65535 {
		out.Port = p
	}
	out.COM = m[keyMotorCOM]
	if b, _ := strconv.Atoi(m[keyMotorBaud]); b >= 1200 && b <= 115200 {
		out.Baud = b
	}
	out.Follow = m[keyUltrabeamFollow] == "1"
	out.TXInhibit = m[keyMotorTXInhibit] == "1"
	if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
		out.StepKHz = st
	}
	out.Baud = normMotorBaud(out.Type, out.Transport, out.Baud)
	out.TrackMode = normMotorTrackMode(m[keyMotorTrackMode])
	out.BandFreqs = decodeMotorBandFreqs(m[keyMotorBandFreqs])
	out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin])
	out.FreqMaxMHz, _ = strconv.Atoi(m[keyMotorFreqMax])
	// Bands is the follow filter. If it was saved, use it verbatim. Otherwise this
	// is a config from before the band selector: migrate the legacy FreqMin/FreqMax
	// range into a band set so the effective coverage is unchanged (an unset range
	// on a SteppIR is treated as the standard 20 m–6 m; an unset range on the
	// Ultrabeam becomes the whole 40 m–6 m universe, which still excludes 80 m).
	if raw := strings.TrimSpace(m[keyMotorBands]); raw != "" {
		out.Bands = normMotorBands(strings.Split(raw, ","))
	}
	if len(out.Bands) == 0 {
		minMHz, maxMHz := out.FreqMinMHz, out.FreqMaxMHz
		if out.Type == "steppir" && minMHz <= 0 && maxMHz <= 0 {
			minMHz, maxMHz = 13, 54
		}
		out.Bands = motorBandsFromRange(minMHz, maxMHz)
	}
	return out, nil
}

// SaveUltrabeamSettings persists the config and (re)starts or stops the TCP
// poller so the change takes effect immediately.
func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if s.Port <= 0 || s.Port > 65535 {
		s.Port = 23
	}
	if s.StepKHz != 25 && s.StepKHz != 50 && s.StepKHz != 100 {
		s.StepKHz = 50
	}
	if s.Type != "steppir" {
		s.Type = "ultrabeam"
	}
	if s.Transport != "serial" {
		s.Transport = "tcp"
	}
	if s.Baud < 1200 || s.Baud > 115200 {
		s.Baud = 9600
	}
	// Sanity: keep the range ordered; 0 = "no limit" (stored as-is).
	if s.FreqMinMHz < 0 {
		s.FreqMinMHz = 0
	}
	if s.FreqMaxMHz < 0 {
		s.FreqMaxMHz = 0
	}
	if s.FreqMinMHz > 0 && s.FreqMaxMHz > 0 && s.FreqMinMHz > s.FreqMaxMHz {
		s.FreqMinMHz, s.FreqMaxMHz = s.FreqMaxMHz, s.FreqMinMHz
	}
	// Bands is the follow filter. Normalise to known bands in canonical order; an
	// empty selection would strand the antenna on every band, so fall back to the
	// full universe (which still excludes 80 m/160 m — outside a motor antenna's
	// reach) rather than persist "nothing".
	s.Bands = normMotorBands(s.Bands)
	if len(s.Bands) == 0 {
		s.Bands = motorBandNames()
	}
	for k, v := range map[string]string{
		keyUltrabeamEnabled: boolStr(s.Enabled),
		keyUltrabeamHost:    strings.TrimSpace(s.Host),
		keyUltrabeamPort:    strconv.Itoa(s.Port),
		keyUltrabeamFollow:  boolStr(s.Follow),
		keyUltrabeamStep:    strconv.Itoa(s.StepKHz),
		keyMotorTrackMode:   normMotorTrackMode(s.TrackMode),
		keyMotorBandFreqs:   encodeMotorBandFreqs(normMotorBandFreqs(s.BandFreqs)),
		keyMotorType:        s.Type,
		keyMotorTransport:   s.Transport,
		keyMotorCOM:         strings.TrimSpace(s.COM),
		keyMotorBaud:        strconv.Itoa(normMotorBaud(s.Type, s.Transport, s.Baud)),
		keyMotorTXInhibit:   boolStr(s.TXInhibit),
		keyMotorFreqMin:     strconv.Itoa(s.FreqMinMHz),
		keyMotorFreqMax:     strconv.Itoa(s.FreqMaxMHz),
		keyMotorBands:       strings.Join(s.Bands, ","),
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	a.restartAsync("antenna", a.startUltrabeam)
	return nil
}

// ubSerialBaud is the ONLY line speed an Ultrabeam controller answers on over
// its USB cable. Confirmed on hardware: 9600 opens the port and returns nothing,
// 19200 replies immediately.
const ubSerialBaud = 19200

// normMotorBaud pins the Ultrabeam's serial speed and leaves the SteppIR's
// alone.
//
// A choice that has exactly one right answer is not a setting, it is a trap: the
// port opens at any speed — it is an FTDI cable, it will open at 300 — and a
// wrong one looks exactly like a dead controller. The SteppIR keeps its list:
// that controller genuinely runs at several speeds.
func normMotorBaud(typ, transport string, baud int) int {
	if typ == "ultrabeam" && transport == "serial" {
		return ubSerialBaud
	}
	if baud < 1200 || baud > 115200 {
		return 9600
	}
	return baud
}

// newMotorClient builds the concrete client for the configured antenna type and
// transport, wrapped in the shared interface. Returns nil if nothing usable is
// configured (no host for TCP, no COM for serial).
func newMotorClient(s UltrabeamSettings) motorAntenna {
	if s.Type == "steppir" {
		tr := steppir.Transport{Mode: s.Transport, Host: strings.TrimSpace(s.Host), Port: s.Port, COM: strings.TrimSpace(s.COM), Baud: s.Baud}
		if tr.Mode == "serial" && tr.COM == "" {
			return nil
		}
		if tr.Mode != "serial" && tr.Host == "" {
			return nil
		}
		return steppirAdapter{c: steppir.New(tr), freqMin: s.FreqMinMHz, freqMax: s.FreqMaxMHz}
	}
	// Ultrabeam: serial or TCP, same as the SteppIR. The controller has an RS232
	// port, so a plain FTDI cable is the direct route; the Ethernet adapter is
	// for putting the antenna at the far end of a link.
	tr := ultrabeam.Transport{Mode: s.Transport, Host: strings.TrimSpace(s.Host), Port: s.Port, COM: strings.TrimSpace(s.COM), Baud: s.Baud}
	if tr.Mode == "serial" && tr.COM == "" {
		return nil
	}
	if tr.Mode != "serial" && tr.Host == "" {
		return nil
	}
	return ubAdapter{c: ultrabeam.New(tr), freqMin: s.FreqMinMHz, freqMax: s.FreqMaxMHz}
}

// startUltrabeam stops any existing client and starts a fresh one if the
// antenna is enabled and configured. Safe to call repeatedly (on startup and
// after a settings save).
func (a *App) startUltrabeam() {
	// ONE restart at a time.
	//
	// Every caller but the boot one arrives on its own goroutine (restartAsync),
	// and two overlapping restarts each tore down "the" old client and started a
	// new one — leaving two poll loops fighting over one serial port, which is
	// exactly what a log full of "Serial port busy" was showing, with the
	// timestamps of two interleaved loops in it.
	a.motorStartMu.Lock()
	defer a.motorStartMu.Unlock()
	// Stop any running follow loop first.
	if a.ubFollowStop != nil {
		close(a.ubFollowStop)
		a.ubFollowStop = nil
	}
	// Stop the inhibit watcher (its deferred cleanup releases any active inhibit).
	if a.motorInhibStop != nil {
		close(a.motorInhibStop)
		a.motorInhibStop = nil
	}
	old := a.motorAnt
	a.motorAnt = nil
	// Every way out of here used to be silent, which is how an antenna that had
	// been working for three minutes went off the air with NOTHING in the log
	// after the disconnection — the operator could not tell a deliberate stop
	// from a lost port from a cleared setting.
	s, err := a.GetUltrabeamSettings()
	if err != nil {
		applog.Printf("antenna: not started — settings unavailable: %v", err)
		return
	}
	if !s.Enabled {
		applog.Printf("antenna: not started — turned off in Settings")
		return
	}
	c := newMotorClient(s)
	if c == nil {
		if s.Transport == "serial" {
			applog.Printf("antenna: %s not started — no serial port configured", s.Type)
		} else {
			applog.Printf("antenna: %s not started — no host configured", s.Type)
		}
		return
	}
	if s.Transport == "serial" {
		applog.Printf("antenna: %s starting on %s @ %d baud", s.Type, s.COM, s.Baud)
	} else {
		applog.Printf("antenna: %s starting on %s:%d", s.Type, s.Host, s.Port)
	}
	// Release the previous client BEFORE opening the new one.
	//
	// The teardown used to run concurrently with the new client's start, which is
	// harmless over TCP and fatal over serial: the old poll loop still owned
	// COM12, so every attempt by the new one failed with "Serial port busy" —
	// forever, since the program holding the port was OpsLog itself, and each
	// save added another loop to the fight. Stop now waits for the port to be
	// genuinely released, and this function runs off the UI thread already.
	if old != nil {
		old.Stop()
	}
	a.motorAnt = c
	_ = c.Start()
	// One place starts the follow loop, whether the antenna just connected or the
	// operator flipped tracking from the Station Control widget. Two copies of
	// this drifting apart is how a step change quietly stops taking effect.
	a.restartMotorFollow(s)
	if s.TXInhibit {
		stop := make(chan struct{})
		a.motorInhibStop = stop
		go a.motorTXInhibitLoop(a.motorAnt, s.Bands, stop)
	}
}

// motorBands is the band universe a motorized HF/6 m antenna (Ultrabeam / SteppIR)
// can cover, low to high. The follow filter is expressed as a SUBSET of these, so
// an operator can drop a single band (e.g. 30 m, when the 30 m extension isn't
// fitted) while keeping its neighbours — something a contiguous min/max range
// can't express. nomMHz is a representative in-band frequency, used only to
// migrate a legacy FreqMin/FreqMax range into a band set.
// defKHz is where a band button tunes the antenna when the operator has not
// chosen a frequency for that band — roughly mid-band, where a beam's pattern is
// usable across the whole allocation. It is only a default: an operator who
// lives in the CW segment sets his own, exactly as the SteppIR controller's own
// "Frequency (KHz)" column does.
var motorBands = []struct {
	name   string
	nomMHz int
	defKHz int
}{
	{"40m", 7, 7100}, {"30m", 10, 10125}, {"20m", 14, 14150}, {"17m", 18, 18110},
	{"15m", 21, 21150}, {"12m", 24, 24930}, {"10m", 28, 28400}, {"6m", 50, 50150},
}

// motorBandDefaultKHz is the fallback tune frequency for a band, 0 if unknown.
func motorBandDefaultKHz(band string) int {
	band = strings.ToLower(strings.TrimSpace(band))
	for _, b := range motorBands {
		if b.name == band {
			return b.defKHz
		}
	}
	return 0
}

// normMotorBandFreqs keeps only entries that name a real motor band AND whose
// frequency actually falls in that band.
//
// The check matters: this value is fed straight to the antenna as a tune
// command. A slip of one digit — 1450 for 20 m, or kHz typed as MHz — would send
// the elements travelling to a length that is wrong for the band the operator is
// on, and on a SteppIR that is a long, transmit-inhibited journey to a position
// nobody asked for. An entry that fails the check is dropped, so the band falls
// back to its default rather than to nonsense.
func normMotorBandFreqs(in map[string]int) map[string]int {
	out := map[string]int{}
	for _, b := range motorBands {
		khz, ok := in[b.name]
		if !ok || khz <= 0 {
			continue
		}
		if bandForHz(int64(khz)*1000) != b.name {
			applog.Printf("motor-antenna: ignoring %d kHz for %s — not in that band", khz, b.name)
			continue
		}
		out[b.name] = khz
	}
	return out
}

// encodeMotorBandFreqs / decodeMotorBandFreqs store the map as "40m=7100,20m=14150".
// A flat string rather than JSON so the settings row stays readable, and so a
// value corrupted by hand degrades one band instead of the whole set.
func encodeMotorBandFreqs(m map[string]int) string {
	parts := []string{}
	for _, b := range motorBands { // canonical order, not map order
		if khz := m[b.name]; khz > 0 {
			parts = append(parts, fmt.Sprintf("%s=%d", b.name, khz))
		}
	}
	return strings.Join(parts, ",")
}

func decodeMotorBandFreqs(s string) map[string]int {
	out := map[string]int{}
	for _, kv := range strings.Split(s, ",") {
		name, val, ok := strings.Cut(strings.TrimSpace(kv), "=")
		if !ok {
			continue
		}
		if khz, err := strconv.Atoi(strings.TrimSpace(val)); err == nil && khz > 0 {
			out[strings.ToLower(strings.TrimSpace(name))] = khz
		}
	}
	return normMotorBandFreqs(out)
}

// motorTuneKHzForBand is the frequency a band button commands: the operator's
// choice when set, the default otherwise.
func motorTuneKHzForBand(m map[string]int, band string) int {
	band = strings.ToLower(strings.TrimSpace(band))
	if khz := m[band]; khz > 0 {
		return khz
	}
	return motorBandDefaultKHz(band)
}

// motorBandNames is the full ordered set (all bands enabled).
func motorBandNames() []string {
	out := make([]string, len(motorBands))
	for i, b := range motorBands {
		out[i] = b.name
	}
	return out
}

// normMotorBands keeps only recognised motor bands, canonical low→high order,
// de-duplicated.
func normMotorBands(in []string) []string {
	want := map[string]bool{}
	for _, s := range in {
		want[strings.TrimSpace(strings.ToLower(s))] = true
	}
	out := []string{}
	for _, b := range motorBands {
		if want[b.name] {
			out = append(out, b.name)
		}
	}
	return out
}

// motorBandsFromRange derives the enabled-band set from a legacy FreqMin/FreqMax
// range (MHz) — a one-time migration to the band selector. An empty/zero range
// means "everything the antenna universe covers".
func motorBandsFromRange(minMHz, maxMHz int) []string {
	if minMHz <= 0 && maxMHz <= 0 {
		return motorBandNames()
	}
	hi := maxMHz
	if hi <= 0 {
		hi = 9999
	}
	out := []string{}
	for _, b := range motorBands {
		if b.nomMHz >= minMHz && b.nomMHz <= hi {
			out = append(out, b.name)
		}
	}
	if len(out) == 0 { // a nonsensical range shouldn't strand the antenna on every band
		return motorBandNames()
	}
	return out
}

// motorBandAllowed reports whether the follow logic may retune the antenna for a
// rig sitting at hz, given the operator's enabled-band list. A frequency outside
// the motor band universe (e.g. 80 m / 160 m) is NEVER allowed, so OpsLog won't
// push the antenna onto a band it physically can't reach. An empty list is
// treated as "every band in the universe" (a safety fallback — the settings
// loader normally fills the list).
func motorBandAllowed(bands []string, hz int64) bool {
	b := bandForHz(hz)
	if b == "" {
		return false
	}
	inUniverse := false
	for _, m := range motorBands {
		if m.name == b {
			inUniverse = true
			break
		}
	}
	if !inUniverse {
		return false
	}
	if len(bands) == 0 {
		return true
	}
	for _, x := range bands {
		if x == b {
			return true
		}
	}
	return false
}

// ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency
// whenever it drifts at least stepKHz from what the antenna is set to — so the
// elements track the band without the motors chasing every small QSY. Runs
// until stop is closed (a settings change or shutdown).
// noteMotorMoveCommanded marks that OpsLog just told the antenna to move. The
// inhibit watcher polls the antenna's Moving status, but that status is only
// refreshed every couple of seconds — so a fresh command opens a short grace
// window during which TX stays inhibited even before the poll confirms motion,
// closing the gap at the start of a move.
// noteMotorMoveCommanded records — and ANNOUNCES — that the antenna has just
// been told to move.
//
// The transmit gag already started here; the screen did not. It waited for a
// status poll to come round and say the elements were travelling, so the
// indicator turned orange a second or more after the button was pressed, and on
// an automatic follow (band change with tracking on) the operator had no warning
// at all until the poll landed. The event lets the interface show it at the
// instant the order goes out, which is also when the transmitter is gagged —
// one moment, one appearance.
func (a *App) noteMotorMoveCommanded() {
	a.motorMoveCmdNs.Store(time.Now().UnixNano())
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "motorant:move")
	}
}

// applyMotorInhibit sets or clears the Flex transmit inhibit, but only when the
// active CAT backend IS a FlexRadio — the inhibit is a Flex-API feature; with any
// other rig the option simply does nothing (as documented in the UI). Idempotent.
func (a *App) applyMotorInhibit(on bool) {
	if a.cat == nil {
		return
	}
	if a.cat.State().Backend != "flex" {
		return
	}
	if a.motorInhibited.Load() == on {
		return
	}
	if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXInhibit(on) }); err != nil {
		applog.Printf("motor-antenna: TX inhibit=%v failed: %v", on, err)
		return
	}
	a.motorInhibited.Store(on)
	applog.Printf("motor-antenna: Flex TX inhibit %s (antenna moving)", map[bool]string{true: "ON", false: "OFF"}[on])
}

// motorTXInhibitLoop blocks Flex transmission while the motorized antenna's
// elements are moving. Runs only when the option is on and a motor antenna is
// active; it errs toward SAFE — TX is inhibited while the status reports motion
// OR within a grace window after a commanded move — and always releases the
// inhibit when it stops (so a settings change / shutdown never leaves TX blocked).
func (a *App) motorTXInhibitLoop(c motorAntenna, bands []string, stop <-chan struct{}) {
	// Just long enough to bridge ONE fast poll at the start of a move, when the
	// antenna has been told to go but has not yet said it is going. It was three
	// seconds, from when the status poll ran every two: a move that took a second
	// left the transmitter gagged for two more, with the elements already in
	// place. The antenna is now polled four times a second while it travels, so
	// this only has to cover the command itself.
	const grace = 900 * time.Millisecond
	ticker := time.NewTicker(300 * time.Millisecond)
	defer ticker.Stop()
	defer a.applyMotorInhibit(false) // never leave TX blocked when the loop ends
	for {
		select {
		case <-stop:
			return
		case <-ticker.C:
			st := c.Status()
			recentCmd := time.Since(time.Unix(0, a.motorMoveCmdNs.Load())) < grace
			moving := st.Connected && (st.Moving || recentCmd)
			// On a band the antenna doesn't cover, OpsLog never commands a move —
			// so it must never gag TX for "antenna moving" either. Otherwise a
			// stray polled Moving flag, or a move finishing from the previous band,
			// blocks the operator's transmit on a band they work with a different
			// antenna (the whole point of un-ticking the band). Hands off means
			// hands off: no tune AND no inhibit.
			if moving && a.cat != nil {
				if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 && !motorBandAllowed(bands, rs.FreqHz) {
					moving = false
				}
			}
			a.applyMotorInhibit(moving)
		}
	}
}

func (a *App) ultrabeamFollowLoop(c motorAntenna, mode string, stepKHz int, bands []string, stop <-chan struct{}) {
	if stepKHz <= 0 {
		stepKHz = 50
	}
	mode = normMotorTrackMode(mode)
	// "Every time the frequency changes" is the threshold mode with the smallest
	// threshold there is: the loop already re-tunes when the rig has moved at
	// least stepKHz from the last commanded frequency, and 1 kHz makes that
	// "moved at all". Expressing it this way keeps ONE decision path, so the
	// deadband reference — which is the rig, not the antenna's own flaky reported
	// frequency — cannot drift out of step between modes.
	if mode == motorTrackAlways {
		stepKHz = 1
	}
	lastCmdBand := "" // band of the last commanded move — the reference in band mode
	ticker := time.NewTicker(1500 * time.Millisecond)
	defer ticker.Stop()
	lastRigKHz := 0 // only log when the followed rig frequency actually changes
	lastCmdKHz := 0 // rig freq we last issued a move for — the deadband reference
	for {
		select {
		case <-stop:
			return
		case <-ticker.C:
			if a.cat == nil {
				continue
			}
			rs := a.cat.State()
			if !rs.Connected || rs.FreqHz <= 0 {
				continue
			}
			st := c.Status()
			if !st.Connected {
				continue
			}
			rigKHz := int(rs.FreqHz / 1000)
			// Log the moment the poll sees a NEW rig frequency — this is what the
			// follow loop chases. If the antenna QSYs unexpectedly, this shows the
			// CAT backend started reporting that frequency (e.g. WSJT-X moved the
			// dial, or the active slice changed) even though you didn't touch the VFO.
			newFreq := rigKHz != lastRigKHz
			if newFreq {
				applog.Printf("ultrabeam: follow loop reads rig freq %.3f MHz (mode %s) — antenna at %d kHz, step %d kHz",
					float64(rs.FreqHz)/1e6, rs.Mode, st.Frequency, stepKHz)
				lastRigKHz = rigKHz
			}
			// Skip bands the antenna doesn't cover (per the operator's band list) —
			// no tune command, so the elements stay where they are on 80 m, an
			// un-fitted 30 m, etc. Log the decision once per QSY so "why did/didn't
			// the antenna move" is answerable from the log.
			if !motorBandAllowed(bands, rs.FreqHz) {
				if newFreq {
					applog.Printf("ultrabeam: %.3f MHz is %q — not in covered bands %v; leaving the antenna put",
						float64(rs.FreqHz)/1e6, bandForHz(rs.FreqHz), bands)
				}
				continue
			}
			// Deadband reference = the rig freq we LAST commanded a move for, not the
			// antenna's reported freq. A SteppIR reports a flaky/stale status
			// frequency (it flips between the commanded freq and its home/6 m value),
			// which would trip the deadband and re-issue a SET on almost every poll —
			// and each SET refreshes the "recently commanded" window that inhibits TX,
			// leaving the operator permanently unable to transmit. Following the rig
			// (only re-tune when the RIG actually QSYs beyond the step) is immune to
			// that. Falls back to the antenna's own freq only until we've commanded once.
			ref := lastCmdKHz
			if ref <= 0 {
				ref = st.Frequency
				if ref <= 0 {
					ref = c.LastSetKHz()
				}
			}
			// Band mode ignores the threshold entirely: the antenna is re-tuned once
			// on entering a band and then left alone however far the rig roams
			// inside it. The reference is the band we last COMMANDED for, not the
			// rig's previous band — otherwise a first move after startup, or any
			// move the operator made by hand, would never be reconciled.
			if mode == motorTrackBand {
				b := bandForHz(rs.FreqHz)
				if b == lastCmdBand {
					continue
				}
				if newFreq {
					applog.Printf("ultrabeam: band changed %q → %q — re-tuning to %d kHz", lastCmdBand, b, rigKHz)
				}
			} else {
				diff := rigKHz - ref
				if diff < 0 {
					diff = -diff
				}
				if ref > 0 && diff < stepKHz {
					continue // within the deadband — leave the motors alone
				}
			}
			a.noteMotorMoveCommanded()
			if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
				applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
			} else {
				lastCmdKHz = rigKHz
				lastCmdBand = bandForHz(rs.FreqHz)
				applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, mode %s, step %d)", rigKHz, st.Direction, ref, mode, stepKHz)
			}
		}
	}
}

// UltrabeamStatusInfo is the live antenna status for the UI (status bar +
// direction control). Enabled mirrors the setting; the rest comes from the
// device's most recent status poll.
type UltrabeamStatusInfo struct {
	Enabled   bool   `json:"enabled"`
	Type      string `json:"type"` // "ultrabeam" | "steppir"
	Connected bool   `json:"connected"`
	Direction int    `json:"direction"` // 0=normal, 1=180°, 2=bidirectional
	Frequency int    `json:"frequency"` // KHz
	Band      int    `json:"band"`
	Moving    bool   `json:"moving"`
	Elements  []int  `json:"elements"` // per-element lengths (mm); empty when unsupported
	// Follow and StepKHz are mirrored here so the Station Control widget can show
	// and change tracking without loading the whole settings block for a poll.
	Follow    bool   `json:"follow"`
	StepKHz   int    `json:"step_khz"`
	TrackMode string `json:"track_mode"`
	// Bands the antenna is configured to cover — the widget offers exactly these
	// as buttons rather than inventing its own list, so a band dropped in Settings
	// cannot be clicked here.
	Bands []string `json:"bands"`
	// Where each band button tunes. Resolved here — operator's choice or the
	// default — so the widget never has to carry its own copy of the band table
	// and cannot drift from what Settings shows.
	BandFreqs map[string]int `json:"band_freqs"`
}

// GetUltrabeamStatus returns the antenna's current state for the UI poll.
func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
	out := UltrabeamStatusInfo{Elements: []int{}}
	s, _ := a.GetUltrabeamSettings()
	out.Enabled = s.Enabled
	out.Type = s.Type
	out.Follow = s.Follow
	out.StepKHz = s.StepKHz
	out.TrackMode = normMotorTrackMode(s.TrackMode)
	out.BandFreqs = map[string]int{}
	for _, b := range s.Bands {
		if khz := motorTuneKHzForBand(s.BandFreqs, b); khz > 0 {
			out.BandFreqs[b] = khz
		}
	}
	out.Bands = append(out.Bands, s.Bands...)
	if a.motorAnt == nil {
		return out
	}
	st := a.motorAnt.Status()
	out.Connected = st.Connected
	out.Direction = st.Direction
	out.Frequency = st.Frequency
	out.Band = st.Band
	out.Moving = st.Moving
	if el := a.motorAnt.Elements(); el != nil {
		out.Elements = el
	}
	return out
}

// MotorSetElement sets one element's length (mm) on an Ultrabeam. Returns an
// error on the SteppIR, which has no per-element command.
func (a *App) MotorSetElement(num, lengthMm int) error {
	if a.motorAnt == nil {
		return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
	}
	a.noteMotorMoveCommanded()
	return a.motorAnt.SetElement(num, lengthMm)
}

// MotorReadElements queries the controller for the current element lengths (mm),
// so the operator adjusts from the real values instead of blind. Ultrabeam only.
func (a *App) MotorReadElements() ([]int, error) {
	if a.motorAnt == nil {
		return nil, fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
	}
	return a.motorAnt.ReadElements()
}

// SetUltrabeamDirection switches the antenna pattern: 0=normal, 1=180°,
// 2=bidirectional (re-issues the current frequency with the new direction).
func (a *App) SetUltrabeamDirection(direction int) error {
	if a.motorAnt == nil {
		return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
	}
	if direction < 0 || direction > 2 {
		return fmt.Errorf("invalid direction %d", direction)
	}
	// Neither controller has a standalone direction command: both re-issue the
	// current frequency with the new direction byte. If the antenna hasn't reported
	// a frequency yet (just connected / link settling), fall back to the rig's CAT
	// frequency so the control still works.
	a.noteMotorMoveCommanded()
	st := a.motorAnt.Status()
	if st.Frequency <= 0 && a.cat != nil {
		if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
			return a.motorAnt.SetFrequency(int(rs.FreqHz/1000), direction)
		}
	}
	return a.motorAnt.SetDirection(direction)
}

// MotorTuneKHz points the antenna at a frequency, in kHz.
//
// The direction is carried over from the antenna's current state rather than
// reset: both controllers set frequency and pattern in the same command, so
// sending a bare frequency would silently drop a 180° or bidirectional pattern
// the operator had chosen — a beam quietly turning round is not an acceptable
// side effect of clicking a band.
func (a *App) MotorTuneKHz(khz int) error {
	if a.motorAnt == nil {
		return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
	}
	// A generous envelope rather than a band table: the antennas differ, some
	// carry extensions, and refusing a frequency the hardware would have accepted
	// is worse than letting the controller say no itself.
	if khz < 1000 || khz > 60000 {
		return fmt.Errorf("frequency %d kHz is outside anything these antennas tune", khz)
	}
	a.noteMotorMoveCommanded()
	return a.motorAnt.SetFrequency(khz, a.motorAnt.Status().Direction)
}

// MotorNudgeKHz moves the antenna's commanded frequency by a step, up or down.
//
// Works from the ANTENNA's frequency, not the rig's: this exists so an operator
// can walk the antenna across a band while the rig stays where it is, and
// starting from the rig would undo that on the first press. Falls back to the
// rig only when the antenna has not reported a frequency yet.
func (a *App) MotorNudgeKHz(deltaKHz int) error {
	if a.motorAnt == nil {
		return fmt.Errorf("antenna not connected — enable it in Settings → Antenna")
	}
	cur := a.motorAnt.Status().Frequency
	if cur <= 0 && a.cat != nil {
		if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
			cur = int(rs.FreqHz / 1000)
		}
	}
	if cur <= 0 {
		return fmt.Errorf("the antenna has not reported a frequency yet")
	}
	return a.MotorTuneKHz(cur + deltaKHz)
}

// SetMotorFollow turns frequency tracking on or off and sets its step, without
// opening Settings. Both are ordinary operating decisions — an operator turns
// tracking off to park the antenna and back on to resume — and a preferences
// dialog is the wrong place for something changed that often.
func (a *App) SetMotorFollow(on bool, stepKHz int, mode string) error {
	s, err := a.GetUltrabeamSettings()
	if err != nil {
		return err
	}
	switch stepKHz {
	case 25, 50, 100:
		s.StepKHz = stepKHz
	case 0: // leave the step alone
	default:
		return fmt.Errorf("step must be 25, 50 or 100 kHz")
	}
	// An empty mode leaves it alone, so the caller toggling tracking on and off
	// does not have to know or resend it.
	if strings.TrimSpace(mode) != "" {
		s.TrackMode = normMotorTrackMode(mode)
	}
	s.Follow = on

	// Persist WITHOUT the restart. SaveUltrabeamSettings tears the client down and
	// dials again, which is right when the transport changed and absurd here: the
	// operator toggling tracking watched the antenna drop off and reconnect, and
	// on a remote controller that is several seconds of a link that was working.
	//
	// The follow loop is a goroutine with its own stop channel, so it can be
	// replaced on its own — the connection underneath never knows.
	// Only the two keys this call actually changes. Writing the whole block would
	// mean re-normalising host, port, baud and bands to change a checkbox, and
	// every one of those is a chance to alter something nobody asked to alter.
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if err := a.settings.Set(a.ctx, keyUltrabeamFollow, boolStr(s.Follow)); err != nil {
		return err
	}
	if err := a.settings.Set(a.ctx, keyUltrabeamStep, strconv.Itoa(s.StepKHz)); err != nil {
		return err
	}
	if err := a.settings.Set(a.ctx, keyMotorTrackMode, normMotorTrackMode(s.TrackMode)); err != nil {
		return err
	}
	a.restartMotorFollow(s)
	return nil
}

// restartMotorFollow swaps the follow loop for one matching the settings, leaving
// the antenna connection alone.
func (a *App) restartMotorFollow(s UltrabeamSettings) {
	if a.ubFollowStop != nil {
		close(a.ubFollowStop)
		a.ubFollowStop = nil
	}
	// Say WHICH of the two reasons it is. "follow loop stopped" covered both, and
	// at startup — where nothing was running to stop — it read as a fault. An
	// operator whose antenna sat at 21050 while the rig worked 21074 sent a log
	// that said the antenna had started, answered every poll, and had its follow
	// loop "stopped": three lines that together looked like a link going dead,
	// when tracking was simply switched off and the antenna was waiting to be
	// tuned by hand.
	if a.motorAnt == nil {
		applog.Printf("antenna: tracking not started — no antenna connected")
		return
	}
	if !s.Follow {
		// "configured", not "connected": nothing here has talked to the antenna
		// yet. The poll loop has only just been started, and it reports its own
		// failures ("steppir: cannot open COM5…"). Saying connected made an
		// unopenable port look like a working link with tracking switched off.
		applog.Printf("antenna: %s configured, but TRACKING IS OFF in Settings — it will not follow the rig, and only moves when you tune it by hand", s.Type)
		return
	}
	stop := make(chan struct{})
	a.ubFollowStop = stop
	applog.Printf("antenna: %s tracking the rig — covered bands %v, mode %s, step %d kHz", s.Type, s.Bands, normMotorTrackMode(s.TrackMode), s.StepKHz)
	go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
}

// UltrabeamRetract retracts all elements (storage / safe position).
func (a *App) UltrabeamRetract() error {
	if a.motorAnt == nil {
		return fmt.Errorf("antenna not connected")
	}
	return a.motorAnt.Retract()
}

// TestUltrabeam opens a one-shot connection and reads one status frame to verify
// the transport (TCP host/port or serial COM) without disturbing the running poller.
func (a *App) TestUltrabeam(s UltrabeamSettings) error {
	if s.Transport == "serial" {
		if strings.TrimSpace(s.COM) == "" {
			return fmt.Errorf("serial port required")
		}
	} else if strings.TrimSpace(s.Host) == "" {
		return fmt.Errorf("host required")
	}
	if s.Port <= 0 || s.Port > 65535 {
		s.Port = 23
	}
	// TESTING A SERIAL PORT WE ALREADY OWN.
	//
	// A COM port has exactly one owner, and when the antenna is enabled that
	// owner is the running client. Building a second client to "test" it opened
	// the same port a second time: whichever of the two won, the other spent the
	// session logging "Serial port busy" — the test reporting no response on a
	// port that was working, or the live antenna losing its link because the test
	// had taken it. Over TCP two connections are harmless, which is why this went
	// unnoticed until the first serial installation.
	//
	// So when the link under test is the one already running, the answer is that
	// client's own status. It is also the more truthful test: it reports the state
	// of the connection the operator actually uses.
	if s.Transport == "serial" {
		if live, ok := a.liveMotorFor(s); ok {
			if live.Status().Connected {
				return nil
			}
			return fmt.Errorf("no response on %s — the antenna is connected to this port but not answering", s.COM)
		}
	}
	c := newMotorClient(s)
	if c == nil {
		return fmt.Errorf("antenna not configured")
	}
	if err := c.Start(); err != nil {
		return err
	}
	defer c.Stop()
	// The poller connects + reads status on its tick; give it a few cycles to come
	// up, then check we got a live status frame.
	deadline := time.Now().Add(8 * time.Second)
	for time.Now().Before(deadline) {
		time.Sleep(500 * time.Millisecond)
		if st := c.Status(); st.Connected {
			return nil
		}
	}
	if s.Transport == "serial" {
		return fmt.Errorf("no response on %s", s.COM)
	}
	return fmt.Errorf("no response from %s:%d", s.Host, s.Port)
}

// liveMotorFor returns the running antenna client when it is the one the given
// settings describe — same type, same transport, same port.
//
// Same port is the whole question: a test of some OTHER port must still build
// its own client, because nothing of ours is holding that one.
func (a *App) liveMotorFor(s UltrabeamSettings) (motorAntenna, bool) {
	if a.motorAnt == nil {
		return nil, false
	}
	cur, err := a.GetUltrabeamSettings()
	if err != nil || !cur.Enabled {
		return nil, false
	}
	if cur.Type != s.Type || cur.Transport != s.Transport {
		return nil, false
	}
	if !strings.EqualFold(strings.TrimSpace(cur.COM), strings.TrimSpace(s.COM)) {
		return nil, false
	}
	return a.motorAnt, true
}

// ── Antenna Genius (4O3A) antenna switch (TCP, port fixed 9007) ─────────────

// AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel.
// The TCP port is fixed at 9007 on the device, so only the IP is configurable.
type AntGeniusSettings struct {
	Enabled  bool   `json:"enabled"`
	Host     string `json:"host"`
	Password string `json:"password"` // remote-access password; leave blank on LAN (no AUTH)
	// UseForMyAntenna stamps the SELECTED antenna's name into MY_ANTENNA on
	// every QSO logged, ahead of the band default from Operating conditions.
	UseForMyAntenna bool `json:"use_for_my_antenna"`
	// Ant1Port says which switch port the radio's ANT1 jack is wired to
	// (1 = A, 2 = B). Station wiring; neither device can report it.
	Ant1Port int `json:"ant1_port"`
}

// GetAntGeniusSettings returns the persisted Antenna Genius config.
func (a *App) GetAntGeniusSettings() (AntGeniusSettings, error) {
	out := AntGeniusSettings{}
	if a.settings == nil {
		return out, fmt.Errorf("db not initialized")
	}
	m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost, keyAntGeniusPassword,
		keyAntGeniusMyAnt, keyAntGeniusPortForA1)
	if err != nil {
		return out, err
	}
	out.Enabled = m[keyAntGeniusEnabled] == "1"
	out.Host = m[keyAntGeniusHost]
	out.Password = m[keyAntGeniusPassword]
	out.UseForMyAntenna = m[keyAntGeniusMyAnt] == "1"
	out.Ant1Port = 1
	if m[keyAntGeniusPortForA1] == "2" {
		out.Ant1Port = 2
	}
	return out, nil
}

// SaveAntGeniusSettings persists the config and (re)starts or stops the client.
func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	for k, v := range map[string]string{
		keyAntGeniusEnabled:   boolStr(s.Enabled),
		keyAntGeniusHost:      strings.TrimSpace(s.Host),
		keyAntGeniusPassword:  s.Password,
		keyAntGeniusMyAnt:     boolStr(s.UseForMyAntenna),
		keyAntGeniusPortForA1: map[bool]string{true: "2", false: "1"}[s.Ant1Port == 2],
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	a.startAntGenius()
	return nil
}

// startAntGenius stops any existing client and starts a fresh one if enabled.
func (a *App) startAntGenius() {
	if a.antgenius != nil {
		// Background teardown so saving Settings doesn't block on an in-progress
		// connect (Stop waits for the dial timeout).
		go a.antgenius.Stop()
		a.antgenius = nil
	}
	s, err := a.GetAntGeniusSettings()
	if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
		return
	}
	a.antgenius = antgenius.New(s.Host, 9007, s.Password)
	_ = a.antgenius.Start()
}

// GetAntGeniusStatus returns the switch's current state for the UI poll
// (connection, active antenna per port, and the configured antenna list).
func (a *App) GetAntGeniusStatus() antgenius.Status {
	if a.antgenius == nil {
		return antgenius.Status{}
	}
	return a.antgenius.GetStatus()
}

// AntGeniusActivate selects an antenna on a port (1 = A, 2 = B).
func (a *App) AntGeniusActivate(port, antenna int) error {
	if a.antgenius == nil {
		return fmt.Errorf("Antenna Genius not connected — enable it in Settings → Antenna Genius")
	}
	return a.antgenius.Activate(port, antenna)
}

// AntGeniusDeselect clears the active antenna on a port (sets it to "None").
func (a *App) AntGeniusDeselect(port int) error {
	if a.antgenius == nil {
		return fmt.Errorf("Antenna Genius not connected")
	}
	return a.antgenius.Activate(port, 0)
}

// ── Tuner Genius XL (4O3A) ATU control (TCP, fixed port 9010) ────────────────

// TunerGeniusSettings is the JSON shape for the Hardware → Tuner Genius panel.
// The TCP port is fixed at 9010 on the device, so only the IP is configurable.
type TunerGeniusSettings struct {
	Enabled  bool   `json:"enabled"`
	Host     string `json:"host"`
	Password string `json:"password"` // remote-access code; leave blank on LAN (no AUTH)
}

// GetTunerGeniusSettings returns the persisted Tuner Genius config.
func (a *App) GetTunerGeniusSettings() (TunerGeniusSettings, error) {
	out := TunerGeniusSettings{}
	if a.settings == nil {
		return out, fmt.Errorf("db not initialized")
	}
	m, err := a.settings.GetMany(a.ctx, keyTunerGeniusEnabled, keyTunerGeniusHost, keyTunerGeniusPassword)
	if err != nil {
		return out, err
	}
	out.Enabled = m[keyTunerGeniusEnabled] == "1"
	out.Host = m[keyTunerGeniusHost]
	out.Password = m[keyTunerGeniusPassword]
	return out, nil
}

// SaveTunerGeniusSettings persists the config and (re)starts or stops the client.
func (a *App) SaveTunerGeniusSettings(s TunerGeniusSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	for k, v := range map[string]string{
		keyTunerGeniusEnabled:  boolStr(s.Enabled),
		keyTunerGeniusHost:     strings.TrimSpace(s.Host),
		keyTunerGeniusPassword: s.Password,
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	a.startTunerGenius()
	return nil
}

// startTunerGenius stops any existing client and starts a fresh one if enabled.
func (a *App) startTunerGenius() {
	if a.tunergenius != nil {
		go a.tunergenius.Stop() // background teardown so saving Settings doesn't block
		a.tunergenius = nil
	}
	s, err := a.GetTunerGeniusSettings()
	if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
		return
	}
	a.tunergenius = tunergenius.New(s.Host, tunergenius.DefaultPort, s.Password)
	_ = a.tunergenius.Start()
}

// GetTunerGeniusStatus returns the ATU's current state for the UI poll.
func (a *App) GetTunerGeniusStatus() tunergenius.Status {
	if a.tunergenius == nil {
		return tunergenius.Status{}
	}
	return a.tunergenius.GetStatus()
}

// TunerGeniusAutotune starts an automatic tuning cycle on the active channel.
func (a *App) TunerGeniusAutotune() error {
	if a.tunergenius == nil {
		return fmt.Errorf("Tuner Genius not connected — enable it in Settings → Tuner Genius")
	}
	return a.tunergenius.Autotune()
}

// TunerGeniusSetBypass engages (true) or clears (false) the global bypass.
func (a *App) TunerGeniusSetBypass(on bool) error {
	if a.tunergenius == nil {
		return fmt.Errorf("Tuner Genius not connected")
	}
	return a.tunergenius.SetBypass(on)
}

// TunerGeniusSetOperate puts the tuner in OPERATE (true) or STANDBY (false).
func (a *App) TunerGeniusSetOperate(on bool) error {
	if a.tunergenius == nil {
		return fmt.Errorf("Tuner Genius not connected")
	}
	return a.tunergenius.SetOperate(on)
}

// TunerGeniusActivate selects the active channel (1 = A, 2 = B; or antenna
// 1/2/3 on the 3-way variant).
func (a *App) TunerGeniusActivate(ch int) error {
	if a.tunergenius == nil {
		return fmt.Errorf("Tuner Genius not connected")
	}
	return a.tunergenius.Activate(ch)
}

// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────

// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
// the 4O3A PowerGenius XL (TCP), the SPE Expert amps and the ACOM S-series amps
// (both: USB serial or an RS232-to-Ethernet bridge). The type name is kept for
// binding compatibility.
type PGXLSettings struct {
	Enabled   bool   `json:"enabled"`
	Type      string `json:"type"`      // "pgxl" | "spe13" | "spe15" | "spe2k" | "acom500" | "acom600" | "acom700" | "acom1200" | "acom2020"
	Transport string `json:"transport"` // "tcp" | "serial"
	Host      string `json:"host"`      // TCP transport
	Port      int    `json:"port"`      // TCP transport
	ComPort   string `json:"com_port"`  // serial transport
	Baud      int    `json:"baud"`      // serial transport
}

func (a *App) GetPGXLSettings() (PGXLSettings, error) {
	out := PGXLSettings{Type: "pgxl", Transport: "tcp", Port: 9008, Baud: 115200}
	if a.settings == nil {
		return out, fmt.Errorf("db not initialized")
	}
	m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort, keyPGXLType, keyPGXLTransport, keyPGXLComPort, keyPGXLBaud)
	if err != nil {
		return out, err
	}
	out.Enabled = m[keyPGXLEnabled] == "1"
	out.Host = m[keyPGXLHost]
	if p, _ := strconv.Atoi(m[keyPGXLPort]); p > 0 && p <= 65535 {
		out.Port = p
	}
	if t := strings.TrimSpace(m[keyPGXLType]); t != "" {
		out.Type = t
	}
	if tr := strings.TrimSpace(m[keyPGXLTransport]); tr != "" {
		out.Transport = tr
	}
	out.ComPort = m[keyPGXLComPort]
	if b, _ := strconv.Atoi(m[keyPGXLBaud]); b > 0 {
		out.Baud = b
	}
	return out, nil
}

func (a *App) SavePGXLSettings(s PGXLSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if s.Port <= 0 || s.Port > 65535 {
		s.Port = 9008
	}
	if s.Baud <= 0 {
		s.Baud = 115200
	}
	if s.Type == "" {
		s.Type = "pgxl"
	}
	if s.Transport == "" {
		s.Transport = "tcp"
	}
	for k, v := range map[string]string{
		keyPGXLEnabled:   boolStr(s.Enabled),
		keyPGXLHost:      strings.TrimSpace(s.Host),
		keyPGXLPort:      strconv.Itoa(s.Port),
		keyPGXLType:      s.Type,
		keyPGXLTransport: s.Transport,
		keyPGXLComPort:   strings.TrimSpace(s.ComPort),
		keyPGXLBaud:      strconv.Itoa(s.Baud),
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	a.restartAsync("amp", a.startAmps)
	return nil
}

// ── Multiple amplifiers ────────────────────────────────────────────────
// Operators can run SEVERAL amps (even two SPEs combined for more power), so
// the config is a LIST. The legacy single-amp keyPGXL* settings migrate into
// entry #1 the first time the list is read. Each enabled entry gets its own
// client; the legacy a.pgxl/a.spe/a.acom fields point at the FIRST enabled amp
// of each family so the pre-multi bindings (and the Flex-panel merge) keep
// working unchanged.

const keyAmpsList = "amps.json"

// AmpConfig is one configured amplifier in Settings → Amplifier.
type AmpConfig struct {
	ID        string `json:"id"`
	Name      string `json:"name"` // user label, e.g. "SPE left"
	Enabled   bool   `json:"enabled"`
	Type      string `json:"type"`      // "pgxl" | "spe13"|"spe15"|"spe2k" | "acom500"…"acom2020"
	Transport string `json:"transport"` // "tcp" | "serial"
	Host      string `json:"host"`
	Port      int    `json:"port"`
	Password  string `json:"password"` // PowerGenius XL remote-access code (blank on LAN); sent as AUTH like the Tuner/Antenna Genius
	ComPort   string `json:"com_port"`
	Baud      int    `json:"baud"`

	// Band-follow: an ACOM is the MASTER on its CAT/AUX connector — it polls a
	// transceiver and changes band from the reply, so following OpsLog means
	// answering those polls on a SECOND serial port (independent of the one used
	// above for metering; both run at once). See internal/catemu.
	FreqOut     bool   `json:"freq_out"`
	FreqComPort string `json:"freq_com_port"`
	FreqBaud    int    `json:"freq_baud"`
	// FreqBroadcastMs > 0 also SENDS the frequency unprompted at that interval,
	// for an amplifier that listens to a transceiver's CAT stream instead of
	// polling it. 0 = answer polls only.
	FreqBroadcastMs int `json:"freq_broadcast_ms"`
}

type ampInst struct {
	cfg    AmpConfig
	pgxl   *powergenius.Client
	spe    *spe.Client
	acom   *acom.Client
	kpa    *kpa.Client
	catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
}

func (i *ampInst) stopAll() {
	if i.pgxl != nil {
		i.pgxl.Stop()
	}
	if i.spe != nil {
		i.spe.Stop()
	}
	if i.acom != nil {
		i.acom.Stop()
	}
	if i.kpa != nil {
		i.kpa.Stop()
	}
	if i.catemu != nil {
		i.catemu.Stop()
	}
}

// ampTypeLabel is the default display name for an amp type.
func ampTypeLabel(t string) string {
	switch {
	case t == "" || t == "pgxl":
		return "PowerGenius XL"
	case strings.HasPrefix(t, "spe"):
		return "SPE " + map[string]string{"spe13": "1.3K-FA", "spe15": "1.5K-FA", "spe2k": "2K-FA"}[t]
	case strings.HasPrefix(t, "acom"):
		return "Acom " + strings.TrimPrefix(t, "acom") + "S"
	case strings.HasPrefix(t, "kpa"):
		return "Elecraft " + strings.ToUpper(t)
	}
	return t
}

// GetAmplifiers returns the configured amplifier list. When none was ever
// saved, the legacy single-amp settings (if any) are presented as entry #1 —
// persisted on the next SaveAmplifiers.
func (a *App) GetAmplifiers() ([]AmpConfig, error) {
	if a.settings == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	raw, _ := a.settings.Get(a.ctx, keyAmpsList)
	if strings.TrimSpace(raw) != "" {
		var list []AmpConfig
		if err := json.Unmarshal([]byte(raw), &list); err == nil {
			return list, nil
		}
	}
	s, err := a.GetPGXLSettings()
	if err != nil || (!s.Enabled && strings.TrimSpace(s.Host) == "" && strings.TrimSpace(s.ComPort) == "") {
		return []AmpConfig{}, nil // never configured
	}
	return []AmpConfig{{
		ID: "amp-1", Name: ampTypeLabel(s.Type), Enabled: s.Enabled,
		Type: s.Type, Transport: s.Transport, Host: s.Host, Port: s.Port, ComPort: s.ComPort, Baud: s.Baud,
	}}, nil
}

// SaveAmplifiers persists the amplifier list and (re)starts the clients.
func (a *App) SaveAmplifiers(list []AmpConfig) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	for i := range list {
		c := &list[i]
		if strings.TrimSpace(c.ID) == "" {
			c.ID = fmt.Sprintf("amp-%d-%d", time.Now().Unix(), i)
		}
		if c.Type == "" {
			c.Type = "pgxl"
		}
		if c.Type == "pgxl" || c.Transport != "serial" {
			c.Transport = "tcp"
		}
		if c.Port <= 0 || c.Port > 65535 {
			c.Port = 9008
		}
		if c.Baud <= 0 {
			if strings.HasPrefix(c.Type, "acom") {
				c.Baud = 9600
			} else {
				c.Baud = 115200
			}
		}
		if strings.TrimSpace(c.Name) == "" {
			c.Name = ampTypeLabel(c.Type)
		}
	}
	b, err := json.Marshal(list)
	if err != nil {
		return err
	}
	if err := a.settings.Set(a.ctx, keyAmpsList, string(b)); err != nil {
		return err
	}
	a.restartAsync("amp", a.startAmps)
	return nil
}

// startAmps stops every running amp client and starts one per enabled entry.
func (a *App) startAmps() {
	a.ampsMu.Lock()
	old := a.ampInsts
	a.ampInsts = map[string]*ampInst{}
	a.ampsMu.Unlock()
	a.pgxl, a.spe, a.acom = nil, nil, nil
	list, err := a.GetAmplifiers()
	if err != nil {
		for _, inst := range old {
			go inst.stopAll()
		}
		return
	}
	// An amplifier whose configuration did not change keeps its RUNNING client
	// across a settings save. Rebuilding closes the serial port, and closing a
	// COM port makes the Windows driver drop DTR/RTS — which on a KPA500 is the
	// POWER SWITCH: every save of any Settings page was switching the amplifier
	// off (F1TPL's report). Reuse also spares the other amps a reconnect blink.
	reused := map[string]bool{}
	for _, c := range list {
		if !c.Enabled {
			continue
		}
		if o, ok := old[c.ID]; ok && o.cfg == c {
			reused[c.ID] = true
			if o.pgxl != nil && a.pgxl == nil {
				a.pgxl = o.pgxl
			}
			if o.spe != nil && a.spe == nil {
				a.spe = o.spe
			}
			if o.acom != nil && a.acom == nil {
				a.acom = o.acom
			}
			a.ampsMu.Lock()
			a.ampInsts[c.ID] = o
			a.ampsMu.Unlock()
		}
	}
	for id, inst := range old {
		if reused[id] {
			continue
		}
		// Stop() can block up to the dial timeout waiting for an in-progress
		// connect; tear down in the background so saving Settings (this runs on
		// the Wails RPC goroutine) doesn't freeze the UI.
		go inst.stopAll()
	}
	for _, c := range list {
		if !c.Enabled || reused[c.ID] {
			continue
		}
		isPGXL := c.Type == "" || c.Type == "pgxl"
		if !isPGXL && c.Transport == "serial" && strings.TrimSpace(c.ComPort) == "" {
			continue
		}
		if (isPGXL || c.Transport == "tcp") && strings.TrimSpace(c.Host) == "" {
			continue
		}
		inst := &ampInst{cfg: c}
		switch {
		case isPGXL:
			inst.pgxl = powergenius.New(c.Host, c.Port, c.Password)
			_ = inst.pgxl.Start()
			if a.pgxl == nil {
				a.pgxl = inst.pgxl
			}
		case strings.HasPrefix(c.Type, "acom"):
			inst.acom = acom.New(acom.Config{Model: strings.TrimPrefix(c.Type, "acom") + "S", Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
			_ = inst.acom.Start()
			if a.acom == nil {
				a.acom = inst.acom
			}
		case strings.HasPrefix(c.Type, "kpa"):
			// A KPA500 has no network port at all, so a configuration asking for
			// one is a mistake worth naming rather than a connection that never
			// succeeds.
			if strings.EqualFold(c.Type, "kpa500") && c.Transport == "tcp" {
				applog.Printf("amp %s: a KPA500 has no network connection — use its serial port", c.Name)
				continue
			}
			inst.kpa = kpa.New(kpa.Config{
				Model: strings.ToUpper(c.Type), Transport: c.Transport,
				ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port,
			})
			_ = inst.kpa.Start()
		default: // spe*
			inst.spe = spe.New(spe.Config{Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port})
			_ = inst.spe.Start()
			if a.spe == nil {
				a.spe = inst.spe
			}
		}
		// Band-follow on a SECOND serial port, for any amp that takes its band from
		// a transceiver CAT link (ACOM and SPE both do). Independent of the control
		// transport above, so metering and band-follow run at the same time.
		if c.FreqOut && strings.TrimSpace(c.FreqComPort) != "" {
			inst.catemu = catemu.New(catemu.Config{
				ComPort: c.FreqComPort, Baud: c.FreqBaud,
				BroadcastMs: c.FreqBroadcastMs,
			}, applog.Printf)
			if st := a.cat.State(); st.Connected {
				inst.catemu.SetFrequency(st.FreqHz)
				inst.catemu.SetMode(st.Mode)
			}
			inst.catemu.Start()
			applog.Printf("amp %s: band-follow on %s at %d baud (Kenwood format, broadcast=%dms)",
				c.Name, c.FreqComPort, c.FreqBaud, c.FreqBroadcastMs)
		}
		a.ampsMu.Lock()
		a.ampInsts[c.ID] = inst
		a.ampsMu.Unlock()
	}
}

// feedAmpBandFollow pushes the rig's frequency/mode to every amplifier we are
// emulating a transceiver for. Called on each CAT state change.
//
// The TX frequency is what the amp must follow: in split it has to be tuned
// for where we transmit, not where we listen.
func (a *App) feedAmpBandFollow(s cat.RigState) {
	if !s.Connected || s.FreqHz <= 0 {
		return
	}
	a.ampsMu.Lock()
	defer a.ampsMu.Unlock()
	for _, inst := range a.ampInsts {
		if inst.catemu != nil {
			inst.catemu.SetFrequency(s.FreqHz)
			inst.catemu.SetMode(s.Mode)
		}
		// A KPA is TOLD its band, on the link it is already on.
		//
		// The emulator above exists because an Acom polls a transceiver and has
		// no command to be given a band; the KPA has one (^BN), so it needs
		// neither a second serial port nor a pretend rig. Sent from a goroutine
		// because this runs on the CAT state-change path, and nothing about the
		// rig should wait on an amplifier's link.
		// Asked for, like every other write to somebody's station. The option is
		// the same one an Acom uses — "keep the amplifier on the radio's band" is
		// one idea to an operator, whatever it takes underneath — and it is off
		// for the operator who has wired the amplifier straight to the rig and
		// does not want a second voice telling it where to be.
		if inst.kpa != nil && inst.cfg.FreqOut && s.Band != "" {
			k, band := inst.kpa, s.Band
			go func() { _ = k.SetBand(band) }()
		}
	}
}

// GetAmpBandFollowStatus returns the band-follow link state per amplifier id,
// so the settings panel can show whether the amp is actually polling us.
func (a *App) GetAmpBandFollowStatus() map[string]catemu.Status {
	out := map[string]catemu.Status{}
	a.ampsMu.Lock()
	defer a.ampsMu.Unlock()
	for id, inst := range a.ampInsts {
		if inst.catemu != nil {
			out[id] = inst.catemu.GetStatus()
		}
	}
	return out
}

// AmpStatus is one amp's live state for the UI poll — exactly one of the
// per-family payloads is set, per the amp's type.
type AmpStatus struct {
	ID   string              `json:"id"`
	Name string              `json:"name"`
	Type string              `json:"type"`
	PGXL *powergenius.Status `json:"pgxl,omitempty"`
	SPE  *spe.Status         `json:"spe,omitempty"`
	ACOM *acom.Status        `json:"acom,omitempty"`
	KPA  *kpa.Status         `json:"kpa,omitempty"`
}

// GetAmpStatuses returns the live state of every ENABLED amplifier, in the
// configured order — one poll feeds every amp card and the status-bar chips.
func (a *App) GetAmpStatuses() []AmpStatus {
	list, _ := a.GetAmplifiers()
	a.ampsMu.Lock()
	insts := a.ampInsts
	a.ampsMu.Unlock()
	out := []AmpStatus{}
	for _, c := range list {
		if !c.Enabled {
			continue
		}
		st := AmpStatus{ID: c.ID, Name: c.Name, Type: c.Type}
		if inst, ok := insts[c.ID]; ok {
			switch {
			case inst.pgxl != nil:
				v := inst.pgxl.GetStatus()
				st.PGXL = &v
			case inst.spe != nil:
				v := inst.spe.GetStatus()
				st.SPE = &v
			case inst.acom != nil:
				v := inst.acom.GetStatus()
				st.ACOM = &v
			case inst.kpa != nil:
				v := inst.kpa.GetStatus()
				st.KPA = &v
			}
		}
		out = append(out, st)
	}
	return out
}

func (a *App) ampInstByID(id string) *ampInst {
	a.ampsMu.Lock()
	defer a.ampsMu.Unlock()
	return a.ampInsts[id]
}

// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false).
func (a *App) AmpOperate(id string, on bool) error {
	// Fan out here rather than in the UI: the card and the docked widget both
	// call this, and a coupling implemented in one of them would be missing from
	// the other — which on a combiner means one amplifier keyed and one not.
	return a.ampFanOut(a.ampTargets(id, a.GetLinkedAmps()), func(tid string) error {
		return a.ampOperateOne(tid, on)
	})
}

// ampFanOut runs one command against every amplifier AT THE SAME TIME.
//
// Sequentially, the second amplifier of a combined pair was commanded only once
// the first had answered — and an SPE answers over its own link, in its own
// time. The combiner heard power appear on one input before the other and
// complained about it, on every OFF and every ON.
//
// So each target gets a goroutine, and they are all parked on the same channel
// until every one of them is ready. Closing it releases them together: the
// difference between "start one, then start the other" and "both leave at
// once". They have separate clients and separate connections, so nothing
// downstream re-serialises them.
//
// A single target — the ordinary case of one amplifier — runs inline. No
// goroutine, no barrier, nothing to go wrong for the operators who have one amp.
func (a *App) ampFanOut(targets []string, do func(id string) error) error {
	if len(targets) == 0 {
		return nil
	}
	if len(targets) == 1 {
		return do(targets[0])
	}
	errs := make([]error, len(targets))
	start := make(chan struct{})
	var wg sync.WaitGroup
	for i, id := range targets {
		wg.Add(1)
		go func(i int, id string) {
			defer wg.Done()
			<-start
			errs[i] = do(id)
		}(i, id)
	}
	close(start)
	wg.Wait()
	// The amplifier the operator actually clicked is first, and its failure is
	// the one worth reporting: "the amp I pressed did not respond" beats the
	// same message about its silent partner.
	for _, err := range errs {
		if err != nil {
			return err
		}
	}
	return nil
}

func (a *App) ampOperateOne(id string, on bool) error {
	inst := a.ampInstByID(id)
	if inst == nil {
		return fmt.Errorf("amplifier not running — check Settings → Amplifier")
	}
	switch {
	case inst.pgxl != nil:
		return inst.pgxl.SetOperate(on)
	case inst.spe != nil:
		return inst.spe.Operate(on)
	case inst.acom != nil:
		return inst.acom.Operate(on)
	case inst.kpa != nil:
		return inst.kpa.Operate(on)
	}
	return fmt.Errorf("amplifier not running")
}

// AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power
// command on its direct link). Logged here because the UI buttons swallow the
// returned error — a failed power-on otherwise looks like a dead button.
func (a *App) AmpPower(id string, on bool) (err error) {
	defer func() {
		if err != nil {
			applog.Printf("amp %s: power %v failed: %v", id, on, err)
		}
	}()
	targets := a.ampTargets(id, a.GetLinkedAmps())
	multi := len(targets) > 1
	return a.ampFanOut(targets, func(tid string) error {
		return a.ampPowerOne(tid, on, multi)
	})
}

func (a *App) ampPowerOne(id string, on, linked bool) error {
	inst := a.ampInstByID(id)
	if inst == nil {
		return fmt.Errorf("amplifier not running — check Settings → Amplifier")
	}
	switch {
	case inst.spe != nil:
		if on {
			return inst.spe.PowerOn()
		}
		return inst.spe.PowerOff()
	case inst.acom != nil:
		if on {
			return inst.acom.PowerOn()
		}
		return inst.acom.PowerOff()
	case inst.kpa != nil:
		// OFF is a real power-down on a KPA1500: the main supplies drop and the
		// way back on is the front panel or Wake-on-LAN. The button that reaches
		// this asks first — see the UI — because "off" here is not standby.
		return inst.kpa.PowerOn(on)
	}
	// Not an error worth surfacing when linked: a PGXL alongside two SPEs simply
	// has no power command on its direct link, and reporting that as a failure
	// would make a successful pair look broken.
	if linked {
		return nil
	}
	return fmt.Errorf("power on/off is not available for this amplifier")
}

// AmpPowerLevel selects the output power level — SPE only (L/M/H).
//
// Coupled like ON, OFF and OPERATE, which it was not: two combined amplifiers
// left at different power levels feed the combiner unevenly, which is the thing
// the coupling exists to prevent. It was simply the command nobody had linked.
//
// Running the pair concurrently matters more here than anywhere else. Setting a
// level is not one command: an SPE has no "set level" at all, so the driver taps
// the POWER key and waits for the amp to report the new level before tapping
// again — up to three taps, up to two seconds each. One after the other, the two
// amplifiers would sit at different levels for as long as six seconds.
func (a *App) AmpPowerLevel(id, level string) error {
	targets := a.ampTargets(id, a.GetLinkedAmps())
	multi := len(targets) > 1
	return a.ampFanOut(targets, func(tid string) error {
		inst := a.ampInstByID(tid)
		if inst == nil || inst.spe == nil {
			// A PowerGenius sitting in the group has no L/M/H, and saying so
			// would make a successful SPE pair look like a failure.
			if multi {
				return nil
			}
			return fmt.Errorf("power level is an SPE feature")
		}
		return inst.spe.SetPowerLevel(level)
	})
}

// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST).
func (a *App) AmpFanMode(id, mode string) error {
	inst := a.ampInstByID(id)
	if inst == nil || inst.pgxl == nil {
		return fmt.Errorf("fan mode is a PowerGenius feature")
	}
	return inst.pgxl.SetFanMode(mode)
}

// GetSPEStatus returns the SPE Expert amplifier state for the UI poll.
func (a *App) GetSPEStatus() spe.Status {
	if a.spe == nil {
		return spe.Status{}
	}
	return a.spe.GetStatus()
}

// SPESetOperate puts the SPE amp in OPERATE (true) or STANDBY (false). The amp has
// a single OPERATE toggle key, so this sends it only when the state must change.
func (a *App) SPESetOperate(on bool) error {
	if a.spe == nil {
		return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
	}
	return a.spe.Operate(on)
}

// SPESetPower turns the SPE amp on (true, POWER key) or off (false, OFF key).
func (a *App) SPESetPower(on bool) error {
	if a.spe == nil {
		return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
	}
	if on {
		return a.spe.PowerOn()
	}
	return a.spe.PowerOff()
}

// SPESetPowerLevel selects the SPE output power level ("L"/"M"/"H").
func (a *App) SPESetPowerLevel(level string) error {
	if a.spe == nil {
		return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
	}
	return a.spe.SetPowerLevel(level)
}

// GetACOMStatus returns the ACOM amplifier state for the UI poll.
func (a *App) GetACOMStatus() acom.Status {
	if a.acom == nil {
		return acom.Status{}
	}
	return a.acom.GetStatus()
}

// ACOMSetOperate puts the ACOM amp in OPERATE (true) or STANDBY (false) — the
// protocol has explicit commands for each, unlike the SPE's toggle key.
func (a *App) ACOMSetOperate(on bool) error {
	if a.acom == nil {
		return fmt.Errorf("Acom amplifier not connected — enable it in Settings → Amplifier")
	}
	return a.acom.Operate(on)
}

// ACOMSetPower turns the ACOM amp on (true, a DTR/RTS pulse — serial only, needs
// the power-on pins wired in the cable) or off (false, the OFF data command).
func (a *App) ACOMSetPower(on bool) error {
	if a.acom == nil {
		return fmt.Errorf("Acom amplifier not connected — enable it in Settings → Amplifier")
	}
	if on {
		return a.acom.PowerOn()
	}
	return a.acom.PowerOff()
}

// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
func (a *App) GetPGXLStatus() powergenius.Status {
	if a.pgxl == nil {
		return powergenius.Status{}
	}
	return a.pgxl.GetStatus()
}

// PGXLSetOperate puts the PowerGenius XL in OPERATE (true) or STANDBY (false)
// over its direct TCP link (no Flex needed).
func (a *App) PGXLSetOperate(on bool) error {
	if a.pgxl == nil {
		return fmt.Errorf("PowerGenius not connected — enable it in Settings → Amplifier")
	}
	return a.pgxl.SetOperate(on)
}

// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
func (a *App) PGXLSetFanMode(mode string) error {
	if a.pgxl == nil {
		return fmt.Errorf("PowerGenius not connected — enable it in Settings → PowerGenius")
	}
	return a.pgxl.SetFanMode(mode)
}

// --- WinKeyer (CW keyer) bindings ---

// WKMacro is one CW message slot (F1…): a short label + the macro text, which
// may contain <VARIABLE> tokens resolved by the frontend before sending.
type WKMacro struct {
	Label string `json:"label"`
	Text  string `json:"text"`
}

// WinkeyerSettings is the Hardware → CW Keyer panel shape. It embeds the
// engine Config (keying parameters) plus the enable flag and message macros.
type WinkeyerSettings struct {
	Enabled bool `json:"enabled"`
	winkeyer.Config
	Engine        string    `json:"engine"`          // keyer backend: "winkeyer" | "icom" (rig keyer via CI-V) | "tci"
	EscClearsCall bool      `json:"esc_clears_call"` // ESC also resets the callsign
	SendOnType    bool      `json:"send_on_type"`    // key chars live as typed
	Esm           bool      `json:"esm"`             // Enter-Sends-Message: Enter fires F1/F2/F3 by QSO stage
	Macros        []WKMacro `json:"macros"`
}

// ListSerialPorts returns the available COM ports for the keyer dropdown.
func (a *App) ListSerialPorts() ([]string, error) {
	ports, err := winkeyer.ListPorts()
	if err != nil {
		return nil, err
	}
	return tidySerialPorts(ports), nil
}

// tidySerialPorts removes duplicates and puts the list in the order a human
// reads it.
//
// Windows enumerates serial ports from HARDWARE\DEVICEMAP\SERIALCOMM, which maps
// a DEVICE path to a COM name — so the same name legitimately appears twice when
// two devices claim it. That happens with a driver uninstalled without cleaning
// up after itself, and with virtual-port software. An operator saw COM1 and COM3
// listed twice, and the dropdown showed "COM3COM3" as its value, because two
// entries with the same value both counted as selected.
//
// The order is natural, not lexical: COM4 belongs between COM3 and COM8, and
// listing it after COM9 is how a port gets overlooked on a machine with a dozen.
// Anything not named COMn keeps its own alphabetical order, after them.
//
// A duplicate is worth a log line rather than silence: two devices holding one
// COM name is also why one of them is "busy" when the other is opened, and that
// is not something an operator can work out from a dropdown.
func tidySerialPorts(ports []string) []string {
	seen := make(map[string]bool, len(ports))
	out := make([]string, 0, len(ports))
	for _, p := range ports {
		name := strings.TrimSpace(p)
		if name == "" {
			continue
		}
		key := strings.ToUpper(name)
		if seen[key] {
			applog.Printf("serial: %s is claimed by more than one device in the Windows port map — listing it once", name)
			continue
		}
		seen[key] = true
		out = append(out, name)
	}
	sort.SliceStable(out, func(i, j int) bool {
		ni, oki := comPortNumber(out[i])
		nj, okj := comPortNumber(out[j])
		switch {
		case oki && okj:
			return ni < nj
		case oki:
			return true // COMn before anything else
		case okj:
			return false
		}
		return out[i] < out[j]
	})
	return out
}

// comPortNumber extracts n from "COMn", false for any other shape.
func comPortNumber(s string) (int, bool) {
	if len(s) <= 3 || !strings.EqualFold(s[:3], "COM") {
		return 0, false
	}
	n, err := strconv.Atoi(s[3:])
	if err != nil {
		return 0, false
	}
	return n, true
}

// GetWinkeyerSettings returns the persisted keyer config (with sane defaults).
func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
	out := WinkeyerSettings{
		Config: winkeyer.Config{
			Baud: 1200, WPM: 25, Weight: 50, LeadInMs: 10, TailMs: 50,
			Ratio: 50, Sidetone: 600, Mode: winkeyer.ModeIambicB, AutoSpace: true,
			SerialEcho: true, // so the panel shows text as it's transmitted
		},
		Engine:        "winkeyer",
		EscClearsCall: true,
		Macros:        defaultWKMacros(),
	}
	if a.settings == nil {
		return out, nil
	}
	m, err := a.settings.GetMany(a.ctx,
		keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
		keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
		keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
		keyWKEngine, keyWKEscClears, keyWKSendOnType, keyWKEsm, keyWKCWLine, keyWKCWInvert)
	if err != nil {
		return out, err
	}
	if v := m[keyWKEngine]; v != "" {
		out.Engine = v
	}
	if v := m[keyWKCWLine]; v != "" {
		out.CWKeyLine = v
	}
	out.CWInvert = m[keyWKCWInvert] == "1"
	if v := m[keyWKEscClears]; v != "" {
		out.EscClearsCall = v == "1"
	}
	out.SendOnType = m[keyWKSendOnType] == "1"
	out.Esm = m[keyWKEsm] == "1"
	out.Enabled = m[keyWKEnabled] == "1"
	if v := m[keyWKPort]; v != "" {
		out.Port = v
	}
	atoiInto(m[keyWKBaud], &out.Baud)
	atoiInto(m[keyWKWPM], &out.WPM)
	atoiInto(m[keyWKWeight], &out.Weight)
	atoiInto(m[keyWKLeadIn], &out.LeadInMs)
	atoiInto(m[keyWKTail], &out.TailMs)
	atoiInto(m[keyWKRatio], &out.Ratio)
	atoiInto(m[keyWKFarnsworth], &out.Farnsworth)
	atoiInto(m[keyWKSidetone], &out.Sidetone)
	if v := m[keyWKMode]; v != "" {
		out.Mode = winkeyer.Mode(v)
	}
	out.Swap = m[keyWKSwap] == "1"
	if v := m[keyWKAutoSpace]; v != "" {
		out.AutoSpace = v == "1"
	}
	out.UsePTT = m[keyWKUsePTT] == "1"
	// Only override the default (true) when the key is actually stored — otherwise
	// settings saved before serial_echo existed would silently disable the echo,
	// and the TX text would stop showing as it's keyed.
	if v := m[keyWKSerialEcho]; v != "" {
		out.SerialEcho = v == "1"
	}
	if v := m[keyWKMacros]; v != "" {
		var mac []WKMacro
		if json.Unmarshal([]byte(v), &mac) == nil && len(mac) > 0 {
			out.Macros = mac
		}
	}
	return out, nil
}

// SaveWinkeyerSettings persists the keyer config; if a link is open and the
// keying params changed, the caller can reconnect to apply them.
func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	macJSON, _ := json.Marshal(s.Macros)
	for k, v := range map[string]string{
		keyWKEnabled:    boolStr(s.Enabled),
		keyWKPort:       strings.TrimSpace(s.Port),
		keyWKBaud:       strconv.Itoa(s.Baud),
		keyWKWPM:        strconv.Itoa(s.WPM),
		keyWKWeight:     strconv.Itoa(s.Weight),
		keyWKLeadIn:     strconv.Itoa(s.LeadInMs),
		keyWKTail:       strconv.Itoa(s.TailMs),
		keyWKRatio:      strconv.Itoa(s.Ratio),
		keyWKFarnsworth: strconv.Itoa(s.Farnsworth),
		keyWKSidetone:   strconv.Itoa(s.Sidetone),
		keyWKMode:       string(s.Mode),
		keyWKSwap:       boolStr(s.Swap),
		keyWKAutoSpace:  boolStr(s.AutoSpace),
		keyWKUsePTT:     boolStr(s.UsePTT),
		keyWKSerialEcho: boolStr(s.SerialEcho),
		keyWKMacros:     string(macJSON),
		keyWKEngine:     strings.TrimSpace(s.Engine),
		keyWKEscClears:  boolStr(s.EscClearsCall),
		keyWKSendOnType: boolStr(s.SendOnType),
		keyWKEsm:        boolStr(s.Esm),
		keyWKCWLine:     strings.TrimSpace(s.CWKeyLine),
		keyWKCWInvert:   boolStr(s.CWInvert),
	} {
		if err := a.settings.Set(a.ctx, k, v); err != nil {
			return err
		}
	}
	// Apply line-control changes (PTT keying, key line, invert, lead/tail, speed)
	// to a running serial keyer immediately — no reconnect needed. Harmless when
	// the keyer isn't the serial type or isn't connected.
	if a.winkeyer != nil && s.Engine == "serial" {
		cfg := s.Config
		cfg.Type = "serial"
		a.winkeyer.ApplySerialConfig(cfg)
	}
	return nil
}

// SetWinkeyerTrace turns the byte-level WinKeyer protocol trace on or off.
//
// The K1EL command set differs across WK1 / WK2 / WK3 and a mismatch is silent:
// the keyer accepts a byte meant for another command and keys something odd
// (one element then a long pause, a sidetone that will not switch off). Reading
// the actual wire is the only way to tell which command the firmware misread,
// and it beats guessing from a description — a guessed fix breaks the operators
// for whom it currently works.
func (a *App) SetWinkeyerTrace(on bool) {
	winkeyer.SetTrace(on)
}

// SetCIVTrace turns the byte-level CI-V trace on or off.
//
// Same reasoning as the WinKeyer trace, and prompted by the same shape of
// report: an IC-9100 whose MOX button sets split instead of transmitting, while
// the source demonstrably sends the PTT command (0x1C 0x00) and not the split
// one (0x0F). Something between the two is not what it appears, and on a link
// already shown to lose frame sync, the bytes are the only witness.
func (a *App) SetCIVTrace(on bool) {
	cat.SetCIVTrace(on)
}

// CIVTraceEnabled and WinkeyerTraceEnabled report whether each trace is running.
//
// The settings dialog needs them. Its checkboxes are React state initialised to
// false on every mount, so a trace left on came back UNTICKED while it was still
// running: the operator ticks the box to \"switch it on\", which switches it off,
// and the log they send then contains no trace at all. Reported by a Xiegu user.
func (a *App) CIVTraceEnabled() bool { return cat.CIVTraceEnabled() }

// WinkeyerTraceEnabled — same, for the keyer trace.
func (a *App) WinkeyerTraceEnabled() bool { return winkeyer.TraceEnabled() }

// keyerSlowBootKey names the "this port holds a keyer that reboots when opened"
// flag. GLOBAL, not per profile: which keyer is plugged into COM3 is a property
// of the computer, and an operator who switches profiles for a different rig has
// not changed the keyer on the desk.
func keyerSlowBootKey(port string) string {
	return "winkeyer.slow_boot." + strings.ToUpper(strings.TrimSpace(port))
}

// keyerNeedsSlowBoot reports whether this port has already been seen to need the
// long opening wait.
//
// A K3NG keyer — an Arduino running the WinKeyer protocol — reboots when the
// port opens, because DTR is wired to its reset pin. The first connect finds
// that out by failing the quick attempt and succeeding on the slow one; every
// connect afterwards reads this and goes straight to the slow one.
//
// Learnt rather than configured. The alternative was a "K3NG" entry beside
// "WinKeyer" in the engine list, which would name one clone among many — WKmini,
// home-built Arduinos, unbranded boxes — and leave every other operator guessing
// which of the two to pick for hardware that speaks exactly the same protocol.
func (a *App) keyerNeedsSlowBoot(port string) bool {
	if a.settings == nil || strings.TrimSpace(port) == "" {
		return false
	}
	v, _ := a.settings.GetGlobal(a.ctx, keyerSlowBootKey(port))
	return v == "1"
}

// noteKeyerSlowBoot records that this port's keyer needed the long wait.
func (a *App) noteKeyerSlowBoot(port string) {
	if a.settings == nil || strings.TrimSpace(port) == "" {
		return
	}
	_ = a.settings.SetGlobal(a.ctx, keyerSlowBootKey(port), "1")
	applog.Printf("winkeyer: %s remembered as a slow-booting keyer — it will open directly next time", port)
}

// WinkeyerConnect opens the serial link using the saved config.
func (a *App) WinkeyerConnect() error {
	if a.winkeyer == nil {
		return fmt.Errorf("winkeyer not initialized")
	}
	s, err := a.GetWinkeyerSettings()
	if err != nil {
		return err
	}
	cfg := s.Config
	// The "serial" engine keys CW on the port's DTR/RTS lines (SCU-17 style)
	// instead of talking the K1EL WinKeyer protocol — flag it for the manager.
	if s.Engine == "serial" {
		cfg.Type = "serial"
	}
	cfg.SlowBoot = a.keyerNeedsSlowBoot(cfg.Port)
	return a.winkeyer.Connect(cfg)
}

// WinkeyerDisconnect closes the serial link.
func (a *App) WinkeyerDisconnect() error {
	if a.winkeyer != nil {
		a.winkeyer.Disconnect()
	}
	return nil
}

// WinkeyerSend keys the (already variable-resolved) text as Morse.
func (a *App) WinkeyerSend(text string) error {
	if a.winkeyer == nil {
		return fmt.Errorf("winkeyer not initialized")
	}
	return a.winkeyer.Send(text)
}

// WinkeyerStop aborts the current message immediately.
func (a *App) WinkeyerStop() error {
	if a.winkeyer == nil {
		return fmt.Errorf("winkeyer not initialized")
	}
	return a.winkeyer.Stop()
}

// WinkeyerBackspace removes the last not-yet-keyed character (send-on-type).
func (a *App) WinkeyerBackspace() error {
	if a.winkeyer == nil {
		return fmt.Errorf("winkeyer not initialized")
	}
	return a.winkeyer.Backspace()
}

// WinkeyerSetSpeed changes the keying speed (WPM) live.
func (a *App) WinkeyerSetSpeed(wpm int) error {
	if a.winkeyer == nil {
		return fmt.Errorf("winkeyer not initialized")
	}
	return a.winkeyer.SetSpeed(wpm)
}

// GetWinkeyerStatus returns the current link status (used on mount).
func (a *App) GetWinkeyerStatus() winkeyer.Status {
	if a.winkeyer == nil {
		return winkeyer.Status{}
	}
	return a.winkeyer.Snapshot()
}

// defaultWKMacros mirrors the classic F-key set (CQ / answer / reports / 73).
func defaultWKMacros() []WKMacro {
	return []WKMacro{
		{Label: "CQ", Text: "CQ CQ DE <MY_CALL> <MY_CALL> K"},
		{Label: "His call", Text: "<CALL> "},
		{Label: "Report", Text: "<CALL> UR <STX> <STX> = "},
		{Label: "Answer", Text: "<CALL> DE <MY_CALL> TU UR <RST_R> = "},
		{Label: "Name/QTH", Text: "NAME <MY_NAME> QTH <MY_QTH> = "},
		{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
		{Label: "QRL?", Text: "QRL? "},
		{Label: "AGN", Text: "AGN "},
		{Label: "QRZ?", Text: "QRZ? DE <MY_CALL> "},
	}
}

func atoiInto(s string, dst *int) {
	if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
		*dst = n
	}
}

// --- DX Cluster bindings (multi-server) ---

// resolveClusterLogin returns the login callsign for a server: explicit
// override on the row, else the active profile's callsign.
func (a *App) resolveClusterLogin(override string) string {
	if override != "" {
		return strings.ToUpper(strings.TrimSpace(override))
	}
	if a.profiles != nil {
		if p, err := a.profiles.Active(a.ctx); err == nil {
			return strings.ToUpper(strings.TrimSpace(p.Callsign))
		}
	}
	return ""
}

// clusterAutoConnect reads the global "auto-connect on startup" toggle.
// Stored in settings (key/value) since it's a single bool, not per-row.
func (a *App) clusterAutoConnect() (bool, error) {
	if a.settings == nil {
		return false, fmt.Errorf("db not initialized")
	}
	v, err := a.settings.Get(a.ctx, keyClusterAutoConnect)
	if err != nil {
		return false, err
	}
	return v == "1", nil
}

// startAllEnabledClusters opens a session for every enabled server.
func (a *App) startAllEnabledClusters() {
	servers, err := a.listClusterServers()
	if err != nil {
		fmt.Println("OpsLog: list cluster servers:", err)
		return
	}
	for _, s := range servers {
		if s.Enabled {
			a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
		}
	}
}

// listClusterServers reads the cluster_servers table ordered for display
// (sort_order asc, id asc). The first row with Enabled=true is the master.
func (a *App) listClusterServers() ([]cluster.ServerConfig, error) {
	if a.db == nil {
		return nil, fmt.Errorf("db not initialized")
	}
	rows, err := a.db.QueryContext(a.ctx, `
		SELECT id, name, host, port, login_override, password, init_commands, enabled, sort_order
		FROM cluster_servers
		ORDER BY sort_order ASC, id ASC`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []cluster.ServerConfig
	for rows.Next() {
		var s cluster.ServerConfig
		var enabled int
		if err := rows.Scan(&s.ID, &s.Name, &s.Host, &s.Port, &s.LoginOverride,
			&s.Password, &s.InitCommands, &enabled, &s.SortOrder); err != nil {
			return nil, err
		}
		s.Enabled = enabled == 1
		out = append(out, s)
	}
	return out, rows.Err()
}

// ListClusterServers returns all saved cluster nodes.
func (a *App) ListClusterServers() ([]cluster.ServerConfig, error) {
	return a.listClusterServers()
}

// SaveClusterServer upserts one row. id=0 inserts a new server. Restarts
// the session if the row was already running (so config edits take effect
// immediately).
func (a *App) SaveClusterServer(s cluster.ServerConfig) (cluster.ServerConfig, error) {
	if a.db == nil {
		return cluster.ServerConfig{}, fmt.Errorf("db not initialized")
	}
	if strings.TrimSpace(s.Name) == "" {
		return cluster.ServerConfig{}, fmt.Errorf("server name required")
	}
	if s.Port <= 0 || s.Port > 65535 {
		s.Port = 7300
	}
	now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
	enabled := 0
	if s.Enabled {
		enabled = 1
	}
	if s.ID == 0 {
		res, err := a.db.ExecContext(a.ctx, `
			INSERT INTO cluster_servers
			    (name, host, port, login_override, password, init_commands, enabled, sort_order, created_at, updated_at)
			VALUES(?,?,?,?,?,?,?,?,?,?)`,
			s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, now)
		if err != nil {
			return cluster.ServerConfig{}, err
		}
		id, _ := res.LastInsertId()
		s.ID = id
	} else {
		_, err := a.db.ExecContext(a.ctx, `
			UPDATE cluster_servers SET name=?, host=?, port=?, login_override=?, password=?,
			    init_commands=?, enabled=?, sort_order=?, updated_at=?
			WHERE id=?`,
			s.Name, s.Host, s.Port, s.LoginOverride, s.Password, s.InitCommands, enabled, s.SortOrder, now, s.ID)
		if err != nil {
			return cluster.ServerConfig{}, err
		}
	}
	// Apply runtime change: stop and restart if enabled, else just stop.
	a.cluster.StopServer(s.ID)
	if s.Enabled {
		a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
	}
	return s, nil
}

// DeleteClusterServer drops a row and closes its session.
func (a *App) DeleteClusterServer(id int64) error {
	if a.db == nil {
		return fmt.Errorf("db not initialized")
	}
	a.cluster.StopServer(id)
	_, err := a.db.ExecContext(a.ctx, `DELETE FROM cluster_servers WHERE id=?`, id)
	return err
}

// SetClusterAutoConnect persists the global auto-connect toggle.
func (a *App) SetClusterAutoConnect(on bool) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	return a.settings.Set(a.ctx, keyClusterAutoConnect, boolStr(on))
}

// GetClusterAutoConnect reads the persisted toggle.
func (a *App) GetClusterAutoConnect() (bool, error) {
	return a.clusterAutoConnect()
}

// ── Self-spot ─────────────────────────────────────────────────────────────

// selfSpotMinMinutes is the shortest gap OpsLog will announce itself at.
//
// A self-spot is traffic every user of the node sees, so the floor is a
// courtesy rule, not a preference: at one spot per QSO a run would flood the
// cluster within minutes and get the station filtered out — which is worse for
// the operator than not spotting at all. Five minutes is the interval the
// DX-cluster etiquette guides give.
const selfSpotMinMinutes = 5

// selfSpotSameRunHz is how far the frequency may drift and still count as the
// same run. A run creeps a few hundred Hz as the operator nudges the VFO, and
// re-announcing on every nudge is exactly the flood the gap exists to prevent.
// Move further than this and it IS a new frequency, which is announced at once.
const selfSpotSameRunHz = 500

// SelfSpotSettings configures announcing the station on the DX cluster as it
// logs, so callers can find it without anyone else having to spot it.
type SelfSpotSettings struct {
	Enabled bool `json:"enabled"`
	Minutes int  `json:"minutes"` // shortest gap between two self-spots
}

// GetSelfSpotSettings reads the self-spot configuration.
func (a *App) GetSelfSpotSettings() (SelfSpotSettings, error) {
	out := SelfSpotSettings{Minutes: selfSpotMinMinutes}
	if a.settings == nil {
		return out, fmt.Errorf("db not initialized")
	}
	m, err := a.settings.GetMany(a.ctx, keyClusterSelfSpot, keyClusterSelfSpotMin)
	if err != nil {
		return out, err
	}
	out.Enabled = m[keyClusterSelfSpot] == "1"
	if n, _ := strconv.Atoi(m[keyClusterSelfSpotMin]); n >= selfSpotMinMinutes {
		out.Minutes = n
	}
	return out, nil
}

// SaveSelfSpotSettings persists it. The minimum gap is clamped here as well as
// in the UI: the floor protects the node from us, so it must not depend on a
// frontend that could be bypassed or carry a stale value.
func (a *App) SaveSelfSpotSettings(s SelfSpotSettings) error {
	if a.settings == nil {
		return fmt.Errorf("db not initialized")
	}
	if s.Minutes < selfSpotMinMinutes {
		s.Minutes = selfSpotMinMinutes
	}
	if err := a.settings.Set(a.ctx, keyClusterSelfSpot, boolStr(s.Enabled)); err != nil {
		return err
	}
	return a.settings.Set(a.ctx, keyClusterSelfSpotMin, strconv.Itoa(s.Minutes))
}

// maybeSelfSpot announces the station on the master cluster after a QSO, so a
// run is findable without waiting for someone else to spot it.
//
// The DX call and the spotter are both us: the node fills the "DE" from the
// login, and the QSO's own station callsign is the DX — which is the right
// source, not the profile, because that is the call the contact was made under.
//
// Fires on the FIRST QSO of a frequency, then at most once per configured gap.
// Called from both log paths (manual entry and UDP auto-log), off the critical
// path — a cluster that is slow or down must never hold up logging.
// spotCommentMax is the DX cluster comment field: 30 characters, and nodes
// silently drop what does not fit.
const spotCommentMax = 30

// selfSpotComment builds the comment for a spot of OUR OWN station: the mode,
// then the references WE are activating.
//
// MY_POTA_REF and friends, deliberately — not the QSO's award references, which
// belong to the station we worked. Spotting yourself with the other operator's
// park number announces you from a place you are not.
//
// A reference goes in whole or not at all: half a park number on the cluster is
// worse than none, since nobody can act on it.
func selfSpotComment(q qso.QSO) string {
	out := strings.ToUpper(strings.TrimSpace(q.Mode))
	for _, r := range []struct{ code, ref string }{
		{"POTA", q.MyPOTARef},
		{"SOTA", q.MySOTARef},
		{"WWFF", q.MyWWFFRef},
		{"IOTA", q.MyIOTA},
	} {
		ref := strings.ToUpper(strings.TrimSpace(r.ref))
		if ref == "" {
			continue
		}
		next := strings.TrimSpace(out + " " + r.code + " " + ref)
		if len(next) > spotCommentMax {
			continue // a shorter reference may still fit
		}
		out = next
	}
	return out
}

func (a *App) maybeSelfSpot(q qso.QSO) {
	cfg, err := a.GetSelfSpotSettings()
	if err != nil || !cfg.Enabled {
		return
	}
	call := strings.ToUpper(strings.TrimSpace(q.StationCallsign))
	if call == "" {
		return
	}
	// The QSO's frequency, not the rig's: by the time this runs the operator may
	// already have tuned away, and a spot has to say where the contact happened.
	var hz int64
	if q.FreqHz != nil {
		hz = *q.FreqHz
	}
	if hz <= 0 && a.cat != nil {
		hz = a.cat.State().FreqHz // no frequency on the QSO (paper-style entry) — fall back to CAT
	}
	if hz <= 0 {
		return
	}

	a.selfSpotMu.Lock()
	sameRun := a.selfSpotHz != 0 && absInt64(hz-a.selfSpotHz) <= selfSpotSameRunHz
	if sameRun && time.Since(a.selfSpotAt) < time.Duration(cfg.Minutes)*time.Minute {
		a.selfSpotMu.Unlock()
		return
	}
	prevAt, prevHz := a.selfSpotAt, a.selfSpotHz
	// Claim the slot BEFORE sending: the two log paths can run concurrently and
	// would otherwise both pass the check and spot twice.
	a.selfSpotAt, a.selfSpotHz = time.Now(), hz
	a.selfSpotMu.Unlock()

	khz := math.Round(float64(hz)/100) / 10 // 0.1 kHz — the resolution a cluster keeps
	if err := a.SendClusterSpot(call, khz, selfSpotComment(q)); err != nil {
		// Not sent (no enabled server, node not connected). Restore the previous
		// state so the NEXT QSO tries again, instead of sitting out a gap that
		// never produced a spot.
		a.selfSpotMu.Lock()
		a.selfSpotAt, a.selfSpotHz = prevAt, prevHz
		a.selfSpotMu.Unlock()
		applog.Printf("cluster: self-spot %s on %.1f kHz failed: %v", call, khz, err)
		return
	}
	applog.Printf("cluster: self-spot %s on %.1f kHz sent", call, khz)
}

func absInt64(n int64) int64 {
	if n < 0 {
		return -n
	}
	return n
}

// ConnectClusterServer opens a session for one specific saved server.
func (a *App) ConnectClusterServer(id int64) error {
	if a.cluster == nil {
		return fmt.Errorf("cluster not initialized")
	}
	servers, err := a.listClusterServers()
	if err != nil {
		return err
	}
	for _, s := range servers {
		if s.ID == id {
			if !s.Enabled {
				return fmt.Errorf("server %q is disabled — enable it first", s.Name)
			}
			a.cluster.StartServer(s, a.resolveClusterLogin(s.LoginOverride))
			return nil
		}
	}
	return fmt.Errorf("no saved server with id %d", id)
}

// DisconnectClusterServer closes the session for one server.
func (a *App) DisconnectClusterServer(id int64) error {
	if a.cluster == nil {
		return fmt.Errorf("cluster not initialized")
	}
	a.cluster.StopServer(id)
	return nil
}

// ConnectAllClusters opens sessions for every enabled server.
func (a *App) ConnectAllClusters() error {
	if a.cluster == nil {
		return fmt.Errorf("cluster not initialized")
	}
	a.startAllEnabledClusters()
	return nil
}

// DisconnectAllClusters closes every running session.
func (a *App) DisconnectAllClusters() error {
	if a.cluster == nil {
		return fmt.Errorf("cluster not initialized")
	}
	a.cluster.StopAll()
	return nil
}

// SendClusterCommand writes `cmd` to the **master** cluster — the first
// enabled server by sort_order. Returns an error if the master is not
// currently connected (the UI should grey the input out in that case).
func (a *App) SendClusterCommand(cmd string) error {
	if a.cluster == nil {
		return fmt.Errorf("cluster not initialized")
	}
	cmd = strings.TrimSpace(cmd)
	if cmd == "" {
		return fmt.Errorf("empty command")
	}
	srv, err := a.masterClusterServer()
	if err != nil {
		return err
	}
	return a.cluster.SendCommand(srv.ID, cmd)
}

// masterClusterServer returns the master node — the first ENABLED server in
// sort order, which is where commands and spots go.
func (a *App) masterClusterServer() (cluster.ServerConfig, error) {
	servers, err := a.listClusterServers()
	if err != nil {
		return cluster.ServerConfig{}, err
	}
	for _, s := range servers {
		if s.Enabled {
			return s, nil
		}
	}
	return cluster.ServerConfig{}, fmt.Errorf("no enabled cluster server to send to")
}

// SendClusterSpot announces a DX spot on the **master** cluster (first
// enabled server). Format is the universal DXSpider/AR-Cluster command
// `DX <freq_khz> <call> <comment>`. The frequency is taken in kHz; call is
// upper-cased; comment is optional (commonly the mode, e.g. "CW").
func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) error {
	call = strings.ToUpper(strings.TrimSpace(call))
	if call == "" {
		return fmt.Errorf("callsign required")
	}
	if freqKHz <= 0 {
		return fmt.Errorf("invalid frequency")
	}
	// Trim a trailing ".0" so integer kHz stay clean (14205 not 14205.0),
	// but keep sub-kHz precision when present (e.g. 10138.7).
	freqStr := strconv.FormatFloat(freqKHz, 'f', -1, 64)
	cmd := fmt.Sprintf("DX %s %s", freqStr, call)
	if c := strings.TrimSpace(comment); c != "" {
		cmd += " " + c
	}
	if a.cluster == nil {
		return fmt.Errorf("cluster not initialized")
	}
	srv, err := a.masterClusterServer()
	if err != nil {
		return err
	}
	applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
	if err := a.cluster.SendCommand(srv.ID, cmd); err != nil {
		return err
	}
	// Show it in OUR OWN spot list straight away. Most nodes never broadcast a
	// spot back to the station that sent it, so the operator saw nothing appear
	// and reported the spot as not sent — several times. It goes through the same
	// queue as a spot off the wire, so it gets the same DXCC/POTA enrichment,
	// alert evaluation and panadapter mirroring, and the UI de-dupes it against
	// the node's echo when there is one.
	sp := cluster.NewLocalSpot(srv, a.resolveClusterLogin(srv.LoginOverride), call, freqKHz, comment)
	a.enqueueClusterEvent(clusterEvent{spot: &sp})
	return nil
}

// GetClusterStatus returns a snapshot of every active session. Used by
// the UI on mount and to hydrate after a `cluster:state` event.
func (a *App) GetClusterStatus() []cluster.ServerStatus {
	if a.cluster == nil {
		return nil
	}
	return a.cluster.Status()
}

// SpotQuery is one (call, band, mode) tuple sent for status colouring.
type SpotQuery struct {
	Call    string `json:"call"`
	Band    string `json:"band"`
	Mode    string `json:"mode"`
	POTARef string `json:"pota_ref,omitempty"` // park id if the spot is a POTA activation
	// Spotter is the station that sent the spot. Only its continent is wanted, and
	// resolving it here rather than in the frontend keeps the one DXCC prefix
	// table as the single authority on what continent a callsign is in.
	Spotter string `json:"spotter,omitempty"`
}

// SpotStatus is the per-tuple result. Status is one of:
//
//	"new"            — entity never worked
//	"new-band-mode"  — entity worked, but neither this band NOR this mode
//	"new-band"       — entity worked in this mode, never on this band
//	"new-mode"       — band worked, but this MODE never worked on the entity (any band)
//	"new-slot"       — band & mode each worked before, but not this band+mode together
//	"worked"         — exact band+mode already in the log
//	""               — couldn't resolve the entity (no cty.dat match)
//
// They are EXCLUSIVE and tested in that order, from most wanted to least. The
// distinction that matters and used to be missing: an entity that is new on
// both counts is not the same catch as one that is new on the band alone, and
// neither is a "slot" — a slot is the pair still missing when each half is
// already in the log.
type SpotStatus struct {
	Call      string `json:"call"`
	Band      string `json:"band"`
	Mode      string `json:"mode"`
	Country   string `json:"country,omitempty"`
	Continent string `json:"continent,omitempty"`
	Status    string `json:"status"`
	// WorkedCall is true when this exact callsign exists in the log
	// (any band, any mode). Drives the per-call text highlight, in
	// addition to the entity-level Status (NEW / NEW BAND / …).
	WorkedCall bool `json:"worked_call"`
	// NewCounty/NewPOTA are ORTHOGONAL to Status: a station already worked for
	// its DXCC entity can still be a never-worked US county or POTA park. County
	// is resolved from the callsign via the offline ULS store (US only, and only
	// when that database has been downloaded); POTA from the spot's tagged park.
	NewCounty bool `json:"new_county"`
	// County and State are that resolved county, so the cluster can show it as a
	// column instead of only flagging it. Free: the ULS lookup that decides
	// NewCounty already has them in hand.
	County string `json:"county,omitempty"`
	State  string `json:"state,omitempty"`
	// NewState says this US state has never been worked — the WAS gap the
	// decode panel's filter chases. Orthogonal to Status, like NewCounty.
	NewState bool `json:"new_state"`
	// The Unconf flags say the matching need exists ONLY because the contact
	// was never confirmed (under the operator's chosen QSL sources): a QSL to
	// chase, not a QSO to make. The badges render them dashed, the grid way.
	UnconfStatus bool `json:"unconf_status,omitempty"`
	UnconfPfx    bool `json:"unconf_pfx,omitempty"`
	UnconfCty    bool `json:"unconf_cty,omitempty"`
	UnconfState  bool `json:"unconf_state,omitempty"`
	NewPOTA      bool `json:"new_pota"`
	// Grid is the 4-character square this station announced in a CQ on the UDP
	// link, and NewGrid says that square has never been worked. Both are empty /
	// false for any station this receiver has not decoded — a DX-cluster line
	// carries the SPOTTER's grid at best, never the DX's.
	Grid    string `json:"grid,omitempty"`
	NewGrid bool   `json:"new_grid"`
	// GridState splits NewGrid into its two meanings: "new" (never worked under
	// the operator's scope) and "unconf" (worked, awaiting a confirmation). Empty
	// when there is nothing to chase. They are different decisions — a QSO to
	// make against a QSL to chase — and one badge for both is what made a station
	// worked an hour ago still read as NEW GRID.
	GridState string `json:"grid_state,omitempty"`
	// SpotterContinent is the continent of the station that SENT the spot, not of
	// the DX. It answers a different question — "is anyone near me hearing this?"
	// — which is what makes it worth filtering on: a JA spot on 20 m tells a
	// European very little about their own path.
	SpotterContinent string `json:"spotter_continent,omitempty"`
	// LoTW is true when the DX callsign appears in ARRL's user-activity list, so
	// an operator chasing confirmations can skip the stations that will never
	// upload. Inert until that list has been downloaded.
	LoTW bool `json:"lotw"`
	// NewPfx flags a CQ WPX prefix never worked before, and Pfx is that prefix.
	// Also orthogonal: a common entity on a worked band can still carry a prefix
	// that has never been in the log, which is exactly what a WPX chaser is
	// scanning the cluster for.
	NewPfx bool   `json:"new_pfx"`
	Pfx    string `json:"pfx,omitempty"`
	// WorkedSlot: this exact callsign already worked on THIS band and mode.
	// Distinct from WorkedCall, which follows the "same slot" preference and so
	// means different things depending on it. This one is always slot-scoped, so
	// the UI can highlight what is still to be worked here without the two
	// options having to agree. Only filled when the slot index is built.
	WorkedSlot bool `json:"worked_slot"`
}

// clusterStatusCache holds the whole-logbook maps ClusterSpotStatuses colours
// spots against. Rebuilding them per spot batch re-scanned the entire logbook
// ~20×/second under an RBN firehose — the dominant CPU cost on a large log. This
// is an immutable snapshot: once built its maps are never mutated, so a batch
// that already holds the pointer keeps reading valid (stale-by-one-log) data
// while a newer snapshot is being built. See clusterStatusMaps.
type clusterStatusCache struct {
	entities           map[int]*qso.EntitySlot
	workedCalls        map[string]struct{}
	workedCallSlots    map[string]struct{}
	workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on
	workedCounties     map[string]struct{}
	workedStates       map[string]struct{}
	// The CONFIRMED-only ledgers, built when the global hunt is
	// "new + unconfirmed": verdicts are judged against these, and a need that
	// exists only because a contact was never confirmed is flagged Unconf so
	// its badge can say "a QSL to chase", not "a QSO to make".
	chaseUnconf        bool
	chaseKey           string
	entitiesConf       map[int]*qso.EntitySlot
	workedPfxConf      map[string]struct{}
	workedCountiesConf map[string]struct{}
	workedStatesConf   map[string]struct{}
	// callCounties holds callsign → "STATE,County" for stations already logged
	// with a county, so a spot shows the county the entry panel showed rather
	// than the one derived from the licence ZIP. See qso.CallCounties.
	callCounties map[string]string
	workedPOTA   map[string]struct{}
	workedPfx    map[string]struct{}
	// workedGrids answers "is this square already mine" under EVERY band-and-mode
	// scope, keyed by qso.GridKey and valued by whether the square is confirmed.
	// One index for all six scopes and both hunting modes: the operator switches
	// between them freely, and rebuilding the log index on each toggle would make
	// a dropdown take seconds.
	workedGrids   map[string]bool
	gridScope     gridScope           // the scope and hunt the cache was built under —
	gridHunt      string              // a change to either rebuilds it
	normMode      func(string) string // nil unless digital-mode grouping is on
	groupDigital  bool                // settings the maps were built under —
	sameSlot      bool                // a change rebuilds the snapshot
	slotHighlight bool                // (same: the slot index is built for either)
}

// ChaseSettings is the global hunt: does "worked but never confirmed" still
// count as something to chase, and which QSL systems count as confirmation.
type ChaseSettings struct {
	Mode    string   `json:"mode"`    // "new" | "new_unconfirmed"
	Sources []string `json:"sources"` // subset of lotw,card,eqsl,qrz
}

// GetChaseSettings returns the global hunt settings.
func (a *App) GetChaseSettings() ChaseSettings {
	mode := a.settingOr(keyChaseMode, "new")
	if mode != "new_unconfirmed" {
		mode = "new"
	}
	csv := a.settingOr(keyChaseConfirm, "lotw,card,eqsl")
	var src []string
	for _, part := range strings.Split(csv, ",") {
		if part = strings.TrimSpace(part); part != "" {
			src = append(src, part)
		}
	}
	return ChaseSettings{Mode: mode, Sources: src}
}

// SaveChaseSettings stores them and drops the status snapshot so the next
// batch is judged under the new rules.
func (a *App) SaveChaseSettings(cs ChaseSettings) error {
	mode := cs.Mode
	if mode != "new_unconfirmed" {
		mode = "new"
	}
	a.setSetting(keyChaseMode, mode)
	a.setSetting(keyChaseConfirm, strings.Join(cs.Sources, ","))
	a.clusterStatusMu.Lock()
	a.clusterStatusIdx = nil
	a.clusterStatusMu.Unlock()
	// Chase new judges each decode AS IT ARRIVES and keeps only what was new at
	// that moment, so its list is a set of verdicts reached under the old rule —
	// and nothing in it would ever be re-judged. Switching to "new only" left
	// rows listed that are merely unconfirmed; switching the other way could not
	// bring back what had already been refused. Emptied, it refills from the
	// live feed within a period or two, under the rule now in force.
	if a.chaseNew != nil {
		a.chaseNew.clear()
	}
	// The frontend caches resolved verdicts and only asks about unknown keys —
	// without this it kept judging half the screen under the OLD rules until a
	// restart, which read as the new mode simply not working.
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
	}
	return nil
}

// clusterStatusMaps returns the cached worked-index snapshot, building it once
// per logbook change (invalidated by invalidateAwardStats) or when a setting
// that shapes the maps flips. This turns the per-batch full-logbook scans into
// one scan per logged QSO — the fix for the RBN-firehose CPU pegging.
func (a *App) clusterStatusMaps() *clusterStatusCache {
	groupDigital := a.groupDigitalSlots()
	sameSlot := a.clusterWorkedSameSlot()
	slotHighlight := a.clusterSlotHighlight()
	// Read BEFORE taking the lock: settingOr goes to the settings store, and
	// holding the status mutex across that is how two subsystems deadlock.
	gs := a.GetGridScopeSettings()
	gridScopeNow := lookupGridScope(gs.Scope)
	// The grid hunt FOLLOWS the global chase mode now — the per-grid selector
	// grew into the global option, and two switches for one idea is one too
	// many. (Same vocabulary: "new" / "new_unconfirmed".)
	chase := a.GetChaseSettings()
	gridHuntNow := chase.Mode
	chaseKeyNow := chase.Mode + "|" + strings.Join(chase.Sources, ",")
	a.clusterStatusMu.Lock()
	defer a.clusterStatusMu.Unlock()
	if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot &&
		c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow &&
		c.chaseKey == chaseKeyNow {
		return c
	}
	c := &clusterStatusCache{
		groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
		gridScope: gridScopeNow, gridHunt: gridHuntNow,
		chaseUnconf: chase.Mode == "new_unconfirmed", chaseKey: chaseKeyNow,
	}
	if a.qso == nil {
		a.clusterStatusIdx = c
		return c
	}
	// Optional digital-mode grouping (Settings → General): with it on, FT8/FT4/
	// RTTY… all count as ONE "DIG" mode, so an FT4 spot on a band where FT8 was
	// worked shows "worked", not "new-slot" — DXCC-style mode classes.
	if groupDigital {
		c.normMode = qso.GroupDigitalMode
	}
	// Compare by DXCC entity NUMBER, not name. For each logged QSO the key is
	// its stored DXCC if present (the authoritative value set at log time, incl.
	// ClubLog date exceptions), else the stored country resolved to a number,
	// else the cty.dat prefix lookup. This fixes false NEWs where cty.dat
	// re-resolves a logged callsign to a different entity than how it was logged
	// (e.g. VK2/SP9FIH logged as Lord Howe Island, but its prefix is Australia —
	// so a VJ2L Lord Howe spot must still count as worked).
	keyFor := func(call string, storedDXCC int, country string) int {
		if storedDXCC > 0 {
			return storedDXCC
		}
		if n := dxcc.EntityDXCC(country); n > 0 {
			return n
		}
		if a.dxcc != nil {
			if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
				return dxcc.EntityDXCC(m.Entity.Name)
			}
		}
		return 0
	}
	c.entities, _ = a.qso.EntitySlotMap(a.ctx, keyFor, c.normMode)
	// Per-call worked set — separate from the entity check so we can flag
	// "I've already QSO'd this exact station" even when the band/mode
	// makes the entity check say "new-band" or "new-slot".
	c.workedCalls, _ = a.qso.WorkedCallsigns(a.ctx)
	// "Already worked only on the same slot" option (Settings → DX Cluster): the
	// WORKED-call flag then needs the SAME band and mode (digital-grouped through
	// the same normMode when that option is on) rather than the call anywhere.
	// Built UNCONDITIONALLY since the watchlist arrived: its per-slot verdict
	// needs this set whatever the cluster's own display options say, and the
	// scan already runs at cache-rebuild cadence, not per batch.
	c.workedCallSlots, _ = a.qso.WorkedCallSlotKeys(a.ctx, c.normMode)
	// And once more at digital-CLASS grain, for the spots whose mode the band
	// plan can only call "DATA": a generic mode cannot be matched exactly, so it
	// is judged against "any digital mode of this call on this band". When the
	// grouping option is on, workedCallSlots is already at that grain.
	if c.normMode != nil {
		c.workedCallSlotsDig = c.workedCallSlots
	} else {
		c.workedCallSlotsDig, _ = a.qso.WorkedCallSlotKeys(a.ctx, qso.GroupDigitalMode)
	}
	// Orthogonal dimensions: worked US counties (for the ULS callsign→county
	// lookup) and worked POTA parks.
	c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
	c.workedStates, _ = a.qso.WorkedStateKeys(a.ctx)
	c.callCounties, _ = a.qso.CallCounties(a.ctx)
	c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
	// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
	// per spot — the same shape as the county and POTA sets beside it, which is
	// why grids cost nothing under an RBN firehose.
	c.workedGrids, _ = a.qso.GridWorkedIndex(a.ctx, gridClassesOf)
	// Worked WPX prefixes, derived from the callsigns we already loaded — no
	// extra query. Derived rather than read from the stored PFX column: that
	// column is only filled when an import supplied it, and deriving keeps this
	// in step with the WPX award, which does the same thing.
	if c.chaseUnconf {
		pred := qso.ConfirmSourcesPredicate(chase.Sources)
		c.entitiesConf, _ = a.qso.EntitySlotMapPred(a.ctx, keyFor, c.normMode, pred)
		c.workedCountiesConf, _ = a.qso.WorkedCountyKeysPred(a.ctx, award.USCountyKey, pred)
		c.workedStatesConf, _ = a.qso.WorkedStateKeysPred(a.ctx, pred)
		confCalls, _ := a.qso.WorkedCallsignsPred(a.ctx, pred)
		c.workedPfxConf = make(map[string]struct{}, len(confCalls))
		for call := range confCalls {
			if pfx := award.WPXPrefix(call); pfx != "" {
				c.workedPfxConf[pfx] = struct{}{}
			}
		}
	}
	c.workedPfx = make(map[string]struct{}, len(c.workedCalls))
	for call := range c.workedCalls {
		if p := award.WPXPrefix(call); p != "" {
			c.workedPfx[p] = struct{}{}
		}
	}
	a.clusterStatusIdx = c
	return c
}

// ClusterSpotStatuses takes a batch of spots and returns slot status for
// each. Used by the Cluster tab to color rows (NEW / NEW BAND / NEW SLOT
// / WORKED). Reads the cached worked-index snapshot (clusterStatusMaps) so a
// spot batch never re-scans the logbook — critical under an RBN firehose.
//
// Mode handling: when the caller passes an empty Mode (cluster comment
// was ambiguous and the frontend couldn't infer) we degrade gracefully
// to band-only — saying "worked" rather than wrongly flagging "new-slot"
// just because we don't know the mode.
// decodeGridsCap is how many callsigns one generation holds before it rotates.
//
// 100 000 measured at 82 bytes an entry — 8 MB a generation, 16 MB for both,
// which is nothing next to what it buys: a locator on a spot instead of an empty
// column. The old 20 000 was chosen when the only feed was this station's own
// decodes and the ceiling was never reached anyway.
const decodeGridsCap = 100000

// GetChaseNewGrids reports whether learnt locators are kept across restarts.
func (a *App) GetChaseNewGrids() bool { return a.settingOr(keyChaseNewGrids, "") == "1" }

// GridCacheStatus is the live count under the option. A store that is on but
// has learnt nothing looks exactly like a broken one until a number moves.
type GridCacheStatus struct {
	Enabled bool `json:"enabled"`
	Known   int  `json:"known"`   // locators in memory, stored + learnt this session
	Pending int  `json:"pending"` // waiting for the next batch write
}

// AnswerDecode tells the decoding application to call a station — the same
// thing as double-clicking the line in WSJT-X's own Band Activity window.
//
// This is not something OpsLog can do by tuning the radio. On FT8 the whole band
// sits inside one passband, so moving the dial changes nothing about who gets
// answered: the decision belongs to WSJT-X/MSHV, and the Reply message is the
// only way to hand it over. The panel therefore does NOT retune the rig on a
// click, which would only fight the digital application for the VFO.
//
// Every argument replays the decode as it arrived, because the target matches it
// against its own decode list and ignores anything it cannot find.
func (a *App) AnswerDecode(instance string, ms uint32, snr int, dt float64, audioHz int64, mode, msg string, lowConf bool) error {
	if a.udp == nil {
		return fmt.Errorf("udp not initialized")
	}
	err := a.udp.SendReply(udp.Reply{
		ProgramID:     instance,
		MsSinceMidnig: ms,
		SNR:           int32(snr),
		DeltaTime:     dt,
		DeltaFreqHz:   uint32(audioHz),
		Mode:          mode,
		Message:       msg,
		LowConfidence: lowConf,
	})
	if err != nil {
		applog.Printf("udp: answer decode %q on %q failed: %v", msg, instance, err)
	}
	return err
}

// HaltDecodeTx tells the decoding application to stop transmitting — the same
// as its own Halt Tx button.
//
// autoTxOnly false stops the transmission mid-over; true lets the current over
// finish and only then disables auto-Tx. Both are real operator intentions: the
// first is "I called the wrong station", the second is "this QSO is done".
//
// instance may be empty, in which case the halt goes to whichever application
// last reported that it is transmitting — with one receiver, which is the
// normal case, that is simply the one running.
func (a *App) HaltDecodeTx(instance string, autoTxOnly bool) error {
	if a.udp == nil {
		return fmt.Errorf("udp not initialized")
	}
	if strings.TrimSpace(instance) == "" {
		instance = a.lastTxInstance()
	}
	err := a.udp.SendHaltTx(instance, autoTxOnly)
	if err != nil {
		applog.Printf("udp: halt tx on %q failed: %v", instance, err)
	}
	return err
}

// lastTxInstance is the program id of the last decoding application heard from,
// or "" if none has reported yet.
func (a *App) lastTxInstance() string {
	s, _ := a.lastTxID.Load().(string)
	return s
}

// GetGridCacheStatus reports what the locator store holds.
func (a *App) GetGridCacheStatus() GridCacheStatus {
	out := GridCacheStatus{Enabled: a.gridStore != nil}
	a.decodeGridsMu.RLock()
	out.Known = len(a.decodeGrids) + len(a.decodeGridsOld)
	a.decodeGridsMu.RUnlock()
	if a.gridStore != nil {
		out.Pending = a.gridStore.Pending()
	}
	return out
}

// SetChaseNewGrids turns grid chasing on or off and applies it immediately.
func (a *App) SetChaseNewGrids(on bool) error {
	a.setSetting(keyChaseNewGrids, boolStr(on))
	a.startGridCache()
	// Resubscribe: with grid chasing the feed covers every band, without it only
	// the opening bands — and with neither consumer it comes down entirely.
	a.startBandOpenFeed()
	return nil
}

// startGridCache brings the locator store up or down to match the setting.
//
// Switching it ON seeds the in-memory map from the database, which is the whole
// point: the cluster's locator column is populated in the first second instead
// of after an hour of listening. Switching it OFF closes the file and leaves the
// map alone — what has already been learnt this session stays usable, it simply
// stops being remembered. Nothing is deleted: turning the option back on picks
// up where it left off.
func (a *App) startGridCache() {
	if a.gridStore != nil {
		if err := a.gridStore.Close(); err != nil {
			applog.Printf("gridcache: close: %v", err)
		}
		a.gridStore = nil
	}
	if !a.GetChaseNewGrids() {
		return
	}
	path := filepath.Join(a.dataDir, "grids.db")
	st, err := gridcache.Open(path, applog.Printf)
	if err != nil {
		applog.Printf("gridcache: disabled — %v", err)
		return
	}
	seed, err := st.LoadAll()
	if err != nil {
		applog.Printf("gridcache: cannot read %s (%v) — starting empty", path, err)
		seed = map[string]string{}
	}
	a.decodeGridsMu.Lock()
	if a.decodeGrids == nil {
		a.decodeGrids = make(map[string]string, len(seed)+512)
	}
	// Seed UNDER what this session already heard: a locator decoded a minute ago
	// is newer than one stored days back, and the newest report is the one that
	// counts when a station has moved.
	for call, grid := range seed {
		if _, live := a.decodeGrids[call]; !live {
			a.decodeGrids[call] = grid
		}
	}
	n := len(a.decodeGrids)
	a.decodeGridsMu.Unlock()

	a.gridStore = st
	st.Start(a.ctx)
	applog.Printf("gridcache: grid chasing on — %d locators loaded from %s (%d known in total)", len(seed), path, n)
}

// rememberDecodeGrid records the grid a station announced.
//
// Rotation, not eviction: when the current generation fills it becomes the
// previous one and a fresh map takes over. Nothing is scanned, nothing is
// timestamped, and the write stays a single map assignment — which matters
// because this runs once per decode.
func (a *App) rememberDecodeGrid(call, grid, source string) {
	call = strings.ToUpper(strings.TrimSpace(call))
	grid = strings.TrimSpace(grid)
	if call == "" || grid == "" {
		return
	}
	store := a.gridStore

	a.decodeGridsMu.Lock()
	if a.decodeGrids == nil {
		a.decodeGrids = make(map[string]string, 512)
	}
	// Rotate only while nothing is persisting the map. With a store the cap would
	// discard callsigns the database still holds, so the lookup would miss what
	// we know; age in the store is the bound instead.
	if store == nil && len(a.decodeGrids) >= decodeGridsCap {
		a.decodeGridsOld = a.decodeGrids
		a.decodeGrids = make(map[string]string, 512)
	}
	// Only a CHANGE is worth writing. The feeds repeat themselves — the same
	// station is reported by dozens of receivers a minute — so queueing every
	// report would put the whole stream in the batch instead of the news in it.
	changed := a.decodeGrids[call] != grid
	if changed && a.decodeGridsOld != nil && a.decodeGridsOld[call] == grid {
		// Known already, just in the older generation: promote it without calling
		// it news.
		changed = false
	}
	a.decodeGrids[call] = grid
	a.decodeGridsMu.Unlock()

	if changed && store != nil {
		store.Put(call, grid, source)
	}
}

// lookupDecodeGrid returns the last grid heard for a callsign, "" if unknown.
// The previous generation is consulted second, so a station that has gone quiet
// keeps its locator across one rotation instead of losing it at the cliff.
func (a *App) lookupDecodeGrid(call string) string {
	call = strings.ToUpper(call)
	a.decodeGridsMu.RLock()
	defer a.decodeGridsMu.RUnlock()
	if g := a.decodeGrids[call]; g != "" {
		return g
	}
	return a.decodeGridsOld[call]
}

// spotEntityStatus decides ONE spot's status from what the entity already has
// in the log. Pure, and separate from the query that feeds it, because the
// ORDER of these tests is the whole meaning of the answer and it deserves to be
// read — and tested — without a database.
//
// Most wanted first, exclusive:
//
//	new            the entity has never been worked at all
//	new-band-mode  worked, but neither this band nor this mode
//	new-band       worked in this mode, never on this band
//	new-mode       worked on this band, never in this mode (on any band)
//	new-slot       both halves already in the log, never this pair
//	worked         this exact band and mode
//
// new-band-mode is NOT a slot, and that is the distinction that was missing:
// a slot is the pair still missing when each half is already worked. A station
// that is new on both counts is a better catch than either, and it used to be
// reported as a plain "new band".
func spotEntityStatus(entityWorked, haveBand, haveMode, haveSlot bool, mode string) string {
	if !entityWorked {
		return "new"
	}
	// Without a mode nothing below "band" can be told apart from worked, and
	// claiming "new" on a guess is the one mistake that costs a QSO.
	if mode == "" {
		if !haveBand {
			return "new-band"
		}
		return "worked"
	}
	switch {
	case !haveBand && !haveMode:
		return "new-band-mode"
	case !haveBand:
		return "new-band"
	case !haveMode:
		return "new-mode"
	case !haveSlot:
		return "new-slot"
	}
	return "worked"
}

// spotStatusName resolves ONE spot to the palette key it should be drawn with.
//
// The entity status first — it is the headline — then the orthogonal markers,
// which only speak when the entity says nothing new: a park is worth a colour
// on a station you have already worked, and worth nothing on one you have not,
// where "new entity" is the better thing to say.
func (a *App) spotStatusName(call, band, mode, potaRef string) string {
	if a.qso == nil {
		return "none"
	}
	st := a.ClusterSpotStatuses([]SpotQuery{{Call: call, Band: band, Mode: mode, POTARef: potaRef}})
	if len(st) == 0 {
		return "none"
	}
	r := st[0]
	if a.profiles != nil {
		if p, err := a.profiles.Active(a.ctx); err == nil &&
			strings.EqualFold(strings.TrimSpace(p.Callsign), strings.TrimSpace(call)) {
			return "my-call"
		}
	}
	switch r.Status {
	case "new", "new-band-mode", "new-band", "new-mode", "new-slot":
		return r.Status
	}
	switch {
	case r.NewPOTA:
		return "new-pota"
	case r.NewCounty:
		return "new-county"
	case r.NewPfx:
		return "new-pfx"
	}
	// WORKED means this CALLSIGN is in the log — the question an operator asks
	// of a spot ("have I had this station?"), and the one DXHunter answers.
	// The entity-level statuses above already say what the ENTITY is worth; an
	// entity worked here whose callsign is new is not "worked", it is simply no
	// news, which is what "none" means.
	if r.WorkedCall {
		return "worked"
	}
	return "none"
}

func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
	out := make([]SpotStatus, len(spots))
	if a.qso == nil {
		return out
	}
	idx := a.clusterStatusMaps()
	entities := idx.entities
	workedCalls := idx.workedCalls
	workedCallSlots := idx.workedCallSlots
	workedCounties := idx.workedCounties
	workedPOTA := idx.workedPOTA
	workedPfx := idx.workedPfx
	// The hunt decides the ledger: "new + unconfirmed" judges every category
	// against the CONFIRMED sets, and the all-QSO sets then say whether a need
	// is a fresh one or a missing QSL (the Unconf flags).
	judgeEntities, judgePfx, judgeCounties, judgeStates := entities, workedPfx, workedCounties, idx.workedStates
	if idx.chaseUnconf {
		judgeEntities, judgePfx, judgeCounties, judgeStates = idx.entitiesConf, idx.workedPfxConf, idx.workedCountiesConf, idx.workedStatesConf
	}
	normMode := idx.normMode
	sameSlot := idx.sameSlot
	for i, q := range spots {
		out[i] = SpotStatus{
			Call: q.Call,
			Band: strings.ToLower(q.Band),
			Mode: strings.ToUpper(q.Mode),
		}
		// Slot-scoped worked flag, independent of the sameSlot preference: it is
		// what "highlight what I have NOT worked here" reads, and that must not
		// change meaning because a different option was toggled.
		if workedCallSlots != nil {
			upCall := strings.ToUpper(q.Call)
			cm := out[i].Mode
			if normMode != nil && cm != "" {
				cm = normMode(cm)
			}
			if cm == "" {
				_, out[i].WorkedSlot = workedCallSlots[upCall+"|"+out[i].Band]
			} else {
				_, out[i].WorkedSlot = workedCallSlots[upCall+"|"+out[i].Band+"|"+cm]
			}
		}
		if sameSlot {
			// Already worked ONLY when this exact band+mode slot was worked. With no
			// inferable mode, fall back to same-band (better than claiming the whole
			// call is fresh). Digital grouping folds through the same normMode.
			upCall := strings.ToUpper(q.Call)
			if out[i].Mode == "" {
				_, out[i].WorkedCall = workedCallSlots[upCall+"|"+out[i].Band]
			} else {
				cm := out[i].Mode
				if normMode != nil {
					cm = normMode(cm)
				}
				_, out[i].WorkedCall = workedCallSlots[upCall+"|"+out[i].Band+"|"+cm]
			}
		} else if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
			out[i].WorkedCall = true
		}
		// NEW PFX: the spot's CQ WPX prefix, never worked before.
		if p := award.WPXPrefix(q.Call); p != "" {
			out[i].Pfx = p
			if _, done := judgePfx[p]; !done {
				out[i].NewPfx = true
				if _, everWorked := workedPfx[p]; everWorked {
					out[i].UnconfPfx = true
				}
			}
		}
		// NEW POTA: the spot's tagged park, never worked before.
		if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
			if _, done := workedPOTA[ref]; !done {
				out[i].NewPOTA = true
			}
		}
		// The spotter's continent, and whether the DX uploads to LoTW. Both are
		// in-memory lookups on tables already loaded, so they add nothing per spot.
		//
		// NOTE: the cluster list no longer filters on THIS copy — it is keyed by
		// call|band|mode, so every spotter of one call shares it. The filter reads
		// the spot's own SpotterContinent instead. Kept because the field is part
		// of the published status shape.
		if a.dxcc != nil && q.Spotter != "" {
			if sp := skimmerBase(q.Spotter); sp != "" {
				if m, ok := a.dxcc.Lookup(sp); ok {
					out[i].SpotterContinent = m.Continent
				}
			}
		}
		if a.lotwUsers != nil {
			out[i].LoTW = a.lotwUsers.Lookup(q.Call).IsUser
		}
		// NEW GRID: the square this station announced on the UDP decode feed.
		//
		// This runs for CLUSTER spots as well as decodes — one function serves
		// both — but a cluster line never carries the DX's grid, only the
		// spotter's. So a spot is judged here exactly when the same callsign has
		// also been heard on the decode feed, and stays unjudged otherwise. That
		// is the honest answer: no grid known is not the same as no grid needed.
		{
			// Read through the accessor: the decode goroutine swaps these maps on
			// rotation, so touching them unguarded is a race on the map header,
			// not a cheap fast path.
			g := a.lookupDecodeGrid(q.Call)
			if g != "" {
				out[i].Grid = g
				// The scope decides whether the band and the exact mode matter,
				// and the hunt whether an unconfirmed square still counts as
				// wanted. Both are the operator's settings — see gridscope.go.
				// Two states, not one: never worked is a QSO to make, worked but
				// unconfirmed is a QSL to chase. NewGrid stays the yes/no the
				// filters read; GridState says WHICH, so the badge can differ.
				out[i].GridState = gridStateFor(idx.workedGrids, idx.gridScope, idx.gridHunt, g, out[i].Band, out[i].Mode)
				out[i].NewGrid = out[i].GridState != ""
			}
		}
		// NEW COUNTY. Two sources, better one first:
		//
		//  1. the county this station was logged with — a callbook's answer,
		//     the same one the entry panel shows;
		//  2. the offline ULS store, which derives a county from the licence
		//     ZIP (US only; inert until downloaded, ~98% for fixed stations).
		//
		// Preferring the log is what keeps the cluster column and the entry
		// panel from disagreeing about the same callsign, which was impossible
		// to explain and made both look wrong.
		if cnty, ok := idx.callCounties[q.Call]; ok {
			st, name, _ := strings.Cut(cnty, ",")
			out[i].State, out[i].County = st, name
			if st != "" {
				if _, done := judgeStates[strings.ToUpper(st)]; !done {
					out[i].NewState = true
					if _, ever := idx.workedStates[strings.ToUpper(st)]; ever {
						out[i].UnconfState = true
					}
				}
			}
			if key := award.USCountyKey(st, name); key != "" {
				if _, done := judgeCounties[key]; !done {
					out[i].NewCounty = true
					if _, ever := workedCounties[key]; ever {
						out[i].UnconfCty = true
					}
				}
			}
		} else if a.uls != nil {
			if loc, ok := a.uls.Resolve(q.Call); ok {
				out[i].County, out[i].State = loc.County, loc.State
				if loc.State != "" {
					if _, done := judgeStates[strings.ToUpper(loc.State)]; !done {
						out[i].NewState = true
						if _, ever := idx.workedStates[strings.ToUpper(loc.State)]; ever {
							out[i].UnconfState = true
						}
					}
				}
				if key := award.USCountyKey(loc.State, loc.County); key != "" {
					if _, done := judgeCounties[key]; !done {
						out[i].NewCounty = true
						if _, ever := workedCounties[key]; ever {
							out[i].UnconfCty = true
						}
					}
				}
			}
		}
		if a.dxcc == nil {
			continue
		}
		// Say WHY a maritime/aeronautical mobile has no entity, rather than
		// leaving the column blank — blank is what an unresolved spot looks like,
		// and the two mean opposite things: one is "we don't know yet", this is
		// "there is nothing to know, the station is at sea". Display only: the
		// QSO's own Country stays empty, because it is a DXCC entity name and
		// there is none.
		if dxcc.IsMobileNoEntity(q.Call) {
			out[i].Country = "Maritime mobile — no DXCC"
			continue
		}
		m, ok := a.dxcc.Lookup(q.Call)
		if !ok || m.Entity == nil {
			continue
		}
		out[i].Country = m.Entity.Name
		out[i].Continent = m.Continent
		dxccNum := dxcc.EntityDXCC(m.Entity.Name)
		if dxccNum == 0 {
			continue // can't resolve the spot's entity number → don't guess
		}
		e, worked := judgeEntities[dxccNum]
		if !worked {
			out[i].Status = "new"
			if _, ever := entities[dxccNum]; ever {
				// Worked, never confirmed — and if it was THIS station on THIS
				// slot, the need is not its to answer (see the rule below).
				if out[i].WorkedSlot {
					out[i].Status = "worked"
					continue
				}
				out[i].UnconfStatus = true
			}
			continue
		}
		// The check mode goes through the same normaliser as the slot map
		// (digital grouping), and is resolved before the band test because
		// "new band" and "new mode" are now asked TOGETHER.
		checkMode := out[i].Mode
		if normMode != nil {
			checkMode = normMode(checkMode)
		}
		_, haveBand := e.Bands[out[i].Band]
		_, haveMode := e.Modes[checkMode]

		_, haveSlot := e.Slots[out[i].Band][checkMode]
		out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode)
		// A need judged against the confirmed ledger that the all-QSO ledger
		// says was already worked is a missing QSL, and its badge should read
		// as one. Judged at the SAME grain as the status itself.
		switch out[i].Status {
		case "new-band-mode", "new-band", "new-mode", "new-slot":
			if idx.chaseUnconf {
				if eAll, ok := entities[dxccNum]; ok {
					_, bAll := eAll.Bands[out[i].Band]
					_, mAll := eAll.Modes[checkMode]
					_, sAll := eAll.Slots[out[i].Band][checkMode]
					// "worked", not "": that is what the verdict function says
					// when every grain is in the log — the first version
					// compared against empty and no band/mode/slot need ever
					// dimmed, however many unconfirmed QSOs stood behind it.
					if spotEntityStatus(true, bAll, mAll, sAll, out[i].Mode) == "worked" {
						out[i].UnconfStatus = true
					}
				}
			}
		}
		// THIS station, already in the log on THIS slot, has nothing left to
		// give. Working it a second time on the same band and mode cannot make
		// the entity count: the QSO is already there and only the QSL is
		// missing, and a duplicate does not produce one. So the need is real —
		// it is simply not this station's to answer, and shouting NEW DXCC over
		// a callsign worked an hour ago is how an operator learns to distrust
		// the colour. Another station in the entity still carries the badge.
		//
		// Only for a need that IS a missing confirmation: a genuinely new
		// entity cannot co-exist with a worked callsign, so nothing real is
		// suppressed here.
		if out[i].UnconfStatus && out[i].WorkedSlot {
			out[i].Status = "worked"
			out[i].UnconfStatus = false
		}
	}
	return out
}

// IsNewUSCounty reports whether this state/county pair has never been worked.
//
// Same question the cluster's NEW CTY badge answers, asked for the contact
// currently in the entry panel: county hunters want to know BEFORE the QSO ends,
// and by the time a county shows up in the awards table the station is long
// gone. Empty (or non-US) input is not "new" — it is unknown, and a badge on a
// guess is worse than no badge.
func (a *App) IsNewUSCounty(state, cnty string) bool {
	if a.qso == nil || strings.TrimSpace(cnty) == "" {
		return false
	}
	worked, err := a.qso.CountyWorked(a.ctx, state, cnty, award.USCountyKey)
	if err != nil {
		return false
	}
	return !worked
}

// ── CAT sharing (Hamlib NET rigctl server) ────────────────────────────────

// catShareRig adapts the CAT manager to what the rigctld server needs. A thin
// interface rather than a direct dependency, so the protocol stays testable
// without a radio — and so sharing works with EVERY backend, not just the
// native ones: an operator on OmniRig or Flex gets the same server.
type catShareRig struct{ a *App }

// Freq is what a rigctl client gets for "f": the frequency of the VFO in use,
// which is where we LISTEN.
//
// This returned FreqHz, and RigState follows ADIF where FreqHz is the TRANSMIT
// frequency — so with split on, every client asking "what frequency is the
// radio on" was told VFO B. Reported from a station running an IC-7850: turning
// VFO B moved VFO A. Nothing in OpsLog was writing to the radio; a client was
// reading the dial, being handed the wrong VFO, and writing it back.
//
// The split TX frequency is a separate question, and Hamlib has a separate
// command for it ("i" / get_split_freq) which Split() below answers.
func (r catShareRig) Freq() int64 { return shareRXFreq(r.a.cat.State()) }

// shareRXFreq is that rule on its own, so it can be pinned by a test: it is one
// line, it was wrong, and being wrong cost a station its VFO A.
func shareRXFreq(st cat.RigState) int64 {
	if st.Split && st.RxFreqHz > 0 {
		return st.RxFreqHz
	}
	return st.FreqHz
}
func (r catShareRig) Mode() string { return r.a.cat.State().Mode }

// Split reports the flag and the OTHER VFO's frequency. RigState follows ADIF —
// FreqHz is where we TRANSMIT and RxFreqHz where we listen — while a rigctl
// client asks for the split TX frequency, which is FreqHz. Getting this pair
// backwards would make a client transmit on the listening frequency.
func (r catShareRig) Split() (bool, int64) {
	st := r.a.cat.State()
	if !st.Split {
		return false, 0
	}
	return true, st.FreqHz
}

// RxFreq is where we LISTEN. Only the TCI server asks for it: TCI's channel A
// is the receive frequency and channel B the transmit one, the opposite way
// round from RigState, and a client handed these two the wrong way about would
// transmit on the DX's own frequency.
func (r catShareRig) RxFreq() int64 {
	st := r.a.cat.State()
	if st.Split && st.RxFreqHz > 0 {
		return st.RxFreqHz
	}
	return st.FreqHz
}

func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) }
func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) }
func (r catShareRig) SetPTT(on bool) error   { return r.a.cat.SetPTT(on) }

// SetSplit passes the client's split request through to the radio, and passes
// the refusal back when the backend cannot do it. That refusal is the feature:
// WSJT-X can tell the operator to use "Fake It" instead, where before it was
// told everything had worked and transmitted on the receive frequency.
func (r catShareRig) SetSplit(on bool, txHz int64) error {
	return r.a.cat.SetSplit(on, txHz)
}

// reloadCATShare starts, stops or restarts the sharing server to match the
// settings. Called from reloadCAT so one "Save & Close" settles both.
//
// One server or the other, never both. rigctl and TCI are two ways of asking
// the same radio the same questions; running both would only double the ways a
// port clash or a confused client can go wrong, and no program speaks both.
func (a *App) reloadCATShare(s CATSettings) {
	want := s.Enabled && s.ShareEnabled
	// Same protocol on the same port → the running server is already the one
	// asked for. Rebuilding it closes the socket WSJT-X is sitting on, which is
	// the error box an operator sees on pressing Save.
	if sig := catShareSig(s); sig == a.catShareSig && (a.catShare != nil || a.catShareTCI != nil || !want) {
		return
	} else {
		a.catShareSig = sig
	}
	// NEVER while the rig is transmitting.
	//
	// Everything above decides that the server genuinely has to be rebuilt; this
	// decides WHEN. Closing the socket mid-over is the one moment it costs
	// something: WSJT-X is inside a run of Hamlib calls — VFO, split, PTT, which
	// with Fake It all land at the start of a transmission — and an operator's
	// log showed it happening three times in a session, followed by wsjtx.exe
	// dying on a null pointer. The transmission is seconds long; the new port
	// can wait for it.
	if a.catShareKeyed() {
		applog.Printf("cat share: rebuild deferred — the rig is transmitting")
		go a.reloadCATShareWhenUnkeyed(s)
		return
	}
	// Always tear down first: the port — or the protocol — may have changed, and
	// a listener bound to the old one would keep answering while the client is
	// told to use the new.
	if a.catShare != nil {
		a.catShare.Stop()
		a.catShare = nil
	}
	if a.catShareTCI != nil {
		a.catShareTCI.Stop()
		a.catShareTCI = nil
	}
	if !want {
		return
	}
	if s.ShareProto == "tci" {
		srv := tciserver.New(s.ShareTCIPort, catShareRig{a: a}, applog.Printf)
		if err := srv.Start(); err != nil {
			applog.Printf("cat share: %v", err)
			return
		}
		a.catShareTCI = srv
		return
	}
	srv := rigctld.New(s.SharePort, catShareRig{a: a}, applog.Printf)
	if err := srv.Start(); err != nil {
		// The usual cause is another rigctld — or a previous OpsLog — already on
		// the port. Logged rather than surfaced: CAT itself is unaffected, and the
		// operator finds it in the log the moment a client fails to connect.
		applog.Printf("cat share: %v", err)
		return
	}
	a.catShare = srv
}

// ── Yaesu control panel bindings ──────────────────────────────────────────
//
// One thin binding per control, mirroring the Icom set. Each dispatches onto the
// CAT goroutine, so a click never races the poll loop for the serial port.

func (a *App) GetYaesuState() cat.YaesuTXState {
	if a.cat == nil {
		return cat.YaesuTXState{}
	}
	st, _ := a.cat.YaesuState()
	return st
}

func (a *App) RefreshYaesuPanel() error {
	if a.cat == nil {
		return fmt.Errorf("CAT not initialized")
	}
	return a.cat.YaesuDo(func(y cat.YaesuController) error { return y.RefreshYaesu() })
}

func (a *App) SetYaesuPower(w int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPower(w) })
}

func (a *App) SetYaesuMicGain(p int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuMicGain(p) })
}

func (a *App) SetYaesuAFGain(p int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAFGain(p) })
}

func (a *App) SetYaesuRFGain(p int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuRFGain(p) })
}

func (a *App) SetYaesuSquelch(p int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuSquelch(p) })
}

func (a *App) SetYaesuAGC(mode string) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAGC(mode) })
}

func (a *App) SetYaesuPreamp(n int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuPreamp(n) })
}

// SetYaesuAntenna selects an antenna jack on a Yaesu, and REMEMBERS it for the
// band the rig is on.
//
// No settings page for this, deliberately. A per-band antenna table is one more
// form to fill in and one more menu to find, for a fact the operator states
// perfectly well by choosing the antenna once on that band. So the choice is
// learnt where it is made, and re-applied on the next visit — see
// applyYaesuBandAntenna.
func (a *App) SetYaesuAntenna(n int) error {
	if err := a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAntenna(n) }); err != nil {
		return err
	}
	if band := strings.ToLower(strings.TrimSpace(a.cat.State().Band)); band != "" && n > 0 {
		m := a.yaesuBandAntennas()
		if m[band] != n {
			m[band] = n
			a.saveYaesuBandAntennas(m)
			applog.Printf("yaesu: antenna %d remembered for %s", n, band)
		}
	}
	return nil
}

// keyYaesuBandAnt holds the learnt band → antenna map, as JSON.
const keyYaesuBandAnt = "yaesu.band_antennas"

func (a *App) yaesuBandAntennas() map[string]int {
	out := map[string]int{}
	if raw := a.settingOr(keyYaesuBandAnt, ""); raw != "" {
		_ = json.Unmarshal([]byte(raw), &out)
	}
	return out
}

func (a *App) saveYaesuBandAntennas(m map[string]int) {
	if b, err := json.Marshal(m); err == nil {
		a.setSetting(keyYaesuBandAnt, string(b))
	}
}

// GetYaesuBandAntennas exposes the learnt map, so the panel can show which band
// carries which antenna without the operator having to change band to find out.
func (a *App) GetYaesuBandAntennas() map[string]int { return a.yaesuBandAntennas() }

// ForgetYaesuBandAntenna drops the memory for one band — the way to undo a
// choice made by mistake, without a table to edit.
func (a *App) ForgetYaesuBandAntenna(band string) {
	m := a.yaesuBandAntennas()
	delete(m, strings.ToLower(strings.TrimSpace(band)))
	a.saveYaesuBandAntennas(m)
}

// YaesuApplyBandAntenna is applyYaesuBandAntenna as a binding, called from the
// same place the Flex one is — a band change or a spot click.
func (a *App) YaesuApplyBandAntenna(band string) error {
	a.applyYaesuBandAntenna(band)
	return nil
}

// applyYaesuBandAntenna puts the remembered antenna back when the rig changes
// band. Silent when nothing was ever learnt for it: an operator who has not
// expressed a preference for 17 m must not have their antenna moved.
func (a *App) applyYaesuBandAntenna(band string) {
	b := strings.ToLower(strings.TrimSpace(band))
	if b == "" || a.cat == nil {
		return
	}
	want := a.yaesuBandAntennas()[b]
	if want <= 0 {
		return
	}
	_ = a.cat.YaesuDo(func(y cat.YaesuController) error {
		if st := y.YaesuState(); st.Antenna == want || st.Antenna == 0 {
			return nil // already there, or a rig with a single socket
		}
		applog.Printf("yaesu: band %s → antenna %d (remembered)", b, want)
		return y.SetYaesuAntenna(want)
	})
}

func (a *App) SetYaesuAtt(db int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuAtt(db) })
}

func (a *App) SetYaesuNB(on bool) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNB(on) })
}

func (a *App) SetYaesuNR(on bool) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNR(on) })
}

func (a *App) SetYaesuNRLevel(n int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNRLevel(n) })
}

func (a *App) SetYaesuNarrow(on bool) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuNarrow(on) })
}

func (a *App) SetYaesuVOX(on bool) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuVOX(on) })
}

func (a *App) SetYaesuSplit(on bool) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuSplit(on) })
}

func (a *App) SetYaesuBand(band string) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuBand(band) })
}

func (a *App) TuneYaesuATU() error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.TuneYaesuATU() })
}

func (a *App) yaesuDo(fn func(cat.YaesuController) error) error {
	if a.cat == nil {
		return fmt.Errorf("CAT not initialized")
	}
	return a.cat.YaesuDo(fn)
}

// SetYaesuModeRaw picks an exact rig mode, sideband included ("CW-L", "DATA-U").
// The panel's mode buttons use this rather than SetCATMode, which takes an ADIF
// mode and can only choose a sideband by convention.
func (a *App) SetYaesuModeRaw(mode string) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuModeRaw(mode) })
}

// SetYaesuSplitOffset turns split on with the TX VFO a fixed distance up — the
// classic "listen down, transmit up 5" of a phone pile-up, or up 1 on CW.
func (a *App) SetYaesuSplitOffset(offsetHz int64) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuSplitOffset(offsetHz) })
}

func (a *App) SetYaesuKeySpeed(wpm int) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuKeySpeed(wpm) })
}

func (a *App) SetYaesuBreakIn(on bool) error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuBreakIn(on) })
}

// YaesuZeroIn retunes the rig so the received CW signal lands on the operator's
// own pitch — the radio's ZIN key.
func (a *App) YaesuZeroIn() error {
	return a.yaesuDo(func(y cat.YaesuController) error { return y.YaesuZeroIn() })
}

// YaesuSendCW keys a CW message through the Yaesu's own keyer (CAT "KY"), so an
// FTDX10 needs no WinKeyer and no second cable.
func (a *App) YaesuSendCW(text string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	err := a.cat.YaesuDo(func(y cat.YaesuController) error { return y.SendCW(text) })
	if err != nil {
		applog.Printf("yaesu cw: YaesuSendCW(%q) failed: %v", text, err)
	}
	return err
}

// YaesuStopCW aborts the message being sent.
func (a *App) YaesuStopCW() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.YaesuDo(func(y cat.YaesuController) error { return y.StopCW() })
}

// KenwoodSendCW keys a CW message through the Kenwood/Elecraft keyer (CAT "KY"),
// so a K3 keys CW over its single CAT link — no WinKeyer, no second COM port.
func (a *App) KenwoodSendCW(text string) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	err := a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.SendCW(text) })
	if err != nil {
		applog.Printf("kenwood cw: KenwoodSendCW(%q) failed: %v", text, err)
	}
	return err
}

// KenwoodStopCW aborts the message being sent (drops to receive).
func (a *App) KenwoodStopCW() error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.StopCW() })
}

// SetKenwoodKeySpeed sets the K3/Kenwood keyer speed in WPM (CAT "KS").
func (a *App) SetKenwoodKeySpeed(wpm int) error {
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.SetKeySpeed(wpm) })
}

// spotTTLMax bounds the setting. Twelve hours is far past any operational use
// and exists only so a mistyped value cannot mean "never".
const spotTTLMax = 720

// The spot list is a ring buffer, and its size decided the lifetime far more
// often than the lifetime setting did: on a busy evening a thousand spots
// arrive in a couple of minutes, so a fifteen-minute lifetime never got the
// chance to expire anything. Hence a setting.
//
// The ceiling is a real limit, not a round number. Every spot is matched against
// the worked index and the alert rules, and each one is a row the cluster grid
// and every open band map re-render; past ten thousand that work starts to show
// on the very evenings the list is worth having.
const (
	spotMaxDefault = 1000
	spotMaxCeiling = 10000
	spotMaxFloor   = 100
)

// GetSpotMax returns how many spots the list holds.
func (a *App) GetSpotMax() int {
	n, _ := strconv.Atoi(a.settingOr(keyClusterSpotMax, ""))
	if n <= 0 {
		return spotMaxDefault
	}
	return clampSpotMax(n)
}

// SetSpotMax sets it. Clamped here as well as in the UI, for the same reason as
// the lifetime: the value decides how much work arrives on every spot, and a
// stale frontend must not be able to widen it past the ceiling.
func (a *App) SetSpotMax(n int) error {
	a.setSetting(keyClusterSpotMax, strconv.Itoa(clampSpotMax(n)))
	return nil
}

func clampSpotMax(n int) int {
	if n < spotMaxFloor {
		return spotMaxFloor
	}
	if n > spotMaxCeiling {
		return spotMaxCeiling
	}
	return n
}

// GetSpotTTLMinutes returns how long a spot stays in the list, in minutes.
// 0 means spots are kept until the count cap pushes them out, which is what
// OpsLog always did.
func (a *App) GetSpotTTLMinutes() int {
	n, _ := strconv.Atoi(a.settingOr(keyClusterSpotTTL, ""))
	if n < 0 {
		return 0
	}
	if n > spotTTLMax {
		return spotTTLMax
	}
	return n
}

// SetSpotTTLMinutes sets it. Clamped here as well as in the UI: the value
// shapes what an operator sees on the band map, and a stale or bypassed
// frontend must not be able to widen it past the ceiling.
func (a *App) SetSpotTTLMinutes(min int) error {
	if min < 0 {
		min = 0
	}
	if min > spotTTLMax {
		min = spotTTLMax
	}
	a.setSetting(keyClusterSpotTTL, strconv.Itoa(min))
	return nil
}

// SetCompactHeight resizes the compact window to fit its content.
//
// The height used to be a constant "tuned so the compact entry strip fits in a
// single row". Constants tuned against a layout stop being true the moment the
// layout changes, and this one outlived a strip that had since lost a row —
// leaving a band of empty window under the fields. The frontend measures what
// it actually rendered and says so.
//
// Ignored unless compact is on, so nothing can shrink the normal window.
func (a *App) SetCompactHeight(h int) {
	if a.ctx == nil || !a.compact {
		return
	}
	// Bounded: a measurement of zero (a layout not yet painted) must not collapse
	// the window to nothing, and no strip is ever 600px tall.
	if h < 80 || h > 600 {
		return
	}
	w, _ := wruntime.WindowGetSize(a.ctx)
	if w <= 0 {
		w = compactW
	}
	wruntime.WindowSetMinSize(a.ctx, 900, h)
	wruntime.WindowSetSize(a.ctx, w, h)
}

// keyAmpsLinked couples the configured amplifiers so ON, OFF and OPERATE act on
// all of them at once — the SPE CO1-2 combiner case.
//
// The combiner is an RF device: it sums two amplifiers and commands nothing. So
// "combined" operation is really two amplifiers that must be kept in the same
// state, and leaving one in OPERATE while the other sits in STANDBY is exactly
// what must not happen — the combiner would see power on one input only.
const keyAmpsLinked = "amps.linked"

// GetLinkedAmps returns the ids of the amplifiers commanded together.
//
// A SET, not a global flag. A station can have a combiner pair AND a third
// amplifier that has nothing to do with it — two SPE on the combiner and a
// PowerGenius on another antenna — and a global switch would send that third
// one into OPERATE alongside them.
func (a *App) GetLinkedAmps() []string {
	out := []string{}
	for _, id := range strings.Split(a.settingOr(keyAmpsLinked, ""), ",") {
		if id = strings.TrimSpace(id); id != "" {
			out = append(out, id)
		}
	}
	return out
}

// SetLinkedAmps records which amplifiers are coupled.
func (a *App) SetLinkedAmps(ids []string) error {
	clean := make([]string, 0, len(ids))
	for _, id := range ids {
		if id = strings.TrimSpace(id); id != "" {
			clean = append(clean, id)
		}
	}
	// One amplifier coupled to itself is not a group; store nothing rather than
	// a set that would make ampTargets fan out to a single member for ever.
	if len(clean) < 2 {
		clean = nil
	}
	a.setSetting(keyAmpsLinked, strings.Join(clean, ","))
	return nil
}

// ampTargets returns the amplifier ids a command should reach: the one asked
// for, or every running amplifier when they are linked.
//
// The METERS are deliberately untouched by this — each amplifier keeps its own
// bars. Two amps combined are still two amps, and an operator watching for one
// of them to run away needs to see them apart.
func (a *App) ampTargets(id string, linked []string) []string {
	inGroup := false
	for _, l := range linked {
		if l == id {
			inGroup = true
			break
		}
	}
	// An amplifier outside the group keeps its own buttons to itself — that is
	// the whole point of the group being a set.
	if !inGroup {
		return []string{id}
	}
	a.ampsMu.Lock()
	defer a.ampsMu.Unlock()
	out := make([]string, 0, len(linked))
	// The one that was asked for goes FIRST, so a partial failure still did what
	// the operator clicked before it stopped.
	out = append(out, id)
	for _, l := range linked {
		if l == id {
			continue
		}
		// Only amplifiers actually running: a group remembering one that has been
		// deleted or switched off must not fail the whole command for it.
		if _, ok := a.ampInsts[l]; ok {
			out = append(out, l)
		}
	}
	return out
}

// wsjtLoggedQSO maps a logged contact onto the fields a WSJT-X "QSO Logged"
// message carries, for the outbound UDP row that speaks that protocol.
//
// TIME_OFF falls back to TIME_ON: the message has both, and a receiver that
// files the contact by the end time would put a QSO with no recorded end at the
// zero date — 1 January year 1, which is what a Julian day of zero means.
func wsjtLoggedQSO(q qso.QSO) udp.LoggedQSO {
	out := udp.LoggedQSO{
		DXCall:       q.Callsign,
		DXGrid:       q.Grid,
		Mode:         q.Mode,
		ReportSent:   q.RSTSent,
		ReportRcvd:   q.RSTRcvd,
		Comments:     q.Comment,
		Name:         q.Name,
		TimeOn:       q.QSODate.UTC(),
		TimeOff:      q.QSODate.UTC(),
		OperatorCall: q.Operator,
		MyCall:       q.StationCallsign,
		MyGrid:       q.MyGrid,
		ExchangeSent: q.STXString,
		ExchangeRcvd: q.SRXString,
		PropMode:     q.PropMode,
	}
	if !q.QSODateOff.IsZero() {
		out.TimeOff = q.QSODateOff.UTC()
	}
	if q.FreqHz != nil && *q.FreqHz > 0 {
		out.TxFreqHz = uint64(*q.FreqHz)
	}
	if q.TXPower != nil && *q.TXPower > 0 {
		out.TxPower = strconv.FormatFloat(*q.TXPower, 'f', -1, 64)
	}
	return out
}

// lookupWhen turns the entry form's date into the instant a ClubLog exception
// should be resolved at.
//
// Empty, unparseable, or in the future → now. A date is only trusted to move
// the resolution BACKWARDS: a half-typed "2026-0" must not send the lookup to
// the year 20, and a mistyped future date must not resolve against an exception
// window that has not opened.
//
// Midday UTC rather than midnight: an exception window given in whole days is
// inclusive of its end date, and resolving at 00:00 of that day sits on the
// boundary where an off-by-one in either direction changes the answer.
func lookupWhen(qsoDate string) time.Time {
	now := time.Now().UTC()
	s := strings.TrimSpace(qsoDate)
	if s == "" {
		return now
	}
	t, err := time.Parse("2006-01-02", s)
	if err != nil {
		return now
	}
	t = t.Add(12 * time.Hour)
	if t.After(now) {
		return now
	}
	return t
}

// potaRefFor returns the park a callsign is currently activating, or "".
//
// A spot clicked on the RADIO's panadapter arrives as a callsign and a
// frequency — the radio knows nothing of parks — so the park has to be found
// again here, from the same live POTA index that tags the cluster spots. Without
// it the Awards tab stayed empty for a click on the panadapter while the very
// same spot clicked in OpsLog filled it in, which is a difference the operator
// has no way to explain.
func (a *App) potaRefFor(call string) string {
	if a.pota == nil || strings.TrimSpace(call) == "" {
		return ""
	}
	if info, ok := a.pota.Lookup(call); ok {
		return info.Reference
	}
	return ""
}

// tidyLogbookSchema removes settings tables that an older OpsLog left inside the
// logbook.
//
// Two ways they got there: every database used to receive the full migration set
// (fixed in internal/db/roles.go, which now filters by role), and the one-time
// legacy split copies the whole settings database with VACUUM INTO and then
// treats the copy as the logbook. Empty ones are already dropped when the
// logbook is opened; the copies from the split have ROWS, and are dropped here
// because we can see the settings database still holding the originals.
//
// The check is the whole point: only when the logbook is a DIFFERENT database
// from the settings one, and that settings database still has its own profiles,
// is a profile row inside the logbook provably a duplicate. A profile pointing
// at a genuine legacy combined database as its logbook fails that test and keeps
// everything.
func (a *App) tidyLogbookSchema(logbookConn *sql.DB) {
	if logbookConn == nil || a.db == nil || logbookConn == a.db {
		return // the settings database IS the logbook: everything in it is in use
	}
	if a.dbBackend == "sqlite" && a.logDbPath != "" &&
		strings.EqualFold(filepath.Clean(a.logDbPath), filepath.Clean(a.dbPath)) {
		return // same file reached twice
	}
	var profiles int
	if err := a.db.QueryRowContext(a.ctx, "SELECT COUNT(*) FROM station_profiles").Scan(&profiles); err != nil || profiles == 0 {
		// No authoritative copy to point at — leave the logbook exactly as it is.
		return
	}
	db.DropRedundantSettingsTables(logbookConn, "logbook")
	// And the mirror image: the settings database keeps an empty qso table from
	// the days when every database got the whole schema. It is not needed while
	// the contacts have a database of their own, and EnsureQSOTable puts it back
	// if this one is ever asked to serve as the logbook again.
	db.DropEmptyQSOTable(a.db, filepath.Base(a.dbPath))
}

// countryFor names the DXCC entity of a callsign, or "" when it cannot be
// resolved. Used to enrich a panadapter spot built from a WSJT-X decode, which
// arrives as a bare callsign.
func (a *App) countryFor(call string) string {
	if a.dxcc == nil || strings.TrimSpace(call) == "" {
		return ""
	}
	if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil {
		return m.Entity.Name
	}
	return ""
}

// CheckHamlogKey validates a HAMLOG.online API key and returns the callsign it
// belongs to.
//
// Its own binding rather than part of a save: the answer names the ACCOUNT, so
// an operator who pasted a club's key, or one that expired last month, learns it
// while looking at the field they just filled in — not from QSOs that quietly
// never arrive.
func (a *App) CheckHamlogKey(key string) (string, error) {
	ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
	defer cancel()
	return extsvc.CheckHamlogKey(ctx, nil, key)
}

// GetAudioMonitorPref says whether the network RX audio should be played
// through the Listening device — the remembered speaker choice behind the
// console's speaker button and the CAT panel's checkbox.
func (a *App) GetAudioMonitorPref() bool {
	return a.settingOr(keyAudioMonitorOn, "1") == "1"
}

// IcomConsolePTT is the console's PTT button. On a USB station it keys the
// rig and nothing more — the operator talks into the radio's own microphone.
// When the transmit audio goes over the NETWORK, keying alone transmits
// silence: the PC microphone must be routed with it, exactly as the Talk
// button does. One button, the right road picked here.
func (a *App) IcomConsolePTT(on bool) error {
	cfg, _ := a.GetAudioSettings()
	if cfg.ToRadio == audio.NetworkDeviceID {
		if on {
			return a.AudioStartTX()
		}
		a.AudioStopTX()
		return nil
	}
	if a.cat == nil {
		return fmt.Errorf("cat not initialized")
	}
	return a.cat.SetPTT(on)
}
