// 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

	// From the official APG rev 1.1 command table. Note POWER does NOT switch a
	// sleeping amp on (that is the RTS/DTR wake, see PowerOn) — on a running amp
	// it cycles the output level L→M→H, which is all we use it for.
	cmdOff   byte = 0x0A // SWITCH OFF key — how PowerOff switches the amp off
	cmdPower byte = 0x0B // POWER key — cycles the output power level

	syncHost = 0x55
	syncAmp  = 0xAA

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

	// How long a modem line is held LOW before being raised again, to give the
	// amp's edge-triggered remote-on input a transition it can see.
	wakePulse = time.Second
)

// Status is the decoded amplifier state for the UI.
type Status struct {
	Connected  bool    `json:"connected"`
	Transport  string  `json:"transport"` // "serial" | "tcp" — the UI enables power-ON on serial even when asleep
	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)
	// offUntil suppresses status frames that were already in flight when the amp
	// was switched off. decodeCSV marks the client connected on every frame it
	// parses, and the poll goroutine can be mid-read while PowerOff runs — so the
	// frame from a second ago resurrected an amplifier the operator had just
	// switched off, and the console only caught up when they pressed OFF twice.
	//
	// Time-bounded rather than a latch: an amp switched back on at its own front
	// panel must reappear on its own.
	offUntil time.Time

	stop    chan struct{}
	running bool

	lastConnErr string // last connect failure logged (log only on change)
}

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()
	s := c.status
	if c.cfg.Transport == "tcp" {
		s.Transport = "tcp"
	} else {
		s.Transport = "serial"
	}
	return s
}

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 switches the amplifier on with a RISING EDGE on RTS, then on DTR.
//
// Confirmed on a live 1.3K-FA. The remote-on input is EDGE-triggered, which is
// why holding the lines high does nothing: a plain port open already leaves both
// high (the serial library's default), so there is no transition left to give.
// Hence low → pause → high on each line, in that order. The keystroke route is a
// dead end — a switched-off amp does not answer its UART at all, and the POWER
// key (0x0B) merely cycles L/M/H on an amp that is already running.
//
// Serial only: over TCP there are no modem lines. The port is (re)opened first,
// since the poll loop has dropped the connection to a sleeping amp long before
// the user clicks ON.
func (c *Client) PowerOn() error {
	if c.cfg.Transport == "tcp" {
		return fmt.Errorf("power-on needs the serial RTS/DTR lines; not available over a network bridge")
	}
	// On an amp that is already awake there is nothing to wake.
	if c.GetStatus().Connected {
		return nil
	}
	// Switching on cancels the suppression window: the operator wants frames
	// believed again, and waiting out a timer they cannot see would read as the
	// power-on having failed.
	c.statusMu.Lock()
	c.offUntil = time.Time{}
	c.statusMu.Unlock()
	// The poll goroutine may still be inside a blocking read on the old handle;
	// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
	// the open for a little longer than that.
	var err error
	deadline := time.Now().Add(ioTimeout + 3*time.Second)
	for {
		if err = c.ensureConn(); err == nil || time.Now().After(deadline) {
			break
		}
		time.Sleep(200 * time.Millisecond)
	}
	if err != nil {
		applog.Printf("spe: power ON failed — cannot open %s: %v", c.cfg.ComPort, err)
		return fmt.Errorf("power-on: %w", err)
	}
	c.mu.Lock()
	sp, ok := c.conn.(serial.Port)
	c.mu.Unlock()
	if !ok {
		return fmt.Errorf("power-on needs a serial connection")
	}
	pulse := func(set func(bool) error) error {
		if err := set(false); err != nil {
			return err
		}
		time.Sleep(wakePulse)
		return set(true)
	}
	// Each line reported separately. Not every USB-serial chip honours the modem
	// lines, and some refuse them without saying so — on an amplifier that never
	// wakes, "which of the two failed, or neither" is the whole diagnosis, and a
	// single combined error cannot give it.
	rtsErr := pulse(sp.SetRTS)
	dtrErr := pulse(sp.SetDTR)
	if err = rtsErr; err == nil {
		err = dtrErr
	}
	applog.Printf("spe: power ON pulse on %s (model=%q transport=%s) — RTS err=%v, DTR err=%v",
		c.cfg.ComPort, c.GetStatus().Model, c.cfg.Transport, rtsErr, dtrErr)
	// Booting, the amp re-enumerates its USB interface, which leaves this handle
	// pointing at a device that no longer exists — the amp came up and OpsLog
	// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
	// the port is back.
	c.mu.Lock()
	c.dropLocked()
	c.mu.Unlock()
	applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)

	// Say whether it actually woke. "The ON button does nothing" is a report with
	// three different causes — the pulse was refused, the pulse went out and the
	// amp ignored it, or the amp came up and the UI missed it — and only the log
	// can separate them. Watched off the caller's goroutine so the click returns
	// at once.
	go func() {
		deadline := time.Now().Add(15 * time.Second)
		for time.Now().Before(deadline) {
			time.Sleep(time.Second)
			if c.GetStatus().Connected {
				applog.Printf("spe: power ON — amp answered after %.0fs",
					15-time.Until(deadline).Seconds())
				return
			}
		}
		applog.Printf("spe: power ON — no answer within 15s on %s. The pulse was sent; "+
			"either this amplifier does not wake on RTS/DTR, or the adapter does not drive those lines",
			c.cfg.ComPort)
	}()
	return err
}

// PowerOff presses the SWITCH OFF key (0x0A) — the amp is running, so it hears
// its UART. Dropping DTR then RTS switches it off too (it is the mirror of the
// wake, and does work when done by hand), but back-to-back from one click it
// did not, while this keystroke has never once failed. The amp then stays off:
// the poll loop keeps reopening the port with both lines high and that has
// never woken it — only the deliberate low→high sequence in PowerOn does.
func (c *Client) PowerOff() error {
	// Close the window BEFORE the key goes out, so a frame already travelling up
	// the wire cannot land after setErr and undo it. Three seconds covers a poll
	// interval with room to spare; after that a real answer means the amp is
	// genuinely alive again, which is what happens if it is switched back on at
	// its own front panel.
	c.statusMu.Lock()
	c.offUntil = time.Now().Add(3 * time.Second)
	c.statusMu.Unlock()
	err := c.sendCmd(cmdOff)
	applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
	// Drop the link and mark the amp offline at once, exactly as a fresh start
	// would see it. The amp stops answering its UART once off, but the connection
	// that was already open BEFORE the off keystroke kept reading as "connected"
	// (stale/queued frames), so the UI never showed OFF and PowerOn — which only
	// wakes a DISCONNECTED amp — did nothing until the operator restarted OpsLog.
	// The poll loop reopens the port on its next tick; a plain reopen never wakes a
	// switched-off amp (only PowerOn's RTS/DTR edge does), so it stays offline.
	time.Sleep(100 * time.Millisecond) // let the 6-byte OFF command flush before we close
	c.mu.Lock()
	c.dropLocked()
	c.mu.Unlock()
	c.setErr(fmt.Errorf("switched off"))
	return err
}

// 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 {
				// Logged once per distinct message: "the amp is on but OpsLog
				// says offline" was impossible to diagnose without the reason.
				if msg := err.Error(); msg != c.lastConnErr {
					c.lastConnErr = msg
					applog.Printf("spe: connect %s failed: %s", c.cfg.ComPort, msg)
				}
				c.setErr(fmt.Errorf("connect: %w", err))
				continue
			}
			c.lastConnErr = ""
			// 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 {
		var sp serial.Port
		// Plain open: both modem lines come up high (the library's default), which
		// is what the interface needs to talk. That is not enough to switch the amp
		// on — the remote-on input wants the deliberate low→high sequence PowerOn
		// sends — so reconnecting never wakes an amplifier the operator switched
		// off, and forcing either line low here would switch a running one off.
		sp, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
		if err == nil {
			// Without this, a read on a sleeping amp blocks forever inside the
			// Windows serial driver — the poll goroutine survived the window and
			// the process took ~a minute to die.
			_ = sp.SetReadTimeout(ioTimeout)
			rwc = sp
		}
	}
	if err != nil {
		return err
	}
	c.conn = rwc
	if sp, ok := rwc.(serial.Port); ok {
		// The driver reports a read timeout as "0 bytes, no error", which bufio
		// spins on; surface it as a real error so the poll loop drops and retries.
		c.r = bufio.NewReader(serialTimeoutReader{sp})
	} else {
		c.r = bufio.NewReader(rwc)
	}
	return nil
}

// serialTimeoutReader converts the serial driver's timeout convention (n=0,
// err=nil once SetReadTimeout expires) into an explicit error, so a bufio
// reader fails fast instead of retrying an amp that is asleep.
type serialTimeoutReader struct{ p serial.Port }

func (s serialTimeoutReader) Read(b []byte) (int, error) {
	n, err := s.p.Read(b)
	if n == 0 && err == nil {
		return 0, fmt.Errorf("spe: read timeout")
	}
	return n, err
}

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()
	// One raw frame per session is enough to verify field alignment against real
	// hardware — the frame carries live meter values, so logging each change
	// meant logging nearly every frame.
	if c.lastRaw == "" {
		c.lastRaw = payload
		applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
	}
	if time.Now().Before(c.offUntil) {
		return // a frame from before the switch-off — the amp is on its way down
	}
	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
	}
}
