package steppir

import (
	"bytes"
	"encoding/binary"
	"errors"
	"sync"
	"testing"
	"time"
)

// The exact bytes are the correctness checksum. If buildSet ever drifts from the
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
// antenna, far worse than a compile error.
func TestBuildSetLayout(t *testing.T) {
	// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
	pkt := buildSet(14_074_000, DirNormal, '1')
	want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
	if len(pkt) != 11 {
		t.Fatalf("packet is %d bytes, want 11", len(pkt))
	}
	for i := range want {
		if pkt[i] != want[i] {
			t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
		}
	}
	// Frequency must round-trip: bytes [2:6] × 10 = Hz.
	if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
		t.Fatalf("freq round-trip = %d, want 14074000", got)
	}
}

func TestBuildSetDirectionAndCommand(t *testing.T) {
	cases := []struct {
		dir     int
		cmd     byte
		wantDir byte
		wantCmd byte
	}{
		{DirNormal, '1', 0x00, '1'},
		{Dir180, '1', 0x40, '1'},
		{DirBi, '1', 0x80, '1'},
		{DirNormal, 'S', 0x00, 'S'}, // retract / home
		{DirNormal, 'R', 0x00, 'R'}, // autotrack on
	}
	for _, c := range cases {
		pkt := buildSet(21_000_000, c.dir, c.cmd)
		if pkt[7] != c.wantDir {
			t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
		}
		if pkt[8] != c.wantCmd {
			t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
		}
	}
}

// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
// version "08". Using the actual device output (rather than hand-built bytes)
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
func TestParseStatusRealFrame(t *testing.T) {
	frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
	st, err := parseStatus(frame)
	if err != nil {
		t.Fatal(err)
	}
	if st.Frequency != 50313 {
		t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
	}
	if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
		t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
	}
	if st.MotorsMoving != 0 {
		t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
	}
}

// parseStatus decodes what the controller sends back — the inverse of buildSet's
// frequency field, plus the direction nibble and the motors-busy byte.
func TestParseStatus(t *testing.T) {
	frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
	st, err := parseStatus(frame)
	if err != nil {
		t.Fatal(err)
	}
	if st.Frequency != 14074 {
		t.Errorf("freq = %d kHz, want 14074", st.Frequency)
	}
	if st.Direction != Dir180 {
		t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
	}
	if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
		t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
	}

	// Motors busy: a bit beyond 0x01 is set.
	frame[6] = 0x07
	st, _ = parseStatus(frame)
	if st.MotorsMoving == 0 {
		t.Error("active-motors 0x07 should read as moving")
	}

	// 0xFF is "command received", not motion.
	frame[6] = 0xFF
	st, _ = parseStatus(frame)
	if st.MotorsMoving != 0 {
		t.Error("active-motors 0xFF (command received) must not read as moving")
	}
}

// fakeConn stands in for the controller link. rx holds bytes the "controller"
// has already sent (what the client will read), tx collects what the client
// wrote, and a "?A" query queues `reply` into rx the way the SDA answers.
//
// Reads never block: an empty rx returns (0, nil), which is exactly how
// go.bug.st/serial reports a read timeout, so drain() sees the same
// nothing-left signal it gets from real hardware.
type fakeConn struct {
	mu    sync.Mutex
	rx    bytes.Buffer
	tx    bytes.Buffer
	reply []byte
}

func (f *fakeConn) Read(p []byte) (int, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.rx.Len() == 0 {
		return 0, nil
	}
	return f.rx.Read(p)
}

func (f *fakeConn) Write(p []byte) (int, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.tx.Write(p)
	if bytes.Contains(p, []byte("?A")) {
		f.rx.Write(f.reply)
	}
	return len(p), nil
}

func (f *fakeConn) Close() error { return nil }

var (
	// 50.150 MHz, normal — the "stuck at 6 m" frame that kept turning up in
	// F4BPO's friend's log long after the rig had left the band.
	frame6mNormal = []byte{0x40, 0x41, 0x00, 0x4C, 0x85, 0xD8, 0x00, 0x07, 0x30, 0x38, 0x0D}
	// 14.250 MHz, 180° — what the controller actually reports right now.
	frame20m180 = []byte{0x40, 0x41, 0x00, 0x15, 0xBE, 0x68, 0x00, 0x47, 0x30, 0x38, 0x0D}
)

// The controller pushes status frames unsolicited, so they pile up between polls.
// Reading one frame per poll then returns state from minutes ago — which is how a
// 180° command could take ~40 s to show up in the UI, and how two polls 4 s apart
// reported 28 MHz then 14 MHz. queryStatus must empty the backlog first.
func TestQueryStatusDiscardsQueuedFrames(t *testing.T) {
	fc := &fakeConn{reply: frame20m180}
	fc.rx.Write(frame6mNormal) // two frames the controller pushed on its own
	fc.rx.Write(frame6mNormal)

	c := &Client{conn: fc}
	st, err := c.queryStatus()
	if err != nil {
		t.Fatal(err)
	}
	if st.Frequency != 14250 {
		t.Errorf("freq = %d kHz, want 14250 — a stale queued frame was read instead of this poll's reply", st.Frequency)
	}
	if st.Direction != Dir180 {
		t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
	}
}

// A reply we land on mid-frame must be rejected, not decoded into a bogus
// frequency and direction the follow loop would then act on — and it must not
// look like a dead link either (errBadFrame keeps the connection).
func TestQueryStatusRejectsMalformedFrame(t *testing.T) {
	shifted := append(append([]byte{}, frame20m180[3:]...), 0x40, 0x41, 0x00) // 11 bytes, wrong header
	c := &Client{conn: &fakeConn{reply: shifted}}
	if _, err := c.queryStatus(); !errors.Is(err, errBadFrame) {
		t.Fatalf("err = %v, want errBadFrame", err)
	}
}

// The direction the operator just commanded is shown until the controller
// confirms it. The hold used to be 4 s — two polls — so the button snapped back
// to Normal while the elements were still swapping over to 180°.
func TestApplyPendingDirHold(t *testing.T) {
	c := &Client{pendingDir: Dir180, pendingDirAt: time.Now(), pendingDirSet: true}

	// Controller still reports the old pattern: keep showing what was commanded.
	st := &Status{Direction: DirNormal}
	c.applyPendingDir(st)
	if st.Direction != Dir180 {
		t.Fatalf("direction = %d, want %d while the move is pending", st.Direction, Dir180)
	}
	if !c.pendingDirSet {
		t.Fatal("hold released before the controller confirmed")
	}

	// Still holding well past the old 4 s window — a SteppIR takes longer than
	// that to report a pattern change.
	c.pendingDirAt = time.Now().Add(-10 * time.Second)
	st = &Status{Direction: DirNormal}
	c.applyPendingDir(st)
	if st.Direction != Dir180 {
		t.Fatalf("direction = %d after 10 s, want %d — the hold expired too early", st.Direction, Dir180)
	}

	// Controller confirms: release the hold and trust its reports again.
	st = &Status{Direction: Dir180}
	c.applyPendingDir(st)
	if c.pendingDirSet {
		t.Fatal("hold should be released once the controller reports the commanded direction")
	}

	// A command the controller never acted on must not lie forever.
	c.pendingDir, c.pendingDirAt, c.pendingDirSet = DirBi, time.Now().Add(-pendingDirTTL-time.Second), true
	st = &Status{Direction: DirNormal}
	c.applyPendingDir(st)
	if st.Direction != DirNormal || c.pendingDirSet {
		t.Fatalf("expired hold should fall back to the controller: direction = %d, pending = %v", st.Direction, c.pendingDirSet)
	}
}
