package autocall

import (
	"fmt"
	"strings"
	"testing"
	"time"
)

const me = "F4BPO"

// base is a slot boundary, so slot parity in the tests is the real arithmetic.
var base = time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)

func at(period int) time.Time { return base.Add(time.Duration(period) * 15 * time.Second) }

func cq(call string, need Need, snr int, opts ...func(*Candidate)) Candidate {
	c := Candidate{
		Decode: Decode{Call: call, Band: "20m", Mode: "FT8", SNR: snr, CQ: true,
			Msg: "CQ " + call + " JN36", TRPeriod: 15, IsNew: true},
		Need: need,
	}
	for _, o := range opts {
		o(&c)
	}
	return c
}

func watched(c *Candidate) { c.Watched = true }
func worked(c *Candidate)  { c.Worked = true }

// busy is a station in the middle of an exchange with somebody else.
func busy(call string, need Need, snr int) Candidate {
	c := cq(call, need, snr)
	c.CQ = false
	c.Msg = "VP6D " + call + " JN36"
	return c
}

// callsMe is a station answering us.
func callsMe(call string, need Need, snr int) Candidate {
	c := cq(call, need, snr)
	c.CQ = false
	c.Msg = me + " " + call + " -12"
	return c
}

func period(n int, decodes ...Candidate) Period {
	for i := range decodes {
		decodes[i].At = at(n)
	}
	return Period{Key: fmt.Sprintf("p%d", n), At: at(n), TRPeriod: 15, Decodes: decodes, MyCall: me}
}

func on() *Engine { return New(Settings{Enabled: true}) }

// ── The ladder ────────────────────────────────────────────────────────────

func TestLadderOrder(t *testing.T) {
	// Every rung. The watched ones come FIRST, ordered among themselves by what
	// is needed — the list is the operator's answer, not a tie-breaker.
	order := []Candidate{
		cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched),
		cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 0, watched),
		cq("I", NeedNone, 0, watched),
		cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
		cq("F", NeedMode, 0), cq("H", NeedSlot, 0),
		// The orthogonal markers sit at the foot of the ladder: worth a call
		// when nothing better is on the air, never worth leaving a band for.
		cq("J", NeedExtra, 0),
	}
	for i := 1; i < len(order); i++ {
		if rank(order[i-1]) <= rank(order[i]) {
			t.Errorf("%s (%d) does not outrank %s (%d)",
				order[i-1].Call, rank(order[i-1]), order[i].Call, rank(order[i]))
		}
	}
	// A station with nothing needed and not watched is not called at all.
	if rank(cq("Z", NeedNone, 0)) != 0 {
		t.Error("a station with nothing to gain from it ranks above zero")
	}
	// And the pick agrees with the ladder, whatever order the period lists them.
	e := on()
	a := e.OnPeriod(period(0, order[8], order[5], order[0], order[6]))
	if a.Kind != DoReply || a.Decode.Call != "A" {
		t.Fatalf("picked %+v, want the watched new entity", a)
	}
	// A watched station with NOTHING needed still beats a new entity that is not
	// watched — the case that sent an operator hunting: a watched DXpedition sat
	// on the band all evening while the engine worked what the log wanted.
	e = on()
	if a := e.OnPeriod(period(2, cq("RARE", NeedDXCC, 0), cq("WATCHED", NeedNone, -20, watched))); a.Decode.Call != "WATCHED" {
		t.Errorf("picked %q, want the watched callsign", a.Decode.Call)
	}
}

func TestStrongestWinsBetweenEquals(t *testing.T) {
	e := on()
	a := e.OnPeriod(period(0, cq("WEAK", NeedBand, -20), cq("LOUD", NeedBand, -5)))
	if a.Decode.Call != "LOUD" {
		t.Errorf("picked %q, want the strongest of two equal needs", a.Decode.Call)
	}
}

// ── The busy station ──────────────────────────────────────────────────────

func TestBusyStationIsNeverCalled(t *testing.T) {
	e := on()
	// The new entity is answering a DXpedition; the new band is calling CQ.
	a := e.OnPeriod(period(0, busy("RARE", NeedDXCC, -3), cq("DL1XX", NeedBand, -15)))
	if a.Kind != DoReply || a.Decode.Call != "DL1XX" {
		t.Fatalf("called %+v — a station in mid-QSO cannot answer and must not be called", a)
	}
	// It is not banned: the moment it calls CQ it takes the slot back, once the
	// QSO in hand is over.
	e2 := on()
	if a := e2.OnPeriod(period(0, cq("RARE", NeedDXCC, -3))); a.Decode.Call != "RARE" {
		t.Errorf("the same station calling CQ was not called: %+v", a)
	}
}

func TestFinalFrameIsCallable(t *testing.T) {
	e := on()
	c := busy("RARE", NeedDXCC, -3)
	c.Msg = "IK2AAA RARE RR73" // one frame from being free
	if a := e.OnPeriod(period(0, c)); a.Kind != DoReply {
		t.Errorf("a station sending its last frame is free next period: %+v", a)
	}
}

// ── The brakes ────────────────────────────────────────────────────────────

func TestAttemptsCapAtSevenAndFifteen(t *testing.T) {
	for _, tc := range []struct {
		name string
		opt  func(*Candidate)
		want int
	}{{"plain", func(*Candidate) {}, 7}, {"watched", watched, 15}} {
		e := on()
		e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, tc.opt)))
		tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
		for i := 1; i < tc.want; i++ {
			if a := e.NoteTX(tx); a.Kind != DoNothing {
				t.Fatalf("%s: gave up at call %d of %d", tc.name, i, tc.want)
			}
		}
		a := e.NoteTX(tx)
		if a.Kind != DoHalt {
			t.Errorf("%s: still calling after %d attempts", tc.name, tc.want)
		}
		if e.Target() != "" {
			t.Errorf("%s: target still held after giving up", tc.name)
		}
	}
}

func TestOnlyFreshCallsCount(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	// The rest of the exchange is not a call: without this the seven were spent
	// on one QSO in progress.
	for _, msg := range []string{"DX " + me + " -12", "DX " + me + " R-12", "DX " + me + " RR73"} {
		if a := e.NoteTX(TXState{Transmitting: true, Msg: msg}); a.Kind != DoNothing {
			t.Fatalf("%q ended the series", msg)
		}
	}
	if e.Status().Attempts != 0 {
		t.Errorf("attempts = %d after three QSO frames, want 0", e.Status().Attempts)
	}
	// A transmission aimed at somebody else counts for nothing either.
	e.NoteTX(TXState{Transmitting: true, Msg: "OTHER " + me + " JN36"})
	if e.Status().Attempts != 0 {
		t.Errorf("a call to another station was counted against the target")
	}
}

func TestMissesOnlyCountTheStationsOwnPeriods(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) // learns nothing yet
	e.OnPeriod(period(2, cq("DX", NeedDXCC, -5))) // seen: it transmits on even periods
	// Its listening periods say nothing about it. Ten of them must not add up
	// to a give-up.
	for _, p := range []int{3, 5, 7, 9, 11} {
		if a := e.OnPeriod(period(p)); a.Kind != DoNothing {
			t.Fatalf("gave up during the station's own listening period %d", p)
		}
	}
	if e.Status().Misses != 0 {
		t.Errorf("misses = %d over five listening periods, want 0", e.Status().Misses)
	}
	// Absent from two of its transmit periods: still holding.
	e.OnPeriod(period(4))
	e.OnPeriod(period(6))
	if e.Target() == "" {
		t.Fatal("gave up after two misses, the limit is three")
	}
	if a := e.OnPeriod(period(8)); a.Kind != DoHalt {
		t.Errorf("still holding after three missed transmit periods: %+v", a)
	}
}

func TestOneMissPerPeriodEvenIfTheHandlerRunsTwice(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	e.OnPeriod(period(2, cq("DX", NeedDXCC, -5)))
	p := period(4)
	e.OnPeriod(p)
	e.OnPeriod(p)
	e.OnPeriod(p)
	if e.Status().Misses != 1 {
		t.Errorf("misses = %d after one period handled three times, want 1", e.Status().Misses)
	}
}

func TestClockBackstopReleasesAStuckTarget(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	// Decoded every one of its periods and never answering, with the decoder
	// never reporting a transmission: no counter can advance.
	for p := 2; p <= 16; p += 2 {
		e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
	}
	if a := e.OnPeriod(period(18, cq("DX", NeedDXCC, -5))); a.Kind != DoHalt {
		t.Errorf("a target held past MaxHold with no counter moving was never released: %+v", a)
	}
}

// ── After a series ────────────────────────────────────────────────────────

func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
	tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
	for i := 0; i < 7; i++ {
		e.NoteTX(tx)
	}
	if e.Target() != "" {
		t.Fatal("still holding after seven calls")
	}
	// It is still there, and so is a new entity: the entity takes the slot.
	a := e.OnPeriod(period(2, cq("DX", NeedBand, -5), cq("RARE", NeedDXCC, -20)))
	if a.Decode.Call != "RARE" {
		t.Errorf("picked %q, want the higher priority over the station just released", a.Decode.Call)
	}
}

// A series that ended in a brake is followed by ONE of the station's own overs
// passed over — long enough to be a pause, short enough that the DX everybody
// is chasing is still there when the calling resumes. It used to be two
// minutes, which on FT8 is four overs: by then the DX has worked four other
// callers, and half the time it has gone.
func TestAReleasedStationSitsOutOneOfItsOvers(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
	tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
	for i := 0; i < 7; i++ {
		e.NoteTX(tx)
	}
	// Its very next over is passed over, whatever else is on the air: seven
	// calls, a halt, and the same station called again four seconds later is not
	// a rest, it is a stutter.
	if a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
		t.Fatalf("called again during the rest: %+v", a)
	}
	// And the one after that goes: still there, still wanted, and the operator
	// has listened through an over.
	if a := e.OnPeriod(period(4, cq("DX", NeedBand, -5))); a.Kind != DoReply {
		t.Errorf("after one over: %+v, want the station called again", a)
	}
}

func TestTheAttemptsCapLetsTheOverFinish(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
	var a Action
	for i := 0; i < 7; i++ {
		a = e.NoteTX(tx)
	}
	if a.Kind != DoHalt {
		t.Fatalf("no halt after seven calls: %+v", a)
	}
	// The cap trips WHILE the seventh call is going out — cutting the carrier
	// there sends half a call. It asks the decoder to finish the over first.
	if !a.Soft {
		t.Error("the attempts cap cut the transmission that counted it")
	}
}

func TestAStationIsParkedAfterItsRounds(t *testing.T) {
	e := on()
	tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
	// Rounds are spaced past the rest (2 min = 8 periods) or the station would
	// simply be resting rather than parked.
	for round := 1; round <= 3; round++ {
		a := e.OnPeriod(period(round*10, cq("DX", NeedBand, -5)))
		if a.Kind != DoReply {
			t.Fatalf("round %d: not called (%+v)", round, a)
		}
		for i := 0; i < 7; i++ {
			e.NoteTX(tx)
		}
	}
	// Three series of seven is twenty-one calls. That is the end of it for this
	// session — the whole point of the exercise is that it cannot reach fifty.
	if a := e.OnPeriod(period(60, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
		t.Errorf("a fourth series was started: %+v", a)
	}
}

// ── Handing over ──────────────────────────────────────────────────────────

func TestFinishedQSOMovesToTheNextPriority(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	done := callsMe("DX", NeedDXCC, -5)
	done.Msg = me + " DX RR73"
	// The period the QSO ends in belongs to our own 73 — nothing else is called.
	if a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))); a.Kind != DoNothing {
		t.Fatalf("took the slot our 73 goes out in: %+v", a)
	}
	a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10)))
	if a.Kind != DoReply || a.Decode.Call != "NEXT" {
		t.Errorf("next period: %+v, want the next priority", a)
	}
}

func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	done := callsMe("DX", NeedDXCC, -5)
	done.Msg = me + " DX RR73"
	// Two stations calling us, nothing needed from either: the strongest wins —
	// on the period after the one our 73 goes out in.
	weak := callsMe("WEAK", NeedNone, -18)
	weak.Watched = true
	loud := callsMe("LOUD", NeedNone, -4)
	loud.Watched = true
	e.OnPeriod(period(2, done))
	a := e.OnPeriod(period(4, weak, loud))
	if a.Kind != DoReply || a.Decode.Call != "LOUD" {
		t.Errorf("answered %+v, want the strongest of the stations calling us", a)
	}
}

func TestAlreadyWorkedIsNeverCalled(t *testing.T) {
	e := on()
	a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5, worked)))
	if a.Kind != DoNothing {
		t.Errorf("called a station already in the log on this band and mode: %+v", a)
	}
}

func TestReplayedHistoryIsNeverCalled(t *testing.T) {
	e := on()
	old := cq("DX", NeedDXCC, -5)
	old.IsNew = false
	if a := e.OnPeriod(period(0, old)); a.Kind != DoNothing {
		t.Errorf("answered a replayed decode: %+v", a)
	}
}

func TestNoCallStartsOverATransmissionInProgress(t *testing.T) {
	e := on()
	p := period(0, cq("DX", NeedDXCC, -5))
	p.TX = TXState{Transmitting: true}
	if a := e.OnPeriod(p); a.Kind != DoNothing {
		t.Errorf("started a call while the decoder was transmitting: %+v", a)
	}
}

// ── The "call this station" field ─────────────────────────────────────────

func TestOnlyCallsThatStation(t *testing.T) {
	e := New(Settings{Enabled: true, Only: "VP6D"})
	a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -5), cq("VP6D", NeedSlot, -20)))
	if a.Kind != DoReply || a.Decode.Call != "VP6D" {
		t.Fatalf("picked %+v, want the station named in the field", a)
	}
	// Same brakes, then a hard stop: there is nothing else it was asked to do.
	tx := TXState{Transmitting: true, Msg: "VP6D " + me + " JN36"}
	for i := 0; i < 7; i++ {
		e.NoteTX(tx)
	}
	if !e.Status().Stopped {
		t.Error("an explicit target ran out of attempts and the feature did not stop")
	}
	if a := e.OnPeriod(period(2, cq("VP6D", NeedSlot, -20))); a.Kind != DoNothing {
		t.Errorf("kept calling after the stop: %+v", a)
	}
	e.Reset()
	if a := e.OnPeriod(period(4, cq("VP6D", NeedSlot, -20))); a.Kind != DoReply {
		t.Errorf("the operator restarted it and nothing happened: %+v", a)
	}
}

func TestDisabledDoesNothingAtAll(t *testing.T) {
	e := New(Settings{})
	if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, 0))); a.Kind != DoNothing {
		t.Errorf("switched off and still calling: %+v", a)
	}
	if a := e.NoteTX(TXState{Transmitting: true, Msg: "DX " + me + " JN36"}); a.Kind != DoNothing {
		t.Errorf("switched off and still counting: %+v", a)
	}
}

func TestOnlyStopsOnceThatStationIsWorked(t *testing.T) {
	e := New(Settings{Enabled: true, Only: "VP6D"})
	e.OnPeriod(period(0, cq("VP6D", NeedDXCC, -10)))
	done := callsMe("VP6D", NeedDXCC, -10)
	done.Msg = me + " VP6D RR73"
	e.OnPeriod(period(2, done))
	// The station is still on the air calling CQ. It has been worked: an
	// explicit request is for one QSO, not for the whole evening.
	if a := e.OnPeriod(period(4, cq("VP6D", NeedDXCC, -10))); a.Kind != DoNothing {
		t.Errorf("called the named station again after working it: %+v", a)
	}
}

func TestAStationJustWorkedIsNotCalledBackWhileTheLogCatchesUp(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	done := callsMe("DX", NeedDXCC, -5)
	done.Msg = me + " DX RR73"
	e.OnPeriod(period(2, done))
	// Still flagged as a new entity — the QSO is not in the log yet — and still
	// calling CQ. It must not be answered again.
	if a := e.OnPeriod(period(4, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
		t.Errorf("called back a station worked two periods ago: %+v", a)
	}
}

func TestChaseListTakesSeveralCallsigns(t *testing.T) {
	// Typed the way an operator types a list: commas, spaces, or both.
	for _, field := range []string{"VP6D 3Y0J", "vp6d,3y0j", "VP6D, 3Y0J", " VP6D ;3Y0J "} {
		if got := onlyList(field); len(got) != 2 || got[0] != "VP6D" || got[1] != "3Y0J" {
			t.Errorf("%q parsed as %v, want [VP6D 3Y0J]", field, got)
		}
	}

	e := New(Settings{Enabled: true, Only: "VP6D, 3Y0J"})
	// Nothing off the list is called, however rare it is.
	if a := e.OnPeriod(period(0, cq("RARE", NeedDXCC, -1))); a.Kind != DoNothing {
		t.Errorf("called a station that is not on the chase list: %+v", a)
	}
	// Between two listed stations the ladder still decides: the new entity over
	// the new slot, whatever their order in the period.
	a := e.OnPeriod(period(2, cq("3Y0J", NeedSlot, -1), cq("VP6D", NeedDXCC, -22)))
	if a.Kind != DoReply || a.Decode.Call != "VP6D" {
		t.Fatalf("picked %+v, want the new entity of the two listed", a)
	}
	// Working one of them leaves the other callable — the list is a hunt, not a
	// single request.
	done := callsMe("VP6D", NeedDXCC, -22)
	done.Msg = me + " VP6D RR73"
	e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
	a = e.OnPeriod(period(6, cq("3Y0J", NeedSlot, -1)))
	if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
		t.Errorf("after working VP6D: %+v, want the other station on the list", a)
	}
}

// ── Two decoders at once (the split view) ─────────────────────────────────

func onInst(inst string, p Period) Period {
	p.Instance = inst
	for i := range p.Decodes {
		p.Decodes[i].Instance = inst
	}
	return p
}

func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) {
	e := on()
	// Calling a new band on receiver A.
	if a := e.OnPeriod(onInst("A", period(0, cq("DL1XX", NeedBand, -10)))); a.Decode.Call != "DL1XX" {
		t.Fatalf("first call went to %+v", a)
	}
	// Receiver B now hears a new ENTITY — a better catch by every rule. It must
	// still not be called: one station at a time, and the QSO in hand is the
	// one already under way.
	if a := e.OnPeriod(onInst("B", period(1, cq("RARE", NeedDXCC, -1)))); a.Kind != DoNothing {
		t.Errorf("the other receiver started a second QSO: %+v", a)
	}
	// And B's periods count no misses against A's target: the station is not
	// absent from B, it was never on that band.
	e.OnPeriod(onInst("B", period(3, cq("RARE", NeedDXCC, -1))))
	e.OnPeriod(onInst("B", period(5, cq("RARE", NeedDXCC, -1))))
	e.OnPeriod(onInst("B", period(7, cq("RARE", NeedDXCC, -1))))
	if e.Status().Misses != 0 || e.Target() != "DL1XX" {
		t.Errorf("misses = %d, target = %q — the other receiver's periods were counted",
			e.Status().Misses, e.Target())
	}
	// B transmitting its own QSO is not us calling DL1XX either.
	for i := 0; i < 9; i++ {
		e.NoteTX(TXState{Transmitting: true, Instance: "B", Msg: "DL1XX " + me + " JN36"})
	}
	if e.Status().Attempts != 0 {
		t.Errorf("attempts = %d from the other receiver's transmissions, want 0", e.Status().Attempts)
	}
	// A's own transmissions do count.
	e.NoteTX(TXState{Transmitting: true, Instance: "A", Msg: "DL1XX " + me + " JN36"})
	if e.Status().Attempts != 1 {
		t.Errorf("attempts = %d after one call from the calling receiver, want 1", e.Status().Attempts)
	}
	// Once the QSO is over, the other receiver's station is free to be taken.
	done := callsMe("DL1XX", NeedBand, -10)
	done.Msg = me + " DL1XX RR73"
	e.OnPeriod(onInst("A", period(9, done)))
	if a := e.OnPeriod(onInst("B", period(11, cq("RARE", NeedDXCC, -1)))); a.Decode.Call != "RARE" {
		t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
	}
}

// The one reported from the air: his RRR arrives, we are sending our 73, and
// the engine picked the next station in the same instant. WSJT-X acts on a
// reply at once — it dropped the exchange and started calling the new station,
// cutting the 73 a second in.
func TestNothingIsCalledOverOurOwnTransmission(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	done := callsMe("DX", NeedDXCC, -5)
	done.Msg = me + " DX RRR"
	p := period(2, done, cq("NEXT", NeedBand, -10))
	p.TX = TXState{Transmitting: true, Msg: "DX " + me + " 73"}
	if a := e.OnPeriod(p); a.Kind != DoNothing {
		t.Fatalf("called %+v while our own over was still going out", a)
	}
	// The QSO is still released — only the next call waits.
	if e.Target() != "" {
		t.Errorf("target = %q after the QSO finished, want none", e.Target())
	}
	// Next period, carrier down: now it may take the next station.
	if a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10))); a.Kind != DoReply || a.Decode.Call != "NEXT" {
		t.Errorf("once the transmission ended: %+v, want the next station called", a)
	}
}

func TestTheHoldClockRestartsWhenTheStationAnswers(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
	// Called for a long time — nearly the backstop — and then he answers.
	for p := 2; p <= 14; p += 2 {
		e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
	}
	e.OnPeriod(period(16, callsMe("DX", NeedDXCC, -5)))
	// The exchange must not be halted by a backstop that was counting the wait.
	for _, p := range []int{18, 20} {
		if a := e.OnPeriod(period(p, callsMe("DX", NeedDXCC, -5))); a.Kind == DoHalt {
			t.Fatalf("period %d: halted an exchange in progress (%s)", p, a.Reason)
		}
	}
}

func TestARealNeedOutranksAnUnconfirmedOne(t *testing.T) {
	real := cq("REAL", NeedBand, -20)
	unconf := cq("UNCONF", NeedBand, -2)
	unconf.Unconfirmed = true
	// Same need, and the unconfirmed one is 18 dB louder. The band never worked
	// is still the catch: the other is a QSL to chase, not a QSO to make.
	e := on()
	if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "REAL" {
		t.Errorf("picked %q, want the band never worked", a.Decode.Call)
	}
	// Watched changes that, and is meant to: the list is the operator's answer.
	unconf.Watched = true
	e = on()
	if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "UNCONF" {
		t.Errorf("picked %q, want the watched station", a.Decode.Call)
	}
	unconf.Watched = false
	// Between two unwatched stations, an unconfirmed BAND still beats a real
	// SLOT: the band is the bigger prize whatever state it is in.
	slot := cq("SLOT", NeedSlot, 0)
	e = on()
	if a := e.OnPeriod(period(0, slot, unconf)); a.Decode.Call != "UNCONF" {
		t.Errorf("picked %q, want the unconfirmed band over a real slot", a.Decode.Call)
	}
}

// ── The operator's Halt ───────────────────────────────────────────────────

func TestHaltSetsTheStationAsideForTheSession(t *testing.T) {
	e := on()
	if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))); a.Decode.Call != "DX" {
		t.Fatalf("not called: %+v", a)
	}
	// The operator stops it mid-call.
	if got := e.Halt(); got != "DX" {
		t.Fatalf("Halt returned %q, want the station being called", got)
	}
	if e.Target() != "" {
		t.Error("the target survived a halt")
	}
	// Still the loudest new entity on the air, period after period. It is the
	// operator's verdict, so it is not called again — not next period, not in
	// ten minutes.
	for _, n := range []int{2, 4, 40} {
		if a := e.OnPeriod(period(n, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
			t.Errorf("period %d: called a station the operator had stopped: %+v", n, a)
		}
	}
	// Naming it explicitly does not override the operator either.
	e.SetSettings(Settings{Enabled: true, Only: "DX"})
	if a := e.OnPeriod(period(42, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
		t.Errorf("the chase list overrode a halt: %+v", a)
	}
	// Switching auto-call off and on is what starts over.
	e.Reset()
	e.SetSettings(Settings{Enabled: true})
	if a := e.OnPeriod(period(44, cq("DX", NeedDXCC, -5))); a.Kind != DoReply {
		t.Errorf("after a restart the station is fair game again: %+v", a)
	}
}

func TestHaltWithNothingBeingCalledIsJustAHalt(t *testing.T) {
	e := on()
	if got := e.Halt(); got != "" {
		t.Errorf("Halt returned %q with no target", got)
	}
	if e.Greylisted() != 0 {
		t.Errorf("greylisted %d stations from an idle halt", e.Greylisted())
	}
}

func TestTraceSaysWhyNobodyWasCalled(t *testing.T) {
	var lines []string
	e := on()
	e.SetTrace(func(f string, args ...any) { lines = append(lines, fmt.Sprintf(f, args...)) })

	worked := cq("A", NeedDXCC, -5, worked)
	nothing := cq("B", NeedNone, -5)
	busyOne := busy("C", NeedBand, -5)
	e.Halt() // nothing held: no-op, keeps the set empty
	e.OnPeriod(period(0, worked, nothing, busyOne))

	if len(lines) == 0 {
		t.Fatal("tracing on and nothing was written")
	}
	got := lines[len(lines)-1]
	for _, want := range []string{"decodes=3", "callable=0", "worked=1", "nothing-needed=1", "busy=1"} {
		if !strings.Contains(got, want) {
			t.Errorf("trace %q does not say %q", got, want)
		}
	}

	// And when it DOES call, the line names the candidates it ranked.
	lines = nil
	e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched)))
	if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r118)") {
		t.Errorf("trace %v does not describe the station it called", lines)
	}
}

func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) {
	e := on()
	// A QSO has just ended and two stations are calling us. Neither is worth
	// anything to the log — and both are worth answering: they have heard us,
	// they are waiting, and the contact is one over away.
	weak := callsMe("WEAK", NeedNone, -18)
	loud := callsMe("LOUD", NeedNone, -3)
	a := e.OnPeriod(period(0, weak, loud))
	if a.Kind != DoReply || a.Decode.Call != "LOUD" {
		t.Fatalf("answered %+v, want the strongest of the stations calling us", a)
	}
	// A caller already worked on this band and mode is a duplicate, not a QSO.
	e = on()
	done := callsMe("DUPE", NeedNone, -1)
	done.Worked = true
	if a := e.OnPeriod(period(2, done)); a.Kind != DoNothing {
		t.Errorf("answered a station already in the log on this band and mode: %+v", a)
	}
	// And one the operator halted stays halted, however politely it calls.
	e = on()
	e.OnPeriod(period(4, cq("STOP", NeedDXCC, -5)))
	e.Halt()
	if a := e.OnPeriod(period(6, callsMe("STOP", NeedDXCC, -5))); a.Kind != DoNothing {
		t.Errorf("answered a station the operator had stopped: %+v", a)
	}
	// A needed station still comes first: a caller with nothing to gain is
	// answered when nothing better is on the air, which is what was asked for.
	e = on()
	if a := e.OnPeriod(period(8, callsMe("CALLER", NeedNone, 0), cq("RARE", NeedDXCC, -20))); a.Decode.Call != "RARE" {
		t.Errorf("picked %q, want the new entity ahead of a caller with nothing needed", a.Decode.Call)
	}
}

// TestAStationWorkingSomebodyElseIsNotCHOSEN — the test above is about a target
// already being called; this is about picking one. A reply to a decode in
// mid-exchange is a reply WSJT-X and JTDX may refuse outright, so it never
// starts a call there.
func TestAStationWorkingSomebodyElseIsNotCHOSEN(t *testing.T) {
	e := on()
	busyNow := busy("ON7GB", NeedSlot, +5)
	busyNow.Msg = "PY2SAD ON7GB JO21"
	if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply {
		t.Fatalf("called %+v — it is in a QSO with PY2SAD", a)
	}
	if e.Target() != "" {
		t.Errorf("target is %q", e.Target())
	}
	// Its final frame is different: one period from free is the best moment
	// there is to be calling it.
	last := busy("ON7GB", NeedSlot, +5)
	last.Msg = "PY2SAD ON7GB RR73"
	if a := e.OnPeriod(period(2, last)); a.Kind != DoReply {
		t.Errorf("%+v — a station on its last frame was not called", a)
	}
}

func TestAFinalFrameToSomebodyElseIsNotABusyTarget(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("DX", NeedSlot, 0)))
	// Sending RR73 to another station: one period from being free, and the best
	// moment there is to be calling it.
	wrap := busy("DX", NeedSlot, 0)
	wrap.Msg = "PY2SAD DX RR73"
	if a := e.OnPeriod(period(2, wrap)); a.Kind != DoNothing || e.Target() != "DX" {
		t.Errorf("dropped a station that was finishing: %+v (target %q)", a, e.Target())
	}
}

func TestNothingHiddenByThePanelIsCalled(t *testing.T) {
	e := New(Settings{Enabled: true, OnScreenOnly: true})
	// The operator has filtered the list down to CQs: what is not on the screen
	// is not called, whatever the log makes of it.
	hidden := cq("RARE", NeedDXCC, 0)
	hidden.Hidden = true
	shown := cq("PLAIN", NeedSlot, -20)
	if a := e.OnPeriod(period(0, hidden, shown)); a.Decode.Call != "PLAIN" {
		t.Errorf("picked %q, want the station the panel is showing", a.Decode.Call)
	}
	// With the panel publishing nothing, nothing is hidden and the ladder
	// decides alone.
	e = New(Settings{Enabled: true, OnScreenOnly: true})
	free := cq("RARE", NeedDXCC, 0)
	if a := e.OnPeriod(period(2, free, shown)); a.Decode.Call != "RARE" {
		t.Errorf("picked %q with no filtering in force, want the new entity", a.Decode.Call)
	}
}

// MSHV answers two stations in one transmission and the decoder prints them as
// one line. Reported from the air: the DX was answering us in the second half
// and the engine, reading only the first, dropped it as busy.
func TestAMultiAnswerLineIsReadWhole(t *testing.T) {
	fox := cq("HK0/PY8WW", NeedDXCC, -17, watched)
	fox.CQ = false
	fox.Msg = "YV5ALI RR73; " + me + " <HK0/PY8WW> -08"

	if !callingUs(fox, me) {
		t.Error("the second half is a report to us and was not read as one")
	}
	if !callable(fox, me) {
		t.Error("a station answering us was judged uncallable")
	}
	// Held as the target, that line must not release it — it is the answer we
	// were waiting for, not a station working somebody else.
	e := on()
	e.OnPeriod(period(0, cq("HK0/PY8WW", NeedDXCC, -17, watched)))
	if a := e.OnPeriod(period(2, fox)); a.Kind != DoNothing || e.Target() != "HK0/PY8WW" {
		t.Errorf("dropped the DX as it answered us: %+v (target %q)", a, e.Target())
	}
	// The exchange is under way, so the attempt counter has done its job.
	if e.Status().Attempts != 0 {
		t.Errorf("attempts = %d while in QSO, want 0", e.Status().Attempts)
	}
	// And its final frame to us, in the same shape, ends the QSO.
	done := fox
	done.Msg = "IK2ABC RR73; " + me + " <HK0/PY8WW> RR73"
	if !finished([]Candidate{done}, "HK0/PY8WW", me) {
		t.Error("a 73 to us inside a multi-answer line did not end the QSO")
	}
}

// Reported from the air twice: the DX sends RR73, our own 73 is about to go
// out, and the engine answered somebody else in that very slot — two
// transmissions in one period, and the station at the other end never got the
// frame that closes the contact.
func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("HP/WE9G", NeedDXCC, -6)))
	bye := callsMe("HP/WE9G", NeedDXCC, -6)
	bye.Msg = "<" + me + "> HP/WE9G RR73"
	// A new band is decoding in the same period and is not called.
	a := e.OnPeriod(period(2, bye, cq("GW8DX", NeedBand, +2)))
	if a.Kind != DoNothing {
		t.Fatalf("called %+v in the slot our 73 goes out in", a)
	}
	if !strings.Contains(a.Reason, "73") {
		t.Errorf("reason %q does not say why the slot was left alone", a.Reason)
	}
	// It is still there fifteen seconds later, which is the whole cost.
	if a := e.OnPeriod(period(4, cq("GW8DX", NeedBand, +2))); a.Kind != DoReply || a.Decode.Call != "GW8DX" {
		t.Errorf("next period: %+v, want the new band called", a)
	}
}

func TestABetterStationTakesOverBeforeAnybodyHasAnswered(t *testing.T) {
	e := on()
	e.OnPeriod(period(0, cq("PLAIN", NeedSlot, +10)))
	// A watched station comes on the air: nothing has answered us yet, so the
	// call in progress is worth less than the one now possible.
	a := e.OnPeriod(period(2, cq("PLAIN", NeedSlot, +10), cq("WATCHED", NeedNone, -15, watched)))
	if a.Kind != DoReply || a.Decode.Call != "WATCHED" {
		t.Fatalf("%+v — a better station did not take over", a)
	}

	// Equal rungs do NOT take over while the station being called is on the air.
	e = on()
	e.OnPeriod(period(4, cq("A", NeedBand, 0)))
	if a := e.OnPeriod(period(6, cq("A", NeedBand, 0), cq("B", NeedBand, +20))); a.Kind != DoNothing {
		t.Errorf("%+v — swapped between two stations of equal value", a)
	}
	// But they do when it is absent: seven calls into the void while a station
	// of the same value is calling CQ is what the operator was watching.
	if a := e.OnPeriod(period(8, cq("B", NeedBand, +20))); a.Kind != DoReply || a.Decode.Call != "B" {
		t.Errorf("%+v — kept calling a station that was not on the air", a)
	}

	// Once the station has answered, nothing takes its place.
	e = on()
	e.OnPeriod(period(10, cq("DX", NeedSlot, 0)))
	e.OnPeriod(period(12, callsMe("DX", NeedSlot, 0)))
	if a := e.OnPeriod(period(14, callsMe("DX", NeedSlot, 0), cq("RARE", NeedDXCC, 0, watched))); a.Kind != DoNothing {
		t.Errorf("%+v — abandoned an exchange in progress", a)
	}
}

// TestAnExchangeInProgressIsNeverAbandoned is the shack report: mid-QSO with
// V31MA — its report decoded, our RR73 going out — and a station of a higher
// rung called us from the other side of the screen. The engine switched.
func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
	e := on()
	// V31MA is worth nothing on the ladder: worked before, nothing needed.
	if a := e.OnPeriod(period(0, callsMe("V31MA", NeedNone, -12))); a.Kind != DoReply {
		t.Fatalf("%+v — a station calling us was not answered", a)
	}
	// Its report arrives in the same period a better station calls us. That
	// period used to be judged before the reply was taken into account.
	a := e.OnPeriod(period(2,
		callsMe("V31MA", NeedNone, -12),
		callsMe("F5NNN", NeedSlot, -10)))
	if a.Kind != DoNothing {
		t.Fatalf("%+v — left V31MA mid-exchange", a)
	}
	if e.target == nil || e.target.Call != "V31MA" {
		t.Fatalf("target is %v — the QSO in progress lost its place", e.target)
	}

	// Our own transmission settles it too: a report is not an opening call, so
	// even a QSO the operator started by hand is protected.
	e = on()
	e.OnPeriod(period(4, cq("V31MA", NeedNone, -12, watched)))
	e.NoteTX(TXState{Transmitting: true, Msg: "V31MA F4BPO RR73"})
	if !e.answered {
		t.Error("sending a report did not count as being inside the exchange")
	}
	if a := e.OnPeriod(period(6, cq("V31MA", NeedNone, -12, watched), callsMe("F5NNN", NeedDXCC, -10))); a.Kind != DoNothing {
		t.Errorf("%+v — abandoned a QSO we were in the middle of", a)
	}
	if e.attempts != 0 {
		t.Errorf("attempts=%d — an exchange frame was counted as a call", e.attempts)
	}
}

// TestAPileupIsWorkedByCallingThroughIt is the shack report: D44TWO finishing a
// contact, the call started, the DX coming back to somebody else — and the
// engine giving up on a station that was one period from being free.
func TestAPileupIsWorkedByCallingThroughIt(t *testing.T) {
	e := on()
	if a := e.OnPeriod(period(0, cq("D44TWO", NeedDXCC, -7))); a.Kind != DoReply {
		t.Fatalf("%+v", a)
	}
	// It answers three other callers in a row — which is what a DX with a queue
	// does, and calling through it is how the queue is joined.
	does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) }
	for _, n := range []int{2, 4, 6} {
		if a := does(n); a.Kind != DoNothing {
			t.Fatalf("%+v — stopped calling a station working the pileup", a)
		}
		if e.Target() != "D44TWO" {
			t.Fatalf("target is %q — the station was released while it worked others", e.Target())
		}
	}
	// And when it comes to us, the exchange starts as usual.
	if a := e.OnPeriod(period(8, callsMe("D44TWO", NeedDXCC, -7))); a.Kind != DoNothing {
		t.Errorf("%+v", a)
	}
	if !e.answered {
		t.Error("the reply was not taken as the start of the exchange")
	}

	// A BETTER station may still take the slot while it is busy: nothing has
	// been answered, so nothing is lost by moving.
	e = on()
	e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7)))
	if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" {
		t.Errorf("%+v — a watched new one did not take the slot from a busy station", a)
	}
}

// TestTheFirstCallStartsWithNoMisses is the shack report: a CQ answered for the
// first time, and the toolbar already reading two misses out of three.
func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
	e := on()
	// It calls CQ on an odd slot; we answer.
	if a := e.OnPeriod(period(1, cq("ER1CW", NeedBand, -2))); a.Kind != DoReply {
		t.Fatalf("%+v", a)
	}
	// The next period is OURS: we are transmitting to it, and of course it is
	// not decoded. That is not a miss.
	pp := period(2)
	pp.TX = TXState{Transmitting: true}
	e.OnPeriod(pp)
	if e.misses != 0 {
		t.Fatalf("misses=%d after our own transmit period", e.misses)
	}
	// Nor is a period of the wrong parity with nothing in it.
	e.OnPeriod(period(4))
	if e.misses != 0 {
		t.Fatalf("misses=%d — counted a period the station never transmits in", e.misses)
	}
	// ITS period, and it is not there: that is a miss.
	e.OnPeriod(period(3))
	if e.misses != 1 {
		t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
	}
}

// Parking answers "it will not answer, stop wasting the evening on it" — which
// is a verdict about a station the LOG picked out, not about one the OPERATOR
// did. From the air: a watched ZD8GB, six streams a period, refused for the
// rest of the session after two series of unanswered calls.
func TestAWatchedCallsignIsNeverParked(t *testing.T) {
	spent := func(c Candidate) *Engine {
		e := New(Settings{Enabled: true, MaxRounds: 2})
		e.OnPeriod(period(0)) // the engine's clock, so the rest below is period time
		for i := 0; i < 2; i++ {
			e.giveUp(strings.ToUpper(c.Call), c) // a series ended by a brake
		}
		return e
	}

	// Period 8, well past the rest that follows a series: what is left is the
	// parking, and only the parking.
	plain := cq("PLAIN", NeedDXCC, 0)
	if a := spent(plain).OnPeriod(period(8, plain)); a.Kind == DoReply {
		t.Errorf("%+v — a station whose series are spent was called again", a)
	}

	dx := cq("ZD8GB", NeedDXCC, 0, watched)
	if a := spent(dx).OnPeriod(period(8, dx)); a.Kind != DoReply {
		t.Errorf("%+v — a watched DXpedition was parked for the session", a)
	}
}

// A period is judged more than once — the decodes arrive in a burst and
// stragglers follow — and a later judgement of it holds a partial view, not
// evidence that the station has gone. From the air: D2ACE answered in the very
// period the counter then read as a miss.
func TestAPeriodTheTargetWasSeenInIsNeverAMiss(t *testing.T) {
	e := on()
	if a := e.OnPeriod(period(1, cq("D2ACE", NeedBand, 13))); a.Kind != DoReply {
		t.Fatalf("%+v", a)
	}
	// Its own next period: decoded, so the exchange is under way.
	p := period(3, callsMe("D2ACE", NeedBand, 13))
	e.OnPeriod(p)
	if e.Status().Misses != 0 {
		t.Fatalf("misses=%d after being decoded", e.Status().Misses)
	}
	// The same period again, this time from a flush that carries only the
	// stragglers — the target is not among them.
	e.OnPeriod(period(3))
	if got := e.Status().Misses; got != 0 {
		t.Errorf("misses=%d — a second flush of a period it was seen in counted against it", got)
	}
}
