// Package spid drives a SPID (AlfaSpid) rotator over its own serial protocol,
// Rot1Prog or Rot2Prog — the controllers sold as RAS, RAK, BIG-RAS/HR, MD-01
// and MD-02.
//
// It exists so an operator with SPID rotators does not need PstRotator running
// just to turn an antenna. Two towers with a controller each is the ordinary
// case; each one is a separate serial port and a separate rotor in OpsLog.
//
// # WIRE FORMAT
//
// Every command is 13 bytes:
//
//	 0    1  2  3  4    5    6  7  8  9    10   11  12
//	0x57 H1 H2 H3 H4   PH   V1 V2 V3 V4   PV   K  0x20
//
// K is the command: 0x0F stop, 0x1F status, 0x2F set.
//
// The DIGITS ARE ASCII in a command ('0'+d) and RAW BYTES in a reply (0..9).
// That asymmetry is the whole trap in this protocol: send raw digits and the
// controller ignores you, read them as ASCII and every heading is 48 degrees
// times a hundred out. It is pinned by the tests beside this file.
//
// PH and PV are the resolution in pulses per degree — 1, 2 or 4 — and are raw
// in both directions. The target is scaled by it:
//
//	u_az = PH × (360 + az)      and the four decimal digits of u_az are sent
//
// A reply is 12 bytes for Rot2Prog (azimuth and elevation) or 5 for Rot1Prog
// (azimuth only, three digits):
//
//	az = H1×100 + H2×10 + H3 + H4/10 − 360
//
// ROT1PROG IS THREE DIGITS IN BOTH DIRECTIONS. Its reply carries three, and so
// does its command — the controller reads the azimuth from offsets 1, 2 and 3,
// with no resolution scaling (it is one pulse per degree). Sending it the
// four-digit Rot2Prog form shifts every target by a decimal place: 90° goes out
// as "0450" and is read as 045, which is −315°, so every command turns the
// antenna nearly a full circle the wrong way. Found on a tower, not here.
//
// The 360 offset is what lets the controller report a rotator that has turned
// past north in either direction, which is the point of a pulse-counting
// rotator: −180…540 rather than 0…359.
//
// Serial is 8N1 at 600 baud for Rot2Prog and 1200 for Rot1Prog. Those are not
// typos — a pulse controller has nothing to say quickly.
//
// Verified against Hamlib's spid.c (rotators/spid/spid.c), which is the
// reference implementation, and SPID's published protocol note. NOT yet run
// against real hardware here; the tests pin the frames, the controller is the
// only thing that can confirm the rest.
package spid

import (
	"fmt"
	"strings"
	"sync"
	"time"

	"go.bug.st/serial"
)

// Model selects the dialect.
type Model string

const (
	Rot1Prog Model = "rot1prog" // azimuth only, 5-byte reply, 1200 baud
	Rot2Prog Model = "rot2prog" // azimuth + elevation, 12-byte reply, 600 baud
)

const (
	cmdStop   = 0x0F
	cmdStatus = 0x1F
	cmdSet    = 0x2F

	frameStart = 0x57
	frameEnd   = 0x20
)

// Client is one controller on one serial port.
//
// The port is opened per exchange rather than held: a rotator is polled every
// few seconds at most, and holding a COM port open for the life of the program
// is what stops an operator from using their controller's own software
// alongside — which they will want while they are still trusting this.
type Client struct {
	mu    sync.Mutex
	port  string
	baud  int
	model Model
	// resolution is pulses per degree: 1, 2 or 4. The controller is configured
	// for one of them and answers with it, so a wrong value here corrects itself
	// on the first status read.
	resolution byte
}

// New builds a client. baud 0 takes the model's documented default.
func New(comPort string, baud int, model Model) *Client {
	if model != Rot1Prog {
		model = Rot2Prog
	}
	if baud <= 0 {
		baud = 600
		if model == Rot1Prog {
			baud = 1200
		}
	}
	return &Client{port: strings.TrimSpace(comPort), baud: baud, model: model, resolution: 1}
}

// BuildStatus frames the "where are you" command.
func BuildStatus() []byte { return buildCmd(0, 0, 0, 0, cmdStatus) }

// BuildStop frames the "stop now" command.
func BuildStop() []byte { return buildCmd(0, 0, 0, 0, cmdStop) }

// BuildSet frames a target. resolution is the controller's pulses per degree.
//
// Azimuth is offset by 360 before scaling, so a target of −10° and one of 350°
// are different instructions: the first turns anticlockwise past north, the
// second does not. Feeding a 0…359 heading in is therefore always safe.
//
// ROT1PROG SENDS THREE DIGITS, NOT FOUR, and that is the whole reason this
// takes a model. Its reply is three digits — a 5-byte frame — and its command
// field matches: the controller reads the azimuth from offsets 1, 2 and 3.
//
// Sending the four-digit Rot2Prog form to one shifts every target by a decimal
// place. "0450" for 90° was read as 045, i.e. 45 − 360 = −315°, so every
// command became a near-full turn ANTICLOCKWISE whatever was asked for — 0°,
// 90°, 180°, all of them. That is exactly how it was reported from a tower:
// every heading wanted to go the wrong way round, and a "point to 0°" test that
// did nothing useful.
func BuildSet(az, el float64, resolution byte, model Model) []byte {
	if model == Rot1Prog {
		// No scaling: a Rot1Prog is one pulse per degree and reports resolution 1.
		return buildCmd3(int(360+az+0.5), int(360+el+0.5))
	}
	if resolution == 0 {
		resolution = 1
	}
	uaz := int(float64(resolution)*(360+az) + 0.5)
	uel := int(float64(resolution)*(360+el) + 0.5)
	return buildCmd(uaz, uel, resolution, resolution, cmdSet)
}

// buildCmd3 frames a Rot1Prog target: three ASCII digits per axis at the same
// offsets its replies use, the fourth digit position left as '0'.
func buildCmd3(uaz, uel int) []byte {
	c := make([]byte, 13)
	c[0] = frameStart
	c[1] = '0' + byte(uaz/100%10)
	c[2] = '0' + byte(uaz/10%10)
	c[3] = '0' + byte(uaz%10)
	c[4] = '0'
	c[5] = 0x01
	c[6] = '0' + byte(uel/100%10)
	c[7] = '0' + byte(uel/10%10)
	c[8] = '0' + byte(uel%10)
	c[9] = '0'
	c[10] = 0x01
	c[11] = cmdSet
	c[12] = frameEnd
	return c
}

func buildCmd(uaz, uel int, ph, pv byte, k byte) []byte {
	c := make([]byte, 13)
	c[0] = frameStart
	if k == cmdSet {
		c[1] = '0' + byte(uaz/1000%10)
		c[2] = '0' + byte(uaz/100%10)
		c[3] = '0' + byte(uaz/10%10)
		c[4] = '0' + byte(uaz%10)
		c[5] = ph
		c[6] = '0' + byte(uel/1000%10)
		c[7] = '0' + byte(uel/100%10)
		c[8] = '0' + byte(uel/10%10)
		c[9] = '0' + byte(uel%10)
		c[10] = pv
	}
	c[11] = k
	c[12] = frameEnd
	return c
}

// ParseStatus decodes a reply. Returns the azimuth, the elevation (0 for
// Rot1Prog) and the resolution the controller reported.
func ParseStatus(buf []byte, model Model) (az, el float64, resolution byte, err error) {
	want := 12
	if model == Rot1Prog {
		want = 5
	}
	if len(buf) < want {
		return 0, 0, 0, fmt.Errorf("spid: short reply (%d bytes, want %d)", len(buf), want)
	}
	if buf[0] != frameStart || buf[want-1] != frameEnd {
		return 0, 0, 0, fmt.Errorf("spid: not a reply frame: % X", buf[:want])
	}
	az = float64(buf[1])*100 + float64(buf[2])*10 + float64(buf[3])
	if model == Rot1Prog {
		return az - 360, 0, 1, nil
	}
	az += float64(buf[4]) / 10
	el = float64(buf[6])*100 + float64(buf[7])*10 + float64(buf[8]) + float64(buf[9])/10
	resolution = buf[5]
	if resolution == 0 {
		resolution = 1
	}
	return az - 360, el - 360, resolution, nil
}

// GoTo points the rotator at az (and el, on a Rot2Prog with elevation).
func (c *Client) GoTo(az int, el int) error {
	c.mu.Lock()
	res := c.resolution
	c.mu.Unlock()
	e := 0.0
	if el >= 0 && c.model == Rot2Prog {
		e = float64(el)
	}
	_, err := c.exchange(BuildSet(float64(az), e, res, c.model), 0)
	return err
}

// Stop interrupts a rotation in progress.
func (c *Client) Stop() error {
	// The controller answers a stop with its position, like a status — read it
	// so the reply does not sit in the buffer and get taken for the ANSWER to
	// the next poll, which would report a heading one command stale for ever.
	_, err := c.exchange(BuildStop(), c.replyLen())
	return err
}

// Heading reads the current position.
func (c *Client) Heading() (az int, el int, err error) {
	buf, err := c.exchange(BuildStatus(), c.replyLen())
	if err != nil {
		return 0, 0, err
	}
	a, e, res, err := ParseStatus(buf, c.model)
	if err != nil {
		return 0, 0, err
	}
	// Believe the controller about its own resolution: it is configured on the
	// front panel, and a wrong guess here would scale every target we send.
	c.mu.Lock()
	c.resolution = res
	c.mu.Unlock()
	return int(a + 0.5), int(e + 0.5), nil
}

func (c *Client) replyLen() int {
	if c.model == Rot1Prog {
		return 5
	}
	return 12
}

// exchange opens the port, writes one frame and reads the expected reply.
func (c *Client) exchange(cmd []byte, wantBytes int) ([]byte, error) {
	if c.port == "" {
		return nil, fmt.Errorf("spid: no serial port configured")
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	p, err := serial.Open(c.port, &serial.Mode{
		BaudRate: c.baud, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit,
	})
	if err != nil {
		return nil, fmt.Errorf("spid: open %s: %w", c.port, err)
	}
	defer p.Close()
	// 600 baud is 60 bytes a second: a 12-byte reply takes a fifth of a second
	// to arrive on the wire alone, before the controller has thought about it.
	_ = p.SetReadTimeout(2 * time.Second)
	if _, err := p.Write(cmd); err != nil {
		return nil, fmt.Errorf("spid: write: %w", err)
	}
	if wantBytes == 0 {
		return nil, nil
	}
	buf := make([]byte, 0, wantBytes)
	tmp := make([]byte, wantBytes)
	deadline := time.Now().Add(3 * time.Second)
	for len(buf) < wantBytes && time.Now().Before(deadline) {
		n, err := p.Read(tmp)
		if n > 0 {
			buf = append(buf, tmp[:n]...)
			continue
		}
		if err != nil {
			break
		}
	}
	if len(buf) < wantBytes {
		return nil, fmt.Errorf("spid: no reply from %s (%d of %d bytes) — check the port, the baud rate (%d) and that nothing else holds the controller",
			c.port, len(buf), wantBytes, c.baud)
	}
	return buf, nil
}
