package cat

import (
	"errors"
	"fmt"
	"strings"
	"sync"
	"time"

	"hamlog/internal/applog"
	"hamlog/internal/cat/civ"

	"go.bug.st/serial"
)

// civTransport is everything IcomSerial needs from its underlying link. It's
// satisfied directly by a go.bug.st serial.Port (USB, local control) and, for
// remote control, by the icomnet UDP stream which presents the tunnelled CI-V
// byte stream as plain Read/Write (there SetDTR/SetRTS/SetReadTimeout are
// no-ops). Abstracting it here means the whole IcomController surface — DSP,
// scope, RIT, CW — is reused unchanged over the network; only `open` differs.
type civTransport interface {
	Read(p []byte) (int, error)
	Write(p []byte) (int, error)
	Close() error
	SetReadTimeout(time.Duration) error
	SetDTR(bool) error
	SetRTS(bool) error
}

// aliveTransport is an OPTIONAL transport capability: report whether the link is
// still up independently of whether the rig answers CI-V. The network transport
// implements it (the rig's server pings even in standby), letting ReadState keep
// the session "connected but rig off" instead of tearing it down and flapping.
// USB doesn't implement it (no such out-of-band signal), so it keeps the bounded
// read-failure tolerance instead.
type aliveTransport interface {
	Alive() bool
}

const (
	// How long the network backend accepts "the control link answers but the rig
	// sends no CI-V" before declaring the session lost. Long enough to cover a
	// band change or a rig booting from standby; short enough that an operator
	// whose CI-V session was stolen (WSJT-X via OmniRig, the Remote Utility) gets
	// a live frequency back on his own rather than restarting OpsLog.
	icomSilentGrace = 30 * time.Second
	// Ceiling for the backoff applied when the silence persists — a rig switched
	// OFF is silent for hours, and re-tearing its session every 30 s would blink
	// the panel (and its ON button) away continuously.
	icomSilentGraceMax = 4 * time.Minute
)

// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
// (0x27) frames on a SEPARATE channel from control replies. The network transport
// implements it so the continuous panadapter stream can't crowd control replies
// out of the main Read path (which made every command time out with the scope
// on). USB doesn't implement it — there the scope frames ride the normal Read
// path and the reader splits them off to specCh.
type scopeTransport interface {
	ScopeChan() <-chan []byte
}

// IcomSerial controls an Icom transceiver over the shared civ protocol. The
// transport is pluggable via `open`: NewIcomSerial opens a USB/serial port;
// NewIcomNet (later) returns one configured with a network transport. Implements
// Backend; all methods run on the Manager's CAT goroutine, so the port is
// accessed single-threaded (no locking needed).
type IcomSerial struct {
	portName string
	baud     int
	rigAddr  byte   // rig's CI-V address (IC-7610 default 0x98)
	digital  string // mode to command for DATA (FT8/RTTY/…)

	// open dials the underlying link (serial port or network stream). Set by the
	// constructor; called by Connect. Blocks until connected (the network opener
	// performs the full UDP handshake + login before returning).
	open func() (civTransport, error)

	port  civTransport
	model string

	// I/O routing. A single reader goroutine owns port.Read and dispatches every
	// decoded rig frame: control replies go to respCh (drained by recv), while
	// spectrum-scope frames (0x27) go to specCh for the panadapter. This decouples
	// the continuous scope stream from the request/response control path — without
	// it, scope frames would flood recv() and stall polling.
	respCh     chan civ.Decoded
	specCh     chan civ.Decoded
	readerDone chan struct{}

	// Spectrum scope (0x27). dualScope marks rigs whose waveform frames carry a
	// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
	// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
	// via ScopeData from the binding goroutine).
	dualScope bool
	// satNative marks the two-band satellite rigs — the IC-9700 and the IC-9100 —
	// which have a real satellite mode of their own. Everything else gets the
	// downlink and, where the uplink is reachable, split.
	satNative bool
	// satOn tracks what we last told the rig, so TuneSatellite can arm the mode
	// once rather than on every Doppler step.
	satOn bool
	// Set when the rig rejects the waveform-output command in both shapes: it has
	// no stream to give, and asking again on every enable is noise.
	scopeUnsupported bool
	scopeMu          sync.Mutex
	scopeAmp         []byte
	scopeLow         int64 // spectrum left-edge frequency (from the sweep's header frame)
	scopeHigh        int64 // spectrum right-edge frequency
	scopeSeq         int
	scopeOn          bool
	scopeFixed       bool // true = fixed-span mode (tracked optimistically)
	scopeSeen        bool // logged the first sweep's structure once (on-rig verification)

	curFreq     int64     // last frequency read (for sideband choice)
	curModeByte byte      // last raw Icom mode byte (for filter re-send)
	pollN       int       // ReadState cycle counter (staggers slow reads)
	splitOn     bool      // last read split state (refreshed every few cycles)
	splitTXFreq int64     // last read unselected/TX VFO freq while in split
	readFails   int       // consecutive ReadState freq-read failures (transient tolerance)
	lastGoodAt  time.Time // last SUCCESSFUL frequency read — bounds the network
	//                       "alive but silent" tolerance below, which used to be
	//                       unbounded and left the display frozen for ever
	silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
	// Needle inertia for the TX meters — the CI-V meters are point samples, and
	// between two SSB syllables a poll lands on 0 W and a perfect SWR. See
	// meterPeak (yaesu_panel.go): power decays like a needle, SWR holds then
	// snaps back to the live truth.
	powerPeak meterPeak
	swrPeak   meterPeak
	dspLoaded bool // readDSP has run since the rig became responsive (loads all
	//                     the panel's set-once controls once the rig actually answers)
	// When the console last asked for the DSP snapshot. The meters and the
	// front-panel rotation are polled ONLY while something is displaying them:
	// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
	// Router splitting one CI-V link into two virtual ports, OpsLog on one and
	// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
	// collide with the other program's.
	dspWantMu sync.Mutex
	dspWantAt time.Time

	lastSetFreq   int64 // last frequency commanded (spot click: freq then mode)
	lastSetFreqAt time.Time

	// dsp caches the receive-DSP state for the Icom control tab. Read off the
	// CAT goroutine via IcomState(), written on the CAT goroutine (RefreshIcom
	// / setters) — hence the mutex.
	dspMu sync.Mutex
	dsp   IcomTXState

	// dialCancel is closed by Interrupt() to abort an in-progress network dial
	// (icomnet's handshake/login/boot-wait can block ~tens of seconds). A fresh
	// channel is made by each Connect. Guarded by dialMu: written on the CAT
	// goroutine, closed from the goroutine calling Stop.
	dialMu     sync.Mutex
	dialCancel chan struct{}
}

// Interrupt aborts an in-progress network Connect so Stop()/Start() don't block
// on a slow UDP handshake (or the 25 s boot-from-standby wait). Safe to call at
// any time and from another goroutine; harmless when no dial is in progress and
// a no-op for the USB transport (which dials instantly).
func (b *IcomSerial) Interrupt() {
	b.dialMu.Lock()
	if b.dialCancel != nil {
		select {
		case <-b.dialCancel: // already closed
		default:
			close(b.dialCancel)
		}
	}
	b.dialMu.Unlock()
}

const (
	icomReadTimeout = 350 * time.Millisecond // wait for a poll response
	icomCmdTimeout  = 400 * time.Millisecond // wait for a set ack (FB/FA)
)

// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
// 115200, rig address to the IC-7610's 0x98 when out of range — and 0x00 is in
// range: it is a valid CI-V address that some rigs and interfaces are set to.
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
	if baud <= 0 {
		baud = 115200
	}
	if civAddr < 0 || civAddr > 0xFF {
		civAddr = 0x98 // IC-7610
	}
	if digitalDefault == "" {
		digitalDefault = "FT8"
	}
	b := &IcomSerial{
		portName:   portName,
		baud:       baud,
		rigAddr:    byte(civAddr),
		digital:    strings.ToUpper(digitalDefault),
		model:      "Icom",
		scopeFixed: true, // rigs default to a fixed-span scope
	}
	// USB/serial transport opener.
	b.open = func() (civTransport, error) {
		if b.portName == "" {
			return nil, fmt.Errorf("no serial port configured")
		}
		p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
		if err != nil {
			return nil, fmt.Errorf("open %s @ %d baud: %w%s", b.portName, b.baud, err, busyHint(b.portName, err))
		}
		return p, nil
	}
	return b
}

func (b *IcomSerial) Name() string { return "icom" }

func (b *IcomSerial) Connect() error {
	if b.open == nil {
		return fmt.Errorf("no transport configured")
	}
	// Fresh cancel channel for this dial so Interrupt() (called by Stop) can abort
	// a slow network handshake instead of freezing the UI.
	b.dialMu.Lock()
	b.dialCancel = make(chan struct{})
	b.dialMu.Unlock()
	port, err := b.open()
	if err != nil {
		return err
	}
	// Short read timeout so recv() polls in a tight loop without blocking the
	// CAT goroutine when the rig is silent. (No-op on the network transport.)
	_ = port.SetReadTimeout(60 * time.Millisecond)
	// Deassert DTR/RTS. Icom USB rigs (IC-7610, IC-7300…) let "USB SEND" and
	// "USB Keying (CW)" be mapped to the RTS or DTR line: if the port opens with
	// those asserted, the rig keys into TRANSMIT. PTT here is CI-V only, so both
	// hardware lines must stay low.
	_ = port.SetDTR(false)
	_ = port.SetRTS(false)
	b.port = port
	b.model = civ.ModelName(b.rigAddr)
	// A NEW SESSION IS NOT JUDGED ON THE OLD ONE'S SILENCE.
	//
	// lastGoodAt bounds "the control link answers but no CI-V comes back". It
	// belongs to a session, and it was never cleared when a new one opened —
	// so a rig that went to standby half an hour ago handed every fresh session
	// a half-hour-old "last good read", which is past the grace before the first
	// command is even sent. The session was torn down at once, redialled twenty
	// seconds later, and torn down again: a loop with no way out, and the Icom
	// console (with its power-ON button) blinking away on every pass.
	//
	// Cleared, the rule reads as it was written: silent since connect is a rig in
	// standby, and the session is kept so the operator can wake it.
	b.lastGoodAt = time.Time{}
	b.readFails = 0
	b.silentGrace = icomSilentGrace

	// Start the reader before any request: recv() now waits on respCh, which only
	// the reader feeds. respCh is buffered so a burst (or the scope stream) never
	// blocks the reader; specCh holds the latest scope frames for the panadapter.
	b.respCh = make(chan civ.Decoded, 64)
	b.specCh = make(chan civ.Decoded, 32)
	b.readerDone = make(chan struct{})
	go b.reader(port, b.readerDone)
	go b.scopeLoop(b.specCh, b.readerDone)
	// On the network the scope frames come on their own channel (kept off the
	// control Read path); feed them into the same scope pipeline.
	if sc, ok := port.(scopeTransport); ok {
		go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
	}

	// Best-effort model identification: ask the rig for its own CI-V address. The
	// 0x19 ID read returns the rig's FACTORY default address (e.g. 0x94 for an
	// IC-7300) regardless of the address it's currently OPERATING on — so it's the
	// reliable model signal even when the user runs the rig at a non-default CI-V
	// address (a common trick to make CAT "just work" at 0x98).
	idAddr := b.rigAddr // fallback: the configured address if the ID read fails
	if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
		if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
			return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
		}); err == nil {
			b.model = civ.ModelName(f.Data[1])
			idAddr = f.Data[1]
		}
	}
	// Whether the rig's scope protocol carries a leading main/sub SELECTOR byte on
	// every 0x27 frame and command. The IC-7610/9700 (dual scope) do — and the logs
	// prove the IC-7300 does too (frames arrive as `27 00 00 <seq> <total> …` and a
	// mode read answers `27 14 00 <mode>`), even though it has a single scope. The
	// waveform parser auto-detects this per-frame regardless, so a 7300 at a
	// non-default address still RENDERS; this flag only drives the SET/read commands
	// (mode, span, edges), which need the 0x00 selector to be accepted on the 7300.
	b.dualScope = idAddr == 0x98 || idAddr == 0xA2 || idAddr == 0x94
	// The satellite rigs: IC-9700 and IC-9100. Both carry two receivers on two
	// bands and a satellite mode that pairs them; no other Icom in this table
	// does, and asking one that does not is a rejected frame per Doppler step.
	b.satNative = idAddr == 0xA2 || idAddr == 0x7C
	// Silence any LEFTOVER waveform stream, BLIND, before anything else. The
	// 0x27 output flag lives in the RADIO and survives sessions; its flood is
	// what makes the IC-7760 stop answering CI-V — so waiting for CI-V to
	// answer before sending this (the first attempt did) was the egg asking
	// the chicken to hatch it. Fire-and-forget: an unanswered set costs one
	// frame, and the rig acts on what it decodes whether or not we hear the
	// acknowledgement. The front-panel display is deliberately untouched.
	// Synchronously: Connect already runs on the CAT goroutine, and a stray
	// goroutine writing to the shared link would interleave with the command
	// loop. Two forms, because the selector byte differs by model and a NAK
	// costs one frame — and the results are LOGGED, because a kill that quietly
	// fails leaves the flood running and the next dropout unexplained.
	err1 := b.execScope("waveform output off (leftover)", civ.SubScopeOn, 0)
	err2 := b.exec(civ.CmdScope, civ.SubScopeOn, 0x00, 0x00) // main-selector form
	applog.Printf("icom: waveform-off sent at connect (plain: %v, with selector: %v)", err1, err2)
	// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
	// the rig may still be booting (or off) at Connect, so an immediate readDSP
	// would time out and leave every control at 0 / off with no retry. ReadState
	// loads it once on the first successful freq read instead (see dspLoaded).
	b.dspLoaded = false
	return nil
}

func (b *IcomSerial) Disconnect() {
	if b.port != nil {
		_ = b.port.Close() // unblocks the reader's pending Read
		b.port = nil
	}
	if b.readerDone != nil {
		<-b.readerDone // wait for the reader goroutine to exit cleanly
		b.readerDone = nil
	}
}

// ReadState polls the rig for frequency and mode. A failed frequency read is
// treated as "lost the rig" so the Manager reconnects.
func (b *IcomSerial) ReadState() (RigState, error) {
	if b.port == nil {
		return RigState{}, fmt.Errorf("not connected")
	}
	s := RigState{Backend: b.Name(), Connected: true, Rig: b.model}

	hz, err := b.readFreq()
	if err != nil {
		// Network transport: if the control link is still alive, the rig is simply
		// silent — either in standby / powered OFF (the ON button is manual now), or
		// mid band-change. Stay CONNECTED and show last-known state (empty until the
		// rig is switched on) rather than tearing the whole UDP session down and
		// flapping every few seconds. The panel stays up so the ON button works.
		if at, ok := b.port.(aliveTransport); ok {
			// The reader goroutine is what feeds every CI-V reply. If it has
			// exited, no read can ever succeed again, however healthy the control
			// link looks — that is not a silent rig, it is a dead connection.
			readerGone := false
			if b.readerDone != nil {
				select {
				case <-b.readerDone:
					readerGone = true
				default:
				}
			}
			// "Alive but silent" is the rig in standby, or mid band-change. It was
			// tolerated for ever, and that is the freeze: when ANOTHER program takes
			// the CI-V session (WSJT-X through OmniRig, say) the rig keeps answering
			// pings on the control stream while sending us no CI-V at all. Alive()
			// stayed true, the cached frequency was re-published with a fresh
			// timestamp on every poll, and the operator saw a confident, frozen
			// number until OpsLog was restarted. Bounded now: past the grace period
			// the error is reported so the Manager tears the session down and
			// reconnects, which re-takes the CI-V stream.
			silentFor := time.Duration(0)
			if !b.lastGoodAt.IsZero() {
				silentFor = time.Since(b.lastGoodAt)
			}
			if b.silentGrace <= 0 {
				b.silentGrace = icomSilentGrace
			}
			if at.Alive() && !readerGone && (b.lastGoodAt.IsZero() || silentFor < b.silentGrace) {
				b.readFails = 0
				s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
				if b.curModeByte != 0 {
					s.Mode = civ.ModeToADIF(b.curModeByte, false)
					if s.Mode == "DATA" {
						s.Mode = b.digital
					}
				}
				// Keep the Icom panel visible (so ON/OFF are reachable) but show no
				// live meters while the rig is silent.
				b.dspMu.Lock()
				b.dsp.Available = true
				b.dsp.Model = b.model
				b.dsp.Transmitting = false
				b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter = 0, 0, 0
				b.dspMu.Unlock()
				return s, nil
			}
			switch {
			case readerGone:
				debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
			case at.Alive():
				// No verdict on WHY: this fires for a session taken by another
				// program (WSJT-X, the Remote Utility) AND for a rig that simply
				// stopped answering — a real IC-7760 did exactly that once with
				// nothing else on the network. Blaming a hijacker sent that
				// operator hunting software that was not installed.
				debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Either another program took the CI-V session (WSJT-X/OmniRig/Remote Utility), or the rig stopped answering on its own.", silentFor.Round(time.Second))
			default:
				debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
			}
			// Restart the clock, and widen the window each time it fires without a
			// good read in between. A hijacked session recovers on the first shot;
			// a rig simply switched OFF never will, and re-tearing its session every
			// 30 s would make the panel — and its ON button — blink away
			// continuously. Backing off to minutes keeps the standby case quiet
			// while still recovering on its own.
			b.lastGoodAt = time.Now()
			if b.silentGrace < icomSilentGraceMax {
				b.silentGrace *= 2
			}
			return RigState{}, err // let the Manager reconnect
		}
		// USB (no liveness signal): the rig briefly stops answering CI-V while it
		// switches band/VFO. Tolerate a few consecutive misses as transient — keep
		// the connection and report last-known state — so a band change doesn't
		// trigger a full disconnect + 5 s reconnect. Only after several failures do
		// we declare the rig lost so the Manager reconnects.
		b.readFails++
		if b.readFails <= 6 && b.curFreq > 0 {
			s.FreqHz = b.curFreq
			if b.curModeByte != 0 {
				s.Mode = civ.ModeToADIF(b.curModeByte, false)
				if s.Mode == "DATA" {
					s.Mode = b.digital
				}
			}
			return s, nil
		}
		return RigState{}, err
	}
	b.readFails = 0
	b.lastGoodAt = time.Now()
	b.silentGrace = icomSilentGrace // the rig answers: back to the short window
	s.FreqHz = hz
	b.curFreq = hz

	if m, ok := b.readMode(); ok {
		b.curModeByte = m
		data := b.readDataMode() // best-effort; ignored on failure
		s.Mode = civ.ModeToADIF(m, data)
		if s.Mode == "DATA" {
			s.Mode = b.digital
		}
		b.dspMu.Lock()
		b.dsp.Mode = s.Mode
		// The console says which SIDEBAND, not the ADIF family: "SSB" on 40 m
		// leaves the operator guessing whether the rig is where convention puts
		// it. The log keeps SSB; the panel shows what the radio is actually doing.
		switch b.curModeByte {
		case civ.ModeUSB:
			if s.Mode == "SSB" {
				b.dsp.Mode = "USB"
			}
		case civ.ModeLSB:
			if s.Mode == "SSB" {
				b.dsp.Mode = "LSB"
			}
		}
		b.dspMu.Unlock()
	}

	b.pollN++

	// Split: the selected VFO (read above) is RX; the unselected VFO is TX. ADIF
	// convention → FreqHz = TX, RxFreqHz = RX. Split changes rarely and its read
	// (0x0F + 0x25, each with a 350 ms timeout) is the costliest part of a poll,
	// so refresh it only every 4th cycle and reuse the cached value between —
	// this keeps the CAT thread free for the freq/mode/meter reads and, above
	// all, for the user's Set* commands.
	if b.pollN%4 == 1 {
		b.splitOn, b.splitTXFreq = false, 0
		on, okSplit := b.readSplit()
		// The unselected VFO is read split or NOT: on a dual-receiver rig it is
		// the sub receiver's dial, and the console showed it blank until split
		// was engaged.
		if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 {
			b.setCache(func(st *IcomTXState) { st.SubHz = txHz })
			if okSplit && on {
				b.splitOn, b.splitTXFreq = true, txHz
			}
		}
	}
	if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz {
		s.Split = true
		s.RxFreqHz = s.FreqHz    // selected VFO = RX
		s.FreqHz = b.splitTXFreq // unselected VFO = TX
	}

	// Live meters + TX state for the Icom panel (the rig doesn't push these).
	// The meters are polled only while the console is actually on screen: they
	// were three CI-V round trips per cycle, every cycle, whether or not anyone
	// could see them. The previous readings are kept so the panel opens on the
	// last known values rather than on zeros.
	tx := b.readTX()
	watched := b.panelWatched()
	b.dspMu.Lock()
	sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
	b.dspMu.Unlock()
	if watched {
		sm, _ = b.readMeter(civ.SubMeterS)
		po, swr = 0, 0
		if tx {
			now := time.Now()
			if v, ok := b.readMeter(civ.SubMeterPo); ok {
				po = b.powerPeak.update(v, now)
			}
			if v, ok := b.readMeter(civ.SubMeterSWR); ok {
				swr = b.swrPeak.updateSnap(v, now)
			}
		} else {
			// Cleared with the carrier, so the next transmission starts fresh.
			b.powerPeak, b.swrPeak = meterPeak{}, meterPeak{}
		}
	}
	b.dspMu.Lock()
	b.dsp.Available = true
	b.dsp.Model = b.model
	b.dsp.Transmitting = tx
	b.dsp.Split = s.Split
	b.dsp.SMeter = sm
	b.dsp.PowerMeter = po
	b.dsp.SWRMeter = swr
	b.dspMu.Unlock()

	// First time the rig answers (it's booted/responsive): load the full DSP
	// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
	// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
	// demand, and a reconnect re-arms it (Connect clears dspLoaded).
	// Both of these only make sense for the console — don't spend the bus on them
	// while it is closed. The snapshot then loads the first time it is opened.
	if watched {
		if !b.dspLoaded {
			b.readDSP()
			b.dspLoaded = true
		} else {
			b.refreshFrontPanel()
		}
	}
	return s, nil
}

// refreshFrontPanel re-reads the few controls an operator actually reaches for on
// the rig itself, so the console follows the radio instead of only driving it.
//
// readDSP loads everything but runs ONCE per connection (dspLoaded), which left
// the panel showing whatever was set at connect: switch AGC from FAST to MID on
// the front panel and OpsLog still said FAST, indefinitely. Commands worked, so
// the link was plainly fine — only this direction was missing.
//
// ONE read per poll cycle, in rotation. The full snapshot is ~30 CI-V round trips
// and refreshing it wholesale would hog the CAT thread for seconds at a time —
// including the operator's own Set* commands, which is far worse than a stale
// label. The rotation completes in 8 cycles; anything not covered here is still a
// connect-time or ↻ Refresh read.
func (b *IcomSerial) refreshFrontPanel() {
	switch b.pollN % 8 {
	case 1:
		if v, ok := b.readSwitch(civ.SubSwAGC); ok {
			b.dspMu.Lock()
			b.dsp.AGC = agcName(v)
			b.dspMu.Unlock()
		}
	case 3:
		if v, ok := b.readAtt(); ok {
			b.dspMu.Lock()
			b.dsp.Att = v
			b.dspMu.Unlock()
		}
	case 5:
		if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
			b.dspMu.Lock()
			b.dsp.Preamp = int(v)
			b.dspMu.Unlock()
		}
	case 7:
		if _, f, ok := b.readModeFilter(); ok {
			b.dspMu.Lock()
			b.dsp.Filter = int(f)
			b.dspMu.Unlock()
		}
	case 2:
		// Tuner in/out — the radio's own TUNER button changes it mid-session.
		if v, ok := b.readSwitchSub(civ.CmdATU, civ.SubATU); ok {
			b.dspMu.Lock()
			b.dsp.ATUOn = v == 1
			b.dspMu.Unlock()
		}
	}
}

func (b *IcomSerial) SetFrequency(hz int64) error {
	if hz <= 0 {
		return fmt.Errorf("invalid frequency")
	}
	b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
	err := b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
		append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
	// Same reasoning as SetPTT: a lost acknowledgement is not a failed command,
	// and WSJT-X or JTDX answer a failed set_freq by dropping the link — an
	// IC-7850 log shows exactly that at 15:16:15, the disconnect four seconds
	// later. The rig is asked what frequency it is on rather than being called a
	// failure on the strength of a missing "FB".
	if err == nil || !errors.Is(err, errIcomAckLost) {
		return err
	}
	if got, rerr := b.readFreq(); rerr == nil && got == hz {
		applog.Printf("icom: frequency %d Hz not acknowledged, but the rig is on it — treating as done", hz)
		return nil
	}
	return err
}

func (b *IcomSerial) SetMode(mode string) error {
	code, data, err := b.modeCode(mode)
	if err != nil {
		return err
	}
	return b.setModeBytes(mode, code, data)
}

// setModeBytes is SetMode once the mode is already a CI-V byte and a data flag.
// Split out for the band-stacking recall, which gets both FROM the radio and
// must not go back through an ADIF name to reach them: a register holding CW-R
// or LSB would come back as plain CW or as whatever the band convention says,
// i.e. not the mode the operator left there.
func (b *IcomSerial) setModeBytes(mode string, code byte, data bool) error {
	// Set the base mode (keeping the rig's current filter by sending only the
	// mode byte), then set the data-mode flag for digital modes.
	if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
		// A lost acknowledgement is not a refused mode — see SetPTT. The mode is
		// readable, so ask rather than report a failure that would cost the
		// digital application its connection.
		if !errors.Is(err, errIcomAckLost) {
			return err
		}
		if got, ok := b.readMode(); !ok || got != code {
			return err
		}
		applog.Printf("icom: mode %s not acknowledged, but the rig reports it — treating as done", mode)
	}
	dataByte := byte(0)
	if data {
		dataByte = 1
	}
	// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
	_ = b.execIdempotent("set data mode", civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
	// Trust, then verify: the IC-7760 acknowledges 1A 06 and stays in USB-D
	// anyway, which left an operator unable to get back to plain USB at all.
	// When the readback disagrees, say it again with 0x26 — mode, data flag and
	// filter in one frame, the command the newer rigs actually honour. Only on
	// a mismatch, so rigs that predate 0x26 never see it.
	if got := b.readDataMode(); got != data {
		if err := b.execIdempotent("set mode+data (0x26)", civ.CmdModeDataFil, 0x00, code, dataByte, 0x01); err != nil {
			applog.Printf("icom: data flag stuck at %v after mode %s, and 0x26 failed too: %v", got, mode, err)
		} else {
			applog.Printf("icom: data flag stuck after 1A06, corrected via 0x26 (mode %s)", mode)
		}
	}
	return nil
}

// errIcomAckLost is "the reply never came" — as opposed to a reply that said no
// (NG) or a dead port. Only this one is worth repeating: the command may well
// have been carried out and only its acknowledgement lost. Sentinel rather than
// a formatted string so callers can tell the two apart.
var errIcomAckLost = errors.New("icom: timeout waiting for response")

// execIdempotent runs a SET command and sends it ONCE MORE if the
// acknowledgement is lost.
//
// A missing FB is not a missing command — the rig acts on the frame as soon as it
// decodes it, and what expires is our wait for the answer on a bus shared with
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
// dial and the mode immediately before every key-down, so those acks queue behind
// each other, and losing one was fatal: rigctld answers RPRT -9, JTDX reads that
// as losing rig control and tears the connection down mid-over. An operator's log
// shows it happening on set_ptt, on set_freq and on set_mode alike, each failure
// followed within 300 ms by a fresh rigctld client — and shows this resend
// rescuing a PTT that would otherwise have ended the over.
//
// Only for commands that say "be in this state": re-sending one changes nothing
// if the first arrived. A relative or incremental command must not come through
// here, which is why this is opt-in per caller rather than folded into exec.
func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
	err := b.exec(payload...)
	if err == nil || !errors.Is(err, errIcomAckLost) {
		return err
	}
	applog.Printf("icom: %s — no acknowledgement in %s, sending it once more", what, icomCmdTimeout)
	return b.exec(payload...)
}

// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
// A lost acknowledgement is NOT proof the rig ignored us, and treating it as one
// is expensive: OpsLog fronts a Hamlib rigctl port, so the failure is handed
// straight to WSJT-X or JTDX, which answer a failed set_ptt with a Rig Control
// Error and drop the link mid-over. An IC-7850 log showed exactly that, three
// times in a session, on a CI-V bus with a second controller corrupting frames.
//
// So when the acknowledgement does not arrive, ASK the rig what it is doing. PTT
// is readable (0x1C 0x00), the answer is unambiguous, and a rig that is keyed
// received the command whatever became of its reply.
func (b *IcomSerial) SetPTT(on bool) error {
	state := byte(0)
	if on {
		state = 1
	}
	err := b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
	if err == nil || !errors.Is(err, errIcomAckLost) {
		return err
	}
	// A GENEROUS window for the verification, not the poll's 150 ms.
	//
	// The rig has just failed to answer twice in 800 ms, and the reason seen on a
	// real IC-7850 is that it was retuning: JTDX moves the transmit frequency and
	// keys in the same breath. Asking it again with the short probe timeout would
	// fail for exactly the same reason and prove nothing.
	keyed, ok := b.readTXWithin(icomPTTVerifyTimeout)
	if ok && keyed == on {
		applog.Printf("icom: PTT %v not acknowledged, but the rig reports it — treating as done", on)
		return nil
	}
	// Logged either way, because "the fix did not work" and "the rig really did
	// not key" look identical from outside and need telling apart.
	if ok {
		applog.Printf("icom: PTT %v not acknowledged, and the rig reports %v — reporting the failure", on, keyed)
	} else {
		applog.Printf("icom: PTT %v not acknowledged, and the rig did not answer the check either", on)
	}
	return err
}

// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
// a run of 0xFE — the wake preamble Icom rigs need to notice a command while
// asleep (harmless when already awake); after it the rig boots for ~10-15 s.
// Sent raw with no ack wait, since a rig waking up or shutting down won't
// reliably answer. On the network transport the whole buffer becomes one data
// packet, exactly as the Remote Utility sends it. Power is manual (the app never
// wakes the rig on connect), so this is driven by the panel's ON/OFF button.
func (b *IcomSerial) SetPower(on bool) error {
	if b.port == nil {
		return fmt.Errorf("icom: not connected")
	}
	if on {
		// The preamble has to last long enough IN TIME for the sleeping rig to
		// notice it, so its length scales with the baud rate — Icom's own table
		// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
		// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
		// 19200 and far too short above it, so a rig configured for 115200 simply
		// ignored the command.
		n := b.baud / 768
		if n < 7 {
			n = 7
		} else if n > 150 {
			n = 150
		}
		buf := make([]byte, 0, n+8)
		for i := 0; i < n; i++ {
			buf = append(buf, 0xFE)
		}
		buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
		_, err := b.port.Write(buf)
		applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
		return err
	}
	_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
	return err
}

// ── helpers ───────────────────────────────────────────────────────────────

func (b *IcomSerial) write(payload ...byte) error {
	// Not connected (rig off / port dropped): fail cleanly instead of
	// dereferencing a nil port — a Set* dispatched while disconnected (e.g.
	// clicking Scope ON with the radio off) would otherwise panic the app.
	if b.port == nil {
		return fmt.Errorf("icom: not connected")
	}
	// Drop any stale/unsolicited frames buffered from before this command so
	// recv() only sees the reply to THIS request (avoids a previous command's ack
	// or an unsolicited dial-turn update being mistaken for our response).
	b.drainResp()
	frame := civ.Frame(b.rigAddr, civ.AddrController, payload...)
	traceCIV("TX", frame)
	_, err := b.port.Write(frame)
	return err
}

// recv waits for a frame the reader routed to respCh that satisfies match, or
// times out. The reader has already discarded echoes and split off scope frames,
// so recv only ever sees candidate control replies. It also bails out at once if
// Interrupt() fires (Stop) so an in-flight ReadState — which can be a dozen reads,
// each up to icomReadTimeout when the rig is slow — doesn't make Stop/Save-&-Close
// wait several seconds for the poll goroutine to finish.
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
	b.dialMu.Lock()
	cancel := b.dialCancel
	b.dialMu.Unlock()
	deadline := time.After(timeout)
	for {
		select {
		case f := <-b.respCh:
			if match(f) {
				return f, nil
			}
		case <-cancel:
			return civ.Decoded{}, fmt.Errorf("icom: interrupted")
		case <-deadline:
			return civ.Decoded{}, errIcomAckLost
		}
	}
}

// reader is the sole owner of port.Read. It decodes the CI-V byte stream into
// frames and routes each: our own echoes are dropped, spectrum-scope frames
// (0x27) go to specCh, everything else (control replies, acks, unsolicited
// transceive updates) goes to respCh. It exits when the port is closed.
func (b *IcomSerial) reader(port civTransport, done chan struct{}) {
	defer close(done)
	tmp := make([]byte, 512)
	var rx []byte
	for {
		n, err := port.Read(tmp)
		if err != nil {
			return // port closed or failed — Disconnect/reconnect handles it
		}
		if n == 0 {
			continue // read timeout with no data
		}
		rx = append(rx, tmp[:n]...)
		// Traced BEFORE framing: the bytes as they arrived are what shows a lost
		// frame boundary, which a decoded view would hide.
		traceCIV("RX", tmp[:n])
		frames, consumed := civ.Scan(rx)
		if consumed > 0 {
			rx = append(rx[:0], rx[consumed:]...)
		}
		for _, f := range frames {
			if f.From != b.rigAddr {
				continue // echo of our own command
			}
			// Addressed to US, or broadcast.
			//
			// A CI-V bus can carry a second controller — RS-BA1, wfview, another
			// logger — and the rig answers each of them in turn on the same wire.
			// Those replies come FROM the rig, so the check above lets them through,
			// and one can be taken for the answer to our own command: an IC-7850 log
			// showed "FE FE 01 8E …" (to controller 01) arriving between our PTT
			// command and its acknowledgement, with bus collisions around it.
			// Transceive broadcasts (to 0x00) stay welcome — that is how the rig
			// announces a knob turn.
			if f.To != civ.AddrController && f.To != 0x00 {
				continue
			}
			if f.Cmd == civ.CmdScope {
				b.route(b.specCh, f)
				continue
			}
			b.route(b.respCh, f)
		}
	}
}

// netScopeFeeder decodes the raw scope (0x27) CI-V frames the network transport
// delivers on its own channel and routes them into specCh — the same pipeline
// the USB reader feeds — so scopeLoop assembles them identically. Exits when the
// connection's reader does (done closes on Disconnect).
func (b *IcomSerial) netScopeFeeder(ch <-chan []byte, done chan struct{}) {
	var buf []byte
	for {
		select {
		case <-done:
			return
		case raw, ok := <-ch:
			if !ok {
				return
			}
			buf = append(buf, raw...)
			frames, consumed := civ.Scan(buf)
			if consumed > 0 {
				buf = append(buf[:0], buf[consumed:]...)
			}
			for _, f := range frames {
				if f.From == b.rigAddr && f.Cmd == civ.CmdScope {
					b.route(b.specCh, f)
				}
			}
			if len(buf) > 1<<16 { // a frame that never completes — don't grow forever
				buf = buf[:0]
			}
		}
	}
}

// route delivers a frame without ever blocking the reader: if the channel is
// full it drops the oldest entry to make room for the newest.
func (b *IcomSerial) route(ch chan civ.Decoded, f civ.Decoded) {
	select {
	case ch <- f:
	default:
		select { // buffer full — discard oldest, then enqueue newest
		case <-ch:
		default:
		}
		select {
		case ch <- f:
		default:
		}
	}
}

// drainResp empties any pending control frames (non-blocking).
func (b *IcomSerial) drainResp() {
	for {
		select {
		case <-b.respCh:
		default:
			return
		}
	}
}

// ── spectrum scope (0x27) ───────────────────────────────────────────────────

// scopeLoop reassembles the Icom's divided waveform frames into complete sweeps.
// Frame layout (verified on an IC-7610): Data = [00, main/sub, seq, total, …].
// The first frame (seq==1) is a HEADER — [info, low-edge 5-BCD, high-edge 5-BCD]
// — and carries NO waveform bytes; frames 2..total each carry a block of
// amplitude bytes. So we parse the edges from frame 1 and concatenate frames
// 2..total for the trace.
func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
	regions := make(map[byte][]byte)
	var total byte
	rawN := 0                    // diagnostic: dump the first few raw 0x27 frames
	loggedCfg := map[byte]bool{} // one-shot dump of each config read response
	for {
		select {
		case <-done:
			return
		case f := <-spec:
			if len(f.Data) < 1 {
				continue
			}
			if f.Data[0] != civ.SubScopeData {
				// Non-waveform 0x27 frame = a config read response (mode/span/edge).
				// Log each subcommand once so we can confirm its exact byte layout.
				if !loggedCfg[f.Data[0]] {
					loggedCfg[f.Data[0]] = true
					applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data)
				}
				// The rig just told us its own layout: a mode/span/edge answer of
				// three bytes or more carries the main/sub selector, one of two
				// bytes does not. Worth reading, because the SET commands take the
				// same shape and several firmwares answer a wrong-shaped set with
				// silence rather than a rejection — which is not something the
				// retry in execScope can act on.
				if f.Data[0] == civ.SubScopeMode && len(f.Data) >= 2 {
					if sel := len(f.Data) >= 3; sel != b.dualScope {
						applog.Printf("icom scope: the rig answers 0x%02X with %d bytes — using the %s form",
							f.Data[0], len(f.Data)-1, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[sel])
						b.dualScope = sel
					}
				}
				continue
			}
			if rawN < 24 {
				rawN++
				applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
			}
			// Frame layout after the 0x00 sub-command byte is one of:
			//   [selector][seq][total][data…]  (IC-7610/9700 AND, as the logs prove,
			//                                    the IC-7300 — selector 0x00 = main)
			//   [seq][total][data…]             (a rig with no selector byte)
			// The sequence starts at 1, so a 0x00 at Data[1] can only be the main-scope
			// selector — never a valid sequence. Keying this off the rig ADDRESS was the
			// long-standing "blank scope on the IC-7300" bug: the 7300 DOES send the
			// selector, so idx stayed 1, seq was read as 0x00, and every frame was
			// dropped. Detect it from the byte itself instead — address-independent.
			idx := 1
			if len(f.Data) >= 2 && f.Data[1] == 0x00 {
				idx = 2 // leading main-scope selector byte present
			} else if b.dualScope {
				continue // dual-scope rig SUB frame (selector != 0x00) — main only
			}
			if len(f.Data) < idx+2 {
				continue
			}
			seq, tot := f.Data[idx], f.Data[idx+1]
			region := f.Data[idx+2:]
			if seq == 0 || tot == 0 {
				continue
			}
			if tot == 1 {
				// Network single-frame sweep: the WHOLE sweep is in one frame —
				// region = [info][low 5-BCD][high 5-BCD][amplitude bytes…]. Parse the
				// edges and take the rest as the trace, then publish immediately.
				// (USB splits this across 21 frames; the net rig sends it as one.)
				if len(region) >= 11 {
					// Net single-frame layout (IC-7610): region = [info 1B][freq1 5-BCD]
					// [freq2 5-BCD][amplitude bytes]. The two freq fields depend on the
					// scope mode: FIXED sends [low-edge][high-edge] (both absolute), CENTRE
					// sends [centre][span]. Tell them apart by magnitude — a second value
					// BIGGER than the first is a real high edge; a small one is a span
					// (e.g. 14.200 MHz + 100 kHz → centred 14.150-14.250; 21.000 +
					// 21.070 → fixed 21.000-21.070). Guessing wrong here gave the absurd
					// 21.000-42.070 span (low + a 21 MHz "span").
					v1, ok1 := civ.BCDToFreq(region[1:6])
					v2, ok2 := civ.BCDToFreq(region[6:11])
					if !ok1 || !ok2 {
						// Not decimal BCD — the frame was read at the wrong offset. Keep the
						// edges we had; a wrong span is drawn, noticed, and mistrusted.
						break
					}
					var low, high int64
					if v2 > v1 {
						low, high = v1, v2 // absolute low/high edges (fixed edge set)
					} else {
						low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO)
					}
					amp := append([]byte(nil), region[11:]...)
					b.scopeMu.Lock()
					b.scopeLow, b.scopeHigh = low, high
					b.scopeAmp = amp
					b.scopeSeq++
					firstLog := !b.scopeSeen
					b.scopeSeen = true
					b.scopeMu.Unlock()
					if firstLog {
						head := region
						if len(head) > 16 {
							head = head[:16]
						}
						applog.Printf("icom scope (net 1-frame): head=[% X] v1=%d v2=%d → %d..%d Hz points=%d", head, v1, v2, low, high, len(amp))
					}
				}
				continue
			}
			if seq == 1 { // header frame — begins a new sweep, no waveform data
				regions = make(map[byte][]byte)
				total = tot
				if len(region) >= 11 { // [info][freq1 5-BCD][freq2 5-BCD]
					// Same center/fixed disambiguation as the single-frame (net) path:
					// FIXED sends [low-edge][high-edge] (both absolute), CENTER sends
					// [center][span]. Tell them apart by magnitude — a second value
					// BIGGER than the first is a real high edge; a smaller one is a
					// span. Without this, an IC-7300 in center (VFO-following) mode set
					// high = span (e.g. 100 kHz), so high < low and the panadapter had
					// no valid range to map the trace onto → blank scope. FIXED mode
					// parsed fine, which is why only center mode was broken.
					v1, ok1 := civ.BCDToFreq(region[1:6])
					v2, ok2 := civ.BCDToFreq(region[6:11])
					if !ok1 || !ok2 {
						// Not decimal BCD — the frame was read at the wrong offset. Keep the
						// edges we had; a wrong span is drawn, noticed, and mistrusted.
						break
					}
					var low, high int64
					if v2 > v1 {
						low, high = v1, v2 // absolute low/high edges (fixed)
					} else {
						low, high = v1-v2/2, v1+v2/2 // centre + span (centre-on-VFO)
					}
					b.scopeMu.Lock()
					b.scopeLow, b.scopeHigh = low, high
					b.scopeMu.Unlock()
				}
				continue
			}
			if total == 0 || tot != total {
				continue // stray frame from a sweep whose header we missed
			}
			regions[seq] = append([]byte(nil), region...)
			if seq == total { // last data frame — assemble in sequence order
				b.assembleSweep(regions, total)
			}
		}
	}
}

func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
	var amp []byte
	for s := byte(2); s <= total; s++ {
		amp = append(amp, regions[s]...)
	}
	b.scopeMu.Lock()
	b.scopeAmp = amp
	b.scopeSeq++
	firstLog := !b.scopeSeen
	b.scopeSeen = true
	low, high := b.scopeLow, b.scopeHigh
	b.scopeMu.Unlock()
	if firstLog {
		applog.Printf("icom scope: first sweep — model=%s total=%d points=%d edges=%d..%d Hz",
			b.model, total, len(amp), low, high)
	}
}

// execScope sends a 0x27 SET and, if the rig rejects it, sends it once more in
// the other shape — with or without the leading main/sub selector byte — and
// remembers which one this rig speaks.
//
// The shape used to be decided from the CI-V address, which meant every new
// model was a blank scope until someone reported it: the IC-7851 (0x8E) rejects
// "27 11 01" outright and wants "27 11 00 01", exactly as the IC-7610 does not.
// A rejection is a cheap and unambiguous answer, so ask the rig instead of
// keeping a list. Only the SET commands need this — the waveform parser already
// detects the selector per frame.
func (b *IcomSerial) execScope(what string, sub byte, args ...byte) error {
	try := func(sel bool) error {
		p := []byte{civ.CmdScope, sub}
		if sel {
			p = append(p, 0x00) // main scope
		}
		return b.exec(append(p, args...)...)
	}
	err := try(b.dualScope)
	// Only a REJECTION means "wrong shape". A timeout says nothing (several
	// firmwares simply don't ack a 0x27 set), and retrying it in the other shape
	// would flip a working rig onto the wrong one.
	if err == nil || !strings.Contains(err.Error(), "rejected") {
		return err
	}
	err2 := try(!b.dualScope)
	applog.Printf("icom scope: %s rejected in the %s form — the other form gave: %v",
		what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], err2)
	if err2 == nil {
		b.dualScope = !b.dualScope
		applog.Printf("icom scope: %s rejected — this rig wants the %s form (selector=%v)",
			what, map[bool]string{true: "27 xx 00 …", false: "27 xx …"}[b.dualScope], b.dualScope)
		return nil
	}
	return err
}

// SetScope enables or disables the spectrum scope. Two commands are needed and
// RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig
// streams nothing — the case when we're remote and can't touch the front panel),
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
// routes every 0x27 frame to scopeLoop.
func (b *IcomSerial) SetScope(on bool) error {
	if on {
		// Context for the scope-diagnostic log: which rig + whether we expect the
		// dual-scope (main/sub) frame layout. If the IC-7300 (single scope) streams
		// but nothing shows, compare its `icom scope raw` frames against this.
		applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
			b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
		// Turn the scope DISPLAY on — needed so the rig actually streams (esp. when
		// remote and we can't touch the front panel). ONLY on enable: we must never
		// switch the display OFF, because that is the operator's own scope on the
		// radio, and closing OpsLog (SetScope(false)) blanking a local IC-7300's
		// screen is exactly the regression this avoids. Some firmwares don't ack a
		// 0x27 set; a timeout isn't fatal, so log and continue.
		if err := b.execScope("display on", civ.SubScopeOnOff, 0x01); err != nil {
			applog.Printf("icom scope: display on ack: %v", err)
		}
	}
	// Waveform data OUTPUT over CI-V: enabled with the scope, and — crucially —
	// the ONLY thing we switch off on disable, so the radio's own scope display is
	// left exactly as the operator had it.
	if err := b.execScope("data output", civ.SubScopeOn, boolByte(on)); err != nil {
		applog.Printf("icom scope: output on=%v ack: %v", on, err)
		// Rejected in both shapes = the command does not exist on this rig, which
		// is a permanent answer and not a bad guess on our part. Remember it: the
		// panel can then say so, and we stop asking a radio that has already
		// said no.
		if strings.Contains(err.Error(), "rejected") {
			applog.Printf("icom scope: %s does not stream its scope over CI-V — control commands only", b.model)
			b.scopeMu.Lock()
			b.scopeUnsupported = true
			b.scopeMu.Unlock()
		}
	}
	b.scopeMu.Lock()
	b.scopeOn = on
	if !on {
		b.scopeAmp = nil
	}
	b.scopeMu.Unlock()
	if on {
		// Fire read requests for the mode/span/edge settings; their 0x27 responses
		// route to scopeLoop, which logs each once so we can confirm the layout.
		// Best-effort (fire-and-forget) — responses are 0x27, not FB/FA acks.
		b.scopeReadCfg()
	}
	return nil
}

// scopeReadCfg requests the scope's mode/span/edge settings for the diagnostic
// log. Sent both with and without the leading main/sub selector byte so we
// capture whichever form the rig answers.
func (b *IcomSerial) scopeReadCfg() {
	for _, sub := range []byte{civ.SubScopeMode, civ.SubScopeSpan, civ.SubScopeEdge} {
		_ = b.write(civ.CmdScope, sub)
		if b.dualScope {
			_ = b.write(civ.CmdScope, sub, 0x00)
		}
	}
}

// SetScopeMode selects fixed-span (true) or center-on-VFO (false). Center mode
// makes the scope follow the VFO, so tuning pans the view left/right.
func (b *IcomSerial) SetScopeMode(fixed bool) error {
	mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
	if err := b.execScope("set mode", civ.SubScopeMode, mode); err != nil {
		applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
	}
	b.scopeMu.Lock()
	b.scopeFixed = fixed
	b.scopeMu.Unlock()
	return nil
}

// icScopeRanges maps a frequency to the IC-7610's spectrum edge-frequency range
// id (from Hamlib's ic7610 caps). SetScopeEdges needs it to address the right
// fixed-edge memory. Each range spans a chunk of the tuning range.
var icScopeRanges = []struct {
	lo, hi int64
	id     byte
}{
	{30_000, 1_600_000, 1}, {1_600_000, 2_000_000, 2}, {2_000_000, 6_000_000, 3},
	{6_000_000, 8_000_000, 4}, {8_000_000, 11_000_000, 5}, {11_000_000, 15_000_000, 6},
	{15_000_000, 20_000_000, 7}, {20_000_000, 22_000_000, 8}, {22_000_000, 26_000_000, 9},
	{26_000_000, 30_000_000, 10}, {30_000_000, 45_000_000, 11}, {45_000_000, 60_000_000, 12},
}

// scopeRangeBCD returns the range id (as a 1-byte BCD) for a frequency, or 0 if
// out of range.
func scopeRangeBCD(freq int64) byte {
	for _, r := range icScopeRanges {
		if freq >= r.lo && freq < r.hi {
			return byte(r.id/10)<<4 | byte(r.id%10) // 1-byte BCD (11 → 0x11)
		}
	}
	return 0
}

// SetScopeEdges points the FIXED-mode scope at [low..high] by writing them into
// the rig's fixed-edge memory (edge set 1) and making that set active. This is
// how the panel's "centre on VFO" and pan ◀/▶ work: they just compute VFO±50 kHz
// (and shift it) and set the edges — no dependence on the waveform decode. CI-V:
// 0x27 0x14 fixed, 0x27 0x16 set 1 active, 0x27 0x1e [range][set][low][high].
func (b *IcomSerial) SetScopeEdges(low, high int64) error {
	if low <= 0 || high <= low {
		return fmt.Errorf("icom scope: bad edges %d..%d", low, high)
	}
	rangeID := scopeRangeBCD((low + high) / 2)
	if rangeID == 0 {
		return fmt.Errorf("icom scope: freq out of range")
	}
	_ = b.execScope("fixed mode", civ.SubScopeMode, 0x01)
	_ = b.execScope("edge set 1", civ.SubScopeEdge, 0x01)
	payload := append([]byte{civ.CmdScope, civ.SubScopeFixEdge, rangeID, 0x01}, civ.FreqToBCD(low)...)
	payload = append(payload, civ.FreqToBCD(high)...)
	b.scopeMu.Lock()
	b.scopeFixed = true
	b.scopeMu.Unlock()
	return b.exec(payload...)
}

// SetRIT sets the RIT/ΔTX offset (signed Hz, ±9999).
func (b *IcomSerial) SetRIT(hz int) error {
	if err := b.exec(append([]byte{civ.CmdRIT, civ.SubRITFreq}, civ.RITToBCD(hz)...)...); err != nil {
		return err
	}
	if hz < -9999 {
		hz = -9999
	}
	if hz > 9999 {
		hz = 9999
	}
	b.setCache(func(s *IcomTXState) { s.RITHz = hz })
	return nil
}

func (b *IcomSerial) SetRITOn(on bool) error {
	if err := b.exec(civ.CmdRIT, civ.SubRITOn, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.RITOn = on })
	return nil
}

func (b *IcomSerial) SetXITOn(on bool) error {
	if err := b.exec(civ.CmdRIT, civ.SubXITOn, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.XITOn = on })
	return nil
}

// SendCW keys a CW message through the rig's internal keyer (CI-V 0x17). The
// text is upper-cased and filtered to keyer-legal characters; the radio must be
// in CW mode. Messages longer than 30 characters are split across several 0x17
// commands (the rig queues them).
func (b *IcomSerial) SendCW(text string) error {
	msg := civ.FilterCW(text)
	if msg == "" {
		applog.Printf("icom cw: nothing to send (filtered %q → empty)", text)
		return nil
	}
	applog.Printf("icom cw: send %q (%d chars) to rig 0x%02X", msg, len(msg), b.rigAddr)
	for len(msg) > 0 {
		n := len(msg)
		if n > 30 {
			n = 30
		}
		chunk := msg[:n]
		msg = msg[n:]
		payload := append([]byte{civ.CmdSendCW}, []byte(chunk)...)
		if err := b.write(payload...); err != nil {
			applog.Printf("icom cw: write failed: %v", err)
			return err
		}
		// A missing ack is NOT fatal: some firmwares don't acknowledge 0x17, and
		// the message bytes were already written. Only an explicit NG (0xFA) means
		// the rig refused it (typically: not in CW mode / break-in off).
		f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG })
		if err != nil {
			applog.Printf("icom cw: chunk %q written, no ack (sent anyway): %v", chunk, err)
		} else if f.Cmd == civ.NG {
			applog.Printf("icom cw: rig REJECTED CW (0xFA) — put the rig in CW mode / enable break-in")
			return fmt.Errorf("icom: rig rejected CW — check CW mode / break-in")
		} else {
			applog.Printf("icom cw: chunk %q acked OK", chunk)
		}
	}
	return nil
}

// SetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL). Break-in must be on for
// the 0x17 CW keyer to actually switch the rig to transmit.
func (b *IcomSerial) SetBreakIn(mode int) error {
	if mode < 0 {
		mode = 0
	}
	if mode > 2 {
		mode = 2
	}
	applog.Printf("icom cw: set break-in %d", mode)
	if err := b.exec(civ.CmdSwitch, civ.SubSwBreakIn, byte(mode)); err != nil {
		applog.Printf("icom cw: set break-in failed: %v", err)
		return err
	}
	b.setCache(func(s *IcomTXState) { s.BreakIn = mode })
	return nil
}

// StopCW aborts a CW message currently being sent (0x17 with the 0xFF stop code).
func (b *IcomSerial) StopCW() error {
	applog.Printf("icom cw: stop")
	if err := b.write(civ.CmdSendCW, civ.StopCWByte); err != nil {
		return err
	}
	_, _ = b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG })
	return nil
}

// SetKeySpeed sets the CW keyer speed in WPM (CmdLevel 0x0C).
func (b *IcomSerial) SetKeySpeed(wpm int) error {
	lvl := civ.WPMToKeyLevel(wpm)
	applog.Printf("icom cw: set key speed %d WPM (level %d)", wpm, lvl)
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelKeySpeed}, civ.LevelToBCD(lvl)...)...); err != nil {
		applog.Printf("icom cw: set key speed failed: %v", err)
		return err
	}
	if wpm < civ.KeyMinWPM {
		wpm = civ.KeyMinWPM
	}
	if wpm > civ.KeyMaxWPM {
		wpm = civ.KeyMaxWPM
	}
	b.setCache(func(s *IcomTXState) { s.KeySpeedWPM = wpm })
	return nil
}

// readRIT reads the offset + RIT/ΔTX on-off flags into st (best-effort).
func (b *IcomSerial) readRIT(st *IcomTXState) {
	if err := b.write(civ.CmdRIT, civ.SubRITFreq); err == nil {
		if f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
			return d.Cmd == civ.CmdRIT && len(d.Data) >= 4 && d.Data[0] == civ.SubRITFreq
		}); err == nil {
			st.RITHz = civ.BCDToRIT(f.Data[1:4])
		}
	}
	if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubRITOn); ok {
		st.RITOn = v != 0
	}
	if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubXITOn); ok {
		st.XITOn = v != 0
	}
}

// readSwitchSub reads a 1-byte on/off value for cmd+sub (generalises readSwitch).
func (b *IcomSerial) readSwitchSub(cmd, sub byte) (byte, bool) {
	if err := b.write(cmd, sub); err != nil {
		return 0, false
	}
	f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
		return d.Cmd == cmd && len(d.Data) >= 2 && d.Data[0] == sub
	})
	if err != nil {
		return 0, false
	}
	return f.Data[1], true
}

// ScopeData returns a copy of the latest reassembled sweep as a number array.
func (b *IcomSerial) ScopeData() ScopeSweep {
	b.scopeMu.Lock()
	defer b.scopeMu.Unlock()
	amp := make([]int, len(b.scopeAmp))
	for i, v := range b.scopeAmp {
		amp[i] = int(v)
	}
	return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed,
		Unsupported: b.scopeUnsupported}
}

// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
func (b *IcomSerial) exec(payload ...byte) error {
	if err := b.write(payload...); err != nil {
		return err
	}
	f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.OK || d.Cmd == civ.NG
	})
	if err != nil {
		return err
	}
	if f.Cmd == civ.NG {
		return fmt.Errorf("icom: rig rejected command 0x%02X", payload[0])
	}
	return nil
}

func (b *IcomSerial) readFreq() (int64, error) {
	if err := b.write(civ.CmdReadFreq); err != nil {
		return 0, err
	}
	f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdReadFreq || d.Cmd == civ.CmdTransceiveFreq
	})
	if err != nil {
		return 0, err
	}
	hz, ok := civ.BCDToFreq(f.Data)
	if !ok {
		// A frame that is not decimal BCD is a desynchronised read, not a
		// frequency. Reporting the error keeps the caller on its last good value
		// instead of publishing a number like 16445550000 Hz to the display.
		return 0, fmt.Errorf("icom: frequency frame is not BCD: % X", f.Data)
	}
	return hz, nil
}

// readSplit reads the rig's split state (CI-V 0x0F). 0x01 = split on; 0x10/0x11
// are repeater duplex (not split) and 0x00 is off.
func (b *IcomSerial) readSplit() (on bool, ok bool) {
	if err := b.write(civ.CmdSplit); err != nil {
		return false, false
	}
	f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdSplit && len(d.Data) >= 1
	})
	if err != nil {
		return false, false
	}
	return f.Data[0] == 0x01, true
}

// readTXFreq reads the UNSELECTED VFO's frequency (CI-V 0x25/01) — the TX VFO
// when the rig is in split. Supported on IC-7610/7300/7851/705/9700 and similar.
func (b *IcomSerial) readTXFreq() (int64, bool) {
	if err := b.write(civ.CmdVfoFreq, civ.SubVfoUnselected); err != nil {
		return 0, false
	}
	f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdVfoFreq && len(d.Data) >= 6 && d.Data[0] == civ.SubVfoUnselected
	})
	if err != nil {
		return 0, false
	}
	hz, ok := civ.BCDToFreq(f.Data[1:])
	if !ok {
		return 0, false
	}
	return hz, true
}

// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
func (b *IcomSerial) readTX() bool {
	on, _ := b.readTXWithin(icomDSPTimeout)
	return on
}

// icomPTTVerifyTimeout is how long the PTT check waits. Long, deliberately: it
// runs only after a command was already given up on, so it costs nothing in the
// normal case and everything depends on it in the abnormal one.
const icomPTTVerifyTimeout = 700 * time.Millisecond

// readTXWithin reads the transmit state, reporting whether the rig ANSWERED.
//
// The two are different questions and the caller usually needs both: "not
// transmitting" and "did not say" are the same bool but not the same fact.
func (b *IcomSerial) readTXWithin(timeout time.Duration) (on bool, answered bool) {
	if err := b.write(civ.CmdPTT, civ.SubPTT); err != nil {
		return false, false
	}
	f, err := b.recv(timeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdPTT && len(d.Data) >= 2 && d.Data[0] == civ.SubPTT
	})
	if err != nil {
		return false, false
	}
	return f.Data[1] != 0, true
}

// readMeter reads a meter (CI-V 0x15) and returns it scaled to 0-100.
func (b *IcomSerial) readMeter(sub byte) (int, bool) {
	if err := b.write(civ.CmdMeter, sub); err != nil {
		return 0, false
	}
	f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdMeter && len(d.Data) >= 3 && d.Data[0] == sub
	})
	if err != nil {
		return 0, false
	}
	return from255(civ.BCDToLevel(f.Data[1:3])), true
}

func (b *IcomSerial) readMode() (byte, bool) {
	if err := b.write(civ.CmdReadMode); err != nil {
		return 0, false
	}
	f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
		return (d.Cmd == civ.CmdReadMode || d.Cmd == civ.CmdTransceiveMode) && len(d.Data) >= 1
	})
	if err != nil {
		return 0, false
	}
	return f.Data[0], true
}

func (b *IcomSerial) readDataMode() bool {
	if err := b.write(civ.CmdExtra, civ.SubDataMode); err != nil {
		return false
	}
	f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubDataMode
	})
	if err != nil {
		return false
	}
	return f.Data[1] != 0
}

// modeCode maps an ADIF mode to an Icom mode byte plus whether the data-mode
// flag should be set. SSB sideband follows the usual convention (LSB below
// 10 MHz, USB above); the frequency just commanded is preferred over the last
// poll so a clicked spot (freq then mode) picks the right sideband immediately.
func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
	freq := b.curFreq
	if b.lastSetFreq > 0 && time.Since(b.lastSetFreqAt) < 5*time.Second {
		freq = b.lastSetFreq
	}
	usb := byte(civ.ModeUSB)
	if freq > 0 && freq < 10_000_000 {
		usb = civ.ModeLSB
	}
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "CW":
		return civ.ModeCW, false, nil
	case "SSB":
		return usb, false, nil
	case "USB":
		// The SIDEBAND, asked for by name. "SSB" resolves to whichever side the
		// band convention wants, which is right for a logged mode and useless
		// when the operator means "put this radio in USB" — on 40 m there was no
		// way to say it at all, and the console's own button could not either.
		return civ.ModeUSB, false, nil
	case "LSB":
		return civ.ModeLSB, false, nil
	case "AM":
		return civ.ModeAM, false, nil
	case "FM":
		return civ.ModeFM, false, nil
	case "RTTY", "FSK":
		return civ.ModeRTTY, false, nil
	case "PSK":
		// The console button, not the ADIF mode: the rigs that HAVE a native PSK
		// mode (7610/7760/7851 class) get it; a 7300 NAKs 0x12 and the button
		// stays dead there, exactly as RS-BA1's does. Soundcard PSK31 stays on
		// USB-D below, where every rig can do it.
		return civ.ModePSK, false, nil
	case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
		// Digital data modes ride on USB with the data flag set (FT8 etc.).
		return civ.ModeUSB, true, nil
	}
	return 0, false, fmt.Errorf("icom: unsupported mode %q", mode)
}

// ── IcomController: receive-DSP controls for the Icom tab ───────────────────

func (b *IcomSerial) IcomState() IcomTXState {
	// Asking for the snapshot is what marks the console as being watched, which
	// is what re-enables the meter polling in ReadState.
	b.dspWantMu.Lock()
	b.dspWantAt = time.Now()
	b.dspWantMu.Unlock()

	b.dspMu.Lock()
	defer b.dspMu.Unlock()
	return b.dsp
}

// panelWatched reports whether the Icom console asked for the DSP snapshot
// recently — i.e. whether anything is on screen to read the meters.
func (b *IcomSerial) panelWatched() bool {
	b.dspWantMu.Lock()
	at := b.dspWantAt
	b.dspWantMu.Unlock()
	return !at.IsZero() && time.Since(at) < 3*time.Second
}

// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
// goroutine (dispatched via IcomDo).
func (b *IcomSerial) RefreshIcom() error {
	if b.port == nil {
		return fmt.Errorf("not connected")
	}
	b.readDSP()
	return nil
}

// readDSP polls every DSP value once and replaces the cache. Best-effort: a
// value the rig doesn't answer keeps its previous cached value rather than
// stalling (each read has a short timeout).
func (b *IcomSerial) readDSP() {
	st := IcomTXState{Available: true, Model: b.model}
	b.dspMu.Lock()
	// Preserve the live fields ReadState polls (mode, TX/split, meters) — readDSP
	// only refreshes the set-once DSP values.
	st.Mode = b.dsp.Mode
	st.Transmitting = b.dsp.Transmitting
	st.Split = b.dsp.Split
	st.SMeter = b.dsp.SMeter
	st.PowerMeter = b.dsp.PowerMeter
	st.SWRMeter = b.dsp.SWRMeter
	b.dspMu.Unlock()

	if v, ok := b.readLevel(civ.SubLevelAF); ok {
		st.AFGain = from255(v)
	}
	if v, ok := b.readLevel(civ.SubLevelRF); ok {
		st.RFGain = from255(v)
	}
	if v, ok := b.readLevel(civ.SubLevelRFPower); ok {
		st.RFPower = from255(v)
	}
	if v, ok := b.readLevel(civ.SubLevelMic); ok {
		st.MicGain = from255(v)
	}
	if v, ok := b.readLevel(civ.SubLevelNR); ok {
		st.NRLevel = from255(v)
	}
	if v, ok := b.readLevel(civ.SubLevelNB); ok {
		st.NBLevel = from255(v)
	}
	if v, ok := b.readSwitch(civ.SubSwNB); ok {
		st.NB = v != 0
	}
	if v, ok := b.readSwitch(civ.SubSwNR); ok {
		st.NR = v != 0
	}
	if v, ok := b.readSwitch(civ.SubSwANF); ok {
		st.ANF = v != 0
	}
	if v, ok := b.readSwitch(civ.SubSwAPF); ok {
		st.APF = v != 0
	}
	if v, ok := b.readSwitch(civ.SubSwAGC); ok {
		st.AGC = agcName(v)
	}
	if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
		st.Preamp = int(v)
	}
	if v, ok := b.readAtt(); ok {
		st.Att = v
	}
	if _, f, ok := b.readModeFilter(); ok {
		st.Filter = int(f)
	}
	b.readRIT(&st)
	if v, ok := b.readLevel(civ.SubLevelKeySpeed); ok {
		st.KeySpeedWPM = civ.KeyLevelToWPM(v)
	}
	if v, ok := b.readSwitch(civ.SubSwBreakIn); ok {
		st.BreakIn = int(v)
	}
	// Antenna + filter fine controls + TX extras.
	if v, ok := b.readAnt(); ok {
		st.Antenna = v
	}
	if v, ok := b.readLevel(civ.SubLevelPBTIn); ok {
		st.PBTInner = from255(v)
	}
	if v, ok := b.readLevel(civ.SubLevelPBTOut); ok {
		st.PBTOuter = from255(v)
	}
	if v, ok := b.readSwitch(civ.SubSwMN); ok {
		st.ManualNotch = v != 0
	}
	if v, ok := b.readLevel(civ.SubLevelNotch); ok {
		st.NotchPos = from255(v)
	}
	if v, ok := b.readLevel(civ.SubLevelSQL); ok {
		st.Squelch = from255(v)
	}
	if v, ok := b.readSwitch(civ.SubSwComp); ok {
		st.Comp = v != 0
	}
	if v, ok := b.readLevel(civ.SubLevelComp); ok {
		st.CompLevel = from255(v)
	}
	if v, ok := b.readSwitch(civ.SubSwMon); ok {
		st.Monitor = v != 0
	}
	if v, ok := b.readLevel(civ.SubLevelMon); ok {
		st.MonLevel = from255(v)
	}
	if v, ok := b.readSwitch(civ.SubSwVOX); ok {
		st.VOX = v != 0
	}
	if v, ok := b.readLevel(civ.SubLevelVOXGain); ok {
		st.VOXGain = from255(v)
	}
	if v, ok := b.readLevel(civ.SubLevelAntiVOX); ok {
		st.AntiVOX = from255(v)
	}

	b.dspMu.Lock()
	b.dsp = st
	b.dspMu.Unlock()
}

const icomDSPTimeout = 150 * time.Millisecond // shorter: unsupported reads mustn't stall the poll

func (b *IcomSerial) readLevel(sub byte) (int, bool) {
	if err := b.write(civ.CmdLevel, sub); err != nil {
		return 0, false
	}
	f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdLevel && len(d.Data) >= 3 && d.Data[0] == sub
	})
	if err != nil {
		return 0, false
	}
	return civ.BCDToLevel(f.Data[1:3]), true
}

func (b *IcomSerial) readSwitch(sub byte) (byte, bool) {
	if err := b.write(civ.CmdSwitch, sub); err != nil {
		return 0, false
	}
	f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdSwitch && len(d.Data) >= 2 && d.Data[0] == sub
	})
	if err != nil {
		return 0, false
	}
	return f.Data[1], true
}

func (b *IcomSerial) readAtt() (int, bool) {
	if err := b.write(civ.CmdAtt); err != nil {
		return 0, false
	}
	f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdAtt && len(d.Data) >= 1
	})
	if err != nil {
		return 0, false
	}
	return civ.BCDToByte(f.Data[0]), true
}

func (b *IcomSerial) readAnt() (int, bool) {
	if err := b.write(civ.CmdAnt); err != nil {
		return 0, false
	}
	f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdAnt && len(d.Data) >= 1
	})
	if err != nil {
		return 0, false
	}
	if f.Data[0] == 0x01 {
		return 2, true
	}
	return 1, true
}

func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
	if err := b.write(civ.CmdReadMode); err != nil {
		return 0, 0, false
	}
	f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdReadMode && len(d.Data) >= 2
	})
	if err != nil {
		return 0, 0, false
	}
	return f.Data[0], f.Data[1], true
}

func (b *IcomSerial) SetAFGain(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAF}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.AFGain = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetRFGain(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRF}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.RFGain = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetNB(on bool) error {
	if err := b.exec(civ.CmdSwitch, civ.SubSwNB, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.NB = on })
	return nil
}

func (b *IcomSerial) SetNBLevel(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNB}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.NBLevel = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetNR(on bool) error {
	if err := b.exec(civ.CmdSwitch, civ.SubSwNR, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.NR = on })
	return nil
}

func (b *IcomSerial) SetNRLevel(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNR}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.NRLevel = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetANF(on bool) error {
	if err := b.exec(civ.CmdSwitch, civ.SubSwANF, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.ANF = on })
	return nil
}

func (b *IcomSerial) SetAPF(on bool) error {
	if err := b.exec(civ.CmdSwitch, civ.SubSwAPF, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.APF = on })
	return nil
}

func (b *IcomSerial) SetAGC(name string) error {
	v := agcValue(name)
	if v == 0 {
		return fmt.Errorf("icom: invalid AGC %q", name)
	}
	if err := b.exec(civ.CmdSwitch, civ.SubSwAGC, v); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.AGC = strings.ToUpper(name) })
	return nil
}

func (b *IcomSerial) SetPreamp(n int) error {
	if n < 0 || n > 2 {
		return fmt.Errorf("icom: invalid preamp %d", n)
	}
	if err := b.exec(civ.CmdSwitch, civ.SubSwPreamp, byte(n)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.Preamp = n })
	return nil
}

func (b *IcomSerial) SetAtt(db int) error {
	if err := b.exec(civ.CmdAtt, civ.ByteToBCD(db)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.Att = db })
	return nil
}

func (b *IcomSerial) SetIcomFilter(n int) error {
	if n < 1 || n > 3 {
		return fmt.Errorf("icom: invalid filter %d", n)
	}
	if b.curModeByte == 0 {
		// Need the current mode to re-send with the chosen filter.
		if m, _, ok := b.readModeFilter(); ok {
			b.curModeByte = m
		}
	}
	if err := b.exec(civ.CmdSetMode, b.curModeByte, byte(n)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.Filter = n })
	return nil
}

func (b *IcomSerial) SetRFPower(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRFPower}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.RFPower = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetMicGain(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMic}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.MicGain = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetIcomSplit(on bool) error {
	return b.SetIcomSplitOffset(on, 0)
}

// SetIcomSplitOffset enables split with a CHOSEN TX offset in Hz (0 = the
// usual convention: +1 kHz on CW, +5 kHz otherwise). The console offers
// +1/+5/+10 directly, DXpedition style.
func (b *IcomSerial) SetIcomSplitOffset(on bool, offsetHz int64) error {
	if on {
		// Set the unselected (TX) VFO to RX+offset first, then turn split on.
		// 0x25 0x01 + BCD sets the unselected VFO's frequency.
		rx := b.curFreq
		if rx <= 0 {
			if hz, err := b.readFreq(); err == nil {
				rx = hz
			}
		}
		if rx > 0 {
			offset := offsetHz
			if offset <= 0 {
				offset = 5000
				if b.curModeByte == civ.ModeCW || b.curModeByte == civ.ModeCWR {
					offset = 1000
				}
			}
			_ = b.exec(append([]byte{civ.CmdVfoFreq, civ.SubVfoUnselected}, civ.FreqToBCD(rx+offset)...)...)
		}
		// And the MODE crosses with it: a split where the TX VFO is still on
		// yesterday's mode transmits FM into a CW pileup. 0x26 0x01 sets the
		// unselected VFO's mode + data flag in one frame; rigs that predate
		// 0x26 NAK it harmlessly and behave as they always did.
		if b.curModeByte == 0 {
			// A fresh session may not have read the mode yet — ask, or the copy
			// below would send mode 0 (LSB) whatever the main is on.
			if m, ok := b.readMode(); ok {
				b.curModeByte = m
			}
		}
		if b.curModeByte != 0 {
			dataByte := byte(0)
			if got := b.readDataMode(); got {
				dataByte = 1
			}
			// Logged, not discarded: the first version swallowed the error and a
			// real 7760's sub VFO kept yesterday's mode with nothing to show why.
			if err := b.exec(civ.CmdModeDataFil, 0x01, b.curModeByte, dataByte, 0x01); err != nil {
				applog.Printf("icom: split mode-align (26 01 %02X %d) refused: %v", b.curModeByte, dataByte, err)
			} else {
				applog.Printf("icom: split mode-align OK (26 01 %02X %d)", b.curModeByte, dataByte)
			}
		}
	}
	if err := b.exec(civ.CmdSplit, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.Split = on })
	return nil
}

// ── Antenna ────────────────────────────────────────────────────────────────

func (b *IcomSerial) SetAntenna(n int) error {
	sub := byte(0x00) // ANT1
	if n == 2 {
		sub = 0x01 // ANT2
	}
	if err := b.exec(civ.CmdAnt, sub); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) {
		if n == 2 {
			s.Antenna = 2
		} else {
			s.Antenna = 1
		}
	})
	return nil
}

// ── Filter: Twin PBT + manual notch ────────────────────────────────────────

func (b *IcomSerial) SetPBTInner(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTIn}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.PBTInner = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetPBTOuter(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTOut}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.PBTOuter = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetManualNotch(on bool) error {
	if err := b.exec(civ.CmdSwitch, civ.SubSwMN, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.ManualNotch = on })
	return nil
}

func (b *IcomSerial) SetNotchPos(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNotch}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.NotchPos = clampPct(p) })
	return nil
}

// ── TX extras: squelch / compressor / monitor / VOX ────────────────────────

func (b *IcomSerial) SetSquelch(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelSQL}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.Squelch = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetComp(on bool) error {
	if err := b.exec(civ.CmdSwitch, civ.SubSwComp, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.Comp = on })
	return nil
}

func (b *IcomSerial) SetCompLevel(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelComp}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.CompLevel = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetMonitor(on bool) error {
	if err := b.exec(civ.CmdSwitch, civ.SubSwMon, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.Monitor = on })
	return nil
}

func (b *IcomSerial) SetMonLevel(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMon}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.MonLevel = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetVOX(on bool) error {
	if err := b.exec(civ.CmdSwitch, civ.SubSwVOX, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.VOX = on })
	return nil
}

func (b *IcomSerial) SetVOXGain(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelVOXGain}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.VOXGain = clampPct(p) })
	return nil
}

func (b *IcomSerial) SetAntiVOX(p int) error {
	if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAntiVOX}, civ.LevelToBCD(to255(p))...)...); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.AntiVOX = clampPct(p) })
	return nil
}

// TuneATU triggers a one-shot antenna-tuner tune (CI-V 0x1C 0x01 0x02).
func (b *IcomSerial) TuneATU() error {
	return b.exec(civ.CmdATU, civ.SubATU, 0x02)
}

// SetATU engages or DISENGAGES the internal tuner (0x1C 0x01: 1 = in line,
// 0 = through). Tune existed without this, which meant a tuner once engaged
// could not be taken out of line again from the console.
func (b *IcomSerial) SetATU(on bool) error {
	if err := b.exec(civ.CmdATU, civ.SubATU, boolByte(on)); err != nil {
		return err
	}
	b.setCache(func(s *IcomTXState) { s.ATUOn = on })
	return nil
}

func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
	b.dspMu.Lock()
	fn(&b.dsp)
	b.dspMu.Unlock()
}

// ── small helpers ──────────────────────────────────────────────────────────

func to255(p int) int    { return clampPct(p) * 255 / 100 }
func from255(v int) int  { return (v*100 + 127) / 255 }
func clampPct(p int) int { return min(100, max(0, p)) }

func boolByte(on bool) byte {
	if on {
		return 1
	}
	return 0
}

func agcName(v byte) string {
	switch v {
	case 1:
		return "FAST"
	case 2:
		return "MID"
	case 3:
		return "SLOW"
	}
	return ""
}

func agcValue(name string) byte {
	switch strings.ToUpper(strings.TrimSpace(name)) {
	case "FAST":
		return 1
	case "MID":
		return 2
	case "SLOW":
		return 3
	}
	return 0
}

// PlayTXAudio hands a voice-keyer message to the network audio session, when
// this rig is reached over one. A USB-connected Icom takes its audio through
// its own sound card instead, and says so.
func (b *IcomSerial) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
	// port is written once per Connect, on the CAT goroutine this is fetched
	// from (IcomDo); at worst a reconnect leaves this one connection stale, and
	// a stale transport fails fast on its closed done channel.
	port := b.port
	// A nil port fails the assertions below too — and used to fail them with
	// the sound-card message, sending an operator whose radio was simply OFF
	// hunting through their audio devices.
	if port == nil {
		return fmt.Errorf("not connected to the radio — check the CAT link")
	}
	type txPlayer interface {
		PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error
	}
	if p, ok := port.(txPlayer); ok {
		return p.PlayTXAudio(pcm, rate, ch, bits, stop)
	}
	return fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
}

// TXAudioSender hands back the network transport's live-mic sender, when this
// rig is reached over one.
func (b *IcomSerial) TXAudioSender() (func([]byte) error, error) {
	port := b.port
	if port == nil {
		return nil, fmt.Errorf("not connected to the radio — check the CAT link")
	}
	type sender interface {
		TXAudioSender() (func([]byte) error, error)
	}
	if p, ok := port.(sender); ok {
		return p.TXAudioSender()
	}
	return nil, fmt.Errorf("this rig takes transmit audio through its USB sound card, not the CAT link")
}

// ── Band stacking registers ───────────────────────────────────────────────

// RecallBandStack puts the VFO where the operator last was on a band, by asking
// the radio rather than by holding an opinion about it.
//
// The console's band buttons used to send a frequency chosen in software — a
// reasonable middle-of-the-band number, and never where anybody actually
// operates. The radio already knows better: every band key press it has ever
// had is remembered in that band's stacking registers, so register 1 is the
// last place used on that band, and cycling through 2 and 3 walks back through
// the ones before it — CW where CW was worked, and the FT8 frequency where FT8
// was worked, without either being written down anywhere.
//
// Reads the register, then sets frequency and mode from it. Returns the
// frequency it landed on, so the caller can say where it went; a register the
// rig will not read leaves the radio untouched and returns an error, which is
// what makes the caller's fallback to a plain frequency safe.
func (b *IcomSerial) RecallBandStack(band, reg int) (int64, error) {
	if b.port == nil {
		return 0, fmt.Errorf("not connected")
	}
	if band <= 0 || reg < 1 || reg > 3 {
		return 0, fmt.Errorf("icom: band stack %d/%d is not a register", band, reg)
	}
	bb, rb := civ.ByteToBCD(band), civ.ByteToBCD(reg)
	if err := b.write(civ.CmdExtra, civ.SubBandStack, bb, rb); err != nil {
		return 0, err
	}
	f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
		return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubBandStack
	})
	if err != nil {
		return 0, err
	}
	bs, ok := civ.DecodeBandStack(f.Data, bb, rb)
	if !ok {
		// Logged with the raw frame: the register layout has a tail that differs
		// between models, and a rig that answers something we cannot read is the
		// one thing worth seeing here.
		applog.Printf("icom: band stack %d/%d — cannot read the register from % X", band, reg, f.Data)
		return 0, fmt.Errorf("icom: band stacking register %d/%d not understood", band, reg)
	}
	if err := b.SetFrequency(bs.FreqHz); err != nil {
		return 0, err
	}
	// The mode is best-effort. Landing on the right frequency in the wrong mode
	// is a nuisance; refusing the whole recall over it would send the operator
	// back to a button that does less.
	if bs.Mode != 0 {
		if err := b.setModeBytes(civ.ModeToADIF(bs.Mode, bs.Data), bs.Mode, bs.Data); err != nil {
			applog.Printf("icom: band stack %d/%d — frequency set, mode 0x%02X refused: %v", band, reg, bs.Mode, err)
		}
	}
	return bs.FreqHz, nil
}
