// Package tunergenius drives a 4O3A Tuner Genius XL over its TCP text API
// (fixed port 9010 — the same port the device also uses for UDP discovery
// broadcasts). It's the same "Genius Series" line protocol as the PowerGenius
// XL / Antenna Genius: on connect the device sends a banner ("V1.1.8" or
// "V1.1.8 AUTH" when reached from outside the LAN); commands are
// "C<seq>|<command>\n" and replies are "R<seq>|<code>|<message>" (code 0 = OK).
// The status reply is pushed back as "S<seq>|status <k=v …>", and unsolicited
// "M|<message>" info/warning lines can arrive at any time.
//
// Protocol reference: 4O3A "TUNER GENIUS XL — PROTOCOL DESCRIPTION".
package tunergenius

import (
	"bufio"
	"fmt"
	"math"
	"net"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"hamlog/internal/applog"
)

const (
	// DefaultPort is fixed on the device (both the TCP control channel and the
	// UDP discovery broadcast use 9010).
	DefaultPort = 9010

	dialTimeout = 5 * time.Second
	ioTimeout   = 3 * time.Second
	// Poll fast so the meters track TX like the amplifier does (the amp's numbers
	// ride the real-time Flex UDP stream; the tuner is a synchronous TCP poll, so
	// a slow interval made its SWR/power lag noticeably behind).
	pollEvery = 400 * time.Millisecond
)

// Channel is the live state of one of the tuner's two RF channels (A / B). The
// Tuner Genius XL is a dual (SO2R) coupler, so each channel tracks its own
// source, band, frequency and antenna — mirroring the two rows the native 4O3A
// app shows.
type Channel struct {
	PTT     bool    `json:"ptt"`      // this channel is keyed
	Band    int     `json:"band"`     // band as reported by the device (0 = unknown)
	Mode    int     `json:"mode"`     // 0=RF Sense 1=FLEX 2=CAT 3=P2B 4=BCD
	ModeStr string  `json:"mode_str"` // human-readable mode/source
	Flex    string  `json:"flex"`     // bound Flex radio nickname (FLEX mode)
	FreqMHz float64 `json:"freq_mhz"` // current frequency
	Bypass  bool    `json:"bypass"`   // this channel bypassed
	Antenna int     `json:"antenna"`  // antenna in use (3WAY / SO2R-with-AG; 0 = n/a)
}

// Status is the snapshot the UI renders. Power/SWR come from the device's
// "status" reply; the booleans mirror the tuner's operating state.
type Status struct {
	Connected bool   `json:"connected"`
	Host      string `json:"host,omitempty"`
	LastError string `json:"last_error,omitempty"`

	FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
	FwdW   float64 `json:"fwd_w"`   // forward power [W], derived from dBm
	PeakW  float64 `json:"peak_w"`  // the device's own peak forward power [W] — what the meters show
	SwrDb  float64 `json:"swr_db"`  // return loss [dB] as reported (negative = good match)
	Vswr   float64 `json:"vswr"`    // VSWR ratio, derived from swr_db (1.0 = perfect)

	Operate  bool `json:"operate"`   // state == OPERATE (1) vs STANDBY (0)
	Bypass   bool `json:"bypass"`    // device global bypass engaged
	Tuning   bool `json:"tuning"`    // autotune in progress
	Active   int  `json:"active"`    // active channel (1 = A, 2 = B)
	ThreeWay bool `json:"three_way"` // 3-way (vs SO2R) hardware variant

	A Channel `json:"a"` // channel A
	B Channel `json:"b"` // channel B

	RelayC1 int `json:"relay_c1"` // tuner network position (0–255)
	RelayL  int `json:"relay_l"`
	RelayC2 int `json:"relay_c2"`

	// FreqMHz / Antenna mirror the ACTIVE channel, kept for the compact docked
	// widget that shows a single readout.
	FreqMHz float64 `json:"freq_mhz"`
	Antenna int     `json:"antenna"`

	Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared)
}

// modeName maps the device's numeric mode/source to a label.
func modeName(m int) string {
	switch m {
	case 0:
		return "RF Sense"
	case 1:
		return "Flex"
	case 2:
		return "CAT"
	case 3:
		return "P2B"
	case 4:
		return "BCD"
	default:
		return ""
	}
}

type Client struct {
	host     string
	port     int
	password string // remote-access code; sent as "auth <code>" when the banner announces AUTH

	mu     sync.Mutex // serialises command send/recv on the connection
	conn   net.Conn
	reader *bufio.Reader

	statusMu sync.RWMutex
	status   Status
	lastRaw  string // last raw status payload — logged on change to map fields against real hardware

	cmdID   atomic.Int64
	stop    chan struct{}
	running bool
}

func New(host string, port int, password string) *Client {
	if port <= 0 || port > 65535 {
		port = DefaultPort
	}
	return &Client{
		host:     host,
		port:     port,
		password: strings.TrimSpace(password),
		stop:     make(chan struct{}),
		status:   Status{Host: host},
	}
}

func (c *Client) Start() error {
	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()
	if c.conn != nil {
		c.conn.Close()
		c.conn = nil
		c.reader = nil
	}
	c.mu.Unlock()
}

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

func (c *Client) setStatus(fn func(*Status)) {
	c.statusMu.Lock()
	fn(&c.status)
	c.statusMu.Unlock()
}

// SetOperate puts the tuner in OPERATE (1) or STANDBY (0).
func (c *Client) SetOperate(on bool) error {
	if _, err := c.command("operate set=" + boolNum(on)); err != nil {
		return err
	}
	c.setStatus(func(s *Status) { s.Operate = on }) // optimistic; the poll confirms
	return nil
}

// SetBypass engages (1) or clears (0) the device's global bypass (antenna
// connected straight through, tuner out of line).
func (c *Client) SetBypass(on bool) error {
	if _, err := c.command("bypass set=" + boolNum(on)); err != nil {
		return err
	}
	c.setStatus(func(s *Status) { s.Bypass = on })
	return nil
}

// Autotune starts an automatic tuning cycle on the active channel. The rig must
// be keyed into a carrier for the tuner to measure SWR — the device asserts its
// own PTT OUT if "tune PTT" is enabled in its setup.
func (c *Client) Autotune() error {
	if _, err := c.command("autotune"); err != nil {
		return err
	}
	c.setStatus(func(s *Status) { s.Tuning = true }) // optimistic until the poll clears it
	return nil
}

// Activate selects the active channel. On SO2R hardware ch is 1 (A) or 2 (B);
// on the 3-way variant it selects the antenna (1/2/3).
func (c *Client) Activate(ch int) error {
	if ch < 1 {
		return fmt.Errorf("tunergenius: invalid channel %d", ch)
	}
	key := "ch"
	if c.GetStatus().ThreeWay {
		key = "ant"
	}
	_, err := c.command(fmt.Sprintf("activate %s=%d", key, ch))
	return err
}

func (c *Client) pollLoop() {
	t := time.NewTicker(pollEvery)
	defer t.Stop()
	// Outage bookkeeping. A dropped link used to be entirely silent: the poll just
	// set Connected=false and retried, so "did the tuner disconnect, or is the
	// meter simply reading a gap between syllables?" could not be answered from
	// the log. Now an outage logs once when it starts and once when it ends, with
	// how long it lasted.
	//
	// Once, not every retry: the poll comes round every 400 ms, and a tuner that
	// is switched off would otherwise bury the log. These are local to the one
	// goroutine that polls — ensureConnected has no other caller — so they need no
	// locking.
	var downSince time.Time
	for {
		select {
		case <-c.stop:
			return
		case <-t.C:
			fresh := false
			if c.needConnect() {
				if err := c.ensureConnected(); err != nil {
					if downSince.IsZero() {
						downSince = time.Now()
						applog.Printf("tunergenius: link DOWN — cannot reach %s:%d: %v (retrying)", c.host, c.port, err)
					}
					c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
					continue
				}
				fresh = true
				if !downSince.IsZero() {
					applog.Printf("tunergenius: link RESTORED after %s down", time.Since(downSince).Round(time.Second))
					downSince = time.Time{}
				}
			}
			// One-shot on a fresh link: learn the hardware variant (3-way vs SO2R).
			if fresh {
				_, _ = c.command("info")
			}
			if _, err := c.command("status"); err != nil {
				if downSince.IsZero() {
					downSince = time.Now()
					applog.Printf("tunergenius: link DOWN — poll failed: %v", err)
				}
				c.dropConn()
				c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
			}
		}
	}
}

// needConnect reports whether the TCP link is currently down (so the poll loop
// knows a fresh connect + one-shot info query is needed).
func (c *Client) needConnect() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.conn == nil
}

func (c *Client) ensureConnected() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn != nil {
		return nil
	}
	conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
	if err != nil {
		return err
	}
	c.conn = conn
	c.reader = bufio.NewReader(conn)
	// Banner: "V1.1.8" (LAN) or "V1.1.8 AUTH" (remote → authentication required).
	_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
	banner, _ := c.reader.ReadString('\n')
	banner = strings.TrimSpace(banner)
	applog.Printf("tunergenius: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
	if strings.Contains(banner, "AUTH") {
		if c.password == "" {
			applog.Printf("tunergenius: device requires AUTH but no remote code set (Settings → Tuner Genius)")
		} else if err := c.authLocked(); err != nil {
			c.conn.Close()
			c.conn, c.reader = nil, nil
			return err
		}
	}
	c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
	return nil
}

// authLocked sends "auth <code>" and checks the reply. Must be called with c.mu
// held (during ensureConnected). Note the device replies R<seq>|0|... for BOTH
// success ("auth OK") and failure ("Unauthorized"), so the message text — not
// the response code — decides.
func (c *Client) authLocked() error {
	id := c.cmdID.Add(1)
	_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
	if _, err := fmt.Fprintf(c.conn, "C%d|auth %s\n", id, c.password); err != nil {
		return err
	}
	_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
	line, err := c.reader.ReadString('\n')
	if err != nil {
		return err
	}
	line = strings.TrimSpace(line)
	if strings.Contains(strings.ToLower(line), "unauthorized") {
		return fmt.Errorf("tunergenius: authentication failed — check the remote code")
	}
	applog.Printf("tunergenius: authenticated")
	return nil
}

// command sends "C<id>|<cmd>\n" and returns the matching reply line, updating
// the status snapshot from whatever status/message lines arrive. Unsolicited
// "M|" info lines that precede the reply are consumed (they update Message).
func (c *Client) command(cmd string) (string, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn == nil || c.reader == nil {
		return "", fmt.Errorf("tunergenius: not connected")
	}
	id := c.cmdID.Add(1)
	_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
	if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil {
		return "", err
	}
	// Read until the command's reply (R…/S…); consume async M| lines along the way.
	for i := 0; i < 8; i++ {
		_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
		line, err := c.reader.ReadString('\n')
		if err != nil {
			return "", err
		}
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		c.parse(line)
		if line[0] == 'R' || line[0] == 'S' {
			return line, nil
		}
	}
	return "", fmt.Errorf("tunergenius: no reply to %q", cmd)
}

func (c *Client) dropConn() {
	c.mu.Lock()
	if c.conn != nil {
		c.conn.Close()
		c.conn = nil
		c.reader = nil
	}
	c.mu.Unlock()
}

// parse handles the three line shapes: "R<id>|<code>|<msg>", "S<id>|status …"
// and "M|<message>".
func (c *Client) parse(resp string) {
	// Async info/warning: "M|<message>" (empty message = cleared).
	if strings.HasPrefix(resp, "M|") {
		msg := strings.TrimSpace(strings.TrimPrefix(resp, "M|"))
		c.setStatus(func(s *Status) { s.Message = msg })
		return
	}
	var data string
	switch {
	case strings.HasPrefix(resp, "R"):
		p := strings.SplitN(resp, "|", 3)
		if len(p) < 3 {
			return
		}
		data = p[2]
	case strings.HasPrefix(resp, "S"):
		p := strings.SplitN(resp, "|", 2)
		if len(p) < 2 {
			return
		}
		data = p[1]
	default:
		return
	}
	// "info …" carries the hardware variant (3way=1 on the 3-way model, absent on
	// SO2R) — parsed once so the UI knows whether channels are A/B or antennas.
	if strings.HasPrefix(data, "info") {
		tw := strings.Contains(data, "3way=1")
		c.statusMu.Lock()
		c.status.ThreeWay = tw
		c.statusMu.Unlock()
		return
	}
	// Only the "status …" payload carries the live state we render.
	if !strings.HasPrefix(data, "status") {
		return
	}
	// One raw frame per session is enough to verify field alignment — the
	// frames carry live meter values, so "log on change" logged every frame.
	if c.lastRaw == "" {
		c.lastRaw = data
		applog.Printf("tunergenius: status raw=%q", data)
	}
	c.applyStatus(data)
}

// applyStatus maps the "status k=v …" fields onto the snapshot, filling both
// channels (A/B) plus the global power/SWR and operating state.
func (c *Client) applyStatus(data string) {
	kv := map[string]string{}
	for _, tok := range strings.Fields(data) {
		if p := strings.SplitN(tok, "=", 2); len(p) == 2 {
			kv[p[0]] = p[1]
		}
	}
	active := atoiDefault(kv["active"], 1)

	c.statusMu.Lock()
	defer c.statusMu.Unlock()
	c.status.Connected = true
	c.status.LastError = ""
	c.status.Active = active
	c.status.Operate = kv["state"] == "1"
	c.status.Bypass = kv["bypass"] == "1"
	c.status.Tuning = kv["tuning"] == "1"
	c.status.RelayC1 = atoiDefault(kv["relayC1"], 0)
	c.status.RelayL = atoiDefault(kv["relayL"], 0)
	c.status.RelayC2 = atoiDefault(kv["relayC2"], 0)

	if v, ok := parseFloat(kv["fwd"]); ok {
		c.status.FwdDbm = v
		c.status.FwdW = dbmToWatts(v)
	}
	// The device's OWN peak detector. A poll samples one instant of the envelope,
	// so on SSB the plain "fwd" figure lands between syllables as often as on a
	// peak — the widget read a few hundred watts on a kilowatt transmission. This
	// is the number to display.
	if v, ok := parseFloat(kv["peak"]); ok {
		c.status.PeakW = dbmToWatts(v)
	}
	if v, ok := parseFloat(kv["swr"]); ok {
		c.status.SwrDb = v
		c.status.Vswr = returnLossToVswr(v)
	}

	c.status.A = channelFrom(kv, "A")
	c.status.B = channelFrom(kv, "B")

	// Mirror the active channel into the flat fields the compact widget uses.
	act := c.status.A
	if active == 2 {
		act = c.status.B
	}
	c.status.FreqMHz = act.FreqMHz
	c.status.Antenna = act.Antenna
}

// channelFrom extracts one channel's fields (suffix "A" or "B") from the parsed
// status map.
func channelFrom(kv map[string]string, suffix string) Channel {
	mode := atoiDefault(kv["mode"+suffix], 0)
	freq, _ := parseFloat(kv["freq"+suffix])
	return Channel{
		PTT:     kv["ptt"+suffix] == "1",
		Band:    atoiDefault(kv["band"+suffix], 0),
		Mode:    mode,
		ModeStr: modeName(mode),
		Flex:    strings.TrimSpace(kv["flex"+suffix]),
		FreqMHz: freq,
		Bypass:  kv["bypass"+suffix] == "1",
		Antenna: atoiDefault(kv["ant"+suffix], 0),
	}
}

func boolNum(on bool) string {
	if on {
		return "1"
	}
	return "0"
}

func atoiDefault(s string, def int) int {
	if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
		return n
	}
	return def
}

func parseFloat(s string) (float64, bool) {
	if s == "" {
		return 0, false
	}
	v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
	return v, err == nil
}

// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW).
func dbmToWatts(dbm float64) float64 {
	return math.Pow(10, (dbm-30)/10)
}

// returnLossToVswr converts the device's "swr" field — a return loss in dB,
// reported as a negative number (e.g. -60 = an excellent 60 dB match) — into a
// conventional VSWR ratio. A near-zero return loss (bad match) yields a large
// VSWR; a large negative one yields ~1.0.
func returnLossToVswr(swrDb float64) float64 {
	rl := math.Abs(swrDb)
	rho := math.Pow(10, -rl/20) // reflection coefficient magnitude
	if rho >= 1 {
		return 99.9
	}
	vswr := (1 + rho) / (1 - rho)
	if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
		return 99.9
	}
	return vswr
}
