// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S /
// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by
// the ACOM-Controller project (bjornekelund, C#). The amp is reached either
// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet
// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE
// backend.
//
// Wire format (host → amp): fixed raw byte strings, validated by the same rule as
// telemetry (sum of ALL frame bytes ≡ 0 mod 256):
//
//	enable telemetry:  55 92 04 15
//	disable telemetry: 55 91 04 16
//	OPERATE:           55 81 08 02 00 06 00 1A
//	STANDBY:           55 81 08 02 00 05 00 1B
//	OFF (power down):  55 81 08 02 00 0A 00 16
//
// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS
// lines (the amp's remote power-on pins) — serial transport only, and only if the
// cable wires those pins (a telemetry-only cable uses just RX/TX/GND).
//
// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them
// permanently blocks the amplifier's front-panel power button.
//
// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting
// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are
// decoded in decodeFrame below.
package acom

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

	"go.bug.st/serial"

	"hamlog/internal/applog"
)

var (
	cmdEnableTelemetry  = []byte{0x55, 0x92, 0x04, 0x15}
	cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16}
	cmdOperate          = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A}
	cmdStandby          = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B}
	cmdOff              = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16}
)

const (
	frameLen    = 72
	sync0       = 0x55
	sync1       = 0x2F
	dialTimeout = 5 * time.Second
	ioTimeout   = 3 * time.Second
	// The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid
	// arrives for this long the link (or the amp) is considered down.
	staleAfter = 5 * time.Second
)

// model carries the per-model constants: the temperature word offset and the
// nominal/max forward power (for UI bar scaling).
type model struct {
	Name       string
	TempOffset int
	NominalW   int
	MaxW       int
}

var models = map[string]model{
	"500S":  {"500S", 282, 500, 600},
	"600S":  {"600S", 273, 600, 700},
	"700S":  {"700S", 282, 700, 800},
	"1200S": {"1200S", 281, 1200, 1400},
	"2020S": {"2020S", 282, 1800, 2000},
}

// paStatusNames maps the PAstatus nibble to a display string.
// 8 is absent from the state list the ACOM-Controller project recovered; it is
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
var paStatusNames = map[int]string{
	1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
	5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
}

// acomBands maps the band nibble to a band label.
var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"}

// errText translates the error code (frame byte 66) shown on the amp's display.
// 0xFF means NO error — everything else, including 0x00, is a fault/warning.
func errText(code int) string {
	switch code {
	case 0xFF:
		return ""
	case 0x00, 0x08:
		return "Hot switching"
	case 0x03:
		return "Drive power at wrong time"
	case 0x04, 0x05:
		return "Reflected power warning"
	case 0x06, 0x07:
		return "Drive power too high"
	case 0x0C:
		return "RF power at wrong time"
	case 0x0E:
		return "Stop transmission first"
	case 0x0F:
		return "Remove drive power"
	case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59:
		return "Excessive PAM current"
	case 0x70:
		return "CAT error"
	default:
		return "ERROR — see display"
	}
}

// Status is the decoded amplifier state for the UI.
type Status struct {
	Connected bool    `json:"connected"` // valid telemetry is flowing
	PortOpen  bool    `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off
	Transport string  `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line)
	LastError string  `json:"last_error,omitempty"`
	Model     string  `json:"model,omitempty"`
	State     string  `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / …
	Operate   bool    `json:"operate"`         // RECEIVE or TRANSMIT (vs STANDBY)
	TX        bool    `json:"tx"`
	FwdW      int     `json:"fwd_w"`  // forward/output power
	ReflW     int     `json:"refl_w"` // reflected power
	SWR       float64 `json:"swr"`
	DriveW    int     `json:"drive_w"`
	DCPowerW  int     `json:"dc_w"`
	TempC     int     `json:"temp_c"` // PA temperature
	Band      string  `json:"band,omitempty"`
	FanLevel  int     `json:"fan"`                // 1..4
	ErrCode   int     `json:"err_code"`           // raw code from the frame; 0xFF = none
	ErrText   string  `json:"err_text,omitempty"` // human message; empty = no error
	NominalW  int     `json:"nominal_w"`
	MaxW      int     `json:"max_w"`
}

// Config selects the transport and amplifier model.
type Config struct {
	Model     string // "500S" | "600S" | "700S" | "1200S" | "2020S"
	Transport string // "serial" | "tcp"
	ComPort   string // serial
	Baud      int    // serial (the amp is fixed 9600 8N1)
	Host      string // tcp (RS232-to-Ethernet bridge)
	Port      int    // tcp
}

type Client struct {
	cfg Config
	mdl model

	mu   sync.Mutex // serialises access to the connection
	conn io.ReadWriteCloser

	statusMu sync.RWMutex
	status   Status

	// Diagnostics: raw byte count + first-bytes capture, so a hardware session log
	// tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid
	// frame" (framing/checksum) without a serial sniffer.
	rawSeen     int64
	dbgBytes    []byte
	dbgLogged   bool
	ckFails     int
	frameLogged bool

	stop    chan struct{}
	running bool
}

func New(cfg Config) *Client {
	if cfg.Baud <= 0 {
		cfg.Baud = 9600
	}
	mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))]
	if !ok {
		mdl = models["700S"]
	}
	c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})}
	c.status.Model = mdl.Name
	c.status.Transport = cfg.Transport
	c.status.NominalW = mdl.NominalW
	c.status.MaxW = mdl.MaxW
	return c
}

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

func (c *Client) Stop() {
	if !c.running {
		return
	}
	c.running = false
	close(c.stop)
	c.mu.Lock()
	if c.conn != nil {
		_, _ = c.conn.Write(cmdDisableTelemetry) // best effort: stop the stream
	}
	c.dropLocked()
	c.mu.Unlock()
}

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

func (c *Client) setErr(msg string) {
	c.statusMu.Lock()
	c.status.Connected = false
	c.status.LastError = msg
	c.statusMu.Unlock()
}

// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's
// single toggle key, the ACOM protocol has explicit commands for each state.
func (c *Client) Operate(on bool) error {
	if on {
		return c.send(cmdOperate)
	}
	return c.send(cmdStandby)
}

// PowerOff sends the power-down command (same as pressing OFF on the amp).
func (c *Client) PowerOff() error { return c.send(cmdOff) }

// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired
// like a press of the front-panel power button. Hardware line ⇒ serial transport
// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have
// those pins connected. Pulse length to be confirmed on real hardware.
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/RTS lines — not available over a network bridge")
	}
	if err := sp.SetDTR(true); err != nil {
		return err
	}
	if err := sp.SetRTS(true); err != nil {
		_ = sp.SetDTR(false)
		return err
	}
	time.Sleep(2500 * time.Millisecond)
	err1 := sp.SetDTR(false)
	err2 := sp.SetRTS(false)
	if err1 != nil {
		return err1
	}
	return err2
}

func (c *Client) send(cmd []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))
	}
	_, err := c.conn.Write(cmd)
	return err
}

// readLoop keeps the link up and consumes the telemetry stream. When no valid
// frame arrives for a while it re-arms telemetry (the amp forgets the enable
// across a power cycle) and, on TCP, reconnects. A serial port is kept open even
// with the amp off, so the DTR power-on pulse stays available.
func (c *Client) readLoop() {
	var lastFrame time.Time
	var lastEnable time.Time
	for {
		select {
		case <-c.stop:
			return
		default:
		}
		if err := c.ensureConn(); err != nil {
			c.setErr("connect: " + err.Error())
			select {
			case <-c.stop:
				return
			case <-time.After(2 * time.Second):
			}
			continue
		}
		frame, err := c.readFrame()
		now := time.Now()
		if err == nil {
			lastFrame = now
			if !c.frameLogged {
				c.frameLogged = true
				applog.Printf("acom: telemetry up — first valid frame received")
			}
			c.decodeFrame(frame)
			continue
		}
		// No valid frame. Re-send the telemetry enable briskly — there is no way to
		// know whether the amp has it on (the reference controller re-sends every
		// 200ms until frames flow), and it revives the stream after a power cycle.
		if now.Sub(lastEnable) >= 500*time.Millisecond {
			lastEnable = now
			_ = c.send(cmdEnableTelemetry)
		}
		if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter {
			if c.cfg.Transport == "tcp" {
				// The bridge socket may be dead — force a reconnect.
				c.mu.Lock()
				c.dropLocked()
				c.mu.Unlock()
			}
			if prev := c.GetStatus(); prev.Connected || prev.LastError == "" {
				applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails)
			}
			c.setErr("no telemetry — amplifier off or cable/bridge issue")
		}
	}
}

func (c *Client) ensureConn() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn != nil {
		return nil
	}
	if c.cfg.Transport == "tcp" {
		nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
		if err != nil {
			return err
		}
		c.conn = nc
	} else {
		sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
		if err != nil {
			return err
		}
		// CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while
		// asserted the amp's front-panel power button is blocked (they are its
		// remote power-on lines).
		_ = sp.SetDTR(false)
		_ = sp.SetRTS(false)
		// Short read timeout so the frame reader can poll the stop channel and
		// detect staleness rather than blocking forever.
		_ = sp.SetReadTimeout(200 * time.Millisecond)
		c.conn = sp
	}
	c.statusMu.Lock()
	c.status.PortOpen = true
	c.statusMu.Unlock()
	applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport)
	_, _ = c.conn.Write(cmdEnableTelemetry)
	return nil
}

func (c *Client) dropLocked() {
	if c.conn != nil {
		c.conn.Close()
		c.conn = nil
	}
	c.statusMu.Lock()
	c.status.PortOpen = false
	c.statusMu.Unlock()
}

// readByte reads a single byte, honouring the transport's short timeout. A serial
// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error
// here so callers can distinguish "no data yet".
var errNoData = fmt.Errorf("no data")

func (c *Client) readByte(deadline time.Time) (byte, error) {
	c.mu.Lock()
	conn := c.conn
	c.mu.Unlock()
	if conn == nil {
		return 0, fmt.Errorf("not connected")
	}
	buf := make([]byte, 1)
	for {
		if nc, ok := conn.(net.Conn); ok {
			_ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
		}
		n, err := conn.Read(buf)
		if n == 1 {
			c.rawSeen++
			// Capture the first 144 raw bytes (~2 frames) once, so a session log shows
			// what the wire actually carries when frames won't validate.
			if !c.dbgLogged {
				c.dbgBytes = append(c.dbgBytes, buf[0])
				if len(c.dbgBytes) >= 144 {
					c.dbgLogged = true
					applog.Printf("acom: first raw bytes: % X", c.dbgBytes)
					c.dbgBytes = nil
				}
			}
			return buf[0], nil
		}
		if err != nil {
			if ne, ok := err.(net.Error); ok && ne.Timeout() {
				err = nil // treat like the serial short-timeout: just no data yet
			} else {
				return 0, err
			}
		}
		select {
		case <-c.stop:
			return 0, fmt.Errorf("stopped")
		default:
		}
		if time.Now().After(deadline) {
			return 0, errNoData
		}
	}
}

// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies
// the mod-256 checksum (sum of ALL 72 bytes ≡ 0).
func (c *Client) readFrame() ([]byte, error) {
	deadline := time.Now().Add(ioTimeout)
	// Sync: hunt for 0x55 followed by 0x2F.
	for {
		b, err := c.readByte(deadline)
		if err != nil {
			return nil, err
		}
		if b != sync0 {
			continue
		}
		b2, err := c.readByte(deadline)
		if err != nil {
			return nil, err
		}
		if b2 == sync1 {
			break
		}
	}
	frame := make([]byte, frameLen)
	frame[0], frame[1] = sync0, sync1
	for i := 2; i < frameLen; i++ {
		b, err := c.readByte(deadline)
		if err != nil {
			return nil, err
		}
		frame[i] = b
	}
	var sum int
	for _, b := range frame {
		sum += int(b)
	}
	if sum%256 != 0 {
		c.ckFails++
		if c.ckFails <= 3 {
			applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame)
		}
		return nil, fmt.Errorf("bad checksum")
	}
	return frame, nil
}

// decodeFrame extracts the telemetry fields (see the package comment for the
// reverse-engineered layout; 16-bit values are little-endian lo + hi*256).
func (c *Client) decodeFrame(f []byte) {
	u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 }
	paStatus := int(f[3]&0xF0) >> 4
	state := paStatusNames[paStatus]
	if state == "" {
		state = fmt.Sprintf("?%d", paStatus)
	}
	bandIdx := int(f[69] & 0x0F)
	band := ""
	if bandIdx > 0 && bandIdx < len(acomBands) {
		band = acomBands[bandIdx]
	}

	c.statusMu.Lock()
	defer c.statusMu.Unlock()
	c.status.Connected = true
	c.status.LastError = ""
	c.status.State = state
	c.status.Operate = paStatus == 6 || paStatus == 7
	c.status.TX = paStatus == 7
	c.status.DCPowerW = u16(8) / 10
	c.status.TempC = u16(16) - c.mdl.TempOffset
	c.status.DriveW = u16(20)
	c.status.FwdW = u16(22)
	c.status.ReflW = u16(24)
	c.status.SWR = float64(u16(26)) / 100
	c.status.ErrCode = int(f[66])
	c.status.ErrText = errText(int(f[66]))
	c.status.Band = band
	c.status.FanLevel = int(f[69]&0xF0) >> 4
}
