//go:build windows

package cat

import (
	"bufio"
	"context"
	"encoding/binary"
	"fmt"
	"math"
	"net"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
// API on port 4992 — a line-based text protocol — and tracks slice state pushed
// by the radio in REAL TIME, so frequency/mode/split are always current (unlike
// the polled, lagging OmniRig path that needed a second click to fix a mode).
// Pure Go, no CGO, and no OmniRig install required for Flex users.
type Flex struct {
	host string
	port int

	mu         sync.Mutex
	conn       net.Conn
	dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop); nil when not dialing
	wmu        sync.Mutex         // serialises writes to conn
	seq        int
	handle     string
	model      string
	gotHandle  bool

	slices map[int]*flexSlice
	// pinnedSlice is the slice the operator chose IN OPSLOG (-1 = none). It
	// overrides the radio's own "active" flag, and only an OpsLog click changes
	// it — activating a slice on the radio's front panel or in SmartSDR does
	// not, by design (see mainSliceLocked).
	pinnedSlice   int
	tx            flexTX               // transmit/ATU state pushed by the radio (FlexRadio tab)
	amp           flexAmp              // external amplifier (PowerGenius XL) state
	micProfiles   []string             // available mic profiles (SmartSDR "profile mic list")
	micProfile    string               // currently loaded mic profile
	txSetAt       map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
	lastStateSig  string               // last logged derived-state signature (log only on change)
	boundClientID string               // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).

	// Live meters streamed over UDP (VITA-49). meterMeta is the definitions
	// pushed over TCP; meterVal the latest scaled values keyed by meter id.
	udpConn        *net.UDPConn
	meterMeta      map[int]meterInfo
	meterVal       map[int]float64
	meterSub       map[int]bool // ids we've already sent "sub meter <id>" for
	vitaSeen       int          // count of UDP datagrams (first few logged for diag)
	meterRawLogged bool         // log the first raw meter-definition status once
	txRawLogged    bool         // log the first raw transmit status once (field-name audit)

	spotsEnabled bool // push cluster spots + manage the panadapter overlay
	// foreignSpotSeen counts what probeForeignSpot has already reported, so a
	// skimmer posting all evening cannot turn the log into its own transcript.
	foreignSpotSeen int
	spotIdx         map[int]bool   // panadapter spot indices currently known to the radio
	pendingSpot     map[int]string // seq → callsign, awaiting the spot index in the R response
	pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
	pendingSplit    map[int]bool   // seq → awaiting the new TX slice's index (split create)
	pendingSat      map[int]string // seq → "rx"/"tx", awaiting a satellite slice's index
	// Satellite pair: slice A is the downlink, slice B the uplink. -1 when not
	// armed. satCreatedTX marks an uplink slice OpsLog opened, and is the only
	// one it will close again.
	satOn        bool
	satRX        int
	satTX        int
	satCreatedTX bool
	spotCall        map[int]string // spot index → callsign (to fill the call on a panadapter click)
	spotMode        map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
	spotFreq        map[int]int64  // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
	pendingSpotFreq map[int]int64  // seq → Hz, paired with pendingSpot
	// panWindow is what each panadapter is currently showing, from its own status
	// (centre and width, both MHz). Tracked because a zoom that only changes the
	// WIDTH keeps the old centre, and the frequency the operator just clicked can
	// end up outside the new window — see ZoomPan.
	panWindow map[string]panView

	// spotSig is what each live spot LOOKS like, and when it was drawn. A
	// callsign spotted again with the same frequency, colour and comment does not
	// need to be removed and re-added: the panadapter would not change by one
	// pixel, and a busy RBN feed re-spots the same station every few seconds. One
	// two-minute session sent 2 128 adds and 1 634 removes, 88 of them for a
	// single callsign — see SendSpot.
	spotSig  map[string]string
	spotSent map[string]time.Time

	spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
	sentCmds   map[int]string // seq → command text, so an R<seq> error names the command

	// OnSpotClick is called (off the reader goroutine's hot path) when the user
	// clicks one of our spots on the panadapter, with the spot's callsign, its
	// frequency and the mode it was spotted in — everything the host knows about
	// the spot, since the radio's own notification carries only an index. The host
	// wires this to fill the entry form and to size the panadapter. Set before Connect.
	OnSpotClick func(callsign string, freqHz int64, mode string)

	// OnForeignSpot is called for every spot posted to the radio by a program
	// OTHER than OpsLog, with the spot's callsign field and its frequency.
	//
	// A CW skimmer posts what it decodes, so the marker a DX operator's report
	// leaves on the panadapter arrives here — that is what the split chaser acts
	// on. Deliberately raw: this package reports what the radio said and does not
	// decide what a marker looks like, because the marker text is configured in
	// the skimmer, by the operator, and only they know what they chose.
	OnForeignSpot func(callsign string, freqHz int64)
}

// panView is one panadapter's visible window, in MHz.
type panView struct{ centre, width float64 }

type flexSlice struct {
	freqHz int64
	mode   string // raw Flex mode (USB/LSB/CW/DIGU/…)
	// pan is the panadapter this slice is displayed on ("0x40000000"), taken
	// from the slice status. Kept PER SLICE rather than as one radio-wide id:
	// with two slices on two bands there are two panadapters, and zooming the
	// wrong one moves a display the operator is not looking at.
	pan    string
	active bool
	tx     bool
	inUse  bool
	// RX DSP controls (SmartSDR slice object).
	agcMode      string // off | slow | med | fast
	agcThreshold int    // 0-100
	audioLevel   int    // 0-100 (RX volume)
	mute         bool   // RX audio muted
	nb           bool   // noise blanker
	nbLevel      int
	nr           bool // noise reduction
	nrLevel      int
	anf          bool // auto notch filter
	anfLevel     int
	apf          bool // CW audio peaking filter
	apfLevel     int
	wnb          bool // wideband noise blanker
	wnbLevel     int
	// SmartSDR v4 DSP (8000/Aurora series). Key names per the FlexLib slice
	// docs: lms_nr(NRL) / lms_anf(ANFL) / speex_nr(NRS) / rnnoise(RNN) /
	// anft(ANFT) / nrf(NRF). Older radios never report them — dspV4 flips true
	// the first time any of these keys appears, and gates the extra UI rows.
	lmsNR        bool // NRL — legacy LMS noise reduction
	lmsNRLevel   int
	lmsANF       bool // ANFL — legacy LMS auto-notch
	lmsANFLevel  int
	speexNR      bool // NRS — spectral subtraction NR
	speexNRLevel int
	rnnoise      bool // RNN — AI noise reduction (on/off only)
	anft         bool // ANFT — FFT-based auto-notch (on/off only)
	nrf          bool // NRF — noise reduction w/ filter
	nrfLevel     int
	dspV4        bool
	daxCh        int      // DAX audio channel for this slice (0 = off, 1-8)
	rit          bool     // receive incremental tuning enabled
	ritFreq      int      // RIT offset in Hz (negative = down)
	xit          bool     // transmit incremental tuning enabled
	xitFreq      int      // XIT offset in Hz
	filterLo     int      // slice filter low cut (Hz)
	filterHi     int      // slice filter high cut (Hz)
	rxAnt        string   // selected RX antenna (e.g. ANT1, ANT2, RX_A)
	txAnt        string   // selected TX antenna
	antList      []string // antennas valid for this slice (RX side)
	txAntList    []string // antennas valid for TX
}

// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
// controls). Populated from status pushes in handleStatus; read by FlexState().
type flexTX struct {
	rfPower      int
	tunePower    int
	tune         bool
	transmitting bool // interlock state == TRANSMITTING
	inhibit      bool // transmit inhibited (e.g. while a motorized antenna moves)
	voxEnable    bool
	voxLevel     int
	voxDelay     int
	procEnable   bool
	procLevel    int
	mon          bool
	monLevel     int
	micLevel     int
	filterLow    int  // TX filter low cut (Hz)
	filterHigh   int  // TX filter high cut (Hz)
	dax          bool // TX audio comes from DAX (SmartSDR's transmit-bar DAX button)
	atuStatus    string
	atuMemories  bool
	// CW keyer params (set via the top-level "cw" commands).
	cwSpeed        int  // WPM
	cwPitch        int  // Hz
	cwBreakInDelay int  // ms (QSK delay)
	cwSidetone     bool // sidetone (audible monitor) enable
	cwMonLevel     int  // sidetone level (mon_gain_cw)
}

// flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the
// hex id used to address SET commands; operate=true means OPERATE (vs STANDBY).
type flexAmp struct {
	handle  string
	model   string
	operate bool
	fault   string
}

// meterInfo is a meter definition pushed by the radio over TCP. unit drives the
// raw-int16 → real-value scaling; src/name identify what it measures.
type meterInfo struct {
	src  string // SLC (slice), TX-, COD, RAD, AMP…
	name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
	unit string // dbm, dbfs, swr, volts, degc, watts…
	slc  int    // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
	lo   float64
	hi   float64
}

// flexTriggerRe matches the radio's "spot <index> triggered" notification, sent
// when the user clicks one of our spots on the panadapter.
var flexTriggerRe = regexp.MustCompile(`spot (\d+) triggered`)

// NewFlex builds a Flex backend for the given radio IP (host) and port (4992).
// spotsEnabled turns on the panadapter spot overlay (subscribe + clear leftovers
// on connect + accept SendSpot).
func NewFlex(host string, port int, spotsEnabled bool) *Flex {
	if port == 0 {
		port = 4992
	}
	return &Flex{
		host: strings.TrimSpace(host), port: port,
		slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
		spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, spotSig: map[string]string{}, spotSent: map[string]time.Time{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, pendingSat: map[int]string{}, satRX: -1, satTX: -1,
		meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
		sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
		pinnedSlice: -1,
	}
}

func (f *Flex) Name() string { return "flex" }

// Connect dials the radio and subscribes to slice/radio status. The reader
// goroutine then keeps our cached state current from the radio's push messages.
func (f *Flex) Connect() error {
	f.mu.Lock()
	already := f.conn != nil
	host := f.host
	port := f.port
	f.mu.Unlock()
	if already {
		return nil
	}
	if host == "" {
		return fmt.Errorf("flex: no radio IP configured")
	}
	// Cancellable dial: Interrupt() (called by Stop/Start) aborts it at once so a
	// dead radio's 5 s dial timeout doesn't make Stop / Settings "Save & Close"
	// wait several seconds for the poll goroutine to give up.
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	f.mu.Lock()
	f.dialCancel = cancel
	f.mu.Unlock()
	var d net.Dialer
	conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
	cancel()
	f.mu.Lock()
	f.dialCancel = nil
	f.mu.Unlock()
	if err != nil {
		return fmt.Errorf("flex: connect %s:%d: %w", host, port, err)
	}
	f.mu.Lock()
	f.conn = conn
	f.gotHandle = false
	f.slices = map[int]*flexSlice{}
	f.meterVal = map[int]float64{}
	f.meterSub = map[int]bool{}
	f.boundClientID = "" // re-bind to the GUI client on each (re)connect
	f.mu.Unlock()
	debugLog.Printf("Flex: connected to %s:%d", host, port)

	go f.reader(conn)
	// Identify ourselves in SmartSDR's client list, then stream slice + transmit
	// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
	f.send("client program OpsLog")
	f.send("sub slice all")     // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio…
	f.send("sub tx all")        // transmit: rfpower, tunepower, vox, processor, mon, mic
	f.send("sub atu all")       // antenna-tuner status + memories
	f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
	f.send("sub radio all")     // radio-wide incl. interlock (TX/RX state)
	f.send("sub cwx all")       // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
	f.send("sub profile all")   // mic/global/tx profiles (for the mic-profile dropdown)
	f.send("profile mic info")  // request the current mic profile list + selection
	f.send("sub pan all")       // panadapter centre/bandwidth, so a zoom knows where the display already is
	f.send("sub client all")    // learn the GUI client (SmartSDR) so we can bind to it (below)
	f.startMeters(conn)         // open the UDP VITA-49 stream for live meters
	// Always subscribed, even when OpsLog draws no spots of its own: the feed is
	// read-only and it is how the spots posted by OTHER programs arrive — a CW
	// skimmer's decoded reports, which the split chaser acts on. Tying the
	// subscription to our own overlay meant a station using SDC and no OpsLog
	// spots heard nothing at all.
	f.send("sub spot all")
	if f.spotsEnabled {
		// Wipe the panadapter so stale spots from a previous session or another
		// logger are cleared before we start adding our own. Only when the
		// overlay is ours to manage: "spot clear" removes EVERY spot on the
		// radio, a skimmer's included, and taking those away from an operator
		// who never asked us to draw anything would be pure vandalism.
		go f.clearSpotsOnConnect(conn)
	}
	return nil
}

func (f *Flex) Disconnect() {
	f.mu.Lock()
	c := f.conn
	uc := f.udpConn
	f.conn = nil
	f.udpConn = nil
	f.gotHandle = false
	f.mu.Unlock()
	if uc != nil {
		_ = uc.Close() // unblocks udpReader
	}
	if c != nil {
		_ = c.Close()
		debugLog.Printf("Flex: disconnected")
	}
}

// Interrupt aborts an in-flight Connect dial so Stop()/Start() (Settings
// "Save & Close", CAT backend switch) don't block on a dead radio's 5 s dial
// timeout. Satisfies the Manager's optional interruptible interface. Safe to call
// anytime and from another goroutine; a no-op when not dialing.
func (f *Flex) Interrupt() {
	f.mu.Lock()
	cancel := f.dialCancel
	c := f.conn
	f.mu.Unlock()
	if cancel != nil {
		cancel()
	}
	if c != nil {
		_ = c.Close() // unblock the reader if we're already past the dial
	}
}

// send writes a sequenced command (C<seq>|<cmd>) to the radio and returns the
// sequence number (so the caller can match the R<seq> response, e.g. to learn a
// new spot's index). Returns 0 when not connected. Best effort.
func (f *Flex) send(cmd string) int {
	f.mu.Lock()
	c := f.conn
	f.seq++
	seq := f.seq
	if f.sentCmds != nil {
		f.sentCmds[seq] = cmd
	}
	f.mu.Unlock()
	if c == nil {
		return 0
	}
	f.wmu.Lock()
	_, err := fmt.Fprintf(c, "C%d|%s\n", seq, cmd)
	f.wmu.Unlock()
	if err != nil {
		debugLog.Printf("Flex: send %q failed: %v", cmd, err)
		return 0
	}
	// Meter subscriptions are pure bookkeeping and a Flex announces meters one
	// status line at a time — tracing each "sub meter N" wrote dozens of lines
	// at every connect.
	if !strings.HasPrefix(cmd, "sub meter ") {
		debugLog.Printf("Flex: → %s", cmd)
	}
	return seq
}

// reader consumes the radio's line stream until the connection drops.
func (f *Flex) reader(conn net.Conn) {
	sc := bufio.NewScanner(conn)
	sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
	for sc.Scan() {
		line := strings.TrimRight(sc.Text(), "\r\n")
		if line == "" {
			continue
		}
		// Panadapter spot click → "…spot <index> triggered…". Resolve the index
		// back to the callsign we stored at spot-add time and notify the host.
		if mm := flexTriggerRe.FindStringSubmatch(line); mm != nil {
			if idx, err := strconv.Atoi(mm[1]); err == nil {
				f.mu.Lock()
				call := f.spotCall[idx]
				mode := f.spotMode[idx]
				hz := f.spotFreq[idx]
				handler := f.OnSpotClick
				f.mu.Unlock()
				if call != "" && handler != nil {
					debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
					go handler(call, hz, mode)
					// SmartSDR tunes the spot's frequency on click but does NOT apply
					// its mode=, so set the slice mode ourselves from what we spotted.
					if mode != "" {
						go func(m string) { _ = f.SetMode(m) }(mode)
					}
				}
			}
		}
		switch line[0] {
		case 'V': // version banner, e.g. "V1.4.0.0"
			debugLog.Printf("Flex: radio %s", line)
		case 'H': // our client handle
			f.mu.Lock()
			f.handle = line[1:]
			f.gotHandle = true
			f.mu.Unlock()
			debugLog.Printf("Flex: handshake ok, handle=%s", line[1:])
		case 'S': // status push: S<handle>|<object ...>
			if i := strings.IndexByte(line, '|'); i >= 0 {
				f.handleStatus(line[i+1:])
			}
		case 'M': // message
			debugLog.Printf("Flex: msg %s", line)
		case 'R': // command response: R<seq>|<hex>|<message>
			parts := strings.SplitN(line[1:], "|", 3)
			if len(parts) < 2 {
				break
			}
			seq, _ := strconv.Atoi(parts[0])
			ok := parts[1] == "0" || parts[1] == "00000000"
			f.mu.Lock()
			cmdText := f.sentCmds[seq]
			delete(f.sentCmds, seq)
			f.mu.Unlock()
			if !ok {
				debugLog.Printf("Flex: cmd error R%d code=%s cmd=%q", seq, parts[1], cmdText)
			}
			// A successful "spot add" returns the new spot's index in the message;
			// pair it with the callsign we stashed under this seq.
			f.mu.Lock()
			call, pending := f.pendingSpot[seq]
			spotMode := f.pendingSpotMode[seq]
			spotFreq := f.pendingSpotFreq[seq]
			if pending {
				delete(f.pendingSpot, seq)
				delete(f.pendingSpotMode, seq)
				delete(f.pendingSpotFreq, seq)
			}
			if pending && ok && len(parts) >= 3 {
				if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
					f.spotCall[idx] = call
					f.spotMode[idx] = spotMode
					f.spotFreq[idx] = spotFreq
					f.spotIdx[idx] = true
					f.spotByCall[strings.ToUpper(call)] = idx
				}
			}
			// A successful "slice create" for split returns the new slice's index;
			// make that slice the transmitter so RX/TX are on separate slices.
			splitSeq := f.pendingSplit[seq]
			if splitSeq {
				delete(f.pendingSplit, seq)
			}
			// The same reply carries the index of a slice created for a satellite
			// pair; which of the two it is was recorded when it was asked for.
			satRole := f.pendingSat[seq]
			if satRole != "" {
				delete(f.pendingSat, seq)
			}
			f.mu.Unlock()
			if splitSeq && ok && len(parts) >= 3 {
				if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
					f.send(fmt.Sprintf("slice s %d tx=1", idx))
				}
			}
			if satRole != "" && ok && len(parts) >= 3 {
				if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
					f.adoptSatSlice(satRole, idx)
				}
			}
		}
	}
	// Connection ended.
	f.mu.Lock()
	if f.conn == conn {
		f.conn = nil
		f.gotHandle = false
	}
	f.mu.Unlock()
}

// handleStatus parses one status payload, e.g.
// "slice 0 in_use=1 RF_frequency=14.150000 mode=USB active=1 tx=1 …"
func (f *Flex) handleStatus(payload string) {
	fields := strings.Fields(payload)
	if len(fields) < 2 || fields[0] != "slice" {
		// radio … model=FLEX-6400 — grab the model when present.
		if len(fields) >= 1 && fields[0] == "radio" {
			for _, kv := range fields[1:] {
				if strings.HasPrefix(kv, "model=") {
					f.mu.Lock()
					f.model = strings.TrimPrefix(kv, "model=")
					f.mu.Unlock()
				}
			}
		}
		// Transmit object — RF/tune power, VOX, speech processor, monitor, mic,
		// tune carrier. Field names per the SmartSDR API (logged so the exact set
		// is auditable against a real radio).
		// "transmit band <N> band_name=… rfpower=…" lines are PER-BAND power
		// presets, not the current TX state — ignore them, otherwise the last
		// band's rfpower (e.g. 630m=100) clobbers the real current value.
		if len(fields) >= 1 && fields[0] == "transmit" && !(len(fields) >= 2 && fields[1] == "band") {
			if !f.txRawLogged {
				f.txRawLogged = true
				debugLog.Printf("Flex: FIRST transmit status: %s", payload)
			}
			debugLog.Printf("Flex: status %s", payload)
			f.mu.Lock()
			for _, kv := range fields[1:] {
				key, val, ok := splitKV(kv)
				if !ok {
					continue
				}
				// Ignore the radio's echo of a field we just set ourselves (it
				// often re-pushes the OLD value for ~1s, which snapped the slider
				// back). Our optimistic value stands until the guard expires.
				if t, ok := f.txSetAt[key]; ok && time.Since(t) < 1200*time.Millisecond {
					continue
				}
				switch key {
				case "rfpower":
					f.tx.rfPower = atoiDefault(val, f.tx.rfPower)
				case "tunepower":
					f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
				case "tune":
					f.tx.tune = val == "1"
				case "dax":
					f.tx.dax = val == "1"
				case "vox_enable":
					f.tx.voxEnable = val == "1"
				case "vox_level":
					f.tx.voxLevel = atoiDefault(val, f.tx.voxLevel)
				case "vox_delay":
					f.tx.voxDelay = atoiDefault(val, f.tx.voxDelay)
				case "speech_processor_enable":
					f.tx.procEnable = val == "1"
				case "speech_processor_level":
					f.tx.procLevel = atoiDefault(val, f.tx.procLevel)
				case "mon", "sb_monitor":
					f.tx.mon = val == "1"
				case "mon_gain_sb":
					f.tx.monLevel = atoiDefault(val, f.tx.monLevel)
				case "mon_gain_cw":
					f.tx.cwMonLevel = atoiDefault(val, f.tx.cwMonLevel)
				case "sidetone", "cw_sidetone":
					f.tx.cwSidetone = val == "1"
				// Once bound to the GUI client (see the client branch) the transmit
				// object carries the GUI client's LIVE CW values, so read them here
				// (and from cwx). Before binding these are the radio's static
				// defaults — that was the "always 600 / 5" bug.
				case "speed", "cwl_speed", "cw_speed", "wpm", "cw_wpm":
					f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed)
				case "pitch", "cwl_pitch", "cw_pitch":
					f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch)
				case "break_in_delay", "cwl_delay", "cw_break_in_delay", "delay":
					f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
				case "mic_level", "miclevel":
					f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
				// TX filter: the transmit STATUS reports the passband as lo/hi (the
				// SET command is filter_low/filter_high — a SmartSDR quirk).
				case "lo", "filter_low":
					f.tx.filterLow = atoiDefault(val, f.tx.filterLow)
				case "hi", "filter_high":
					f.tx.filterHigh = atoiDefault(val, f.tx.filterHigh)
				}
			}
			f.mu.Unlock()
		}
		// Client object — list of connected clients. GUI clients (SmartSDR /
		// Maestro) carry a client_id; non-GUI clients don't. We bind to the GUI
		// client so the radio routes GUI-tied data (CW pitch/speed, break-in
		// delay, RF power) to us. Logged so the exact field names are confirmable.
		if len(fields) >= 1 && fields[0] == "client" {
			debugLog.Printf("Flex: status %s", payload)
			var clientID, program string
			disconnected := false
			for _, kv := range fields[1:] {
				if kv == "disconnected" {
					disconnected = true
					continue
				}
				key, val, ok := splitKV(kv)
				if !ok {
					continue
				}
				switch key {
				case "client_id":
					clientID = val
				case "program":
					program = val
				}
			}
			f.mu.Lock()
			alreadyBound := f.boundClientID != ""
			f.mu.Unlock()
			lp := strings.ToLower(program)
			// The real GUI client is SmartSDR (Windows) or Maestro. Its non-GUI
			// helpers "SmartSDR CAT" and "SmartSDR DAX" also carry "smartsdr" in the
			// name, so exclude them — and require an explicit GUI name (dropping the
			// old program=="" fallback that could match CAT before its program field
			// arrived). Binding to CAT/DAX is invalid and the radio was seen to drop
			// SmartSDR CAT (connect/disconnect loop) when a logger did this.
			isGUI := (strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")) &&
				!strings.Contains(lp, "cat") && !strings.Contains(lp, "dax")
			if !disconnected && clientID != "" && !alreadyBound && isGUI {
				f.mu.Lock()
				f.boundClientID = clientID
				f.mu.Unlock()
				f.send("client bind client_id=" + clientID)
				debugLog.Printf("Flex: bound to GUI client %s (program=%q)", clientID, program)
			}
		}
		// CWX object — the LIVE CW keyer values (speed/pitch/break-in delay).
		// SmartSDR reads these here; the transmit object only carries a static
		// default. Logged in full so we can confirm the exact field names.
		if len(fields) >= 1 && fields[0] == "cwx" {
			debugLog.Printf("Flex: status %s", payload)
			f.mu.Lock()
			for _, kv := range fields[1:] {
				key, val, ok := splitKV(kv)
				if !ok {
					continue
				}
				switch key {
				case "wpm", "speed", "cw_speed":
					f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed)
				case "pitch", "cw_pitch":
					f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch)
				case "delay", "break_in_delay", "cw_break_in_delay":
					f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
				}
			}
			f.mu.Unlock()
		}
		// ATU object — auto-tuner status + memories.
		if len(fields) >= 1 && fields[0] == "atu" {
			debugLog.Printf("Flex: status %s", payload)
			f.mu.Lock()
			for _, kv := range fields[1:] {
				key, val, ok := splitKV(kv)
				if !ok {
					continue
				}
				switch key {
				case "status":
					f.tx.atuStatus = val
				case "memories_enabled":
					f.tx.atuMemories = val == "1"
				}
			}
			f.mu.Unlock()
		}
		// Mic-profile object — "profile mic list=A^B^C" (available profiles) and
		// "profile mic current=<name>" (loaded one). Names can contain spaces, so
		// values are taken from the raw payload after the key. Logged once so the
		// exact field names are confirmable on real hardware.
		if len(fields) >= 2 && fields[0] == "profile" && fields[1] == "mic" {
			debugLog.Printf("Flex: profile status: %s", payload)
			if i := strings.Index(payload, "list="); i >= 0 {
				var profs []string
				for _, p := range strings.Split(payload[i+len("list="):], "^") {
					if p = strings.TrimSpace(p); p != "" {
						profs = append(profs, p)
					}
				}
				f.mu.Lock()
				f.micProfiles = profs
				f.mu.Unlock()
			}
			// The loaded profile arrives as current=<name> (some firmwares:
			// selection=<name>); accept either.
			for _, key := range []string{"current=", "selection="} {
				if i := strings.Index(payload, key); i >= 0 {
					cur := strings.TrimSpace(payload[i+len(key):])
					f.mu.Lock()
					f.micProfile = cur
					f.mu.Unlock()
					break
				}
			}
		}
		// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
		if len(fields) >= 1 && fields[0] == "interlock" {
			f.mu.Lock()
			for _, kv := range fields[1:] {
				if key, val, ok := splitKV(kv); ok && key == "state" {
					f.tx.transmitting = strings.EqualFold(val, "TRANSMITTING")
				}
			}
			f.mu.Unlock()
		}
		// Amplifier object — "amplifier <handle> model=… operate=… …" (PowerGenius
		// XL). The handle (hex) addresses the operate/standby SET command.
		if len(fields) >= 2 && fields[0] == "amplifier" {
			debugLog.Printf("Flex: status %s", payload)
			f.mu.Lock()
			if strings.HasPrefix(fields[1], "0x") {
				f.amp.handle = fields[1]
			}
			removed := false
			for _, kv := range fields[2:] {
				if kv == "removed" || kv == "in_use=0" {
					removed = true
					continue
				}
				key, val, ok := splitKV(kv)
				if !ok {
					continue
				}
				switch key {
				case "handle":
					f.amp.handle = val
				case "model":
					f.amp.model = val
				case "operate":
					f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE")
				case "mode":
					f.amp.operate = strings.EqualFold(val, "OPERATE")
				case "state":
					// The PowerGenius XL reports its live state here (the status
					// push has no operate= field). Anything but STANDBY/OFF means
					// the amp is IN LINE (OPERATE) — IDLE = operate, not keyed.
					switch strings.ToUpper(val) {
					case "STANDBY", "OFF", "POWERED_OFF", "DISCONNECTED":
						f.amp.operate = false
					case "OPERATE", "IDLE", "TRANSMIT", "TX", "RECEIVE", "RX", "KEYED", "OPERATING":
						f.amp.operate = true
					}
				case "fault":
					f.amp.fault = val
				}
			}
			if removed {
				f.amp = flexAmp{}
				// The amp's meters (FWD / ID / TEMP …) don't always get an individual
				// "meter removed" push, so power-cycling the amp left the OLD meter
				// ids in the maps while the re-registered amp added NEW ones — the
				// panel then showed every amp meter twice. Drop every AMP-sourced
				// meter here so only the fresh set survives.
				for id, mi := range f.meterMeta {
					if strings.Contains(strings.ToUpper(mi.src), "AMP") {
						delete(f.meterMeta, id)
						delete(f.meterVal, id)
						delete(f.meterSub, id)
					}
				}
			}
			f.mu.Unlock()
		}
		// Panadapter — "display pan 0x40000000 center=14.075 bandwidth=0.2 …".
		// Only the window matters here; everything else about the display is
		// SmartSDR's business.
		if len(fields) >= 3 && fields[0] == "display" && fields[1] == "pan" {
			id := fields[2]
			f.mu.Lock()
			w := f.panWindow[id]
			for _, kv := range fields[3:] {
				key, val, ok := splitKV(kv)
				if !ok {
					continue
				}
				switch key {
				case "center":
					if v, err := strconv.ParseFloat(val, 64); err == nil && v > 0 {
						w.centre = v
					}
				case "bandwidth":
					if v, err := strconv.ParseFloat(val, 64); err == nil && v > 0 {
						w.width = v
					}
				}
			}
			f.panWindow[id] = w
			f.mu.Unlock()
		}
		// Meter definitions — "meter <num>.src=… <num>.nam=… <num>.unit=… …".
		// The unit scales the UDP values, the name labels them; subscribe to each
		// new id so the radio streams it.
		if len(fields) >= 2 && fields[0] == "meter" {
			if !f.meterRawLogged {
				f.meterRawLogged = true
				debugLog.Printf("Flex: meter status raw: %s", payload)
			}
			// Meter removal — "meter <id> removed": drop it so a stale value can't
			// linger (e.g. after an amp/slice is torn down).
			removedMeter := false
			for _, tok := range fields[1:] {
				if tok == "removed" || tok == "in_use=0" {
					removedMeter = true
				}
			}
			if removedMeter {
				f.mu.Lock()
				for _, tok := range fields[1:] {
					if n, err := strconv.Atoi(strings.TrimSpace(tok)); err == nil {
						delete(f.meterMeta, n)
						delete(f.meterVal, n)
						delete(f.meterSub, n)
					}
				}
				f.mu.Unlock()
				return
			}
			var newIDs []int
			f.mu.Lock()
			for _, tok := range fields[1:] {
				// One meter per token; its fields are '#'-separated:
				// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
				num := -1
				mi := meterInfo{slc: -1}
				for _, sub := range strings.Split(tok, "#") {
					key, val, ok := splitKV(sub)
					if !ok {
						continue
					}
					dot := strings.IndexByte(key, '.')
					if dot <= 0 {
						continue
					}
					n, err := strconv.Atoi(key[:dot])
					if err != nil {
						continue
					}
					num = n
					switch key[dot+1:] {
					case "src":
						mi.src = val
					case "nam":
						mi.name = val
					case "num":
						// For a slice (SLC) meter this field is the slice index —
						// how we tell slice A's S-meter from slice B's.
						if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
							mi.slc = v
						}
					case "unit", "units":
						mi.unit = val
					case "low", "lo":
						mi.lo = parseFloatDefault(val, mi.lo)
					case "hi":
						mi.hi = parseFloatDefault(val, mi.hi)
					}
				}
				if num < 0 {
					continue
				}
				old, seen := f.meterMeta[num]
				if !seen {
					newIDs = append(newIDs, num)
					old.slc = -1
				}
				if mi.src != "" {
					old.src = mi.src
				}
				if mi.name != "" {
					old.name = mi.name
				}
				if mi.unit != "" {
					old.unit = mi.unit
				}
				if mi.slc >= 0 {
					old.slc = mi.slc
				}
				if mi.lo != 0 {
					old.lo = mi.lo
				}
				if mi.hi != 0 {
					old.hi = mi.hi
				}
				f.meterMeta[num] = old
			}
			f.mu.Unlock()
			// Not logged: the radio announces meters ONE per status line, so even
			// a batched line meant dozens of them at every connect.
			for _, id := range newIDs {
				f.subscribeMeter(id)
			}
		}
		// Spot status: "spot <index> …". Track the index so we can clear the
		// panadapter, and log it verbatim — a click on a panadapter spot pushes a
		// spot status, which we'll use to fill the callsign once we see its shape.
		if len(fields) >= 2 && fields[0] == "spot" {
			// The click ("spot N triggered") is handled in the reader; here we
			// just keep the set of live spot indices for ClearSpots.
			if idx, err := strconv.Atoi(fields[1]); err == nil {
				removed := false
				for _, kv := range fields[2:] {
					if kv == "removed" || kv == "in_use=0" {
						removed = true
					}
				}
				f.mu.Lock()
				if removed {
					// The radio expired or dropped this spot: forget what it looked
					// like, or the next identical spot would be skipped as "already
					// drawn" and the station would never come back.
					if c, ok := f.spotCall[idx]; ok {
						delete(f.spotSig, strings.ToUpper(c))
						delete(f.spotSent, strings.ToUpper(c))
					}
					delete(f.spotIdx, idx)
					delete(f.spotCall, idx)
					delete(f.spotMode, idx)
					delete(f.spotFreq, idx)
				} else {
					f.spotIdx[idx] = true
				}
				f.mu.Unlock()
				if !removed {
					f.probeForeignSpot(payload)
					f.reportForeignSpot(payload)
				}
			}
			debugLog.Printf("Flex: status %s", payload)
		}
		return
	}
	// Slice status — log it so split/freq/mode issues are diagnosable.
	debugLog.Printf("Flex: status %s", payload)
	idx, err := strconv.Atoi(fields[1])
	if err != nil {
		return
	}
	f.mu.Lock()
	s := f.slices[idx]
	if s == nil {
		s = &flexSlice{}
		f.slices[idx] = s
	}
	for _, kv := range fields[2:] {
		eq := strings.IndexByte(kv, '=')
		if eq <= 0 {
			continue
		}
		key, val := kv[:eq], kv[eq+1:]
		switch key {
		case "RF_frequency":
			if mhz, e := strconv.ParseFloat(val, 64); e == nil {
				s.freqHz = int64(math.Round(mhz * 1e6))
			}
		case "mode":
			s.mode = val
		case "pan":
			s.pan = val
		case "active":
			s.active = val == "1"
		case "tx":
			s.tx = val == "1"
		case "in_use":
			s.inUse = val == "1"
		case "agc_mode":
			s.agcMode = val
		case "agc_threshold":
			s.agcThreshold = atoiDefault(val, s.agcThreshold)
		case "audio_level":
			s.audioLevel = atoiDefault(val, s.audioLevel)
		case "audio_mute", "mute":
			s.mute = val == "1"
		case "rxant":
			s.rxAnt = val
		case "txant":
			s.txAnt = val
		case "ant_list":
			s.antList = splitCSV(val)
		case "tx_ant_list":
			s.txAntList = splitCSV(val)
		case "nb":
			s.nb = val == "1"
		case "nb_level":
			s.nbLevel = atoiDefault(val, s.nbLevel)
		case "nr":
			s.nr = val == "1"
		case "nr_level":
			s.nrLevel = atoiDefault(val, s.nrLevel)
		case "anf":
			s.anf = val == "1"
		case "anf_level":
			s.anfLevel = atoiDefault(val, s.anfLevel)
		case "apf":
			s.apf = val == "1"
		case "apf_level":
			s.apfLevel = atoiDefault(val, s.apfLevel)
		case "wnb":
			s.wnb = val == "1"
		case "wnb_level":
			s.wnbLevel = atoiDefault(val, s.wnbLevel)
		case "lms_nr":
			s.lmsNR, s.dspV4 = val == "1", true
		case "lms_nr_level":
			s.lmsNRLevel, s.dspV4 = atoiDefault(val, s.lmsNRLevel), true
		case "lms_anf":
			s.lmsANF, s.dspV4 = val == "1", true
		case "lms_anf_level":
			s.lmsANFLevel, s.dspV4 = atoiDefault(val, s.lmsANFLevel), true
		case "speex_nr":
			s.speexNR, s.dspV4 = val == "1", true
		case "speex_nr_level":
			s.speexNRLevel, s.dspV4 = atoiDefault(val, s.speexNRLevel), true
		case "rnnoise":
			s.rnnoise, s.dspV4 = val == "1", true
		case "anft":
			s.anft, s.dspV4 = val == "1", true
		case "nrf":
			s.nrf, s.dspV4 = val == "1", true
		case "nrf_level":
			s.nrfLevel, s.dspV4 = atoiDefault(val, s.nrfLevel), true
		case "dax":
			s.daxCh = atoiDefault(val, s.daxCh)
		case "rit_on":
			s.rit = val == "1"
		case "rit_freq":
			s.ritFreq = atoiDefault(val, s.ritFreq)
		case "xit_on":
			s.xit = val == "1"
		case "xit_freq":
			s.xitFreq = atoiDefault(val, s.xitFreq)
		case "filter_lo":
			s.filterLo = atoiDefault(val, s.filterLo)
		case "filter_hi":
			s.filterHi = atoiDefault(val, s.filterHi)
		}
	}
	f.mu.Unlock()
}

// defInt returns v, or def when v is zero (so sliders show sane defaults before
// the radio has pushed the real value).
func defInt(v, def int) int {
	if v == 0 {
		return def
	}
	return v
}

// ReadState returns the cached state derived from the radio's push messages —
// no round-trip, so it's always current.
func (f *Flex) ReadState() (RigState, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.conn == nil {
		return RigState{}, fmt.Errorf("flex: not connected")
	}
	st := RigState{Connected: f.gotHandle, Rig: f.model}
	if !f.gotHandle {
		return st, nil // connected TCP but radio hasn't handshaked yet
	}
	main, rxS, txSplit := f.operatingLocked()
	if main == nil {
		return st, nil
	}
	// Main frequency/mode = the ACTIVE slice (what the operator is on). Only a
	// genuine same-band split adds a separate TX freq; then ADIF convention wants
	// FreqHz = TX and RxFreqHz = RX.
	st.FreqHz = main.freqHz
	st.Mode = flexModeToADIF(main.mode)
	if rxS != nil && txSplit != nil {
		st.Split = true
		st.RxFreqHz = rxS.freqHz
		st.FreqHz = txSplit.freqHz
	}
	sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
	if sig != f.lastStateSig {
		f.lastStateSig = sig
		debugLog.Printf("Flex: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
	}
	return st, nil
}

// mainSliceLocked is the operator's slice: the ACTIVE (focused) in-use slice, or
// the lowest-indexed in-use slice when none is flagged active. EVERYTHING the
// user does — freq/mode display, RX DSP, tuning, mode changes, spot clicks —
// follows this slice, so a second independent slice (e.g. monitoring another
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
// use. Caller holds f.mu.
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
	// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
	// randomises. When two slices transiently BOTH report active=1 (e.g. an
	// external controller like DXHunter activates a slice on another band while
	// ours still holds active, before SmartSDR sends active=0 to the old one),
	// map order returned a RANDOM active slice each call → the operating frequency
	// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
	// Deterministic order = the lowest-indexed active slice wins, stably.
	// An explicit choice made in OpsLog wins over everything, including the
	// radio's active flag. It is dropped only when that slice stops being in
	// use — a slice the operator closed is not a choice any more.
	if f.pinnedSlice >= 0 {
		if s := f.slices[f.pinnedSlice]; s != nil && s.inUse {
			return f.pinnedSlice, s
		}
	}

	firstInUse := -1
	chosen := -1
	for _, idx := range f.sortedSliceIdxLocked() {
		s := f.slices[idx]
		if !s.inUse {
			continue
		}
		if firstInUse < 0 {
			firstInUse = idx
		}
		if s.active {
			chosen = idx
			break
		}
	}
	if chosen < 0 {
		chosen = firstInUse
	}
	if chosen < 0 {
		return -1, nil
	}

	// In SPLIT, stay on the RECEIVE slice.
	//
	// SmartSDR moves its "active" flag to the TX slice as soon as the transmit
	// frequency is touched, so OpsLog followed the transmitter. But the operator
	// is LISTENING to the DX on the other slice: the S-meter, the audio level,
	// the filter and the DSP they are adjusting all belong there, and following
	// the TX slice hands them the controls for a receiver they are not using.
	//
	// Only in split, and only when nothing was pinned above — clicking slice B
	// in OpsLog still selects it and keeps it.
	if txS := f.txSliceLocked(); txS != nil && f.slices[chosen] == txS {
		if rxIdx, rxS := f.splitPartnerLocked(txS); rxS != nil {
			return rxIdx, rxS
		}
	}
	return chosen, f.slices[chosen]
}

// splitPartnerLocked returns the slice that FORMS A SPLIT with txS: in use, on
// the same band, at a different frequency, in the same class (phone with phone,
// CW with CW — so SSB alongside FT8 on one band is not a split).
//
// Lowest index first, so the answer is stable; map order is randomised in Go
// and gave a different partner on each poll. Caller holds f.mu.
func (f *Flex) splitPartnerLocked(txS *flexSlice) (int, *flexSlice) {
	if txS == nil {
		return -1, nil
	}
	bt := BandFromHz(txS.freqHz)
	ct := flexSplitClass(txS.mode)
	if bt == "" || ct == "" {
		return -1, nil
	}
	for _, idx := range f.sortedSliceIdxLocked() {
		s := f.slices[idx]
		if s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
			BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct {
			return idx, s
		}
	}
	return -1, nil
}

// sortedSliceIdxLocked returns the slice indices in ascending order so every
// slice-selection helper is deterministic (map iteration is randomised). Caller
// holds f.mu.
func (f *Flex) sortedSliceIdxLocked() []int {
	idxs := make([]int, 0, len(f.slices))
	for idx := range f.slices {
		idxs = append(idxs, idx)
	}
	sort.Ints(idxs)
	return idxs
}

// activeSliceIndexLocked returns the slice index to send commands to (the main
// slice, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int {
	if idx, _ := f.mainSliceLocked(); idx >= 0 {
		return idx
	}
	return 0
}

// sliceLetter maps a slice index to its SmartSDR letter (0→A, 1→B, …).
func sliceLetter(idx int) string {
	if idx < 0 || idx > 25 {
		return fmt.Sprintf("%d", idx)
	}
	return string(rune('A' + idx))
}

// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
// Caller holds f.mu.
func (f *Flex) txSliceLocked() *flexSlice {
	for _, idx := range f.sortedSliceIdxLocked() {
		if s := f.slices[idx]; s.inUse && s.tx {
			return s
		}
	}
	return nil
}

// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
// freq) AND the SAME split class (both PHONE, or both CW) — detected from the
// pair itself, NOT from which slice is active (the TX slice often steals focus
// right after "slice create", which must NOT read as "no split"). A slice on
// another band is an independent receiver, ignored; so is a same-band slice in a
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
// Caller holds f.mu.
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
	_, main = f.mainSliceLocked()
	txS := f.txSliceLocked()
	if txS == nil {
		return main, nil, nil
	}
	bt := BandFromHz(txS.freqHz)
	if bt == "" {
		return main, nil, nil
	}
	// Split only applies to PHONE/CW; a data-mode TX slice never splits.
	ct := flexSplitClass(txS.mode)
	if ct == "" {
		return main, nil, nil
	}
	// A split partner is an in-use, same-band slice at a different freq in the
	// SAME split class (so SSB + FT8 on one band is NOT a split).
	sameSplit := func(s *flexSlice) bool {
		return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
			BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
	}
	// RX = the active slice when it qualifies, else the first other qualifying
	// same-band slice.
	if sameSplit(main) {
		rx = main
	} else {
		for _, idx := range f.sortedSliceIdxLocked() {
			if s := f.slices[idx]; sameSplit(s) {
				rx = s
				break
			}
		}
	}
	if rx == nil {
		return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
	}
	return main, rx, txS
}

// SetActiveSlice focuses slice idx on the radio so every subsequent command
// (freq / mode / DSP / spot click) targets it. Lets the operator pick the
// operating slice from OpsLog (like SliceLogger's A/B selector).
func (f *Flex) SetActiveSlice(idx int) error {
	f.mu.Lock()
	_, exists := f.slices[idx]
	connected := f.conn != nil
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	if !exists {
		return fmt.Errorf("flex: no slice %d", idx)
	}
	// Remember it: this is the operator speaking, and it must survive the radio
	// moving its own active flag to the transmitter during split.
	f.mu.Lock()
	f.pinnedSlice = idx
	f.mu.Unlock()
	f.send(fmt.Sprintf("slice s %d active=1", idx))
	return nil
}

// SetTXSlice makes slice idx the transmitter (tx=1) — e.g. "put TX on the active
// slice" so you transmit where you're listening. Only one slice can be TX; the
// radio clears the flag on the others.
func (f *Flex) SetTXSlice(idx int) error {
	f.mu.Lock()
	_, exists := f.slices[idx]
	connected := f.conn != nil
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	if !exists {
		return fmt.Errorf("flex: no slice %d", idx)
	}
	f.send(fmt.Sprintf("slice s %d tx=1", idx))
	return nil
}

func (f *Flex) SetFrequency(hz int64) error {
	if hz <= 0 {
		return fmt.Errorf("flex: invalid frequency")
	}
	f.mu.Lock()
	idx := f.activeSliceIndexLocked()
	connected := f.conn != nil
	// Optimistically update the active slice's cached freq NOW, before the radio
	// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
	// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
	// jumped to the new band while the slice cache — which the FlexPanel and the
	// Ultrabeam follow loop read — still showed the old one, so the antenna chased
	// the stale value. The real echo confirms/corrects this a moment later.
	if s := f.slices[idx]; s != nil {
		s.freqHz = hz
	}
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	// "slice t <rx> <freq_MHz>" — tune command per the SmartSDR API (MHz, 6 dp).
	f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6))
	return nil
}

func (f *Flex) SetMode(mode string) error {
	f.mu.Lock()
	idx := f.activeSliceIndexLocked()
	var freq int64
	if s := f.slices[idx]; s != nil {
		freq = s.freqHz
	}
	connected := f.conn != nil
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	fm := adifModeToFlex(mode, freq)
	if fm == "" {
		return fmt.Errorf("flex: unsupported mode %q", mode)
	}
	// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
	// panel reflects it immediately instead of lagging the radio's echo.
	f.mu.Lock()
	if s := f.slices[idx]; s != nil {
		s.mode = fm
	}
	f.mu.Unlock()
	// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
	f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
	return nil
}

// ZoomPan sets the visible width of the ACTIVE slice's panadapter, and puts the
// frequency the operator clicked inside it.
//
// bandwidthMHz is the whole visible span, which is how SmartSDR expresses it —
// not a zoom factor.
//
// Re-centring is the subtle part. SmartSDR keeps the panadapter's CENTRE when
// only the width changes, so narrowing from 200 kHz to 25 for a CW spot — or
// jumping to a spot clicked in the band map — regularly left the very signal the
// operator asked for outside the new window: the radio had tuned to it, but the
// display was showing somewhere else. So the centre moves when it must:
//
//	centre == true   always re-centre (the operator asked for it)
//	centre == false  re-centre ONLY when freqMHz would fall outside the new
//	                 window, leaving a settled display alone the rest of the time
//
// The test uses 40% of the width either side rather than the full half: a spot
// pinned to the last pixel of the panadapter is visible in the arithmetic and
// useless in practice.
//
// A no-op when the slice reports no panadapter, which is the case on a slice
// that exists but is not displayed. Silent, because that is not a fault.
// needRecentre decides whether the panadapter has to move for freqMHz to be
// comfortably visible in a window of bandwidthMHz. forced short-circuits it.
//
// An unknown current centre (no pan status seen yet) counts as "would fall
// outside": the display is more likely wrong than right, and re-centring on the
// spot the operator just clicked is never the surprising answer.
func needRecentre(cur panView, freqMHz, bandwidthMHz float64, forced bool) bool {
	if freqMHz <= 0 {
		return false // nothing to centre on
	}
	if forced {
		return true
	}
	return cur.centre <= 0 || math.Abs(freqMHz-cur.centre) > bandwidthMHz*0.4
}

func (f *Flex) ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error {
	f.mu.Lock()
	pan := ""
	// The operator's slice, by the same rule as everything else — see
	// mainSliceLocked. Zooming any other panadapter would move a display the
	// operator is not looking at.
	if _, sl := f.mainSliceLocked(); sl != nil {
		pan = sl.pan
	}
	cur := f.panWindow[pan]
	connected := f.conn != nil && f.gotHandle
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	if pan == "" || bandwidthMHz <= 0 {
		return nil
	}
	recentre := needRecentre(cur, freqMHz, bandwidthMHz, centre)
	// Centre first, then width: SmartSDR clamps a window that would run past the
	// end of the spectrum, and moving to the right place before widening leaves
	// nothing to clamp.
	if recentre {
		f.send(fmt.Sprintf("display pan s %s center=%.6f", pan, freqMHz))
	}
	f.send(fmt.Sprintf("display pan s %s bandwidth=%.6f", pan, bandwidthMHz))
	debugLog.Printf("Flex: zoom pan %s → %.4f MHz (centre=%v on %.6f, was %.6f)", pan, bandwidthMHz, recentre, freqMHz, cur.centre)
	return nil
}

// foreignSpotProbeMax bounds the probe below. Enough lines to see what another
// program writes into a spot, few enough that a running skimmer — which posts
// hundreds an hour — cannot fill the log file with them.
const foreignSpotProbeMax = 60

// probeForeignSpot records, verbatim, the spots this radio receives from
// programs OTHER than OpsLog.
//
// A CW skimmer (SDC, for one) posts what it decodes as panadapter spots, the
// exchange included: chasing a split pileup means finding the "5NN" the DX just
// sent and moving the transmit slice there. Acting on that requires knowing
// exactly what the spot looks like — which field carries the text, whether the
// report is the callsign or the comment, what source names the skimmer — and no
// amount of reasoning substitutes for reading real lines off a real radio.
//
// So this logs and does nothing else. It is deliberately not a feature.
func (f *Flex) probeForeignSpot(payload string) {
	if strings.Contains(payload, "source=OpsLog") {
		return
	}
	f.mu.Lock()
	if f.foreignSpotSeen >= foreignSpotProbeMax {
		f.mu.Unlock()
		return
	}
	f.foreignSpotSeen++
	n := f.foreignSpotSeen
	f.mu.Unlock()
	debugLog.Printf("flex: foreign spot %d/%d: %s", n, foreignSpotProbeMax, payload)
	if n == foreignSpotProbeMax {
		debugLog.Printf("flex: that is enough foreign spots to go on — no more will be logged this session")
	}
}

// reportForeignSpot hands another program's spot to OnForeignSpot.
//
// Off the reader goroutine, like OnSpotClick: the handler tunes the radio, and
// a command sent from inside the reader would deadlock against the socket it is
// reading.
func (f *Flex) reportForeignSpot(payload string) {
	if strings.Contains(payload, "source=OpsLog") {
		return
	}
	handler := f.OnForeignSpot
	if handler == nil {
		return
	}
	var call string
	var hz int64
	for _, kv := range strings.Fields(payload) {
		eq := strings.IndexByte(kv, '=')
		if eq <= 0 {
			continue
		}
		switch kv[:eq] {
		case "callsign":
			call = kv[eq+1:]
		case "rx_freq":
			if mhz, err := strconv.ParseFloat(kv[eq+1:], 64); err == nil {
				hz = int64(math.Round(mhz * 1e6))
			}
		}
	}
	if call == "" || hz <= 0 {
		return
	}
	go handler(call, hz)
}

// SetTXSliceFrequency tunes the TRANSMIT slice, leaving the receive slice where
// it is. That distinction is the whole point in split: the operator listens to
// the DX on one slice and moves the other around the pile-up.
func (f *Flex) SetTXSliceFrequency(hz int64) error {
	if hz <= 0 {
		return fmt.Errorf("flex: invalid frequency")
	}
	f.mu.Lock()
	idx := -1
	for i, s := range f.slices {
		if s != nil && s.inUse && s.tx {
			idx = i
			break
		}
	}
	if idx >= 0 && f.slices[idx] != nil {
		f.slices[idx].freqHz = hz // optimistic, like SetFrequency
	}
	connected := f.conn != nil
	f.mu.Unlock()
	if idx < 0 {
		return fmt.Errorf("flex: no transmit slice")
	}
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6))
	return nil
}

// SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry
// a lifetime so the radio expires them on its own (the API has no "spot clear").
// Per the SmartSDR API, spaces inside a field value are encoded as 0x7F.
func (f *Flex) SendSpot(s SpotInfo) error {
	f.mu.Lock()
	connected := f.conn != nil && f.gotHandle
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	call := flexEncode(s.Callsign)
	if call == "" || s.FreqHz <= 0 {
		return nil
	}
	color := s.Color
	if color == "" {
		color = "#FFFFA500" // opaque orange default
	}
	life := s.LifetimeSec
	if life <= 0 {
		life = 1800 // default 30 min (cluster spots)
	}
	// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
	// spotted gets its previous spot removed first — one live spot per call,
	// refreshed, instead of a pile that all expire independently. NB: capture the
	// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
	// and Go mutexes aren't reentrant (calling send while locked deadlocks the
	// whole Flex goroutine → the radio drops OFFLINE).
	upperCall := strings.ToUpper(s.Callsign)
	// Unchanged and still fresh → leave it alone.
	//
	// Refreshed once past half its lifetime, so a long-lived spot still gets its
	// timer wound on and never quietly expires off the panadapter; anything that
	// actually differs (a new frequency, a status that has changed colour, a
	// different comment) is redrawn at once.
	sig := fmt.Sprintf("%d|%s|%s|%s|%s", s.FreqHz, s.Mode, s.Color, s.BackgroundColor, s.Comment)
	f.mu.Lock()
	if prev, ok := f.spotSig[upperCall]; ok && prev == sig {
		if at, ok := f.spotSent[upperCall]; ok && time.Since(at) < time.Duration(life)*time.Second/2 {
			f.mu.Unlock()
			return nil
		}
	}
	f.spotSig[upperCall] = sig
	f.spotSent[upperCall] = time.Now()
	old, hadOld := f.spotByCall[upperCall]
	if hadOld {
		delete(f.spotByCall, upperCall)
		delete(f.spotCall, old)
		delete(f.spotMode, old)
		delete(f.spotFreq, old)
		delete(f.spotIdx, old)
	}
	f.mu.Unlock()
	if hadOld {
		f.send(fmt.Sprintf("spot remove %d", old))
	}
	prio := ""
	if s.Priority >= 1 && s.Priority <= 5 {
		prio = fmt.Sprintf(" priority=%d", s.Priority)
	}
	cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
		float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
	// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
	// slice mode on a spot click when mode= is one of ITS mode names — "SSB" or a
	// bare cluster label is ignored, so the mode wouldn't change. adifModeToFlex
	// also resolves SSB→USB/LSB from the spot frequency.
	if m := flexEncode(adifModeToFlex(s.Mode, s.FreqHz)); m != "" {
		cmd += " mode=" + m
	}
	// background_color is optional in the API and empty means "the radio's own".
	// Sent only when set, so a spot with no configured background does not blank
	// the default to nothing.
	if bg := flexEncode(s.BackgroundColor); bg != "" {
		cmd += " background_color=" + bg
	}
	if c := flexEncode(s.Comment); c != "" {
		cmd += " comment=" + c
	}
	cmd += prio
	seq := f.send(cmd)
	if seq > 0 {
		// Remember which call this add was for; the R<seq> response carries the
		// radio-assigned spot index, which we map to the call so a later click
		// (trigger) can be resolved back to the callsign.
		f.mu.Lock()
		f.pendingSpot[seq] = s.Callsign
		f.pendingSpotMode[seq] = s.Mode
		f.pendingSpotFreq[seq] = s.FreqHz
		f.mu.Unlock()
	}
	return nil
}

// clearSpotsOnConnect waits until the radio handshake completes (we're truly
// connected), then sends "spot clear" so launching OpsLog — or enabling the
// option — starts from a clean panadapter, including spots left by another
// logger or a previous session.
func (f *Flex) clearSpotsOnConnect(conn net.Conn) {
	for i := 0; i < 50; i++ { // up to ~5s for the handshake
		f.mu.Lock()
		ready := f.gotHandle && f.conn == conn
		gone := f.conn != conn
		f.mu.Unlock()
		if gone {
			return // reconnected/closed in the meantime
		}
		if ready {
			f.ClearSpots()
			return
		}
		time.Sleep(100 * time.Millisecond)
	}
}

// ClearSpots wipes ALL panadapter spots in one command ("spot clear") — removes
// stale spots from a previous session or another logger, not just our own.
func (f *Flex) ClearSpots() error {
	f.mu.Lock()
	f.spotIdx = map[int]bool{}
	f.spotCall = map[int]string{}
	f.spotMode = map[int]string{}
	f.spotFreq = map[int]int64{}
	f.spotSig = map[string]string{}
	f.spotSent = map[string]time.Time{}
	f.spotByCall = map[string]int{}
	connected := f.conn != nil
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	f.send("spot clear")
	debugLog.Printf("Flex: spot clear sent")
	return nil
}

// flexEncode prepares a value for the Flex command line: trimmed, with any
// internal spaces replaced by 0x7F as the SmartSDR API requires.
func flexEncode(s string) string {
	s = strings.TrimSpace(s)
	if s == "" {
		return ""
	}
	return strings.ReplaceAll(s, " ", "\x7f")
}

func (f *Flex) SetPTT(on bool) error {
	f.mu.Lock()
	connected := f.conn != nil
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	if on {
		f.send("xmit 1")
	} else {
		f.send("xmit 0")
	}
	return nil
}

// splitKV splits a "key=value" token. ok is false when there's no '='.
func splitKV(kv string) (key, val string, ok bool) {
	eq := strings.IndexByte(kv, '=')
	if eq <= 0 {
		return "", "", false
	}
	return kv[:eq], kv[eq+1:], true
}

// atoiDefault parses an int (or a float like "20.0", truncated), else def.
// splitCSV splits a comma-separated antenna list (e.g. "ANT1,ANT2,RX_A") into a
// trimmed slice, dropping empties.
func splitCSV(s string) []string {
	out := []string{}
	for _, p := range strings.Split(s, ",") {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}

func atoiDefault(s string, def int) int {
	s = strings.TrimSpace(s)
	if n, err := strconv.Atoi(s); err == nil {
		return n
	}
	if fl, err := strconv.ParseFloat(s, 64); err == nil {
		return int(fl)
	}
	return def
}

func clampLevel(v int) int {
	if v < 0 {
		return 0
	}
	if v > 100 {
		return 100
	}
	return v
}

// rxSliceLocked returns the operator's (main/active) slice and its index — the
// slice every RX-DSP control and read targets. Caller holds f.mu.
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
	return f.mainSliceLocked()
}

// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
// RX slice's DSP controls, for the FlexRadio control tab. Available is true once
// the handshake has completed.
func (f *Flex) FlexState() FlexTXState {
	f.mu.Lock()
	defer f.mu.Unlock()
	st := FlexTXState{
		Available:    f.gotHandle && f.conn != nil,
		Model:        f.model,
		RFPower:      f.tx.rfPower,
		TunePower:    f.tx.tunePower,
		Tune:         f.tx.tune,
		Transmitting: f.tx.transmitting,
		VoxEnable:    f.tx.voxEnable,
		VoxLevel:     f.tx.voxLevel,
		VoxDelay:     f.tx.voxDelay,
		ProcEnable:   f.tx.procEnable,
		ProcLevel:    f.tx.procLevel,
		Mon:          f.tx.mon,
		MonLevel:     f.tx.monLevel,
		MicLevel:     f.tx.micLevel,
		TXFilterLow:  f.tx.filterLow,
		TXFilterHigh: f.tx.filterHigh,
		TXDAX:        f.tx.dax,
		MicProfile:   f.micProfile,
		MicProfiles:  f.micProfiles,
		ATUStatus:    f.tx.atuStatus,
		ATUMemories:  f.tx.atuMemories,
		// CW keyer (defaults applied so the sliders show sane values pre-read).
		CWSpeed:        defInt(f.tx.cwSpeed, 25),
		CWPitch:        defInt(f.tx.cwPitch, 600),
		CWBreakInDelay: defInt(f.tx.cwBreakInDelay, 30),
		CWSidetone:     f.tx.cwSidetone,
		CWMonLevel:     f.tx.cwMonLevel,
	}
	if _, rxS, txSplit := f.operatingLocked(); rxS != nil && txSplit != nil {
		st.Split = true
		st.RXFreqHz = rxS.freqHz
		st.TXFreqHz = txSplit.freqHz
	}
	// Every in-use slice (A/B/C/D…) so the panel shows them all and highlights the
	// active/TX one — the active slice drives everything the operator does.
	sidx := make([]int, 0, len(f.slices))
	for i, s := range f.slices {
		if s.inUse {
			sidx = append(sidx, i)
		}
	}
	sort.Ints(sidx)
	mainIdx, _ := f.mainSliceLocked()
	for _, i := range sidx {
		s := f.slices[i]
		st.Slices = append(st.Slices, FlexSliceInfo{
			Index:  i,
			Letter: sliceLetter(i),
			FreqHz: s.freqHz,
			Mode:   flexModeToADIF(s.mode),
			Band:   BandFromHz(s.freqHz),
			// The slice OpsLog is working with, which is not always the one the
			// radio has focused — in split OpsLog stays on RX. Reporting the
			// radio's flag here would highlight one slice while every control
			// acted on another.
			Active: i == mainIdx,
			TX:     s.tx,
		})
	}
	if _, rx := f.rxSliceLocked(); rx != nil {
		st.RXAvail = true
		st.Mode = strings.ToUpper(rx.mode)
		st.AGCMode = rx.agcMode
		st.AGCThreshold = rx.agcThreshold
		st.AudioLevel = rx.audioLevel
		st.Mute = rx.mute
		st.NB = rx.nb
		st.NBLevel = rx.nbLevel
		st.NR = rx.nr
		st.NRLevel = rx.nrLevel
		st.ANF = rx.anf
		st.ANFLevel = rx.anfLevel
		st.APF = rx.apf
		st.APFLevel = rx.apfLevel
		st.WNB = rx.wnb
		st.WNBLevel = rx.wnbLevel
		st.LMSNR = rx.lmsNR
		st.LMSNRLevel = rx.lmsNRLevel
		st.LMSANF = rx.lmsANF
		st.LMSANFLevel = rx.lmsANFLevel
		st.SpeexNR = rx.speexNR
		st.SpeexNRLevel = rx.speexNRLevel
		st.RNN = rx.rnnoise
		st.ANFT = rx.anft
		st.NRF = rx.nrf
		st.NRFLevel = rx.nrfLevel
		st.DSPV4 = rx.dspV4
		st.DAXCh = rx.daxCh
		st.RIT = rx.rit
		st.RITFreq = rx.ritFreq
		st.XIT = rx.xit
		st.XITFreq = rx.xitFreq
		st.FilterLo = rx.filterLo
		st.FilterHi = rx.filterHi
		st.RXAnt = rx.rxAnt
		st.TXAnt = rx.txAnt
		st.AntList = rx.antList
		st.TXAntList = rx.txAntList
		if len(st.TXAntList) == 0 {
			st.TXAntList = rx.antList // many configs share one antenna list
		}
	}
	if f.amp.handle != "" {
		st.AmpAvailable = true
		st.AmpModel = f.amp.model
		st.AmpOperate = f.amp.operate
		st.AmpFault = f.amp.fault
	}
	if len(f.meterVal) > 0 {
		ids := make([]int, 0, len(f.meterVal))
		for id := range f.meterVal {
			ids = append(ids, id)
		}
		sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
		for _, id := range ids {
			mi := f.meterMeta[id]
			st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
		}
	}
	return st
}

// sendSlice sends a "slice s <rxIdx> <param>=<val>" to the active RX slice, and
// optimistically updates our cached slice state — the radio doesn't reliably
// echo every field back to the client that changed it (e.g. agc_mode), so
// without this the UI would snap back to the stale value.
func (f *Flex) sendSlice(param string, val any) error {
	f.mu.Lock()
	idx, rx := f.rxSliceLocked()
	connected := f.conn != nil
	if rx != nil {
		switch param {
		case "agc_mode":
			rx.agcMode = fmt.Sprint(val)
		case "agc_threshold":
			rx.agcThreshold = toInt(val)
		case "audio_level":
			rx.audioLevel = toInt(val)
		case "audio_mute", "mute":
			rx.mute = fmt.Sprint(val) == "1"
		case "nb":
			rx.nb = val == "1"
		case "nb_level":
			rx.nbLevel = toInt(val)
		case "nr":
			rx.nr = val == "1"
		case "nr_level":
			rx.nrLevel = toInt(val)
		case "anf":
			rx.anf = val == "1"
		case "anf_level":
			rx.anfLevel = toInt(val)
		case "apf":
			rx.apf = val == "1"
		case "apf_level":
			rx.apfLevel = toInt(val)
		case "lms_nr":
			rx.lmsNR = val == "1"
		case "lms_nr_level":
			rx.lmsNRLevel = toInt(val)
		case "lms_anf":
			rx.lmsANF = val == "1"
		case "lms_anf_level":
			rx.lmsANFLevel = toInt(val)
		case "speex_nr":
			rx.speexNR = val == "1"
		case "speex_nr_level":
			rx.speexNRLevel = toInt(val)
		case "rnnoise":
			rx.rnnoise = val == "1"
		case "anft":
			rx.anft = val == "1"
		case "nrf":
			rx.nrf = val == "1"
		case "nrf_level":
			rx.nrfLevel = toInt(val)
		case "dax":
			rx.daxCh = toInt(val)
		case "rxant":
			rx.rxAnt = fmt.Sprint(val)
		case "txant":
			rx.txAnt = fmt.Sprint(val)
		case "rit_on":
			rx.rit = val == "1"
		case "rit_freq":
			rx.ritFreq = toInt(val)
		case "xit_on":
			rx.xit = val == "1"
		case "xit_freq":
			rx.xitFreq = toInt(val)
		}
	}
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	if rx == nil || idx < 0 {
		return fmt.Errorf("flex: no receive slice")
	}
	f.send(fmt.Sprintf("slice s %d %s=%v", idx, param, val))
	return nil
}

// toInt coerces an int or numeric string to int (for the optimistic cache).
func toInt(v any) int {
	switch t := v.(type) {
	case int:
		return t
	case string:
		return atoiDefault(t, 0)
	}
	return 0
}

func (f *Flex) SetAGCMode(m string) error {
	switch m {
	case "off", "slow", "med", "fast":
	default:
		return fmt.Errorf("flex: invalid agc mode %q", m)
	}
	return f.sendSlice("agc_mode", m)
}
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
func (f *Flex) SetAudioLevel(l int) error   { return f.sendSlice("audio_level", clampLevel(l)) }
func (f *Flex) SetMute(on bool) error       { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) }
func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) }

// SetSplit toggles 2-slice split like SmartSDR's SPLIT button. ON creates a TX
// slice at RX freq + offset (+1 kHz on CW, +5 kHz otherwise) and makes it the
// transmitter; OFF removes the extra TX slice and returns to simplex (RX slice
// transmits again).
func (f *Flex) SetSplit(on bool) error {
	f.mu.Lock()
	if f.conn == nil {
		f.mu.Unlock()
		return fmt.Errorf("flex: not connected")
	}
	// Split is built AROUND THE ACTIVE slice, and "already split" uses the SAME
	// same-band-pair rule as the button state (operatingLocked) — otherwise two
	// INDEPENDENT slices on different bands look "already split" and SPLIT ON does
	// nothing (the bug the user hit: A on 20m + B on 80m).
	main, rxS, txS := f.operatingLocked()
	rxIdx, txIdx := -1, -1
	for i, s := range f.slices {
		if s == rxS {
			rxIdx = i
		}
		if s == txS {
			txIdx = i
		}
	}
	alreadySplit := rxS != nil && txS != nil
	var baseFreq int64
	var baseMode, baseAnt string
	if main != nil {
		baseFreq, baseMode, baseAnt = main.freqHz, main.mode, main.rxAnt
	}
	f.mu.Unlock()

	if on {
		if alreadySplit || main == nil {
			return nil // already split, or no active slice
		}
		offset := int64(5000)
		if strings.HasPrefix(strings.ToUpper(baseMode), "CW") {
			offset = 1000
		}
		// Create the TX slice at the ACTIVE slice's freq + offset (same band).
		cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(baseFreq+offset)/1e6, baseMode)
		if baseAnt != "" {
			cmd += " ant=" + baseAnt
		}
		if seq := f.send(cmd); seq > 0 {
			f.mu.Lock()
			f.pendingSplit[seq] = true // mark the new slice TX once its index returns
			f.mu.Unlock()
		}
		return nil
	}
	if !alreadySplit {
		return nil
	}
	if txIdx >= 0 {
		f.send(fmt.Sprintf("slice remove %d", txIdx)) // drop the extra TX slice
	}
	if rxIdx >= 0 {
		f.send(fmt.Sprintf("slice s %d tx=1", rxIdx)) // RX slice transmits again
	}
	return nil
}
func (f *Flex) SetNB(on bool) error     { return f.sendSlice("nb", boolFlex(on)) }
func (f *Flex) SetNBLevel(l int) error  { return f.sendSlice("nb_level", clampLevel(l)) }
func (f *Flex) SetNR(on bool) error     { return f.sendSlice("nr", boolFlex(on)) }
func (f *Flex) SetNRLevel(l int) error  { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error    { return f.sendSlice("anf", boolFlex(on)) }
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }

// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
// switch is off, so turning RIT back on restores the last offset — same as the
// radio's own knob.
func (f *Flex) SetRIT(on bool) error    { return f.sendSlice("rit_on", boolFlex(on)) }
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
func (f *Flex) SetXIT(on bool) error    { return f.sendSlice("xit_on", boolFlex(on)) }
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }

// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
func clampOffset(hz int) int {
	if hz > 99999 {
		return 99999
	}
	if hz < -99999 {
		return -99999
	}
	return hz
}

func (f *Flex) SetAPF(on bool) error    { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }

// SmartSDR v4 DSP (8000/Aurora series) — the extra noise tools SmartSDR ships
// beyond nb/nr/anf/wnb. Key names per the FlexLib slice command docs.
func (f *Flex) SetLMSNR(on bool) error      { return f.sendSlice("lms_nr", boolFlex(on)) }
func (f *Flex) SetLMSNRLevel(l int) error   { return f.sendSlice("lms_nr_level", clampLevel(l)) }
func (f *Flex) SetLMSANF(on bool) error     { return f.sendSlice("lms_anf", boolFlex(on)) }
func (f *Flex) SetLMSANFLevel(l int) error  { return f.sendSlice("lms_anf_level", clampLevel(l)) }
func (f *Flex) SetSpeexNR(on bool) error    { return f.sendSlice("speex_nr", boolFlex(on)) }
func (f *Flex) SetSpeexNRLevel(l int) error { return f.sendSlice("speex_nr_level", clampLevel(l)) }
func (f *Flex) SetRNN(on bool) error        { return f.sendSlice("rnnoise", boolFlex(on)) }
func (f *Flex) SetANFT(on bool) error       { return f.sendSlice("anft", boolFlex(on)) }
func (f *Flex) SetNRF(on bool) error        { return f.sendSlice("nrf", boolFlex(on)) }
func (f *Flex) SetNRFLevel(l int) error     { return f.sendSlice("nrf_level", clampLevel(l)) }

// SetDAX routes the active slice's RX audio to a DAX channel (0 = off, 1-8) —
// the SmartSDR slice "DAX" selector, e.g. to feed WSJT-X via DAX audio.
func (f *Flex) SetDAX(ch int) error {
	if ch < 0 {
		ch = 0
	}
	if ch > 8 {
		ch = 8
	}
	return f.sendSlice("dax", ch)
}

// SetTXDAX toggles DAX as the TRANSMIT audio source — SmartSDR's transmit-bar
// DAX button ("transmit set dax="), the one used to send WSJT-X audio.
func (f *Flex) SetTXDAX(on bool) error {
	return f.txSet("transmit set dax="+boolFlex(on), "dax", func(t *flexTX) { t.dax = on })
}
func (f *Flex) SetWNB(on bool) error    { return f.sendSlice("wnb", boolFlex(on)) }
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }

// ── CW keyer controls (top-level "cw" commands) ──

func (f *Flex) SetCWSpeed(wpm int) error {
	if wpm < 5 {
		wpm = 5
	} else if wpm > 100 {
		wpm = 100
	}
	f.mu.Lock()
	f.tx.cwSpeed = wpm
	f.mu.Unlock()
	f.send(fmt.Sprintf("cw wpm %d", wpm))
	return nil
}

func (f *Flex) SetCWPitch(hz int) error {
	if hz < 100 {
		hz = 100
	} else if hz > 6000 {
		hz = 6000
	}
	f.mu.Lock()
	f.tx.cwPitch = hz
	f.mu.Unlock()
	f.send(fmt.Sprintf("cw pitch %d", hz))
	return nil
}

func (f *Flex) SetCWBreakInDelay(ms int) error {
	if ms < 0 {
		ms = 0
	} else if ms > 2000 {
		ms = 2000
	}
	f.mu.Lock()
	f.tx.cwBreakInDelay = ms
	f.mu.Unlock()
	f.send(fmt.Sprintf("cw break_in_delay %d", ms))
	return nil
}

// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
// characters fed while it's already sending append and key in order (the basis
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
func (f *Flex) SendCW(text string) error {
	text = strings.TrimRight(text, "\r\n")
	if strings.TrimSpace(text) == "" {
		return nil
	}
	esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
	f.send(`cwx send "` + esc + `"`)
	return nil
}

// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
func (f *Flex) StopCW() error {
	f.send("cwx clear")
	return nil
}

// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
// corrections (backspacing a mistyped call before the radio has keyed it).
func (f *Flex) BackspaceCW(n int) error {
	if n < 1 {
		n = 1
	}
	f.send(fmt.Sprintf("cwx erase %d", n))
	return nil
}

func (f *Flex) SetCWSidetone(on bool) error {
	f.mu.Lock()
	f.tx.cwSidetone = on
	f.mu.Unlock()
	f.send("cw sidetone " + boolWord(on))
	return nil
}

// SetSidetoneLevel sets the CW sidetone (audible monitor) gain via mon_gain_cw.
func (f *Flex) SetSidetoneLevel(l int) error {
	l = clampLevel(l)
	return f.txSet(fmt.Sprintf("transmit set mon_gain_cw=%d", l), "mon_gain_cw", func(t *flexTX) { t.cwMonLevel = l })
}

// SetCWFilter changes the CW passband WIDTH to bw Hz while keeping the current
// filter CENTER fixed — so the frequency never shifts. The new low/high cuts are
// center ± bw/2, where center is the midpoint of the slice's current filter
// (falling back to the CW pitch only if the filter isn't known yet).
func (f *Flex) SetCWFilter(bw int) error {
	if bw < 50 {
		bw = 50
	}
	f.mu.Lock()
	idx, rx := f.rxSliceLocked()
	connected := f.conn != nil
	center := 0
	if rx != nil && (rx.filterLo != 0 || rx.filterHi != 0) {
		center = (rx.filterLo + rx.filterHi) / 2
	} else {
		center = defInt(f.tx.cwPitch, 600)
	}
	lo := center - bw/2
	hi := center + bw/2
	if rx != nil {
		rx.filterLo, rx.filterHi = lo, hi
	}
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	if rx == nil || idx < 0 {
		return fmt.Errorf("flex: no receive slice")
	}
	f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi))
	return nil
}

// SetFilter sets the active RX slice's passband to an explicit low/high cut (Hz,
// audio offsets; negative for LSB). Used by the SSB width presets, where the
// frontend keeps the carrier-side edge and extends the far edge.
func (f *Flex) SetFilter(lo, hi int) error {
	f.mu.Lock()
	idx, rx := f.rxSliceLocked()
	connected := f.conn != nil
	if rx != nil {
		rx.filterLo, rx.filterHi = lo, hi
	}
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	if rx == nil || idx < 0 {
		return fmt.Errorf("flex: no receive slice")
	}
	f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi))
	return nil
}

// boolWord renders a Flex on/off boolean as the word form some commands want.
func boolWord(on bool) string {
	if on {
		return "on"
	}
	return "off"
}

// connected reports whether the TCP link is up (commands are no-ops otherwise).
func (f *Flex) connected() bool {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.conn != nil
}

// --- FlexController controls (SmartSDR transmit object). ---
//
// txSet sends a command AND optimistically updates our cached transmit state.
// The radio doesn't reliably echo a changed field back to the client that set
// it, so without the optimistic update the UI would snap back to the stale
// cached value (a real echo, e.g. a change from SmartSDR, still overrides it).
// txSet sends a command, optimistically updates our cached transmit state, and
// records `field` (the STATUS field name) so the radio's lagging echo of the old
// value is ignored for a moment (see handleStatus) — otherwise the slider snaps
// back. `field` may be "" for non-guarded commands.
func (f *Flex) txSet(cmd, field string, apply func(*flexTX)) error {
	f.mu.Lock()
	connected := f.conn != nil
	if connected && apply != nil {
		apply(&f.tx)
		if field != "" {
			f.txSetAt[field] = time.Now()
		}
	}
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	f.send(cmd)
	return nil
}

func (f *Flex) SetRFPower(p int) error {
	p = clampLevel(p)
	return f.txSet(fmt.Sprintf("transmit set rfpower=%d", p), "rfpower", func(t *flexTX) { t.rfPower = p })
}

func (f *Flex) SetTunePower(p int) error {
	p = clampLevel(p)
	return f.txSet(fmt.Sprintf("transmit set tunepower=%d", p), "tunepower", func(t *flexTX) { t.tunePower = p })
}

func (f *Flex) SetTune(on bool) error {
	cmd := "transmit tune off"
	if on {
		cmd = "transmit tune on"
	}
	return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
}

// SetTXInhibit blocks (on=true) or allows transmission at the radio. Used to keep
// the operator from keying while a motorized antenna's elements are moving —
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
// footswitch or an external keyer, which a software PTT block could not.
//
// `transmit set inhibit=` is the whole mechanism. We used to also send
// `interlock set reason=OpsLog` to label WHY TX was blocked, but `reason` is a
// read-only status field on the interlock object — writing it is rejected
// (cmd error 5000002D on SmartSDR v1.4/v3) and does nothing, so it's dropped.
func (f *Flex) SetTXInhibit(on bool) error {
	return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
}

func (f *Flex) SetVOX(on bool) error {
	return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
}

func (f *Flex) SetVOXLevel(l int) error {
	l = clampLevel(l)
	return f.txSet(fmt.Sprintf("transmit set vox_level=%d", l), "vox_level", func(t *flexTX) { t.voxLevel = l })
}

// SetVOXDelay sets the VOX hang time (0-100, a percentage scale in SmartSDR).
func (f *Flex) SetVOXDelay(l int) error {
	l = clampLevel(l)
	return f.txSet(fmt.Sprintf("transmit set vox_delay=%d", l), "vox_delay", func(t *flexTX) { t.voxDelay = l })
}

func (f *Flex) SetProcessor(on bool) error {
	return f.txSet("transmit set speech_processor_enable="+boolFlex(on), "speech_processor_enable", func(t *flexTX) { t.procEnable = on })
}

// SetProcessorLevel sets the speech-processor preset: 0=NOR, 1=DX, 2=DX+ (NOT a
// 0-100 level — per the SmartSDR transmit API).
func (f *Flex) SetProcessorLevel(l int) error {
	if l < 0 {
		l = 0
	}
	if l > 2 {
		l = 2
	}
	return f.txSet(fmt.Sprintf("transmit set speech_processor_level=%d", l), "speech_processor_level", func(t *flexTX) { t.procLevel = l })
}

func (f *Flex) SetMon(on bool) error {
	return f.txSet("transmit set mon="+boolFlex(on), "mon", func(t *flexTX) { t.mon = on })
}

func (f *Flex) SetMonLevel(l int) error {
	l = clampLevel(l)
	return f.txSet(fmt.Sprintf("transmit set mon_gain_sb=%d", l), "mon_gain_sb", func(t *flexTX) { t.monLevel = l })
}

// SetMic sets the mic gain. The SET token is "miclevel" (one word) even though
// the radio reports it back as "mic_level" in the transmit status.
func (f *Flex) SetMic(l int) error {
	l = clampLevel(l)
	return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
}

// SetTXFilter sets the transmit-audio bandwidth (low + high cut, in Hz). SmartSDR
// clamps to legal values per mode; we just pass them through in one command so
// both edges move together.
func (f *Flex) SetTXFilter(low, high int) error {
	if low < 0 {
		low = 0
	}
	if high < low {
		high = low
	}
	// Guard both status field names (the echo comes back as lo/hi, not
	// filter_low/high) so the radio's lagging echo doesn't snap the inputs back.
	f.mu.Lock()
	f.txSetAt["hi"] = time.Now()
	f.mu.Unlock()
	return f.txSet(fmt.Sprintf("transmit set filter_low=%d filter_high=%d", low, high), "lo",
		func(t *flexTX) { t.filterLow, t.filterHigh = low, high })
}

// SetMicProfile loads a SmartSDR mic profile by name (quoted, as names may hold
// spaces). Optimistically caches the selection.
func (f *Flex) SetMicProfile(name string) error {
	name = strings.TrimSpace(name)
	if name == "" {
		return fmt.Errorf("flex: empty mic profile")
	}
	f.mu.Lock()
	connected := f.conn != nil
	if connected {
		f.micProfile = name
	}
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	f.send(fmt.Sprintf("profile mic load \"%s\"", name))
	return nil
}

func (f *Flex) ATUStart() error {
	if !f.connected() {
		return fmt.Errorf("flex: not connected")
	}
	f.send("atu start")
	return nil
}

func (f *Flex) ATUBypass() error {
	if !f.connected() {
		return fmt.Errorf("flex: not connected")
	}
	f.send("atu bypass")
	return nil
}

func (f *Flex) SetATUMemories(on bool) error {
	return f.txSet("atu set memories_enabled="+boolFlex(on), "", func(t *flexTX) { t.atuMemories = on })
}

// SetAmpOperate switches the external amplifier between OPERATE (on=true) and
// STANDBY. Needs the amplifier handle learned from its status push.
func (f *Flex) SetAmpOperate(on bool) error {
	f.mu.Lock()
	handle := f.amp.handle
	connected := f.conn != nil
	if handle != "" {
		f.amp.operate = on // optimistic (radio may not echo to us)
	}
	f.mu.Unlock()
	if !connected {
		return fmt.Errorf("flex: not connected")
	}
	if handle == "" {
		return fmt.Errorf("flex: no amplifier detected")
	}
	f.send(fmt.Sprintf("amplifier set %s operate=%s", handle, boolFlex(on)))
	return nil
}

func boolFlex(b bool) string {
	if b {
		return "1"
	}
	return "0"
}

// --- Live meters over UDP (VITA-49) ---

// flexMeterClass is the VITA-49 packet class code FlexRadio uses for meter
// extension packets. The payload is 32-bit words: upper 16 bits = meter id,
// lower 16 bits = signed value (scaled per the meter's unit).
const flexMeterClass = 0x8002

// startMeters opens a UDP socket for the radio's VITA-49 realtime stream (sent
// from the radio's :4991), tells the radio which local port to stream to, and
// starts the reader. The socket is DIALED to radio:4991 and we send a "punch"
// datagram + periodic keepalives so Windows Firewall accepts the inbound stream
// (an unsolicited inbound UDP to our ephemeral port would otherwise be dropped).
func (f *Flex) startMeters(conn net.Conn) {
	raddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(f.host, "4991"))
	if err != nil {
		debugLog.Printf("Flex: meters resolve %s:4991: %v", f.host, err)
		return
	}
	// Unconnected socket: accept the stream from ANY source (the radio's source
	// port can change across NAT), while we still punch/keepalive toward :4991.
	uc, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
	if err != nil {
		debugLog.Printf("Flex: meters UDP listen failed: %v", err)
		return
	}
	port := uc.LocalAddr().(*net.UDPAddr).Port
	f.mu.Lock()
	f.udpConn = uc
	f.vitaSeen = 0
	f.mu.Unlock()
	f.send(fmt.Sprintf("client udpport %d", port)) // route VITA-49 to our port
	f.send("sub meter all")                        // stream all meter values
	_, _ = uc.WriteToUDP([]byte{0}, raddr)         // firewall/NAT punch
	debugLog.Printf("Flex: meters UDP local=:%d punch→%s", port, raddr)
	go f.udpReader(uc)
	go f.udpKeepalive(uc, raddr)
	go f.udpWatchdog(uc)
}

// udpWatchdog says so when the meter stream never arrives.
//
// The meters come over VITA-49 on a UDP socket the radio sends TO US; the TCP
// side can be perfectly healthy — handshake, slices, spots, everything — while
// not one datagram gets through. On Windows that is almost always the firewall
// refusing inbound UDP for a program it has not been asked about, and the only
// symptom is meters that stay at zero. Nothing said so: an operator saw a
// connected radio with dead meters and no reason anywhere.
//
// Five seconds is far longer than the stream's own 40 frames a second needs.
func (f *Flex) udpWatchdog(uc *net.UDPConn) {
	time.Sleep(5 * time.Second)
	f.mu.Lock()
	cur, seen := f.udpConn, f.vitaSeen
	port := 0
	if uc.LocalAddr() != nil {
		port = uc.LocalAddr().(*net.UDPAddr).Port
	}
	f.mu.Unlock()
	if cur != uc || seen > 0 {
		return // reconnected in the meantime, or the stream is flowing
	}
	debugLog.Printf("Flex: NO meter data after 5s — the radio's VITA-49 stream is not reaching UDP port %d. "+
		"The TCP link is fine, so this is almost always a firewall: allow OpsLog.exe inbound UDP "+
		"(Windows Defender Firewall → Allow an app), or open port %d for %s", port, port, f.host)
}

func (f *Flex) udpReader(uc *net.UDPConn) {
	buf := make([]byte, 16*1024)
	for {
		n, src, err := uc.ReadFromUDP(buf)
		if err != nil {
			return // socket closed on disconnect
		}
		f.mu.Lock()
		f.vitaSeen++
		seen := f.vitaSeen
		f.mu.Unlock()
		if seen <= 3 {
			debugLog.Printf("Flex: UDP datagram #%d %d bytes from %s", seen, n, src)
		}
		f.parseVita(buf[:n], seen)
	}
}

// udpKeepalive keeps the firewall/NAT mapping open by pinging the radio's :4991.
func (f *Flex) udpKeepalive(uc *net.UDPConn, raddr *net.UDPAddr) {
	t := time.NewTicker(10 * time.Second)
	defer t.Stop()
	for range t.C {
		f.mu.Lock()
		cur := f.udpConn
		f.mu.Unlock()
		if cur != uc {
			return
		}
		if _, err := uc.WriteToUDP([]byte{0}, raddr); err != nil {
			return
		}
	}
}

// parseVita decodes a VITA-49 datagram and, if it's a meter packet, updates the
// cached meter values. Header flags are honoured so the payload offset is right.
func (f *Flex) parseVita(p []byte, seen int) {
	if len(p) < 4 {
		return
	}
	w0 := binary.BigEndian.Uint32(p[0:4])
	off := 4
	pktType := (w0 >> 28) & 0xF
	hasClass := (w0>>27)&0x1 == 1
	tsi := (w0 >> 22) & 0x3
	tsf := (w0 >> 20) & 0x3
	if pktType == 0x1 || pktType == 0x3 { // packet types carrying a Stream ID
		off += 4
	}
	var packetClass uint16
	if hasClass {
		if off+8 > len(p) {
			return
		}
		packetClass = uint16(binary.BigEndian.Uint32(p[off+4 : off+8]))
		off += 8
	}
	if tsi != 0 {
		off += 4
	}
	if tsf != 0 {
		off += 8
	}
	// Diagnostics: log the first few datagrams's parsed header so we can confirm
	// the class code (in case 0x8002 / offsets differ on a real radio).
	if seen <= 3 {
		debugLog.Printf("Flex: VITA #%d len=%d type=%d class=0x%04x off=%d", seen, len(p), pktType, packetClass, off)
	}
	if packetClass != flexMeterClass || off > len(p) {
		return
	}
	payload := p[off:]
	f.mu.Lock()
	for i := 0; i+4 <= len(payload); i += 4 {
		id := int(binary.BigEndian.Uint16(payload[i : i+2]))
		raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
		f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
	}
	f.mu.Unlock()
}

// scaleMeter converts the raw int16 to its real value per the meter's unit.
func scaleMeter(raw int16, unit string) float64 {
	switch strings.ToUpper(unit) {
	case "DB", "DBM", "DBFS":
		return float64(raw) / 128.0
	case "VOLTS", "AMPS":
		return float64(raw) / 256.0
	case "DEGC", "DEGF", "TEMPC", "TEMPF":
		return float64(raw) / 64.0
	case "SWR":
		return float64(raw) / 128.0 // raw 128 = SWR 1.0 at idle
	default:
		return float64(raw)
	}
}

// subscribeMeter asks the radio to stream a meter's values (once per id).
func (f *Flex) subscribeMeter(id int) {
	f.mu.Lock()
	if f.meterSub[id] || f.conn == nil {
		f.mu.Unlock()
		return
	}
	f.meterSub[id] = true
	f.mu.Unlock()
	f.send(fmt.Sprintf("sub meter %d", id))
}

func parseFloatDefault(s string, def float64) float64 {
	if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
		return v
	}
	return def
}

// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
// Flex) and digital voice never operate split, so they return "" (not
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
// independent operations, not a split.
func flexSplitClass(rawMode string) string {
	switch flexModeToADIF(rawMode) {
	case "SSB", "AM", "FM":
		return "PHONE"
	case "CW":
		return "CW"
	default: // DATA, RTTY, DIGITALVOICE, "" → never split
		return ""
	}
}

func flexModeToADIF(m string) string {
	switch strings.ToUpper(strings.TrimSpace(m)) {
	case "USB", "LSB":
		return "SSB"
	case "CW":
		return "CW"
	case "AM", "SAM":
		return "AM"
	case "FM", "NFM", "DFM":
		return "FM"
	case "DIGU", "DIGL":
		return "DATA"
	case "RTTY":
		return "RTTY"
	case "FDV":
		return "DIGITALVOICE"
	case "":
		return ""
	default:
		return strings.ToUpper(m)
	}
}

// adifModeToFlex maps an ADIF mode to a Flex slice mode. SSB picks USB/LSB from
// the frequency (LSB below 10 MHz, USB above) — the standard convention.
func adifModeToFlex(mode string, freqHz int64) string {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "SSB":
		if freqHz > 0 && freqHz < 10_000_000 {
			return "LSB"
		}
		return "USB"
	case "USB":
		return "USB"
	case "LSB":
		return "LSB"
	case "CW":
		return "CW"
	case "AM":
		return "AM"
	case "FM":
		return "FM"
	case "RTTY", "FSK":
		return "RTTY"
	case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
		return "DIGU"
	default:
		return ""
	}
}
