// Package catemu emulates a transceiver on a serial port so a device that
// POLLS a radio for its frequency can follow OpsLog instead.
//
// This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is
// the master — it polls the transceiver every few hundred milliseconds and
// changes band only when it gets a valid reply. There is no way to push a
// frequency to it, so following OpsLog means answering its polls.
//
// The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what
// Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is
// the simplest of the five sets by a wide margin, which is why the SDC utility
// uses it to steer an ACOM with no physical radio attached.
//
// Only the handful of commands an amp actually asks for are implemented:
//
//	FA;  → FA00014025000;   TX frequency, 11 digits, Hz
//	FB;  → same (sub VFO — amps poll it on some firmware)
//	IF;  → the 38-character TS-2000 status frame
//	ID;  → ID019;  (TS-2000 — a known model keeps the amp from timing out)
//
// Anything else is ignored rather than answered: a wrong-length reply is worse
// than none, because it desynchronises the amp's parser for the next poll.
package catemu

import (
	"fmt"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"go.bug.st/serial"
)

// Config is the serial port the amplifier's CAT/AUX cable is wired to. This is
// a SECOND port, independent of the one used for the amp's own remote/metering
// protocol — both run at the same time on an ACOM.
type Config struct {
	ComPort string
	Baud    int

	// BroadcastMs > 0 also sends the frequency UNPROMPTED every that many
	// milliseconds. Some amplifiers do not poll at all: they sit in parallel on
	// the radio↔PC CAT line and read whatever goes past. Such an amp would never
	// hear us, since answering polls means speaking only when spoken to.
	// 0 = answer polls only.
	BroadcastMs int
}

// Status is what the settings panel shows about the link.
type Status struct {
	Enabled   bool   `json:"enabled"`
	Connected bool   `json:"connected"`
	Port      string `json:"port"`
	Polls     int64  `json:"polls"`    // replies sent since start
	LastCmd   string `json:"last_cmd"` // last command received, e.g. "FA;"
	LastAt    string `json:"last_at"`  // RFC3339 of the last poll, "" if none
	FreqHz    int64  `json:"freq_hz"`  // what we are currently answering
	Error     string `json:"error"`    // last open/IO failure
}

// Server answers a polling amplifier on one serial port.
type Server struct {
	cfg Config

	mu     sync.Mutex
	port   serial.Port
	status Status

	freqHz atomic.Int64
	mode   atomic.Value // string, ADIF-ish ("CW", "USB"…)

	stop chan struct{}
	done chan struct{}

	logf func(string, ...any)
}

// New builds a server. Nothing is opened until Start.
func New(cfg Config, logf func(string, ...any)) *Server {
	if cfg.Baud <= 0 {
		cfg.Baud = 9600
	}
	s := &Server{cfg: cfg, logf: logf}
	s.mode.Store("")
	s.status.Port = cfg.ComPort
	return s
}

func (s *Server) log(format string, args ...any) {
	if s.logf != nil {
		s.logf(format, args...)
	}
}

// SetFrequency updates the frequency reported to the amplifier. Called from the
// CAT state callback; safe from any goroutine and never blocks — the serve loop
// reads the value when a poll arrives, so a fast-tuning VFO costs nothing.
func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) }

// SetMode updates the mode digit in the IF frame. Optional: the amp only cares
// about the frequency, but a coherent frame avoids odd firmware behaviour.
func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) }

// Start opens the port and serves polls until Stop. It returns immediately;
// a port that is missing or busy is retried every 5 s, because the amplifier is
// often powered on after the software.
func (s *Server) Start() {
	s.stop = make(chan struct{})
	s.done = make(chan struct{})
	go s.run()
}

// Stop closes the port and waits for the loop to end.
func (s *Server) Stop() {
	if s.stop == nil {
		return
	}
	close(s.stop)
	s.mu.Lock()
	if s.port != nil {
		_ = s.port.Close()
		s.port = nil
	}
	s.mu.Unlock()
	<-s.done
	s.stop = nil
}

// GetStatus returns a snapshot for the UI.
func (s *Server) GetStatus() Status {
	s.mu.Lock()
	defer s.mu.Unlock()
	st := s.status
	st.Enabled = true
	st.FreqHz = s.freqHz.Load()
	return st
}

func (s *Server) setErr(msg string) {
	s.mu.Lock()
	s.status.Error = msg
	s.status.Connected = false
	s.mu.Unlock()
}

func (s *Server) run() {
	defer close(s.done)
	for {
		select {
		case <-s.stop:
			return
		default:
		}
		if err := s.open(); err != nil {
			s.setErr(err.Error())
			s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err)
			select {
			case <-s.stop:
				return
			case <-time.After(5 * time.Second):
			}
			continue
		}
		s.serve()
	}
}

func (s *Server) open() error {
	if strings.TrimSpace(s.cfg.ComPort) == "" {
		return fmt.Errorf("no COM port configured")
	}
	p, err := serial.Open(s.cfg.ComPort, &serial.Mode{
		BaudRate: s.cfg.Baud,
		DataBits: 8,
		Parity:   serial.NoParity,
		StopBits: serial.OneStopBit,
	})
	if err != nil {
		return err
	}
	// A short read timeout keeps the loop responsive to Stop while idle: the amp
	// may poll only every few hundred ms, and a blocking read would hold the
	// port open past shutdown.
	_ = p.SetReadTimeout(200 * time.Millisecond)
	s.mu.Lock()
	s.port = p
	s.status.Connected = true
	s.status.Error = ""
	s.mu.Unlock()
	s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud)
	return nil
}

// broadcast sends an unsolicited FA frame at the configured interval, for an
// amplifier that listens to the CAT line rather than polling it. It stops when
// the port is closed or Stop is called.
func (s *Server) broadcast(stopServe <-chan struct{}) {
	if s.cfg.BroadcastMs <= 0 {
		return
	}
	// Below ~100 ms this is pure noise on the wire; the band only ever changes
	// at human speed.
	every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond
	if every < 100*time.Millisecond {
		every = 100 * time.Millisecond
	}
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-s.stop:
			return
		case <-stopServe:
			return
		case <-t.C:
			hz := s.freqHz.Load()
			if hz <= 0 {
				continue // nothing known yet — say nothing rather than "0 Hz"
			}
			s.mu.Lock()
			p := s.port
			s.mu.Unlock()
			if p == nil {
				return
			}
			if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil {
				s.setErr(err.Error())
				return
			}
		}
	}
}

// serve reads commands until the port fails or Stop is called.
func (s *Server) serve() {
	// The broadcaster shares this port and must die with it, or it would write
	// into a closed handle after a reopen.
	stopServe := make(chan struct{})
	defer close(stopServe)
	go s.broadcast(stopServe)

	buf := make([]byte, 64)
	var acc []byte
	for {
		select {
		case <-s.stop:
			return
		default:
		}
		s.mu.Lock()
		p := s.port
		s.mu.Unlock()
		if p == nil {
			return
		}
		n, err := p.Read(buf)
		if err != nil {
			s.setErr(err.Error())
			s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err)
			s.mu.Lock()
			if s.port != nil {
				_ = s.port.Close()
				s.port = nil
			}
			s.mu.Unlock()
			return
		}
		if n == 0 {
			continue
		}
		acc = append(acc, buf[:n]...)
		// Commands are ';'-terminated; handle every complete one in the buffer.
		for {
			i := indexByte(acc, ';')
			if i < 0 {
				break
			}
			cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i])))
			acc = acc[i+1:]
			s.handle(p, cmd)
		}
		// A runaway buffer means we are seeing something that is not this
		// protocol (wrong baud, or the amp's other port); drop it rather than
		// grow without bound.
		if len(acc) > 512 {
			acc = acc[:0]
		}
	}
}

func indexByte(b []byte, c byte) int {
	for i := range b {
		if b[i] == c {
			return i
		}
	}
	return -1
}

// handle answers one command. cmd has no trailing ';'.
func (s *Server) handle(p serial.Port, cmd string) {
	hz := s.freqHz.Load()
	var reply string
	switch {
	case cmd == "FA" || cmd == "FB":
		reply = fmt.Sprintf("%s%011d;", cmd, hz)
	case cmd == "IF":
		reply = s.ifFrame(hz)
	case cmd == "ID":
		reply = "ID019;" // TS-2000
	default:
		// Unknown or a SET command (FA00014025000;) — silently ignored: an amp
		// never sets our frequency, and answering the wrong length would break
		// its parser for the following poll.
		return
	}
	if _, err := p.Write([]byte(reply)); err != nil {
		s.setErr(err.Error())
		return
	}
	s.mu.Lock()
	s.status.Polls++
	s.status.LastCmd = cmd + ";"
	s.status.LastAt = time.Now().Format(time.RFC3339)
	s.mu.Unlock()
}

// modeDigit maps our mode name to the Kenwood mode digit used in IF.
func (s *Server) modeDigit() byte {
	m, _ := s.mode.Load().(string)
	switch {
	case strings.HasPrefix(m, "CW"):
		return '3'
	case strings.HasPrefix(m, "LSB"):
		return '1'
	case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"):
		return '2'
	case strings.HasPrefix(m, "FM"):
		return '4'
	case strings.HasPrefix(m, "AM"):
		return '5'
	case m == "RTTY", strings.HasPrefix(m, "FSK"):
		return '6'
	case m == "":
		return '2'
	default:
		// Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares.
		return '2'
	}
}

// ifFrame builds the 38-character TS-2000 IF status frame:
//
//	IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode |
//	FR | scan | split | tone | tone#(2) | shift | ;
//
// Only the frequency and mode carry meaning here; the rest is a valid, inert
// state (no RIT, receiving, simplex) so the amp's parser is satisfied.
func (s *Server) ifFrame(hz int64) string {
	return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;",
		hz,            // P1 frequency, Hz
		0,             // P2 frequency step
		0,             // P3 RIT/XIT offset, signed 5 digits
		0,             // P4-P6 RIT off, XIT off, channel-bank
		0,             // P7 memory channel
		0,             // P8 0 = RX
		s.modeDigit(), // P9 mode
		0,             // P10 VFO A
		0,             // P11 scan off
		0,             // P12 split off
		0,             // P13 tone off
		0,             // P14 tone number
		0,             // P15 shift
	)
}
