// Package powergenius drives a 4O3A PowerGenius XL amplifier over its TCP text
// API (same "Genius Series" line protocol as the Antenna Genius). OpsLog reads
// the amp's operate state via the FlexRadio amplifier object, but the fan mode
// is a PGXL-only setting only reachable on the amp's own control port — hence
// this small direct client. Commands are "C<id>|<cmd>\n"; replies are
// "R<id>|0|<k=v …>" and asynchronous "S0|<k=v …>".
package powergenius

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

	"hamlog/internal/applog"
)

const (
	defaultPort = 9008
	dialTimeout = 5 * time.Second
	ioTimeout   = 3 * time.Second
	// Poll fast enough that the amp's OWN forward/current figures make a usable
	// live meter on their own — the UI prefers them over the FlexRadio VITA stream
	// (which never traverses a public-IP/NAT link), so this direct reading is what
	// an operator watches when running the amp over the internet. At 250 ms the
	// SSB envelope is sampled often enough that peak-hold keeps a steady reading
	// instead of collapsing to ~1 W in the gaps between syllables.
	pollEvery      = 250 * time.Millisecond
	reconnectDelay = 2 * time.Second
)

// Status is the snapshot the UI renders (only the bits OpsLog needs).
type Status struct {
	Connected   bool    `json:"connected"`
	Host        string  `json:"host,omitempty"`
	LastError   string  `json:"last_error,omitempty"`
	State       string  `json:"state,omitempty"`    // IDLE / TRANSMIT_A …
	FanMode     string  `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
	Temperature float64 `json:"temperature"`
	Operate     bool    `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)

	// Live power, read straight from the amplifier's own status frame rather
	// than sampled off the FlexRadio meter stream. PeakW is the amp's own peak
	// detector: a poll catches one instant of the envelope, so the plain forward
	// figure lands between syllables as often as on a peak.
	FwdW   float64 `json:"fwd_w"`   // forward power [W] (the frame reports dBm)
	PeakW  float64 `json:"peak_w"`  // peak forward power [W]
	Vswr   float64 `json:"vswr"`    // VSWR, derived from the frame's return loss in dB
	Id     float64 `json:"id"`      // drain current [A]
	PeakId float64 `json:"peak_id"` // peak drain current [A]
}

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 so unknown fields (operate?) can be mapped from a real amp
	// Optimistic fan mode kept until the amp's status poll confirms it (or it
	// ages out) — otherwise a stale poll right after a change reverts the UI.
	fanPending   string
	fanPendingAt time.Time

	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()
}

// SetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
func (c *Client) SetFanMode(mode string) error {
	m := strings.ToUpper(strings.TrimSpace(mode))
	switch m {
	case "STANDARD", "CONTEST", "BROADCAST":
	default:
		return fmt.Errorf("powergenius: invalid fan mode %q", mode)
	}
	// The verb the amp wants is "setup fanmode=VALUE" — confirmed LIVE: with the
	// "setup " prefix the amp replies code 0 (accepted), while the bare
	// "fanmode=VALUE" we switched to earlier is rejected (reply code 0x50000015).
	// That regression is what stopped the fan from changing; restoring "setup "
	// fixes it.
	reply, err := c.command("setup fanmode=" + m)
	if err != nil {
		return err
	}
	if code := replyCode(reply); code != "" && code != "0" {
		applog.Printf("pgxl: set fanmode=%s REJECTED, reply=%q", m, reply)
		return fmt.Errorf("powergenius: amp rejected fanmode=%s (code %s)", m, code)
	}
	applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
	c.statusMu.Lock()
	c.status.FanMode = m // optimistic
	c.fanPending, c.fanPendingAt = m, time.Now()
	c.statusMu.Unlock()
	return nil
}

// replyCode returns the result code from an "R<id>|<code>|…" reply — "0" means
// the amp accepted the command. Returns "" when the line isn't an R reply.
func replyCode(reply string) string {
	if !strings.HasPrefix(reply, "R") {
		return ""
	}
	p := strings.SplitN(reply, "|", 3)
	if len(p) < 2 {
		return ""
	}
	return strings.TrimSpace(p[1])
}

// operateFromState maps the amplifier's live state to "in line or not".
//
// Unknown states leave the flag alone rather than guessing: a state nobody has
// seen is not evidence that the amp is standing by, and claiming STANDBY on an
// amplifier that is in line is the error that matters here — it invites the
// operator to "switch it on" and command the state it is already in.
func operateFromState(state string) (operate, known bool) {
	switch strings.ToUpper(strings.TrimSpace(state)) {
	case "STANDBY", "OFF", "POWERED_OFF", "DISCONNECTED":
		return false, true
	case "OPERATE", "OPERATING", "IDLE", "RECEIVE", "RX", "TRANSMIT", "TRANSMIT_A", "TRANSMIT_B", "TX", "KEYED":
		return true, true
	}
	return false, false
}

// SetOperate puts the amp in OPERATE (1) or STANDBY (0).
func (c *Client) SetOperate(on bool) error {
	v := "0"
	if on {
		v = "1"
	}
	if _, err := c.command("operate=" + v); err != nil {
		return err
	}
	// Optimistic: the status poll's "operate" field (when the firmware reports
	// one) confirms or corrects this.
	c.statusMu.Lock()
	c.status.Operate = on
	c.statusMu.Unlock()
	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.ensureConnected(); err != nil {
				c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
				continue
			}
			if _, err := c.command("status"); err != nil {
				c.dropConn()
				c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
			}
		}
	}
}

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: "V…" (LAN) or "V… AUTH" (remote → authentication required, exactly
	// like the Tuner Genius / Antenna Genius). Send the remote code when the amp
	// demands it, otherwise every command comes back "Unauthorized".
	_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
	banner, _ := c.reader.ReadString('\n')
	banner = strings.TrimSpace(banner)
	applog.Printf("pgxl: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
	if strings.Contains(banner, "AUTH") {
		if c.password == "" {
			applog.Printf("pgxl: device requires AUTH but no remote code set (Settings → Amplifier)")
		} else if err := c.authLocked(); err != nil {
			c.conn.Close()
			c.conn, c.reader = nil, nil
			return err
		}
	}
	return nil
}

// authLocked authenticates the remote link. Must be called with c.mu held
// (during ensureConnected). 4O3A boxes want "auth code=<pw>" and reply
// "R<seq>|<hex>|" with an EMPTY message — hex "0" means accepted, so the response
// CODE decides, not the text. The device also rejects the FIRST attempt (R|FF)
// and accepts a retry, so resend a few times before giving up.
func (c *Client) authLocked() error {
	var lastHex string
	for try := 1; try <= 4; try++ {
		id := c.cmdID.Add(1)
		_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
		if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); err != nil {
			return err
		}
		line, err := c.readReplyLocked(fmt.Sprintf("R%d|", id))
		if err != nil {
			return err
		}
		applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
		hex, msg := "", ""
		if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
			hex = strings.TrimSpace(p[1])
			if len(p) == 3 {
				msg = strings.TrimSpace(p[2])
			}
		}
		// hex "0" is the standard accept; also treat an explicit OK message as success.
		if hex == "0" || (msg != "" && strings.Contains(strings.ToLower(msg), "ok")) {
			applog.Printf("pgxl: authenticated")
			return nil
		}
		lastHex = hex
	}
	return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
}

// readReplyLocked reads until the reply carrying `want` as its prefix arrives,
// feeding every unsolicited frame it passes to parse() on the way.
//
// The amplifier PUSHES status frames ("S0|state=…") on the same socket, and it
// pushes them constantly once it is in OPERATE — power, SWR and temperature all
// move while transmitting. The old code read exactly one line per command and
// took whatever came first as its answer, so a single pushed frame put the
// stream permanently one reply behind: every later command read the PREVIOUS
// command's answer, and the last one waited out the 3 s deadline, failed, and
// dropped the connection. That is the reconnect-every-few-seconds seen in the
// field, and the stall in transmit — command() holds the mutex across that whole
// dead wait, so anything else touching the amplifier queued behind it.
//
// It only showed up remotely and in OPERATE: on a LAN with an idle amplifier
// there is almost nothing to push and the race hardly ever opens.
func (c *Client) readReplyLocked(want string) (string, error) {
	deadline := time.Now().Add(ioTimeout)
	for {
		_ = c.conn.SetReadDeadline(deadline)
		line, err := c.reader.ReadString('\n')
		if err != nil {
			return "", err
		}
		line = strings.TrimSpace(line)
		// Every frame is worth having, ours or not — a pushed status is fresher
		// than the one we were about to ask for.
		c.parse(line)
		if strings.HasPrefix(line, want) {
			return line, nil
		}
	}
}

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

// command sends "C<id>|<cmd>\n" and parses the single-line reply into status.
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("powergenius: 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
	}
	return c.readReplyLocked(fmt.Sprintf("R%d|", id))
}

// parse handles "R<id>|0|<k=v …>" and "S0|<k=v …>" status lines.
func (c *Client) parse(resp string) {
	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
	}
	c.statusMu.Lock()
	c.status.Connected = true
	c.status.LastError = ""
	// Log the first REAL status frame (one that carries "key=value" fields) so the
	// field set is visible — even if an earlier reply was junk like "Unauthorized"
	// (which would otherwise latch lastRaw and hide the real frame).
	if strings.Contains(data, "=") && !strings.Contains(c.lastRaw, "=") {
		c.lastRaw = data
		applog.Printf("pgxl: status raw=%q", data)
	}
	sawOperate := false
	for _, pair := range strings.Fields(data) {
		kv := strings.SplitN(pair, "=", 2)
		if len(kv) != 2 {
			continue
		}
		switch kv[0] {
		case "state":
			c.status.State = kv[1]
		case "operate":
			sawOperate = true
			c.status.Operate = kv[1] == "1"
		case "fanmode":
			dev := strings.ToUpper(kv[1])
			// Honour a recent optimistic change until the amp confirms it.
			if c.fanPending != "" && time.Since(c.fanPendingAt) < 3*time.Second && dev != c.fanPending {
				break
			}
			c.fanPending = ""
			c.status.FanMode = dev
		case "temp":
			c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
		case "fwd":
			if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
				c.status.FwdW = dbmToWatts(v)
			}
		case "peakfwd":
			if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
				c.status.PeakW = dbmToWatts(v)
			}
		case "swr":
			if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
				c.status.Vswr = returnLossToVswr(v)
			}
		case "id":
			c.status.Id, _ = strconv.ParseFloat(kv[1], 64)
		case "peakid":
			c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64)
		}
	}
	// THE AMP DOES NOT SEND "operate=".
	//
	// Operate was therefore never read at all on this link: it stayed at the
	// zero value until the operator pressed the button, so OpsLog opened
	// claiming STANDBY on an amplifier that was in line — and the first press
	// then commanded the state it was already in. Reported from a real PGXL.
	//
	// The live state IS in the frame, under "state", and the FlexRadio side of
	// this same amplifier has been reading it that way all along (see
	// flexAmp): anything but STANDBY/OFF means the amp is IN LINE. IDLE is
	// operate — in line, not keyed.
	if !sawOperate && c.status.State != "" {
		if op, known := operateFromState(c.status.State); known {
			c.status.Operate = op
		}
	}

	c.statusMu.Unlock()
}

// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp
// reports power that way — "fwd=60.5" is 1122 W, not 60 W.
func dbmToWatts(dbm float64) float64 {
	if dbm <= 0 {
		return 0
	}
	return math.Pow(10, (dbm-30)/10)
}

// returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent
// negative for a good match — into the VSWR ratio an operator reads.
func returnLossToVswr(swrDb float64) float64 {
	rl := math.Abs(swrDb)
	if rl <= 0 {
		return 0
	}
	rho := math.Pow(10, -rl/20)
	if rho >= 1 {
		return 0
	}
	vswr := (1 + rho) / (1 - rho)
	if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
		return 0
	}
	return vswr
}
