// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP
// text API (default port 9007). On connect the device sends a banner line
// (e.g. "V4.1.16 AG"); commands are "C<seq>|<command>\r" and the device replies
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
// "S<0>|<message>" status pushes once you subscribe with "sub port/antenna".
//
// (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used
// by pre-v4 firmware and is NOT what v4 speaks.)
package antgenius

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

	"hamlog/internal/applog"
)

const (
	defaultPort     = 9007
	dialTimeout     = 5 * time.Second
	writeTimeout    = 3 * time.Second
	readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead
	keepaliveEvery  = 3 * time.Second  // periodic "port get" refreshes state + keeps the link alive
	reconnectDelay  = 2 * time.Second
)

// Antenna is one configured antenna (index + name as stored on the device).
// Bands is the device's band bitmask for the antenna (which bands it covers);
// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like
// the native 4O3A app.
type Antenna struct {
	Index int    `json:"index"`
	Name  string `json:"name"`
	Bands int    `json:"bands"`
}

// Status is the snapshot the UI renders.
type Status struct {
	Connected bool      `json:"connected"`
	Host      string    `json:"host,omitempty"`
	LastError string    `json:"last_error,omitempty"`
	PortA     int       `json:"port_a"` // active antenna index on port A (0 = none)
	PortB     int       `json:"port_b"` // active antenna index on port B
	TxA       bool      `json:"tx_a"`   // port A is transmitting
	TxB       bool      `json:"tx_b"`   // port B is transmitting
	Antennas  []Antenna `json:"antennas"`
}

type Client struct {
	host     string
	port     int
	password string // remote-access password; sent as "login <pw>" when the device requires AUTH

	mu   sync.Mutex // guards conn + writes
	conn net.Conn

	// Auth handshake state (touched only on the runLoop/readLoop goroutine).
	awaitingAuth bool
	authTries    int
	ready        atomic.Bool // init commands sent → keepalive may run

	statusMu  sync.RWMutex
	status    Status
	antennas  map[int]string // index → name (rebuilt into status.Antennas)
	antBands  map[int]int    // index → band bitmask (which bands the antenna covers)
	antRawN   int            // one-shot: how many raw antenna lines we've logged
	lastShown map[int]int    // port id → antenna last reported, so only CHANGES are logged

	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{}),
		antennas:  map[int]string{},
		antBands:  map[int]int{},
		lastShown: map[int]int{},
		status:    Status{Host: host},
	}
}

func (c *Client) Start() error {
	c.running = true
	go c.runLoop()
	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.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()
}

// Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets
// the port to "None"). We set both RX and TX antennas and force manual mode so
// the choice sticks regardless of the device's auto band-following.
func (c *Client) Activate(port, antenna int) error {
	if port != 1 && port != 2 {
		return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port)
	}
	if antenna < 0 {
		return fmt.Errorf("antgenius: invalid antenna %d", antenna)
	}
	// Set only rxant (like the reference ShackMaster client): the AG mirrors it
	// to the TX antenna automatically. Forcing txant too can be rejected on the
	// 8x2 (an antenna can't be TX on both ports at once), which broke port-B
	// selection and deselection.
	if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil {
		return err
	}
	// Ask for the new port state so the snapshot reflects it promptly (the
	// subscription also pushes it, but this makes the change deterministic).
	_ = c.send(fmt.Sprintf("port get %d", port))
	return nil
}

func (c *Client) runLoop() {
	for {
		if !c.running {
			return
		}
		conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
		if err != nil {
			c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
			if c.sleep(reconnectDelay) {
				return
			}
			continue
		}
		c.mu.Lock()
		c.conn = conn
		c.mu.Unlock()
		c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
		applog.Printf("antgenius: TCP connected %s → %s", conn.LocalAddr(), conn.RemoteAddr())

		// Auth + init are driven by the banner (handleLine): on "V… AG AUTH" with a
		// password we send "auth code=<pw>" and only send the subscribe/get commands
		// once it's accepted (retrying on FF); without AUTH they go out immediately.
		c.awaitingAuth, c.authTries = false, 0
		c.ready.Store(false)

		done := make(chan struct{})
		go c.keepalive(conn, done)
		err = c.readLoop(conn) // blocks until the link errors
		close(done)

		c.mu.Lock()
		if c.conn == conn {
			c.conn = nil
		}
		c.mu.Unlock()
		conn.Close()
		c.setStatus(func(s *Status) {
			s.Connected = false
			if err != nil {
				s.LastError = "read: " + err.Error()
			}
		})
		if c.sleep(reconnectDelay) {
			return
		}
	}
}

// keepalive periodically re-reads a port so an idle-but-dead link is detected
// (the read loop's idle timeout fires if these stop producing replies).
func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
	t := time.NewTicker(keepaliveEvery)
	defer t.Stop()
	for {
		select {
		case <-done:
			return
		case <-c.stop:
			return
		case <-t.C:
			if !c.ready.Load() {
				continue // not authenticated / subscribed yet
			}
			_ = c.send("port get 1")
			_ = c.send("port get 2")
		}
	}
}

// sendInit subscribes to live updates and pulls the initial state. Called once
// the link is ready (authenticated, or immediately when no AUTH is required).
// Command set/order mirror a known-working Node-RED v4 client (WA9WUD).
func (c *Client) sendInit() {
	c.ready.Store(true)
	for _, cmd := range []string{"antenna list", "sub port all", "port get 1", "port get 2"} {
		if err := c.send(cmd); err != nil {
			applog.Printf("antgenius: init send %q failed: %v", cmd, err)
		}
	}
}

func (c *Client) readLoop(conn net.Conn) error {
	r := bufio.NewReader(conn)
	var sb strings.Builder
	start := time.Now()
	lines, bytesRx := 0, 0
	for {
		_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
		b, err := r.ReadByte()
		if err != nil {
			// Log where the link died: 0 lines after connect points at the device
			// refusing/closing (e.g. a client-slot limit or source-IP rejection —
			// common when reaching it remotely); an EOF after the banner/replies
			// points at something later in the exchange.
			applog.Printf("antgenius: read ended after %s — %d line(s), %d byte(s): %v",
				time.Since(start).Round(time.Millisecond), lines, bytesRx, err)
			return err
		}
		bytesRx++
		if b == '\r' || b == '\n' {
			if sb.Len() > 0 {
				lines++
				if lines <= 10 {
					applog.Printf("antgenius: rx[%d] %q", lines, sb.String())
				}
				c.handleLine(sb.String())
				sb.Reset()
			}
			continue
		}
		sb.WriteByte(b)
	}
}

// send writes a "C<seq>|<command>\r" line to the device.
func (c *Client) send(command string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.conn == nil {
		return fmt.Errorf("antgenius: not connected")
	}
	_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
	// The device only accepts the constant "C1|" sequence prefix for every
	// command (using incrementing sequence numbers makes it drop the link);
	// commands are LF-terminated.
	_, err := fmt.Fprintf(c.conn, "C1|%s\n", command)
	return err
}

// handleLine parses one response/status/banner line and updates the snapshot.
func (c *Client) handleLine(line string) {
	line = strings.TrimSpace(line)
	if line == "" {
		return
	}
	// Banner: "V4.1.16 AG" (LAN) or "V4.1.16 AG AUTH" (remote). It drives what we
	// send next — authenticate first when AUTH is announced, else go straight to
	// the subscribe/get commands.
	if line[0] == 'V' && strings.Contains(line, "AG") {
		c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
		if strings.Contains(line, "AUTH") && c.password != "" {
			c.awaitingAuth, c.authTries = true, 1
			applog.Printf("antgenius: AUTH required — authenticating")
			_ = c.send("auth code=" + c.password)
		} else {
			if strings.Contains(line, "AUTH") {
				applog.Printf("antgenius: device requires AUTH but no remote password set (Settings → Antenna Genius)")
			}
			c.sendInit()
		}
		return
	}
	// R<seq>|<hex>|<message>  or  S<seq>|<message>
	var hexCode, msg string
	switch {
	case strings.HasPrefix(line, "R"):
		p := strings.SplitN(line, "|", 3)
		if len(p) >= 2 {
			hexCode = p[1]
		}
		if len(p) == 3 {
			msg = p[2]
		}
	case strings.HasPrefix(line, "S"):
		p := strings.SplitN(line, "|", 2)
		if len(p) == 2 {
			msg = p[1]
		}
	}
	msg = strings.TrimSpace(msg)
	// Auth result: the reply to "auth code=" carries an empty message; hex "0" =
	// accepted. The device rejects the FIRST attempt (R|FF) and accepts a retry,
	// so resend a few times before giving up.
	if c.awaitingAuth && strings.HasPrefix(line, "R") && msg == "" {
		switch {
		case hexCode == "0":
			c.awaitingAuth = false
			applog.Printf("antgenius: authenticated")
			c.sendInit()
		case c.authTries < 4:
			c.authTries++
			applog.Printf("antgenius: auth rejected (R|%s|) — retry %d", hexCode, c.authTries)
			_ = c.send("auth code=" + c.password)
		default:
			c.awaitingAuth = false
			applog.Printf("antgenius: auth failed after %d tries (R|%s|) — check the remote password", c.authTries, hexCode)
		}
		return
	}
	switch {
	case strings.HasPrefix(msg, "antenna "):
		c.parseAntenna(msg)
	case strings.HasPrefix(msg, "port "):
		c.parsePort(msg)
	}
}

// parseAntenna handles "antenna <id> name=<name> tx=.. rx=.. inband=..".
// The name may contain spaces, so it's extracted up to the " tx=" field.
func (c *Client) parseAntenna(msg string) {
	fields := strings.Fields(msg)
	if len(fields) < 2 {
		return
	}
	id, err := strconv.Atoi(fields[1])
	if err != nil {
		return
	}
	name := ""
	if i := strings.Index(msg, "name="); i >= 0 {
		name = msg[i+len("name="):]
		if j := strings.Index(name, " tx="); j >= 0 {
			name = name[:j]
		}
		// The device stores spaces as underscores in names.
		name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
	}
	// Band bitmask: which bands this antenna is configured for. The device sends
	// it as "band=<mask>" (a bitmask). Kept so the UI can show only band-relevant
	// antennas (like the native app). Log the first few raw antenna lines so the
	// exact field name + bit values can be confirmed on real hardware.
	bands := kvInt(msg, "band")
	c.statusMu.Lock()
	if c.antRawN < 8 {
		c.antRawN++
		applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands)
	}
	if name != "" && !isPlaceholderName(name) {
		c.antennas[id] = name
		c.antBands[id] = bands
	} else {
		delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
		delete(c.antBands, id)
	}
	c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
	c.status.Connected = true
	c.statusMu.Unlock()
}

// parsePort handles "port <id> ... rxant=<n> txant=<n> ...". The active antenna
// shown is the TX antenna, falling back to the RX antenna when TX is none.
func (c *Client) parsePort(msg string) {
	fields := strings.Fields(msg)
	if len(fields) < 2 {
		return
	}
	id, err := strconv.Atoi(fields[1])
	if err != nil || (id != 1 && id != 2) {
		return
	}
	tx := kvInt(msg, "txant")
	rx := kvInt(msg, "rxant")
	// The RX antenna is what identifies a port's selection, and it is the only
	// thing Activate sets. Preferring the TX antenna made both ports converge on
	// the same row a few seconds after a change: on an 8x2 only ONE port can hold
	// the transmit antenna, so the device reports the same txant on both, and a
	// port A genuinely on the 80 m vertical was redrawn as the beam that port B
	// transmits through.
	//
	// TX remains the fallback for a port that reports no RX antenna at all.
	active := rx
	if active == 0 {
		active = tx
	}
	// Logged only when the shown antenna CHANGES: the device pushes port state
	// every few seconds, and logging each one would bury everything else — but
	// without any trace, "port A jumped to the wrong antenna" is unfalsifiable.
	c.statusMu.Lock()
	prev, seen := c.lastShown[id]
	c.lastShown[id] = active
	c.statusMu.Unlock()
	if !seen || prev != active {
		applog.Printf("antgenius: port %d → antenna %d (rxant=%d txant=%d) raw=%q", id, active, rx, tx, msg)
	}
	txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
	c.setStatus(func(s *Status) {
		s.Connected = true
		if id == 1 {
			s.PortA, s.TxA = active, txOn
		} else {
			s.PortB, s.TxB = active, txOn
		}
	})
}

func (c *Client) sleep(d time.Duration) (stopped bool) {
	select {
	case <-c.stop:
		return true
	case <-time.After(d):
		return false
	}
}

// kvInt extracts the integer value of a "key=<int>" token from a space-
// separated string (returns 0 if absent).
func kvInt(s, key string) int {
	i := strings.Index(s, key+"=")
	if i < 0 {
		return 0
	}
	v := s[i+len(key)+1:]
	if sp := strings.IndexByte(v, ' '); sp >= 0 {
		v = v[:sp]
	}
	n, _ := strconv.Atoi(strings.TrimSpace(v))
	return n
}

// isPlaceholderName reports whether name is an unconfigured-slot default like
// "Antenna 4" / "antenna_5" (after underscores become spaces): the word
// "antenna" followed by a number, which the UI shouldn't list.
func isPlaceholderName(name string) bool {
	f := strings.Fields(strings.ToLower(name))
	if len(f) != 2 || f[0] != "antenna" {
		return false
	}
	_, err := strconv.Atoi(f[1])
	return err == nil
}

func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
	out := make([]Antenna, 0, len(m))
	for idx, name := range m {
		out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
	return out
}
