// Package cat drives the transceiver via swappable backends (OmniRig, Flex…)
// and pushes state changes to the UI through an injected emitter callback.
//
// The poll loop runs on an OS-thread-locked goroutine so COM-based backends
// (OmniRig) work correctly — COM is thread-affine on Windows and must be
// initialised, used and uninitialised from the same OS thread.
package cat

import (
	"fmt"
	"runtime"
	"strings"
	"sync"
	"time"
)

// Backend abstracts a specific transceiver-control library. All methods run
// on the dedicated CAT goroutine spawned by Manager — implementations can
// assume single-threaded access and can safely manage thread-bound resources
// (e.g. COM objects in OmniRig).
type Backend interface {
	Name() string // "omnirig" | "flex" | …
	Connect() error
	Disconnect()
	ReadState() (RigState, error)
	SetFrequency(hz int64) error
	// SetMode receives an ADIF mode string (SSB, CW, FT8, RTTY, AM, FM…).
	// Implementations decide USB vs LSB (typically by current freq) and
	// generic vs specific digital modes (most rigs just have DATA).
	SetMode(mode string) error
	// SetPTT keys (on=true) or unkeys the transmitter. Used by the Digital
	// Voice Keyer to put the rig into TX while a message plays.
	SetPTT(on bool) error
}

// interruptible is an OPTIONAL backend capability: abort an in-progress Connect
// quickly. The network Icom backend's Connect blocks for up to tens of seconds
// (UDP handshake + login + waiting for the rig to boot from standby); without a
// way to interrupt it, Stop()/Start() would freeze on the poll goroutine until
// the dial gives up — which is why Settings "Save & Close" hung for ~1 min once
// the link was lost. Backends that don't implement it are simply not interrupted.
type interruptible interface {
	Interrupt()
}

// RigState is the snapshot exchanged with the frontend.
//
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
// rig is in split, FreqHz is the inactive VFO (where the operator transmits)
// and RxFreqHz is the active VFO (where they listen). When not split,
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
type RigState struct {
	Enabled   bool      `json:"enabled"`              // user toggled CAT on
	Connected bool      `json:"connected"`            // backend says rig is online
	Backend   string    `json:"backend,omitempty"`    // active backend name
	RigNum    int       `json:"rig_num,omitempty"`    // OmniRig slot 1 or 2 (when applicable)
	Rig       string    `json:"rig,omitempty"`        // rig model (best-effort)
	FreqHz    int64     `json:"freq_hz,omitempty"`    // TX freq (= active VFO when not split)
	RxFreqHz  int64     `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
	Split     bool      `json:"split,omitempty"`      // rig is in split mode
	Mode      string    `json:"mode,omitempty"`       // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
	Band      string    `json:"band,omitempty"`       // computed from FreqHz
	Vfo       string    `json:"vfo,omitempty"`        // "A" | "B" | "AA" | "AB" | "BA" | "BB"
	Error     string    `json:"error,omitempty"`      // last connect/poll error if any
	UpdatedAt time.Time `json:"updated_at,omitempty"`
}

// Manager owns the active backend and runs the polling loop.
type Manager struct {
	mu      sync.RWMutex
	startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
	state   RigState
	emit    func(RigState)
	backend Backend

	// Set when running. nil when stopped.
	stopCh chan struct{}
	doneCh chan struct{}
	cmdCh  chan func() // marshall arbitrary work onto the CAT goroutine

	pollEvery time.Duration
	cmdDelay  time.Duration // pause after each command (some rigs need it)
	// freqOffset is the transverter offset in Hz — see SetFreqOffset. Added to
	// what the rig reports, taken off what it is told.
	freqOffset int64
}

func NewManager(emit func(RigState)) *Manager {
	return &Manager{emit: emit, pollEvery: 250 * time.Millisecond}
}

// SetPollInterval changes the polling cadence. Caps at 50ms…2s to avoid
// either hammering the rig or feeling laggy.
func (m *Manager) SetPollInterval(d time.Duration) {
	if d < 50*time.Millisecond {
		d = 50 * time.Millisecond
	}
	if d > 2*time.Second {
		d = 2 * time.Second
	}
	m.mu.Lock()
	m.pollEvery = d
	m.mu.Unlock()
}

// SetCommandDelay sets a pause inserted after each CAT command. Some older
// Kenwood/Yaesu rigs drop bytes if commands arrive too fast back to back.
// Capped at 0…500ms — beyond that, fix your rig.
func (m *Manager) SetCommandDelay(d time.Duration) {
	if d < 0 {
		d = 0
	}
	if d > 500*time.Millisecond {
		d = 500 * time.Millisecond
	}
	m.mu.Lock()
	m.cmdDelay = d
	m.mu.Unlock()
}

// State returns a copy of the latest known state.
func (m *Manager) State() RigState {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.state
}

// Start spins up the CAT goroutine with the given backend. If a backend is
// already running it is stopped first. Errors during Connect are surfaced as
// state.Error rather than returned, so the UI can keep retrying via the
// poll loop on next reconnect attempt.
func (m *Manager) Start(b Backend) {
	// Serialize the whole stop-old-then-start-new sequence. Two concurrent
	// Start (or Start+Stop) calls could otherwise interleave and leave the
	// previous poll goroutine alive — two pollers then fight, e.g. flipping
	// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
	m.startMu.Lock()
	defer m.startMu.Unlock()
	m.stopLocked()
	m.mu.Lock()
	m.stopCh = make(chan struct{})
	m.doneCh = make(chan struct{})
	m.cmdCh = make(chan func(), 4)
	m.backend = b
	stop := m.stopCh
	done := m.doneCh
	cmds := m.cmdCh
	poll := m.pollEvery
	m.state = RigState{Enabled: true, Backend: b.Name()}
	m.mu.Unlock()
	m.emitState()

	go m.run(b, stop, done, cmds, poll)
}

// Stop signals the CAT goroutine to disconnect and waits for it to exit.
func (m *Manager) Stop() {
	m.startMu.Lock()
	defer m.startMu.Unlock()
	m.stopLocked()
	m.mu.Lock()
	m.state = RigState{Enabled: false}
	m.mu.Unlock()
	m.emitState()
}

// stopLocked tears down any running poller and blocks until it exits. The
// caller must hold startMu so it can't race a concurrent Start.
func (m *Manager) stopLocked() {
	m.mu.Lock()
	stop := m.stopCh
	done := m.doneCh
	b := m.backend
	m.stopCh = nil
	m.doneCh = nil
	m.cmdCh = nil
	m.backend = nil
	m.mu.Unlock()
	if stop != nil {
		close(stop)
	}
	// Abort any in-progress Connect so we don't block on a slow network dial
	// (the poll goroutine can be tens of seconds deep in the Icom UDP handshake).
	if iv, ok := b.(interruptible); ok {
		iv.Interrupt()
	}
	if done != nil {
		// Bounded, and loud when it expires.
		//
		// This used to be a bare <-done. A poll goroutine wedged in a serial read
		// then held every caller for ever: each restart attempt blocked inside
		// Start before it could even try to connect, so the rig stayed dead and
		// NOTHING was written to the log — the operator saw a CAT link that would
		// not come back and no reason anywhere. Shutdown blocked on the same wait.
		//
		// Ten seconds is far longer than any honest disconnect. Past that the old
		// poller is not coming back, and carrying on without it — noisily — beats
		// freezing the application around it. The abandoned goroutine still holds
		// its port, which is exactly what the log line has to say, because the
		// next connect will fail because of it.
		select {
		case <-done:
		case <-time.After(10 * time.Second):
			name := "cat"
			if b != nil {
				name = b.Name()
			}
			debugLog.Printf("%s: poll loop did not stop within 10s — abandoning it; "+
				"its port stays open, so the next connect may fail until OpsLog is restarted", name)
		}
	}
}

// SetFreqOffset sets the transverter offset: the number of hertz between what
// the RIG is tuned to and where the station is actually on the air.
//
// A 28 MHz IF driving a 144 MHz transverter is an offset of +116 MHz. Everything
// above this layer — the entry form, the band, the log, the cluster, the shared
// CAT servers — then works in real frequencies, and the rig keeps seeing its own.
//
// Zero disables it, which is why it is a plain number and not a flag plus a
// number: "enabled with an offset of nothing" and "disabled" are the same thing
// to everyone downstream.
func (m *Manager) SetFreqOffset(hz int64) {
	m.mu.Lock()
	m.freqOffset = hz
	m.mu.Unlock()
}

// freqOffsetHz reads the offset.
func (m *Manager) freqOffsetHz() int64 {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.freqOffset
}

// SetFrequency dispatches a SetFreq call to the CAT goroutine.
//
// The caller speaks in REAL frequencies (a 2 m spot is 144.300), so the offset
// comes back off before the rig hears it. Without this the offset would be a
// display trick: the readout would say 144 and every spot click, band change and
// memory recall would send the rig somewhere 116 MHz away.
func (m *Manager) SetFrequency(hz int64) error {
	real := hz
	if off := m.freqOffsetHz(); off != 0 && hz > off {
		hz -= off
	}
	err := m.exec(func(b Backend) error { return b.SetFrequency(hz) })
	if err == nil {
		m.noteCommandedFreq(real)
	}
	return err
}

// noteCommandedFreq publishes a frequency the radio has just acknowledged,
// without waiting for the next poll to come round and read it back.
//
// The wait is what this is about. A rigctl client — WSJT-X above all — sets a
// frequency and then READS it back before it believes it is there, and until
// then it will not decode, transmit or even update its own dial. Everything
// answering "f" here comes from the last poll, so the answer was the OLD
// frequency for as long as a poll cycle takes; on a rig reached over the
// internet, where one cycle is many round trips, a band change from WSJT-X took
// ten seconds to be believed while the radio itself had moved instantly.
//
// Only when NOT split. In split the two frequencies mean different VFOs and a
// guess about which one just moved is how a client ends up writing the transmit
// frequency onto the dial — the poll is left to settle that case.
func (m *Manager) noteCommandedFreq(hz int64) {
	if hz <= 0 {
		return
	}
	m.mu.Lock()
	st := m.state
	if !st.Connected || st.Split || st.FreqHz == hz {
		m.mu.Unlock()
		return
	}
	st.FreqHz = hz
	st.Band = BandFromHz(hz)
	st.UpdatedAt = time.Now()
	m.state = st
	m.mu.Unlock()
	m.emitState()
}

// SetMode dispatches a SetMode call to the CAT goroutine.
func (m *Manager) SetMode(mode string) error {
	err := m.exec(func(b Backend) error { return b.SetMode(mode) })
	if err == nil {
		m.noteCommandedMode(mode)
	}
	return err
}

// noteCommandedMode is the mode half of noteCommandedFreq, and exists for the
// same client readback.
//
// "DATA" is deliberately not published. A backend reports data mode under the
// operator's own digital mode (FT8, JS8, RTTY…), and that name is what a QSO is
// logged with — a plain "DATA" standing in for a poll cycle is a mode nobody
// works, in a field that ends up in an ADIF file. The poll is a fraction of a
// second away and knows the real name.
func (m *Manager) noteCommandedMode(mode string) {
	if mode == "" || strings.EqualFold(mode, "DATA") {
		return
	}
	m.mu.Lock()
	st := m.state
	if !st.Connected || st.Mode == mode {
		m.mu.Unlock()
		return
	}
	st.Mode = mode
	st.UpdatedAt = time.Now()
	m.state = st
	m.mu.Unlock()
	m.emitState()
}

// SetPTT dispatches a transmit on/off request to the CAT goroutine.
func (m *Manager) SetPTT(on bool) error {
	return m.exec(func(b Backend) error { return b.SetPTT(on) })
}

// dataPTTSetter is implemented by a backend that can key the DATA input rather
// than the microphone. A Kenwood TS-590 has two transmit commands and takes its
// audio from a different socket for each: TX (or TX0) opens the front mic, TX1
// the rear ACC2/USB. Send the wrong one and the radio transmits in silence,
// because the audio arriving on USB is simply not the input it is listening to.
type dataPTTSetter interface {
	SetPTTData(on bool) error
}

// SetPTTSource keys the transmitter, saying WHERE the audio is coming from.
//
// data=true means "the audio reaches the radio on its data/USB input" — what a
// voice keyer playing through the rig's own sound card needs. A backend that
// draws no distinction (every rig where one PTT is all there is) falls back to
// the ordinary key, so nothing changes for it.
func (m *Manager) SetPTTSource(on, data bool) error {
	if !data {
		return m.SetPTT(on)
	}
	return m.exec(func(b Backend) error {
		if d, ok := b.(dataPTTSetter); ok {
			return d.SetPTTData(on)
		}
		return b.SetPTT(on)
	})
}

// splitSetter is implemented by the backends that can arm split AND place the
// transmit frequency. Both together: arming without setting the dial transmits
// on whatever the transmit VFO happened to hold, which is worse than refusing.
type splitSetter interface {
	SetSplit(on bool, txHz int64) error
}

// SetSplit arms or clears split on the rig, with the transmit frequency.
//
// Returns a plain error on a backend that cannot do it, and that is the point.
// The shared-CAT server used to answer "done" to WSJT-X's split commands while
// doing nothing at all — the software then believed it was transmitting up the
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
// report is worth far more than a success it cannot check.
func (m *Manager) SetSplit(on bool, txHz int64) error {
	// Real frequency in, IF frequency out — same as SetFrequency.
	if off := m.freqOffsetHz(); off != 0 && txHz > off {
		txHz -= off
	}
	return m.exec(func(b Backend) error {
		s, ok := b.(splitSetter)
		if !ok {
			return fmt.Errorf("cat: this radio's backend cannot set split from software — " +
				"use Split Operating: Fake It in WSJT-X/JTDX, or set split on the radio itself")
		}
		return s.SetSplit(on, txHz)
	})
}

// SpotInfo is one cluster spot to render on a backend that supports a spot
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
// the backend picks a default when it's empty. (Status-based colouring can be
// driven later by setting Color per spot.)
type SpotInfo struct {
	FreqHz   int64
	Callsign string
	Mode     string
	// Color is the TEXT colour of the callsign on the panadapter, BackgroundColor
	// the plate behind it. Both are #AARRGGBB — the alpha channel first, which is
	// SmartSDR's order and not the web's.
	//
	// Two colours rather than one because that is what makes a spot readable at a
	// glance on a busy waterfall: the fill carries the status and the text stays
	// legible against it. An empty background leaves the radio's own default.
	Color           string
	BackgroundColor string
	Comment         string
	LifetimeSec     int // panadapter display seconds before auto-removal (0 = backend default)
	// Priority is SmartSDR's own tie-breaker, 1 (highest) to 5.
	//
	// It matters because the panadapter has finite room: spots close in
	// frequency are stacked behind a "+" and only one of them is drawn. The
	// radio picks that one by priority — so an entity never worked can sit
	// invisible behind three stations already in the log unless we say which
	// is worth the space. 0 leaves the field off the command entirely.
	Priority int
}

// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
// (FlexRadio panadapter). Backends that don't implement it are simply skipped.
type Spotter interface {
	SendSpot(SpotInfo) error
}

// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on
// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a
// missed spot on the panadapter is harmless.
func (m *Manager) SendSpot(s SpotInfo) {
	m.mu.RLock()
	cmds := m.cmdCh
	b := m.backend
	m.mu.RUnlock()
	if cmds == nil || b == nil {
		return
	}
	if _, ok := b.(Spotter); !ok {
		return
	}
	select {
	case cmds <- func() {
		if sp, ok := b.(Spotter); ok {
			_ = sp.SendSpot(s)
		}
	}:
	default: // queue busy → drop this spot
	}
}

// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
// the radio over TCP; live meters arrive over a separate UDP stream later.)
// FlexSliceInfo identifies one FlexRadio receiver slice (A/B/C/D…) for the
// panel, so the operator sees every slice and which one is active/TX.
type FlexSliceInfo struct {
	Index  int    `json:"index"`  // 0-based slice index
	Letter string `json:"letter"` // A, B, C, D…
	FreqHz int64  `json:"freq_hz"`
	Mode   string `json:"mode,omitempty"` // ADIF mode
	Band   string `json:"band,omitempty"`
	Active bool   `json:"active"` // the focused/operating slice
	TX     bool   `json:"tx"`     // this slice transmits
}

type FlexTXState struct {
	Available bool   `json:"available"` // backend is Flex and handshaked
	Model     string `json:"model,omitempty"`
	// Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show
	// them all and highlight the active one. The active slice drives everything.
	Slices       []FlexSliceInfo `json:"slices,omitempty"`
	RFPower      int             `json:"rf_power"`
	TunePower    int             `json:"tune_power"`
	Tune         bool            `json:"tune"`         // tune carrier active
	Transmitting bool            `json:"transmitting"` // interlock state = TRANSMITTING
	VoxEnable    bool            `json:"vox_enable"`
	VoxLevel     int             `json:"vox_level"`
	VoxDelay     int             `json:"vox_delay"`
	ProcEnable   bool            `json:"proc_enable"`
	ProcLevel    int             `json:"proc_level"`
	Mon          bool            `json:"mon"`
	MonLevel     int             `json:"mon_level"`
	MicLevel     int             `json:"mic_level"`
	TXFilterLow  int             `json:"tx_filter_low"`  // TX filter low cut (Hz)
	TXFilterHigh int             `json:"tx_filter_high"` // TX filter high cut (Hz)
	// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
	MicProfile  string   `json:"mic_profile,omitempty"`
	MicProfiles []string `json:"mic_profiles,omitempty"`
	ATUStatus   string   `json:"atu_status,omitempty"`
	ATUMemories bool     `json:"atu_memories"`
	// Active RX slice DSP controls.
	RXAvail      bool     `json:"rx_avail"`             // an RX slice exists
	Split        bool     `json:"split"`                // RX/TX on separate slices
	RXFreqHz     int64    `json:"rx_freq_hz,omitempty"` // RX slice freq when split
	TXFreqHz     int64    `json:"tx_freq_hz,omitempty"` // TX slice freq when split
	AGCMode      string   `json:"agc_mode,omitempty"`
	AGCThreshold int      `json:"agc_threshold"`
	AudioLevel   int      `json:"audio_level"`
	Mute         bool     `json:"mute"`
	RXAnt        string   `json:"rx_ant,omitempty"`      // selected RX antenna
	TXAnt        string   `json:"tx_ant,omitempty"`      // selected TX antenna
	AntList      []string `json:"ant_list,omitempty"`    // antennas selectable for RX
	TXAntList    []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX
	NB           bool     `json:"nb"`
	NBLevel      int      `json:"nb_level"`
	NR           bool     `json:"nr"`
	NRLevel      int      `json:"nr_level"`
	ANF          bool     `json:"anf"`
	ANFLevel     int      `json:"anf_level"`
	WNB          bool     `json:"wnb"`
	WNBLevel     int      `json:"wnb_level"`
	// SmartSDR v4 DSP (8000/Aurora series): NRL/ANFL (legacy LMS), NRS
	// (spectral subtraction), RNN (AI NR), ANFT (FFT notch), NRF (NR w/filter).
	// DSPV4 is true once the radio reported any of these keys — gates the UI.
	LMSNR        bool `json:"lms_nr"`
	LMSNRLevel   int  `json:"lms_nr_level"`
	LMSANF       bool `json:"lms_anf"`
	LMSANFLevel  int  `json:"lms_anf_level"`
	SpeexNR      bool `json:"speex_nr"`
	SpeexNRLevel int  `json:"speex_nr_level"`
	RNN          bool `json:"rnn"`
	ANFT         bool `json:"anft"`
	NRF          bool `json:"nrf"`
	NRFLevel     int  `json:"nrf_level"`
	DSPV4        bool `json:"dsp_v4"`
	DAXCh        int  `json:"dax_ch"` // DAX audio channel of the active slice (0 = off, 1-8)
	TXDAX        bool `json:"tx_dax"` // DAX is the TX audio source (SmartSDR transmit-bar DAX button)
	// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
	// moving the slice. The offset survives the switch being turned off, so
	// re-enabling restores it, exactly like the radio's own knob.
	RIT     bool `json:"rit"`
	RITFreq int  `json:"rit_freq"`
	XIT     bool `json:"xit"`
	XITFreq int  `json:"xit_freq"`
	// CW / mode-specific controls.
	Mode           string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
	CWSpeed        int    `json:"cw_speed"`
	CWPitch        int    `json:"cw_pitch"`
	CWBreakInDelay int    `json:"cw_break_in_delay"`
	CWSidetone     bool   `json:"cw_sidetone"`
	CWMonLevel     int    `json:"cw_mon_level"` // sidetone level
	APF            bool   `json:"apf"`
	APFLevel       int    `json:"apf_level"`
	FilterLo       int    `json:"filter_lo"`
	FilterHi       int    `json:"filter_hi"`
	// External amplifier (PowerGenius XL).
	AmpAvailable bool   `json:"amp_available"`
	AmpModel     string `json:"amp_model,omitempty"`
	AmpOperate   bool   `json:"amp_operate"`
	AmpFault     string `json:"amp_fault,omitempty"`
	// Live meters streamed over UDP (S-meter, PWR, SWR, temp, voltage…).
	Meters []FlexMeter `json:"meters,omitempty"`
}

// FlexMeter is one live meter value (already scaled to real units).
type FlexMeter struct {
	ID    int     `json:"id"`
	Src   string  `json:"src,omitempty"`  // SLC / TX- / RAD / AMP…
	Name  string  `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
	Unit  string  `json:"unit,omitempty"`
	Slice int     `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
	Value float64 `json:"value"`
	Lo    float64 `json:"lo"`
	Hi    float64 `json:"hi"`
}

// FlexController is an OPTIONAL backend capability (the FlexRadio backend): the
// SmartSDR-style transmit controls. Backends that don't implement it are skipped
// by the FlexRadio tab. FlexState() is mutex-guarded in the backend so it's safe
// to read off the CAT goroutine; the setters are dispatched onto it via FlexDo.
type FlexController interface {
	FlexState() FlexTXState
	SetRFPower(int) error
	SetTunePower(int) error
	SetTune(bool) error
	SetTXInhibit(bool) error
	SetVOX(bool) error
	SetVOXLevel(int) error
	SetVOXDelay(int) error
	SetProcessor(bool) error
	SetProcessorLevel(int) error
	SetMon(bool) error
	SetMonLevel(int) error
	SetMic(int) error
	SetTXFilter(low, high int) error // transmit-audio bandwidth (Hz)
	SetMicProfile(string) error      // load a SmartSDR mic profile by name
	ATUStart() error
	ATUBypass() error
	SetATUMemories(bool) error
	// RX slice DSP controls (target the active receive slice).
	SetAGCMode(string) error
	SetAGCThreshold(int) error
	SetAudioLevel(int) error
	SetMute(bool) error
	SetRXAntenna(string) error
	SetTXAntenna(string) error
	SetActiveSlice(int) error // focus slice idx so commands target it
	// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
	// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
	ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error
	SetTXSlice(int) error // make slice idx the transmitter (tx=1)
	// SetTXSliceFrequency moves the TRANSMIT slice only — split pile-up chasing.
	SetTXSliceFrequency(int64) error
	SetSplit(bool) error
	SetNB(bool) error
	SetNBLevel(int) error
	SetNR(bool) error
	SetNRLevel(int) error
	SetANF(bool) error
	SetANFLevel(int) error
	SetAPF(bool) error
	SetAPFLevel(int) error
	SetWNB(bool) error
	SetWNBLevel(int) error
	// SmartSDR v4 DSP (8000/Aurora): NRL / ANFL / NRS / RNN / ANFT / NRF.
	SetLMSNR(bool) error
	SetLMSNRLevel(int) error
	SetLMSANF(bool) error
	SetLMSANFLevel(int) error
	SetSpeexNR(bool) error
	SetSpeexNRLevel(int) error
	SetRNN(bool) error
	SetANFT(bool) error
	SetNRF(bool) error
	SetNRFLevel(int) error
	SetDAX(int) error    // slice RX DAX channel 0 (off) - 8
	SetTXDAX(bool) error // TX audio from DAX (transmit set dax=)
	SetRIT(bool) error
	SetRITFreq(int) error
	SetXIT(bool) error
	SetXITFreq(int) error
	// CW keyer + mode-specific controls.
	SetCWSpeed(int) error
	SetCWPitch(int) error
	SetCWBreakInDelay(int) error
	// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
	// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
	// StopCW clears the buffer, aborting the send. BackspaceCW removes the last n
	// not-yet-keyed characters from the buffer (un-send while sending — for
	// type-ahead corrections).
	SendCW(string) error
	StopCW() error
	BackspaceCW(int) error
	SetCWSidetone(bool) error
	SetSidetoneLevel(int) error
	SetCWFilter(int) error
	SetFilter(lo, hi int) error
	// External amplifier (PowerGenius XL) operate/standby.
	SetAmpOperate(bool) error
}

// FlexState returns the current FlexRadio transmit state, or (zero, false) when
// the active backend isn't a Flex. Safe to call from any goroutine.
func (m *Manager) FlexState() (FlexTXState, bool) {
	m.mu.RLock()
	b := m.backend
	m.mu.RUnlock()
	if fc, ok := b.(FlexController); ok {
		return fc.FlexState(), true
	}
	return FlexTXState{}, false
}

// FlexDo dispatches a FlexRadio control onto the CAT goroutine. Errors if the
// active backend isn't a Flex.
func (m *Manager) FlexDo(fn func(FlexController) error) error {
	return m.exec(func(b Backend) error {
		fc, ok := b.(FlexController)
		if !ok {
			return fmt.Errorf("active CAT backend is not a FlexRadio")
		}
		return fn(fc)
	})
}

// IcomTXState is the Icom receive-DSP state surfaced to the dedicated Icom
// control tab. Levels are 0-100 (scaled from the rig's 0-255). Unlike Flex,
// the Icom doesn't push changes, so these reflect the last RefreshIcom() read
// plus the optimistic updates each setter applies.
type IcomTXState struct {
	Available bool   `json:"available"`
	Model     string `json:"model,omitempty"`
	Mode      string `json:"mode,omitempty"`
	// Transmit + live status (polled).
	Transmitting bool `json:"transmitting"`
	Split        bool `json:"split"`
	// SubHz is the unselected VFO / sub receiver's frequency — shown on the
	// console's SUB display whether or not split is on: a dual-receiver rig
	// (IC-7610/7760) has a second dial worth seeing at all times.
	SubHz      int64 `json:"sub_hz"`
	SMeter     int   `json:"s_meter"`     // 0-100 (raw 0-255; S9≈120)
	PowerMeter int   `json:"power_meter"` // 0-100 (TX Po)
	SWRMeter   int   `json:"swr_meter"`   // 0-100 (TX SWR)
	// RIT / ΔTX (XIT).
	RITHz int  `json:"rit_hz"` // RIT/XIT offset, signed Hz
	RITOn bool `json:"rit_on"`
	XITOn bool `json:"xit_on"`
	// CW keyer (send messages via the rig's internal keyer, CI-V 0x17).
	KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM
	BreakIn     int `json:"break_in"`      // CW break-in: 0=OFF, 1=SEMI, 2=FULL
	// Set controls.
	RFPower int    `json:"rf_power"` // 0-100 (TX output)
	MicGain int    `json:"mic_gain"` // 0-100
	AFGain  int    `json:"af_gain"`
	RFGain  int    `json:"rf_gain"`
	NB      bool   `json:"nb"`
	NBLevel int    `json:"nb_level"`
	NR      bool   `json:"nr"`
	NRLevel int    `json:"nr_level"`
	ANF     bool   `json:"anf"`
	APF     bool   `json:"apf"`           // audio peak filter (CW only)
	AGC     string `json:"agc,omitempty"` // FAST | MID | SLOW
	Preamp  int    `json:"preamp"`        // 0=off, 1=P.AMP1, 2=P.AMP2
	Att     int    `json:"att"`           // dB attenuation, 0=off
	Filter  int    `json:"filter"`        // 1 | 2 | 3 (FIL1/2/3)
	// Antenna (IC-7610 = ANT1/ANT2).
	Antenna int  `json:"antenna"` // 1 | 2 (0 = unknown)
	ATUOn   bool `json:"atu_on"`  // internal tuner engaged
	// Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre).
	PBTInner    int  `json:"pbt_inner"`
	PBTOuter    int  `json:"pbt_outer"`
	ManualNotch bool `json:"manual_notch"`
	NotchPos    int  `json:"notch_pos"`
	// TX extras.
	Squelch   int  `json:"squelch"`
	Comp      bool `json:"comp"`
	CompLevel int  `json:"comp_level"`
	Monitor   bool `json:"monitor"`
	MonLevel  int  `json:"mon_level"`
	VOX       bool `json:"vox"`
	VOXGain   int  `json:"vox_gain"`
	AntiVOX   int  `json:"anti_vox"`
}

// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
// receive-DSP controls shown on the Icom tab. IcomState() is mutex-guarded in
// the backend so it's safe off the CAT goroutine; setters dispatch via IcomDo.
type IcomController interface {
	IcomState() IcomTXState
	RefreshIcom() error // re-read all DSP state from the rig
	SetAFGain(int) error
	SetRFGain(int) error
	SetNB(bool) error
	SetNBLevel(int) error
	SetNR(bool) error
	SetNRLevel(int) error
	SetANF(bool) error
	SetAPF(bool) error
	SetAGC(string) error
	SetPreamp(int) error
	SetAtt(int) error
	SetIcomFilter(int) error
	SetRFPower(int) error
	SetMicGain(int) error
	SetIcomSplit(bool) error
	SetIcomSplitOffset(bool, int64) error
	SetATU(bool) error
	TuneATU() error
	SetScope(bool) error              // enable/disable the spectrum-scope waveform stream
	SetScopeMode(bool) error          // true = fixed span, false = center-on-VFO
	SetScopeEdges(int64, int64) error // point the fixed scope at low..high Hz (centre/pan)
	ScopeData() ScopeSweep            // latest assembled sweep (empty until enabled)
	SetRIT(int) error                 // RIT/ΔTX offset in signed Hz
	SetRITOn(bool) error              // RIT on/off
	SetXITOn(bool) error              // ΔTX (XIT) on/off
	SendCW(string) error              // key a CW message via the rig's keyer (CI-V 0x17)
	StopCW() error                    // abort the CW message being sent
	SetKeySpeed(int) error            // CW keyer speed in WPM
	SetBreakIn(int) error             // CW break-in: 0=OFF, 1=SEMI, 2=FULL
	SetAntenna(int) error             // 1 = ANT1, 2 = ANT2
	SetPBTInner(int) error            // Twin PBT inside (0-100, 50 = centre)
	SetPBTOuter(int) error            // Twin PBT outside (0-100, 50 = centre)
	SetManualNotch(bool) error
	SetNotchPos(int) error // manual-notch position (0-100, 50 = centre)
	SetSquelch(int) error
	SetComp(bool) error
	SetCompLevel(int) error
	SetMonitor(bool) error
	SetMonLevel(int) error
	SetVOX(bool) error
	SetVOXGain(int) error
	SetAntiVOX(int) error
	SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect)
	// RecallBandStack moves the VFO to what the radio's own band stacking
	// register holds — the operator's last frequency and mode on that band.
	// Returns the frequency landed on.
	RecallBandStack(band, reg int) (int64, error)
}

// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
// divided 0x27 waveform frames. Amp holds one amplitude byte per pixel (raw rig
// scale, typically 0-160). Seq increments on every completed sweep so the UI can
// tell fresh data from a repeated poll.
type ScopeSweep struct {
	Amp    []int `json:"amp"` // []int (not []byte) so it marshals as a JSON number array
	Seq    int   `json:"seq"`
	LowHz  int64 `json:"low_hz"`  // left edge frequency (0 when unknown)
	HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
	Fixed  bool  `json:"fixed"`   // true = fixed-span mode, false = center-on-VFO
	// Unsupported: this radio refuses the waveform-output command, so there will
	// never be a sweep. The IC-7851 does — its last firmware is from 2016, older
	// than the CI-V waveform stream — while still answering the scope's other
	// commands. Reported so the panadapter can say so instead of showing a black
	// rectangle that looks like a bug in OpsLog.
	Unsupported bool `json:"unsupported"`
}

// IcomState returns the current Icom DSP state, or (zero, false) when the active
// backend isn't an Icom. Safe to call from any goroutine.
func (m *Manager) IcomState() (IcomTXState, bool) {
	m.mu.RLock()
	b := m.backend
	m.mu.RUnlock()
	if ic, ok := b.(IcomController); ok {
		return ic.IcomState(), true
	}
	return IcomTXState{}, false
}

// IcomScope returns the latest spectrum-scope sweep, or (zero, false) when the
// active backend isn't an Icom. The sweep is mutex-guarded in the backend, so
// this reads it directly (no CAT-goroutine round trip) — cheap enough to poll.
func (m *Manager) IcomScope() (ScopeSweep, bool) {
	m.mu.RLock()
	b := m.backend
	m.mu.RUnlock()
	if ic, ok := b.(IcomController); ok {
		return ic.ScopeData(), true
	}
	return ScopeSweep{}, false
}

// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
// active backend isn't an Icom.
func (m *Manager) IcomDo(fn func(IcomController) error) error {
	return m.exec(func(b Backend) error {
		ic, ok := b.(IcomController)
		if !ok {
			return fmt.Errorf("active CAT backend is not an Icom")
		}
		return fn(ic)
	})
}

// SatTuner is a backend that can be put on a satellite: a receiver on one band
// and a transmitter on another, both moving under Doppler, at the same time.
//
// It is a separate interface from the per-manufacturer ones because what a
// satellite needs is not a manufacturer's feature — it is a shape of operating
// that a FlexRadio and an IC-9700 both provide and reach in completely
// different ways. A backend that cannot do it simply does not implement this,
// and the caller falls back to tuning the downlink alone rather than pretending.
type SatTuner interface {
	// SetSatellite arms or disarms satellite operation: the rig's own satellite
	// mode where it has one, two slices where it has those. Disarming must leave
	// the radio somewhere an operator can work from, not half-configured.
	SetSatellite(on bool) error
	// TuneSatellite points the receiver at downHz and the transmitter at upHz,
	// both already Doppler-corrected. Modes are ADIF names ("SSB", "FM", "CW");
	// an empty one leaves that side's mode alone.
	TuneSatellite(downHz, upHz int64, downMode, upMode string) error
	// SatReceiveHz is where the receiver actually is. The operator tunes it to
	// follow a station across a linear transponder, and that dial movement is
	// the input the whole tracker works from — without reading it back, a
	// tracker fights the operator instead of helping them.
	SatReceiveHz() (int64, error)
}

// SatCapable reports whether the active backend can hold a satellite pair.
func (m *Manager) SatCapable() bool {
	m.mu.RLock()
	b := m.backend
	m.mu.RUnlock()
	_, ok := b.(SatTuner)
	return ok
}

// SatDo dispatches a satellite control onto the CAT goroutine.
func (m *Manager) SatDo(fn func(SatTuner) error) error {
	return m.exec(func(b Backend) error {
		st, ok := b.(SatTuner)
		if !ok {
			return fmt.Errorf("this radio cannot hold a satellite pair from OpsLog")
		}
		return fn(st)
	})
}

// exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error {
	m.mu.RLock()
	cmds := m.cmdCh
	b := m.backend
	m.mu.RUnlock()
	if cmds == nil || b == nil {
		return fmt.Errorf("cat not running")
	}
	errCh := make(chan error, 1)
	select {
	case cmds <- func() { errCh <- fn(b) }:
	case <-time.After(500 * time.Millisecond):
		return fmt.Errorf("cat busy")
	}
	return <-errCh
}

// run is the CAT goroutine. Owns the backend lifecycle.
func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pollEvery time.Duration) {
	// Lock to a single OS thread — required for COM. Cheap for non-COM backends.
	runtime.LockOSThread()
	defer runtime.UnlockOSThread()
	defer close(done)

	defer b.Disconnect()

	// Connection is (re)established lazily and retried with a backoff, so a rig
	// that's off at startup — or a FlexRadio that reboots/drops its TCP link —
	// reconnects on its own instead of staying dead until the user toggles CAT.
	const reconnectEvery = 5 * time.Second
	connected := false
	var lastAttempt time.Time
	var lastConnErr string // last connect failure logged, so the retry loop says it once
	tryConnect := func() {
		if connected || time.Since(lastAttempt) < reconnectEvery {
			return
		}
		lastAttempt = time.Now()
		if err := b.Connect(); err != nil {
			// Log it — the message used to live only in RigState.Error, i.e. in a
			// tooltip. The status pill condenses everything to "OmniRig not found",
			// so a user reporting that had no way to tell us WHY: the COM HRESULT,
			// the serial error, the refused TCP connect, all invisible. Logged once
			// per distinct message so the retry loop doesn't flood the file.
			if msg := err.Error(); msg != lastConnErr {
				lastConnErr = msg
				debugLog.Printf("%s connect failed: %s", b.Name(), msg)
			}
			m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
			return
		}
		if lastConnErr != "" {
			debugLog.Printf("%s connected (after: %s)", b.Name(), lastConnErr)
			lastConnErr = ""
		}
		connected = true
	}
	tryConnect()

	ticker := time.NewTicker(pollEvery)
	defer ticker.Stop()

	for {
		select {
		case <-stop:
			return
		case fn := <-cmds:
			fn()
			m.applyCommandDelay()
		case <-ticker.C:
			// Drain any queued commands before polling. A serial backend reads
			// many registers per ReadState, so without this the shared select's
			// fairness lets polls repeatedly win and a user's Set* can lag by
			// seconds. Servicing commands first bounds that latency to a single
			// ReadState.
			for {
				select {
				case fn := <-cmds:
					fn()
					m.applyCommandDelay()
					continue
				default:
				}
				break
			}
			if !connected {
				tryConnect()
				continue
			}
			ns, err := b.ReadState()
			if err != nil {
				// Lost the rig — drop the backend so the next attempt reconnects
				// cleanly, then back off before retrying.
				connected = false
				lastAttempt = time.Now()
				b.Disconnect()
				m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
				continue
			}
			ns.Enabled = true
			ns.Backend = b.Name()
			ns.UpdatedAt = time.Now()
			// Transverter offset: the rig reports its IF, the operator is on the
			// real band. Applied BEFORE the band is worked out, or a 28 MHz IF
			// behind a 2 m transverter would log every contact on 10 m — and the
			// band the backend may already have filled in is the IF's, so it is
			// recomputed rather than trusted.
			if off := m.freqOffsetHz(); off != 0 {
				if ns.FreqHz != 0 {
					ns.FreqHz += off
					ns.Band = ""
				}
				if ns.RxFreqHz != 0 {
					ns.RxFreqHz += off
				}
			}
			if ns.FreqHz != 0 && ns.Band == "" {
				ns.Band = BandFromHz(ns.FreqHz)
			}
			m.update(ns)
		}
	}
}

func (m *Manager) applyCommandDelay() {
	m.mu.RLock()
	d := m.cmdDelay
	m.mu.RUnlock()
	if d > 0 {
		time.Sleep(d)
	}
}

// update stores the new state and emits an event ONLY if something changed
// that the UI cares about — avoids flooding the event bus 4x per second.
func (m *Manager) update(ns RigState) {
	m.mu.Lock()
	changed := !stateUserEqual(m.state, ns)
	m.state = ns
	m.mu.Unlock()
	if changed {
		m.emitState()
	}
}

func (m *Manager) emitState() {
	if m.emit == nil {
		return
	}
	m.emit(m.State())
}

func stateUserEqual(a, b RigState) bool {
	return a.Enabled == b.Enabled &&
		a.Connected == b.Connected &&
		a.Backend == b.Backend &&
		a.RigNum == b.RigNum &&
		a.Rig == b.Rig &&
		a.FreqHz == b.FreqHz &&
		a.RxFreqHz == b.RxFreqHz &&
		a.Split == b.Split &&
		a.Mode == b.Mode &&
		a.Vfo == b.Vfo &&
		a.Band == b.Band &&
		a.Error == b.Error
}

// BandFromHz returns the ADIF band tag covering the given frequency, or "".
// Ranges follow IARU/ITU plans. 60m is treated as a single block for
// simplicity — channelised access varies by region.
func BandFromHz(hz int64) string {
	mhz := float64(hz) / 1_000_000
	switch {
	// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
	// which then cost them the band on the QSO and in every award count.
	case mhz >= 0.1357 && mhz <= 0.1378:
		return "2190m"
	case mhz >= 0.472 && mhz <= 0.479:
		return "630m"
	case mhz >= 0.501 && mhz <= 0.504:
		return "560m"
	case mhz >= 1.8 && mhz <= 2.0:
		return "160m"
	case mhz >= 3.5 && mhz <= 4.0:
		return "80m"
	case mhz >= 5.3 && mhz <= 5.5:
		return "60m"
	case mhz >= 7.0 && mhz <= 7.3:
		return "40m"
	case mhz >= 10.1 && mhz <= 10.15:
		return "30m"
	case mhz >= 14.0 && mhz <= 14.35:
		return "20m"
	case mhz >= 18.068 && mhz <= 18.168:
		return "17m"
	case mhz >= 21.0 && mhz <= 21.45:
		return "15m"
	case mhz >= 24.89 && mhz <= 24.99:
		return "12m"
	case mhz >= 28.0 && mhz <= 29.7:
		return "10m"
	case mhz >= 50.0 && mhz <= 54.0:
		return "6m"
	case mhz >= 70.0 && mhz <= 70.5:
		return "4m"
	case mhz >= 144.0 && mhz <= 148.0:
		return "2m"
	case mhz >= 222.0 && mhz <= 225.0:
		return "1.25m"
	case mhz >= 420.0 && mhz <= 450.0:
		return "70cm"
	case mhz >= 902.0 && mhz <= 928.0:
		return "33cm"
	case mhz >= 1240.0 && mhz <= 1300.0:
		return "23cm"
	// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
	// actually work, and spot — resolved to an empty band: no band on the QSO, no
	// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
	// which is what an export has to carry.
	case mhz >= 2300.0 && mhz <= 2450.0:
		return "13cm"
	case mhz >= 3300.0 && mhz <= 3500.0:
		return "9cm"
	case mhz >= 5650.0 && mhz <= 5925.0:
		return "6cm"
	case mhz >= 10000.0 && mhz <= 10500.0:
		return "3cm"
	case mhz >= 24000.0 && mhz <= 24250.0:
		return "1.25cm"
	case mhz >= 47000.0 && mhz <= 47200.0:
		return "6mm"
	case mhz >= 75500.0 && mhz <= 81000.0:
		return "4mm"
	case mhz >= 119980.0 && mhz <= 123000.0:
		return "2.5mm"
	case mhz >= 134000.0 && mhz <= 149000.0:
		return "2mm"
	case mhz >= 241000.0 && mhz <= 250000.0:
		return "1mm"
	}
	return ""
}

// YaesuState returns the panel snapshot, or (zero, false) when the active
// backend isn't a Yaesu — same shape as IcomState.
func (m *Manager) YaesuState() (YaesuTXState, bool) {
	m.mu.RLock()
	b := m.backend
	m.mu.RUnlock()
	if yc, ok := b.(YaesuController); ok {
		return yc.YaesuState(), true
	}
	return YaesuTXState{}, false
}

// YaesuDo dispatches a Yaesu control onto the CAT goroutine, so a panel click
// and the poll loop never share the serial port at the same instant.
func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
	return m.exec(func(b Backend) error {
		yc, ok := b.(YaesuController)
		if !ok {
			return fmt.Errorf("active CAT backend is not a Yaesu")
		}
		return fn(yc)
	})
}

// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
// so a K3 can key CW through its single CAT link instead of a second COM port.
type KenwoodController interface {
	SendCW(string) error
	StopCW() error
	SetKeySpeed(int) error
}

// KenwoodState returns the K3/K4 panel snapshot, or (zero, false) when the
// active backend is not a Kenwood-dialect rig.
func (m *Manager) KenwoodState() (KenwoodTXState, bool) {
	m.mu.RLock()
	b := m.backend
	m.mu.RUnlock()
	if kc, ok := b.(KenwoodPanelController); ok {
		return kc.KenwoodState(), true
	}
	return KenwoodTXState{}, false
}

// KenwoodPanelDo dispatches a K3/K4 panel control onto the CAT goroutine, so a
// panel click and the poll loop never share the serial port at the same instant.
func (m *Manager) KenwoodPanelDo(fn func(KenwoodPanelController) error) error {
	return m.exec(func(b Backend) error {
		kc, ok := b.(KenwoodPanelController)
		if !ok {
			return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
		}
		return fn(kc)
	})
}

// KenwoodDo dispatches a Kenwood control onto the CAT goroutine.
func (m *Manager) KenwoodDo(fn func(KenwoodController) error) error {
	return m.exec(func(b Backend) error {
		kc, ok := b.(KenwoodController)
		if !ok {
			return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
		}
		return fn(kc)
	})
}
