// Package dcu1 drives rotator controllers that speak the Hy-Gain DCU-1 protocol,
// over a serial COM port (or a raw TCP socket, e.g. a serial-over-IP bridge).
//
// DCU-1 is used by the Hy-Gain DCU-1, the Idiom Press Rotor-EZ, Green Heron
// controllers, and the RotorCard DXA (hamsupply) for Yaesu DXA rotors. It is a
// DIFFERENT command set from Yaesu GS-232 (see internal/rotator/gs232):
// semicolon-terminated, azimuth only.
//
// Commands (';' terminated — roundTrip appends the ';'):
//
//	AP1nnn   set the target bearing nnn (000-359)
//	AM1      rotate to the target (some controllers move on AP1 alone; AM1 is
//	         harmless and makes the Rotor-EZ/DCU-1 variants that need it work)
//	AI1      query the current bearing → the reply carries the 3-digit azimuth
//
// The base DCU-1 set has no dedicated stop; Stop re-commands the current bearing,
// which halts rotation.
package dcu1

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

	"go.bug.st/serial"
)

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

// Client is a stateless per-call sender, mirroring the gs232/pst/rotgenius idiom.
// Exactly one of (Host, Port) or ComPort is used.
type Client struct {
	Host    string
	Port    int
	ComPort string // serial transport: "COM5" etc.
	// Baud varies by controller (a Hy-Gain DCU-1 is 4800; Green Heron / RotorCard
	// can differ). Zero keeps 4800.
	Baud int
}

// New returns a TCP Client (a serial-over-IP bridge in front of the controller).
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 over the controller's COM port.
func NewSerial(comPort string, baud int) *Client {
	return &Client{ComPort: comPort, Baud: baud}
}

// roundTrip opens a connection, sends one ';'-terminated command and (when
// wantReply) reads until a 3-digit bearing is present. cmd must NOT carry the
// ';'.
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
	var conn io.ReadWriteCloser
	if c.ComPort != "" {
		baud := c.Baud
		if baud <= 0 {
			baud = 4800
		}
		sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
		if err != nil {
			return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
		}
		_ = sp.SetReadTimeout(200 * time.Millisecond)
		conn = sp
	} else {
		nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
		if err != nil {
			return "", fmt.Errorf("connect DCU-1 %s:%d: %w", c.Host, c.Port, err)
		}
		_ = nc.SetDeadline(time.Now().Add(ioTimeout))
		conn = nc
	}
	defer conn.Close()
	if _, err := conn.Write([]byte(cmd + ";")); err != nil {
		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])
			// The DCU-1 reply carries the bearing as three digits (framing varies —
			// ";nnn", "nnn;", "+0nnn"). Stop once we have them rather than on a
			// specific terminator, so any flavour reads cleanly.
			if azRe.MatchString(sb.String()) {
				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 == "" {
		return "", fmt.Errorf("no reply to %q", cmd)
	}
	return line, nil
}

// GoTo points the antenna at az (0-359): set the target, then rotate.
func (c *Client) GoTo(az int) error {
	az = ((az % 360) + 360) % 360
	if _, err := c.roundTrip(fmt.Sprintf("AP1%03d", az), false); err != nil {
		return err
	}
	_, err := c.roundTrip("AM1", false)
	return err
}

// Stop halts rotation. The base DCU-1 set has no stop command, so re-command the
// current bearing — the controller stops when the target equals where it is.
func (c *Client) Stop() error {
	az, _, err := c.Heading()
	if err != nil {
		return err
	}
	_, err = c.roundTrip(fmt.Sprintf("AP1%03d", az), false)
	return err
}

// azRe matches the 3-digit bearing in any of the DCU-1 reply framings.
var azRe = regexp.MustCompile(`(\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("AI1", 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
}
