// Package ultrabeam drives an Ultrabeam remote-controlled antenna over TCP
// (typically via an RS232↔Ethernet adapter). The wire protocol (STX/ETX
// framing, DLE escaping, XOR checksum) and command codes are the manufacturer's.
package ultrabeam

import (
	"bufio"
	"errors"
	"fmt"
	"log"
	"net"
	"runtime"
	"sync"
	"time"
)

// 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 {
	host       string
	port       int
	conn       net.Conn
	connMu     sync.Mutex
	reader     *bufio.Reader
	lastStatus *Status
	statusMu   sync.RWMutex
	stopChan   chan struct{}
	running    bool
	seqNum     byte
	seqMu      sync.Mutex

	// 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

// 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(host string, port int) *Client {
	return &Client{
		host:     host,
		port:     port,
		stopChan: make(chan struct{}),
		seqNum:   0,
	}
}

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

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()
}

func (c *Client) pollLoop() {
	ticker := time.NewTicker(2 * time.Second) // Increased from 500ms to 2s
	defer ticker.Stop()

	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...")
				dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
				conn, err := dialer.Dial("tcp", net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)))
				if err != nil {
					log.Printf("Ultrabeam: Connection failed: %v", err)
					c.connMu.Unlock()

					// Mark as disconnected
					c.statusMu.Lock()
					c.lastStatus = &Status{Connected: false}
					c.statusMu.Unlock()
					continue
				}
				c.conn = conn
				c.reader = bufio.NewReader(c.conn)
				pollFails = 0
				log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
			}
			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.
				var ne net.Error
				transient := errors.As(err, &ne) && ne.Timeout()
				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()

		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)
	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.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
	buffer, err := c.readPacket()
	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.conn.SetReadDeadline(time.Now().Add(5 * time.Millisecond))
	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) {
	var buffer []byte
	for {
		b, err := c.reader.ReadByte()
		if err != nil {
			return nil, fmt.Errorf("failed to read: %w", err)
		}
		if len(buffer) == 0 && b != STX {
			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
}
