package cat

// CW keying through the Yaesu's own keyer — the KY command.
//
// This is the fifth CW engine, alongside WinKeyer, the DTR/RTS line keyer, the
// Icom CI-V keyer and FlexRadio's CWX. The point is the same in each: no extra
// hardware, no second cable. The radio holds the text and keys it with its own
// timing, which is why the character spacing is perfect where a PC keying a line
// through USB latency is not.
//
//	KY;           → KY0;  buffer has room / KY1; buffer full
//	KY <text>;            queue up to 24 characters
//
// The leading SPACE after KY is part of the command, not padding.
//
// CONFIRMED on an FTDX10 (2026-07-29): it refuses this outright, answering "?;"
// to both KY; and KY <text>; — including with PC KEYING on DAKY, the obvious
// candidate, which does NOT help. That model keys from a PC only through the DTR
// line of its second (standard) COM port, which is OpsLog's "serial" engine and
// is confirmed working.
//
// The code stays because KY is documented for the FTDX101 / FT-991A / FT-710
// family. On a rig that refuses it, the error now names the route that works.
//
// STOP is the other uncertain half: Yaesu documents no buffer-clear, see StopCW.

import (
	"errors"
	"fmt"
	"strings"
	"time"
)

// yaesuCWChunk is the most characters one KY command accepts. Longer text is
// split and fed as the rig drains its buffer.
const yaesuCWChunk = 24

// cwStatusLogged makes the KY status reply appear in the log once per run: it
// is a line we have no verified sample of, so the first one is worth keeping.
var cwStatusLogged bool

// yaesuCWAllowed is what the rig's keyer can actually send. Anything else is
// dropped rather than passed through: an unsupported byte can abort the whole
// buffer, losing the rest of the message with no error anywhere.
const yaesuCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"

// SendCW queues a message on the rig's keyer.
//
// Text is filtered, upper-cased and fed in 24-character pieces, waiting for room
// between pieces. Without that wait a long macro would silently lose its tail:
// the rig drops what does not fit rather than reporting an error.
func (y *Yaesu) SendCW(text string) error {
	msg := filterYaesuCW(text)
	if msg == "" {
		return nil
	}
	for len(msg) > 0 {
		n := yaesuCWChunk
		if len(msg) < n {
			n = len(msg)
		}
		chunk := msg[:n]
		msg = msg[n:]
		if err := y.waitCWBuffer(4 * time.Second); err != nil {
			return err
		}
		y.mu.Lock()
		err := y.write("KY " + chunk + ";")
		if err == nil {
			// KY produces no reply when it is accepted — but a rig that REJECTS it
			// answers "?;", and that frame then sat in the buffer until the poll
			// loop's next query picked it up, failed, and the Manager tore the link
			// down. That is the CAT dropping for a few seconds on every macro click.
			//
			// Reading here does two things: it keeps the stray frame off the poll
			// loop, and it turns a silent non-transmission into a stated reason.
			err = y.drainCWReply()
		}
		y.mu.Unlock()
		if err != nil {
			return err
		}
		// When the rig will not tell us how full its buffer is (an FTDX10 answers
		// "?;" to KY;), pace the next piece by how long this one takes to key.
		// Sending everything at once overruns the 24-character buffer and the rig
		// silently drops the rest.
		if len(msg) > 0 && y.cwStatusUnsupported() {
			time.Sleep(yaesuCWDuration(chunk, y.keyerWPM()))
		}
	}
	return nil
}

// drainCWReply reads for a moment after a KY write.
//
// An accepted KY says nothing at all, so silence here is success. A rejection
// answers "?;" — and left unread, that frame was picked up by the poll loop's
// next query, which failed to parse it and took the whole CAT link down with it.
// Reading it here keeps the link up AND turns "nothing was transmitted, no idea
// why" into a stated reason.
//
// The caller holds the mutex.
func (y *Yaesu) drainCWReply() error {
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	deadline := time.Now().Add(200 * time.Millisecond)
	buf := make([]byte, 0, 32)
	tmp := make([]byte, 32)
	for time.Now().Before(deadline) {
		n, err := y.port.Read(tmp)
		if err != nil {
			return nil // a read problem here is the poll loop's business, not ours
		}
		if n == 0 {
			continue
		}
		buf = append(buf, tmp[:n]...)
		for {
			i := strings.IndexByte(string(buf), ';')
			if i < 0 {
				break
			}
			frame := strings.TrimSpace(string(buf[:i+1]))
			buf = buf[i+1:]
			if frame == "?;" {
				// An FTDX10 refuses KY whatever PC KEYING is set to — DAKY included,
				// tested on the radio 2026-07-29. The message therefore points
				// straight at what works, rather than at a setting that will not
				// help.
				return fmt.Errorf("this radio does not accept CW over CAT (it answered \"?;\" to KY). " +
					"Switch the keyer engine to \"Serial port (DTR=CW / RTS=PTT)\" on the rig's OTHER " +
					"COM port — the standard one, CAT keeping the enhanced one — with PC KEYING set " +
					"to DTR in the rig's CW menu")
			}
			debugLog.Printf("yaesu cw: rig answered %q to KY — ignoring", frame)
		}
	}
	return nil // silence = accepted
}

// cwStatusUnsupported reports whether this rig refused the KY status query.
func (y *Yaesu) cwStatusUnsupported() bool {
	y.mu.Lock()
	defer y.mu.Unlock()
	return y.unsupported["KY;"]
}

// keyerWPM is the speed the rig is keying at, for pacing. Falls back to a
// middling 20 wpm when the rig does not report it: too slow only wastes a
// moment, too fast overruns the buffer.
func (y *Yaesu) keyerWPM() int {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.panel.KeySpeed >= 4 {
		return y.panel.KeySpeed
	}
	return 20
}

// yaesuCWDuration estimates how long a piece of text takes to key.
//
// PARIS timing: one word is 50 dit-lengths and a word is 5 characters, so a
// character averages 10 dits and a dit is 1.2/wpm seconds.
func yaesuCWDuration(text string, wpm int) time.Duration {
	if wpm < 4 {
		wpm = 20
	}
	ditMs := 1200.0 / float64(wpm)
	return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
}

// StopCW aborts the message being sent.
//
// Yaesu documents no buffer-clear, so this drops the transmitter instead: TX0
// takes the rig out of transmit, which is what an operator pressing Escape
// actually wants. Anything still queued is not keyed on the air.
//
// It deliberately does NOT send "KY0;" — a plausible-looking clear that the rig
// would read as the CHARACTER zero and dutifully send. A wrong guess here
// transmits, which is worse than an imperfect abort.
func (y *Yaesu) StopCW() error {
	y.mu.Lock()
	defer y.mu.Unlock()
	if y.port == nil {
		return fmt.Errorf("yaesu: not connected")
	}
	return y.write("TX0;")
}

// waitCWBuffer blocks until the keyer reports room, or the deadline passes.
// A rig that never answers the status query is not a reason to refuse to send —
// we go ahead once, and the worst case is the truncation we were avoiding.
func (y *Yaesu) waitCWBuffer(within time.Duration) error {
	deadline := time.Now().Add(within)
	for {
		y.mu.Lock()
		if y.port == nil {
			y.mu.Unlock()
			return fmt.Errorf("yaesu: not connected")
		}
		if y.unsupported["KY;"] {
			y.mu.Unlock()
			return nil // this rig does not report buffer state — pacing covers it
		}
		r, err := y.ask("KY;")
		if err != nil {
			if errors.Is(err, errYaesuUnsupported) {
				if y.unsupported == nil {
					y.unsupported = map[string]bool{}
				}
				y.unsupported["KY;"] = true
				debugLog.Printf("yaesu cw: this rig does not answer KY; — pacing the send by keying time instead")
			} else {
				debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err)
			}
			y.mu.Unlock()
			return nil
		}
		y.mu.Unlock()
		// Only an explicit "full" holds us back.
		//
		// The first version required the reply to be exactly "KY0;" at byte 2, and
		// on a real FTDX10 that refused to send at all: anything the rig phrases
		// differently — a space before the digit, a longer status — read as "still
		// full" and the send died after four seconds having keyed nothing. The rig
		// is the one that knows; when its answer is not a clear "1", the right move
		// is to go ahead rather than to sit and time out.
		// Log the shape once per session: this is a reply we have no verified
		// sample of, and the log is what turns the next surprise into a fact.
		if !cwStatusLogged {
			cwStatusLogged = true
			debugLog.Printf("yaesu cw: KY status reply is %q (full=%v)", r, yaesuCWBufferFull(r))
		}
		if !yaesuCWBufferFull(r) {
			return nil
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("yaesu: the keyer buffer stayed full for %s (last reply %q)", within, r)
		}
		time.Sleep(50 * time.Millisecond)
	}
}

// yaesuCWBufferFull reads the KY status reply.
//
// Deliberately asymmetric: only a clear "1" means full. Anything else — an
// unexpected shape, a reply from another command that arrived first, an empty
// string — is treated as "go ahead". Refusing to send because a status line was
// phrased unexpectedly is the worse failure: the operator gets silence with no
// explanation, where at worst sending early truncates a long message.
func yaesuCWBufferFull(reply string) bool {
	r := strings.TrimSpace(reply)
	if !strings.HasPrefix(strings.ToUpper(r), "KY") {
		return false
	}
	for _, c := range r[2:] {
		switch c {
		case '0':
			return false
		case '1':
			return true
		case ' ', ';':
			continue
		default:
			return false // not a status digit — don't block on it
		}
	}
	return false
}

// filterYaesuCW upper-cases and strips what the keyer cannot send.
func filterYaesuCW(text string) string {
	var b strings.Builder
	for _, r := range strings.ToUpper(text) {
		// Any whitespace becomes a word gap FIRST. Dropping tabs and newlines as
		// "not in the allowed set" glued the words either side together: a macro
		// written on two lines went out as CQCQ.
		if r == '\t' || r == '\n' || r == '\r' {
			b.WriteByte(' ')
			continue
		}
		if strings.ContainsRune(yaesuCWAllowed, r) {
			b.WriteRune(r)
		}
	}
	// Runs of spaces become one: the rig sends each as a word gap, so three in a
	// row is three gaps and the message crawls.
	return strings.Join(strings.Fields(b.String()), " ")
}
