//go:build windows

package cat

import (
	"context"
	"fmt"
	"net"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gorilla/websocket"
)

// TCI is a native backend for Expert Electronics' TCI protocol (SunSDR2/MB1/
// ColibriNANO via ExpertSDR2/EESDR, and TCI-compatible apps). TCI is a text
// protocol over a WebSocket: the server streams state ("vfo:0,0,14100000;",
// "modulation:0,cw;", "trx:0,true;") and accepts the same commands to control
// the rig. We keep the pushed state cached so ReadState is instant, like Flex.
//
// Pure Go (gorilla/websocket, no CGO). Default port 40001.
type TCI struct {
	host string
	port int

	digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
	spotsEnabled   bool   // mirror cluster spots onto the TCI panorama
	// wantFreq is the frequency last COMMANDED and not yet echoed back, used to
	// pick the sideband before the radio has confirmed the move.
	wantFreq int64
	// What the server said it is, from its "protocol:" announcement.
	serverName    string
	serverVersion string
	// How many spots have been logged verbatim (the first few only).
	spotsSent int

	// OnSpotClick is called when the user clicks one of our spots on the TCI
	// panorama (callsign + freq), so the host can fill the entry form. Set before
	// Connect. Mirrors the FlexRadio panadapter-click flow.
	OnSpotClick   func(callsign string, freqHz int64)
	unhandledSeen map[string]bool // log each unknown TCI message type once

	// panel is the control-console state — everything the radio announces about
	// itself that is not frequency or mode. See tci_panel.go.
	panel tciPanel

	// audio holds the receive-audio stream — see tci_audio.go. TCI carries it
	// on this same WebSocket, which is what lets a SunSDR record and decode
	// without a virtual audio cable in the way.
	audio tciAudio

	// One writer at a time. send() held the lock only long enough to READ conn,
	// which was enough while every command came from the poll loop — a stream of
	// audio frames from a second goroutine is not, and gorilla panics on a
	// concurrent write rather than corrupting the socket quietly.
	wmu        sync.Mutex // serialises writes to the socket (text AND binary)
	mu         sync.Mutex // guards conn + writes + state
	conn       *websocket.Conn
	dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
	ready      bool

	// Cached state pushed by the radio.
	device string
	freqA  int64 // VFO A (RX) frequency, Hz  (vfo:0,0)
	freqB  int64 // VFO B (TX in split), Hz    (vfo:0,1)
	mode   string
	split  bool
	tx     bool
	// txAllowed is what the radio last said about TRANSMIT PERMISSION.
	//
	// TX_ENABLE is sent by ExpertSDR when a client connects and again whenever
	// the band changes, "in case transmitter permission was changed" (§4.3). When
	// it is false the radio silently ignores TRX — which is exactly what an
	// operator sees as "PTT does nothing", with no error anywhere to explain it.
	//
	// txAllowedKnown keeps an OLDER ExpertSDR, or a TCI-compatible program that
	// never sends TX_ENABLE at all, from being treated as refusing: without a
	// word from the radio we key and let it decide.
	txAllowed bool
	// txSource is the TRX third argument: "tci" while OpsLog has audio to send,
	// empty for the operator's microphone. See SetPTT.
	txSource string
	// drive is the radio's transmit drive, 0-100. Kept because a quiet
	// transmission has two possible causes — our level or the radio's — and a
	// log that names both settles it in one line instead of an evening.
	drive          int
	txAllowedKnown bool

	lastSig string // last logged state signature (log only on change)

	// spotFreq is the frequency of the marker currently on the panorama for each
	// callsign — the panadapter's own state, which TCI never reports back. It is
	// what makes one spot per call possible: without it there is no way to know
	// there is an older marker to delete.
	spotFreq map[string]int64
}

func absInt64(v int64) int64 {
	if v < 0 {
		return -v
	}
	return v
}

const tciDefaultPort = 40001

// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
// mode surfaced when the radio reports a generic digital modulation; spots turns
// on mirroring OpsLog's cluster spots onto the TCI panorama.
func NewTCI(host string, port int, digitalDefault string, spots bool) *TCI {
	if port <= 0 || port > 65535 {
		port = tciDefaultPort
	}
	return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault), spotsEnabled: spots}
}

func (t *TCI) Name() string { return "tci" }

// Connect opens the WebSocket and starts the reader goroutine. The reader keeps
// our cached state current from the radio's push messages.
func (t *TCI) Connect() error {
	t.mu.Lock()
	already := t.conn != nil
	host, port := t.host, t.port
	t.mu.Unlock()
	if already {
		return nil
	}
	if host == "" {
		return fmt.Errorf("tci: no host configured")
	}
	url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
	// Cancellable dial so Interrupt() (Stop / Settings "Save & Close") aborts it at
	// once instead of waiting out a dead server's 5 s handshake timeout.
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	t.mu.Lock()
	t.dialCancel = cancel
	t.mu.Unlock()
	dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
	conn, _, err := dialer.DialContext(ctx, url, nil)
	cancel()
	t.mu.Lock()
	t.dialCancel = nil
	t.mu.Unlock()
	if err != nil {
		return fmt.Errorf("tci: connect %s: %w", url, err)
	}
	t.mu.Lock()
	t.conn = conn
	t.ready = false
	// Forget the previous session's transmit permission: the radio announces it
	// again on connect, and a refusal remembered from a band we have since left
	// would block PTT until it did.
	t.txAllowed, t.txAllowedKnown = false, false
	t.mu.Unlock()
	debugLog.Printf("TCI: connected to %s", url)
	go t.reader(conn)
	// Ask for the meters. Nothing measures anything until this goes out: the
	// S-meter, the transmit power and the SWR are all pushed by the radio, and
	// only to a client that has subscribed. 200 ms is the rate the protocol's own
	// examples use — fast enough for a needle, slow enough not to flood a socket
	// that also carries audio.
	if t.spotsEnabled {
		debugLog.Printf("TCI: panorama spots are ON — spots will be sent to the radio")
	}
	t.subscribeSensors("connect")
	if t.spotsEnabled {
		// Forget what we thought was on the panorama at the same moment the radio
		// is told to drop it. Kept, the memory would suppress the next spot for
		// each of those calls as "already drawn" onto a panorama now empty.
		t.mu.Lock()
		t.spotFreq = map[string]int64{}
		t.mu.Unlock()
		_ = t.send("spot_clear;") // drop any leftover spots from a previous session
	}
	return nil
}

// spotFreqTolHz is how far a re-spot of the same callsign may sit from the one
// already on the panorama before it is treated as a move rather than the same
// spot said again.
//
// Two spotters hearing the same CW station rarely agree to better than a couple
// of hundred hertz, and every one of them produces a cluster line. Below this
// they are the same spot and nothing is sent at all; above it the marker is
// deleted and redrawn where the station now is.
const spotFreqTolHz = 500

// noteSpot records what the panorama is about to hold for a callsign and says
// what has to be sent: whether to draw at all, and whether an older marker for
// the same call must be deleted first.
//
// Separate from SendSpot so the rule can be tested without a radio — and
// because the lock must be released before anything is sent: t.send takes t.mu
// itself, Go mutexes are not reentrant, and sending while holding it would
// deadlock the backend and take the rig offline.
func (t *TCI) noteSpot(call string, freqHz int64) (draw, deletePrev bool) {
	key := strings.ToUpper(strings.TrimSpace(call))
	t.mu.Lock()
	defer t.mu.Unlock()
	prev, had := t.spotFreq[key]
	if had && absInt64(prev-freqHz) <= spotFreqTolHz {
		return false, false
	}
	if t.spotFreq == nil {
		t.spotFreq = map[string]int64{}
	}
	if len(t.spotFreq) > 4000 {
		t.spotFreq = map[string]int64{} // bound memory on a long session
		had = false                     // forgotten: nothing left to delete by name
	}
	t.spotFreq[key] = freqHz
	return true, had
}

// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
// No-op when spot mirroring is disabled.
//
// ONE MARKER PER CALLSIGN. This code assumed the radio replaced a spot carrying
// a callsign it already had; it does not. ExpertSDR keys a spot on its
// frequency too, so a DX station spotted by three operators — 14025.00,
// 14025.12, 14024.90, which is an ordinary minute on a cluster — was drawn
// three times, a few pixels apart, and stayed that way.
//
// So the previous spot for the call is deleted before the new one is sent,
// which is what the FlexRadio backend has always done (spot remove / spot add).
func (t *TCI) SendSpot(s SpotInfo) error {
	if !t.spotsEnabled {
		return nil
	}
	call := strings.TrimSpace(s.Callsign)
	if call == "" || s.FreqHz <= 0 {
		return nil
	}
	draw, deletePrev := t.noteSpot(call, s.FreqHz)
	if !draw {
		return nil // the same station said again by another spotter
	}
	if deletePrev {
		// SPOT_DELETE takes the callsign alone. Not in the protocol PDF this
		// backend was written from; confirmed against ars-ka0s/eesdr-tci, which
		// lists SPOT (5 arguments), SPOT_DELETE (1) and SPOT_CLEAR (0) — the
		// other two matching what already works here.
		_ = t.send(fmt.Sprintf("spot_delete:%s;", call))
	}
	// The colour is a DECIMAL ARGB integer, and an UNSIGNED one.
	//
	// Expert Electronics' own protocol document gives the whole command:
	//
	//	SPOT:RN6LHF,CW,7100000,16711680,ANY_TEXT;
	//
	// 16711680 is 0x00FF0000 — positive, alpha zero. This backend was sending
	// the same number as a SIGNED 32-bit value, taken from a third-party
	// example: with the alpha byte set to FF for opacity, 0xFFFFA500 becomes
	// -22336, and a spot whose colour field ExpertSDR cannot read is dropped in
	// silence. Reported on ExpertSDR3 1.3 (which speaks TCI 2.x, so the version
	// was never the problem): everything else worked and the panorama stayed
	// empty.
	hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
	if hex == "" {
		hex = "FFFFA500" // opaque orange default
	}
	if len(hex) == 6 {
		hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
	}
	argb, err := strconv.ParseUint(hex, 16, 32)
	if err != nil {
		argb = 0xFFFFA500
	}
	// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
	// fall back to the raw label if we can't map it. The click-to-tune path already
	// maps the mode separately, so this only affects the spot's displayed mode.
	mode := adifToTCIMode(s.Mode, s.FreqHz)
	if mode == "" {
		mode = strings.ToLower(strings.TrimSpace(s.Mode))
	}
	// Commas/semicolons would break TCI's comma-separated argument parsing.
	text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
	cmd := fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, argb, text)
	// The first few, verbatim. A spot that the radio ignores leaves no trace at
	// all — no reply, no error — so the only evidence that OpsLog sent one, and
	// in what shape, is this line.
	if n := t.spotsSent; n < 3 {
		t.spotsSent = n + 1
		debugLog.Printf("TCI: sending spot #%d: %s", n+1, strings.TrimSuffix(cmd, ";"))
	}
	return t.send(cmd)
}

// Disconnect closes the WebSocket; the reader goroutine then exits.
func (t *TCI) Disconnect() {
	t.mu.Lock()
	c := t.conn
	t.conn = nil
	t.ready = false
	t.mu.Unlock()
	if c != nil {
		_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
		_ = c.Close()
	}
}

// Interrupt aborts an in-flight Connect dial so Stop()/Start() don't block on a
// dead server's handshake timeout. Satisfies the Manager's interruptible
// interface. Safe from another goroutine; a no-op when not dialing.
func (t *TCI) Interrupt() {
	t.mu.Lock()
	cancel := t.dialCancel
	c := t.conn
	t.mu.Unlock()
	if cancel != nil {
		cancel()
	}
	if c != nil {
		_ = c.Close()
	}
}

// ReadState returns the cached state pushed by the radio.
func (t *TCI) ReadState() (RigState, error) {
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.conn == nil {
		return RigState{}, fmt.Errorf("tci: not connected")
	}
	st := RigState{Connected: t.ready, Rig: t.device}
	if !t.ready {
		return st, nil
	}
	// ADIF convention: FreqHz is the TX freq. In split, TX is VFO B.
	if t.split && t.freqB > 0 {
		st.FreqHz = t.freqB
		st.RxFreqHz = t.freqA
		st.Split = true
	} else {
		st.FreqHz = t.freqA
	}
	// The transmit meters are asked for, not pushed: TX_POWER and TX_SWR are
	// read-only commands the radio answers when asked, and asking is only worth
	// anything while it is keyed. Fired and forgotten from here — the answers
	// arrive on the reader like everything else — and only while transmitting,
	// so a receiving station pays nothing for a meter nobody is watching.
	// Keyed by PTT **or** by TUNE. A tune carrier is exactly when the meters
	// matter most — it is the carrier an operator is watching an SWR on — and
	// asking only on t.tx left them at zero for the whole tune, because the
	// radio reports tuning as its own state and not as a transmission.
	if t.tx || t.panel.st.Tuning {
		tx := t
		go func() {
			_ = tx.send("tx_power;")
			_ = tx.send("tx_swr;")
		}()
	}
	st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
	if st.FreqHz > 0 {
		st.Band = BandFromHz(st.FreqHz)
	}
	sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
	if sig != t.lastSig {
		t.lastSig = sig
		debugLog.Printf("TCI: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
	}
	return st, nil
}

// SetFrequency tunes VFO A (the main/RX VFO).
func (t *TCI) SetFrequency(hz int64) error {
	// Remember what we ASKED for. SetMode reads it to choose the sideband, and
	// the radio's own echo can be a moment behind — see SetMode.
	t.mu.Lock()
	t.wantFreq = hz
	t.mu.Unlock()
	return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
}

// SetMode maps an ADIF mode to a TCI modulation and sets it. USB vs LSB is
// chosen from the current VFO-A frequency (< 10 MHz → LSB).
func (t *TCI) SetMode(mode string) error {
	t.mu.Lock()
	freq := t.freqA
	// Prefer the frequency we just COMMANDED over the one the radio has echoed.
	//
	// Clicking a spot sets the frequency and then the mode, and the sideband is
	// chosen from the frequency (below 10 MHz → LSB). Read from the echo, that
	// is the frequency we were on BEFORE the click whenever the echo has not
	// landed yet: a 14 MHz spot clicked from 7 MHz got LSB, and clicking the same
	// spot again — now that the echo has arrived — got USB. Reported from a
	// SunSDR as "the frequency is right, the mode is wrong until I click twice".
	if t.wantFreq > 0 {
		freq = t.wantFreq
	}
	t.mu.Unlock()
	m := adifToTCIMode(mode, freq)
	if m == "" {
		return nil
	}
	return t.send(fmt.Sprintf("modulation:0,%s;", m))
}

// SetPTT keys or unkeys the transmitter (VFO 0).
//
// A refusal by the radio is reported rather than swallowed. ExpertSDR announces
// transmit permission with TX_ENABLE and then simply IGNORES trx when it is
// false — out-of-band frequency, TX disabled in the program, no PA. The command
// went out, nothing happened, and nothing anywhere said why. Now the operator
// is told, and the message names the place to look.
func (t *TCI) SetPTT(on bool) error {
	if on {
		t.mu.Lock()
		known, allowed := t.txAllowedKnown, t.txAllowed
		t.mu.Unlock()
		if known && !allowed {
			return fmt.Errorf("the radio is refusing to transmit (TCI reports TX disabled) — " +
				"check the frequency is inside a transmit band and that TX is enabled in ExpertSDR")
		}
	}
	// THE THIRD ARGUMENT NAMES THE AUDIO SOURCE, and it is the whole answer to
	// "why does the radio ignore what I send it".
	//
	// TCI 2.0 §TRX: "The signal for transmitting is always taken from the
	// microphone selected in the ExpertSDR3. If a third-party software connected
	// via TCI wants to transmit its audio signal, you must specify the third
	// argument - TCI." Without it the radio never sends a single chrono frame,
	// whatever the mode and whatever is configured in its window — which is
	// exactly what a night of experiments showed and misread as "digital modes
	// only".
	//
	// Sent only when a transmission is ours to feed. A plain trx keeps the
	// operator's own microphone, which is what every other PTT in OpsLog means.
	t.mu.Lock()
	src := t.txSource
	t.mu.Unlock()
	if on && src != "" {
		return t.send(fmt.Sprintf("trx:0,true,%s;", src))
	}
	return t.send(fmt.Sprintf("trx:0,%t;", on))
}

// SetTXAudioSource says where the radio should take its transmit audio from
// while OpsLog keys it: "tci" for the stream this program sends, "" for the
// microphone the operator chose in ExpertSDR3.
//
// Set from the audio settings — it follows the "To radio" device — so keying
// for a voice message and keying for anything else behave differently on
// purpose: only the first one takes the audio away from the microphone.
func (t *TCI) SetTXAudioSource(src string) {
	t.mu.Lock()
	changed := t.txSource != src
	t.txSource = src
	t.mu.Unlock()
	if changed {
		if src == "" {
			debugLog.Printf("TCI: transmit audio will come from the radio's own microphone")
		} else {
			debugLog.Printf("TCI: transmit audio will be taken from %s when OpsLog keys the radio", src)
		}
	}
}

// send writes a command to the WebSocket (one writer at a time).
// subscribeSensors asks the radio to push its meters. Nothing measures anything
// until this goes out — the S-meter, the transmit power and the SWR are all
// subscription-only (TCI §4.4) — and it is sent at connect AND again at every
// "ready", because a subscription sent during the server's initial dump can be
// dropped. 200 ms is the rate the protocol's own examples use.
func (t *TCI) subscribeSensors(when string) {
	// Both cases, deliberately. Every other command this backend sends works in
	// lower case, but the meters stayed silent on a real SunSDR through two
	// rounds of fixes — and the protocol document's own examples are upper case
	// (TX_SENSORS_ENABLE:true,200;). A server that is case-insensitive ignores
	// the duplicate; one that is not finally hears the subscription.
	e1 := t.send("rx_sensors_enable:true,200;")
	e2 := t.send("tx_sensors_enable:true,200;")
	_ = t.send("RX_SENSORS_ENABLE:true,200;")
	_ = t.send("TX_SENSORS_ENABLE:true,200;")
	debugLog.Printf("TCI: sensor subscription sent (%s, both cases): rx=%v tx=%v", when, e1, e2)
}

func (t *TCI) send(cmd string) error {
	t.mu.Lock()
	c := t.conn
	t.mu.Unlock()
	if c == nil {
		return fmt.Errorf("tci: not connected")
	}
	t.wmu.Lock()
	defer t.wmu.Unlock()
	_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
	if err := c.WriteMessage(websocket.TextMessage, []byte(cmd)); err != nil {
		debugLog.Printf("TCI: send %q failed: %v", cmd, err)
		return err
	}
	debugLog.Printf("TCI: → %s", cmd)
	return nil
}

// reader drains push messages and keeps the cached state current until the
// connection closes.
func (t *TCI) reader(conn *websocket.Conn) {
	for {
		mt, data, err := conn.ReadMessage()
		if err != nil {
			break
		}
		// TEXT frames are commands, BINARY frames are streams. The type used to
		// be ignored and every frame split on ';' — harmless only for as long as
		// no stream was ever opened, since audio bytes would then have been fed
		// to the command parser a hundred times a second.
		if wsMessageIsBinary(mt) {
			t.handleBinary(data)
			continue
		}
		// A frame may carry several ";"-terminated commands.
		for _, cmd := range strings.Split(string(data), ";") {
			t.handle(strings.TrimSpace(cmd))
		}
	}
	t.mu.Lock()
	if t.conn == conn {
		t.conn = nil
		t.ready = false
	}
	t.mu.Unlock()
	debugLog.Printf("TCI: reader ended")
}

// handle parses one "command:args" message and updates the cache.
func (t *TCI) handle(msg string) {
	if msg == "" {
		return
	}
	name, args := msg, ""
	if i := strings.IndexByte(msg, ':'); i >= 0 {
		name, args = msg[:i], msg[i+1:]
	}
	f := strings.Split(args, ",")
	get := func(i int) string {
		if i < len(f) {
			return strings.TrimSpace(f[i])
		}
		return ""
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	lower := strings.ToLower(name)
	// The console's own messages first. Most of them were being logged once as
	// unhandled and thrown away — the radio has been announcing its drive, its
	// filters and its noise blanker since the first connection.
	if t.handlePanel(lower, get, args) {
		// Still falls through for the few the rig state also needs (split, tune),
		// which is why this does not return.
		switch lower {
		case "split_enable", "trx", "modulation", "vfo":
		default:
			return
		}
	}
	switch lower {
	case "device":
		t.device = strings.TrimSpace(args)
	// The server's own announcement: "protocol:ExpertSDR3,1.9;" — its name and
	// the TCI version it speaks. Worth keeping rather than filing under
	// "unhandled": panorama spots need a version that HAS the spot command, and
	// without this an operator on an older ExpertSDR sees nothing on the
	// waterfall and nothing anywhere saying why.
	case "protocol":
		t.serverName, t.serverVersion = get(0), get(1)
		debugLog.Printf("TCI: server is %s, TCI %s", t.serverName, t.serverVersion)
		if t.spotsEnabled && tciSpotsUnsupported(t.serverVersion) {
			debugLog.Printf("TCI: this server speaks TCI %s — panorama spots need 1.5 or later, so they will not appear",
				t.serverVersion)
		}
	// The radio ANNOUNCES its audio format at connect —
	// "audio_stream_sample_type:float32" and "audio_stream_channels:2" — which
	// is better evidence than anything derived from a frame, and it arrives
	// before the first frame does. Both were being logged as unhandled.
	case "audio_stream_sample_type":
		t.audio.declaredType = strings.TrimSpace(args)
	case "audio_stream_channels":
		if n, err := strconv.Atoi(strings.TrimSpace(args)); err == nil && n > 0 && n <= 8 {
			t.audio.declaredChans = n
		}
	case "ready", "start":
		t.ready = true
		// (Re)subscribe to the meters HERE, not only at connect. ExpertSDR3
		// dumps its whole state and then says "ready"; a unidirectional control
		// command sent while that dump is still in flight can be ignored, and
		// the report from a real SunSDR — transmit meters still empty after the
		// connect-time subscription — has exactly that shape. From a goroutine:
		// send takes t.mu, which this handler holds.
		go t.subscribeSensors("ready")
	case "stop":
		t.ready = false
	case "vfo":
		// vfo:<rx>,<channel>,<freq>
		if get(0) == "0" {
			hz, _ := strconv.ParseInt(get(2), 10, 64)
			if hz > 0 {
				t.ready = true // receiving live state → treat as ready even without an explicit "ready;"
				switch get(1) {
				case "0":
					t.freqA = hz
					// The radio has caught up: from here the echo IS the truth.
					if t.wantFreq != 0 && absInt64(hz-t.wantFreq) < 100 {
						t.wantFreq = 0
					}
				case "1":
					t.freqB = hz
				}
			}
		}
	case "modulation":
		if get(0) == "0" {
			t.mode = strings.ToLower(get(1))
		}
	case "split_enable":
		if get(0) == "0" {
			t.split = get(1) == "true"
		}
	case "trx":
		if get(0) == "0" {
			was := t.tx
			t.tx = get(1) == "true"
			// Said out loud, every time. The transmit side of TCI can only be
			// written from a log of a real transmission, and the first one came
			// back without a single line to say whether the radio had even been
			// keyed — which left the interesting question, why no transmit
			// frames, indistinguishable from nobody having pressed anything.
			if was != t.tx {
				t.noteTXTransition(t.tx)
			}
		}
	case "drive":
		if get(0) == "0" {
			if v, err := strconv.Atoi(get(1)); err == nil {
				t.drive = v
			}
		}
	case "tx_enable":
		if get(0) == "0" {
			allowed := get(1) == "true"
			if !t.txAllowedKnown || t.txAllowed != allowed {
				debugLog.Printf("TCI: the radio %s transmitting", map[bool]string{true: "allows", false: "REFUSES"}[allowed])
			}
			t.txAllowed, t.txAllowedKnown = allowed, true
		}
	default:
		lname := lower
		// A click on one of our panorama spots comes back as
		//   CLICKED_ON_SPOT:<call>,<hz>              (legacy)
		//   RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
		// Neither name starts with "spot", which is why the click was silently
		// ignored before. Read the callsign (the one non-numeric field) and the
		// frequency (the large numeric field) positionally-independently, so both
		// shapes work without depending on the exact arg order.
		if strings.Contains(lname, "spot") {
			var call string
			var hz int64
			for _, raw := range f {
				v := strings.TrimSpace(raw)
				if v == "" {
					continue
				}
				if n, err := strconv.ParseInt(v, 10, 64); err == nil {
					if n >= 10000 { // a real frequency, not an rx/channel index
						hz = n
					}
				} else if call == "" {
					call = strings.ToUpper(v) // callsigns always carry letters
				}
			}
			debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
			if call != "" && t.OnSpotClick != nil {
				cb := t.OnSpotClick
				go cb(call, hz)
			}
			return
		}
		// Log every OTHER unknown message TYPE once, so the protocol (incl. any
		// spot-click notification named differently) is discoverable from the log
		// without flooding it with the frequent streamed messages.
		if t.unhandledSeen == nil {
			t.unhandledSeen = map[string]bool{}
		}
		if !t.unhandledSeen[lname] {
			t.unhandledSeen[lname] = true
			debugLog.Printf("TCI: (unhandled once) %s", msg)
		}
	}
}

// tciModeToADIF converts a TCI modulation to an ADIF mode. Generic digital
// modulations surface the operator's chosen digital default (FT8/FT4/RTTY…).
func tciModeToADIF(m, digitalDefault string) string {
	switch strings.ToLower(strings.TrimSpace(m)) {
	case "usb", "lsb", "dsb":
		return "SSB"
	case "cw":
		return "CW"
	case "am", "sam":
		return "AM"
	case "nfm", "wfm", "fm":
		return "FM"
	case "digu", "digl":
		if digitalDefault != "" {
			return strings.ToUpper(digitalDefault)
		}
		return "DATA"
	case "drm":
		return "DIGITALVOICE"
	case "":
		return ""
	default:
		return strings.ToUpper(m)
	}
}

// adifToTCIMode maps an ADIF mode to a TCI modulation. USB/LSB is chosen from
// the frequency (< 10 MHz → LSB) as usual. Digital modes → digu.
func adifToTCIMode(mode string, freqHz int64) string {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "SSB", "USB", "LSB":
		if freqHz > 0 && freqHz < 10_000_000 {
			return "lsb"
		}
		return "usb"
	case "CW", "CWR", "CW-R":
		return "cw"
	case "AM":
		return "am"
	case "FM", "NFM":
		return "nfm"
	case "RTTY":
		return "digl"
	case "":
		return ""
	default:
		// FT8/FT4/PSK/DATA/JT… → upper-sideband digital.
		return "digu"
	}
}

// tciSpotsUnsupported reports whether a TCI version predates the spot commands.
//
// SPOT / SPOT_DELETE / SPOT_CLEAR arrived in TCI 1.5. An older ExpertSDR accepts
// the connection, answers frequency and mode perfectly, and silently ignores
// every spot — which is indistinguishable from a bug in the logger unless
// somebody says so. Anything unparseable is treated as supported: refusing to
// draw on a doubt would be the worse mistake.
func tciSpotsUnsupported(version string) bool {
	var maj, min int
	if n, err := fmt.Sscanf(strings.TrimSpace(version), "%d.%d", &maj, &min); n < 2 || err != nil {
		return false
	}
	return maj < 1 || (maj == 1 && min < 5)
}
