// Package rotgenius drives a 4O3A Rotator Genius over its native TCP text
// protocol (rev 4, default port 9006). All data is fixed-length extended-ASCII;
// there is no sequence/framing wrapper — you send a short command and read back a
// fixed-length reply.
//
// Commands used here:
//
//	|h                    read heading + full state (both rotators)
//	|A<rot><az3>          move rotator <rot> ('1'|'2') to azimuth az3 (000..360)
//	|P<rot> / |M<rot>     rotate CW / CCW
//	|S                    stop all movement
//
// The |h reply is 72 bytes: "|h" + Active[1] + Panic[1] then, per rotator,
// CurrentAzimuth[3] LimitCW[3] LimitCCW[3] Config[1] Moving[1] Offset[4]
// TargetAzimuth[3] StartAzimuth[3] Limit[1] Name[12]. Numeric fields may be
// space-padded; a CurrentAzimuth of 999 means the sensor is not connected.
package rotgenius

import (
	"fmt"
	"net"
	"strconv"
	"strings"
	"time"
)

const (
	defaultPort = 9006
	dialTimeout = 4 * time.Second
	ioTimeout   = 4 * time.Second
	hdrReplyLen = 72 // fixed length of the |h reply
)

// Status is one rotator's live state parsed from a |h reply.
type Status struct {
	Azimuth   int  // current heading in degrees (0..360)
	Connected bool // false when the sensor reports 999 (not connected)
	Moving    int  // 0 not moving, 1 CW, 2 CCW
	Target    int  // target azimuth when moving (else -1)
}

// Client is a stateless connector: each call opens a short-lived TCP connection,
// mirroring how the PstRotator client works, so there is no socket to manage.
type Client struct {
	host string
	port int
}

func New(host string, port int) *Client {
	if port <= 0 {
		port = defaultPort
	}
	return &Client{host: host, port: port}
}

func (c *Client) dial() (net.Conn, error) {
	d := net.Dialer{Timeout: dialTimeout}
	conn, err := d.Dial("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)))
	if err != nil {
		return nil, err
	}
	_ = conn.SetDeadline(time.Now().Add(ioTimeout))
	return conn, nil
}

// exchange sends cmd and returns up to max bytes of the reply.
func (c *Client) exchange(cmd string, max int) ([]byte, error) {
	conn, err := c.dial()
	if err != nil {
		return nil, err
	}
	defer conn.Close()
	if _, err := conn.Write([]byte(cmd)); err != nil {
		return nil, fmt.Errorf("write %q: %w", cmd, err)
	}
	buf := make([]byte, 0, max)
	tmp := make([]byte, max)
	for len(buf) < max {
		n, rerr := conn.Read(tmp)
		if n > 0 {
			buf = append(buf, tmp[:n]...)
		}
		if rerr != nil {
			break // deadline or EOF — return what we have and let the parser judge
		}
	}
	return buf, nil
}

// atoiField trims the space-padding a Rotator Genius field may carry and parses
// it. An empty or non-numeric field yields 0.
func atoiField(s string) int {
	n, _ := strconv.Atoi(strings.TrimSpace(s))
	return n
}

// Heading reads the current azimuth of the given rotator (1 or 2). raw is the
// decoded field for diagnostics.
func (c *Client) Heading(rotator int) (Status, string, error) {
	st, err := c.Read(rotator)
	if err != nil {
		return Status{}, "", err
	}
	return st, strconv.Itoa(st.Azimuth), nil
}

// Read fetches and parses the full |h reply for one rotator (1 or 2).
func (c *Client) Read(rotator int) (Status, error) {
	if rotator != 1 && rotator != 2 {
		rotator = 1
	}
	reply, err := c.exchange("|h", hdrReplyLen)
	if err != nil {
		return Status{}, err
	}
	i := strings.Index(string(reply), "|h")
	if i < 0 || len(reply)-i < hdrReplyLen {
		return Status{}, fmt.Errorf("rotgenius: short |h reply (%d bytes)", len(reply))
	}
	p := reply[i:]
	// Per-rotator block base: rotator 1 at offset 4, rotator 2 at 4+34=38.
	base := 4
	if rotator == 2 {
		base = 38
	}
	// Within a rotator block: CurrentAzimuth@0, LimitCW@3, LimitCCW@6, Config@9,
	// Moving@10, Offset@11, TargetAzimuth@15, StartAzimuth@18, Limit@21, Name@22.
	cur := atoiField(string(p[base : base+3]))
	moving := atoiField(string(p[base+10 : base+11]))
	target := atoiField(string(p[base+15 : base+18]))
	st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
	if target != 999 {
		st.Target = target
	}
	return st, nil
}

// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
// accept, 'F' on reject.
func (c *Client) GoTo(rotator, az int) error {
	if rotator != 1 && rotator != 2 {
		rotator = 1
	}
	if az < 0 {
		az = 0
	}
	if az > 360 {
		az = 360
	}
	reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
	if err != nil {
		return err
	}
	return checkKF(reply, "GoTo")
}

// Stop halts all movement.
func (c *Client) Stop() error {
	reply, err := c.exchange("|S", 8)
	if err != nil {
		return err
	}
	return checkKF(reply, "Stop")
}

// CW / CCW nudge a rotator; it runs to its limit unless stopped.
func (c *Client) CW(rotator int) error  { return c.rotate('P', rotator) }
func (c *Client) CCW(rotator int) error { return c.rotate('M', rotator) }

func (c *Client) rotate(cmd byte, rotator int) error {
	if rotator != 1 && rotator != 2 {
		rotator = 1
	}
	reply, err := c.exchange(fmt.Sprintf("|%c%d", cmd, rotator), 8)
	if err != nil {
		return err
	}
	return checkKF(reply, string(cmd))
}

// checkKF reads the accept/reject status: 'K' ok, 'F' failed. The reply carries
// no other letters (the rest is the header + digits), so scanning for them is
// unambiguous.
func checkKF(reply []byte, what string) error {
	s := string(reply)
	if strings.ContainsRune(s, 'K') {
		return nil
	}
	if strings.ContainsRune(s, 'F') {
		return fmt.Errorf("rotgenius: %s rejected by the controller", what)
	}
	return fmt.Errorf("rotgenius: no reply to %s", what)
}
