package cat

// The Yaesu control panel: meters and the settings an operator reaches for
// mid-QSO. Split out from yaesu.go, which stays the small fast path the whole
// application depends on — a panel read that hangs must never delay the
// frequency display.
//
// Reads are STAGGERED. Meters change constantly and are polled every cycle;
// settings (power, gains, AGC, filters) only change when someone turns a knob,
// so they are refreshed every 8th cycle and immediately after any set. At the
// default 250 ms cycle that is a two-second worst case for a knob turned on the
// radio, against a serial link that would otherwise carry twenty queries a
// second and starve the frequency poll it shares.
//
// Verified on: FTDX10. Commands come from its CAT reference; a control a model
// does not implement simply never answers, and askNum keeps the previous value
// rather than showing a zero that reads as "it reset itself".

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

// YaesuTXState is the panel snapshot handed to the frontend.
type YaesuTXState struct {
	Available bool   `json:"available"`
	Model     string `json:"model,omitempty"`
	Mode      string `json:"mode,omitempty"`
	// RawMode is the rig mode with its sideband (CW-U, DATA-L…), which ADIF
	// deliberately does not record but the panel has to show.
	RawMode string `json:"raw_mode,omitempty"`

	Transmitting bool `json:"transmitting"`
	Split        bool `json:"split"`
	// SplitTXHz is where the rig will TRANSMIT while split — the panel showed a
	// lit SPLIT button and nothing else, which does not tell an operator whether
	// they are up 1 or up 5.
	SplitTXHz  int64 `json:"split_tx_hz"`
	SMeter     int   `json:"s_meter"`     // 0-100 (raw 0-255)
	PowerMeter int   `json:"power_meter"` // 0-100, TX only
	SWRMeter   int   `json:"swr_meter"`   // 0-100, TX only

	RFPower int `json:"rf_power"` // watts
	// MaxPower is what this model can actually be set to, so the slider stops
	// where the radio does. An FTDX101MP is a 200 W transmitter and its operator
	// could not ask for more than 100 W — the slider, not the rig, was the limit.
	MaxPower int `json:"max_power"`
	// NarrowSupported says the rig answered NA at all. A button that reports a
	// state the radio never gave, and does nothing when pressed, is worse than
	// an absent one: it looks like a fault in the radio.
	NarrowSupported bool   `json:"narrow_supported"`
	MicGain         int    `json:"mic_gain"` // 0-100
	AFGain          int    `json:"af_gain"`  // 0-100
	RFGain          int    `json:"rf_gain"`  // 0-100
	Squelch         int    `json:"squelch"`  // 0-100
	AGC             string `json:"agc,omitempty"`
	Preamp          int    `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
	// Antenna is the selected jack, 1-3, or 0 when the rig has no AN command —
	// an FT-891 or FT-991A has a single socket and answers nothing. 0 is what
	// tells the panel to draw no selector at all rather than a dead one.
	Antenna int  `json:"antenna"`
	Att     int  `json:"att"` // 0=off, else dB
	NB      bool `json:"nb"`
	NR      bool `json:"nr"`
	NRLevel int  `json:"nr_level"` // 1-15
	Narrow  bool `json:"narrow"`   // NAR filter
	// SWR is the RATIO (1.0, 1.5…), computed from the reflection coefficient —
	// what an operator reads on the rig, not a percentage of meter travel.
	SWR float64 `json:"swr"`
	// PowerW is the output in WATTS, taken from the METER rather than from the
	// power setting — the setting says what was asked for, not what came out.
	PowerW float64 `json:"power_w"`
	VOX    bool    `json:"vox"`
	// CW-only controls. Read (and shown) only in CW, where MIC and VOX mean
	// nothing and these are what an operator reaches for.
	KeySpeed int  `json:"key_speed"` // WPM
	BreakIn  bool `json:"break_in"`
}

// YaesuController is the typed escape the Manager exposes for the panel, in the
// same shape as FlexController and IcomController.
type YaesuController interface {
	YaesuState() YaesuTXState
	// SetRTTYUpper is a preference, not a command — see Yaesu.SetRTTYUpper. It
	// belongs here so a change of mind reaches the RUNNING rig: the link is not
	// rebuilt for it, and until it was reachable this way the setting only took
	// effect on the next launch.
	SetRTTYUpper(bool)
	RefreshYaesu() error
	SetYaesuPower(int) error
	SetYaesuMicGain(int) error
	SetYaesuAFGain(int) error
	SetYaesuRFGain(int) error
	SetYaesuSquelch(int) error
	SetYaesuAGC(string) error
	SetYaesuPreamp(int) error
	SetYaesuAntenna(int) error
	SetYaesuAtt(int) error
	SetYaesuNB(bool) error
	SetYaesuNR(bool) error
	SetYaesuNRLevel(int) error
	SetYaesuNarrow(bool) error
	SetYaesuVOX(bool) error
	SetYaesuKeySpeed(int) error
	SetYaesuBreakIn(bool) error
	YaesuZeroIn() error
	// CW keying through the rig's own keyer (KY) — the fifth CW engine.
	SendCW(string) error
	StopCW() error
	SetYaesuSplit(bool) error
	SetYaesuSplitOffset(int64) error
	SetYaesuBand(string) error
	SetYaesuModeRaw(string) error
	TuneYaesuATU() error
}

func (y *Yaesu) YaesuState() YaesuTXState {
	y.mu.Lock()
	defer y.mu.Unlock()
	st := y.panel
	st.Available = y.port != nil
	st.Model = y.model
	st.MaxPower = yaesuMaxPower(y.model, st.RFPower)
	return st
}

// yaesuMaxPower is how far the power slider may go on this model.
//
// The rig itself is never asked — there is no CAT command for "how much power
// can you make" — so it comes from the model name, with one safeguard: a rig
// REPORTING more than the table expects is believed, because it has just proved
// what it can do. Anything unknown gets 100 W, which is the family default and
// errs towards asking for too little.
func yaesuMaxPower(model string, reported int) int {
	max := 100
	switch {
	case strings.Contains(model, "101MP"), strings.Contains(model, "5000"), strings.Contains(model, "9000"):
		max = 200
	}
	if reported > max {
		max = reported
	}
	return max
}

// readPanel refreshes the meters, and the settings on the slower beat. Called
// from ReadState with the mutex HELD, so it shares the same serialised link.
func (y *Yaesu) readPanel(mode string, split bool, txHz int64) {
	y.panel.Mode = mode
	y.panel.Split = split
	y.panel.SplitTXHz = 0
	if split {
		y.panel.SplitTXHz = txHz
	}

	// TX state first: which meters mean anything depends on it, and a power
	// reading shown while receiving is how a panel lies.
	if r, err := y.ask("TX;"); err == nil && len(r) >= 3 {
		y.panel.Transmitting = r[2] != '0'
	}
	if v, ok := y.askNum("SM0;", "SM0", 3); ok {
		y.panel.SMeter = scale255(v)
	}
	if y.panel.Transmitting {
		// One-shot survey of every meter while transmitting.
		//
		// Which RM index carries which meter is NOT the same across the family, and
		// an operator reported an SWR bar at 80 with a real SWR of 1.1 — the shape
		// of reading the wrong index (ALC, say) rather than of a scaling error.
		// Guessing again would just move the wrong number; this prints all six
		// once, and the log then says which is which on THIS radio.
		// Sampled on EVERY poll while transmitting, capped — one snapshot taken as
		// the transmission starts catches the meters still at rest (RM4=13, the
		// rest zero), which says nothing. What identifies a meter is which index
		// TRACKS the power over a few seconds of steady carrier.
		if y.metersLogged < 40 {
			y.metersLogged++
			raw := make([]string, 0, 6)
			for i := 1; i <= 6; i++ {
				cmd := fmt.Sprintf("RM%d;", i)
				if v, ok := y.askNum(cmd, fmt.Sprintf("RM%d", i), 3); ok {
					raw = append(raw, fmt.Sprintf("RM%d=%d", i, v))
				} else {
					raw = append(raw, fmt.Sprintf("RM%d=-", i))
				}
			}
			// The POWER SETTING goes on the same line. Which index is the wattmeter
			// cannot be read off one transmission — RM4 rose while RM5 sat still,
			// but RM5 differed BETWEEN transmissions (208 then 105), so both are
			// candidates. What settles it is transmitting at two different power
			// settings and seeing which index follows: printing the setting here
			// makes that a one-line comparison instead of a memory exercise.
			debugLog.Printf("yaesu: meters at PC=%dW: %s (compare two different power settings)",
				y.panel.RFPower, strings.Join(raw, " "))
		}
		// Measured on an FTDX10 (2026-07-29). The FM carrier was inconclusive —
		// constant by definition — so the answer came from CW at 100 W, where the
		// keying itself is the experiment:
		//
		//   key down: RM4=25 RM5=207 RM6=13
		//   key up:   RM4=25 RM5=0   RM6=0
		//
		// RM5 follows the RF envelope exactly, so RM5 is the POWER meter. RM4 sits
		// near 25 whether the key is down or up, so it is not measuring output at
		// all — reading it as power is what showed 8 W on a 100 W transmission.
		// Peak-hold. In CW the key is up between elements and the meters genuinely
		// read 0 there — the log shows RM5 alternating 207, 0, 207 — so following
		// the raw value makes the bars flicker to nothing several times a second
		// and the number unreadable. A real meter has needle inertia; this is the
		// same idea, and it only ever holds a value the radio actually reported.
		now := time.Now()
		if v, ok := y.askNum("RM5;", "RM5", 3); ok {
			y.panel.PowerMeter = y.powerPeak.update(scale255(v), now)
			y.panel.PowerW = float64(y.powerWPeak.update(int(yaesuWatts(v)+0.5), now))
		}
		// SWR is RM6, and a second measurement at a KNOWN mismatch settled both the
		// index and the scale: 0 at SWR 1.1, then 52 at SWR 1.5. 52/255 = 0.204,
		// which is the reflection coefficient of a 1.5 SWR to three decimals — so
		// the raw value is rho scaled to 255, and the ratio follows from physics
		// rather than from a fitted curve.
		if v, ok := y.askNum("RM6;", "RM6", 3); ok {
			y.panel.SWRMeter = y.swrPeak.update(scale255(v), now)
			// The RATIO is only meaningful while power is going out: between words
			// the reading is 0, which would display as a perfect 1.0 match — worse
			// than a stale figure, because it looks like good news.
			if v > 0 {
				y.panel.SWR = swrFromReflection(v)
			}
		}
	} else {
		// Zeroed rather than frozen: a stale reading from the last transmission
		// reads as a live one.
		//
		// EVERY transmit value has to be cleared here, and the peak-hold state with
		// them. Clearing only the two bar percentages left the watts and the SWR
		// ratio standing — the PWR bar stayed at full scale after the operator
		// stopped transmitting — and a peak left in the holder would have carried
		// the old reading into the next transmission.
		y.panel.PowerMeter, y.panel.SWRMeter = 0, 0
		y.panel.PowerW, y.panel.SWR = 0, 0
		y.powerPeak, y.powerWPeak, y.swrPeak = meterPeak{}, meterPeak{}, meterPeak{}
	}

	y.panelCycle++
	if y.panelLoaded && y.panelCycle < 8 {
		return
	}
	y.panelCycle = 0
	y.panelLoaded = true
	y.readPanelSettings()
}

// readPanelSettings re-reads everything a knob can change. Separate so a set can
// force it without waiting for the slow beat.
func (y *Yaesu) readPanelSettings() {
	if v, ok := y.askNum("PC;", "PC", 3); ok {
		y.panel.RFPower = v // watts, not a 0-255 scale
	}
	// MIC GAIN IS 0-100, not 0-255.
	//
	// It was read through the 0-255 scale like the audio gains, so an FTDX101
	// set to 80 showed 38 — and worse, writing sent 80 → 204, outside the range
	// the rig accepts, so the setting was refused and the slider sprang back to
	// where it had been. Reported from a real FTDX101; the CAT reference gives
	// MG000-100 for the whole current family.
	if v, ok := y.askNum("MG;", "MG", 3); ok {
		y.panel.MicGain = clampInt(v, 0, 100)
	}
	if v, ok := y.askNum("AG0;", "AG0", 3); ok {
		y.panel.AFGain = scale255(v)
	}
	if v, ok := y.askNum("RG0;", "RG0", 3); ok {
		y.panel.RFGain = scale255(v)
	}
	if v, ok := y.askNum("SQ0;", "SQ0", 3); ok {
		y.panel.Squelch = scale255(v)
	}
	if v, ok := y.askNum("GT0;", "GT0", 1); ok {
		y.panel.AGC = yaesuAGCName(v)
	}
	if v, ok := y.askNum("PA0;", "PA0", 1); ok {
		y.panel.Preamp = v
	}
	// ANTENNA. "AN0;" → "AN01;" — the first digit is the receiver (0 = main,
	// 1 = sub on an FTDX101), the second the jack. A rig with one socket does
	// not implement it and simply says nothing, which askNum reports as
	// not-ok — and 0 then means "no antenna switching here".
	if v, ok := y.askNum("AN0;", "AN0", 1); ok {
		if y.panel.Antenna == 0 {
			debugLog.Printf("yaesu: antenna select available (AN0; → %d)", v)
		}
		y.panel.Antenna = v
	} else {
		// Said once, at the first read: an operator who expects the ANT row and
		// does not get it should find the reason in the log rather than wonder
		// whether OpsLog forgot the feature.
		if y.panel.Antenna != -1 {
			debugLog.Printf("yaesu: no answer to AN0; — this rig has no antenna selection, the ANT row stays hidden")
		}
		y.panel.Antenna = -1
	}
	if v, ok := y.askNum("RA0;", "RA0", 1); ok {
		y.panel.Att = yaesuAttDB(v)
	}
	if v, ok := y.askNum("NB0;", "NB0", 1); ok {
		y.panel.NB = v != 0
	}
	if v, ok := y.askNum("NR0;", "NR0", 1); ok {
		y.panel.NR = v != 0
	}
	if v, ok := y.askNum("RL0;", "RL0", 2); ok {
		y.panel.NRLevel = v
	}
	if v, ok := y.askNum("NA0;", "NA0", 1); ok {
		y.panel.Narrow = v != 0
		y.panel.NarrowSupported = true
	}
	if v, ok := y.askNum("VX;", "VX", 1); ok {
		y.panel.VOX = v != 0
	}
	// CW keyer. Read unconditionally — it is two more queries on the SLOW beat,
	// and having the value ready means the CW card is populated the instant the
	// operator switches mode rather than a poll cycle later.
	if v, ok := y.askNum("KS;", "KS", 3); ok {
		y.panel.KeySpeed = v
	}
	if v, ok := y.askNum("BI;", "BI", 1); ok {
		y.panel.BreakIn = v != 0
	}
}

// askNum sends a query and reads a fixed-width decimal field out of the reply.
// ok=false when the rig does not answer, or answers something else — a control
// this model lacks then keeps its previous value instead of dropping to zero,
// which would look like a setting that reset itself.
func (y *Yaesu) askNum(cmd, prefix string, digits int) (int, bool) {
	// A command this model refused once is never asked again. Models implement
	// different subsets — an FTDX10 answers "?;" to MG; — and re-asking costs a
	// 600 ms timeout on every slow beat, for a control that will never answer.
	if y.unsupported[cmd] {
		return 0, false
	}
	r, err := y.ask(cmd)
	if err != nil {
		if errors.Is(err, errYaesuUnsupported) {
			if y.unsupported == nil {
				y.unsupported = map[string]bool{}
			}
			y.unsupported[cmd] = true
			debugLog.Printf("yaesu: this rig does not support %q — not asking again", cmd)
		}
		return 0, false
	}
	if !strings.HasPrefix(r, prefix) || len(r) < len(prefix)+digits {
		debugLog.Printf("yaesu: unexpected reply %q to %q", r, cmd)
		return 0, false
	}
	n, err := strconv.Atoi(r[len(prefix) : len(prefix)+digits])
	if err != nil {
		return 0, false
	}
	return n, true
}

// setAndRefresh writes a command then re-reads the settings, so the panel shows
// what the RIG ended up with rather than what we asked for — the two differ
// whenever a value is out of range or the current mode forbids the control.
func (y *Yaesu) setAndRefresh(cmd string) error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	if err := y.write(cmd); err != nil {
		return err
	}
	time.Sleep(30 * time.Millisecond) // let the rig apply it before reading back
	y.readPanelSettings()
	return nil
}

func (y *Yaesu) RefreshYaesu() error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	y.readPanelSettings()
	return nil
}

func (y *Yaesu) SetYaesuPower(w int) error {
	// The SAME ceiling the console draws its slider to. It was hard-coded at 100
	// here while yaesuMaxPower already answered 200 for an FTDX101MP: asking for
	// 200 W sent PC100, the rig obeyed, and the slider sprang back to 100 on the
	// next poll — which is how the operator discovered it.
	y.mu.Lock()
	max := yaesuMaxPower(y.model, y.panel.RFPower)
	y.mu.Unlock()
	return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, max)))
}

func (y *Yaesu) SetYaesuMicGain(p int) error {
	return y.setAndRefresh(fmt.Sprintf("MG%03d;", clampInt(p, 0, 100)))
}

func (y *Yaesu) SetYaesuAFGain(p int) error {
	return y.setAndRefresh(fmt.Sprintf("AG0%03d;", from100(p)))
}

func (y *Yaesu) SetYaesuRFGain(p int) error {
	return y.setAndRefresh(fmt.Sprintf("RG0%03d;", from100(p)))
}

func (y *Yaesu) SetYaesuSquelch(p int) error {
	return y.setAndRefresh(fmt.Sprintf("SQ0%03d;", from100(p)))
}

func (y *Yaesu) SetYaesuAGC(name string) error {
	return y.setAndRefresh(fmt.Sprintf("GT0%d;", yaesuAGCCode(name)))
}

func (y *Yaesu) SetYaesuPreamp(n int) error {
	return y.setAndRefresh(fmt.Sprintf("PA0%d;", clampInt(n, 0, 2)))
}

// SetYaesuAntenna selects an antenna jack (1-3) on the main receiver.
//
// Verified against the FTDX10 command set; the FTDX101 adds a third jack and a
// sub receiver, which is the "0" in AN0 — the day someone drives a sub receiver
// from here it becomes a parameter rather than a constant. A rig without the
// command ignores it, and readPanelSettings then keeps Antenna at 0, so the
// control never appears in the first place.
func (y *Yaesu) SetYaesuAntenna(n int) error {
	return y.setAndRefresh(fmt.Sprintf("AN0%d;", clampInt(n, 1, 3)))
}

func (y *Yaesu) SetYaesuAtt(db int) error {
	return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db)))
}

func (y *Yaesu) SetYaesuNB(on bool) error {
	return y.setAndRefresh(fmt.Sprintf("NB0%d;", boolDigit(on)))
}

func (y *Yaesu) SetYaesuNR(on bool) error {
	return y.setAndRefresh(fmt.Sprintf("NR0%d;", boolDigit(on)))
}

func (y *Yaesu) SetYaesuNRLevel(n int) error {
	return y.setAndRefresh(fmt.Sprintf("RL0%02d;", clampInt(n, 1, 15)))
}

func (y *Yaesu) SetYaesuNarrow(on bool) error {
	return y.setAndRefresh(fmt.Sprintf("NA0%d;", boolDigit(on)))
}

func (y *Yaesu) SetYaesuVOX(on bool) error {
	return y.setAndRefresh(fmt.Sprintf("VX%d;", boolDigit(on)))
}

// SetYaesuSplit uses whichever command this rig answered at connect. Sending the
// other one would be silently ignored, and the operator would get a split button
// that does nothing.
func (y *Yaesu) SetYaesuSplit(on bool) error {
	// Turning split ON also PLACES the transmit VFO. Flipping the flag alone
	// transmits wherever the other VFO happens to sit — it held 18.115 from an
	// earlier session while the operator was listening on 14.244, so pressing
	// SPLIT threw them onto another band entirely. The other VFO is stale by
	// nature; the only frequency that makes sense is one derived from where the
	// operator is listening NOW.
	//
	// The distance is the mode's usual one: 1 kHz on CW and the data modes,
	// 5 kHz on phone. The +1k / +5k buttons remain for anything else.
	if on {
		return y.SetYaesuSplitOffset(y.defaultSplitOffset())
	}
	y.mu.Lock()
	cmd, vfo := y.splitCmd, y.curVFO
	y.mu.Unlock()
	if cmd == "" {
		return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
	}
	return y.setAndRefresh(yaesuSplitCommand(cmd, vfo, false))
}

// defaultSplitOffset is what SPLIT means on this mode: up 1 kHz on CW and the
// data modes, up 5 kHz on phone — the offsets operators actually call.
func (y *Yaesu) defaultSplitOffset() int64 {
	y.mu.Lock()
	raw := strings.ToUpper(y.panel.RawMode)
	y.mu.Unlock()
	switch {
	case strings.HasPrefix(raw, "CW"), strings.HasPrefix(raw, "RTTY"), strings.HasPrefix(raw, "DATA"):
		return 1000
	}
	return 5000
}

// SetYaesuBand switches band with BS, which lands the rig on ITS OWN last-used
// frequency for that band — the radio's band memory, not a frequency we choose.
// SetYaesuModeRaw selects an exact rig mode, sideband included — "CW-U",
// "DATA-L", "RTTY-U"… The plain SetMode path takes an ADIF mode and can only
// choose a sideband by convention, but CW, RTTY and the data modes are routinely
// run on EITHER sideband and the operator is the one who knows which. This is
// what the panel's mode buttons use.
func (y *Yaesu) SetYaesuModeRaw(name string) error {
	d, ok := yaesuRawModeDigit(name)
	if !ok {
		return fmt.Errorf("yaesu: unknown rig mode %q", name)
	}
	// Same receiver as everywhere else — the console must not set main's mode
	// while the operator is working the sub VFO.
	return y.setAndRefresh(fmt.Sprintf("MD%s%c;", y.modeVFOSuffix(), d))
}

// yaesuRawModeDigit maps a rig-mode name to its MD digit.
func yaesuRawModeDigit(name string) (byte, bool) {
	switch strings.ToUpper(strings.TrimSpace(name)) {
	case "LSB":
		return '1', true
	case "USB":
		return '2', true
	case "CW-U", "CWU":
		return '3', true
	case "CW-L", "CWL":
		return '7', true
	case "FM":
		return '4', true
	case "AM":
		return '5', true
	case "RTTY-L", "RTTYL":
		return '6', true
	case "RTTY-U", "RTTYU":
		return '9', true
	case "DATA-L", "DATAL", "DIGI-L":
		return '8', true
	case "DATA-U", "DATAU", "DIGI-U":
		return 'C', true
	}
	return 0, false
}

func (y *Yaesu) SetYaesuBand(band string) error {
	code, ok := yaesuBandCode(band)
	if !ok {
		return fmt.Errorf("yaesu: no band code for %q", band)
	}
	return y.setAndRefresh(fmt.Sprintf("BS%02d;", code))
}

// TuneYaesuATU starts a tuning cycle. Deliberately not followed by a settings
// read: the rig is transmitting into the tuner for several seconds and answers
// little, so the reads would just time out one after another.
func (y *Yaesu) TuneYaesuATU() error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	return y.write("AC002;")
}

// ── small mappings ────────────────────────────────────────────────────────

func scale255(v int) int {
	if v <= 0 {
		return 0
	}
	if v >= 255 {
		return 100
	}
	return v * 100 / 255
}

func from100(p int) int { return clampInt(p, 0, 100) * 255 / 100 }

func clampInt(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}

func boolDigit(b bool) int {
	if b {
		return 1
	}
	return 0
}

func yaesuAGCName(code int) string {
	switch code {
	case 0:
		return "OFF"
	case 1:
		return "FAST"
	case 2:
		return "MID"
	case 3:
		return "SLOW"
	case 4:
		return "AUTO"
	}
	return ""
}

func yaesuAGCCode(name string) int {
	switch strings.ToUpper(strings.TrimSpace(name)) {
	case "OFF":
		return 0
	case "FAST":
		return 1
	case "MID", "MEDIUM":
		return 2
	case "SLOW":
		return 3
	}
	return 4 // AUTO — the safe default for anything unrecognised
}

// The FTDX10/FTDX101 attenuator is a THREE-step pad (RA00..RA03 = off, 6, 12,
// 18 dB), not the single step this first assumed — a panel offering only one
// step hides two thirds of the control. Reporting the dB rather than the raw
// code lets the buttons label themselves honestly.
func yaesuAttDB(code int) int {
	switch code {
	case 1:
		return 6
	case 2:
		return 12
	case 3:
		return 18
	}
	return 0
}

func yaesuAttCode(db int) int {
	switch {
	case db >= 18:
		return 3
	case db >= 12:
		return 2
	case db >= 6:
		return 1
	}
	return 0
}

// yaesuBandCode maps an ADIF band to the BS command's band number.
func yaesuBandCode(band string) (int, bool) {
	switch strings.ToLower(strings.TrimSpace(band)) {
	case "160m":
		return 0, true
	case "80m":
		return 1, true
	case "60m":
		return 2, true
	case "40m":
		return 3, true
	case "30m":
		return 4, true
	case "20m":
		return 5, true
	case "17m":
		return 6, true
	case "15m":
		return 7, true
	case "12m":
		return 8, true
	case "10m":
		return 9, true
	case "6m":
		return 10, true
	}
	return 0, false
}

// yaesuRawModeName is the inverse of yaesuRawModeDigit: what the rig is on,
// sideband included, for the panel's mode buttons to highlight.
func yaesuRawModeName(d byte) string {
	switch d {
	case '1':
		return "LSB"
	case '2':
		return "USB"
	case '3':
		return "CW-U"
	case '4':
		return "FM"
	case '5':
		return "AM"
	case '6':
		return "RTTY-L"
	case '7':
		return "CW-L"
	case '8':
		return "DATA-L"
	case '9':
		return "RTTY-U"
	case 'C':
		return "DATA-U"
	}
	return ""
}

// SetYaesuSplitOffset puts the transmit VFO a fixed distance above the receive
// one and turns split on, in a single action.
//
// This is the split an operator actually uses when working a pile-up: listen on
// the DX, transmit up 5 kHz on phone or up 1 kHz on CW. Doing it by hand means
// swapping VFOs, retuning and swapping back, which is exactly the fumbling a
// panel should remove.
//
// The offset is applied to the RECEIVE frequency and written to the OTHER VFO —
// whichever that is. On VFO B the roles are mirrored, so listening on B writes A.
func (y *Yaesu) SetYaesuSplitOffset(offsetHz int64) error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	rx := y.curRXFreq
	if rx <= 0 {
		return fmt.Errorf("yaesu: no receive frequency read yet")
	}
	tx := rx + offsetHz
	if tx <= 0 || tx > 999_999_999 {
		return fmt.Errorf("yaesu: split frequency %d out of the CAT range", tx)
	}
	// Write the VFO we are NOT listening on.
	cmd := "FB"
	if y.curVFO == "B" {
		cmd = "FA"
	}
	if err := y.write(fmt.Sprintf("%s%09d;", cmd, tx)); err != nil {
		return err
	}
	if y.splitCmd == "" {
		return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
	}
	time.Sleep(30 * time.Millisecond)
	if err := y.write(yaesuSplitCommand(y.splitCmd, y.curVFO, true)); err != nil {
		return err
	}
	y.panel.Split = true
	y.panel.SplitTXHz = tx
	return nil
}

// SetYaesuKeySpeed sets the internal keyer speed in words per minute. The rig
// clamps to its own 4-60 range; clamping here too keeps a slider from sending a
// value that would simply be ignored, which reads as a dead control.
func (y *Yaesu) SetYaesuKeySpeed(wpm int) error {
	return y.setAndRefresh(fmt.Sprintf("KS%03d;", clampInt(wpm, 4, 60)))
}

// SetYaesuBreakIn toggles CW break-in (BK).
func (y *Yaesu) SetYaesuBreakIn(on bool) error {
	return y.setAndRefresh(fmt.Sprintf("BI%d;", boolDigit(on)))
}

// YaesuZeroIn is the CW ZIN function: the rig retunes itself so the station
// being received lands exactly on the operator's CW pitch. It is a one-shot
// action with no state to read back, so no settings refresh follows — and the
// frequency change arrives through the normal poll like any other.
func (y *Yaesu) YaesuZeroIn() error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	return y.write("ZI;")
}

// swrFromReflection turns the rig's SWR meter reading into the ratio itself.
//
// The raw value is the reflection coefficient scaled to 255 — established on an
// FTDX10 by measuring at two known matches (0 at 1.1, 52 at 1.5; 52/255 = 0.204,
// which is rho for a 1.5 SWR). So SWR = (1+rho)/(1-rho), physics rather than a
// curve fitted to two points.
//
// Capped at 9.9: past that the number stops meaning anything to an operator, and
// rho approaching 1 sends the ratio to infinity.
func swrFromReflection(raw int) float64 {
	if raw <= 0 {
		return 1.0
	}
	rho := float64(raw) / 255.0
	if rho >= 0.98 {
		return 9.9
	}
	swr := (1 + rho) / (1 - rho)
	if swr > 9.9 {
		return 9.9
	}
	return swr
}

// yaesuWatts converts the power-meter reading to watts.
//
// The meter is NOT linear in power, which one calibration point could never
// reveal: scaling 207 = 100 W straight down read 30 W where the rig showed 10,
// and 75 where it showed 50. Three points measured against the radio's own
// display settle the curve:
//
//	raw  62 → 10 W
//	raw 155 → 50 W
//	raw 207 → 100 W
//
// Interpolating between them reproduces the rig exactly at those points and
// stays close in between — better than fitting a formula to three samples and
// pretending it holds everywhere. Above the last point it keeps the final
// segment's slope, so an amplifier-driving rig does not flatten at 100 W.
//
// Measured on an FTDX10 (2026-07-29). Another model may well need its own row.
var yaesuPowerCurve = []struct {
	raw   int
	watts float64
}{
	{0, 0},
	{62, 10},
	{155, 50},
	{207, 100},
}

func yaesuWatts(raw int) float64 {
	if raw <= 0 {
		return 0
	}
	for i := 1; i < len(yaesuPowerCurve); i++ {
		hi := yaesuPowerCurve[i]
		if raw <= hi.raw {
			lo := yaesuPowerCurve[i-1]
			span := float64(hi.raw - lo.raw)
			if span <= 0 {
				return hi.watts
			}
			f := float64(raw-lo.raw) / span
			return lo.watts + f*(hi.watts-lo.watts)
		}
	}
	// Past the top of the curve: extend the last segment rather than clamp.
	n := len(yaesuPowerCurve)
	lo, hi := yaesuPowerCurve[n-2], yaesuPowerCurve[n-1]
	slope := (hi.watts - lo.watts) / float64(hi.raw-lo.raw)
	return hi.watts + float64(raw-hi.raw)*slope
}

// meterPeak gives a meter the inertia a needle has: it jumps to a higher reading
// at once, HOLDS it for a moment, then falls back gradually.
//
// The hold is the part that matters on the air. Between CW elements the gap is
// milliseconds, but between the words of a CQ — in CW as in SSB — it is most of
// a second, and a meter that decays straight away reads 0 in every one of those
// gaps: the operator sees a bar flashing rather than the power they are running.
type meterPeak struct {
	val int
	at  time.Time
}

const (
	meterHold  = 1500 * time.Millisecond // how long a peak stands before it starts to fall
	meterDecay = 4                       // then a quarter of the remaining gap per poll
)

// update returns the value to show for this sample.
func (m *meterPeak) update(sample int, now time.Time) int {
	if sample >= m.val {
		m.val, m.at = sample, now
		return m.val
	}
	if now.Sub(m.at) < meterHold {
		return m.val // still inside the hold — the needle has not started to fall
	}
	// At least one step down, always. A proportional decay on integers stalls
	// near the end: with the needle at 13 and the truth at 10, a quarter of the
	// gap rounds to zero and the meter sits three units high for ever.
	step := (m.val - sample) / meterDecay
	if step < 1 {
		step = 1
	}
	m.val -= step
	if m.val <= sample {
		m.val = sample
	}
	return m.val
}

// yaesuSplitCommand builds the command that turns split on or off.
//
// Writing is as asymmetric as reading. ST takes the state directly — ST1 is
// split on. FT sets which VFO TRANSMITS, so "split on" means transmit on the
// OTHER VFO from the one being listened to, and "split off" means transmit on
// the same one. Sending FT1 for "on" regardless is right only for an operator on
// VFO A; on SUB it would clear the split it was asked to set.
func yaesuSplitCommand(cmd, vfo string, on bool) string {
	if cmd == "ST" {
		if on {
			return "ST1;"
		}
		return "ST0;"
	}
	onSub := strings.HasPrefix(strings.ToUpper(vfo), "B")
	txOnB := onSub // split off = transmit where we listen
	if on {
		txOnB = !onSub
	}
	if txOnB {
		return cmd + "1;"
	}
	return cmd + "0;"
}

// updateSnap is update for a meter where lingering is misinformation: the peak
// stands for the hold, then the display returns to the live sample AT ONCE.
// Made for the K3's SWR — the radio throws a brief SWR spike as an FT8 frame
// ends, and the quarter-of-the-gap decay above turned that one bad sample into
// twelve seconds of alarming red on the next transmission. A needle's inertia
// suits a power meter; an SWR reading is a warning light, and a warning that
// fades slowly reads as a fault that is slowly getting better.
func (m *meterPeak) updateSnap(sample int, now time.Time) int {
	if sample >= m.val {
		m.val, m.at = sample, now
		return m.val
	}
	if now.Sub(m.at) < meterHold {
		return m.val
	}
	m.val, m.at = sample, now
	return m.val
}
