// Package tciserver shares OpsLog's CAT link with programs that speak TCI.
//
// It is the second half of internal/rigctld, and exists for the same reason:
// Windows gives a COM port to ONE process, so the moment OpsLog talks to the
// radio directly nothing else can. rigctld answers the programs that speak
// Hamlib NET rigctl (WSJT-X, JTDX, MSHV, Log4OM); this answers the ones built
// around Expert Electronics' TCI instead — and it answers them whatever radio
// is actually connected, because it sits on the same backend-agnostic
// interface. An operator with an Icom or a Yaesu can hand a TCI-only program a
// working rig.
//
// ── The protocol ──────────────────────────────────────────────────────────
// Text commands over a WebSocket, "name:arg,arg;", the same syntax in both
// directions. On connection the server sends a block of initialisation
// commands describing the device, ending with ready; and start;. Thereafter
// either side may send a control command, and the server echoes every change
// to all connected clients so they stay in step with each other.
//
//	vfo:0,0,14074000;      receiver 0, channel A (RX), Hz
//	vfo:0,1,14080000;      channel B — the TX frequency when split is on
//	modulation:0,usb;      mode
//	trx:0,true;            PTT
//	split_enable:0,true;   split
//	vfo:0,0;               a READ: the reply is the three-argument form
//
// Written against the official TCI Protocol document (ExpertSDR3/TCI, 12
// January 2024, MIT) — the initialisation set and the argument order of every
// command below are from §4.1 and §4.2, not from guesswork about what a client
// might accept.
package tciserver

import (
	"fmt"
	"net"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gorilla/websocket"
)

// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
// package stays testable without a radio and without importing internal/cat —
// which also keeps it building on every platform.
type Rig interface {
	Freq() int64          // TX frequency in Hz (ADIF sense), 0 if unknown
	RxFreq() int64        // RX frequency in Hz; equals Freq when not split
	Mode() string         // ADIF mode (SSB, CW, FT8…)
	Split() (bool, int64) // split on?, and the TX frequency
	SetFreq(hz int64) error
	SetMode(mode string) error
	SetPTT(on bool) error
	SetSplit(on bool, txHz int64) error
}

// DefaultPort is TCI's own default, which is what a client offers first.
const DefaultPort = 40001

// pollInterval is how often the rig is compared with what the clients were last
// told. TCI is an event protocol — a client is entitled to sit silent and be
// told when something moves — so this is the rate at which a knob turned on the
// radio reaches it.
const pollInterval = 250 * time.Millisecond

type Server struct {
	port int
	rig  Rig
	log  func(string, ...any)

	mu     sync.Mutex
	ln     net.Listener
	http   *http.Server
	conns  map[*client]struct{}
	closed bool

	// pendingTxHz is a transmit frequency a client set on channel B while the rig
	// was still simplex.
	//
	// It must be REMEMBERED, not discarded. A client working split sends two
	// commands and is free to send them in either order; when the frequency comes
	// first, throwing it away means the split is then armed on whatever the
	// transmit VFO happened to hold — the receive frequency — and the operator
	// transmits straight onto the DX while their software shows exactly what they
	// asked for. rigctld learned this the same way, and pairs set_split_vfo with
	// set_split_freq for the same reason.
	pendingTxHz int64

	// ptt mirrors the last PTT state a client commanded, so a repeat can be
	// recognised. A client is free to restate PTT as often as it likes, and one
	// does: through the rigctl server Nexus sent set_ptt 0 about sixteen times a
	// second, and the Flex's own "xmit 1" landed between two of them and was
	// overwritten inside a millisecond — a transmit request that simply did
	// nothing. The same radio sits behind this server.
	ptt      bool
	pttKnown bool

	// last is what the clients have been told, so only changes are sent. TCI
	// clients redraw on every command they receive; re-sending an unchanged
	// frequency four times a second makes a VFO readout flicker and, in some
	// clients, fights the operator's own tuning.
	last state
}

// state is the part of the rig the clients are kept in step with.
type state struct {
	rxHz  int64
	txHz  int64
	mode  string
	split bool
	valid bool
}

// clientLogCap bounds how many of one client's commands reach the log.
const clientLogCap = 200

// client is one connected program.
type client struct {
	conn *websocket.Conn
	mu   sync.Mutex // one writer at a time: gorilla panics on concurrent writes
	// logged counts what has been written to the log for this connection. Only
	// the reader goroutine touches it, so it needs no lock of its own.
	logged int
}

func (c *client) send(s string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn == nil {
		return nil // a client with no socket: the tests exercise the protocol, not the transport
	}
	_ = c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
	return c.conn.WriteMessage(websocket.TextMessage, []byte(s))
}

func New(port int, rig Rig, logf func(string, ...any)) *Server {
	if port <= 0 || port > 65535 {
		port = DefaultPort
	}
	if logf == nil {
		logf = func(string, ...any) {}
	}
	return &Server{port: port, rig: rig, log: logf, conns: map[*client]struct{}{}}
}

// Start binds the port and serves until Stop.
func (s *Server) Start() error {
	ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
	if err != nil {
		return fmt.Errorf("tci server: port %d: %w", s.port, err)
	}
	up := websocket.Upgrader{
		// Any origin: the clients are desktop programs on the same machine or
		// LAN, and they send whatever Origin their toolkit happens to set. This
		// is the same trust boundary as the rigctl server on 4532 — a plain TCP
		// port with no authentication, which is what every logger expects.
		CheckOrigin: func(*http.Request) bool { return true },
	}
	mux := http.NewServeMux()
	// Any path: clients connect to ws://host:port/ but some append a name.
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		conn, err := up.Upgrade(w, r, nil)
		if err != nil {
			s.log("tci server: upgrade from %s failed: %v", r.RemoteAddr, err)
			return
		}
		s.serve(&client{conn: conn}, r.RemoteAddr)
	})
	srv := &http.Server{Handler: mux}
	s.mu.Lock()
	s.ln, s.http, s.closed = ln, srv, false
	s.mu.Unlock()
	go func() { _ = srv.Serve(ln) }()
	go s.pushLoop()
	s.log("tci server: listening on :%d", s.port)
	return nil
}

// Stop closes the listener and every client.
// releasePTT drops a PTT this server asserted, and does it once.
//
// A client that dies mid-over — or a settings save that closes the server —
// leaves the rig keyed with nobody left to un-key it, into an amplifier that has
// no idea the transmission ended. rigctld has had this guard for a while (a K3
// once sat in transmit for 29 s until the CAT link happened to be rebuilt); the
// TCI server was written without it, so an operator who moved from Hamlib to TCI
// silently lost the protection.
//
// pttKnown is cleared whatever happens: after an emergency unkey the radio's
// state is a guess, and the next command must reach it rather than be dismissed
// as a repeat.
// Keyed reports whether a client currently has the rig transmitting. See the
// rigctld server's own Keyed for why the caller wants to know.
func (s *Server) Keyed() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.ptt
}

func (s *Server) releasePTT(why string) {
	s.mu.Lock()
	keyed := s.ptt
	s.ptt, s.pttKnown = false, false
	s.mu.Unlock()
	if !keyed {
		return
	}
	s.log("tci server: %s while the rig was keyed — dropping PTT", why)
	if err := s.rig.SetPTT(false); err != nil {
		s.log("tci server: emergency unkey FAILED: %v", err)
		return
	}
	// Any client still attached is told, so a second logger's transmit indicator
	// does not stay lit over a rig that is back in receive.
	s.broadcast("trx:0,false;")
}

func (s *Server) Stop() {
	// Before anything is torn down: the CAT backend is still up here, so an unkey
	// still lands. Same ordering as rigctld.Stop for the same reason.
	s.releasePTT("TCI server stopped")

	s.mu.Lock()
	if s.closed {
		s.mu.Unlock()
		return
	}
	s.closed = true
	ln, srv := s.ln, s.http
	conns := make([]*client, 0, len(s.conns))
	for c := range s.conns {
		conns = append(conns, c)
	}
	s.conns = map[*client]struct{}{}
	s.last = state{}
	s.mu.Unlock()
	for _, c := range conns {
		_ = c.conn.Close()
	}
	if srv != nil {
		_ = srv.Close()
	}
	if ln != nil {
		_ = ln.Close()
	}
	s.log("tci server: stopped")
}

// Clients reports how many programs are connected — the one thing an operator
// wants to know when a client says it cannot find the rig.
func (s *Server) Clients() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.conns)
}

// serve runs one connection: the initialisation block, then commands until it
// closes.
func (s *Server) serve(c *client, remote string) {
	s.mu.Lock()
	if s.closed {
		s.mu.Unlock()
		_ = c.conn.Close()
		return
	}
	s.conns[c] = struct{}{}
	s.mu.Unlock()
	s.log("tci server: %s connected", remote)

	for _, line := range s.initBlock() {
		if err := c.send(line); err != nil {
			break
		}
	}
	for {
		_, data, err := c.conn.ReadMessage()
		if err != nil {
			break
		}
		// One frame may carry several ";"-terminated commands.
		for _, cmd := range strings.Split(string(data), ";") {
			if cmd = strings.TrimSpace(cmd); cmd == "" {
				continue
			}
			// Every command the client sends, in the log.
			//
			// This is the only evidence there will ever be about a program on
			// someone else's machine: "MSHV's PTT test does nothing" is
			// unanswerable without knowing whether MSHV sent trx at all, and if
			// so in what form. Cheap, because TCI is event-driven — a client
			// speaks when the operator does something, not on a timer.
			//
			// Capped so a client that DOES poll cannot quietly fill the
			// operator's log; the cap says so once and then stays quiet.
			if c.logged < clientLogCap {
				c.logged++
				s.log("tci server: ← %s;", cmd)
			} else if c.logged == clientLogCap {
				c.logged++
				s.log("tci server: (further commands from this client are not logged)")
			}
			s.handle(c, cmd)
		}
	}
	s.mu.Lock()
	delete(s.conns, c)
	s.mu.Unlock()
	_ = c.conn.Close()
	s.log("tci server: %s disconnected", remote)
	// A client that walks away mid-over must not leave the rig transmitting.
	s.releasePTT("client " + remote + " left")
}

// initBlock is the initialisation set from §4.1 of the protocol document, in
// the documented order, followed by the current state so a client that has just
// connected shows the right frequency instead of waiting for the first change.
//
// A client will not proceed without these: they are how it learns the device
// exists, what it can do, and that the server has finished setting up.
func (s *Server) initBlock() []string {
	rx, tx, mode, split := s.read()
	return []string{
		"protocol:ExpertSDR3,1.9;",
		"device:OpsLog;",
		"receive_only:false;",
		"trx_count:1;",
		"channel_count:2;",
		// The whole HF/VHF/UHF span OpsLog itself works over. A client uses this
		// to bound its own tuning; too narrow a range and it refuses to follow the
		// rig onto 2 m.
		"vfo_limits:10000,470000000;",
		"if_limits:-48000,48000;",
		"modulations_list:am,sam,dsb,lsb,usb,cw,nfm,digl,digu;",
		"ready;",
		"start;",
		fmt.Sprintf("vfo:0,0,%d;", rx),
		fmt.Sprintf("vfo:0,1,%d;", tx),
		fmt.Sprintf("modulation:0,%s;", mode),
		fmt.Sprintf("split_enable:0,%t;", split),
		"trx:0,false;",
		// TRANSMIT PERMISSION, and it is not optional in practice.
		//
		// The document files TX_ENABLE under unidirectional control rather than
		// initialisation, but its own note says it is "sent to the client when
		// connected". A client that models permission — and one written for
		// ExpertSDR users has every reason to — starts out assuming it may NOT
		// transmit, and without this it never even tries: PTT does nothing and
		// the server never sees a trx command to refuse.
		//
		// Always true. OpsLog is not the thing that decides: the radio behind
		// whichever backend is connected does, and its refusal comes back through
		// SetPTT and into the log.
		"tx_enable:0,true;",
		fmt.Sprintf("tx_frequency:%d;", tx),
	}
}

// read takes one consistent snapshot of the rig in TCI's terms: channel A is
// where we LISTEN and channel B where we transmit, which is the opposite way
// round from ADIF's RigState and the one mistake here that would make a client
// transmit on the DX's frequency.
func (s *Server) read() (rxHz, txHz int64, mode string, split bool) {
	split, txHz = s.rig.Split()
	rxHz = s.rig.RxFreq()
	if !split {
		txHz = s.rig.Freq()
		if rxHz == 0 {
			rxHz = txHz
		}
	}
	if rxHz == 0 {
		rxHz = s.rig.Freq()
	}
	if txHz == 0 {
		txHz = rxHz
	}
	mode = adifToTCIMode(s.rig.Mode(), rxHz)
	return rxHz, txHz, mode, split
}

// pushLoop tells the clients what has changed on the radio.
func (s *Server) pushLoop() {
	t := time.NewTicker(pollInterval)
	defer t.Stop()
	for range t.C {
		s.mu.Lock()
		done := s.closed
		s.mu.Unlock()
		if done {
			return
		}
		s.publish()
	}
}

// publish sends only what moved. Returns the lines sent, for the tests.
func (s *Server) publish() []string {
	rx, tx, mode, split := s.read()
	cur := state{rxHz: rx, txHz: tx, mode: mode, split: split, valid: true}

	s.mu.Lock()
	prev := s.last
	s.last = cur
	s.mu.Unlock()

	var lines []string
	if !prev.valid || prev.rxHz != cur.rxHz {
		lines = append(lines, fmt.Sprintf("vfo:0,0,%d;", cur.rxHz))
	}
	if !prev.valid || prev.txHz != cur.txHz {
		lines = append(lines, fmt.Sprintf("vfo:0,1,%d;", cur.txHz))
		// The transmit frequency has its own command, which is what a client
		// showing "TX 14.080" reads. Channel B alone leaves that stale.
		lines = append(lines, fmt.Sprintf("tx_frequency:%d;", cur.txHz))
	}
	if (!prev.valid || prev.mode != cur.mode) && cur.mode != "" {
		lines = append(lines, fmt.Sprintf("modulation:0,%s;", cur.mode))
	}
	if !prev.valid || prev.split != cur.split {
		lines = append(lines, fmt.Sprintf("split_enable:0,%t;", cur.split))
	}
	for _, l := range lines {
		s.broadcast(l)
	}
	return lines
}

func (s *Server) broadcast(line string) {
	s.mu.Lock()
	conns := make([]*client, 0, len(s.conns))
	for c := range s.conns {
		conns = append(conns, c)
	}
	s.mu.Unlock()
	for _, c := range conns {
		_ = c.send(line)
	}
}

// handle answers one command from a client. Returns what was sent back, which
// is "" for a command that only acts on the radio.
//
// A command that SETS something is echoed to every client, not just answered to
// the one that sent it: the protocol document is explicit that the server
// synchronises all connected clients, and two loggers that disagree about the
// frequency are worse than one that is merely slow.
func (s *Server) handle(c *client, cmd string) string {
	name, args := cmd, ""
	if i := strings.IndexByte(cmd, ':'); i >= 0 {
		name, args = cmd[:i], cmd[i+1:]
	}
	f := strings.Split(args, ",")
	arg := func(i int) string {
		if i < len(f) {
			return strings.TrimSpace(f[i])
		}
		return ""
	}
	num := func(i int) int64 {
		v, _ := strconv.ParseInt(arg(i), 10, 64)
		return v
	}
	reply := func(line string) string {
		_ = c.send(line)
		return line
	}
	rx, tx, mode, split := s.read()

	switch strings.ToLower(strings.TrimSpace(name)) {
	case "vfo":
		// Read form: two arguments. Set form: three.
		if len(f) < 3 || arg(2) == "" {
			if arg(1) == "1" {
				return reply(fmt.Sprintf("vfo:0,1,%d;", tx))
			}
			return reply(fmt.Sprintf("vfo:0,0,%d;", rx))
		}
		hz := num(2)
		if hz <= 0 {
			return ""
		}
		if arg(1) == "1" {
			// Channel B is the transmit frequency. Setting it while simplex must
			// not move the rig's only VFO — the client asked to prepare a split
			// transmit frequency, not to QSY — but it must not be thrown away
			// either: it is where the split will be armed a moment from now.
			s.mu.Lock()
			s.pendingTxHz = hz
			s.mu.Unlock()
			if !split {
				s.broadcast(fmt.Sprintf("vfo:0,1,%d;", hz))
				return ""
			}
			if err := s.rig.SetSplit(true, hz); err != nil {
				s.log("tci server: split TX %d Hz refused: %v", hz, err)
				return ""
			}
		} else if err := s.rig.SetFreq(hz); err != nil {
			s.log("tci server: tune to %d Hz refused: %v", hz, err)
			return ""
		}
		s.broadcast(fmt.Sprintf("vfo:0,%s,%d;", orZero(arg(1)), hz))
		return ""

	case "modulation":
		if len(f) < 2 || arg(1) == "" {
			return reply(fmt.Sprintf("modulation:0,%s;", mode))
		}
		m := tciModeToADIF(arg(1))
		if m == "" {
			return ""
		}
		if err := s.rig.SetMode(m); err != nil {
			s.log("tci server: mode %s refused: %v", m, err)
			return ""
		}
		s.broadcast(fmt.Sprintf("modulation:0,%s;", strings.ToLower(arg(1))))
		return ""

	case "trx":
		if len(f) < 2 || arg(1) == "" {
			return reply("trx:0,false;")
		}
		on := strings.EqualFold(arg(1), "true")
		// Only touch the radio on a CHANGE — restating a state is not a request
		// to change it. The first command always goes through, since there is no
		// knowing how the radio was left.
		s.mu.Lock()
		known, prev := s.pttKnown, s.ptt
		s.mu.Unlock()
		if known && prev == on {
			s.broadcast(fmt.Sprintf("trx:0,%t;", on))
			return ""
		}
		if err := s.rig.SetPTT(on); err != nil {
			// The cache is stamped ONLY on success, and a failure clears "known"
			// outright so the NEXT command — whatever it is — reaches the radio.
			//
			// It used to be written before the radio was commanded and left in
			// place when the command failed. That is how a rig got stuck keyed for
			// good: the un-key failed on a lost CI-V acknowledgement, the cache
			// recorded "off" regardless, and from then on every trx:0,false was
			// dismissed as a repeat of a state the radio had never reached. Not
			// even reconnecting the client cleared it — this cache is per-server,
			// not per-connection — so the transmitter stayed keyed into the
			// amplifier until the operator switched the radio off. A cache must
			// never claim something the radio refused.
			s.mu.Lock()
			s.pttKnown = false
			s.mu.Unlock()
			s.log("tci server: PTT %v refused: %v", on, err)
			return ""
		}
		s.mu.Lock()
		s.ptt, s.pttKnown = on, true
		s.mu.Unlock()
		s.log("tci server: PTT %s", map[bool]string{true: "ON", false: "off"}[on])
		s.broadcast(fmt.Sprintf("trx:0,%t;", on))
		return ""

	case "split_enable":
		if len(f) < 2 || arg(1) == "" {
			return reply(fmt.Sprintf("split_enable:0,%t;", split))
		}
		on := strings.EqualFold(arg(1), "true")
		// Already in the state asked for? Then it is done, and nothing goes to
		// the radio. This is the lesson the rigctl server paid for: JTDX in "Fake
		// It" uses no split but still says so to be sure, and a backend that
		// cannot set split answered an error to a request that was already true.
		// JTDX read that as rig control failing and abandoned the transmission a
		// second into the frame. A refusal is only honest when something actually
		// needed doing.
		if on == split {
			s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
			return ""
		}
		// Arm on the frequency the client gave for channel B, which it is free to
		// have sent before this command rather than after.
		s.mu.Lock()
		pending := s.pendingTxHz
		s.mu.Unlock()
		txHz := tx
		if on && pending > 0 {
			txHz = pending
		}
		if err := s.rig.SetSplit(on, txHz); err != nil {
			// The refusal is the useful part: a backend that cannot split says
			// so, and the client can tell the operator instead of transmitting
			// on the wrong frequency believing all is well.
			s.log("tci server: split %v refused: %v", on, err)
			return ""
		}
		s.log("tci server: split %s, TX %d Hz", map[bool]string{true: "ON", false: "off"}[on], txHz)
		s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
		return ""

	case "dds":
		// The panorama's centre frequency. OpsLog has no panorama, so it answers
		// with the receive frequency — which is where a client draws its own.
		return reply(fmt.Sprintf("dds:0,%d;", rx))

	case "if":
		// Offset of the tuning filter inside the panorama: zero, since our "dds"
		// is the receive frequency itself.
		return reply("if:0,0,0;")

	case "start", "stop", "ready":
		return ""

	default:
		// Everything else — audio streams, CW macros, the E-Coder, the
		// panorama's own settings — belongs to a radio, not to a CAT link.
		// Silence rather than an error: a client sends these hopefully at
		// connect, and a refusal it did not ask for reads as a fault.
		return ""
	}
}

func orZero(s string) string {
	if s == "" {
		return "0"
	}
	return s
}

// adifToTCIMode maps an ADIF mode to a TCI modulation.
//
// SSB carries no sideband, so it is resolved from the frequency the way every
// operator does: below 10 MHz lower, above it upper. A client told "ssb" would
// not recognise it — the modulation list is the vocabulary.
func adifToTCIMode(mode string, hz int64) string {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "":
		return ""
	case "CW", "CWR":
		return "cw"
	case "USB":
		return "usb"
	case "LSB":
		return "lsb"
	case "SSB":
		if hz > 0 && hz < 10_000_000 {
			return "lsb"
		}
		return "usb"
	case "AM":
		return "am"
	case "FM", "NFM":
		return "nfm"
	case "RTTY":
		return "digl"
	}
	// Everything else is a data mode: FT8, FT4, JT65, PSK31, MSK144, VARA…
	// TCI has one pair for the whole family, and the sideband follows the same
	// rule the data modes themselves use — upper, but for the few HF corners
	// where LSB is conventional the radio is already there.
	return "digu"
}

// tciModeToADIF maps a TCI modulation back to an ADIF mode.
func tciModeToADIF(m string) string {
	switch strings.ToLower(strings.TrimSpace(m)) {
	case "cw":
		return "CW"
	case "usb":
		return "USB"
	case "lsb":
		return "LSB"
	case "am", "sam":
		return "AM"
	case "nfm", "fm", "wfm":
		return "FM"
	case "digl", "digu", "dsb", "drm":
		// The data family: the mode the operator is actually running (FT8, RTTY)
		// is chosen in OpsLog, and a client switching to "digital" must not
		// overwrite it with a guess. DATA is the honest ADIF answer.
		return "DATA"
	}
	return ""
}
