// Package winkeyer drives a K1EL WinKeyer (WK1/WK2/WK3) CW keyer over a
// serial port — the same hardware Log4OM, N1MM and fldigi talk to. It opens
// the host-mode interface, applies the operator's keying parameters (speed,
// weight, lead-in/tail, sidetone, paddle mode…), sends arbitrary text as
// Morse, and aborts mid-message on demand.
//
// Protocol reference: K1EL "WinKeyer USB / WK3 Interface Description". The
// host link is 1200 baud 8N1. Bytes 0x00–0x1F are commands; printable ASCII
// is keyed directly. The device streams status bytes back (busy/idle, the
// speed-pot value, and an echo of each character as it's sent) which we
// surface to the UI via the OnStatus callback.
package winkeyer

import (
	"fmt"
	"runtime/debug"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"go.bug.st/serial"

	"hamlog/internal/applog"
)

// Mode selects the paddle keying mode (WinKey "mode register" low bits).
type Mode string

const (
	ModeIambicB   Mode = "iambic_b"
	ModeIambicA   Mode = "iambic_a"
	ModeUltimatic Mode = "ultimatic"
	ModeBug       Mode = "bug"
)

// Config is the keyer configuration the UI persists and applies on connect.
type Config struct {
	Port       string `json:"port"`        // e.g. "COM6"
	Baud       int    `json:"baud"`        // 1200 for WK2, also fine for WK3
	WPM        int    `json:"wpm"`         // 5..99
	Weight     int    `json:"weight"`      // 10..90, 50 = normal
	LeadInMs   int    `json:"lead_in_ms"`  // PTT lead-in, 10 ms units sent to device
	TailMs     int    `json:"tail_ms"`     // PTT tail
	Ratio      int    `json:"ratio"`       // dah/dit ratio 33..66 (50 = 3:1)
	Farnsworth int    `json:"farnsworth"`  // Farnsworth WPM (0 = off)
	Sidetone   int    `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code)
	Mode       Mode   `json:"mode"`        // paddle mode
	Swap       bool   `json:"swap"`        // swap dit/dah paddles
	AutoSpace  bool   `json:"autospace"`   // auto letter-space
	UsePTT     bool   `json:"use_ptt"`     // key PTT (Key/PTT output)
	SerialEcho bool   `json:"serial_echo"` // device echoes sent chars back to host

	// SlowBoot skips the quick opening attempt and waits for a keyer that reboots
	// when the port opens — a K3NG or any other Arduino-based WinKeyer with its
	// auto-reset still enabled. Not an operator setting: it is remembered per port
	// the first time a keyer only answers the long attempt, so the second connect
	// is as quick as a K1EL's and nobody has to know what is inside the box.
	SlowBoot bool `json:"-"`

	// Type selects the keyer engine on this serial port:
	//   "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
	//   "serial"    → the PC bit-bangs Morse on a control line (no WinKeyer chip):
	//                 the "hardware CW keying" a Yaesu SCU-17 / generic interface
	//                 uses. WPM / Weight / Farnsworth / LeadIn / Tail / UsePTT still
	//                 apply; the paddle/sidetone/ratio fields are WinKeyer-only.
	Type      string `json:"type"`
	CWKeyLine string `json:"cw_key_line"` // serial: "dtr" (CW on DTR, PTT on RTS — default) | "rts"
	CWInvert  bool   `json:"cw_invert"`   // serial: invert line polarity (active-LOW) for both key and PTT
}

func (c Config) normalised() Config {
	if c.Baud <= 0 {
		c.Baud = 1200
	}
	if c.WPM < 5 {
		c.WPM = 20
	}
	if c.WPM > 99 {
		c.WPM = 99
	}
	if c.Weight < 10 || c.Weight > 90 {
		c.Weight = 50
	}
	if c.Ratio < 33 || c.Ratio > 66 {
		c.Ratio = 50
	}
	switch c.Mode {
	case ModeIambicA, ModeIambicB, ModeUltimatic, ModeBug:
	default:
		c.Mode = ModeIambicB
	}
	return c
}

// Status is pushed to the UI whenever the link state or keyer activity changes.
type Status struct {
	Connected bool   `json:"connected"`
	Busy      bool   `json:"busy"`    // device is currently sending CW
	WPM       int    `json:"wpm"`     // current speed (tracks the speed pot)
	Version   int    `json:"version"` // host firmware version byte
	Port      string `json:"port"`
	Error     string `json:"error,omitempty"`
}

// Manager owns the serial link. Safe for concurrent use.
type Manager struct {
	mu       sync.Mutex
	port     serial.Port
	cfg      Config
	status   Status
	stopRead chan struct{}
	doneRead chan struct{}
	serial   *serialKeyer // non-nil when cfg.Type == "serial" (DTR/RTS line keying)

	onStatus func(Status)
	onEcho   func(string) // chars the device echoes back as it keys them
	// onSlowBoot fires when a keyer answered only after the long wait, so the
	// caller can persist that for this port. See Config.SlowBoot.
	onSlowBoot func(port string)
}

func NewManager(onStatus func(Status), onEcho func(string)) *Manager {
	return &Manager{onStatus: onStatus, onEcho: onEcho}
}

// OnSlowBoot registers the callback that persists "this port needs the long
// opening wait". Optional: without it a slow keyer still connects, it just
// pays the failed quick attempt on every connect.
func (m *Manager) OnSlowBoot(fn func(port string)) { m.onSlowBoot = fn }

// ListPorts returns the available serial port names (COM3, COM6, …).
func ListPorts() ([]string, error) {
	ports, err := serial.GetPortsList()
	if err != nil {
		return nil, err
	}
	return ports, nil
}

// Status returns a snapshot.
func (m *Manager) Snapshot() Status {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.status
}

func (m *Manager) emit() {
	if m.onStatus != nil {
		m.onStatus(m.status)
	}
}

// Connect opens the port, performs the host-open handshake and applies cfg.
func (m *Manager) Connect(cfg Config) error {
	cfg = cfg.normalised()
	if strings.TrimSpace(cfg.Port) == "" {
		return fmt.Errorf("winkeyer: no serial port selected")
	}
	if cfg.Type == "serial" {
		return m.connectSerial(cfg)
	}
	m.Disconnect() // drop any existing link first

	p, err := serial.Open(cfg.Port, &serial.Mode{
		BaudRate: cfg.Baud,
		DataBits: 8,
		Parity:   serial.NoParity,
		StopBits: serial.OneStopBit,
		// DTR on, RTS OFF. K1EL's own init code sets exactly this
		// (DTR_CONTROL_ENABLE / RTS_CONTROL_DISABLE), and on a serial WinKeyer
		// the two lines are the chip's power supply: DTR feeds the 3.3 V
		// regulator, RTS provides the negative rail for the RS-232 swing.
		// Driving RTS high starves that rail. The serial library defaults BOTH
		// to true, which is how this was wrong without anyone writing it.
		InitialStatusBits: &serial.ModemOutputBits{DTR: true, RTS: false},
	})
	if err != nil {
		return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
	}

	ver, slowBoot, err := hostOpen(p, cfg.SlowBoot)
	if err != nil {
		_ = p.Close()
		return fmt.Errorf("winkeyer: %s: %w", cfg.Port, err)
	}
	// Tell the caller to remember it, so this port opens quickly next time.
	if slowBoot && !cfg.SlowBoot && m.onSlowBoot != nil {
		m.onSlowBoot(cfg.Port)
	}
	_ = p.SetReadTimeout(200 * time.Millisecond)

	m.mu.Lock()
	m.port = p
	m.cfg = cfg
	m.status = Status{Connected: true, WPM: cfg.WPM, Version: ver, Port: cfg.Port}
	m.stopRead = make(chan struct{})
	m.doneRead = make(chan struct{})
	stop, done := m.stopRead, m.doneRead
	m.mu.Unlock()

	applog.Printf("winkeyer: connected on %s — %s", cfg.Port, firmwareFamily(ver))
	go m.readLoop(p, stop, done)

	if err := m.applyConfig(cfg); err != nil {
		applog.Printf("winkeyer: applyConfig: %v", err)
	}
	m.emit()
	return nil
}

// ApplySerialConfig live-updates a running serial-line keyer's control options
// (PTT keying, key line, invert, lead/tail, speed) WITHOUT reopening the port —
// so ticking "Key PTT line" (or changing the key line) takes effect from the next
// character, no reconnect needed. No-op unless a serial keyer is running.
func (m *Manager) ApplySerialConfig(cfg Config) {
	cfg = cfg.normalised()
	m.mu.Lock()
	sk := m.serial
	m.cfg = cfg
	m.mu.Unlock()
	if sk != nil {
		sk.ApplyConfig(cfg)
	}
}

// connectSerial opens the port for line-keying (DTR=CW / RTS=PTT) — no K1EL
// handshake, no read loop. The PC bit-bangs the Morse itself (see serialcw.go).
func (m *Manager) connectSerial(cfg Config) error {
	m.Disconnect() // drop any existing link first

	// Open for line control only (DTR/RTS). On Windows this uses EscapeCommFunction
	// so a held RTS (PTT) survives DTR (CW) toggling on USB adapters — see linectl_*.
	line, err := openLineCtl(cfg.Port)
	if err != nil {
		return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
	}
	// The keyer updates busy state as it keys; mirror it into status + notify.
	sk := newSerialKeyer(line, cfg, func(busy bool) {
		m.mu.Lock()
		changed := m.status.Busy != busy
		m.status.Busy = busy
		m.mu.Unlock()
		if changed {
			m.emit()
		}
	})

	m.mu.Lock()
	m.port = nil // serial keyer owns its own (line-only) port, not m.port
	m.serial = sk
	m.cfg = cfg
	m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
	m.mu.Unlock()

	lineDesc := "DTR (CW) / RTS (PTT)"
	if strings.EqualFold(cfg.CWKeyLine, "rts") {
		lineDesc = "RTS (CW) / DTR (PTT)"
	}
	applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, cfg.UsePTT)
	m.emit()
	return nil
}

// configCommands is the init sequence for a config — separated from the sending
// so the BYTES can be tested. The command numbers are the contract with the
// hardware, and a wrong one produces a keyer that misbehaves in a way no amount
// of reading the UI explains.
func configCommands(c Config) [][]byte {
	cmds := [][]byte{
		{0x0E, modeRegister(c)},                            // set mode register (paddle mode, swap, autospace…)
		{0x02, byte(c.WPM)},                                // set speed (WPM)
		{0x03, byte(c.Weight)},                             // set weighting
		{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units)
		// Dit/dah ratio is 0x17. It was being sent as 0x11, which on a WinKeyer 2
		// is SET KEY COMPENSATION — in milliseconds. So a neutral ratio of 50 was
		// read as 50 ms of extra key-down on every element: at 25 wpm a dit is
		// 48 ms, so each element more than doubled and ran into the next. That is
		// the reported "it sends one element then stalls", and it was in the trace
		// as "TX 11 32".
		{0x17, byte(c.Ratio)},
		// And clear the compensation explicitly. A keyer left at 50 ms by the
		// previous version — or by another program — keeps it in EEPROM, so
		// merely stopping the wrong command would not fix an affected keyer.
		{0x11, 0x00},
	}
	// Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
	cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
	if c.Farnsworth > 0 {
		cmds = append(cmds, []byte{0x0D, byte(c.Farnsworth)}) // Farnsworth WPM
	}
	return cmds
}

// applyConfig pushes the keying parameters to the device.
func (m *Manager) applyConfig(c Config) error {
	for _, cmd := range configCommands(c) {
		if err := m.write(cmd); err != nil {
			return err
		}
	}
	return nil
}

// modeRegister builds the WinKey mode-register byte (command 0x0E).
//
//	bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug)
//	bit 3     : paddle swap
//	bit 0/... : (autospace is bit 0 of a separate group on some firmwares)
//
// We keep to the widely-compatible WK2 layout.
func modeRegister(c Config) byte {
	var b byte
	switch c.Mode {
	case ModeIambicB:
		b |= 0x00
	case ModeIambicA:
		b |= 0x10
	case ModeUltimatic:
		b |= 0x20
	case ModeBug:
		b |= 0x30
	}
	if c.Swap {
		b |= 0x08 // bit3 paddle swap
	}
	if c.AutoSpace {
		b |= 0x02 // bit1 autospace
	}
	if c.SerialEcho {
		b |= 0x04 // bit2 serial echoback — device echoes keyed chars to host
	}
	return b
}

// sidetoneCode maps a target Hz to the WinKey sidetone control byte. 0 = off.
func sidetoneCode(hz int) byte {
	if hz <= 0 {
		return 0x00 // sidetone off
	}
	// WK sidetone = 4000 / n Hz, n = 1..10. Pick the nearest n, enable bit6.
	best, bestErr := 1, 1<<30
	for n := 1; n <= 10; n++ {
		f := 4000 / n
		e := f - hz
		if e < 0 {
			e = -e
		}
		if e < bestErr {
			bestErr, best = e, n
		}
	}
	return 0x80 | byte(best) // bit7 paddle-only sidetone on; low nibble = divisor
}

// SetSpeed changes the WPM live (command 0x02).
func (m *Manager) SetSpeed(wpm int) error {
	if wpm < 5 {
		wpm = 5
	}
	if wpm > 99 {
		wpm = 99
	}
	m.mu.Lock()
	sk := m.serial
	m.mu.Unlock()
	if sk != nil {
		sk.SetSpeed(wpm)
	} else if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
		return err
	}
	m.mu.Lock()
	m.cfg.WPM = wpm
	m.status.WPM = wpm
	m.mu.Unlock()
	m.emit()
	return nil
}

// allowedCW is the set of characters WinKey can key (everything else dropped).
const allowedCW = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,?/=+-:();\"'@"

// Send keys the given text as Morse. The text is upper-cased and filtered to
// keyable characters. Non-keyable input is silently dropped.
func (m *Manager) Send(text string) error {
	var b strings.Builder
	for _, r := range strings.ToUpper(text) {
		if strings.ContainsRune(allowedCW, r) {
			b.WriteRune(r)
		}
	}
	out := b.String()
	if out == "" {
		return nil
	}
	m.mu.Lock()
	sk := m.serial
	m.mu.Unlock()
	if sk != nil {
		sk.Send(out)
		return nil
	}
	return m.write([]byte(out))
}

// Stop aborts the current message and clears the keyer buffer (command 0x0A).
func (m *Manager) Stop() error {
	m.mu.Lock()
	sk := m.serial
	m.mu.Unlock()
	if sk != nil {
		sk.Stop()
		return nil
	}
	return m.write([]byte{0x0A})
}

// Backspace removes the most recent character from the keyer's send buffer,
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
// so a fast typo can be corrected before it goes on the air. The serial line
// keyer has no buffer to un-key from, so backspace is a no-op there.
func (m *Manager) Backspace() error {
	m.mu.Lock()
	sk := m.serial
	m.mu.Unlock()
	if sk != nil {
		return nil
	}
	return m.write([]byte{0x08})
}

func (m *Manager) write(b []byte) error {
	m.mu.Lock()
	p := m.port
	m.mu.Unlock()
	if p == nil {
		return fmt.Errorf("winkeyer: not connected")
	}
	traceTX(b)
	_, err := p.Write(b)
	return err
}

// ── Protocol trace ─────────────────────────────────────────────────────
//
// The WinKeyer command set differs between WK1, WK2 and WK3, and the failures
// it produces are silent: the keyer accepts a byte meant for another command
// and keys something odd. Guessing at that from a description ("it sends one
// element then pauses ten seconds") is how a fix for one firmware breaks the
// others, so the wire is made visible instead.
//
// Off by default — 1200 baud is slow but a long message would still fill the
// log. Enabled per session from the CW settings panel.

var traceOn atomic.Bool

// SetTrace turns the byte-level protocol trace on or off.
func SetTrace(on bool) {
	traceOn.Store(on)
	if on {
		applog.Printf("winkeyer: protocol trace ON — every byte to and from the keyer is logged")
	}
}

func hexBytes(b []byte) string {
	var sb strings.Builder
	for i, c := range b {
		if i > 0 {
			sb.WriteByte(' ')
		}
		fmt.Fprintf(&sb, "%02X", c)
		if c >= 0x20 && c < 0x7F {
			fmt.Fprintf(&sb, "(%c)", c)
		}
	}
	return sb.String()
}

func traceTX(b []byte) {
	if !traceOn.Load() || len(b) == 0 {
		return
	}
	applog.Printf("winkeyer: TX %s %s", hexBytes(b), cmdName(b))
}

func traceRX(b []byte) {
	if !traceOn.Load() || len(b) == 0 {
		return
	}
	applog.Printf("winkeyer: RX %s", hexBytes(b))
}

// cmdName names the command a TX frame carries, so the trace can be read
// without the datasheet open beside it.
func cmdName(b []byte) string {
	if len(b) == 0 || b[0] >= 0x20 {
		return "(text)"
	}
	switch b[0] {
	case 0x00:
		return "admin"
	case 0x01:
		return "sidetone"
	case 0x02:
		return "set wpm"
	case 0x03:
		return "set weight"
	case 0x04:
		return "ptt lead/tail"
	case 0x05:
		return "setup speed pot"
	case 0x06:
		return "pause"
	case 0x0A:
		return "clear buffer"
	case 0x0D:
		return "farnsworth wpm"
	case 0x17:
		return "set dit/dah ratio"
	case 0x0E:
		return "set mode register"
	case 0x11:
		return "set key compensation (ms)"
	case 0x15:
		return "request status"
	default:
		return fmt.Sprintf("cmd 0x%02X", b[0])
	}
}

// firmwareFamily names the keyer generation from the byte returned by Host
// Open. The version is what decides which command set applies, so it is spelled
// out in the log rather than left as a bare number.
func firmwareFamily(ver int) string {
	switch {
	case ver == 0:
		// Unreachable from a successful connect — the handshake fails rather
		// than returning a version of zero. Kept so a future caller that skips
		// hostOpen cannot print "WK1 (v0)" and be believed.
		return "unknown firmware (no version returned)"
	case ver < 20:
		return fmt.Sprintf("WK1 (v%d)", ver)
	case ver < 30:
		return fmt.Sprintf("WK2 (v%d)", ver)
	default:
		return fmt.Sprintf("WK3 (v%d)", ver)
	}
}

// Disconnect sends Host Close and releases the port.
func (m *Manager) Disconnect() {
	m.mu.Lock()
	p := m.port
	sk := m.serial
	stop, done := m.stopRead, m.doneRead
	m.port = nil
	m.serial = nil
	m.stopRead = nil
	m.doneRead = nil
	connected := m.status.Connected
	m.status = Status{Connected: false}
	m.mu.Unlock()

	if sk != nil {
		// Serial line keyer: stop the worker (drops the key/PTT lines) then close.
		sk.Close()
		if p != nil {
			_ = p.Close()
		}
		if connected {
			applog.Printf("winkeyer: serial CW keyer disconnected")
			m.emit()
		}
		return
	}

	if p != nil {
		_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
		_ = p.Close()
	}
	if stop != nil {
		close(stop)
	}
	if done != nil {
		<-done
	}
	if connected {
		applog.Printf("winkeyer: disconnected")
		m.emit()
	}
}

// readLoop drains device→host status bytes. WK status frames have bit7 set
// (0xC0 + flags); 0x80–0xBF carry the speed-pot value; printable bytes are
// the echo of characters being sent. We track busy/idle and the speed pot.
func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
	defer close(done)
	// A panic here (e.g. in a status/echo callback) would otherwise take down
	// the whole app — which showed up as a crash when logging a CW QSO while
	// the keyer was still streaming echo bytes. Recover, log, end the loop.
	defer func() {
		if r := recover(); r != nil {
			applog.Printf("winkeyer: readLoop panic recovered: %v\n%s", r, debug.Stack())
		}
	}()
	buf := make([]byte, 64)
	for {
		select {
		case <-stop:
			return
		default:
		}
		n, err := p.Read(buf)
		if err != nil {
			// Timeout is normal (no data); a real error ends the loop.
			if isTimeout(err) {
				continue
			}
			return
		}
		traceRX(buf[:n])
		for i := 0; i < n; i++ {
			b := buf[i]
			switch {
			case b&0xC0 == 0xC0: // status byte
				busy := b&0x04 != 0 // bit2 = busy (sending)
				m.mu.Lock()
				changed := m.status.Busy != busy
				m.status.Busy = busy
				m.mu.Unlock()
				if changed {
					m.emit()
				}
			case b&0xC0 == 0x80: // speed-pot value: 0x80 | (wpm-min)
				// Reported relative to the configured pot range; surfaced as-is.
			default:
				// Echo of a keyed character (serial echo). Surface printable
				// ones so the UI can show the text as it's transmitted.
				if b >= 0x20 && b < 0x7F && m.onEcho != nil {
					m.onEcho(string(rune(b)))
				}
			}
		}
	}
}

func isTimeout(err error) bool {
	type timeout interface{ Timeout() bool }
	if t, ok := err.(timeout); ok {
		return t.Timeout()
	}
	return strings.Contains(strings.ToLower(err.Error()), "timeout")
}

// TraceEnabled reports whether the protocol trace is running.
//
// The settings dialog needs it: the checkbox is React state that starts false on
// every mount, so a trace switched on stayed on in the backend while the box
// reappeared unticked — the operator ticks it again, which switches it OFF, and
// the log they then send has no trace in it at all.
func TraceEnabled() bool { return traceOn.Load() }
