package cat

// icomnet.go — the NETWORK transport for the Icom backend. It talks the Icom IP
// remote protocol (the LAN server built into the IC-7610, the one the Icom
// Remote Utility speaks) directly, and presents the tunnelled CI-V byte stream
// as a plain civTransport (Read/Write). So the entire IcomController surface —
// freq/mode, receive-DSP, TX, scope, RIT, CW — runs unchanged over the network;
// only the transport differs. OpsLog thus replaces BOTH the Remote Utility and
// RS-BA1.
//
// The protocol (framing, passcode table, packet offsets, power-on) was
// reimplemented from the public wfview protocol and verified byte-for-byte
// against real Remote-Utility captures. No GPLv3 code is copied.
//
// Three UDP streams exist on the rig (control 50001 / CI-V 50002 / audio 50003);
// this transport uses control + CI-V (audio is not needed for CAT). Connect:
//   control: areYouThere→iAmHere→areYouReady→iAmReady → login → token → conninfo
//   civ:     areYouThere→…→iAmReady → openClose(open) → power-on
// then CI-V flows in data packets. A pump goroutine keeps both streams alive
// (ping replies + idle keepalives) and feeds received CI-V bytes to Read.

import (
	"encoding/binary"
	"fmt"
	"io"
	"net"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

var icnLE = binary.LittleEndian
var icnBE = binary.BigEndian

// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
//
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
// non-nil the conninfo asks the rig to stream audio and each received payload is
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
// so enabling it can't affect freq/mode/DSP control.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
	if civAddr < 0 || civAddr > 0xFF {
		civAddr = 0x98 // IC-7610
	}
	if digitalDefault == "" {
		digitalDefault = "FT8"
	}
	b := &IcomSerial{
		portName:   host,
		rigAddr:    byte(civAddr),
		digital:    strings.ToUpper(digitalDefault),
		model:      "Icom",
		scopeFixed: true,
	}
	// lastNetConnect is when the previous session came up, and it drives a
	// deliberate pause: on a real IC-7760 a session sometimes goes deaf on CI-V
	// while the rig still holds it half-open, and a session dialled straight
	// back in answers for a second and is then strangled when the rig finally
	// purges the old one — reconnect, die, reconnect, die, twenty seconds a
	// lap and no audio the whole time. When the last session died YOUNG, wait
	// out the rig's cleanup before dialling again; a session that lived long
	// reconnects immediately, as ever.
	var lastNetConnect time.Time
	b.open = func() (civTransport, error) {
		if strings.TrimSpace(host) == "" {
			return nil, fmt.Errorf("no rig host configured")
		}
		b.dialMu.Lock()
		cancel := b.dialCancel
		b.dialMu.Unlock()
		if !lastNetConnect.IsZero() && time.Since(lastNetConnect) < 90*time.Second {
			debugLog.Printf("icom net: the last session died young — pausing 20 s before redialling so the rig can purge it first")
			for i := 0; i < 40; i++ {
				if icnCanceled(cancel) {
					return nil, errDialCanceled
				}
				time.Sleep(500 * time.Millisecond)
			}
		}
		tr, err := dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
		if err == nil {
			lastNetConnect = time.Now()
		}
		return tr, err
	}
	return b
}

// errDialCanceled is returned by dialIcomNet when Interrupt() aborts the dial
// (Stop/Start). The Manager treats it like any connect error and simply stops.
var errDialCanceled = fmt.Errorf("dial canceled")

// icnCanceled reports whether the dial has been asked to abort.
func icnCanceled(cancel <-chan struct{}) bool {
	if cancel == nil {
		return false
	}
	select {
	case <-cancel:
		return true
	default:
		return false
	}
}

// icomNet is the connected network transport. It satisfies civTransport.
type icomNet struct {
	ctrl *net.UDPConn // control stream (50001)
	civ  *net.UDPConn // CI-V stream (50002)

	cID, cRemote uint32 // control stream ids
	vID, vRemote uint32 // civ stream ids

	// Tracked-packet sequence + CI-V data sequence. Written only by Write (on the
	// CAT goroutine) and during dial — never by the pump — so no lock is needed.
	vTracked uint16
	vCivSeq  uint16
	seqMu    sync.Mutex // guards vTracked/vCivSeq: the command loop AND the pump's quiet-recovery both send
	// Client-ping sequence counters, one per stream, and the connection's epoch
	// for the ping timestamps. Owned by their pump goroutines — no locking.
	civPingSeq  uint16
	ctrlPingSeq uint16
	started     time.Time
	// txCiv counts CI-V command packets sent, and txAtData snapshots it at the
	// last received CI-V data. Their difference during a silence answers the
	// question the reconnects cannot: were we still ASKING when the answers
	// stopped? Zero writes-since-data would mean the fault is our own poll
	// loop, not the rig — and RS-BA1 showing no such dropouts points that way.
	txCiv    atomic.Uint32
	txAtData atomic.Uint32
	// WHAT was asked, not just how much. A rig that answers at connect and then
	// never again has usually been sent something it does not like, and counting
	// the unanswered commands says nothing about which one that was. The last few
	// command headers are kept so the silence report can name them — the only way
	// to find a poison command on a radio nobody here has.
	cmdMu   sync.Mutex
	lastCmd []string

	rx      chan []byte // CI-V byte chunks from civPump → Read (control replies)
	scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
	//                        stream can't crowd control replies out (→ ScopeChan)
	leftover []byte        // partial chunk not yet returned by Read (Read-only)
	readTO   time.Duration // Read timeout (SetReadTimeout)

	// sentBuf keeps recently-sent tracked civ packets (by outer seq) so we can
	// answer the rig's UDP retransmit requests. Written by Write (CAT goroutine),
	// read by the pumps → guarded by sentMu.
	sentMu  sync.Mutex
	sentBuf map[uint16][]byte

	// Control-stream auth state, carried out of dial so ctrlPump can RENEW the
	// login token every ~45 s. The rig invalidates the session ~2 min after login
	// without renewal (this was the "loses control after 2 min" drop — RS-BA1/the
	// Remote Utility renew too). Owned solely by ctrlPump after dial → no lock.
	cTracked uint16            // control-stream tracked seq (continues after dial)
	cAuthSeq uint16            // token-packet innerseq
	cToken   uint32            // login token (opaque, echoed back verbatim)
	cTokReq  uint16            // token-request id (echoed)
	cSentBuf map[uint16][]byte // control-stream retransmit buffer (token renewals)

	// Receive-side retransmit (CI-V stream): track the rig's data-packet send seq
	// and ask it to resend any gap. Under the scope stream, UDP drops are common;
	// without recovering them the gaps accumulate and the rig drops the WHOLE
	// session after ~20 s (RS-BA1/wfview request retransmits, which is why they
	// stay up with the panadapter on). Owned solely by civPump → no lock.
	rxHaveSeq bool
	rxLastSeq uint16
	rxMissing map[uint16]int

	// lastRx is the UnixNano of the last packet received from the rig (any type),
	// updated by both pumps. The rig's network server answers pings/idles even
	// when the RADIO is in standby, so this tracks the CONTROL-LINK liveness
	// independently of whether CI-V is replying — letting ReadState tell "rig off
	// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
	lastRx atomic.Int64

	// dead is set when the rig explicitly tears the session down (control 0x05):
	// Alive() then returns false immediately so ReadState fails on the next poll and
	// the manager reconnects cleanly, instead of waiting out the 6 s lastRx timeout.
	dead atomic.Bool

	// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
	// Torn down alongside the CI-V/control streams in Close.
	audio *icomAudio

	done      chan struct{}
	closeOnce sync.Once
}

// ScopeChan exposes the raw scope (0x27) CI-V frames for the scope feeder.
// Satisfies scopeTransport in icomserial.go.
func (n *icomNet) ScopeChan() <-chan []byte { return n.scopeRx }

// icnEnqueueDrop pushes onto a bounded channel, discarding the oldest entry when
// full — a lagging consumer never blocks the producer (used for the scope stream,
// where only the latest sweep matters).
func icnEnqueueDrop(ch chan []byte, v []byte) {
	select {
	case ch <- v:
	default:
		select {
		case <-ch:
		default:
		}
		select {
		case ch <- v:
		default:
		}
	}
}

func (n *icomNet) SetReadTimeout(d time.Duration) error { n.readTO = d; return nil }
func (n *icomNet) SetDTR(bool) error                    { return nil } // n/a on the network
func (n *icomNet) SetRTS(bool) error                    { return nil }

// markRx records that a packet just arrived from the rig (control-link liveness).
func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) }

// Alive reports whether the rig's network server is still talking to us. The rig
// pings/idles continuously (even in standby), so a gap means the link — not just
// the radio — is gone. Independent of CI-V replies, so a powered-off rig still
// reads as Alive and the session isn't torn down. Satisfies aliveTransport.
func (n *icomNet) Alive() bool {
	if n.dead.Load() {
		return false // rig sent an explicit disconnect — reconnect now, don't wait
	}
	last := n.lastRx.Load()
	if last == 0 {
		return true // just connected, nothing received yet — give it a chance
	}
	return time.Since(time.Unix(0, last)) < 6*time.Second
}

// Read returns tunnelled CI-V bytes, mimicking a serial port: (0,nil) on
// timeout, (n,nil) with data, (0,err) when the link is closed.
func (n *icomNet) Read(p []byte) (int, error) {
	if len(n.leftover) > 0 {
		k := copy(p, n.leftover)
		n.leftover = n.leftover[k:]
		return k, nil
	}
	to := n.readTO
	if to <= 0 {
		to = 60 * time.Millisecond
	}
	select {
	case f, ok := <-n.rx:
		if !ok {
			return 0, io.EOF
		}
		k := copy(p, f)
		if k < len(f) {
			n.leftover = append(n.leftover[:0], f[k:]...)
		}
		return k, nil
	case <-time.After(to):
		return 0, nil // timeout, no data
	case <-n.done:
		return 0, io.EOF
	}
}

// Write wraps raw CI-V bytes (FE FE … FD) in a data packet and sends them.
func (n *icomNet) Write(p []byte) (int, error) {
	if icnTrace {
		debugLog.Printf("icom net TX: % X", p)
	}
	n.seqMu.Lock()
	seq, civSeq := n.vTracked, n.vCivSeq
	n.vTracked++
	n.vCivSeq++
	n.seqMu.Unlock()
	n.txCiv.Add(1)
	n.noteCmd(p)
	pkt := icnCivData(seq, n.vID, n.vRemote, civSeq, p)
	n.sentMu.Lock()
	n.sentBuf[seq] = pkt
	delete(n.sentBuf, seq-1024) // keep the buffer bounded (~last 1024 packets) so
	// the rig's retransmit requests still hit even under sustained CW + poll load
	n.sentMu.Unlock()
	if _, err := n.civ.Write(pkt); err != nil {
		return 0, err
	}
	return len(p), nil
}

// noteCmd remembers the command bytes of a CI-V frame — everything after the
// preamble and the two addresses, up to four bytes, which is command,
// sub-command and the first of the data.
func (n *icomNet) noteCmd(p []byte) {
	if len(p) < 5 {
		return
	}
	body := p[4:]
	if len(body) > 4 {
		body = body[:4]
	}
	n.cmdMu.Lock()
	n.lastCmd = append(n.lastCmd, fmt.Sprintf("% X", body))
	if len(n.lastCmd) > 8 {
		n.lastCmd = n.lastCmd[len(n.lastCmd)-8:]
	}
	n.cmdMu.Unlock()
}

// recentCmds is what noteCmd collected, oldest first.
func (n *icomNet) recentCmds() string {
	n.cmdMu.Lock()
	defer n.cmdMu.Unlock()
	if len(n.lastCmd) == 0 {
		return "none"
	}
	return strings.Join(n.lastCmd, " | ")
}

// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing
// the network transport. Off by default (the connect-step logs stay); flip to
// true to trace every TX/RX again.
var icnTrace = false

func (n *icomNet) Close() error {
	n.closeOnce.Do(func() {
		close(n.done)
		if n.audio != nil {
			n.audio.Close()
		}
		// Tell the rig we're leaving so it frees its SINGLE control session at
		// once. If it never gets a disconnect it holds the session for minutes and
		// refuses every new login — which is why a lost link (or a hard app exit)
		// left the rig un-reconnectable, even from the Icom Remote Utility. UDP is
		// lossy, so send openClose(close) + disconnect on both streams a few times.
		// The whole teardown is bounded to ~90 ms so it never stalls the caller
		// (Settings "Save & Close" / a reconnect's Disconnect).
		for i := 0; i < 3; i++ {
			_, _ = n.civ.Write(icnOpenClose(n.vTracked, n.vID, n.vRemote, n.vCivSeq, 0x00)) // close CI-V
			_, _ = n.civ.Write(icnCtrl(0x05, 0, n.vID, n.vRemote))                          // disconnect civ
			_, _ = n.ctrl.Write(icnCtrl(0x05, 0, n.cID, n.cRemote))                         // disconnect ctrl
			time.Sleep(25 * time.Millisecond)
		}
		debugLog.Printf("icom net: sent disconnect to rig (session released)")
		_ = n.civ.Close()
		_ = n.ctrl.Close()
	})
	return nil
}

// ctrlPump keeps the control stream (50001) alive: replies to the rig's pings,
// sends idle keepalives, RENEWS the login token every ~45 s (without this the rig
// invalidates the session after ~2 min → total loss of control), and answers the
// rig's retransmit requests for those tracked control packets. Its own goroutine
// so it never throttles civPump.
func (n *icomNet) ctrlPump() {
	buf := make([]byte, 4096)
	lastIdle := time.Now()
	lastCtrlPing := time.Now()
	lastToken := time.Now() // token was just granted during dial
	for {
		select {
		case <-n.done:
			return
		default:
		}
		_ = n.ctrl.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
		if k, err := n.ctrl.Read(buf); err == nil && k >= 16 {
			n.markRx()
			switch icnLE.Uint16(buf[4:]) {
			case 0x07: // ping
				_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
			case 0x00: // idle keepalive from the rig — nothing to do
			case 0x01: // retransmit request — resend from the CONTROL sent-buffer
				if k >= 8 {
					n.ctrlResend(icnLE.Uint16(buf[6:]))
				}
			case 0x05: // rig-initiated disconnect — it dropped US
				n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
				debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
			default:
				// Anything else on the control stream is (almost always) the rig's
				// reply to our token renewal. Log it: a 0x40-length token packet
				// carries a result code, and if the rig is REJECTING renewals this is
				// where the ~2-3 min disconnect originates. The hex makes the cause
				// visible in the friend's log without a protocol analyzer.
				if k >= 0x18 {
					debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:0x18])
				} else {
					debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:k])
				}
			}
		}
		if time.Since(lastIdle) > 100*time.Millisecond {
			_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
			lastIdle = time.Now()
		}
		// Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
		// for one lost renewal + its retransmit before the token would lapse.
		if time.Since(lastCtrlPing) > 500*time.Millisecond {
			n.ctrlPingSeq++
			_, _ = n.ctrl.Write(icnPing(n.ctrlPingSeq, n.cID, n.cRemote, uint32(time.Since(n.started).Milliseconds())))
			lastCtrlPing = time.Now()
		}
		if time.Since(lastToken) > 30*time.Second {
			n.renewToken()
			lastToken = time.Now()
		}
	}
}

// renewToken re-authorizes the session (control 0x40 token packet, requesttype
// 0x05). Tracked so a lost renewal can be retransmitted. Runs only on ctrlPump,
// the sole owner of the control-stream auth state, so no locking is needed.
func (n *icomNet) renewToken() {
	seq := n.cTracked
	pkt := icnTokenRenew(seq, n.cAuthSeq, n.cTokReq, n.cID, n.cRemote, n.cToken)
	n.cTracked++
	n.cAuthSeq++
	n.cSentBuf[seq] = pkt
	delete(n.cSentBuf, seq-256)
	_, _ = n.ctrl.Write(pkt)
	debugLog.Printf("icom net: token renewed (seq %d)", seq)
}

// ctrlResend answers a control-stream retransmit request from the control
// sent-buffer (token renewals). Separate from resend(), which owns the CI-V
// buffer — the two streams have independent sequence spaces.
func (n *icomNet) ctrlResend(seq uint16) {
	if pkt := n.cSentBuf[seq]; pkt != nil {
		_, _ = n.ctrl.Write(pkt)
	}
}

// civPump owns the CI-V stream (50002): drains it as fast as packets arrive
// (its own goroutine — not throttled by the control reads), replies to pings,
// answers retransmit requests, skips scope frames, and feeds control CI-V bytes
// to Read via n.rx.
func (n *icomNet) civPump() {
	buf := make([]byte, 8192)
	lastIdle := time.Now()
	lastReq := time.Now()
	// Diagnosis for the recurring 2-3-minute silence a real IC-7760 shows on
	// this stream while the control link stays alive: when the stream has been
	// quiet for 10 s, say so ONCE, with the last read error — a socket error
	// and a rig that stopped talking are different repairs, and the 30 s
	// watchdog that follows cannot tell them apart from where it sits.
	debugLog.Printf("icom net: client pings armed on the CI-V stream (every 500 ms)")
	lastPkt := time.Now()
	lastPing := time.Now()
	lastData := time.Now()   // CI-V payload packets (replies + transceive)
	lastScope := time.Time{} // scope frames within those
	var lastErr error
	quietSaid := false
	gaveUp := false
	// sawData: this session has heard at least one CI-V payload. A radio in
	// STANDBY is silent on CI-V by design — only the transport chats — and the
	// quiet-recovery below must not treat that as a fault: it was tearing the
	// session down every 15 s, and with it the console and its ON button, so a
	// radio that was off when OpsLog started could never be turned on at all.
	sawData := false
	for {
		select {
		case <-n.done:
			return
		default:
		}
		_ = n.civ.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
		k, err := n.civ.Read(buf)
		if err != nil {
			if e, ok := err.(net.Error); !ok || !e.Timeout() {
				lastErr = err // a REAL socket error, not the read deadline
			}
		}
		if err == nil && k >= 16 {
			lastPkt = time.Now()
			n.markRx()
			switch typ := icnLE.Uint16(buf[4:]); {
			case typ == 0x07: // ping
				_, _ = n.civ.Write(icnPingReply(buf[:k], n.vID, n.vRemote))
			case typ == 0x01: // retransmit request — resend that seq
				if k >= 8 {
					n.resend(icnLE.Uint16(buf[6:]))
				}
			case typ == 0x05: // rig-initiated disconnect — it dropped US
				n.dead.Store(true) // make Alive() fail now → prompt clean reconnect
				debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig")
			case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data
				if quietSaid {
					debugLog.Printf("icom net: CI-V replies are back after %s", time.Since(lastData).Round(time.Second))
					quietSaid, gaveUp = false, false
				}
				sawData = true
				lastData = time.Now()
				n.txAtData.Store(n.txCiv.Load())
				n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit
				civBytes := buf[0x15:k]
				cp := append([]byte(nil), civBytes...)
				// Scope (0x27) frames go to their OWN channel: the panadapter streams
				// continuously as large frames and would otherwise crowd control
				// replies out of rx (every command would then time out). The scope
				// feeder in IcomSerial picks them up. Everything else is a control
				// reply → rx → Read.
				if len(civBytes) >= 5 && civBytes[4] == 0x27 {
					lastScope = time.Now()
					icnEnqueueDrop(n.scopeRx, cp)
					break
				}
				if icnTrace {
					debugLog.Printf("icom net RX: % X", civBytes)
				}
				select {
				case n.rx <- cp:
				case <-n.done:
					return
				default: // buffer full — drop oldest, enqueue newest
					select {
					case <-n.rx:
					default:
					}
					select {
					case n.rx <- cp:
					default:
					}
				}
			}
		}
		if !quietSaid && sawData && time.Since(lastData) > 10*time.Second {
			quietSaid = true
			scopeAge := "never"
			if !lastScope.IsZero() {
				scopeAge = time.Since(lastScope).Round(time.Second).String()
			}
			debugLog.Printf("icom net: no CI-V DATA for 10 s (transport last heard %s ago; last scope frame %s ago; last socket error: %v; missing-seq backlog: %d; CI-V commands SENT since the last answer: %d)",
				time.Since(lastPkt).Round(time.Second), scopeAge, lastErr, len(n.rxMissing), n.txCiv.Load()-n.txAtData.Load())
			debugLog.Printf("icom net: the last CI-V commands sent, oldest first: %s", n.recentCmds())
			// THE AUDIO STREAM IS THE FIRST SUSPECT, AND ONLY THE LOG CAN SAY SO.
			//
			// The RX audio stream is experimental and shares the rig's session with
			// CI-V. The shape seen in the field is exactly this one: audio packets
			// arriving by the hundred while not one CI-V reply comes back, the
			// watchdog tearing the session down, and the whole thing beginning
			// again — a loop an operator reads as "the Icom keeps disconnecting",
			// with nothing pointing at the switch that would end it.
			if n.audio != nil && n.audio.packets() > 0 {
				debugLog.Printf("icom net: the RX audio stream is running and still delivering while CI-V has gone quiet — that option is experimental and shares this session. If the drops continue, turn OFF \"Stream RX audio over the network\" in Settings → CAT and see whether control steadies.")
			}
			// And try the gentle repair before the 30 s watchdog tears the whole
			// session down: if the rig quietly closed the CI-V data flow (the
			// transport is still chatting, so the session itself stands), saying
			// "open" again on the same stream is all it should take. Harmless
			// when the cause is elsewhere — the watchdog still fires at 30 s.
			n.seqMu.Lock()
			seq, civSeq := n.vTracked, n.vCivSeq
			n.vTracked++
			n.vCivSeq++
			n.seqMu.Unlock()
			ocPkt := icnOpenClose(seq, n.vID, n.vRemote, civSeq, 0x04)
			n.sentMu.Lock()
			n.sentBuf[seq] = ocPkt
			n.sentMu.Unlock()
			_, _ = n.civ.Write(ocPkt)
			debugLog.Printf("icom net: re-sent the CI-V open on the existing stream")
		}
		// The reopen was given five seconds. On a real IC-7760 it never works —
		// the rig ignores it and only a fresh session brings CI-V back — so
		// rather than sit out the 30 s watchdog, fail the link NOW and let the
		// manager rebuild it: the outage drops from ~35 s to ~15.
		if quietSaid && !gaveUp && time.Since(lastData) > 15*time.Second {
			gaveUp = true
			n.dead.Store(true)
			debugLog.Printf("icom net: the reopen did not bring CI-V back — forcing a fresh session")
		}
		if time.Since(lastIdle) > 150*time.Millisecond {
			// Idles carry a REAL sequence number, drawn from the same counter as
			// the data packets, and sit in the retransmit buffer like them —
			// which is how RS-BA1 and wfview number theirs. Ours used seq 0 on
			// every idle, seven a second, interleaved with properly-numbered
			// data; a rig that follows the sequence tolerates that for a minute
			// or so and then stops serving CI-V data on the stream — which is
			// the shape of every dropout this loaner IC-7760 has shown, with
			// the scope and the TX path both since eliminated.
			n.seqMu.Lock()
			iseq := n.vTracked
			n.vTracked++
			n.seqMu.Unlock()
			ipkt := icnCtrl(0x00, iseq, n.vID, n.vRemote)
			n.sentMu.Lock()
			n.sentBuf[iseq] = ipkt
			n.sentMu.Unlock()
			_, _ = n.civ.Write(ipkt)
			lastIdle = time.Now()
		}
		if time.Since(lastReq) > 100*time.Millisecond {
			n.sendRetransmitReq()
			lastReq = time.Now()
		}
		if time.Since(lastPing) > 500*time.Millisecond {
			n.civPingSeq++
			_, _ = n.civ.Write(icnPing(n.civPingSeq, n.vID, n.vRemote, uint32(time.Since(n.started).Milliseconds())))
			lastPing = time.Now()
		}
	}
}

// icnMaxMissing caps the outstanding retransmit backlog; a bigger jump is treated
// as a wrap/desync and the tracker resets rather than requesting a storm.
const icnMaxMissing = 50

// trackRxSeq records the rig's data-packet send seq (outer seq @0x06) and flags
// any forward gap as missing so sendRetransmitReq can ask for it. Handles uint16
// wrap via the signed distance; ignores duplicates and already-seen packets.
func (n *icomNet) trackRxSeq(seq uint16) {
	if !n.rxHaveSeq {
		n.rxHaveSeq = true
		n.rxLastSeq = seq
		return
	}
	switch d := int16(seq - n.rxLastSeq); {
	case d == 0: // duplicate
	case d < 0: // an older seq arrived — a retransmit we were missing
		delete(n.rxMissing, seq)
	case d == 1: // in order
		n.rxLastSeq = seq
	case int(d) <= icnMaxMissing: // forward gap — mark the in-between seqs missing
		for f := n.rxLastSeq + 1; f != seq; f++ {
			n.rxMissing[f] = 0
		}
		n.rxLastSeq = seq
	default: // huge jump (wrap/desync) — reset to avoid a false retransmit storm
		n.rxMissing = make(map[uint16]int)
		n.rxLastSeq = seq
	}
}

// sendRetransmitReq asks the rig to resend any CI-V data packets we detected as
// missing. Each seq is requested up to 4 times then dropped. Mirrors the Remote
// Utility/wfview format: a single miss = a 16-byte control (type 0x01, seq set);
// several = a control header + a list of [lo hi lo hi] per seq.
func (n *icomNet) sendRetransmitReq() {
	if len(n.rxMissing) == 0 {
		return
	}
	if len(n.rxMissing) > icnMaxMissing {
		n.rxMissing = make(map[uint16]int) // hopelessly behind — flush and move on
		return
	}
	var seqs []uint16
	for s, cnt := range n.rxMissing {
		if cnt >= 4 {
			delete(n.rxMissing, s)
			continue
		}
		n.rxMissing[s] = cnt + 1
		seqs = append(seqs, s)
	}
	switch {
	case len(seqs) == 0:
		return
	case len(seqs) == 1:
		_, _ = n.civ.Write(icnCtrl(0x01, seqs[0], n.vID, n.vRemote))
	default:
		b := make([]byte, 16+4*len(seqs))
		icnLE.PutUint32(b[0:], uint32(len(b)))
		icnLE.PutUint16(b[4:], 0x01) // type = retransmit request
		icnLE.PutUint32(b[8:], n.vID)
		icnLE.PutUint32(b[12:], n.vRemote)
		off := 16
		for _, s := range seqs {
			icnLE.PutUint16(b[off:], s)
			icnLE.PutUint16(b[off+2:], s)
			off += 4
		}
		_, _ = n.civ.Write(b)
	}
}

// resend re-transmits a previously-sent tracked CI-V packet the rig asks for
// (its UDP retransmit mechanism). Without this the rig drops the whole session
// after a few seconds when a packet is lost under load.
func (n *icomNet) resend(seq uint16) {
	n.sentMu.Lock()
	pkt := n.sentBuf[seq]
	n.sentMu.Unlock()
	if pkt != nil {
		_, _ = n.civ.Write(pkt)
	} else {
		// The rig asked for a packet we've already evicted (>256 sent since). It
		// can't fill its gap → it eventually drops the session. If this shows up in
		// the log around a disconnect, the send buffer is too small for the load.
		debugLog.Printf("icom net: retransmit MISS for seq %d (already evicted)", seq)
	}
}

// ------------------------- connect -------------------------

func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
	wantAudio := audioSink != nil
	debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
	// ---- control stream (50001): handshake → login → token → conninfo ----
	craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
	if err != nil {
		return nil, err
	}
	ctrl, err := net.DialUDP("udp4", nil, craddr)
	if err != nil {
		return nil, fmt.Errorf("dial control: %w", err)
	}
	cID := icnLocalID(ctrl)
	cRemote, err := icnHandshake(ctrl, cID, cancel)
	if err != nil {
		_ = ctrl.Close()
		debugLog.Printf("icom net: control handshake FAILED (rig unreachable at %s:50001?): %v", host, err)
		return nil, fmt.Errorf("control handshake: %w", err)
	}
	debugLog.Printf("icom net: control link up (rig id 0x%08X) — logging in", cRemote)

	var cTracked, cInner uint16 = 1, 1
	tokReq := uint16(0x0c77)
	_, _ = ctrl.Write(icnLogin(cTracked, cInner, tokReq, cID, cRemote, 0, user, pass, compName))
	cTracked++
	cInner++

	var token uint32
	buf := make([]byte, 2048)
	deadline := time.Now().Add(5 * time.Second)
	for token == 0 && time.Now().Before(deadline) {
		if icnCanceled(cancel) {
			_ = ctrl.Close()
			return nil, errDialCanceled
		}
		p, ok := icnRecv(ctrl, 200, buf)
		if !ok {
			continue
		}
		length := icnLE.Uint32(p[0:])
		typ := icnLE.Uint16(p[4:])
		if typ == 0x00 && length == 0x60 && len(p) >= 0x34 { // login response
			token = icnLE.Uint32(p[0x1c:])
			if e := icnLE.Uint32(p[0x30:]); e != 0 || token == 0 {
				_ = ctrl.Close()
				debugLog.Printf("icom net: LOGIN REJECTED (err=0x%08X) — wrong Network User1 ID/Password", e)
				return nil, fmt.Errorf("login rejected — check the rig's Network User1 ID/Password")
			}
			_, _ = ctrl.Write(icnToken(cTracked, cInner, tokReq, cID, cRemote, token))
			cTracked++
			cInner++
			debugLog.Printf("icom net: LOGIN OK, token 0x%08X", token)
		} else if typ == 0x07 {
			_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
		}
	}
	if token == 0 {
		_ = ctrl.Close()
		debugLog.Printf("icom net: login TIMED OUT (no token in 5s) — check host/credentials")
		return nil, fmt.Errorf("login timed out (no token) — check host/credentials")
	}

	// Learn the rig's MAC from its conninfo push (144B) to echo in our conninfo.
	var rigMAC []byte
	macEnd := time.Now().Add(1200 * time.Millisecond)
	for time.Now().Before(macEnd) {
		if icnCanceled(cancel) {
			_ = ctrl.Close()
			return nil, errDialCanceled
		}
		p, ok := icnRecv(ctrl, 150, buf)
		if !ok {
			continue
		}
		if len(p) >= 0x30 && icnLE.Uint32(p[0:]) == 0x90 { // 144-byte conninfo push
			rigMAC = append([]byte(nil), p[0x2a:0x30]...)
		}
		if icnLE.Uint16(p[4:]) == 0x07 {
			_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
		}
		if rigMAC != nil {
			break
		}
	}
	if rigMAC == nil {
		rigMAC = make([]byte, 6)
	}

	var rxEnable byte
	if wantAudio {
		rxEnable = 0x01 // ask the rig to stream RX audio on 50003
	}
	_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
	cTracked++
	cInner++
	drainEnd := time.Now().Add(500 * time.Millisecond)
	for time.Now().Before(drainEnd) {
		if icnCanceled(cancel) {
			_ = ctrl.Close()
			return nil, errDialCanceled
		}
		if p, ok := icnRecv(ctrl, 100, buf); ok && icnLE.Uint16(p[4:]) == 0x07 {
			_, _ = ctrl.Write(icnPingReply(p, cID, cRemote))
		}
	}

	// ---- CI-V stream (50002): bind LOCAL :50002 (the announced civport) ----
	vraddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50002"))
	if err != nil {
		_ = ctrl.Close()
		return nil, err
	}
	debugLog.Printf("icom net: conninfo sent (rig mac % X) — opening CI-V stream", rigMAC)
	civ, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50002}, vraddr)
	if err != nil {
		_ = ctrl.Close()
		debugLog.Printf("icom net: cannot bind local :50002 — the Icom Remote Utility is probably still running: %v", err)
		return nil, fmt.Errorf("dial CI-V (local :50002 — is the Icom Remote Utility still running?): %w", err)
	}
	vID := icnLocalID(civ)
	vRemote, err := icnHandshake(civ, vID, cancel)
	if err != nil {
		_ = civ.Close()
		_ = ctrl.Close()
		debugLog.Printf("icom net: CI-V handshake FAILED: %v", err)
		return nil, fmt.Errorf("CI-V handshake: %w", err)
	}
	debugLog.Printf("icom net: CI-V link up — opening the CI-V data flow (rig power left to the ON button)")

	// Bigger receive buffers so a burst of scope/CI-V packets doesn't overflow
	// (dropped packets → the rig's retransmit requests → session drop).
	_ = ctrl.SetReadBuffer(1 << 20)
	_ = civ.SetReadBuffer(1 << 20)

	n := &icomNet{
		ctrl: ctrl, civ: civ,
		cID: cID, cRemote: cRemote, vID: vID, vRemote: vRemote,
		vTracked: 1, vCivSeq: 1,
		rx:        make(chan []byte, 256),
		scopeRx:   make(chan []byte, 8),
		sentBuf:   make(map[uint16][]byte),
		rxMissing: make(map[uint16]int),
		done:      make(chan struct{}),
		// Auth state for periodic token renewal (see ctrlPump). cTracked/cAuthSeq
		// continue the control-stream sequences from where the dial's login/token/
		// conninfo left off.
		cTracked: cTracked, cAuthSeq: cInner,
		cToken: token, cTokReq: tokReq,
		cSentBuf: make(map[uint16][]byte),
		started:  time.Now(),
	}
	n.markRx() // the successful handshake counts as initial rig activity
	// openClose(open) starts the CI-V data flow. We intentionally DO NOT power the
	// rig on here — that's a manual ON button now (the user asked not to wake the
	// rig at launch). If the rig is in standby the control/CI-V streams still stay
	// up and Alive() stays true (the rig's server answers pings even when the radio
	// is off), so the session doesn't flap; CI-V just stays silent until ON.
	ocPkt := icnOpenClose(n.vTracked, vID, vRemote, n.vCivSeq, 0x04)
	n.sentBuf[n.vTracked] = ocPkt
	_, _ = civ.Write(ocPkt)
	n.vTracked++
	n.vCivSeq++

	// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
	// stream audio; open the socket + handshake now. A failure here is NON-fatal:
	// CAT works without audio, so we log and continue rather than tear down a
	// perfectly good control/CI-V session.
	//
	// BEFORE the pumps start, because the conninfo below touches the control-
	// stream auth state, and after ctrlPump is running that state belongs to it.
	if wantAudio {
		if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
			debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
		} else {
			n.audio = a
			// AND THE CONNINFO AGAIN, NOW THAT SOMEBODY IS LISTENING ON 50003.
			//
			// The first one goes out during the login, before this socket exists —
			// it has to, since it is what authorises the stream. So the rig is told
			// to send audio to a port nothing is bound to yet, and what comes back
			// is an ICMP port-unreachable; it then backs off, and the audio appears
			// only when its own retry timer comes round. An operator timed that at
			// twenty to thirty seconds of silence after switching the speakers on,
			// and one log here shows a minute and a half.
			//
			// Re-sent once the port is open, so the rig starts streaming into a
			// socket that is ready for it. Idempotent — the same message the session
			// already carries, which is why RS-BA1 repeats it too.
			pkt := icnConnInfo(n.cTracked, n.cAuthSeq, n.cTokReq, n.cID, n.cRemote, n.cToken, user, rigMAC, 50002, 50003, 0x01)
			n.cSentBuf[n.cTracked] = pkt
			n.cTracked++
			n.cAuthSeq++
			if _, err := ctrl.Write(pkt); err != nil {
				debugLog.Printf("icom net: could not re-send the conninfo after opening the audio port: %v", err)
			} else {
				debugLog.Printf("icom net: conninfo re-sent now that :50003 is listening — the rig can start the audio at once")
			}
		}
	}

	go n.ctrlPump()
	go n.civPump()

	return n, nil
}

// icnHandshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady.
func icnHandshake(c *net.UDPConn, myID uint32, cancel <-chan struct{}) (uint32, error) {
	buf := make([]byte, 2048)
	_, _ = c.Write(icnCtrl(0x03, 0, myID, 0))
	var remoteID uint32
	deadline := time.Now().Add(4 * time.Second)
	lastTry := time.Now()
	for time.Now().Before(deadline) {
		if icnCanceled(cancel) {
			return 0, errDialCanceled
		}
		p, ok := icnRecv(c, 200, buf)
		if !ok {
			if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond {
				_, _ = c.Write(icnCtrl(0x03, 0, myID, 0))
				lastTry = time.Now()
			}
			continue
		}
		typ := icnLE.Uint16(p[4:])
		sentid := icnLE.Uint32(p[8:])
		// Every packet the rig sends during the handshake, verbatim. A radio that
		// answers SOMETHING unrecognised (a newer model, a different firmware) and
		// a radio that answers nothing are different faults, and a silent timeout
		// hides which one this is.
		icnHandshakeProbe(p)
		switch typ {
		case 0x04: // iAmHere
			remoteID = sentid
			_, _ = c.Write(icnCtrl(0x06, 1, myID, remoteID))
		case 0x06: // iAmReady
			if remoteID != 0 {
				return remoteID, nil
			}
		case 0x07: // ping
			_, _ = c.Write(icnPingReply(p, myID, remoteID))
		}
	}
	return 0, fmt.Errorf("handshake timeout")
}

func icnRecv(c *net.UDPConn, ms int, buf []byte) ([]byte, bool) {
	_ = c.SetReadDeadline(time.Now().Add(time.Duration(ms) * time.Millisecond))
	k, err := c.Read(buf)
	if err != nil || k < 16 {
		return nil, false
	}
	return buf[:k], true
}

func icnLocalID(c *net.UDPConn) uint32 {
	a := c.LocalAddr().(*net.UDPAddr)
	ip := a.IP.To4()
	if ip == nil {
		ip = []byte{192, 168, 0, 1}
	}
	return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(uint16(a.Port))
}

// ------------------------- packet builders -------------------------
// (offsets verified vs wfview structs + real captures)

func icnCtrl(typ, seq uint16, sentid, rcvdid uint32) []byte {
	b := make([]byte, 16)
	icnLE.PutUint32(b[0:], 0x10)
	icnLE.PutUint16(b[4:], typ)
	icnLE.PutUint16(b[6:], seq)
	icnLE.PutUint32(b[8:], sentid)
	icnLE.PutUint32(b[12:], rcvdid)
	return b
}

func icnPingReply(pkt []byte, myID, remoteID uint32) []byte {
	r := append([]byte(nil), pkt...)
	if len(r) >= 17 {
		icnLE.PutUint32(r[8:], myID)
		icnLE.PutUint32(r[12:], remoteID)
		r[16] = 0x01
	}
	return r
}

func icnLogin(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user, pass, name string) []byte {
	b := make([]byte, 0x80)
	icnLE.PutUint32(b[0:], 0x80)
	icnLE.PutUint16(b[6:], seq)
	icnLE.PutUint32(b[8:], sentid)
	icnLE.PutUint32(b[12:], rcvdid)
	icnBE.PutUint32(b[0x10:], 0x80-0x10)
	b[0x14] = 0x01
	b[0x15] = 0x00
	icnBE.PutUint16(b[0x16:], innerSeq)
	icnLE.PutUint16(b[0x1a:], tokReq)
	icnLE.PutUint32(b[0x1c:], token)
	copy(b[0x40:0x50], icnPasscode(user))
	copy(b[0x50:0x60], icnPasscode(pass))
	nm := name
	if len(nm) > 16 {
		nm = nm[:16]
	}
	copy(b[0x60:0x70], []byte(nm))
	return b
}

func icnToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
	b := make([]byte, 0x40)
	icnLE.PutUint32(b[0:], 0x40)
	icnLE.PutUint16(b[6:], seq)
	icnLE.PutUint32(b[8:], sentid)
	icnLE.PutUint32(b[12:], rcvdid)
	icnBE.PutUint32(b[0x10:], 0x40-0x10)
	b[0x14] = 0x01
	b[0x15] = 0x02
	icnBE.PutUint16(b[0x16:], innerSeq)
	icnLE.PutUint16(b[0x1a:], tokReq)
	icnLE.PutUint32(b[0x1c:], token)
	return b
}

// icnTokenRenew builds the periodic token-renewal packet (control 0x40). Same as
// the login-time token confirm but requesttype 0x05 (renew) with the resetcap
// field (0x0798 BE @0x24) the Remote Utility sends on renewals. Keeps the rig
// from invalidating the session (~2-min timeout without renewal). Offsets per the
// wfview token_packet struct (verified) — protocol facts, not copied code.
func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte {
	b := make([]byte, 0x40)
	icnLE.PutUint32(b[0:], 0x40)
	icnLE.PutUint16(b[6:], seq)
	icnLE.PutUint32(b[8:], sentid)
	icnLE.PutUint32(b[12:], rcvdid)
	icnBE.PutUint32(b[0x10:], 0x40-0x10)
	b[0x14] = 0x01 // requestreply = request
	b[0x15] = 0x05 // requesttype = token renewal
	icnBE.PutUint16(b[0x16:], innerSeq)
	icnLE.PutUint16(b[0x1a:], tokReq)
	icnLE.PutUint32(b[0x1c:], token)
	icnBE.PutUint16(b[0x24:], 0x0798) // resetcap
	return b
}

func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
	b := make([]byte, 0x90)
	icnLE.PutUint32(b[0:], 0x90)
	icnLE.PutUint16(b[6:], seq)
	icnLE.PutUint32(b[8:], sentid)
	icnLE.PutUint32(b[12:], rcvdid)
	icnBE.PutUint32(b[0x10:], 0x90-0x10)
	b[0x14] = 0x01
	b[0x15] = 0x03 // requesttype = conninfo / open streams
	icnBE.PutUint16(b[0x16:], innerSeq)
	icnLE.PutUint16(b[0x1a:], tokReq)
	icnLE.PutUint32(b[0x1c:], token)
	icnLE.PutUint16(b[0x27:], 0x8010) // commoncap
	copy(b[0x2a:0x30], rigMAC)
	copy(b[0x40:0x60], []byte("IC-7610"))
	copy(b[0x60:0x70], icnPasscode(user))
	b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
	// TX rides the same audio session: whenever the operator wants RX audio the
	// TX side is opened with it, so the voice keyer can play to the radio with
	// no cable. Costs nothing when unused — no packets flow until a message is
	// actually played.
	b[0x71] = rxEnable // txenable
	// rxcodec 0x04 = LPCM, ONE channel, 16-BIT — settled by experiment on a
	// real IC-7760, one wrong guess at a time: 0x10 produced 1280-byte payloads
	// of interleaved 16-bit stereo, 0x02 produced 320-byte payloads of 8-bit
	// mono (samples hugging 0x80). 0x04 sits between them in the codec table
	// (as in wfview): 16-bit mono, the format the 16 kHz playback path plays.
	b[0x72] = 0x04 // rxcodec
	b[0x73] = 0x04 // txcodec
	icnBE.PutUint32(b[0x74:], 16000)
	icnBE.PutUint32(b[0x78:], 16000) // TX sample rate — the same 16 kHz everything else here runs at
	icnBE.PutUint32(b[0x7c:], uint32(civPort))
	icnBE.PutUint32(b[0x80:], uint32(audioPort))
	icnBE.PutUint32(b[0x84:], 100)
	b[0x88] = 0x00
	return b
}

// PlayTXAudio plays one voice-keyer message through the 50003 audio session.
// On the transport because the transport owns the session; the IcomSerial
// controller forwards here after a type assertion, exactly as the TCI path
// reaches its radio.
func (n *icomNet) PlayTXAudio(pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
	if n.audio == nil {
		return fmt.Errorf("the radio's audio stream is not open — enable RX audio in Settings → CAT first")
	}
	return n.audio.PlayTX(pcm, rate, ch, bits, stop)
}

// TXAudioSender returns a function that streams live microphone chunks to the
// rig — the talk button's road, where PlayTXAudio is the voice keyer's.
func (n *icomNet) TXAudioSender() (func([]byte) error, error) {
	if n.audio == nil {
		return nil, fmt.Errorf("the radio's audio stream is not open — enable RX audio in Settings → CAT first")
	}
	return n.audio.SendTXChunk, nil
}

func icnOpenClose(seq uint16, sentid, rcvdid uint32, civSeq uint16, magic byte) []byte {
	b := make([]byte, 0x16)
	icnLE.PutUint32(b[0:], 0x16)
	icnLE.PutUint16(b[6:], seq)
	icnLE.PutUint32(b[8:], sentid)
	icnLE.PutUint32(b[12:], rcvdid)
	icnLE.PutUint16(b[0x10:], 0x01c0)
	icnBE.PutUint16(b[0x13:], civSeq)
	b[0x15] = magic
	return b
}

func icnCivData(seq uint16, sentid, rcvdid uint32, civSeq uint16, civ []byte) []byte {
	nn := 0x15 + len(civ)
	b := make([]byte, nn)
	icnLE.PutUint32(b[0:], uint32(nn))
	icnLE.PutUint16(b[6:], seq)
	icnLE.PutUint32(b[8:], sentid)
	icnLE.PutUint32(b[12:], rcvdid)
	b[0x10] = 0xc1
	icnLE.PutUint16(b[0x11:], uint16(len(civ)))
	icnBE.PutUint16(b[0x13:], civSeq)
	copy(b[0x15:], civ)
	return b
}

// icnPasscodeSeq — Icom's obfuscation table (values at index 0x20..0x7e).
// VERIFIED: user "f6bgc" → 3F 65 50 25 55 (matches the capture).
var icnPasscodeSeq = [256]byte{
	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
	0x47, 0x5d, 0x4c, 0x42, 0x66, 0x20, 0x23, 0x46, 0x4e, 0x57, 0x45, 0x3d, 0x67, 0x76, 0x60, 0x41, 0x62, 0x39, 0x59, 0x2d, 0x68, 0x7e,
	0x7c, 0x65, 0x7d, 0x49, 0x29, 0x72, 0x73, 0x78, 0x21, 0x6e, 0x5a, 0x5e, 0x4a, 0x3e, 0x71, 0x2c, 0x2a, 0x54, 0x3c, 0x3a, 0x63, 0x4f,
	0x43, 0x75, 0x27, 0x79, 0x5b, 0x35, 0x70, 0x48, 0x6b, 0x56, 0x6f, 0x34, 0x32, 0x6c, 0x30, 0x61, 0x6d, 0x7b, 0x2f, 0x4b, 0x64, 0x38,
	0x2b, 0x2e, 0x50, 0x40, 0x3f, 0x55, 0x33, 0x37, 0x25, 0x77, 0x24, 0x26, 0x74, 0x6a, 0x28, 0x53, 0x4d, 0x69, 0x22, 0x5c, 0x44, 0x31,
	0x36, 0x58, 0x3b, 0x7a, 0x51, 0x5f, 0x52,
}

func icnPasscode(s string) []byte {
	out := make([]byte, 0, len(s))
	for i := 0; i < len(s) && i < 16; i++ {
		p := int(s[i]) + i
		if p > 126 {
			p = 32 + p%127
		}
		out = append(out, icnPasscodeSeq[p])
	}
	return out
}

// icnHandshakeProbe logs the first packets seen during a control handshake —
// capped hard, because a working handshake would log for ever.
var icnProbeCount int

func icnHandshakeProbe(p []byte) {
	if icnProbeCount >= 12 {
		return
	}
	icnProbeCount++
	n := len(p)
	if n > 32 {
		n = 32
	}
	debugLog.Printf("icom net: handshake rx %d bytes: % X", len(p), p[:n])
}

// icnPing builds a CLIENT ping request (wfview's ping_packet: 0x15 bytes,
// type 0x07, reply=0, a monotonic time at 0x11). The rig answers each one —
// and, decisively, treats them as the client's sign of life: wfview sends one
// every 500 ms on every stream, and OpsLog, which only ever REPLIED to the
// rig's pings, watched the IC-7760 stop serving CI-V data about a minute into
// every session. Same socket answered, same transport alive: the rig had
// simply concluded nobody was listening.
func icnPing(seq uint16, sentid, rcvdid uint32, ms uint32) []byte {
	b := make([]byte, 0x15)
	icnLE.PutUint32(b[0:], 0x15)
	icnLE.PutUint16(b[4:], 0x07)
	icnLE.PutUint16(b[6:], seq)
	icnLE.PutUint32(b[8:], sentid)
	icnLE.PutUint32(b[12:], rcvdid)
	b[0x10] = 0x00
	icnLE.PutUint32(b[0x11:], ms)
	return b
}
