package cat

// Native Xiegu CAT (G90, X6100, X6200, X5105 and relatives) over the rig's
// serial/USB port.
//
// Xiegu speaks CI-V — Icom's bus protocol — with a REDUCED command set. Frames,
// BCD encoding, addressing and the opcodes for frequency (0x03/0x05), mode
// (0x04/0x06), PTT (0x1C 0x00), split (0x0F) and the meters (0x15) are the same
// as Icom's, which is why this backend reuses internal/cat/civ wholesale rather
// than re-deriving it.
//
// It is a SEPARATE backend rather than the Icom one with another address,
// because what the two rigs DON'T share is the important part. The Icom backend
// reads the spectrum scope, the DSP block, data-mode via 0x1A 0x06, the model id
// via 0x19 — none of which a Xiegu implements. Pointed at a G90 it would poll
// for answers that never come on every cycle, and its silence tolerance would
// spend itself on commands the radio was never going to support.
//
// Mode differences that matter: the Xiegu table lists LSB, USB, AM, CW and CWR
// only — no FM, no RTTY, and no data mode. A digital QSO therefore runs in USB
// (which is what the operator does on the radio anyway), and the mode is
// reported as the operator's configured digital mode when they select one, not
// invented from the rig.
//
// Verified on: nothing yet — written from the Xiegu CI-V command table. The
// command table published by Xiegu has rows that clearly slipped during
// typesetting (0x07 and 0x0F share a block), so where it contradicts itself the
// Icom meaning is used, since the rest of the table matches Icom exactly. Every
// unexpected reply is logged raw so a first on-air run settles it.

import (
	"fmt"
	"strings"
	"sync"
	"time"

	"go.bug.st/serial"

	"hamlog/internal/cat/civ"
)

// XieguDefaultAddr is the factory CI-V address of the G90/X6100 family.
const XieguDefaultAddr = 0x70

type Xiegu struct {
	portName string
	baud     int
	rigAddr  byte
	digital  string

	mu   sync.Mutex
	port serial.Port

	curFreq int64
	// splitSupported is cleared when the rig ignores the split query, so we stop
	// asking every cycle — a Xiegu that has no split must not cost a timeout per
	// poll, which would slow the whole loop to a crawl.
	splitSupported bool
	// pttLine is "", "rts" or "dtr": which hardware line keys the rig, when the
	// CI-V command does not.
	pttLine string
}

func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
	if baud <= 0 {
		baud = 19200 // G90 factory default
	}
	if addr <= 0 || addr > 0xFF {
		addr = XieguDefaultAddr
	}
	if strings.TrimSpace(digital) == "" {
		digital = "FT8"
	}
	return &Xiegu{
		portName: strings.TrimSpace(portName), baud: baud,
		rigAddr: byte(addr), digital: digital, splitSupported: true,
	}
}

// SetPTTLine selects the hardware line that keys this rig: "rts", "dtr", or ""
// for the CI-V command. Set before Connect.
func (x *Xiegu) SetPTTLine(line string) {
	x.mu.Lock()
	x.pttLine = strings.ToLower(strings.TrimSpace(line))
	x.mu.Unlock()
}

func (x *Xiegu) Name() string { return "xiegu" }

func (x *Xiegu) Connect() error {
	x.mu.Lock()
	defer x.mu.Unlock()
	if x.portName == "" {
		return fmt.Errorf("xiegu: no serial port configured")
	}
	p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
	if err != nil {
		return fmt.Errorf("xiegu: open %s @ %d baud: %w%s", x.portName, x.baud, err, busyHint(x.portName, err))
	}
	p.SetReadTimeout(200 * time.Millisecond)
	// Deassert DTR and RTS.
	//
	// Windows raises both when a serial port is opened, and a great many
	// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
	// transmit the moment OpsLog connects and STAYS there — Xiegu's own
	// documentation asks for RTS and DTR low. The same applies to an Icom with
	// USB SEND mapped to a line (which is why the CI-V backend has done this
	// from the start) and to any rig keyed by a home-made cable.
	//
	// PTT on this backend is a CAT command, so neither line should ever be
	// asserted here. A station keying by RTS/DTR configures that separately,
	// on its own port.
	_ = p.SetDTR(false)
	_ = p.SetRTS(false)
	x.port = p
	x.splitSupported = true

	// Prove the link before declaring success: an open COM port says nothing
	// about a radio being on the other end, and a backend that reports
	// "connected" to a powered-off rig sends the operator hunting for a fault in
	// the wrong place.
	if _, err := x.readFreq(); err != nil {
		_ = p.Close()
		x.port = nil
		return fmt.Errorf("xiegu: no answer on %s @ %d baud (address 0x%02X): %w", x.portName, x.baud, x.rigAddr, err)
	}
	debugLog.Printf("xiegu: connected on %s @ %d baud, CI-V address 0x%02X", x.portName, x.baud, x.rigAddr)
	return nil
}

func (x *Xiegu) Disconnect() {
	x.mu.Lock()
	defer x.mu.Unlock()
	if x.port != nil {
		_ = x.port.Close()
		x.port = nil
	}
}

func (x *Xiegu) ReadState() (RigState, error) {
	x.mu.Lock()
	defer x.mu.Unlock()
	if x.port == nil {
		return RigState{}, fmt.Errorf("xiegu: not connected")
	}
	s := RigState{Backend: x.Name(), Connected: true, Rig: "Xiegu"}

	hz, err := x.readFreq()
	if err != nil {
		return RigState{}, err // let the Manager reconnect
	}
	s.FreqHz = hz
	x.curFreq = hz

	if d, err := x.ask(civ.CmdReadMode); err == nil && len(d.Data) >= 1 {
		s.Mode = civ.ModeToADIF(d.Data[0], false)
		// The rig has no data mode, so it reports USB on the digital watering
		// holes. Naming the operator's digital mode there is the frontend's job
		// (it infers from frequency); reporting USB honestly is ours.
	}

	if x.splitSupported {
		d, err := x.ask(civ.CmdSplit)
		switch {
		case err != nil:
			debugLog.Printf("xiegu: split query got no answer (%v) — not asking again this session", err)
			x.splitSupported = false
		case len(d.Data) >= 1:
			s.Split = d.Data[0] == 0x01
		}
	}
	// Split TX frequency is deliberately NOT reported. Reading the unselected
	// VFO needs 0x25, which the Xiegu table does not list — and a split flag with
	// a wrong TX frequency is worse than a split flag alone, because it is the
	// frequency that gets logged.
	return s, nil
}

func (x *Xiegu) SetFrequency(hz int64) error {
	x.mu.Lock()
	defer x.mu.Unlock()
	if x.port == nil {
		return fmt.Errorf("xiegu: not connected")
	}
	if hz <= 0 {
		return fmt.Errorf("xiegu: invalid frequency %d", hz)
	}
	payload := append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)
	return x.send(payload...)
}

func (x *Xiegu) SetMode(mode string) error {
	x.mu.Lock()
	defer x.mu.Unlock()
	if x.port == nil {
		return fmt.Errorf("xiegu: not connected")
	}
	m, ok := xieguModeByte(mode, x.curFreq)
	if !ok {
		return fmt.Errorf("xiegu: no CAT mode for %q", mode)
	}
	return x.send(civ.CmdSetMode, m)
}

// SetPTT keys the transmitter, by CI-V or by a hardware line.
//
// A G90 does not transmit on the CI-V PTT command. Xiegu's own interfaces key
// on RTS or DTR instead — the DE-19 does exactly that — which is why the line
// can be selected here. Without it, WSJT-X talking to OpsLog's rigctld server
// decoded perfectly and never transmitted: the command left, the radio ignored
// it, and nothing in the chain was wrong enough to complain.
func (x *Xiegu) SetPTT(on bool) error {
	x.mu.Lock()
	defer x.mu.Unlock()
	if x.port == nil {
		return fmt.Errorf("xiegu: not connected")
	}
	switch x.pttLine {
	case "rts":
		return x.port.SetRTS(on)
	case "dtr":
		return x.port.SetDTR(on)
	}
	v := byte(0x00)
	if on {
		v = 0x01
	}
	return x.send(civ.CmdPTT, 0x00, v)
}

// ── helpers ───────────────────────────────────────────────────────────────

func (x *Xiegu) send(payload ...byte) error {
	if x.port == nil {
		return fmt.Errorf("xiegu: not connected")
	}
	_, err := x.port.Write(civ.Frame(x.rigAddr, civ.AddrController, payload...))
	return err
}

// ask sends a query and returns the rig's answer frame.
//
// CI-V is a shared bus: the rig echoes back what we sent before answering, so
// our own frame has to be skipped. Matching on the SENDER (From == the rig)
// rather than on position is what makes this robust when an echo is dropped or
// an unsolicited frame arrives from the dial being turned.
func (x *Xiegu) ask(payload ...byte) (civ.Decoded, error) {
	if err := x.send(payload...); err != nil {
		return civ.Decoded{}, err
	}
	buf := make([]byte, 0, 64)
	tmp := make([]byte, 64)
	deadline := time.Now().Add(600 * time.Millisecond)
	for time.Now().Before(deadline) {
		n, err := x.port.Read(tmp)
		if err != nil {
			return civ.Decoded{}, err
		}
		if n == 0 {
			continue
		}
		buf = append(buf, tmp[:n]...)
		frames, consumed := civ.Scan(buf)
		buf = buf[consumed:]
		for _, f := range frames {
			if f.From != x.rigAddr {
				continue // our own echo on the bus
			}
			if f.Cmd == 0xFA {
				return civ.Decoded{}, fmt.Errorf("xiegu: rig rejected command 0x%02X", payload[0])
			}
			if f.Cmd == payload[0] {
				return f, nil
			}
			// An unsolicited update (the operator turning the dial) — useful, but
			// not the answer we asked for.
			debugLog.Printf("xiegu: unsolicited frame cmd=0x%02X data=% X", f.Cmd, f.Data)
		}
	}
	return civ.Decoded{}, fmt.Errorf("xiegu: timeout answering 0x%02X", payload[0])
}

func (x *Xiegu) readFreq() (int64, error) {
	d, err := x.ask(civ.CmdReadFreq)
	if err != nil {
		return 0, err
	}
	hz, ok := civ.BCDToFreq(d.Data)
	if !ok || hz <= 0 {
		return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
	}
	return hz, nil
}

// xieguModeByte maps an ADIF mode to the Xiegu's mode byte.
//
// The rig has LSB, USB, AM, CW and CWR — nothing else. A digital mode therefore
// becomes USB (or LSB below 10 MHz), which is what the operator selects on the
// radio; claiming a DATA mode it does not have would just be refused.
func xieguModeByte(mode string, freqHz int64) (byte, bool) {
	lowBand := freqHz > 0 && freqHz < 10_000_000
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "":
		return 0, false
	case "LSB":
		return 0x00, true
	case "USB":
		return 0x01, true
	case "SSB":
		if lowBand {
			return 0x00, true
		}
		return 0x01, true
	case "AM":
		return 0x02, true
	case "CW":
		return 0x03, true
	case "CWR", "CW-R":
		return 0x07, true
	default:
		// Every digital sub-mode rides on plain sideband here.
		if lowBand {
			return 0x00, true
		}
		return 0x01, true
	}
}
