// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over
// its "Transceiver Interface" serial protocol, reached either directly on a COM
// port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to
// an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can
// drive either behind one interface.
//
// Protocol (cross-checked against the SteppIR "Transceiver Interface Operation"
// note, the we7u/steppir library, and the la1k.no write-up — three independent
// sources that agree, which is what makes the byte layout trustworthy):
//
//	SET   : "@A" <freq> 00 <dir> <cmd> 00 0x0D          (11 bytes)
//	        <freq> = int32 big-endian of (Hz / 10)
//	        <dir>  = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
//	        <cmd>  = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF
//	                 'S' home/retract · 'V' calibrate
//	STATUS: "?A" 0x0D  → 11 bytes back:
//	        [2:6] int32 big-endian frequency (× 10 = Hz)
//	        [6]   active-motor bitmask (0xFF = command received / setup)
//	        [7] & 0xE0 direction
//
// Timing: the controller needs ≥100 ms between commands and dislikes status
// polls faster than ~10/s. The poll loop runs at 2 s, well inside that.
package steppir

import (
	"bytes"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"sync"
	"time"

	"go.bug.st/serial"
)

// errBadFrame marks a reply that isn't a well-formed status frame. It means
// "ignore this poll", not "the link is down".
var errBadFrame = errors.New("steppir: malformed status frame")

// Direction values, matching the app-wide convention (also used by Ultrabeam):
// 0 normal, 1 reverse (180°), 2 bidirectional.
const (
	DirNormal = 0
	Dir180    = 1
	DirBi     = 2
)

// SteppIR direction bytes on the wire.
const (
	wireNormal = 0x00
	wire180    = 0x40
	wireBi     = 0x80
)

// pendingDirTTL is how long a commanded direction is trusted over the
// controller's own report. The elements physically re-tune to swap director and
// reflector, and the SDA only reports the new pattern once it starts that move,
// so a few seconds is not enough — 4 s (the original value) had the UI snapping
// back to "normal" while the antenna was on its way to 180°. Long enough to
// cover a real move, short enough that a command the controller never received
// self-corrects instead of lying forever.
const pendingDirTTL = 45 * time.Second

// Transport says how to reach the controller.
type Transport struct {
	Mode string // "tcp" | "serial"
	Host string // tcp
	Port int    // tcp
	COM  string // serial device (COM3, /dev/ttyUSB0)
	Baud int    // serial baud (controller default 9600; 1200-19200 valid)
}

// Status is the antenna state, in the same shape the app reads from the
// Ultrabeam so the two are interchangeable at the UI.
type Status struct {
	Connected    bool `json:"connected"`
	Frequency    int  `json:"frequency"` // kHz
	Band         int  `json:"band"`      // 0 (SteppIR does not report a band index)
	Direction    int  `json:"direction"` // 0 normal, 1 180°, 2 bidirectional
	MotorsMoving int  `json:"motors_moving"`
}

type Client struct {
	tr Transport

	connMu sync.Mutex
	conn   io.ReadWriteCloser

	// ioMu serialises EVERY exchange on the shared connection — a status query
	// (write "?A" then read 11 bytes) and a command write must never interleave,
	// or their bytes mix on the wire and both frames are corrupted. The status
	// poll runs on one goroutine, tuning on another, so this is essential.
	ioMu sync.Mutex

	statusMu   sync.RWMutex
	lastStatus *Status
	lastSetKHz int

	// lastRaw holds the previous raw status frame so we only log a status line
	// when the controller's reply actually changes — enough to diagnose a stuck
	// "motors moving" read (which drives the app's TX-inhibit interlock) without
	// spamming the log every 2 s poll.
	lastRaw []byte

	// A just-commanded direction is held until the controller's poll reports it —
	// the motors take a second or two, and a stale poll would otherwise snap the
	// UI back. Same trick as the Ultrabeam client.
	//
	// The hold is deliberately long (pendingDirTTL). It is not just a UI nicety:
	// the follow loop re-tunes with the direction it reads back from this status,
	// so a single stale poll reading "normal" would make OpsLog command the
	// antenna out of 180° all by itself.
	pendingDir    int
	pendingDirAt  time.Time
	pendingDirSet bool

	stopChan chan struct{}
	running  bool
}

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

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

// LastSetKHz returns the frequency last commanded, or 0.
func (c *Client) LastSetKHz() int {
	c.statusMu.RLock()
	defer c.statusMu.RUnlock()
	return c.lastSetKHz
}

func (c *Client) GetStatus() (*Status, error) {
	c.statusMu.RLock()
	defer c.statusMu.RUnlock()
	if c.lastStatus == nil {
		return &Status{Connected: false}, nil
	}
	return c.lastStatus, nil
}

// open dials the transport. Callers hold connMu.
func (c *Client) open() (io.ReadWriteCloser, error) {
	switch c.tr.Mode {
	case "serial":
		if c.tr.COM == "" {
			return nil, fmt.Errorf("steppir: no serial port configured")
		}
		p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
		if err != nil {
			return nil, err
		}
		// A finite read timeout so a silent controller doesn't wedge the poll loop.
		_ = p.SetReadTimeout(2 * time.Second)
		return p, nil
	default: // tcp
		if c.tr.Host == "" {
			return nil, fmt.Errorf("steppir: no host configured")
		}
		d := net.Dialer{Timeout: 5 * time.Second}
		return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
	}
}

func (c *Client) pollLoop() {
	ticker := time.NewTicker(2 * time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-c.stopChan:
			return
		case <-ticker.C:
			c.connMu.Lock()
			if c.conn == nil {
				conn, err := c.open()
				if err != nil {
					c.connMu.Unlock()
					c.setDisconnected()
					continue
				}
				c.conn = conn
			}
			c.connMu.Unlock()

			st, err := c.queryStatus()
			if errors.Is(err, errBadFrame) {
				// Framing glitch, not a dead link: skip this tick and keep the
				// previous status. Dropping the connection here would blink the
				// UI to "disconnected" over one garbled reply.
				continue
			}
			if err != nil {
				log.Printf("steppir: status query failed, reconnecting: %v", err)
				c.closeConn()
				c.setDisconnected()
				continue
			}
			st.Connected = true
			c.statusMu.Lock()
			c.applyPendingDir(st)
			c.lastStatus = st
			c.statusMu.Unlock()
		}
	}
}

// applyPendingDir replaces a freshly polled direction with the one the operator
// last commanded, until the controller confirms it (or the hold expires). The
// caller holds statusMu.
func (c *Client) applyPendingDir(st *Status) {
	if !c.pendingDirSet {
		return
	}
	switch {
	case st.Direction == c.pendingDir:
		c.pendingDirSet = false // confirmed — trust the controller's reports again
	case time.Since(c.pendingDirAt) > pendingDirTTL:
		c.pendingDirSet = false
		log.Printf("steppir: controller never confirmed direction %d (still reports %d) — dropping the hold",
			c.pendingDir, st.Direction)
	default:
		st.Direction = c.pendingDir
	}
}

func (c *Client) setDisconnected() {
	c.statusMu.Lock()
	c.lastStatus = &Status{Connected: false}
	c.statusMu.Unlock()
}

func (c *Client) closeConn() {
	c.connMu.Lock()
	if c.conn != nil {
		c.conn.Close()
		c.conn = nil
	}
	c.connMu.Unlock()
}

// setDeadline applies a read/write deadline on TCP; serial uses its own timeout.
func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
	if nc, ok := conn.(net.Conn); ok {
		_ = nc.SetDeadline(time.Now().Add(d))
	}
}

// setReadTimeout bounds a single read on either transport, so a drain can tell
// "nothing more queued" from "still arriving" without blocking.
func setReadTimeout(conn io.ReadWriteCloser, d time.Duration) {
	switch t := conn.(type) {
	case net.Conn:
		_ = t.SetReadDeadline(time.Now().Add(d))
	case serial.Port:
		_ = t.SetReadTimeout(d)
	}
}

// restoreTimeouts puts the normal exchange timeouts back after a drain shortened
// them.
func restoreTimeouts(conn io.ReadWriteCloser) {
	setDeadline(conn, 3*time.Second) // TCP: read + write
	setReadTimeout(conn, 2*time.Second)
}

// drain throws away everything already sitting in the input buffer and returns
// how many bytes it discarded.
//
// This is the fix for the antenna's state appearing tens of seconds out of date.
// The SDA controller does not only answer "?A" — it also pushes status frames on
// its own (front-panel changes, autotrack moves, each command it processes). We
// consume exactly one frame per poll, so every unsolicited frame adds one to a
// backlog that only ever grows: reading 11 bytes then returns a frame from
// minutes ago. The field log showed it plainly — two consecutive polls 4 s apart
// reporting 28280 kHz then 14200 kHz, a frequency last used hours earlier, and a
// 180° command not showing up in the status for ~40 s (long after the UI had
// given up waiting and snapped the button back to "normal"). Emptying the buffer
// immediately before each query means the frame we then read is the answer to
// THIS query.
func drain(conn io.ReadWriteCloser) int {
	buf := make([]byte, 512)
	total := 0
	// Bounded so a controller that streams continuously can't hold the poll
	// goroutine here forever. 32 × 512 B is ~1500 frames — far more backlog than
	// any real link builds up, and it only costs one 30 ms timeout when the
	// buffer is already empty (reads return immediately while data is queued).
	for i := 0; i < 32; i++ {
		setReadTimeout(conn, 30*time.Millisecond)
		n, err := conn.Read(buf)
		total += n
		if err != nil || n == 0 { // timeout / nothing left
			break
		}
	}
	return total
}

func (c *Client) queryStatus() (*Status, error) {
	c.connMu.Lock()
	conn := c.conn
	c.connMu.Unlock()
	if conn == nil {
		return nil, fmt.Errorf("steppir: not connected")
	}
	c.ioMu.Lock()
	defer c.ioMu.Unlock()
	// Discard any frame the controller pushed on its own since the last poll, so
	// what we read below is this query's answer and not a stale backlog entry.
	if n := drain(conn); n > 0 {
		log.Printf("steppir: discarded %d stale byte(s) queued by the controller before polling", n)
	}
	restoreTimeouts(conn)
	if _, err := conn.Write([]byte("?A\r")); err != nil {
		return nil, fmt.Errorf("write status cmd: %w", err)
	}
	buf := make([]byte, 11)
	if _, err := io.ReadFull(conn, buf); err != nil {
		return nil, fmt.Errorf("read status: %w", err)
	}
	// Reject anything that isn't a framed reply rather than decoding garbage into
	// a frequency and a direction the app would then act on.
	if buf[0] != '@' || buf[1] != 'A' || buf[10] != 0x0D {
		log.Printf("steppir: ignoring malformed status frame % X", buf)
		drain(conn) // resync: drop the rest of whatever we landed mid-way through
		restoreTimeouts(conn)
		return nil, errBadFrame
	}
	st, err := parseStatus(buf)
	// Log the raw frame + decode whenever it changes. The motor byte (buf[6]) is
	// what decides st.MotorsMoving, and that in turn drives the app's "block TX
	// while moving" interlock — so if a controller (e.g. with its INHIBIT engaged)
	// reports a byte we misread as perpetual motion, this line makes it visible.
	if err == nil && !bytes.Equal(buf, c.lastRaw) {
		c.lastRaw = append(c.lastRaw[:0], buf...)
		log.Printf("steppir: status ← % X  (freq=%d kHz dir=%d moving=%d motorByte=0x%02X dirByte=0x%02X)",
			buf, st.Frequency, st.Direction, st.MotorsMoving, buf[6], buf[7])
	}
	return st, err
}

// parseStatus decodes an 11-byte status frame.
func parseStatus(b []byte) (*Status, error) {
	if len(b) < 11 {
		return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b))
	}
	freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
	active := b[6]
	dir := decodeDir(b[7])
	// active-motors byte: one bit per element that is currently moving.
	//   0x04 driver · 0x08 DIR1 · 0x10 reflector · 0x20 DIR2  (mask 0x3C)
	// Bit 0 (0x01) is documented as always set — not a motor. 0xFF means the
	// controller just received a command, not motion. So "moving" is precisely
	// "any real motor bit set", ignoring the always-on bit and the ack value.
	const motorBits = 0x3C
	moving := 0
	if active != 0xFF && active&motorBits != 0 {
		moving = 1
	}
	return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
}

func decodeDir(b byte) int {
	switch b & 0xE0 {
	case wireBi:
		return DirBi
	case wire180:
		return Dir180
	default:
		return DirNormal
	}
}

func dirWireByte(dir int) byte {
	switch dir {
	case Dir180:
		return wire180
	case DirBi:
		return wireBi
	default:
		return wireNormal
	}
}

// buildSet frames a SET command: "@A" <freq be32 of Hz/10> 00 <dir> <cmd> 00 CR.
func buildSet(freqHz int, dir int, cmd byte) []byte {
	var f [4]byte
	binary.BigEndian.PutUint32(f[:], uint32(freqHz/10))
	out := make([]byte, 0, 11)
	out = append(out, '@', 'A')
	out = append(out, f[:]...)
	out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D)
	return out
}

func (c *Client) writeCmd(pkt []byte) error {
	c.connMu.Lock()
	conn := c.conn
	c.connMu.Unlock()
	if conn == nil {
		return fmt.Errorf("steppir: not connected")
	}
	c.ioMu.Lock()
	defer c.ioMu.Unlock()
	log.Printf("steppir: → % X", pkt)
	setDeadline(conn, 3*time.Second)
	if _, err := conn.Write(pkt); err != nil {
		c.closeConn()
		return err
	}
	// The controller needs breathing room between commands.
	time.Sleep(120 * time.Millisecond)
	return nil
}

// SetFrequency tunes the elements to freqKhz with the given direction.
//
// AUTOTRACK is (re-)enabled first, EVERY time: the controller ignores frequency
// sets unless it is in AUTOTRACK mode ("when not in AUTOTRACK only CALIBRATE and
// RETRACT work"), and it can be out of AUTOTRACK at power-on, after a Home, or if
// switched off on the front panel. Sending the 'R' command each tune is cheap and
// makes tuning work regardless of the controller's current mode — which is what
// was silently failing before.
func (c *Client) SetFrequency(freqKhz int, direction int) error {
	if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { // AUTOTRACK ON
		return err
	}
	if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir
		return err
	}
	c.statusMu.Lock()
	c.lastSetKHz = freqKhz
	c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
	c.statusMu.Unlock()
	return nil
}

// SetDirection changes the pattern. SteppIR has no standalone direction command —
// it is a SET with the current frequency and the new direction byte.
func (c *Client) SetDirection(direction int) error {
	khz := c.LastSetKHz()
	if khz <= 0 {
		if st, _ := c.GetStatus(); st != nil {
			khz = st.Frequency
		}
	}
	if khz <= 0 {
		return fmt.Errorf("steppir: no frequency known yet — cannot set direction")
	}
	return c.SetFrequency(khz, direction)
}

// Retract homes the elements into the hubs (storage). This drops the controller
// out of AUTOTRACK, but that is handled transparently: the next SetFrequency
// re-issues AUTOTRACK ON before tuning.
func (c *Client) Retract() error {
	// A valid frequency must accompany the command; reuse the last one.
	khz := c.LastSetKHz()
	if khz <= 0 {
		if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
			khz = st.Frequency
		} else {
			khz = 14000 // any in-range value; the controller just homes
		}
	}
	return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
}
