// 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 (
	"encoding/binary"
	"fmt"
	"io"
	"log"
	"net"
	"sync"
	"time"

	"go.bug.st/serial"
)

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

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

	// 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.
	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 err != nil {
				log.Printf("steppir: status query failed, reconnecting: %v", err)
				c.closeConn()
				c.setDisconnected()
				continue
			}
			st.Connected = true
			c.statusMu.Lock()
			if c.pendingDirSet {
				if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
					c.pendingDirSet = false
				} else {
					st.Direction = c.pendingDir
				}
			}
			c.lastStatus = st
			c.statusMu.Unlock()
		}
	}
}

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

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()
	setDeadline(conn, 3*time.Second)
	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)
	}
	return parseStatus(buf)
}

// 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'))
}
