// Package spe drives the SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA amplifiers over
// their proprietary serial protocol (SPE "Application Programmer's Guide" rev 1.1).
// The amp is reached either directly over USB (a virtual COM port) or over TCP via
// an RS232-to-Ethernet bridge — both are just an io.ReadWriteCloser to this code.
//
// Wire format (host → amp):  0x55 0x55 0x55 | CNT | DATA… | CHK
//   CNT = number of DATA bytes, CHK = sum(DATA) mod 256.
// Status reply (amp → host): 0xAA 0xAA 0xAA | LEN | <LEN CSV bytes> | chk0 chk1 | CR LF
//   LEN is 0x43 (67); the payload is 19 comma-separated fixed fields.
//
// This MVP implements the two commands anchored by worked examples in the guide:
// OPERATE (0x0D, toggles STANDBY↔OPERATE) and STATUS (0x90). Other keystroke codes
// exist but the guide's command table did not extract unambiguously, so they are
// left out rather than risk sending the wrong key to the amplifier.
package spe

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

	"go.bug.st/serial"

	"hamlog/internal/applog"
)

const (
	cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
	cmdStatus  byte = 0x90 // request the status string

	// Best-guess keystroke codes reconstructed from the APG command table after
	// correcting the PDF's note-wrap misalignment (anchored to the confirmed
	// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
	// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
	// single physical POWER button. To be verified on hardware.
	cmdOff   byte = 0x0A // OFF key — switch the amplifier off
	cmdPower byte = 0x0B // POWER key — turn on / cycle output power level

	syncHost = 0x55
	syncAmp  = 0xAA

	dialTimeout = 5 * time.Second
	ioTimeout   = 3 * time.Second
	pollEvery   = 800 * time.Millisecond
)

// Status is the decoded amplifier state for the UI.
type Status struct {
	Connected  bool    `json:"connected"`
	LastError  string  `json:"last_error,omitempty"`
	Model      string  `json:"model,omitempty"`       // "20K" / "13K"
	Operate    bool    `json:"operate"`               // true = OPERATE, false = STANDBY
	TX         bool    `json:"tx"`                    // true = transmitting
	Input      string  `json:"input,omitempty"`       // "1" / "2"
	Band       string  `json:"band,omitempty"`        // raw 2-char band code
	PowerLevel string  `json:"power_level,omitempty"` // L / M / H
	OutputW    int     `json:"output_w"`
	SWRATU     float64 `json:"swr_atu"`
	SWRAnt     float64 `json:"swr_ant"`
	VoltPA     float64 `json:"volt_pa"`
	CurrPA     float64 `json:"curr_pa"`
	TempC      int     `json:"temp_c"` // heatsink (upper) temperature
	Warnings   string  `json:"warnings,omitempty"`
	Alarms     string  `json:"alarms,omitempty"`
}

// Config selects the transport.
type Config struct {
	Transport string // "serial" | "tcp"
	ComPort   string // serial
	Baud      int    // serial
	Host      string // tcp
	Port      int    // tcp
}

type Client struct {
	cfg Config

	mu   sync.Mutex // serialises access to the connection
	conn io.ReadWriteCloser
	r    *bufio.Reader

	statusMu sync.RWMutex
	status   Status
	lastRaw  string // last raw status payload logged (log only on change)

	stop    chan struct{}
	running bool
}

func New(cfg Config) *Client {
	if cfg.Baud <= 0 {
		cfg.Baud = 115200
	}
	return &Client{cfg: cfg, stop: make(chan struct{})}
}

func (c *Client) Start() error {
	if c.running {
		return nil
	}
	c.running = true
	go c.pollLoop()
	return nil
}

func (c *Client) Stop() {
	if !c.running {
		return
	}
	c.running = false
	close(c.stop)
	c.mu.Lock()
	c.dropLocked()
	c.mu.Unlock()
}

func (c *Client) GetStatus() Status {
	c.statusMu.RLock()
	defer c.statusMu.RUnlock()
	return c.status
}

func (c *Client) setErr(err error) {
	c.statusMu.Lock()
	c.status.Connected = false
	c.status.LastError = err.Error()
	c.statusMu.Unlock()
}

// Operate toggles the amplifier between STANDBY and OPERATE (the amp has a single
// OPERATE key that flips the state, so we send it only when the desired state
// differs from the last-read one).
func (c *Client) Operate(on bool) error {
	if c.GetStatus().Operate == on {
		return nil
	}
	return c.sendCmd(cmdOperate)
}

// ToggleOperate flips STANDBY/OPERATE unconditionally.
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }

// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
// the special SPE cable — so it only applies to the serial transport. Over TCP
// there is no DTR line and power-on isn't possible remotely.
func (c *Client) PowerOn() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	sp, ok := c.conn.(serial.Port)
	if !ok {
		return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
	}
	// A single positive pulse (~1.2s) on DTR, as the manual describes.
	if err := sp.SetDTR(true); err != nil {
		return err
	}
	time.Sleep(1200 * time.Millisecond)
	return sp.SetDTR(false)
}

// PowerOff presses the OFF key (switches the amp off).
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }

// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
// level before the next tap — the status is streamed, so we poll GetStatus rather
// than sleeping a fixed time. `level` is "L", "M" or "H" (case-insensitive).
func (c *Client) SetPowerLevel(level string) error {
	want := strings.ToUpper(strings.TrimSpace(level))
	if want == "" {
		return nil
	}
	// At most 3 taps to walk the 3-way L→M→H cycle around to the target.
	for i := 0; i < 3; i++ {
		if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) == want {
			return nil
		}
		prev := strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel))
		if err := c.sendCmd(cmdPower); err != nil {
			return err
		}
		// Wait (up to ~2s) for the streamed status to reflect the change.
		for w := 0; w < 20; w++ {
			time.Sleep(100 * time.Millisecond)
			if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) != prev {
				break
			}
		}
	}
	return nil
}

func (c *Client) pollLoop() {
	t := time.NewTicker(pollEvery)
	defer t.Stop()
	for {
		select {
		case <-c.stop:
			return
		case <-t.C:
			if err := c.ensureConn(); err != nil {
				c.setErr(fmt.Errorf("connect: %w", err))
				continue
			}
			// The amp streams status frames faster than one per poll, so a backlog
			// builds up and we'd read stale frames (the display lagged ~15s behind
			// the real amp). Flush anything pending, THEN request + read exactly one
			// fresh frame — the status we show is always current.
			c.drainInput()
			if err := c.sendCmd(cmdStatus); err != nil {
				c.mu.Lock()
				c.dropLocked()
				c.mu.Unlock()
				c.setErr(err)
				continue
			}
			c.readStatus()
		}
	}
}

func (c *Client) ensureConn() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn != nil {
		return nil
	}
	var rwc io.ReadWriteCloser
	var err error
	if c.cfg.Transport == "tcp" {
		var nc net.Conn
		nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
		rwc = nc
	} else {
		rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
	}
	if err != nil {
		return err
	}
	c.conn = rwc
	c.r = bufio.NewReader(rwc)
	return nil
}

func (c *Client) dropLocked() {
	if c.conn != nil {
		c.conn.Close()
		c.conn = nil
		c.r = nil
	}
}

// drainInput discards everything currently pending on the link (OS buffer + the
// bufio reader) so the next read returns a fresh frame rather than a queued stale
// one. Called before each status request.
func (c *Client) drainInput() {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn == nil {
		return
	}
	if sp, ok := c.conn.(serial.Port); ok {
		_ = sp.ResetInputBuffer()
	} else if nc, ok := c.conn.(net.Conn); ok {
		_ = nc.SetReadDeadline(time.Now().Add(15 * time.Millisecond))
		buf := make([]byte, 4096)
		for {
			n, err := nc.Read(buf)
			if n == 0 || err != nil {
				break
			}
		}
	}
	if c.r != nil {
		c.r.Reset(c.conn)
	}
}

// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
// the code itself.
func (c *Client) sendCmd(code byte) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn == nil {
		return fmt.Errorf("not connected")
	}
	if nc, ok := c.conn.(net.Conn); ok {
		_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
	}
	pkt := []byte{syncHost, syncHost, syncHost, 0x01, code, code}
	_, err := c.conn.Write(pkt)
	return err
}

// readStatus reads one amp packet and, when it's a status string, decodes it. ACK
// packets (short) are consumed and ignored.
func (c *Client) readStatus() {
	c.mu.Lock()
	r := c.r
	if nc, ok := c.conn.(net.Conn); ok && nc != nil {
		_ = nc.SetReadDeadline(time.Now().Add(ioTimeout))
	}
	c.mu.Unlock()
	if r == nil {
		return
	}
	// Sync on three 0xAA bytes.
	run := 0
	for run < 3 {
		b, err := r.ReadByte()
		if err != nil {
			c.mu.Lock()
			c.dropLocked()
			c.mu.Unlock()
			c.setErr(err)
			return
		}
		if b == syncAmp {
			run++
		} else {
			run = 0
		}
	}
	length, err := r.ReadByte()
	if err != nil {
		return
	}
	data := make([]byte, int(length))
	if _, err := io.ReadFull(r, data); err != nil {
		return
	}
	// Status strings are the long ones (LEN 0x43 = 67). Short packets are ACKs
	// (1 checksum byte, no CRLF) — nothing else to consume for those.
	if length >= 40 {
		// consume the 2 checksum bytes + CR LF
		_, _ = r.Discard(4)
		c.decodeCSV(string(data))
	} else {
		_, _ = r.Discard(1) // ACK checksum
	}
}

// decodeCSV parses the 19-field comma-separated status payload.
func (c *Client) decodeCSV(payload string) {
	f := strings.Split(payload, ",")
	get := func(i int) string {
		if i < len(f) {
			return strings.TrimSpace(f[i])
		}
		return ""
	}
	pf := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
	pi := func(s string) int { v, _ := strconv.Atoi(strings.TrimSpace(s)); return v }

	c.statusMu.Lock()
	defer c.statusMu.Unlock()
	// Log the raw status frame ONCE per change, so field alignment can be verified
	// against real hardware (the doc's 19-field layout may differ per firmware).
	if payload != c.lastRaw {
		c.lastRaw = payload
		applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
	}
	c.status.Connected = true
	c.status.LastError = ""
	// The real frame carries a leading empty field (it starts with a comma), so the
	// 19 documented fields live at indices 1..19, not 0..18. Verified against a live
	// 1.3K-FA: ",13K,S,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,".
	c.status.Model = get(1)
	c.status.Operate = get(2) == "O" // "O" = OPERATE, "S" = STANDBY
	c.status.TX = get(3) == "T"      // "T" = transmit, "R" = receive
	c.status.Input = get(5)
	c.status.Band = bandName(get(6))
	c.status.PowerLevel = get(9)
	c.status.OutputW = pi(get(10))
	c.status.SWRATU = pf(get(11))
	c.status.SWRAnt = pf(get(12))
	c.status.VoltPA = pf(get(13))
	c.status.CurrPA = pf(get(14))
	c.status.TempC = pi(get(15))
	if w := get(18); w != "" && w != "N" {
		c.status.Warnings = w
	} else {
		c.status.Warnings = ""
	}
	if a := get(19); a != "" && a != "N" {
		c.status.Alarms = a
	} else {
		c.status.Alarms = ""
	}
}

// bandName maps the SPE 2-digit band index to a human band label, falling back to
// the raw code for anything unrecognised.
func bandName(code string) string {
	// SPE band index, verified against a live 1.3K-FA: the status frame reports the
	// band as a 2-digit decimal string ordered by descending wavelength — 80m→"01"
	// and 20m→"05" were both confirmed on hardware (the printed manual's table was
	// off by one). 00=160m, 01=80m, 02=60m, 03=40m, 04=30m, 05=20m, 06=17m, 07=15m,
	// 08=12m, 09=10m, 10=6m.
	switch strings.TrimSpace(code) {
	case "00":
		return "160m"
	case "01":
		return "80m"
	case "02":
		return "60m"
	case "03":
		return "40m"
	case "04":
		return "30m"
	case "05":
		return "20m"
	case "06":
		return "17m"
	case "07":
		return "15m"
	case "08":
		return "12m"
	case "09":
		return "10m"
	case "10":
		return "6m"
	case "":
		return ""
	default:
		return code
	}
}
