// Package ultrabeam drives an Ultrabeam remote-controlled antenna over SERIAL
// or TCP. The wire protocol (STX/ETX framing, DLE escaping, XOR checksum) and
// command codes are the manufacturer's, and identical on both: the Ethernet
// route is an RS232↔Ethernet adapter passing the same bytes.
//
// Serial came second, which is the wrong way round for most stations: the
// controller has an RS232 port and the PC is usually right next to it, so a
// plain FTDI cable does the job and the adapter is only needed to put the
// antenna at the other end of a link.
package ultrabeam

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"runtime"
	"strings"
	"sync"
	"time"

	"go.bug.st/serial"
)

// Transport says how to reach the controller. Mirrors internal/steppir, which
// has had both routes from the start — one shape for the two antennas rather
// than two shapes to remember.
type Transport struct {
	Mode string // "tcp" | "serial"
	Host string // tcp
	Port int    // tcp
	COM  string // serial device (COM3, /dev/ttyUSB0)
	Baud int    // serial baud
}

// Connection tuning. Remote operation (the antenna controller reached over the
// internet, not the LAN) sees real latency and jitter, so the read timeout is
// generous and a few transient timeouts are tolerated before the link is torn
// down — otherwise a single slow reply dropped the whole connection and the
// client churned reconnect/disconnect.
const (
	ubReadTimeout    = 4 * time.Second  // was 1s — too tight for a remote link
	ubKeepAlive      = 15 * time.Second // OS-level TCP keepalive
	ubMaxPollTimeout = 3                // consecutive read timeouts tolerated before reconnecting
	// How long a just-commanded direction is trusted AFTER the motors have stopped
	// but before the antenna's status confirms it. The timer is held off entirely
	// while the motors are still moving, so this is only the grace period for the
	// confirmation poll to arrive once the elements have settled — generous, because
	// over a remote link that poll lags by several seconds.
	ubPendingDirGrace = 8 * time.Second
)

// Protocol constants
const (
	STX byte = 0xF5 // 245 decimal
	ETX byte = 0xFA // 250 decimal
	DLE byte = 0xF6 // 246 decimal
)

// Command codes
const (
	CMD_STATUS      byte = 1  // General status query
	CMD_RETRACT     byte = 2  // Retract elements
	CMD_FREQ        byte = 3  // Change frequency
	CMD_READ_BANDS  byte = 9  // Read current band adjustments
	CMD_PROGRESS    byte = 10 // Read progress bar
	CMD_MODIFY_ELEM byte = 12 // Modify element length
)

// Reply codes
const (
	UB_OK  byte = 0 // Normal execution
	UB_BAD byte = 1 // Invalid command
	UB_PAR byte = 2 // Bad parameters
	UB_ERR byte = 3 // Error executing command
)

// Direction modes
const (
	DIR_NORMAL byte = 0
	DIR_180    byte = 1
	DIR_BIDIR  byte = 2
)

type Client struct {
	tr         Transport
	conn       io.ReadWriteCloser
	connMu     sync.Mutex
	reader     *bufio.Reader
	lastStatus *Status
	statusMu   sync.RWMutex
	stopChan   chan struct{}
	// done is closed by pollLoop as it exits, so Stop can WAIT for it. Without
	// that wait the loop outlives the client that owns it, and on a serial link
	// the zombie keeps the port open: every later client then fails with "Serial
	// port busy" — forever, because the thing holding the port is us.
	done    chan struct{}
	running bool
	seqNum  byte
	seqMu   sync.Mutex

	// First-exchange diagnostics — see armDiag.
	diagMu   sync.Mutex
	diag     bool
	diagJunk []byte

	// Optimistic pattern direction kept until the antenna's status poll reports
	// it (or it ages out) — the motors take a second or two, and a stale poll in
	// between would otherwise snap the UI back to the old direction.
	pendingDir    int
	pendingDirAt  time.Time
	pendingDirSet bool

	// lastSetKHz is the frequency we last COMMANDED. Used as the follow-loop
	// deadband reference when the antenna's own status hasn't reported a frequency
	// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
	// re-tunes the motors.
	lastSetKHz int

	// moveCmdAt is when a move (frequency or direction) was last COMMANDED. The
	// status poll runs every couple of seconds, so without this the "moving" flag
	// — which drives the UI indicator and the Flex TX-inhibit — appeared up to a
	// poll late even though the elements start moving at once. GetStatus reports
	// motion during a short window after a command so both react immediately.
	moveCmdAt time.Time
}

// ubMoveOptimisticWindow is how long after a commanded move GetStatus reports
// "moving" before the status poll has had a chance to read the real motor state.
// It only needs to bridge one poll interval; once a poll sees real motion, that
// takes over. Bounded, so if the antenna never reports motion the flag still
// clears rather than latching the TX-inhibit on for ever.
const ubMoveOptimisticWindow = 3 * time.Second

// Poll cadences: what an idle antenna is worth, and what a moving one is worth.
// See the poll loop.
const (
	ubPollIdle   = 2 * time.Second
	ubPollMoving = 250 * time.Millisecond
)

// movingOptimistically reports whether a move was commanded so recently that the
// antenna cannot have answered yet — the same window GetStatus reports motion
// for, so the fast poll starts with the movement rather than one interval into
// it.
func (c *Client) movingOptimistically() bool {
	c.statusMu.RLock()
	defer c.statusMu.RUnlock()
	return !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < ubMoveOptimisticWindow
}

// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
// or 0 if none yet.
func (c *Client) LastSetKHz() int {
	c.statusMu.RLock()
	defer c.statusMu.RUnlock()
	return c.lastSetKHz
}

type Status struct {
	FirmwareMinor    int   `json:"firmware_minor"`
	FirmwareMajor    int   `json:"firmware_major"`
	CurrentOperation int   `json:"current_operation"`
	Frequency        int   `json:"frequency"` // KHz
	Band             int   `json:"band"`
	Direction        int   `json:"direction"` // 0=normal, 1=180°, 2=bi-dir
	OffState         bool  `json:"off_state"`
	MotorsMoving     int   `json:"motors_moving"`    // Bitmask
	FreqMin          int   `json:"freq_min"`         // MHz
	FreqMax          int   `json:"freq_max"`         // MHz
	ElementLengths   []int `json:"element_lengths"`  // mm
	ProgressTotal    int   `json:"progress_total"`   // mm
	ProgressCurrent  int   `json:"progress_current"` // 0-60
	Connected        bool  `json:"connected"`
}

func New(tr Transport) *Client {
	if tr.Baud <= 0 {
		tr.Baud = 9600
	}
	return &Client{
		tr:       tr,
		stopChan: make(chan struct{}),
		seqNum:   0,
	}
}

// open dials the transport. Callers hold connMu.
func (c *Client) open() (io.ReadWriteCloser, error) {
	if c.tr.Mode == "serial" {
		if c.tr.COM == "" {
			return nil, fmt.Errorf("ultrabeam: no serial port configured")
		}
		// 8N1 spelled out. The library's zero values happen to mean the same
		// thing today, but a controller that answers nothing is impossible to
		// diagnose with a line count that depends on a default.
		p, err := serial.Open(c.tr.COM, &serial.Mode{
			BaudRate: c.tr.Baud,
			DataBits: 8,
			Parity:   serial.NoParity,
			StopBits: serial.OneStopBit,
		})
		if err != nil {
			return nil, err
		}
		// A finite read timeout so a silent controller cannot wedge the poll
		// loop. Replaced per exchange by setReadTimeout below.
		_ = p.SetReadTimeout(ubReadTimeout)
		return p, nil
	}
	if c.tr.Host == "" {
		return nil, fmt.Errorf("ultrabeam: no host configured")
	}
	dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
	return dialer.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
}

// diagNextExchange asks for the next command/reply to be dumped to the log.
//
// "It does not work" is unanswerable for a serial link, because the three
// possible causes look identical from the outside: nothing arrives (wrong port,
// dead cable, controller off), something arrives but is not our protocol (wrong
// baud), or a valid frame arrives and we misread it. One hex dump of the first
// exchange after each connect separates them, and costs two log lines per
// connection.
func (c *Client) armDiag() {
	c.diagMu.Lock()
	c.diag = true
	c.diagJunk = c.diagJunk[:0]
	c.diagMu.Unlock()
}

func (c *Client) diagOn() bool {
	c.diagMu.Lock()
	defer c.diagMu.Unlock()
	return c.diag
}

// target names what the client is talking to, for the log.
func (c *Client) target() string {
	if c.tr.Mode == "serial" {
		return fmt.Sprintf("%s @ %d baud", c.tr.COM, c.tr.Baud)
	}
	return fmt.Sprintf("%s:%d", c.tr.Host, c.tr.Port)
}

// setReadTimeout bounds the next read, whichever transport is open.
//
// TCP takes a deadline (an instant) and serial a timeout (a duration) — the two
// libraries disagree, and the caller should not have to care.
func (c *Client) setReadTimeout(d time.Duration) {
	switch t := c.conn.(type) {
	case net.Conn:
		_ = t.SetReadDeadline(time.Now().Add(d))
	case serial.Port:
		_ = t.SetReadTimeout(d)
	}
}

// transientRead reports a read that timed out rather than failed.
//
// The two transports say it differently. TCP returns a net.Error with
// Timeout(); a serial port that stays silent returns (0, nil) on every read,
// which bufio turns into io.ErrNoProgress after a hundred empty attempts.
// Neither means the link is gone — over a remote link, or with a controller
// busy moving its motors, a slow reply is ordinary.
func transientRead(err error) bool {
	var ne net.Error
	if errors.As(err, &ne) && ne.Timeout() {
		return true
	}
	return errors.Is(err, io.ErrNoProgress)
}

func (c *Client) Start() error {
	c.running = true
	c.done = make(chan struct{})
	go c.pollLoop()
	return nil
}

// Stop closes the link and WAITS for the poll loop to leave.
//
// The wait is the point. Closing the port from another goroutine does not undo
// an open() the loop is already inside: that open returns a fresh handle, the
// loop stores it, and the client that was told to stop goes on owning the serial
// port. The next client — a settings save, a profile switch — then cannot open
// it, and the operator sees "Serial port busy" with no other program running.
//
// Bounded, because a serial open can sit in the driver for a while and a stuck
// device must not freeze a settings save. If the deadline passes, the loop is
// still on its way out and the log says so.
func (c *Client) Stop() {
	if !c.running {
		return
	}
	c.running = false
	close(c.stopChan)

	c.connMu.Lock()
	if c.conn != nil {
		c.conn.Close()
		c.conn = nil
	}
	c.connMu.Unlock()

	if c.done == nil {
		return
	}
	select {
	case <-c.done:
	case <-time.After(6 * time.Second):
		log.Printf("Ultrabeam: poll loop did not exit within 6s — %s may stay busy a moment longer", c.target())
	}
}

func (c *Client) pollLoop() {
	// Signals Stop that the port is genuinely released.
	defer func() {
		c.connMu.Lock()
		if c.conn != nil {
			c.conn.Close()
			c.conn = nil
		}
		c.connMu.Unlock()
		if c.done != nil {
			close(c.done)
		}
	}()
	// TWO CADENCES, and the fast one is what matters.
	//
	// An idle antenna has nothing to say, so two seconds is generous. A MOVING
	// one has one thing to say and it is urgent: transmit is inhibited while the
	// elements travel, so every poll interval between the motors stopping and
	// this loop noticing is a second the operator cannot call — with the antenna
	// already in place and the rig still gagged. Reported from the air as "it
	// stays orange one or two seconds after it has finished".
	//
	// While the motors run — or a move has just been commanded — the antenna is
	// asked four times a second. The controller answers a status query in
	// milliseconds; this is nothing to it, and it lasts only as long as the
	// movement does.
	ticker := time.NewTicker(ubPollIdle)
	defer ticker.Stop()
	fast := false

	pollCount := 0
	pollFails := 0 // consecutive failed status polls (transient timeouts tolerated)

	for {
		select {
		case <-ticker.C:
			pollCount++

			// Try to connect if not connected
			c.connMu.Lock()
			if c.conn == nil {
				log.Printf("Ultrabeam: Not connected, attempting connection to %s...", c.target())
				conn, err := c.open()
				if err != nil {
					log.Printf("Ultrabeam: Connection failed: %v%s", err, portBusyHint(c.tr.Mode, c.tr.COM, err))
					c.connMu.Unlock()

					// Mark as disconnected
					c.statusMu.Lock()
					c.lastStatus = &Status{Connected: false}
					c.statusMu.Unlock()
					continue
				}
				c.conn = conn
				// Stopped while open() was running? Let go of the port at once.
				// Stop cannot interrupt an open already in flight, so without this
				// the fresh handle is stored by a client that has been told to die,
				// and it keeps the port — which is precisely the "Serial port busy"
				// the next client then reports, forever.
				select {
				case <-c.stopChan:
					conn.Close()
					c.conn = nil // already closed; keep the deferred cleanup from closing it twice
					c.connMu.Unlock()
					return
				default:
				}
				c.reader = bufio.NewReader(c.conn)
				pollFails = 0
				log.Printf("Ultrabeam: Connected to %s", c.target())
				// Dump the first exchange on this link. A connection that opens and
				// then says nothing is the whole of what a serial user can see.
				c.armDiag()
			}
			c.connMu.Unlock()

			// Query status
			status, err := c.queryStatus()
			if err != nil {
				// A single slow/lost reply over a remote link is normal — keep
				// the connection (and the last status) for a few tries before
				// tearing it down, so we don't churn reconnect/disconnect.
				transient := transientRead(err)
				pollFails++
				if transient && pollFails < ubMaxPollTimeout {
					log.Printf("Ultrabeam: status timeout (%d/%d), keeping link: %v", pollFails, ubMaxPollTimeout, err)
					continue
				}
				log.Printf("Ultrabeam: Failed to query status, reconnecting: %v", err)
				c.connMu.Lock()
				if c.conn != nil {
					c.conn.Close()
					c.conn = nil
					c.reader = nil
				}
				c.connMu.Unlock()

				// Mark as disconnected
				c.statusMu.Lock()
				c.lastStatus = &Status{Connected: false}
				c.statusMu.Unlock()
				continue
			}
			pollFails = 0

			// Mark as connected
			status.Connected = true

			// Query progress if motors moving
			if status.MotorsMoving != 0 {
				progress, err := c.queryProgress()
				if err == nil {
					status.ProgressTotal = progress[0]
					status.ProgressCurrent = progress[1]
				}
			} else {
				// Motors stopped - reset progress
				status.ProgressTotal = 0
				status.ProgressCurrent = 0
			}

			c.statusMu.Lock()
			// Keep a just-commanded direction until the antenna actually reports it.
			// Over a remote link the confirmation arrives several seconds after the
			// command — the motors flip the elements first — so the old fixed 4 s
			// timeout expired WHILE the change was still in flight, and the stale poll
			// reverted the UI to the old pattern even though the antenna was on its way
			// to the new one. Now: while the motors are still moving the change is in
			// progress, so hold the commanded pattern and keep resetting the timer;
			// only once the motors have stopped does the short grace window run, giving
			// the confirmation poll time to land. The poll only wins if the motors are
			// idle AND the antenna still reports a different pattern past that window —
			// i.e. the command genuinely did not take.
			if c.pendingDirSet {
				if status.MotorsMoving != 0 {
					c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
				}
				switch {
				case status.Direction == c.pendingDir:
					c.pendingDirSet = false // confirmed by the antenna
				case time.Since(c.pendingDirAt) > ubPendingDirGrace:
					c.pendingDirSet = false // motors idle, still unconfirmed → accept the poll
					log.Printf("Ultrabeam: antenna never confirmed direction %d (reports %d) — dropping the hold",
						c.pendingDir, status.Direction)
				default:
					status.Direction = c.pendingDir // still changing, or within the grace window
				}
			}
			c.lastStatus = status
			c.statusMu.Unlock()

			// Follow the motors with the poll rate. Changed only when it actually
			// changes: Reset on a ticker is cheap but not free, and the antenna is
			// polled for hours on end.
			if moving := status.MotorsMoving != 0 || c.movingOptimistically(); moving != fast {
				fast = moving
				if fast {
					ticker.Reset(ubPollMoving)
				} else {
					ticker.Reset(ubPollIdle)
				}
			}

		case <-c.stopChan:
			return
		}
	}
}

func (c *Client) GetStatus() (*Status, error) {
	c.statusMu.RLock()
	defer c.statusMu.RUnlock()

	if c.lastStatus == nil {
		return &Status{Connected: false}, nil
	}

	// Copy so the optimistic-motion tweak below never mutates the cached status
	// the poll goroutine owns.
	st := *c.lastStatus
	// Optimistic motion: right after a commanded move, report "moving" until a
	// status poll can read the real motor state (~one poll interval). The elements
	// start moving the instant the operator clicks a band/pattern, so this makes
	// the UI indicator and the Flex TX-inhibit react immediately instead of a poll
	// later. Real polled motion takes over once seen; the window is bounded so the
	// flag can't latch the inhibit on for ever.
	if st.Connected && st.MotorsMoving == 0 && !c.moveCmdAt.IsZero() && time.Since(c.moveCmdAt) < ubMoveOptimisticWindow {
		st.MotorsMoving = 1 // sentinel: optimistically moving (read only as != 0)
	}
	return &st, nil
}

// getNextSeq returns the next sequence number
func (c *Client) getNextSeq() byte {
	c.seqMu.Lock()
	defer c.seqMu.Unlock()

	seq := c.seqNum
	c.seqNum = (c.seqNum + 1) % 128
	return seq
}

// calculateChecksum calculates the checksum for a packet
func calculateChecksum(data []byte) byte {
	chk := byte(0x55)
	for _, b := range data {
		chk ^= b
		chk++
	}
	return chk
}

// quoteByte handles DLE escaping
func quoteByte(b byte) []byte {
	if b == STX || b == ETX || b == DLE {
		return []byte{DLE, b & 0x7F} // Clear MSB
	}
	return []byte{b}
}

// buildPacket creates a complete packet with checksum and escaping. The seq is
// supplied by the caller so sendCommand can match the reply against it.
func (c *Client) buildPacket(seq, cmd byte, data []byte) []byte {
	// Calculate checksum on unquoted data
	payload := append([]byte{seq, cmd}, data...)
	chk := calculateChecksum(payload)

	// Build packet with quoting
	packet := []byte{STX}

	// Add quoted SEQ
	packet = append(packet, quoteByte(seq)...)

	// Add quoted CMD
	packet = append(packet, quoteByte(cmd)...)

	// Add quoted data
	for _, b := range data {
		packet = append(packet, quoteByte(b)...)
	}

	// Add quoted checksum
	packet = append(packet, quoteByte(chk)...)

	// Add ETX
	packet = append(packet, ETX)

	return packet
}

// parsePacket parses a received packet, handling DLE unescaping
func parsePacket(data []byte) (seq byte, cmd byte, payload []byte, err error) {
	if len(data) < 5 { // STX + SEQ + CMD + CHK + ETX
		return 0, 0, nil, fmt.Errorf("packet too short")
	}

	if data[0] != STX {
		return 0, 0, nil, fmt.Errorf("missing STX")
	}

	if data[len(data)-1] != ETX {
		return 0, 0, nil, fmt.Errorf("missing ETX")
	}

	// Unquote the data
	var unquoted []byte
	dle := false
	for i := 1; i < len(data)-1; i++ {
		b := data[i]
		if b == DLE {
			dle = true
			continue
		}
		if dle {
			b |= 0x80 // Set MSB
			dle = false
		}
		unquoted = append(unquoted, b)
	}

	if len(unquoted) < 3 {
		return 0, 0, nil, fmt.Errorf("unquoted packet too short")
	}

	seq = unquoted[0]
	cmd = unquoted[1]
	chk := unquoted[len(unquoted)-1]
	payload = unquoted[2 : len(unquoted)-1]

	// Verify checksum
	calcChk := calculateChecksum(unquoted[:len(unquoted)-1])
	if calcChk != chk {
		return 0, 0, nil, fmt.Errorf("checksum mismatch: got %02X, expected %02X", chk, calcChk)
	}

	return seq, cmd, payload, nil
}

// sendCommand sends a command and waits for reply
func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
	c.connMu.Lock()
	defer c.connMu.Unlock()

	if c.conn == nil || c.reader == nil {
		return nil, fmt.Errorf("not connected")
	}

	// Flush anything already sitting in the stream before we send. The antenna
	// does NOT echo our sequence number — its replies carry their own counter — so
	// a reply cannot be matched to its request. Instead we keep the exchange 1:1:
	// a reply left behind by an earlier timed-out command is discarded here, so
	// the reply we read next belongs to THIS command. Reading a stale reply as the
	// current one crossed STATUS with READ_BANDS/PROGRESS — phantom frequencies (a
	// spurious follow-loop re-tune), dropped connections and wrong element lengths.
	c.drainStale()

	seq := c.getNextSeq()
	packet := c.buildPacket(seq, cmd, data)
	diag := c.diagOn()
	if diag {
		log.Printf("Ultrabeam: first exchange on %s — sending %d bytes: % X", c.target(), len(packet), packet)
	}
	if _, err := c.conn.Write(packet); err != nil {
		return nil, fmt.Errorf("failed to write: %w", err)
	}

	// Read the reply with a timeout generous enough for a remote link.
	c.setReadTimeout(ubReadTimeout)
	buffer, err := c.readPacket()
	if diag {
		c.diagMu.Lock()
		junk := append([]byte(nil), c.diagJunk...)
		c.diag = false
		c.diagMu.Unlock()
		switch {
		case err != nil && len(junk) == 0:
			log.Printf("Ultrabeam: first exchange — NOTHING came back (%v). The controller is not answering on this port. Note that the USB cable presents TWO COM ports and only the SECOND one reaches the controller (at 19200 baud on the units seen so far); also check the cable and that the controller is on.", err)
		case err != nil:
			log.Printf("Ultrabeam: first exchange — %d bytes came back but no frame started (% X): %v. Bytes with no frame usually mean the wrong baud rate.", len(junk), junk, err)
		default:
			log.Printf("Ultrabeam: first exchange — reply %d bytes: % X (%d discarded before the frame: % X)", len(buffer), buffer, len(junk), junk)
		}
	}
	if err != nil {
		return nil, err
	}
	_, replyCmd, payload, err := parsePacket(buffer)
	if err != nil {
		return nil, fmt.Errorf("failed to parse reply: %w", err)
	}

	// Log for debugging unknown codes
	if replyCmd != UB_OK && replyCmd != UB_BAD && replyCmd != UB_PAR && replyCmd != UB_ERR {
		log.Printf("Ultrabeam: Unknown reply code %d (0x%02X), raw packet: %v", replyCmd, replyCmd, buffer)
	}

	// Check for errors
	switch replyCmd {
	case UB_BAD:
		return nil, fmt.Errorf("invalid command")
	case UB_PAR:
		return nil, fmt.Errorf("bad parameters")
	case UB_ERR:
		return nil, fmt.Errorf("execution error")
	case UB_OK:
		return payload, nil
	default:
		// Unknown codes might indicate "busy" or "in progress"
		// Treat as non-fatal, return empty payload
		log.Printf("Ultrabeam: Unusual reply code %d, treating as busy/in-progress", replyCmd)
		return []byte{}, nil
	}
}

// drainStale discards any bytes already waiting in the stream — a reply left
// behind by a command that timed out. A short read deadline lets it consume what
// is there and stop quickly when the stream is clean. Caller holds connMu.
func (c *Client) drainStale() {
	c.setReadTimeout(5 * time.Millisecond)
	defer c.setReadTimeout(ubReadTimeout) // leave the link on its normal budget
	buf := make([]byte, 256)
	for {
		n, err := c.reader.Read(buf)
		if n == 0 || err != nil {
			return
		}
	}
}

// readPacket reads one complete STX…ETX frame, skipping any leading bytes until
// an STX so a partial/garbage remnant left in the stream can't derail the parse.
// STX/ETX/DLE are all high-bit (0xF5/0xFA/0xF6) and the escaping clears the MSB,
// so a raw ETX only ever appears as the real terminator. Caller holds connMu and
// has set a read deadline.
func (c *Client) readPacket() ([]byte, error) {
	// A DEADLINE for the whole frame, not a timeout per read.
	//
	// A silent serial port does not error: it returns (0, nil) on every read once
	// its timeout expires, and bufio retries that a hundred times before giving up
	// with ErrNoProgress. With a 4-second port timeout that is over six minutes of
	// a poll loop frozen mid-exchange, logging nothing — which is exactly how a
	// controller that never answered looked like a program that had hung. Short
	// port timeouts, checked against a deadline here, turn it into one clear
	// "nothing came back" after four seconds.
	deadline := time.Now().Add(ubReadTimeout)
	c.setReadTimeout(250 * time.Millisecond)
	defer c.setReadTimeout(ubReadTimeout)
	var buffer []byte
	for {
		// A stop must not have to wait out the deadline. Without this, tearing the
		// client down mid-exchange took up to four seconds — long enough for the
		// replacement client to find the port still held, and for Stop to give up
		// waiting and say so.
		select {
		case <-c.stopChan:
			return nil, fmt.Errorf("stopped")
		default:
		}
		if time.Now().After(deadline) {
			if len(buffer) == 0 {
				return nil, fmt.Errorf("no reply within %s", ubReadTimeout)
			}
			return nil, fmt.Errorf("incomplete frame within %s (% X)", ubReadTimeout, buffer)
		}
		b, err := c.reader.ReadByte()
		if err != nil {
			// A quiet port between bytes is normal — go.bug.st returns (0, nil) on
			// its timeout, which bufio eventually reports as ErrNoProgress. Only the
			// deadline above decides that the exchange has failed.
			if transientRead(err) {
				continue
			}
			return nil, fmt.Errorf("failed to read: %w", err)
		}
		if len(buffer) == 0 && b != STX {
			// Kept, briefly, for the first exchange after a connect: what gets
			// discarded here IS the diagnosis when the link is misconfigured.
			c.diagMu.Lock()
			if c.diag && len(c.diagJunk) < 32 {
				c.diagJunk = append(c.diagJunk, b)
			}
			c.diagMu.Unlock()
			continue // resync to the start of a frame
		}
		buffer = append(buffer, b)
		if b == ETX {
			return buffer, nil
		}
		if len(buffer) > 256 {
			return nil, fmt.Errorf("packet too long")
		}
	}
}

// queryStatus queries general status (command 1)
func (c *Client) queryStatus() (*Status, error) {
	reply, err := c.sendCommand(CMD_STATUS, nil)
	if err != nil {
		return nil, err
	}

	// An RCU-06 controller — seen behind an RS232-to-Ethernet bridge — answers with
	// an 11-byte status frame: the standard one WITHOUT the trailing FreqMax byte.
	// The packet checksum was already verified, so a short-but-valid frame is real,
	// not a fragment. Accept it and default the missing tail fields instead of
	// reconnect-looping on "reply too short". reply[9]/[10] (MotorsMoving, FreqMin)
	// are the last we truly need, so 10 bytes is the floor.
	if len(reply) < 10 {
		return nil, fmt.Errorf("status reply too short: %d bytes", len(reply))
	}

	status := &Status{
		FirmwareMinor:    int(reply[0]),
		FirmwareMajor:    int(reply[1]),
		CurrentOperation: int(reply[2]),
		Frequency:        int(reply[3]) | (int(reply[4]) << 8),
		Band:             int(reply[5]),
		Direction:        int(reply[6] & 0x0F),
		OffState:         (reply[7] & 0x02) != 0,
		MotorsMoving:     int(reply[9]),
	}
	if len(reply) > 10 {
		status.FreqMin = int(reply[10])
	}
	if len(reply) > 11 {
		status.FreqMax = int(reply[11])
	}

	return status, nil
}

// queryProgress queries motor progress (command 10)
func (c *Client) queryProgress() ([]int, error) {
	reply, err := c.sendCommand(CMD_PROGRESS, nil)
	if err != nil {
		return nil, err
	}

	if len(reply) < 4 {
		return nil, fmt.Errorf("progress reply too short")
	}

	total := int(reply[0]) | (int(reply[1]) << 8)
	current := int(reply[2]) | (int(reply[3]) << 8)

	return []int{total, current}, nil
}

// ReadElements reads the current per-element lengths for the active band
// (CMD_READ_BANDS). The controller is write-only for ModifyElement, so this is
// the only way to see the current lengths — needed so the operator isn't
// adjusting blind. The reply payload layout is not documented in the code, so we
// LOG it verbatim (once) and parse a best guess: element lengths as 16-bit
// little-endian values, matching how ModifyElement WRITES a length. Confirm the
// format from the logged bytes on real hardware, then tighten the parse.
func (c *Client) ReadElements() ([]int, error) {
	payload, err := c.sendCommand(CMD_READ_BANDS, nil)
	if err != nil {
		return nil, err
	}
	log.Printf("Ultrabeam: READ_BANDS payload (% X) — %d bytes", payload, len(payload))
	// Best-guess parse: consecutive 16-bit LE values = element lengths in mm.
	out := make([]int, 0, len(payload)/2)
	for i := 0; i+1 < len(payload); i += 2 {
		out = append(out, int(payload[i])|int(payload[i+1])<<8)
	}
	return out, nil
}

// SetFrequency changes frequency and optional direction (command 3)
func (c *Client) SetFrequency(freqKhz int, direction int) error {
	// Trace WHO asked for the change — the caller's function + line — so an
	// unexpected antenna QSY (e.g. jumping to 14.074 while on 40m) can be traced
	// to the follow loop, an immediate re-tune, or a direction re-issue.
	caller := "?"
	if pc, _, line, ok := runtime.Caller(1); ok {
		caller = fmt.Sprintf("%s:%d", runtime.FuncForPC(pc).Name(), line)
	}
	log.Printf("Ultrabeam: SetFrequency(%d kHz, dir %d) ← %s", freqKhz, direction, caller)

	data := []byte{
		byte(freqKhz & 0xFF),
		byte((freqKhz >> 8) & 0xFF),
		byte(direction),
	}

	_, err := c.sendCommand(CMD_FREQ, data)
	if err == nil {
		c.statusMu.Lock()
		c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
		c.lastSetKHz = freqKhz
		c.moveCmdAt = time.Now() // start reporting motion at once — see ubMoveOptimisticWindow
		if c.lastStatus != nil {
			c.lastStatus.Direction = direction // reflect immediately
		}
		c.statusMu.Unlock()
	}
	return err
}

// SetDirection changes only the pattern direction (Normal / 180° / Bidirectional)
// by re-issuing the current frequency with the new direction byte — the device
// has no standalone direction command. Needs a status poll to have populated the
// current frequency first.
func (c *Client) SetDirection(direction int) error {
	c.statusMu.RLock()
	freq := 0
	if c.lastStatus != nil {
		freq = c.lastStatus.Frequency
	}
	c.statusMu.RUnlock()
	if freq <= 0 {
		return fmt.Errorf("current frequency not known yet — wait for the antenna to report status")
	}
	return c.SetFrequency(freq, direction)
}

// Retract retracts all elements (command 2)
func (c *Client) Retract() error {
	_, err := c.sendCommand(CMD_RETRACT, nil)
	return err
}

// ModifyElement modifies element length (command 12)
func (c *Client) ModifyElement(elementNum int, lengthMm int) error {
	if elementNum < 0 || elementNum > 5 {
		return fmt.Errorf("invalid element number: %d", elementNum)
	}

	data := []byte{
		byte(elementNum),
		0, // Reserved
		byte(lengthMm & 0xFF),
		byte((lengthMm >> 8) & 0xFF),
	}

	_, err := c.sendCommand(CMD_MODIFY_ELEM, data)
	return err
}

// portBusyHint turns "Serial port busy" into something actionable.
//
// A COM port has exactly one owner. The message the driver gives back says the
// port is busy and stops there, which reads like a fault in OpsLog — and the
// program actually holding it is usually the antenna manufacturer's own control
// window, sitting open on the same desktop. Naming that is the difference
// between a bug report and a five-second fix.
func portBusyHint(mode, com string, err error) string {
	if mode != "serial" || err == nil {
		return ""
	}
	msg := strings.ToLower(err.Error())
	if !strings.Contains(msg, "busy") && !strings.Contains(msg, "access is denied") && !strings.Contains(msg, "denied") {
		return ""
	}
	return " — another program already has " + com + " open (the UltraBeam Controller window, PstRotator, a terminal). A COM port has one owner: close the other program, then OpsLog can connect."
}
