// Package easycomm drives azimuth/elevation rotator controllers that speak
// EasyComm II, over a raw TCP socket or a serial port.
//
// EasyComm is what satellite rotator controllers agreed on: SatPC32, Gpredict
// and Hamlib all speak it, so a controller that works with any of those works
// here. The dialect matters less than it looks — every command is a two-letter
// name with a number stuck to it, on one line, and a controller that does not
// recognise one ignores it.
//
// The subset used:
//
//	AZ123.4 EL45.0<LF>  point there
//	AZ EL<LF>           ask where it is — the reply is the same shape
//	SA SE<LF>           stop both axes
//
// Not every controller ANSWERS. A great many EasyComm boxes — the Arduino
// trackers above all — accept commands and never say a word back, which is
// perfectly legal in EasyComm I and common in II. So a silent controller is not
// treated as a broken one: the last commanded position is reported instead, and
// the rotator keeps being driven. Refusing to work with a write-only controller
// would rule out half the satellite stations in the hobby.
package easycomm

import (
	"fmt"
	"io"
	"math"
	"net"
	"strconv"
	"strings"
	"sync"
	"time"

	"go.bug.st/serial"
)

const (
	dialTimeout = 3 * time.Second
	ioTimeout   = 1500 * time.Millisecond
	// replyWait is how long a query waits before deciding the controller is one
	// of the silent ones. Short: this runs once a second inside a pass, and a
	// controller that is going to answer answers in milliseconds.
	replyWait = 400 * time.Millisecond
)

// Client is one rotator controller. Exactly one of (Host, Port) or ComPort is
// used.
type Client struct {
	Host    string
	Port    int
	ComPort string
	Baud    int
	// MaxAz is how far the rotator turns: 360 or 450. A 450° rotator can follow
	// a pass straight through north without unwinding, which is the difference
	// between hearing the whole of an overhead pass and losing the middle of it.
	MaxAz int

	mu sync.Mutex
	// lastAz/lastEl are what was last commanded — the answer for a controller
	// that does not talk back.
	lastAz, lastEl float64
	commanded      bool
	// silent latches once a query has gone unanswered. Without it, a write-only
	// controller costs a 400 ms wait on every single poll of a pass.
	silent bool
}

// New builds a TCP client. There is no standard port; 4533 is Hamlib's rotctld
// convention and the usual default in the controllers' own setup screens.
func New(host string, port int, maxAz int) *Client {
	if strings.TrimSpace(host) == "" {
		host = "127.0.0.1"
	}
	if port <= 0 || port > 65535 {
		port = 4533
	}
	return &Client{Host: host, Port: port, MaxAz: normMaxAz(maxAz)}
}

// NewSerial builds a serial client.
func NewSerial(comPort string, baud int, maxAz int) *Client {
	if baud <= 0 {
		baud = 9600
	}
	return &Client{ComPort: comPort, Baud: baud, MaxAz: normMaxAz(maxAz)}
}

func normMaxAz(v int) int {
	if v == 450 {
		return 450
	}
	return 360
}

// Point commands the rotator to an azimuth and elevation.
//
// The azimuth is given in the rotator's own terms: on a 450° machine an
// azimuth past 360 is a real, reachable position, and asking for 010 when the
// rotator is sitting at 370 would send it the long way round through the whole
// scale — three quarters of a turn, in the middle of a pass, with the antenna
// pointing at the ground for most of it.
func (c *Client) Point(az, el float64) error {
	az = c.wrapAz(az)
	el = clamp(el, 0, 180)
	if err := c.send(fmt.Sprintf("AZ%.1f EL%.1f", az, el), false); err != nil {
		return err
	}
	c.mu.Lock()
	c.lastAz, c.lastEl, c.commanded = az, el, true
	c.mu.Unlock()
	return nil
}

// Stop halts both axes.
func (c *Client) Stop() error { return c.send("SA SE", false) }

// Heading is where the rotator says it is.
//
// live is false when the answer is the last commanded position rather than a
// reading — the caller shows that differently, because "where I told it to go"
// and "where it is" are not the same claim and a stuck rotator must not be able
// to hide behind the first.
func (c *Client) Heading() (az, el float64, live bool, err error) {
	c.mu.Lock()
	silent, la, le, commanded := c.silent, c.lastAz, c.lastEl, c.commanded
	c.mu.Unlock()
	if silent {
		if !commanded {
			return 0, 0, false, fmt.Errorf("easycomm: the controller does not report its position")
		}
		return la, le, false, nil
	}
	line, err := c.query("AZ EL")
	if err != nil {
		// One silence is enough: a controller either answers or it does not, and
		// this runs every second for the length of a pass.
		c.mu.Lock()
		c.silent = true
		c.mu.Unlock()
		if commanded {
			return la, le, false, nil
		}
		return 0, 0, false, err
	}
	a, e, ok := parseHeading(line)
	if !ok {
		c.mu.Lock()
		c.silent = true
		c.mu.Unlock()
		if commanded {
			return la, le, false, nil
		}
		return 0, 0, false, fmt.Errorf("easycomm: could not read %q", line)
	}
	return a, e, true, nil
}

// wrapAz brings an azimuth into what this rotator can reach.
//
// On a 360° machine that is a plain modulo. On a 450° one the extra 90° is an
// OVERLAP — 370 and 10 are the same direction — and which of the two to use is
// decided by whichever is nearer where the rotator already is, so a pass
// crossing north continues instead of unwinding.
func (c *Client) wrapAz(az float64) float64 {
	az = math.Mod(az, 360)
	if az < 0 {
		az += 360
	}
	if c.MaxAz != 450 {
		return az
	}
	c.mu.Lock()
	cur, known := c.lastAz, c.commanded
	c.mu.Unlock()
	if !known {
		return az
	}
	alt := az + 360
	if alt > 450 {
		return az
	}
	if math.Abs(alt-cur) < math.Abs(az-cur) {
		return alt
	}
	return az
}

// ── Transport ───────────────────────────────────────────────────────────────

type heldPort struct {
	p        serial.Port
	openedAt time.Time
}

var (
	portsMu   sync.Mutex
	openPorts = map[string]*heldPort{}
)

// bootSettle: an Arduino-based controller resets when its serial port is
// opened, and its bootloader then holds the processor for a second or more. A
// command sent into that window is simply lost — which is how a controller that
// answers a terminal perfectly reports nothing here.
const bootSettle = 2 * time.Second

func acquire(com string, baud int) (*heldPort, error) {
	portsMu.Lock()
	defer portsMu.Unlock()
	if h, ok := openPorts[com]; ok && h.p != nil {
		return h, nil
	}
	if baud <= 0 {
		baud = 9600
	}
	sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
	if err != nil {
		return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
	}
	_ = sp.SetReadTimeout(150 * time.Millisecond)
	h := &heldPort{p: sp, openedAt: time.Now()}
	openPorts[com] = h
	return h, nil
}

func drop(com string) {
	portsMu.Lock()
	defer portsMu.Unlock()
	if h, ok := openPorts[com]; ok {
		if h.p != nil {
			_ = h.p.Close()
		}
		delete(openPorts, com)
	}
}

// Close releases the serial port. TCP dials per command and holds nothing.
func (c *Client) Close() {
	if c.ComPort != "" {
		drop(c.ComPort)
	}
}

func (c *Client) send(cmd string, wantReply bool) error {
	_, err := c.exchange(cmd, wantReply)
	return err
}

func (c *Client) query(cmd string) (string, error) { return c.exchange(cmd, true) }

func (c *Client) exchange(cmd string, wantReply bool) (string, error) {
	var conn io.ReadWriteCloser
	if c.ComPort != "" {
		h, err := acquire(c.ComPort, c.Baud)
		if err != nil {
			return "", err
		}
		if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
			time.Sleep(wait)
		}
		conn = h.p
		drain(h.p)
	} else {
		nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
		if err != nil {
			return "", fmt.Errorf("connect rotator %s:%d: %w", c.Host, c.Port, err)
		}
		_ = nc.SetDeadline(time.Now().Add(ioTimeout))
		defer nc.Close()
		conn = nc
	}
	// LF, not CR: EasyComm's own documents use a line feed, and the controllers
	// that want CR accept either. The reverse is not true of every Arduino
	// sketch out there.
	if _, err := conn.Write([]byte(cmd + "\n")); err != nil {
		if c.ComPort != "" {
			drop(c.ComPort)
		}
		return "", fmt.Errorf("send %q: %w", cmd, err)
	}
	if !wantReply {
		return "", nil
	}
	buf := make([]byte, 128)
	var sb strings.Builder
	deadline := time.Now().Add(replyWait)
	for time.Now().Before(deadline) {
		n, err := conn.Read(buf)
		if n > 0 {
			sb.Write(buf[:n])
			if strings.ContainsAny(sb.String(), "\r\n") {
				break
			}
		}
		if err != nil {
			break
		}
	}
	line := strings.TrimSpace(sb.String())
	if line == "" {
		return "", fmt.Errorf("no reply to %q", cmd)
	}
	return line, nil
}

func drain(sp serial.Port) {
	buf := make([]byte, 256)
	for {
		n, err := sp.Read(buf)
		if n == 0 || err != nil {
			return
		}
	}
}

// parseHeading reads a controller's answer.
//
// The shapes in the wild differ more than the specification suggests —
// "AZ123.4 EL45.0", "AZ=123.4 EL=45.0", "+123.4+045.0", lower case, tabs — so
// this looks for the two labels and takes the number attached to each rather
// than trying to match a whole line.
func parseHeading(line string) (az, el float64, ok bool) {
	up := strings.ToUpper(line)
	az, aok := numberAfter(up, "AZ")
	el, eok := numberAfter(up, "EL")
	if !aok {
		return 0, 0, false
	}
	// Elevation missing is not a broken reply: an azimuth-only controller
	// answering an AZ EL query says what it has.
	if !eok {
		el = 0
	}
	return az, el, true
}

func numberAfter(s, label string) (float64, bool) {
	i := strings.Index(s, label)
	if i < 0 {
		return 0, false
	}
	rest := strings.TrimLeft(s[i+len(label):], " \t=:")
	end := 0
	for end < len(rest) {
		ch := rest[end]
		if (ch >= '0' && ch <= '9') || ch == '.' || ((ch == '-' || ch == '+') && end == 0) {
			end++
			continue
		}
		break
	}
	if end == 0 {
		return 0, false
	}
	v, err := strconv.ParseFloat(strings.TrimSuffix(rest[:end], "."), 64)
	if err != nil {
		return 0, false
	}
	return v, true
}

func clamp(v, lo, hi float64) float64 {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}
