// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
// protocol, over a raw TCP socket or a serial COM port.
//
// Any controller set to GS-232A works, which is most of them: the microHAM ARCO
// natively, and the ERC family (Easy Rotor Control — ERC Mini, ERC interface),
// which EMULATES GS-232A/B over its USB port. An ERC can also be configured for
// Hy-Gain DCU-1, a different command set entirely, so it must be set to GS-232.
//
// On the ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
// connections, and commands are single CR-terminated lines, so short
// per-call connections (same idiom as the other rotator backends) work fine.
//
// GS-232A subset used:
//
//	Maaa<CR>  move to azimuth aaa (000-450)
//	S<CR>     stop rotation
//	C<CR>     query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
//	          flavour); both are parsed.
package gs232

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

	"go.bug.st/serial"
)

const (
	dialTimeout = 3 * time.Second
	ioTimeout   = 2 * time.Second
)

// Client is a stateless per-call sender, mirroring the pst/rotgenius idiom.
// Exactly one of (Host, Port) or ComPort is used, per Transport.
type Client struct {
	Host    string
	Port    int
	ComPort string // serial transport: "COM5" etc.
	// Baud matters on some controllers. An ARCO's USB virtual COM ignores it, but
	// an ERC (Easy Rotor Control) runs at whatever rate its own configuration
	// sets — commonly 9600 or 19200 — and a mismatch reads as a dead rotator.
	// Zero keeps the historical 9600.
	Baud int
}

// New returns a TCP Client with sane defaults applied for empty fields. There
// is no standard port: the number is whatever the user typed into the ARCO's
// LAN CONTROL PROTOCOL setting — 4001 is only a placeholder.
func New(host string, port int) *Client {
	if host == "" {
		host = "127.0.0.1"
	}
	if port <= 0 || port > 65535 {
		port = 4001
	}
	return &Client{Host: host, Port: port}
}

// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
// open at 9600 which also suits a real RS-232 hookup left at its default.
func NewSerial(comPort string, baud int) *Client {
	return &Client{ComPort: comPort, Baud: baud}
}

// bootSettle is how long a freshly opened serial port is left alone before the
// first command.
//
// An Arduino-based controller — K3NG's firmware, the ERC family — RESETS when
// the serial port is opened: the DTR line pulses its reset pin, and the
// bootloader then holds the processor for a second or more. A command sent into
// that window is simply lost, which is exactly how a controller that answers
// PuTTY perfectly reports "no reply" here.
const bootSettle = 2 * time.Second

// heldPort is an open serial port, kept between calls.
//
// The package holds it rather than the Client because the callers build a FRESH
// Client for every poll (one per heading request), and the port has to outlive
// them. Reopening per command is what made an Arduino controller reboot several
// times a second and never answer anything. A serial port is a single-owner
// resource in any case: two clients for COM5 would be two handles on one cable.
type heldPort struct {
	p        serial.Port
	openedAt time.Time
}

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

// acquire returns the open port for com, opening it if needed.
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(200 * time.Millisecond)
	h := &heldPort{p: sp, openedAt: time.Now()}
	openPorts[com] = h
	return h, nil
}

// drop closes and forgets a port, so the next call opens a fresh one. Called
// when an exchange fails: a half-spoken conversation is worse than a new one.
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)
	}
}

// roundTrip sends one CR-terminated command and (when wantReply) reads one
// CR/LF-terminated reply line. Serial keeps its port open between calls; TCP
// dials per call, which is what the ARCO's LAN side expects.
func (c *Client) roundTrip(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
		}
		// Let a just-reset controller finish booting before speaking to it.
		if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
			time.Sleep(wait)
		}
		conn = h.p
		// Whatever is already in the buffer belongs to the last exchange — the
		// trailing LF of the previous reply, or a line the controller volunteered
		// while nobody was reading. Read as the answer to THIS command it would
		// be an answer to the wrong question.
		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 ARCO %s:%d: %w", c.Host, c.Port, err)
		}
		_ = nc.SetDeadline(time.Now().Add(ioTimeout))
		defer nc.Close()
		conn = nc
	}
	if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
		if c.ComPort != "" {
			drop(c.ComPort)
		}
		return "", fmt.Errorf("send %q: %w", cmd, err)
	}
	if !wantReply {
		return "", nil
	}
	buf := make([]byte, 64)
	var sb strings.Builder
	deadline := time.Now().Add(ioTimeout)
	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
			}
		}
		// A serial read that times out returns (0, nil) — keep polling until the
		// overall deadline; a real error ends the read.
		if err != nil {
			break
		}
	}
	line := strings.TrimSpace(sb.String())
	if line == "" {
		// Silence may mean the port is fine and the controller is not, or that
		// the handle is stale (a USB adapter unplugged and replugged). Let go of
		// it so the next attempt starts from a clean open rather than repeating
		// the same silence for ever.
		if c.ComPort != "" {
			drop(c.ComPort)
		}
		return "", fmt.Errorf("no reply to %q", cmd)
	}
	return line, nil
}

// drain empties whatever is waiting, without blocking for long.
func drain(sp serial.Port) {
	buf := make([]byte, 128)
	for i := 0; i < 4; i++ {
		n, err := sp.Read(buf)
		if n == 0 || err != nil {
			return
		}
	}
}

// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
// (overlap rotators accept >360); we normalise to [0,360).
func (c *Client) GoTo(az int) error {
	az = ((az % 360) + 360) % 360
	_, err := c.roundTrip(fmt.Sprintf("M%03d", az), false)
	return err
}

// Stop interrupts any in-progress rotation.
func (c *Client) Stop() error {
	_, err := c.roundTrip("S", false)
	return err
}

// azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B).
var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})`)

// Heading queries the current azimuth. Returns the raw reply for diagnostics.
func (c *Client) Heading() (az int, raw string, err error) {
	raw, err = c.roundTrip("C", true)
	if err != nil {
		return 0, raw, err
	}
	m := azRe.FindStringSubmatch(raw)
	if m == nil {
		return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
	}
	az, _ = strconv.Atoi(m[1])
	return az % 360, raw, nil
}
