package cat

import (
	"fmt"
	"strings"
	"time"

	"github.com/go-ole/go-ole"
	"github.com/go-ole/go-ole/oleutil"
)

// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
// split ON = 0x8000, split OFF = 0x10000.
const (
	pmSplitOn  = 0x8000  // PM_SPLITON
	pmSplitOff = 0x10000 // PM_SPLITOFF
)

// OmniRig talks to the user's installed OmniRig server over COM.
//
// All methods MUST be called from the same OS thread (the one Manager.run
// locks). COM is thread-affine on Windows — calling these from random
// goroutines will return E_FAIL or crash.
//
// The user must install OmniRig separately and configure their rig (COM port,
// baud rate) in OmniRig's own GUI. HamLog just reads/writes through it.
type OmniRig struct {
	RigNum int // 1 (Rig1) or 2 (Rig2)

	// ForceVFO overrides which VFO to read: "" trusts the rig file, "A"/"B" do
	// not. OmniRig's VFO report is only as good as the .ini behind it, and a
	// wrong one is invisible: an IC-7610 file was seen declaring VFO B while the
	// operator worked on the main VFO, so OpsLog wrote to A (SetSimplexMode acts
	// on the main VFO) and read B — the frequency "never followed the knob",
	// while it was following the other one all along.
	ForceVFO string

	omnirig *ole.IDispatch
	rig     *ole.IDispatch
	lastSig string // last logged Split/VFO signature — only log on change
	rigType string // OmniRig's RigType string (the .ini title), e.g. "IC-7610"

	// connLogged holds the connect failure already written to the log, so the
	// 5-second reconnect loop reports a persistent problem once instead of
	// forever. Cleared on success.
	connLogged string

	// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
	// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
	// async Freq property, which still reports the OLD band for a poll or two
	// after a QSY — that lag is why a clicked spot needed a second click to fix
	// the sideband (freq moved, but mode read the old band → wrong sideband).
	lastSetFreq   int64
	lastSetFreqAt time.Time

	// lastSplitOnAt is when OmniRig last reported PM_SPLITON cleanly. See the
	// FTDX101D note in ReadState — some .ini files alternate between ON and OFF
	// on consecutive polls, so the flag has to be latched to be usable.
	lastSplitOnAt time.Time

	// splitFlaky records that THIS rig's .ini flips the split flag on its own,
	// which is what arms the latch. lastSplitFlag / splitFlips / splitFlipWindow
	// count the flips inside a rolling window to detect it.
	lastSplitFlag   bool
	splitFlaky      bool
	splitFlips      int
	splitFlipWindow time.Time

	// pendingReadback schedules one delayed frequency log after a Set — see
	// SetFrequency. Zero when nothing is pending.
	pendingReadback time.Time
}

// NewOmniRig creates a non-connected backend. Call Connect before use.
// NewOmniRig builds the backend. forceVFO is "" to follow whatever the rig file
// reports, or "A"/"B" to override it — see the ForceVFO field.
func NewOmniRig(rigNum int, forceVFO string) *OmniRig {
	if rigNum < 1 || rigNum > 2 {
		rigNum = 1
	}
	v := strings.ToUpper(strings.TrimSpace(forceVFO))
	if v != "A" && v != "B" {
		v = ""
	}
	return &OmniRig{RigNum: rigNum, ForceVFO: v}
}

func (o *OmniRig) Name() string { return "omnirig" }

// elevationHint recognises the COM refusal that happens when OmniRig runs
// elevated (as administrator) and OpsLog does not — or the reverse. Windows keeps
// the two integrity levels apart, so the client cannot bind to the running
// server's object and COM falls back to launching a fresh one, which then needs
// elevation the client cannot grant.
//
// It is worth naming explicitly: the operator SEES OmniRig running, with its
// settings window open, so "OmniRig not found" reads as nonsense and sends them
// hunting for a driver or COM-port problem that does not exist. The fix is thirty
// seconds of work once you know what to look for.
func elevationHint(err error) string {
	if err == nil {
		return ""
	}
	msg := strings.ToLower(err.Error())
	// Matched on the HRESULT text in whatever language Windows is running in, so
	// the code is checked too: 0x800702E4 = ERROR_ELEVATION_REQUIRED.
	if strings.Contains(msg, "elevation") || strings.Contains(msg, "élévation") ||
		strings.Contains(msg, "0x800702e4") || strings.Contains(msg, "access denied") ||
		strings.Contains(msg, "accès refusé") {
		return "OmniRig and OpsLog are running at different privilege levels — Windows keeps them apart, " +
			"so OpsLog cannot reach OmniRig even though it is running. Start BOTH the same way: either " +
			"un-tick \"Run as administrator\" on the OmniRig shortcut (and its Compatibility tab), or run " +
			"OpsLog as administrator too"
	}
	return ""
}

// logConnFailure writes a connect failure once per distinct cause. The reconnect
// loop retries every 5 seconds forever, and a station whose OmniRig was simply
// elevated had this filling its log at roughly 1500 lines an hour — which buries
// the very diagnostics someone would go looking for.
func (o *OmniRig) logConnFailure(msg string) {
	if o.connLogged == msg {
		return
	}
	o.connLogged = msg
	debugLog.Printf("OmniRig Rig%d: %s", o.RigNum, msg)
}

func (o *OmniRig) Connect() error {
	// This used to announce DebugLogPath() on every attempt — the path of the
	// FALLBACK cat.log, which nothing writes to once the app has wired LogSink and
	// everything goes to data\opslog.log. It pointed operators at an empty file in
	// %APPDATA% while the lines they wanted were somewhere else entirely. Dropped;
	// and logged once per failure run rather than every 5-second retry.
	if o.connLogged == "" {
		debugLog.Printf("OmniRig.Connect Rig%d", o.RigNum)
	}
	if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
		// 0x1 = S_FALSE → COM already initialised on this thread, fine.
		if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
			return fmt.Errorf("CoInitializeEx: %w", err)
		}
	}

	const progID = "Omnirig.OmnirigX"
	var omnirig *ole.IDispatch
	unk, err := oleutil.CreateObject(progID)
	if err == nil {
		omnirig, err = unk.QueryInterface(ole.IID_IDispatch)
		unk.Release()
		if err != nil {
			return fmt.Errorf("query interface: %w", err)
		}
	} else {
		// A privilege mismatch is final — retrying, or trying the 32-bit server,
		// cannot cross an integrity boundary. Say what to do instead of dressing it
		// up as "not installed", which is what sends operators looking in the wrong
		// place entirely.
		if hint := elevationHint(err); hint != "" {
			o.logConnFailure(hint)
			return fmt.Errorf("%s (Windows said: %v)", hint, err)
		}
		// Otherwise it may be a partial registration; try activating the 32-bit
		// server explicitly before giving up (see omnirig_activate32.go).
		disp, err32 := createOmniRig32(progID)
		if err32 != nil {
			o.logConnFailure(fmt.Sprintf("CreateObject(%s) failed: %v; 32-bit activation also failed: %v", progID, err, err32))
			// Name the version requirement. HB9RYZ's OmniRig v2.1 is a different
			// product that its own author states is not compatible with v1, and it
			// does not provide v1's IOmniRigX interface — so an operator who has
			// only v2 installed sees OmniRig running and OpsLog failing, with
			// nothing to connect the two facts.
			return fmt.Errorf("OmniRig (v1) not reachable: %w — OpsLog needs OmniRig v1.19/v1.20 "+
				"(VE3NEA/Alex), the interface every logger uses. HB9RYZ's OmniRig v2.1 is a separate, "+
				"incompatible product and cannot serve OpsLog; the two may be installed side by side, "+
				"but v1 must be present and running", err)
		}
		debugLog.Printf("OmniRig: reached via explicit 32-bit activation after CreateObject failed (%v)", err)
		omnirig = disp
	}
	o.connLogged = "" // connected: re-arm the one-shot failure logging

	rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
	if err != nil {
		omnirig.Release()
		return fmt.Errorf("get Rig%d: %w", o.RigNum, err)
	}
	o.omnirig = omnirig
	o.rig = rigVar.ToIDispatch()

	// Log WHICH OmniRig answered. There are two incompatible products called
	// OmniRig: v1.19/1.20 (Alex, VE3NEA), whose IOmniRigX interface every logger
	// including OpsLog uses, and HB9RYZ's v2.1, which its own author states is "not
	// compatible" with v1 and works only with programs written for it. They can
	// coexist, and OmniRig's own window looks much the same either way — so an
	// operator running only v2 sees OmniRig on screen, sees OpsLog fail, and has no
	// way to know the two were never going to talk. These two version numbers
	// settle it in one line of a bug report.
	var iv, sv int64 = -1, -1
	if v, err := oleutil.GetProperty(o.omnirig, "InterfaceVersion"); err == nil {
		iv = v.Val
	}
	if v, err := oleutil.GetProperty(o.omnirig, "SoftwareVersion"); err == nil {
		sv = v.Val
	}
	if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
		o.rigType = rt.ToString()
	}
	debugLog.Printf("OmniRig connected: Rig%d type=%q (OmniRig interface=%d software=%d)",
		o.RigNum, o.rigType, iv, sv)
	return nil
}

func (o *OmniRig) Disconnect() {
	if o.rig != nil {
		o.rig.Release()
		o.rig = nil
	}
	if o.omnirig != nil {
		o.omnirig.Release()
		o.omnirig = nil
	}
	ole.CoUninitialize()
}

func (o *OmniRig) ReadState() (RigState, error) {
	if o.rig == nil {
		return RigState{}, fmt.Errorf("not connected")
	}
	var s RigState
	s.Backend = o.Name()
	s.RigNum = o.RigNum

	// Status: 0 = NOTCONFIGURED, 1 = DISABLED, 2 = PORTBUSY,
	//         3 = NOTRESPONDING, 4 = ONLINE.
	if statusVar, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
		s.Connected = statusVar.Val == 4
	}

	if rigTypeVar, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
		s.Rig = rigTypeVar.ToString()
	}

	if !s.Connected {
		// Status string from OmniRig is informative for the user.
		if statusStrVar, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
			s.Error = statusStrVar.ToString()
		}
		return s, nil
	}

	if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
		s.Mode = omniRigMode(modeVar.Val)
	}
	rawVfo := int64(0)
	if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
		rawVfo = vfoVar.Val
		s.Vfo = omniRigVfo(vfoVar.Val)
	}

	// Read the active/displayed frequency (generic Freq) AND both VFOs. The
	// generic Freq is what the rig is operating on — the reliable source for the
	// main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
	freqMain, freqA, freqB := int64(0), int64(0), int64(0)
	if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
		freqMain = v.Val
	}
	if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
		freqA = v.Val
	}
	if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
		freqB = v.Val
	}

	// Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
	// compared to PM_SPLITON, not "!= 0".
	splitRaw := int64(0)
	if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
		splitRaw = v.Val
	}

	// FTDX101D field capture: OmniRig alternates between two contradictory
	// readings on consecutive polls — "Vfo=AB Split=0x10000(OFF)" then
	// "Vfo=BA Split=0x8000(ON)", ~1.5 s apart, with the rig untouched. The stock
	// .ini evidently has two status commands that each write these params. A
	// sample-by-sample test therefore reports split for half the polls and no
	// split for the other half, which the UI shows as no split at all. Latch the
	// ON flag briefly so one truthful sample survives the contradicting one; the
	// latch expires on its own once the rig stops reporting ON, so cancelling
	// split on the radio still clears within a few seconds.
	//
	// The latch is ARMED ONLY for a rig that actually oscillates, because it costs
	// ~6 s before split clears on screen. A correct .ini (FTDX10 with VS; and FT;
	// read as separate frames, confirmed on the air 2026-07-26) never flips
	// unprompted, and there the latch would be a pure delay on a reading that was
	// already right.
	//
	// 8 flips in 30 s: a first cut at 3-in-15s was armed by the OPERATOR toggling
	// split three times while testing, which then imposed the 6 s delay on a rig
	// that did not need it. A misreading .ini flips every 1.5–3 s without being
	// touched — a dozen in the same window — so the gap is wide. The arming also
	// expires after 30 s without a flip, so a rig that behaves is never stuck with
	// the delay because of one burst.
	const flipWindow, flipsToArm = 30 * time.Second, 8
	now := time.Now()
	flagOn := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
	if flagOn {
		o.lastSplitOnAt = now
	}
	if flagOn != o.lastSplitFlag {
		o.lastSplitFlag = flagOn
		if now.Sub(o.splitFlipWindow) > flipWindow {
			o.splitFlipWindow, o.splitFlips, o.splitFlaky = now, 0, false
		}
		o.splitFlips++
		if o.splitFlips >= flipsToArm {
			o.splitFlaky = true
		}
	} else if o.splitFlaky && now.Sub(o.splitFlipWindow) > flipWindow {
		o.splitFlaky, o.splitFlips = false, 0 // stopped oscillating — drop the delay
	}
	splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second

	// A forced VFO replaces whatever the rig file reported, BEFORE anything is
	// derived from it. Nothing downstream then has to know about the override.
	vfo := s.Vfo
	if o.ForceVFO != "" {
		vfo = o.ForceVFO
	}
	s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, vfo, splitRaw, splitRecentOn)

	// Delayed readback after a Set (see SetFrequency): logged from HERE because
	// this runs on the COM-owning goroutine. One line, once per set.
	if !o.pendingReadback.IsZero() && time.Now().After(o.pendingReadback) {
		o.pendingReadback = time.Time{}
		debugLog.Printf("OmniRig.SetFrequency: readback +1.5s FreqA=%d FreqB=%d Freq=%d → shown %d",
			freqA, freqB, freqMain, s.FreqHz)
	}

	// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
	// operation stays quiet but toggling split or SUB VFO on the radio is
	// captured. It logs the RESOLVED tx/rx/split too: with the raw values alone a
	// user's log showed what OmniRig said but not what OpsLog concluded, which is
	// the half that was wrong.
	if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
		o.lastSig = sig
		debugLog.Printf("OmniRig Rig%d raw: rig=%q Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X sticky=%v → tx=%d rx=%d split=%v",
			o.RigNum, o.rigType, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, splitRecentOn,
			s.FreqHz, s.RxFreqHz, s.Split)
	}
	return s, nil
}

// resolveOmniRigVFOs turns OmniRig's four readings into the ADIF pair
// (FreqHz = TX, RxFreqHz = RX) plus a split flag.
//
// Pure and separate from ReadState because it encodes rig-specific rules that
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
// to change it safely is with every known rig's behaviour pinned in a test. COM
// cannot be exercised from a test; this can.
func resolveOmniRigVFOs(rigType string, freqMain, freqA, freqB int64, vfo string, splitRaw int64, splitRecentOn bool) (txHz, rxHz int64, split bool) {
	// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
	// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
	// nothing else, and silently reports "no split" for any rig reporting the bit
	// alongside another. Requiring ON set and OFF clear keeps the two states apart
	// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
	// tolerating extra bits.
	splitFlagged := (splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0) || splitRecentOn

	// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
	// band test kills the common false positive where VFO B merely holds a
	// leftover from another band (a "28 MHz / 7 MHz split" is nonsensical), which
	// on the FT-710 / TS-570 otherwise froze the TX freq on the wrong VFO.
	if splitFlagged && freqA != 0 && freqB != 0 && freqA != freqB &&
		BandFromHz(freqA) == BandFromHz(freqB) {
		// RX is the VFO being listened on — identified from the generic Freq rather
		// than from the Vfo AB/BA enum, which several rigs (Yaesu FTDX10) report
		// inverted, showing TX and RX swapped.
		switch {
		case freqMain != 0 && freqMain == freqA:
			return freqB, freqA, true // listening on A → TX on B
		case freqMain != 0 && freqMain == freqB:
			return freqA, freqB, true // listening on B → TX on A
		case vfo == "BA":
			return freqA, freqB, true // fall back to the Vfo enum
		default:
			return freqB, freqA, true
		}
	}

	// Simplex. The VFO the rig says is ACTIVE comes first: preferring freqA
	// unconditionally (as this did) meant the displayed frequency never left VFO
	// A — press SUB VFO on an FTDX101D and the radio receives on B while OpsLog
	// went on showing A, taking the band and the logged frequency with it.
	//
	// Only a VFO OmniRig explicitly names is honoured, so a rig that does not
	// report the enum keeps exactly the previous fallback order. That matters for
	// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
	// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
	// the generic Freq.
	// The PAIR enums name BOTH VFOs at once — first letter = the one being
	// listened on, second = the one that transmits. Only the single-letter forms
	// were honoured here, so a rig that reports nothing but pairs (the whole Yaesu
	// family) always fell through to freqA and never followed the operator to SUB.
	// Log4OM reads that first letter and logs the right frequency on the same
	// rigs, which is what showed this was readable data and not a dead end.
	//
	// Checked BEFORE the Yaesu fallback below: an enum that names a VFO is the
	// rig speaking, the fallback is only an inference.
	switch {
	case (vfo == "BA" || vfo == "BB") && freqB != 0:
		return freqB, 0, false
	case (vfo == "AA" || vfo == "AB") && freqA != 0:
		return freqA, 0, false
	}

	// Yaesu fallback, for a rig file that names no VFO at all (the stock FTDX10
	// one: it answers neither VS; nor FR; usably, verified on the air 2026-07-26).
	// There the generic Freq is the last clue — matching FreqB and not FreqA means
	// the operator is on SUB. Limited to Yaesu: the IC-7610's stock ini reports
	// the generic Freq as VFO B permanently, where this would name the wrong VFO —
	// that case is pinned in the test table.
	if isYaesuRig(rigType) && freqMain != 0 && freqMain == freqB && freqB != freqA {
		return freqB, 0, false
	}

	switch {
	case (vfo == "B" || vfo == "BB") && freqB != 0:
		return freqB, 0, false
	case (vfo == "A" || vfo == "AA") && freqA != 0:
		return freqA, 0, false
	case freqA != 0:
		return freqA, 0, false
	case freqMain != 0:
		return freqMain, 0, false
	default:
		return freqB, 0, false
	}
}

func (o *OmniRig) SetFrequency(hz int64) error {
	if o.rig == nil {
		debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
		return fmt.Errorf("not connected")
	}
	// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
	if hz < 0 || hz > 0x7fffffff {
		debugLog.Printf("OmniRig.SetFrequency(%d): out of int32 range", hz)
		return fmt.Errorf("frequency out of OmniRig int32 range")
	}
	hz32 := int32(hz)
	// Remember the commanded frequency so a mode change moments later (a clicked
	// spot sets freq then mode) picks the sideband from the TARGET band, not the
	// not-yet-updated OmniRig Freq property.
	o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()

	// Log the rig's writable-params, status and VFO state up front so a
	// friend's session shows exactly what OmniRig reports for their rig.
	status, statusStr, rigType := int64(-1), "", ""
	if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
		status = v.Val
	}
	if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
		statusStr = v.ToString()
	}
	if v, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
		rigType = v.ToString()
	}
	rawVfo, vfo := int64(-1), ""
	if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
		rawVfo = vfoVar.Val
		vfo = omniRigVfo(vfoVar.Val)
	} else {
		debugLog.Printf("OmniRig.SetFrequency: Vfo read error: %v", err)
	}
	split := int64(0)
	if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
		split = v.Val
	}
	// What can this rig's .ini actually write? OmniRig exposes a WriteableParams
	// bitmask — if FreqA/FreqB/Freq bits are missing, the write is a silent no-op.
	writeable := int64(-1)
	if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
		writeable = v.Val
	}
	debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz): rig=%q status=%d(%s) vfo=%q(raw=%d) split=%d writeableParams=0x%X",
		hz, float64(hz)/1e6, rigType, status, statusStr, vfo, rawVfo, split, writeable)

	// Primary path: OmniRig's SetSimplexMode is the rig-agnostic "QSY here"
	// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
	// where direct FreqA/FreqB writes are accepted but never move the radio.
	// Clearing split is the right thing when tuning to a spot anyway.
	// …but ONLY when the operator is on the main VFO. SetSimplexMode means
	// "receive and transmit here, simplex", and OmniRig implements that on the
	// MAIN VFO — so on an FTDX101 listening on SUB, clicking a cluster spot
	// dragged the radio back to MAIN (F4NBZ, 2026-07-29). The operator picked SUB
	// deliberately; a spot click is a request to change frequency, not to change
	// VFO. On SUB the direct FreqB write below does the whole job.
	onSubVFO := vfo == "B" || vfo == "BB" || vfo == "BA"
	// …and NEVER on a Yaesu.
	//
	// On Yaesu the call is useless at best: an FT-891 logged "SetSimplexMode OK"
	// on every spot click while FreqA never moved (see below — the direct property
	// write is what actually tunes these rigs). On an FT-2000 it is worse than
	// useless. From a log of one QSY, before and after the call:
	//
	//	Vfo="AB"(0x80)  Split=0x10000 (off)   → the operator's state
	//	Vfo="BA"(0x100) Split=0x8000  (ON)    → after SetSimplexMode
	//
	// The rig-agnostic "receive and transmit HERE, simplex" method turned split ON
	// and moved reception to VFO B, which is exactly what the operator reported:
	// OpsLog showing B instead of A. OpsLog was not misreading the radio — it was
	// reading it correctly after having moved it itself.
	//
	// Restricted to Yaesu because that is where the evidence is: on Icom the call
	// is authoritative and the direct write is the unreliable one.
	isYaesu := isYaesuRig(rigType)
	simplexOK := false
	switch {
	case onSubVFO:
		debugLog.Printf("OmniRig.SetFrequency: on VFO %q — skipping SetSimplexMode so the rig stays on SUB", vfo)
	case isYaesu:
		debugLog.Printf("OmniRig.SetFrequency: %q is a Yaesu — skipping SetSimplexMode (a no-op on some, and on an FT-2000 it turns split on and jumps to VFO B); the direct write below does the work", rigType)
	default:
		if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
			simplexOK = true
			debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
		} else {
			debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v)", err)
		}
	}

	// On Yaesu / Kenwood (and anything non-Icom), SetSimplexMode frequently returns
	// OK but is a SILENT NO-OP — the rig never moves. Confirmed on an FT-891 over
	// OmniRig: every spot click logged "SetSimplexMode OK" yet FreqA stayed put,
	// while SetMode on the same .ini worked fine (so the CAT link is healthy).
	// Writing the VFO frequency PROPERTY directly DOES move them, so also do that
	// here. It is skipped on Icom, where the direct write is the unreliable one and
	// could nudge the wrong Main/Sub VFO — there SetSimplexMode is authoritative.
	isIcom := strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "IC")
	if !isIcom || !simplexOK {
		prop := "FreqA"
		if onSubVFO {
			prop = "FreqB"
		}
		okAny := false
		// The generic "Freq" property is written too — but NOT on SUB, where it is
		// the main VFO's frequency on several rig files and would move the VFO the
		// operator is not using, or pull them back to it.
		props := []string{prop, "Freq"}
		if onSubVFO {
			props = []string{prop}
		} else if isYaesu && split&pmSplitOn != 0 && split&pmSplitOff == 0 {
			// Yaesu with SPLIT engaged: the generic Freq write lands on the TX
			// VFO — an FT-2000 moved B and left A (the receiver) behind, so a
			// spot click QSYed the transmitter and nothing audible changed.
			// Both VFOs are written: the radio arrives on the spot RX and TX
			// together, split left as the operator had it.
			props = []string{"FreqA", "FreqB", "Freq"}
		}
		for _, p := range props {
			if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
				debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
			} else {
				debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", p, hz32)
				okAny = true
			}
		}
		if !simplexOK && !okAny {
			return fmt.Errorf("OmniRig: no writable frequency property for this rig")
		}
	}

	// Read back all three, then AGAIN a moment later. OmniRig is async — the CAT
	// command is queued and sent over serial — so an immediate read always shows
	// the old value and proves nothing. The delayed one is the useful half: it
	// separates "the rig ignored the write" from "the rig moved but its .ini
	// never re-reads the frequency", which look identical to an operator whose
	// display stays on the old band until they nudge the VFO.
	//
	// Off the CAT goroutine so the poll loop is not held for a second. COM is
	// thread-affine, so the delayed read goes through the manager's own command
	// channel rather than touching o.rig from here.
	logFreqs := func(when string) {
		fa, fb, fg := int64(-1), int64(-1), int64(-1)
		if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
			fa = v.Val
		}
		if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
			fb = v.Val
		}
		if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
			fg = v.Val
		}
		debugLog.Printf("OmniRig.SetFrequency: readback %s FreqA=%d FreqB=%d Freq=%d (target %d)", when, fa, fb, fg, hz)
	}
	logFreqs("now")
	o.pendingReadback = time.Now().Add(1500 * time.Millisecond)
	return nil
}

// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
// For SSB, the USB/LSB side is chosen from the rig's current frequency
// following worldwide convention (LSB below 14 MHz, USB above).
//
// IMPORTANT: OmniRig's Mode property is typed as Long (VT_I4). go-ole would
// otherwise wrap a Go int64 into a VT_I8 variant which COM marshalling can
// reject silently or misinterpret — passing the wrong bit. Always cast to
// int32 explicitly.
//
// Logs each call to stdout so the user can cross-check what HamLog sent
// against OmniRig's Monitor window (right-click systray → Monitor) to find
// rig-specific mismatches (e.g. a Kenwood without FM on HF, an .ini with the
// wrong CAT command for a mode, etc.).
func (o *OmniRig) SetMode(mode string) error {
	if o.rig == nil {
		return fmt.Errorf("not connected")
	}
	var (
		bit     int64
		bitName string
	)
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "CW":
		bit, bitName = pmCWU, "PM_CW_U"
	case "SSB":
		// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
		// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
		// property still reports the OLD band for a poll or two after a QSY, so
		// reading it here picked the wrong sideband and the user had to click a
		// second time. Fall back to the live read for a standalone mode change.
		var freq int64
		if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
			freq = o.lastSetFreq
		} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
			freq = freqVar.Val
		}
		if freq > 0 && freq < 10_000_000 {
			bit, bitName = pmSSBL, "PM_SSB_L"
		} else {
			bit, bitName = pmSSBU, "PM_SSB_U"
		}
	case "AM":
		bit, bitName = pmAM, "PM_AM"
	case "FM":
		bit, bitName = pmFM, "PM_FM"
	case "RTTY", "FSK":
		// OmniRig has no specific RTTY/FSK mode — falls back to generic
		// digital USB. Many rigs need RTTY selected manually on the panel.
		bit, bitName = pmDIGU, "PM_DIG_U"
	case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DIGITALVOICE", "DATA":
		bit, bitName = pmDIGU, "PM_DIG_U"
	default:
		return fmt.Errorf("OmniRig: unsupported mode %q", mode)
	}
	debugLog.Printf("OmniRig.SetMode(%q) → %s = 0x%08X (%d)", mode, bitName, bit, bit)
	_, err := oleutil.PutProperty(o.rig, "Mode", int32(bit))
	if err != nil {
		debugLog.Printf("OmniRig.SetMode error: %v", err)
		return fmt.Errorf("SetMode(%s) → %s: %w", mode, bitName, err)
	}

	// Read back what OmniRig now thinks the rig is on (best-effort —
	// OmniRig is async so this may still be the old value for one poll).
	if mv, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
		debugLog.Printf("OmniRig.Mode immediately after Put = 0x%08X (%d) → %s",
			mv.Val, mv.Val, omniRigMode(mv.Val))
	}
	return nil
}

// SetPTT keys or unkeys the rig via OmniRig's SetTx(PM_RX|PM_TX). Used by the
// Digital Voice Keyer to put the rig into TX while a voice message plays.
func (o *OmniRig) SetPTT(on bool) error {
	if o.rig == nil {
		debugLog.Printf("OmniRig.SetPTT(%v): NOT CONNECTED", on)
		return fmt.Errorf("not connected")
	}
	status, statusStr, writeable := int64(-1), "", int64(-1)
	if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
		status = v.Val
	}
	if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
		statusStr = v.ToString()
	}
	if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
		writeable = v.Val
	}
	txWriteable := writeable != -1 && writeable&pmTX != 0
	param, name := pmRX, "PM_RX"
	if on {
		param, name = pmTX, "PM_TX"
	}
	debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s",
		on, status, statusStr, writeable, txWriteable, name)
	// When OmniRig DID report its writeable params (writeable != -1) and PM_TX
	// is NOT among them, writing Tx is a silent no-op: the rig never keys and
	// SetPTT would otherwise return success, leaving the user puzzled ("Test PTT
	// does nothing"). Surface a clear, actionable error instead. If we couldn't
	// read the writeable params (-1), fall through and try anyway (best effort).
	if on && writeable != -1 && writeable&pmTX == 0 {
		debugLog.Printf("OmniRig.SetPTT: ⚠ PM_TX not writeable for this rig profile (writeableParams=0x%X)", writeable)
		return fmt.Errorf("this rig's OmniRig profile doesn't expose CAT TX keying (PM_TX not writeable) — use RTS/DTR or VOX for PTT")
	}
	// OmniRig has NO SetTx method (that returns "unknown name"); the Tx
	// parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX).
	if _, err := oleutil.PutProperty(o.rig, "Tx", int32(param)); err != nil {
		debugLog.Printf("OmniRig.SetPTT error: %v", err)
		return fmt.Errorf("set Tx=%s: %w", name, err)
	}
	// Read the Tx param straight back. OmniRig is async — this may still show the
	// previous value for a poll cycle — but if a key/unkey NEVER changes it, the
	// write was coalesced or the rig isn't honouring PM_TX/PM_RX (wrong .ini).
	if v, err := oleutil.GetProperty(o.rig, "Tx"); err == nil {
		txState := "PM_RX"
		if v.Val&pmTX != 0 {
			txState = "PM_TX"
		}
		debugLog.Printf("OmniRig.SetPTT: Tx readback = 0x%X (%s)", v.Val, txState)
	}
	return nil
}

// ===== OmniRig enum decoders =====

// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
//
// Cross-checked against https://github.com/VE3NEA/OmniRig — be careful when
// referencing other people's writeups online, several have these one bit
// too low which causes every mode to map to the slot below it (AM → DIG_L,
// FT8 → SSB_L, etc.).
const (
	pmRX   int64 = 1 << 20 // 0x00100000 — PM_RX (receive)
	pmTX   int64 = 1 << 21 // 0x00200000 — PM_TX (transmit / PTT on)
	pmCWU  int64 = 1 << 23 // 0x00800000
	pmCWL  int64 = 1 << 24 // 0x01000000
	pmSSBU int64 = 1 << 25 // 0x02000000
	pmSSBL int64 = 1 << 26 // 0x04000000
	pmDIGU int64 = 1 << 27 // 0x08000000
	pmDIGL int64 = 1 << 28 // 0x10000000
	pmAM   int64 = 1 << 29 // 0x20000000
	pmFM   int64 = 1 << 30 // 0x40000000 — still fits in int32 (max 2^31-1)
)

// omniRigMode maps the OmniRig Mode bit-flag to an ADIF mode string.
// OmniRig only reports rough categories; specific digital modes
// (FT8, RTTY, PSK31…) can't be inferred — DATA is returned and the user
// can keep / override the mode they already had in the entry form.
func omniRigMode(m int64) string {
	switch {
	case m&(pmCWU|pmCWL) != 0:
		return "CW"
	case m&(pmSSBU|pmSSBL) != 0:
		return "SSB"
	case m&(pmDIGU|pmDIGL) != 0:
		return "DATA"
	case m&pmAM != 0:
		return "AM"
	case m&pmFM != 0:
		return "FM"
	}
	return ""
}

// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
// isYaesuRig recognises a Yaesu from OmniRig's RigType (the .ini title, e.g.
// "FTDX101D", "FT-891"). Only used to gate rules that are true for Yaesu and
// false for Icom, so a mis-titled ini simply keeps the generic behaviour.
func isYaesuRig(rigType string) bool {
	return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "FT")
}

func omniRigVfo(v int64) string {
	switch {
	case v&0x40 != 0: // PM_VFOAA
		return "AA"
	case v&0x80 != 0: // PM_VFOAB
		return "AB"
	case v&0x100 != 0: // PM_VFOBA
		return "BA"
	case v&0x200 != 0: // PM_VFOBB
		return "BB"
	case v&0x400 != 0: // PM_VFOA
		return "A"
	case v&0x800 != 0: // PM_VFOB
		return "B"
	}
	return ""
}
