package cat

// Native Yaesu CAT over the rig's serial/USB port — no OmniRig.
//
// Why this exists: OmniRig sits between OpsLog and the radio and adds its own
// rig-description files, its own VFO/split interpretation and its own polling.
// Every Yaesu problem reported so far came from that layer disagreeing with the
// radio — a .ini that never exposes the VFO, a Freq property that means A on one
// model and B on another, a split flag that alternates. Talking to the rig
// directly removes the disagreement: what the radio answers is what we show.
//
// ── The protocol ──────────────────────────────────────────────────────────
// Modern Yaesu CAT is plain ASCII: a command, its arguments, and a ';'
// terminator. A query is the command with no argument; the rig echoes the same
// command with the value. It is the same shape as Kenwood's, which is why an
// FTDX10 answers a Kenwood-speaking logger for the basics.
//
//	FA;            → FA014074000;   VFO A frequency, 9 digits, Hz
//	FB;            → FB014100000;   VFO B frequency
//	MD0;/MD1;      → MD02;          operating mode — 0 = main receiver, 1 = sub
//	FR;            → FR1;           RECEIVE VFO (0=main/A, 1=sub/B)
//	VS;            → VS0;           selected VFO, on models without FR
//	ST;            → ST1;           split (FTDX10/FTDX101)
//	FT;            → FT1;           TX VFO (FT-991A/FT-710/FT-891 family)
//	TX1; / TX0;                     key / unkey
//	ID;            → ID0761;        model identifier
//
// Two of these are genuinely uncertain across the family and are treated as
// such rather than guessed at: SPLIT is read through ST and, if the rig does not
// answer that, through FT — whichever replies wins, and the choice is
// remembered. Every unrecognised reply is logged raw, because that log is the
// only way to learn a model's real behaviour from an operator's shack.
//
// Verified on: FTDX10, 2026-07-29 — frequency, mode, VFO and split all correct
// against the radio. The other models are still inference from the same CAT
// reference; anything this file asserts about a rig it has not met should be
// read as a hypothesis with a log line attached.

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

	"go.bug.st/serial"
)

// yaesuModels maps the ID reply to a display name. An unknown id is shown as
// itself rather than guessed — a wrong model name would be worse than a number,
// because it silently implies capabilities the rig may not have.
var yaesuModels = map[string]string{
	"0761": "FTDX10",
	"0681": "FTDX101D",
	"0682": "FTDX101MP",
	"0800": "FT-710",
	"0570": "FT-991A",
	"0650": "FT-891",
	"0670": "FT-DX3000",
	"0460": "FT-450D",
	// The eight-digit family. Named for the console; the frequency format is
	// learned from the rig either way (see learnFreqWidth).
	"0251": "FT-2000",
	"0310": "FT-950",
	"0583": "FTDX1200",
	"0462": "FTDX3000",
	"0101": "FTDX5000",
}

// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
// differ only by sideband, which ADIF does not record — they collapse to the
// operator's configured digital mode and to RTTY respectively.
var yaesuModeToADIF = map[byte]string{
	'1': "LSB",
	'2': "USB",
	'3': "CW",
	'4': "FM",
	'5': "AM",
	'6': "RTTY",
	'7': "CW",
	'8': "DATA",
	'9': "RTTY",
	'A': "FM",
	'B': "FM",
	'C': "DATA",
	'D': "AM",
	'E': "FM", // C4FM — digital voice, closest ADIF sense is FM
}

type Yaesu struct {
	// lowerLines deasserts DTR and RTS on connect — see Kenwood.lowerLines for
	// why neither default is safe for every station.
	lowerLines bool
	portName   string
	baud       int
	digital    string // ADIF mode reported for DATA (FT8, RTTY…)

	mu   sync.Mutex
	port serial.Port

	model string
	// splitCmd is learned at connect: "ST" or "FT" depending on which the rig
	// answers. Empty means the rig answered neither, and split is reported as
	// off rather than invented.
	splitCmd string
	// rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty
	// and VS is used — see ReadState.
	rxVFOCmd string
	// freqDigits is how many digits this rig writes a frequency in, LEARNED from
	// its own replies. See learnFreqWidth.
	freqDigits int
	// rttyUpper picks RTTY-U over RTTY-L — see SetRTTYUpper.
	rttyUpper bool

	curFreq   int64
	curRXFreq int64
	curVFO    string // "A" or "B"

	// Control-panel state and its slow-beat counter — see yaesu_panel.go.
	panel       YaesuTXState
	panelCycle  int
	panelLoaded bool
	// Commands this rig answered "?;" to — asked once, then never again.
	unsupported map[string]bool
	// Needle inertia for the TX meters — see meterPeak.
	powerPeak  meterPeak
	powerWPeak meterPeak
	swrPeak    meterPeak
	// metersLogged counts the RM1..RM6 samples taken during transmission, so the
	// survey follows a real carrier instead of catching one instant of it.
	metersLogged int
}

func NewYaesu(portName string, baud int, digital string) *Yaesu {
	if baud <= 0 {
		baud = 38400 // FTDX10/FTDX101 factory default
	}
	if strings.TrimSpace(digital) == "" {
		digital = "FT8"
	}
	return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
}

// SetRTTYUpper chooses which sideband RTTY is set on.
//
// Yaesu has both — MD06 is RTTY-L, MD09 is RTTY-U — and ADIF has neither: it
// says "RTTY" and stops there, so the rig cannot be driven from the logged mode
// alone. LSB is the older convention and stays the default; an operator whose
// FSK controller or decoder wants the other one says so once here.
func (y *Yaesu) SetRTTYUpper(v bool) {
	y.mu.Lock()
	defer y.mu.Unlock()
	y.rttyUpper = v
}

// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
// before Connect.
func (y *Yaesu) SetLowerLines(v bool) {
	y.mu.Lock()
	y.lowerLines = v
	y.mu.Unlock()
}

func (y *Yaesu) Name() string { return "yaesu" }

func (y *Yaesu) Connect() error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.portName == "" {
		return fmt.Errorf("yaesu: no serial port configured")
	}
	// Close any handle we still hold before opening another.
	//
	// Connect is called again on every reconnect, and it used to overwrite y.port
	// with a fresh handle and leak the old one. Windows opens a serial port
	// EXCLUSIVELY, so a leaked handle makes the next open fail with "Serial port
	// busy" — seen in the field — and the retry loop then leaks one handle per
	// attempt, once every few seconds, for as long as it keeps failing.
	if y.port != nil {
		_ = y.port.Close()
		y.port = nil
	}
	p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
	if err != nil {
		return fmt.Errorf("yaesu: open %s @ %d baud: %w%s", y.portName, y.baud, err, busyHint(y.portName, err))
	}
	// The modem lines are only touched when the operator asks for it — see the
	// note on Kenwood.lowerLines. Both defaults break somebody's station.
	if y.lowerLines {
		_ = p.SetDTR(false)
		_ = p.SetRTS(false)
	}
	p.SetReadTimeout(300 * time.Millisecond)
	y.port = p

	// Silence unsolicited status reports. The rig can push them on every knob
	// movement (AI1), which interleaves with our request/response pairs and makes
	// a reply impossible to attribute — we poll instead, so the traffic is ours.
	_ = y.write("AI0;")

	answered := false
	if id, err := y.ask("ID;"); err == nil {
		answered = true
		code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
		if name, ok := yaesuModels[code]; ok {
			y.model = name
		} else {
			y.model = "Yaesu (" + code + ")"
			debugLog.Printf("yaesu: unknown model id %q — add it to yaesuModels", code)
		}
	} else {
		debugLog.Printf("yaesu: ID query failed (%v) — continuing, the model name is cosmetic", err)
	}

	// Which command carries split on THIS rig. Asking once at connect and
	// remembering the answer keeps the poll loop from paying for two round trips
	// per cycle, and makes "neither answered" an explicit, logged state instead
	// of a silent assumption that split is off.
	// Which command reports the RECEIVE VFO. FR is the right one where it exists;
	// VS is a weaker substitute that an FTDX101 answers with the main VFO even
	// when the operator has moved both RX and TX to sub.
	if r, err := y.ask("FR;"); err == nil && strings.HasPrefix(r, "FR") {
		answered = true
		y.rxVFOCmd = "FR"
		debugLog.Printf("yaesu: receive VFO is read through FR (answered %q)", r)
	} else {
		debugLog.Printf("yaesu: no FR; — falling back to VS for the receive VFO")
	}

	// Which command carries split — and the ORDER matters.
	//
	// ST is a bare flag: it says "split", not which VFO transmits. On an FTDX101
	// with RX and TX both moved to SUB, the rig reports ST1 even though that is
	// plain simplex on the sub VFO, and OpsLog showed split with the main
	// frequency as TX (F4NBZ, 2026-07-29). FT names the TRANSMIT VFO, so where the
	// receive VFO is also known (FR), "split" can be derived from the two: it is
	// on when they differ. That is a statement about the rig's actual state rather
	// than a flag whose meaning varies by model, so FT is asked FIRST when FR
	// answered, and ST remains the fallback for rigs without either.
	splitProbes := []string{"ST", "FT"}
	if y.rxVFOCmd != "" {
		splitProbes = []string{"FT", "ST"}
	}
	for _, c := range splitProbes {
		if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) {
			answered = true
			y.splitCmd = c
			debugLog.Printf("yaesu: split is read through %s (answered %q)", c, r)
			break
		}
	}
	if y.splitCmd == "" {
		debugLog.Printf("yaesu: neither ST; nor FT; answered — split will be reported as OFF. Send this log if the rig does have split.")
	}
	// A port that opens is not a rig that is there.
	//
	// The log used to announce "connected … model=FTDX101D" after every probe had
	// timed out, with the model left over from the previous session — an operator
	// whose radio was simply switched OFF got a line saying it was connected, and
	// the real cause took a log study to find. Say what actually happened.
	if !answered {
		y.model = ""
		debugLog.Printf("yaesu: %s opens at %d baud but the rig answers nothing — is it powered on, and is its CAT rate %d?",
			y.portName, y.baud, y.baud)
		return fmt.Errorf("yaesu: %s opened but the rig is not answering — check that it is switched on", y.portName)
	}
	debugLog.Printf("yaesu: connected on %s @ %d baud, model=%q", y.portName, y.baud, y.model)
	return nil
}

func (y *Yaesu) Disconnect() {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port != nil {
		_ = y.port.Close()
		y.port = nil
	}
}

func (y *Yaesu) ReadState() (RigState, error) {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return RigState{}, fmt.Errorf("yaesu: not connected")
	}
	s := RigState{Backend: y.Name(), Connected: true, Rig: y.model}

	faRaw, err := y.ask("FA;")
	if errors.Is(err, errYaesuUnsupported) {
		// A "?;" here is almost never about FA — the rig answers frequency queries
		// perfectly well. It is a rejection left over from the PREVIOUS command
		// that our read then attributed to this one. Retrying once costs a few
		// milliseconds; treating it as "lost the rig" tore the CAT link down and
		// reconnected it, which is what the operator saw on every CW macro.
		debugLog.Printf("yaesu: FA; got a stray rejection — retrying once")
		faRaw, err = y.ask("FA;")
	}
	if err != nil {
		return RigState{}, err // the rig stopped answering — let the Manager reconnect
	}
	y.learnFreqWidth(faRaw, "FA")
	freqA, ok := parseYaesuFreq(faRaw, "FA")
	if !ok {
		return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
	}
	freqB := int64(0)
	if r, err := y.ask("FB;"); err == nil {
		freqB, _ = parseYaesuFreq(r, "FB")
	}

	// Which VFO the operator is LISTENING on.
	//
	// FR is the command that answers that — it selects the receive VFO — and VS
	// does not: on an FTDX101 with both RX and TX moved to SUB, VS still reported
	// the main VFO, so OpsLog displayed VFO A while the operator was entirely on
	// B. Reported 2026-07-29. FR is asked first and VS is the fallback for models
	// that do not implement it.
	vfo := "A"
	switch {
	case y.rxVFOCmd != "":
		if r, err := y.ask(y.rxVFOCmd + ";"); err == nil && yaesuStateDigit(r, y.rxVFOCmd) == '1' {
			vfo = "B"
		}
	default:
		if r, err := y.ask("VS;"); err == nil && yaesuStateDigit(r, "VS") == '1' {
			vfo = "B"
		}
	}
	y.curVFO = vfo

	split := false
	if y.splitCmd != "" {
		if r, err := y.ask(y.splitCmd + ";"); err == nil {
			split = yaesuSplitFromReply(r, y.splitCmd, vfo)
		}
	}

	s.Vfo = vfo
	s.FreqHz, s.RxFreqHz, s.Split = resolveYaesuVFOs(freqA, freqB, vfo, split)
	y.curFreq = s.FreqHz
	// The frequency being LISTENED to, which is what a split offset is measured
	// from — under split that is RxFreqHz, not FreqHz.
	y.curRXFreq = s.FreqHz
	if s.Split && s.RxFreqHz > 0 {
		y.curRXFreq = s.RxFreqHz
	}

	if r, err := y.ask("MD" + y.modeVFOSuffix() + ";"); err == nil && len(r) >= 4 {
		// Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together,
		// but the panel has to show which sideband the rig is actually on.
		y.panel.RawMode = yaesuRawModeName(r[3])
		if m, ok := yaesuModeToADIF[r[3]]; ok {
			if m == "DATA" {
				m = y.digital
			}
			s.Mode = m
		} else {
			debugLog.Printf("yaesu: unknown mode reply %q", r)
		}
	}
	// s.FreqHz is the TX frequency by the ADIF convention, so it IS the split
	// transmit frequency when split is on.
	y.readPanel(s.Mode, s.Split, s.FreqHz)
	return s, nil
}

func (y *Yaesu) SetFrequency(hz int64) error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	if hz <= 0 || hz > 999_999_999 {
		return fmt.Errorf("yaesu: frequency %d out of the 9-digit CAT range", hz)
	}
	// Write to the VFO the operator is ACTUALLY on. Always writing FA is what
	// makes a display disagree with the radio when the operator is on B.
	cmd := "FA"
	if y.curVFO == "B" {
		cmd = "FB"
	}
	return y.write(fmt.Sprintf("%s%0*d;", cmd, y.freqWidth(), hz))
}

func (y *Yaesu) SetMode(mode string) error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	d := yaesuModeDigit(mode, y.curFreq, y.rttyUpper)
	if d == 0 {
		return fmt.Errorf("yaesu: no CAT mode for %q", mode)
	}
	return y.write(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
}

// modeVFOSuffix picks which receiver the mode commands address: "0" is main,
// "1" is sub.
//
// Both the read and the write used 0 unconditionally. A spot click already tuned
// the right VFO — that part was fixed — but then set the MODE on main, so an
// operator working from the sub receiver had a spot change the mode of the VFO
// they were not using and leave the one they were on untouched (F4NBZ,
// 2026-07-29). The rig reads the same way, so the panel showed main's mode too.
//
// Rigs without a sub receiver simply never see "1": curVFO only becomes B on a
// rig that reports its receive VFO.
func (y *Yaesu) modeVFOSuffix() string {
	if y.curVFO == "B" {
		return "1"
	}
	return "0"
}

func (y *Yaesu) SetPTT(on bool) error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	if on {
		return y.write("TX1;")
	}
	return y.write("TX0;")
}

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

// write sends one command. The caller holds the mutex.
func (y *Yaesu) write(cmd string) error {
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	_, err := y.port.Write([]byte(cmd))
	return err
}

// ask sends a query and reads the reply up to its ';'. The caller holds the
// mutex, so a command and its answer are never interleaved with another's.
func (y *Yaesu) ask(cmd string) (string, error) {
	if err := y.write(cmd); err != nil {
		return "", err
	}
	// Match the reply to the COMMAND, and drop anything else.
	//
	// Returning the first ';'-terminated string whatever it was is what made a CW
	// macro knock the CAT link over: KY produces no reply, so the next query —
	// FA; from the poll loop — collected a leftover frame, failed to parse as a
	// frequency, and the Manager treated that as "lost the rig" and reconnected.
	// The operator saw the CAT drop for a few seconds on every macro click.
	want := cmdPrefix(cmd)
	buf := make([]byte, 0, 64)
	tmp := make([]byte, 64)
	deadline := time.Now().Add(600 * time.Millisecond)
	for time.Now().Before(deadline) {
		n, err := y.port.Read(tmp)
		if err != nil {
			return "", err
		}
		if n == 0 {
			continue // read timeout — the rig may still be composing its answer
		}
		buf = append(buf, tmp[:n]...)
		for {
			i := strings.IndexByte(string(buf), ';')
			if i < 0 {
				break
			}
			frame := string(buf[:i+1])
			buf = buf[i+1:]
			// "?;" is the rig saying it does not know this command. Confirmed on an
			// FTDX10, which answers it to KY; and MG;. Reporting it as such — rather
			// than discarding it and waiting out the timeout — is what lets callers
			// stop asking instead of paying 600 ms per poll for ever.
			if strings.TrimSpace(frame) == "?;" {
				return "", errYaesuUnsupported
			}
			if want == "" || strings.HasPrefix(strings.ToUpper(frame), want) {
				return frame, nil
			}
			debugLog.Printf("yaesu: discarding %q while waiting for %s (asked %q)", frame, want, cmd)
		}
		if len(buf) > 512 {
			return "", fmt.Errorf("yaesu: no ';' in %d bytes answering %q", len(buf), cmd)
		}
	}
	return "", fmt.Errorf("yaesu: timeout answering %q", cmd)
}

// errYaesuUnsupported is returned when the rig answers "?;" — it does not know
// the command. Different models implement different subsets, and the only
// reliable way to learn which is to ask once and remember the refusal.
var errYaesuUnsupported = errors.New("yaesu: command not supported by this rig")

// cmdPrefix is the leading letters of a command — what its reply starts with.
// "FA;" → "FA", "MD0;" → "MD", "KY;" → "KY".
func cmdPrefix(cmd string) string {
	c := strings.ToUpper(strings.TrimSpace(cmd))
	for i := 0; i < len(c); i++ {
		if c[i] < 'A' || c[i] > 'Z' {
			return c[:i]
		}
	}
	return c
}

// EIGHT DIGITS OR NINE — the rig says which, and it is not a matter of taste.
//
// The FTDX10, FT-991A, FT-891 and FT-710 write a frequency in nine digits;
// everything before them — FTDX3000, FTDX5000, FTDX1200, FT-2000, FT-950,
// FT-450 — writes eight, and answers a nine-digit SET with "?;". An operator
// with an FTDX3000 saw exactly that: every FA command rejected, a radio that
// would not follow, and nothing to say why.
//
// The width is LEARNED rather than tabulated: the rig announces it in every
// reply to "FA;", so the answer comes from the radio in front of the operator
// instead of from a list of models that will always be one release behind. Nine
// until the first reply lands, which is what the modern rigs use and what this
// backend was written against.
const yaesuFreqDigitsDefault = 9

func (y *Yaesu) freqWidth() int {
	if y.freqDigits >= 8 && y.freqDigits <= 11 {
		return y.freqDigits
	}
	return yaesuFreqDigitsDefault
}

// learnFreqWidth takes the width from a frequency reply. Only a reply that
// parses as a frequency teaches anything — a "?;" or a stray frame says nothing
// about the format, and a width learned from one would be worse than the
// default.
func (y *Yaesu) learnFreqWidth(reply, prefix string) {
	r := strings.TrimSpace(reply)
	if !strings.HasPrefix(r, prefix) {
		return
	}
	digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
	if len(digits) < 8 || len(digits) > 11 {
		return
	}
	for _, c := range digits {
		if c < '0' || c > '9' {
			return
		}
	}
	if y.freqDigits != len(digits) {
		debugLog.Printf("yaesu: this rig writes frequencies in %d digits — commands will match", len(digits))
		y.freqDigits = len(digits)
	}
}

// parseYaesuFreq reads "FA014074000;" (or "FA14074000;") into Hz.
func parseYaesuFreq(reply, prefix string) (int64, bool) {
	r := strings.TrimSpace(reply)
	if !strings.HasPrefix(r, prefix) {
		return 0, false
	}
	digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
	if digits == "" {
		return 0, false
	}
	hz, err := strconv.ParseInt(digits, 10, 64)
	if err != nil || hz <= 0 {
		return 0, false
	}
	return hz, true
}

// yaesuSplitFromReply turns the split reply into a yes or no.
//
// The two commands say DIFFERENT things and reading them alike is a real fault,
// reported on an FTDX101 (F4NBZ, 2026-07-29) where the panel showed split ON with
// the radio OFF and the reverse:
//
//	ST is a split FLAG      — ST1 means split, whatever VFO is in use.
//	FT names the TX VFO     — FT0 = transmit on A, FT1 = transmit on B.
//
// Split is on when the rig TRANSMITS on a different VFO from the one it is
// LISTENING to. Reading FT1 as "split" is therefore only right for an operator
// on VFO A: on SUB it is exactly inverted, which is why the same model behaved
// correctly for one operator and backwards for another — one was on MAIN, the
// other on SUB.
func yaesuSplitFromReply(reply, cmd, vfo string) bool {
	d := yaesuStateDigit(reply, cmd)
	if d == 0 {
		return false
	}
	if cmd == "ST" {
		return d == '1'
	}
	// FT: compare the transmit VFO with the one being listened to.
	txOnB := d == '1'
	rxOnB := strings.HasPrefix(strings.ToUpper(vfo), "B")
	return txOnB != rxOnB
}

// resolveYaesuVFOs turns the two frequencies plus the VFO and split flags into
// the ADIF pair: FreqHz is where we TRANSMIT, RxFreqHz only when split.
//
// Kept pure and separate from ReadState so the rules can be tested without a
// radio — the equivalent OmniRig function is where every Yaesu bug lived.
func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64, isSplit bool) {
	listening, transmitting := freqA, freqB
	if vfo == "B" {
		listening, transmitting = freqB, freqA
	}
	if !split {
		return listening, 0, false
	}
	// Split with a missing or identical other VFO is not split: reporting it
	// would put a wrong TX frequency in the log, which is worse than ignoring a
	// flag the rig may have left set.
	if transmitting <= 0 || transmitting == listening {
		return listening, 0, false
	}
	return transmitting, listening, true
}

// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
// which is why the current frequency is part of the decision.
func yaesuModeDigit(mode string, freqHz int64, rttyUpper bool) byte {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "SSB":
		if freqHz > 0 && freqHz < 10_000_000 {
			return '1' // LSB
		}
		return '2' // USB
	case "LSB":
		return '1'
	case "USB":
		return '2'
	case "CW":
		return '3'
	case "FM":
		return '4'
	case "AM":
		return '5'
	case "RTTY":
		if rttyUpper {
			return '9' // RTTY-U
		}
		return '6' // RTTY-L, the older convention
	case "":
		return 0
	default:
		// Everything else is a digital sub-mode (FT8, FT4, PSK31, JS8…). They all
		// ride on the rig's DATA mode; the sideband follows the same convention.
		if freqHz > 0 && freqHz < 10_000_000 {
			return '8' // DATA-LSB
		}
		return 'C' // DATA-USB
	}
}

// yaesuStateDigit returns the STATE digit of a reply — the FIRST digit after
// the command — or 0 if the reply does not belong to this command.
//
// The parameter is not always one character: an FTDX101 answers "FR01;" where an
// FTDX10 answers "FR0;". The state is the FIRST digit in both; the second is a
// separate parameter. Reading the LAST digit inverted it — with RX and TX on
// MAIN the rig answered FR01, OpsLog concluded SUB, the main frequency stopped
// updating and a spot click tuned VFO B (F4NBZ, 2026-07-29). That was my own
// correction of the previous evening, made the wrong way round: the earlier
// "SUB shows MAIN" fault came from reading VS, not from this digit.
func yaesuStateDigit(reply, cmd string) byte {
	r := strings.TrimSpace(reply)
	if !strings.HasPrefix(r, cmd) || len(r) <= len(cmd) {
		return 0
	}
	d := r[len(cmd)]
	if d < '0' || d > '9' {
		return 0
	}
	return d
}
