// Package autocall answers FT8/FT4 decodes without the operator clicking them.
//
// THIS KEYS THE TRANSMITTER ON ITS OWN, which is why the decision lives here,
// in one place, as a function of state that can be read, argued with and
// tested — rather than spread through a panel that only runs while its tab is
// open. Everything below is written as a series of refusals: a call goes out
// only when nothing says it should not.
//
// ── What it is for ────────────────────────────────────────────────────────
// On a busy band the operator cannot read twenty decodes, judge each against
// the log and click the right one inside a fifteen-second slot. This does that
// part: it ranks what is on the air by what the log still needs, calls the best
// one, and — the harder half — knows when to STOP calling it.
//
// ── The ladder ────────────────────────────────────────────────────────────
// A watched callsign outranks the same need from anybody else, at every level:
//
//	WL new DXCC > new DXCC > WL new band > new band > WL new mode > new mode
//	  > WL new slot > new slot > watched with nothing needed
//
// Watching a callsign is the operator saying "this one matters more than the
// rule", so it lifts a station by one rung rather than jumping the whole
// ladder: a watched new-slot must not outrank a new entity that will not come
// back.
//
// ── Why it stops ──────────────────────────────────────────────────────────
// The failure that matters is not calling the wrong station, it is calling one
// station for ever. Four independent brakes, any of which releases the target:
//
//   - attempts: 7 calls, or 15 for a watched callsign
//   - misses: 3 periods IN WHICH IT TRANSMITS with no decode of it
//   - the clock: a target held longer than MaxHold is dropped whatever the
//     counters say, because a counter that never advances never trips
//   - rounds: a callsign released this way is rested, and after MaxRounds
//     series it is parked for the session
//
// A released station is not banned. It can be picked again once rested, but
// only while nothing of HIGHER rank is callable — which is what keeps "seven
// calls, then straight back to the same station" from being fifty calls.
//
// ── Who is callable ───────────────────────────────────────────────────────
// A station in the middle of an exchange with somebody else is never targeted.
// It cannot answer, and WSJT-X and JTDX ignore a reply to it anyway; only the
// station calling CQ, calling US, or sending its last frame is worth a slot.
package autocall

import (
	"fmt"
	"regexp"
	"sort"
	"strings"
	"time"
	"unicode"
)

// Need is what the log still wants from a station, worst to best.
type Need int

const (
	NeedNone Need = iota
	// NeedExtra: the ENTITY has nothing left to give on this band and mode, but
	// the station carries something else that has never been worked — a WPX
	// prefix, a square, a US county or state, a park. Those are what the cluster
	// calls the orthogonal markers, and an operator who ticked them in the chase
	// settings is hunting them: leaving them at nothing-needed meant auto-call
	// watched a never-worked prefix call CQ and did nothing.
	//
	// The lowest rung, deliberately. It is worth calling when nothing better is
	// on the air, and never worth leaving a new band for.
	NeedExtra
	NeedSlot // entity worked on this band and in this mode, never together
	NeedMode // entity never worked in this mode
	NeedBand // entity never worked on this band
	NeedDXCC // entity never worked at all
)

func (n Need) String() string {
	switch n {
	case NeedDXCC:
		return "DXCC"
	case NeedBand:
		return "band"
	case NeedMode:
		return "mode"
	case NeedSlot:
		return "slot"
	case NeedExtra:
		return "extra"
	}
	return "-"
}

// Decode is one line from the decoder, carrying both what the decision needs
// and what a reply has to send back untouched.
type Decode struct {
	Call     string
	Band     string
	Mode     string
	Msg      string
	SNR      int
	At       time.Time
	TRPeriod int // slot length in seconds; 0 means "work it out from the mode"
	Instance string
	CQ       bool

	// Replay of the decoder's own line. WSJT-X matches a Reply against its
	// decode list field for field, so these are passed through unread.
	Ms      uint32
	DT      float64
	AudioHz int64
	ModeRaw string
	MsgRaw  string
	LowConf bool

	// IsNew is false for a decode the sender replayed from its history. Shown
	// in the panel, never answered: the station may have gone hours ago.
	IsNew bool
}

// Candidate is a decode with the log's verdict on it attached.
type Candidate struct {
	Decode
	Need    Need
	Watched bool
	// Extra names what NeedExtra is about — "prefix", "county", "square" — so the
	// decision line says why the station was worth a call. Empty otherwise.
	Extra string
	// Unconfirmed says the need above exists ONLY because a contact was never
	// confirmed — the operator hunts "new + unconfirmed", the band is in the log
	// and the QSL is not. A real need outranks it; see rank.
	Unconfirmed bool
	// Hidden is true when the decodes panel's own filters are keeping this
	// station off the screen. The operator's filters are the control: what is
	// not shown is not called — see Settings.OnScreenOnly.
	Hidden bool
	// Worked is "already in the log on this band AND mode". Nothing is gained
	// by calling it again — and while chasing confirmations it is the trap that
	// makes the same station be called all evening, because an unconfirmed QSO
	// leaves the entity flagged as still wanted.
	Worked bool
}

// TXState is what the decoding application says it is doing.
type TXState struct {
	Transmitting bool
	Enabled      bool // its own Enable Tx: nothing we send transmits while false
	DXCall       string
	Msg          string
	Instance     string
}

// Settings are the operator's.
type Settings struct {
	Enabled bool
	// Only is the "chase these callsigns" field: one or more, separated by
	// spaces or commas. Filled, nothing else is ever called — the ladder still
	// orders the ones listed, so "VP6D 3Y0J" calls whichever of the two is on
	// the air and takes the better catch when both are. Wildcards are the watch
	// list's job; this is a hunt list for right now.
	//
	// The brakes are unchanged, and hitting one stops the feature rather than
	// moving on to somebody else — an explicit request deserves an explicit
	// restart.
	Only string
	// Attempts before a target is released, and the larger allowance for a
	// watched callsign.
	Attempts        int
	WatchedAttempts int
	// Misses is how many of the station's OWN transmit periods may pass with no
	// decode of it before it is given up on.
	Misses int
	// RestPeriods is how many of the station's OWN OVERS a released callsign sits
	// out before it can be picked again, and MaxRounds how many such series it
	// gets before being parked for the session.
	//
	// Counted in overs, not in minutes, because that is the unit the thing is
	// happening in: an operator who has called a DX seven times without an answer
	// listens through one of its transmissions and calls again if it is still
	// there. Two minutes is four overs on FT8 — the DX has worked four other
	// callers by then, and half the time it has gone.
	RestPeriods int
	MaxRounds   int
	// MaxHold is the wall-clock backstop on one target.
	MaxHold time.Duration
	// OnScreenOnly restricts the calling to what the decodes panel is actually
	// showing.
	//
	// The filters an operator sets while reading the band — CQ only, LoTW only,
	// the new-category chips, continents, a minimum report, the search box — now
	// bind the transmitter too: a station filtered off the screen is not called.
	// It replaces a separate "LoTW users only" setting, which said one thing
	// while the chip above the list said another.
	//
	// The panel publishes what it is showing; when it is publishing nothing —
	// the tab was never opened this session — there is nothing to restrict and
	// the ladder decides alone.
	OnScreenOnly bool
}

// Defaults are what the operator gets before touching anything.
func Defaults() Settings {
	return Settings{
		Attempts: 7, WatchedAttempts: 15, Misses: 3,
		RestPeriods: 1, MaxRounds: 3, MaxHold: 4 * time.Minute,
	}
}

func (s Settings) withDefaults() Settings {
	d := Defaults()
	if s.Attempts <= 0 {
		s.Attempts = d.Attempts
	}
	if s.WatchedAttempts <= 0 {
		s.WatchedAttempts = d.WatchedAttempts
	}
	if s.Misses <= 0 {
		s.Misses = d.Misses
	}
	if s.RestPeriods <= 0 {
		s.RestPeriods = d.RestPeriods
	}
	if s.MaxRounds <= 0 {
		s.MaxRounds = d.MaxRounds
	}
	if s.MaxHold <= 0 {
		s.MaxHold = d.MaxHold
	}
	return s
}

// ActionKind is what the caller must do about the decision.
type ActionKind int

const (
	DoNothing ActionKind = iota
	DoReply              // answer this decode
	DoHalt               // stop the decoder transmitting
)

// Action is the decision, with the reason in plain words. The reason is not
// decoration: it is the only way an operator can tell an auto-call that is
// working from one that is stuck, and it goes to the log line by line.
type Action struct {
	Kind   ActionKind
	Decode Decode
	Reason string
	// Soft asks the decoder to finish the over it is sending and only then stop
	// transmitting, rather than cutting the carrier where it stands.
	//
	// It matters for exactly one brake. The attempts cap trips WHILE the seventh
	// call is going out — that is what counts it — so a hard halt cuts that
	// transmission a second in, which on the air is a half-sent call and on the
	// screen is an auto-call that "starts and stops". Nothing is saved by
	// stopping it: the frame is already half gone.
	//
	// Every other brake fires on a station that is not being transmitted to (it
	// has vanished, or the clock ran out between overs), and there a hard halt
	// is right: it is the one that also clears the decoder's own auto-sequence.
	Soft bool
}

// Engine holds the state between periods. Not safe for concurrent use; the
// caller owns the serialisation, which in OpsLog is the UDP event loop.
type Engine struct {
	set Settings

	// target is the station being called, held as the decode last seen of it so
	// a reply always carries a fresh timestamp.
	target *Candidate
	// targetInst is the receiver the target was picked on, so the brakes are
	// counted against ITS periods and its transmissions.
	targetInst string
	// now is the time of the last period seen. Every deadline in here is measured
	// against it rather than against the wall clock: the periods ARE the clock —
	// they arrive stamped — and mixing the two gave a rest that expired against
	// one reading and a hold that ran against another.
	now time.Time
	// waiting is the station the engine WOULD call and cannot, because it is in
	// a QSO with somebody else. Kept so the toolbar can say so: an auto-call
	// deliberately holding its fire looks exactly like one with nothing to do,
	// and from the outside there was no way to tell them apart.
	waiting string
	// answered says the target has replied to us at least once — the exchange is
	// under way and nothing may take its place.
	answered bool
	// heldSince is when this station BECAME the target, and is never refreshed
	// while it stays one. It was, and that quietly disabled the clock backstop:
	// a station decoded every period for ever kept pushing its own deadline
	// forward, which is exactly the case the backstop exists for.
	heldSince time.Time
	attempts  int
	misses    int
	// seenKey is the period in which the target was last DECODED. A period is
	// judged more than once — the decodes arrive in bursts and stragglers follow —
	// and a later judgement holds a partial view of it, not evidence of absence:
	// the station answered in that very period and the counter still read one
	// miss out of three.
	seenKey   string
	lastMiss  string // period already counted, so one period counts once
	txSlot    int    // which of the two slots the target transmits in; -1 unknown
	stopped   bool   // an explicit "Only" target gave up: needs a restart
	stoppedOn string

	// rested says when a released callsign may be considered again, and rounds
	// how many series it has already had this session.
	rested map[string]time.Time
	rounds map[string]int
	// trace, when set, receives one line per period: what was on the air, why
	// each station was refused, and what was decided. Off unless the operator
	// asks for it — a line per period is a line every fifteen seconds, all
	// night.
	trace func(string, ...any)
	// greyed is every station the OPERATOR stopped a call to.
	//
	// Halt is a verdict, not a pause: the operator watched the engine call this
	// station and said no. Answering that by releasing the target and picking
	// the same station again the next period — which is what clearing the state
	// did — is the machine overruling them, and it is what Halt exists to
	// prevent. It holds until auto-call is switched off and on again, which is
	// the one gesture that plainly means "start over".
	greyed map[string]bool
	// done is every station the exchange was completed with this session.
	//
	// The log is the authority on what is worked, and it is SLOW: the QSO is
	// logged when the operator (or the watcher) gets to it, several periods
	// later. In between, the station we have just worked is still flagged as a
	// new entity and is still on the air sending its 73 — which read as a
	// perfectly good station to call, and the engine called it straight back.
	done map[string]bool
}

func New(s Settings) *Engine {
	return &Engine{set: s.withDefaults(), txSlot: -1,
		rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{},
		greyed: map[string]bool{}}
}

// SetSettings swaps the settings in place. A target already being called is
// kept: the operator adjusting a limit is not asking to abandon the QSO in
// flight, and switching the feature off is a separate call.
func (e *Engine) SetSettings(s Settings) { e.set = s.withDefaults() }

// Target is the callsign being called, for the panel. Empty when idle.
func (e *Engine) Target() string {
	if e.target == nil {
		return ""
	}
	return e.target.Call
}

// Status is what the panel shows: who, how far into the brakes, and whether
// the engine has given up and is waiting for the operator.
type Status struct {
	Target string `json:"target"`
	// Waiting: wanted, decoded, and in a QSO with somebody else.
	Waiting   string `json:"waiting"`
	Attempts  int    `json:"attempts"`
	Max       int    `json:"max"`
	Misses    int    `json:"misses"`
	MaxMiss   int    `json:"max_miss"`
	Stopped   bool   `json:"stopped"`
	StoppedOn string `json:"stopped_on"`
}

func (e *Engine) Status() Status {
	st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn,
		Waiting: e.waiting}
	if e.target != nil {
		st.Target = e.target.Call
		st.Attempts = e.attempts
		st.Max = e.maxAttempts(*e.target)
	}
	return st
}

// Reset clears everything except the settings — the operator's Halt, a profile
// change, or switching the feature off and on again.
func (e *Engine) Reset() {
	e.target, e.attempts, e.misses, e.txSlot = nil, 0, 0, -1
	e.targetInst = ""
	e.lastMiss, e.stopped, e.stoppedOn = "", false, ""
	e.rested, e.rounds = map[string]time.Time{}, map[string]int{}
	e.done, e.greyed = map[string]bool{}, map[string]bool{}
}

// Halt is the operator stopping the call in progress.
//
// The station is set aside for the session — see greyed — and the target is
// released. Returns the callsign so the caller can say what happened; empty
// when nothing was being called, where Halt is just a halt.
func (e *Engine) Halt() string {
	if e.target == nil {
		return ""
	}
	call := strings.ToUpper(e.target.Call)
	e.greyed[call] = true
	e.release(call, true)
	// Not counted as a series: the brakes are about a station that will not
	// answer, and this one was never given the chance. It will not be called
	// again anyway.
	e.rounds[call]--
	if e.rounds[call] < 0 {
		e.rounds[call] = 0
	}
	delete(e.rested, call)
	return call
}

// SetTrace turns the per-period trace on (a non-nil function) or off (nil).
func (e *Engine) SetTrace(fn func(string, ...any)) { e.trace = fn }

// Greylisted reports how many stations the operator has stopped this session.
func (e *Engine) Greylisted() int { return len(e.greyed) }

// Take hands the operator's own click to the engine: the station they picked
// becomes the target, with the counters restarted. Everything after it — the
// brakes, the hand-off at the end of the QSO — then applies as usual.
func (e *Engine) Take(c Candidate) {
	e.target, e.targetInst = &c, c.Instance
	e.heldSince = c.At
	e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, ""
	e.stopped, e.stoppedOn = false, ""
}

// maxAttempts is the allowance for one target: larger for a watched callsign,
// because that is the operator saying this one is worth the extra slots.
func (e *Engine) maxAttempts(c Candidate) int {
	if c.Watched {
		return e.set.WatchedAttempts
	}
	return e.set.Attempts
}

// rank is the ladder, as one number.
//
// A WATCHED callsign outranks everything that is not watched. That is what a
// watch list means: the operator has named this station, and the machine's
// opinion of what the log needs does not get to overrule it.
//
// It did before, one rung at a time — watched new band above plain new band,
// but a watched station with nothing needed at the very bottom. On a band full
// of stations that ARE needed, that station was never reached: a watched
// DXpedition sat there all evening while the engine worked Brazilians. The
// list is not a tie-breaker, it is the answer.
//
// Below that line the old order stands, and for the same reasons: what is
// needed first, and a real need above one that exists only because a QSL never
// came — an unconfirmed new BAND still outranks a real new SLOT, because the
// band is the bigger prize whatever state it is in.
//
//	100+  every watched callsign, ordered among themselves by the same rule
//	 18   DXCC        16 DXCC unconf
//	 14   band        12 band unconf
//	 10   mode         8 mode unconf
//	  6   slot         4 slot unconf
//	  0   nothing to call for
//
// needLabel is what the candidate is worth, in words: the rung, or the name of
// the orthogonal marker when that is the whole reason for the call.
func needLabel(c Candidate) string {
	if c.Need == NeedExtra && c.Extra != "" {
		return "new " + c.Extra
	}
	return c.Need.String()
}

func rank(c Candidate) int {
	r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16
	if c.Need != NeedNone && !c.Unconfirmed {
		r += 2
	}
	if c.Watched {
		// Above every unwatched station, whatever the log makes of either.
		return 100 + r
	}
	return r
}

// onlyList splits the chase field. Space or comma, either way: an operator
// typing a list types it the way they think of it, and a field that silently
// ignores half of what was entered is worse than one that refuses it.
func onlyList(field string) []string {
	out := []string{}
	for _, tok := range strings.FieldsFunc(strings.ToUpper(field), func(r rune) bool {
		return r == ',' || r == ';' || unicode.IsSpace(r)
	}) {
		if tok = strings.TrimSpace(tok); tok != "" {
			out = append(out, tok)
		}
	}
	return out
}

func inList(list []string, call string) bool {
	for _, c := range list {
		if c == call {
			return true
		}
	}
	return false
}

var gridRe = regexp.MustCompile(`^[A-R]{2}[0-9]{2}([A-X]{2})?$`)

// isGrid is the grid test with the exchange tokens taken out first.
//
// "RR73" IS a valid Maidenhead square by the pattern — two letters in A-R, two
// digits — and it is also the second most common thing to find in that position
// of a message. Counting it as a fresh call spent an attempt on every QSO the
// engine actually completed, which is precisely backwards: a station that
// answers should cost nothing.
func isGrid(tok string) bool {
	switch tok {
	case "RR73", "RRR", "73", "RR", "R":
		return false
	}
	return gridRe.MatchString(tok)
}

// messages splits a decoded line into the messages it carries.
//
// One transmission can hold two. MSHV answers several callers at once — its
// own multi-answer transmission, not SuperFox — and the decoder prints them as
// one line:
//
//	YV5ALI RR73; F4BPO <HK0/PY8WW> -08
//
// The second half is a report to F4BPO. Reading only the start of the line, the
// engine saw a station working YV5ALI, decided it could not answer us, and
// dropped the target — at the exact moment the DX was answering. Each segment
// is its own message and names its own recipient first.
func messages(msg string) []string {
	return strings.Split(strings.ToUpper(strings.TrimSpace(msg)), ";")
}

// bare strips the angle brackets a decoder puts round a compressed callsign,
// and upper-cases what is left. "<HP/WE9G>" and "HP/WE9G" are one station, and
// every comparison in here has to agree about that.
func bare(call string) string {
	return strings.Trim(strings.ToUpper(strings.TrimSpace(call)), "<>")
}

// addressedTo reports whether one message segment is addressed to a callsign.
// The recipient is the first token, sometimes bracketed when the sender has
// compressed a non-standard call.
func addressedTo(part, call string) bool {
	toks := strings.Fields(part)
	if len(toks) == 0 || call == "" {
		return false
	}
	return bare(toks[0]) == bare(call)
}

// callable reports whether a station can be answered RIGHT NOW.
//
// A station in mid-exchange is committed to somebody else: it will not answer,
// and WSJT-X and JTDX refuse to act on a reply to it at all — so calling it is
// at best a wasted slot and at worst the operator watching an auto-call that
// appears to do nothing. CQ, a message addressed to us, and the final frame of
// somebody else's QSO (the station is free from the next period) are the three
// states worth a call.
func callable(c Candidate, myCall string) bool {
	if c.CQ {
		return true
	}
	parts := messages(c.Msg)
	if len(parts) == 0 {
		return false // nothing to read: assume it is busy rather than call blind
	}
	for _, part := range parts {
		toks := strings.Fields(part)
		if len(toks) == 0 {
			continue
		}
		if addressedTo(part, myCall) {
			return true // it is calling us
		}
		switch toks[len(toks)-1] {
		case "RR73", "RRR", "73":
			return true
		}
	}
	return false
}

// callingUs is the stronger half of callable: the station has our callsign in
// its message, so it has heard us and is waiting for an answer.
func callingUs(c Candidate, myCall string) bool {
	if myCall == "" || c.CQ {
		return false
	}
	for _, part := range messages(c.Msg) {
		if addressedTo(part, myCall) {
			return true
		}
	}
	return false
}

// finished reports that the exchange with this station is over: it sent us the
// last frame. Read from ITS message rather than from our own transmit state,
// which says only what we did.
func finished(decodes []Candidate, call, myCall string) bool {
	if myCall == "" {
		return false
	}
	me := strings.ToUpper(myCall)
	call = strings.ToUpper(call)
	for _, c := range decodes {
		if strings.ToUpper(c.Call) != call {
			continue
		}
		// Every segment: a fox says goodbye to one caller and hello to the next
		// in the same transmission.
		for _, part := range messages(c.Msg) {
			toks := strings.Fields(part)
			if len(toks) < 2 || !addressedTo(part, me) {
				continue
			}
			switch toks[len(toks)-1] {
			case "RR73", "RRR", "73":
				return true
			}
		}
	}
	return false
}

// slotOf is which of the two alternating slots a moment falls in. FT8 stations
// transmit in one and listen in the other, so a station is legitimately absent
// half the time — counting that as a miss dropped a perfectly workable station
// after two unlucky periods.
func slotOf(at time.Time, trSec int) int {
	if trSec <= 0 {
		trSec = 15
	}
	return int((at.UTC().Unix() / int64(trSec)) % 2)
}

// Period is one slot's worth of decodes, with the state of the world around it.
type Period struct {
	// Instance is the receiver this period came from. With two decoders running
	// — the split view, two bands — their slots are separate: a station absent
	// from the OTHER receiver's period says nothing about the one being called
	// on this one, and counting it as a miss dropped a target that was being
	// decoded perfectly well.
	Instance string
	// Key identifies the period, so a handler that runs twice for one slot
	// cannot count the same miss twice.
	Key      string
	At       time.Time
	TRPeriod int
	Decodes  []Candidate
	TX       TXState
	MyCall   string
}

// OnPeriod is the decision, taken once per receive period.
func (e *Engine) OnPeriod(p Period) Action {
	if !e.set.Enabled {
		return Action{}
	}
	if !p.At.IsZero() {
		e.now = p.At
	}
	// ONE station at a time, on the receiver it is being called on.
	//
	// This is the guarantee with two decoders running: while a target is held,
	// a period from any OTHER receiver is not even looked at. It cannot start a
	// second QSO over the top of the one in progress, however much better the
	// station it hears is, and its periods count no missed periods against a
	// target that was never on its band.
	if e.target != nil && e.targetInst != "" && p.Instance != "" && p.Instance != e.targetInst {
		return Action{}
	}

	// ── An existing target ────────────────────────────────────────────────
	if e.target != nil {
		t := *e.target
		tc := strings.ToUpper(t.Call)

		// HAS IT ANSWERED US? Settled first, before any of the checks below can
		// decide to leave it.
		//
		// The reply lands in the same period the ladder is re-read, and that
		// period used to be judged with answered still false — so a station
		// answering our CQ-call was dropped for a better-ranked one in the exact
		// period it came back to us. Watched from the shack: mid-exchange with
		// V31MA, report sent, and OpsLog switched to a station calling on the
		// other side of the screen. A QSO in progress outranks the ladder.
		if seen := bestOf(p.Decodes, tc); seen != nil && callingUs(*seen, p.MyCall) {
			e.answered = true
		}

		// The exchange is over — either the station sent us its last frame, or
		// the log now holds it. Hand straight on to the next station in the same
		// period rather than waiting for the next one: the slot is the resource.
		if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) {
			e.release(tc, false)
			// AND NOTHING ELSE THIS PERIOD. The next station is picked on the next
			// one, fifteen seconds later, and it is still there.
			//
			// His RR73 is decoded while we are between overs, so the decoder is
			// about to send our own 73 in the slot that is opening. Answering
			// somebody else now takes that slot: the reply switches the DX call
			// mid-sequence and the 73 never goes out whole — an operator watching
			// saw both transmissions in one period, and the station at the other
			// end never got the frame that closes the contact.
			//
			// This used to hand straight on "because the slot is the resource".
			// The slot is worth less than the QSO it would spoil.
			return Action{Kind: DoNothing,
				Reason: fmt.Sprintf("QSO with %s finished — leaving the next slot for our 73", tc)}
		}

		// The clock backstop. Every counter below depends on decodes arriving in
		// a particular shape; this one does not depend on anything.
		if !e.heldSince.IsZero() && p.At.Sub(e.heldSince) > e.set.MaxHold {
			e.giveUp(tc, t)
			return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)}
		}

		if e.trace != nil {
			e.trace("period %s holding %s calls=%d/%d misses=%d/%d%s",
				p.Key, tc, e.attempts, e.maxAttempts(t), e.misses, e.set.Misses,
				map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
		}
		// SOMETHING BETTER HAS COME ON THE AIR.
		//
		// A target is held until it answers or a brake releases it, and that is
		// right once an exchange has started — but before it has, holding means
		// spending the next two minutes on a new slot while the watched
		// DXpedition calls CQ three rows above it. Which is what an operator
		// sees: the first CQ ignored, then the second one answered because the
		// other call had run its course.
		//
		// So while the target has NOT answered us, a strictly better station
		// takes its place. "Better" is the ladder, which does not flicker: a
		// station's need does not change from period to period, so this cannot
		// oscillate between two of them — only a genuinely higher rung wins.
		if !e.answered {
			if a, ok := e.preempt(p); ok {
				return a
			}
		}
		if seen := bestOf(p.Decodes, tc); seen != nil {
			// The decode is refreshed so a reply carries a current timestamp; the
			// hold clock is NOT — see heldSince.
			e.target = seen
			e.misses, e.lastMiss, e.seenKey = 0, "", p.Key
			e.txSlot = slotOf(p.At, periodSecs(p, *seen))
			// In QSO: the decoder is sequencing the exchange on its own and the
			// attempt counter has done its job. Interrupting it with another
			// reply is how two transmissions land in one slot.
			if callingUs(*seen, p.MyCall) {
				e.attempts = 0
				e.answered = true
				// The exchange is under way, so the hold clock starts again from
				// here: it exists to end a call nobody answers, and this one has
				// been answered. Without this a QSO that began at the end of a
				// long wait could be halted mid-exchange by a backstop that was
				// counting the wait.
				e.heldSince = p.At
				return Action{}
			}
			// IT IS WORKING SOMEBODY ELSE, AND WE GO ON CALLING.
			//
			// This used to stop, on the reasoning that a station in a QSO cannot
			// hear the call. That is how a pileup is NOT worked: a DX with a
			// queue answers one caller per period, and the only way to be the
			// next one is to keep calling while it works the others. An operator
			// watched exactly that — D44TWO finishing a contact, the call
			// started, the DX coming back to somebody else, and the engine
			// giving up on a station that was about to be free.
			//
			// The effort is already bounded: seven calls, fifteen for a watched
			// station, and the miss counter for one that goes off the air. And
			// nothing is wasted while it is busy — a better station may still
			// take the slot, because it has not answered us (see preempt).
			//
			// The callable test still governs the CHOICE of a target, where it
			// belongs: a reply to a decode in mid-exchange is one WSJT-X and
			// JTDX may refuse outright.
			if e.trace != nil && !callable(*seen, p.MyCall) {
				e.trace("period %s %s is working %s — carrying on calling", p.Key, tc, addressee(seen.Msg))
			}
			return Action{}
		}

		// Absent. Only its OWN transmit periods count against it — and never one
		// of ours: our transmission is the reason it is not there.
		if p.TX.Transmitting {
			return Action{}
		}
		// Nor a period it was already decoded in: see seenKey.
		if e.seenKey == p.Key {
			return Action{}
		}
		if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
			return Action{}
		}
		if e.lastMiss == p.Key {
			return Action{}
		}
		e.lastMiss = p.Key
		e.misses++
		if e.misses >= e.set.Misses {
			e.giveUp(tc, t)
			return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s not decoded for %d of its own periods", tc, e.set.Misses)}
		}
		return Action{}
	}

	// ── No target ─────────────────────────────────────────────────────────
	if e.stopped {
		return Action{} // an explicit target gave up; the operator restarts it
	}
	return e.pick(p, "")
}

// pick chooses the best station on the air and answers it.
//
// NEVER over a transmission in progress, and that guard belongs HERE because
// there are two ways in. A reply is not a request the decoder queues: WSJT-X
// acts on it at once, drops the exchange it is in and starts calling the new
// station — so a reply sent while our own 73 was going out cut that 73 about a
// second in, and the station we had just worked never got it. Reported from the
// air, and the sequence is exactly this one: his RRR is decoded, the QSO reads
// as finished, and the next station is picked in the same instant.
//
// The slot is worth having, but not at the price of the QSO in hand. The next
// period is a fifteen-second wait and everything worth calling is still there.
func (e *Engine) pick(p Period, why string) Action {
	if e.trace != nil {
		e.tracePick(p)
	}
	if p.TX.Transmitting {
		if why != "" {
			return Action{Kind: DoNothing, Reason: why + " — holding until the transmission ends"}
		}
		return Action{}
	}
	only := onlyList(e.set.Only)
	var best *Candidate
	var bestRank int
	// The best station that is wanted, decoded, and working somebody else. It
	// is not a candidate — it cannot answer — but it is the reason the engine
	// is silent, and the operator is entitled to know that.
	waiting, waitingRank := "", 0
	for i := range p.Decodes {
		c := p.Decodes[i]
		if ok, why := e.judge(c, p, only); !ok {
			if why == "busy" {
				if r := rank(c); r > waitingRank {
					waiting, waitingRank = strings.ToUpper(c.Call), r
				}
			}
			continue
		}
		r := rank(c)
		if best == nil || better(c, r, *best, bestRank, p.MyCall) {
			cc := c
			best, bestRank = &cc, r
		}
	}
	e.waiting = waiting
	if best == nil {
		if waiting != "" {
			return Action{Kind: DoNothing,
				Reason: fmt.Sprintf("waiting for %s — it is working somebody else", waiting)}
		}
		if why != "" {
			return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
		}
		return Action{}
	}
	e.waiting = ""
	e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
	// ITS SLOT IS KNOWN FROM THE DECODE THAT MADE IT A CANDIDATE.
	//
	// This used to start at -1, meaning "parity unknown", and with parity
	// unknown the miss counter has nothing to filter on: every period without
	// the station counted, INCLUDING the one we spend transmitting to it, where
	// it cannot be decoded by definition. An operator answering a CQ saw the
	// counter at two misses out of three before the station had had a single
	// chance to come back — one bad period from being given up on.
	e.attempts, e.misses, e.lastMiss = 0, 0, ""
	e.txSlot = slotOf(p.At, periodSecs(p, *best))
	e.answered = false
	e.waiting = ""
	reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), needLabel(*best))
	if why != "" {
		reason = why + " — " + reason
	}
	return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
}

// tracePick writes the one line that says what this period held and what it
// was worth. Counts by refusal, then the best few candidates in rank order —
// enough to see WHY a station on the screen was not the one called.
func (e *Engine) tracePick(p Period) {
	only := onlyList(e.set.Only)
	refused := map[string]int{}
	// The callsigns too, not only the counts. "worked=2" answers "how many" and
	// leaves "which" — and which is the whole question when an operator is
	// looking at one station on the screen and asking why it was passed over.
	who := map[string][]string{}
	type scored struct {
		c Candidate
		r int
	}
	var ok []scored
	for i := range p.Decodes {
		if good, why := e.judge(p.Decodes[i], p, only); good {
			ok = append(ok, scored{p.Decodes[i], rank(p.Decodes[i])})
		} else {
			refused[why]++
			if len(who[why]) < 4 {
				who[why] = append(who[why], p.Decodes[i].Call)
			}
		}
	}
	sort.Slice(ok, func(i, j int) bool { return ok[i].r > ok[j].r })
	best := make([]string, 0, 3)
	for i := 0; i < len(ok) && i < 3; i++ {
		best = append(best, fmt.Sprintf("%s(%s%s,%d dB,r%d)",
			ok[i].c.Call, watchedTag(ok[i].c), needLabel(ok[i].c), ok[i].c.SNR, ok[i].r))
	}
	why := make([]string, 0, len(refused))
	for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
		if n := refused[k]; n > 0 {
			names := strings.Join(who[k], ",")
			if n > len(who[k]) {
				names += ",…"
			}
			why = append(why, fmt.Sprintf("%s=%d(%s)", k, n, names))
		}
	}
	e.trace("period %s rx=%q decodes=%d callable=%d [%s] refused[%s]%s",
		p.Key, p.Instance, len(p.Decodes), len(ok), strings.Join(best, " "),
		strings.Join(why, " "),
		map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
}
func watchedTag(c Candidate) string {
	if c.Watched {
		return "watched "
	}
	return ""
}

// eligible is every refusal that applies before a station is even ranked.
func (e *Engine) eligible(c Candidate, p Period, only []string) bool {
	ok, _ := e.judge(c, p, only)
	return ok
}

// judge is eligible() with the reason attached, in one word.
//
// The reason exists for the log. An auto-call that calls nobody is the hardest
// thing to argue with — the band is full, the panel shows a dozen stations
// wanted, and nothing goes out — and "twenty-three decodes, nineteen already
// worked, three greyed, one resting" answers it in a line where a boolean
// cannot.
func (e *Engine) judge(c Candidate, p Period, only []string) (bool, string) {
	call := strings.ToUpper(strings.TrimSpace(c.Call))
	if call == "" || call == strings.ToUpper(p.MyCall) {
		return false, "self"
	}
	if !c.IsNew {
		return false, "replay" // the station may be hours gone
	}
	if len(only) > 0 {
		// The named stations, and each only until the QSO with it is made: an
		// explicit request is not a standing order to work the same station all
		// evening. The others on the list stay callable.
		switch {
		case !inList(only, call):
			return false, "not-chased"
		case e.greyed[call]:
			return false, "halted"
		case e.done[call] || c.Worked:
			return false, "worked"
		}
		return true, ""
	}
	if c.Worked || e.done[call] {
		return false, "worked"
	}
	if e.greyed[call] {
		return false, "halted"
	}
	// A STATION CALLING US is answered, needed or not.
	//
	// It has heard us, it is waiting, and the QSO is one over away — refusing it
	// because the log has nothing to gain is how an operator finishes a contact,
	// sees two stations calling them on the next sequence, and watches the
	// machine ignore both. Everything below this point is about choosing whom to
	// CALL; a caller is not chosen, it is already there.
	//
	// The refusals above still apply — worked on this band and mode, the QSO
	// already made, a station the operator stopped. Those are answers about this
	// station, not about the log's appetite.
	if callingUs(c, p.MyCall) {
		return true, ""
	}
	// "LoTW users only" is a rule about STRANGERS. It cannot overrule the watch
	// list: naming a callsign is the operator saying they want that station, and
	// a DXpedition that uploads nowhere is exactly the kind of station one puts
	// on the list. Reported from the air — a watched J38DX, plainly calling CQ,
	// never called all evening because it is not in the LoTW user file.
	// FILTERED OFF THE SCREEN. The operator's own filters, applied to the
	// transmitter: what is not shown is not called.
	if e.set.OnScreenOnly && c.Hidden {
		return false, "hidden"
	}
	if rank(c) == 0 {
		return false, "nothing-needed"
	}
	// A WATCHED callsign is never parked.
	//
	// Parking is the answer to "it will not answer, stop wasting the evening on
	// it" — a fair verdict about a station the LOG picked out, and the wrong one
	// about a station the OPERATOR did. A DXpedition running a pileup takes more
	// than two series of calls to get through to, which is precisely why it was
	// put on the list; watched ZD8GB was refused for the rest of the session
	// after fourteen unanswered calls, while it went on transmitting six streams
	// a period.
	//
	// The rest between series still applies, so it does not monopolise the
	// transmitter — it simply never becomes ineligible.
	if e.rounds[call] >= e.set.MaxRounds && !c.Watched {
		return false, "parked" // its series are spent for the session
	}
	// RESTING. A series that ended in a brake is followed by a real pause,
	// whatever else is on the air — the exception used to be "unless nothing
	// better is decoding", and for the station everybody is chasing that is
	// every period: seven calls, a halt, and the same station called again four
	// seconds later. From the outside that is not a rest, it is a stutter.
	if until, ok := e.rested[call]; ok && p.At.Before(until) {
		return false, "resting"
	}
	// Mid-exchange with somebody else: it cannot answer us — see callable.
	if !callable(c, p.MyCall) {
		return false, "busy"
	}
	return true, ""
}

// better orders two eligible stations: the need first, then the one already
// calling us, then a CQ over a station about to be free, then the strongest.
func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
	if ra != rb {
		return ra > rb
	}
	if x, y := callingUs(a, myCall), callingUs(b, myCall); x != y {
		return x
	}
	if a.CQ != b.CQ {
		return a.CQ
	}
	return a.SNR > b.SNR
}

// preempt hands the slot to a better station, if one is on the air.
func (e *Engine) preempt(p Period) (Action, bool) {
	if e.target == nil || p.TX.Transmitting {
		// Mid-over: the reply would switch the decoder's call in the middle of
		// a transmission. It waits for the gap, like every other call.
		return Action{}, false
	}
	held := rank(*e.target)
	heldSeen := bestOf(p.Decodes, strings.ToUpper(e.target.Call)) != nil
	only := onlyList(e.set.Only)
	best, bestRank := (*Candidate)(nil), held
	for i := range p.Decodes {
		c := p.Decodes[i]
		if strings.EqualFold(c.Call, e.target.Call) || !e.eligible(c, p, only) {
			continue
		}
		// Strictly better takes over. So does an EQUAL rung when the station we
		// are calling is not even on the air this period: the seven calls are
		// going into the void while a station of the same value is calling CQ
		// three rows above it, which is what an operator sees and cannot explain.
		r := rank(c)
		switch {
		case r > bestRank:
		case r == bestRank && !heldSeen && best == nil:
		case best != nil && r == bestRank && better(c, r, *best, bestRank, p.MyCall):
		default:
			continue
		}
		{
			cc := c
			best, bestRank = &cc, r
		}
	}
	if best == nil {
		return Action{}, false
	}
	was := strings.ToUpper(e.target.Call)
	e.drop()
	e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
	return Action{Kind: DoReply, Decode: best.Decode,
		Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet",
			best.Call, watchedTag(*best), needLabel(*best), was)}, true
}

// drop lets the target go with no verdict attached: not worked, not given up
// on, not rested. It is simply not the station to be calling right now, and it
// is picked again like any other the moment it is.
func (e *Engine) drop() {
	e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
	e.targetInst, e.answered, e.seenKey = "", false, ""
}

// addressee is who a message is being sent to — the first token, which is the
// callsign being answered — the first segment of a multi-answer line, which is
// the QSO it is finishing.
func addressee(msg string) string {
	parts := messages(msg)
	if len(parts) == 0 {
		return "someone else"
	}
	toks := strings.Fields(parts[0])
	if len(toks) == 0 {
		return "someone else"
	}
	return bare(toks[0])
}

// release clears the target. gaveUp marks it as a series that ended in a brake
// rather than in a QSO.
func (e *Engine) release(call string, gaveUp bool) {
	e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
	e.targetInst, e.answered = "", false
	if !gaveUp {
		// A finished QSO is not a failed series — the callsign keeps its rounds —
		// but it IS finished: nothing more is wanted from this station today,
		// whatever the log still says while it catches up.
		delete(e.rounds, call)
		e.done[call] = true
	}
}

func (e *Engine) giveUp(call string, t Candidate) {
	e.release(call, true)
	e.rounds[call]++
	at := e.now
	if at.IsZero() {
		at = time.Now()
	}
	e.rested[call] = at.Add(restFor(e.set.RestPeriods, candidateSecs(t)))
	// An explicit "call this station" that runs out of attempts stops the
	// feature instead of moving on. There is nothing else it was asked to do.
	if strings.TrimSpace(e.set.Only) != "" {
		e.stopped, e.stoppedOn = true, call
	}
}

// NoteTX counts what actually goes on the air, and is the ONLY place attempts
// are counted or capped.
//
// Counted here rather than where the reply is sent, because those are different
// things: a reply may be refused by the decoder, and the decoder transmits
// several times per QSO on its own. Capped here too — the period handler used
// to cap as well, and a target could sit at twenty-four calls while none of its
// conditions were met. One place that counts and stops cannot overshoot.
//
// A FRESH call only: the third token of an FT8 call is a grid, where the rest
// of the exchange carries a report, R-report or 73. Without that, the answer to
// "how many times have we called this station" counted the whole QSO.
func (e *Engine) NoteTX(tx TXState) Action {
	if !e.set.Enabled || e.target == nil || !tx.Transmitting {
		return Action{}
	}
	// The OTHER decoder transmitting is not us calling this station: in a split
	// view both are on the air, and counting both spent the seven calls in half
	// the time — on a target the other receiver had never heard of.
	if e.targetInst != "" && tx.Instance != "" && tx.Instance != e.targetInst {
		return Action{}
	}
	t := *e.target
	tc := bare(t.Call)
	msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
	switch {
	case msg != "":
		toks := strings.Fields(msg)
		// bare(): a decoder compresses a non-standard callsign into angle
		// brackets — "<HP/WE9G> F4BPO JN36" is a call to HP/WE9G — and comparing
		// the raw token left the counter at 0/15 while the operator watched call
		// after call go out. Every brake downstream of it was dead too.
		if len(toks) < 3 || bare(toks[0]) != tc {
			return Action{}
		}
		if !isGrid(toks[2]) {
			// A report, an R-report, RR73: we are INSIDE the exchange, not
			// opening it. It does not count as a call — and it settles that
			// nothing may take this station's place, which matters when the
			// decoder is answering somebody the engine never picked (the
			// operator double-clicked a row) and no reply has been decoded yet.
			e.answered = true
			return Action{}
		}
	default:
		// JTDX reports no transmit message. The DX call is all there is, so
		// every transmit period aimed at the target counts — more generous, and
		// far better than a counter frozen at zero for ever.
		if bare(tx.DXCall) != tc {
			return Action{}
		}
	}
	e.attempts++
	if e.attempts < e.maxAttempts(t) {
		return Action{}
	}
	max := e.maxAttempts(t)
	e.giveUp(tc, t)
	return Action{Kind: DoHalt, Soft: true,
		Reason: fmt.Sprintf("%s called %d times without an answer — stopping after this over", tc, max)}
}

// bestOf returns the most callable decode of one station in a period.
//
// A station running several streams at once — a DXpedition answering four
// callers — appears several times in one period: a report to one, RR73 to
// another, a CQ to the band. Judging it on whichever line came first reads a
// station that is free right now as busy.
func bestOf(decodes []Candidate, call string) *Candidate {
	var best *Candidate
	for i := range decodes {
		c := decodes[i]
		if strings.ToUpper(c.Call) != call {
			continue
		}
		if best == nil || (c.CQ && !best.CQ) || (c.CQ == best.CQ && c.SNR > best.SNR) {
			cc := c
			best = &cc
		}
	}
	return best
}

// workedNow reports that the log has caught up with a station mid-series — the
// QSO was logged, by this or by another hand.
func workedNow(decodes []Candidate, call string) bool {
	for _, c := range decodes {
		if strings.ToUpper(c.Call) == call && c.Worked {
			return true
		}
	}
	return false
}

// restFor turns a number of the station's own overs into a deadline.
//
// A station transmits every OTHER slot, so one of its overs is two T/R periods —
// and the extra half-cycle is what makes the answer stable: without it the
// deadline lands exactly on the station's next transmission, where a
// millisecond of clock skew decides whether it is called or passed over. Landing
// it mid-cycle makes "sit out one over" mean one over, every time.
func restFor(overs, trSec int) time.Duration {
	if overs <= 0 {
		return time.Duration(trSec) * time.Second // no rest: its next over will do
	}
	return time.Duration(overs*2*trSec+trSec) * time.Second
}

// candidateSecs is the station's own T/R period in seconds, defaulting to FT8's.
func candidateSecs(c Candidate) int {
	if c.TRPeriod > 0 {
		return c.TRPeriod
	}
	return 15
}

func periodSecs(p Period, c Candidate) int {
	if c.TRPeriod > 0 {
		return c.TRPeriod
	}
	if p.TRPeriod > 0 {
		return p.TRPeriod
	}
	return 15
}

// TargetInstance names the receiver the current target is being called on, and
// the callsign. Both empty when idle.
func (e *Engine) TargetInstance() (instance, call string) {
	if e.target == nil {
		return "", ""
	}
	return e.targetInst, e.target.Call
}
