// Package award computes amateur-radio award progress (worked / confirmed)
// directly from the logbook. An award is defined declaratively: a QSO FIELD to
// scan plus an optional regular-expression PATTERN that extracts the reference
// from that field. With no pattern the whole field value is the reference; with
// a pattern, capture group 1 (or the whole match) is the reference and a single
// QSO may yield several references (e.g. a Note holding "D74 D73").
//
// Examples:
//
//	DXCC : field "dxcc"   (no pattern)            → entity number
//	WAS  : field "state", DXCCFilter [291,110,6]  → US state
//	DDFM : field "note",  pattern "D(\d{1,2}[AB]?)" → French department from notes
//	WPX  : field "prefix" (computed from callsign)
package award

import (
	"embed"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"sort"
	"strconv"
	"strings"

	"hamlog/internal/qso"
)

// AwardType selects how a QSO is matched to an award's references.
//
//	"DXCC"      — match the QSO's DXCC entity number (references keyed by entity)
//	"QSOFIELDS" — search a QSO field for a reference code/description/pattern
//	"REFERENCE" — the reference is carried by a dedicated field (POTA_REF, …) or
//	              the per-reference DXCC list (e.g. RAC provinces by state)
//	"GRID"      — match a Maidenhead grid square
type AwardType = string

const (
	TypeDXCC      AwardType = "DXCC"
	TypeQSOFields AwardType = "QSOFIELDS"
	TypeReference AwardType = "REFERENCE"
	TypeGrid      AwardType = "GRID"
)

// Def defines one award. Fields mirror Log4OM's Award Management model: an
// identity + scope (when the award applies) + a matching rule (how a QSO maps
// to a reference) + confirmation rules. Most fields are optional; the zero
// value of a legacy Def (only Field/Pattern/DXCCFilter/Confirm/Total set) still
// behaves as before.
type Def struct {
	// --- Identity ---
	Code        string `json:"code"`                   // unique key, e.g. "DXCC"
	Name        string `json:"name"`                   // friendly name
	Description string `json:"description,omitempty"`  // free text
	Valid       bool   `json:"valid"`                  // award enabled
	Protected   bool   `json:"protected,omitempty"`    // shipped/locked award
	URL         string `json:"url,omitempty"`          // award home page
	DownloadURL string `json:"download_url,omitempty"` // reference-list source
	RefURL      string `json:"ref_url,omitempty"`      // per-ref link, <REF> placeholder
	ValidFrom   string `json:"valid_from,omitempty"`   // ISO date (QSOs before don't count)
	ValidTo     string `json:"valid_to,omitempty"`     // ISO date (QSOs after don't count)
	Alias       string `json:"alias,omitempty"`
	// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
	// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
	// the prefecture name), "both" = "ref — name". DXCC always shows the country
	// name under "ref" (unchanged).
	RefDisplay string `json:"ref_display,omitempty"`

	// --- Type & matching ---
	Type        AwardType `json:"type,omitempty"`         // matching strategy (default QSOFIELDS)
	Field       string    `json:"field"`                  // QSO field to scan (see fieldRaw)
	MatchBy     string    `json:"match_by,omitempty"`     // "code" | "description" | "pattern"
	ExactMatch  bool      `json:"exact_match,omitempty"`  // match the whole field vs substring
	Pattern     string    `json:"pattern"`                // award-level Go regexp; group 1 = reference
	LeadingStr  string    `json:"leading_str,omitempty"`  // strip this prefix before matching
	TrailingStr string    `json:"trailing_str,omitempty"` // strip this suffix before matching
	Prefix      string    `json:"prefix,omitempty"`       // prepended to each found reference (e.g. captured "01" + "D" → "D01"), same as OrRule.Prefix
	Dynamic     bool      `json:"dynamic,omitempty"`      // references not predefined (any value counts)
	// OneRefPerQSO refuses an AMBIGUOUS match rather than guessing.
	//
	// This is not the old `Multi` flag, which was removed for good reason: a QSO
	// always yields every reference its FIELD holds — an n-fer POTA activation
	// ("US-6544,US-0680"), a VUCC contact on a grid line. That is several
	// references, correctly.
	//
	// The case here is the opposite. The field holds ONE value and two entries in
	// the reference list carry it: two German DOKs are both called "Gießen",
	// because two clubs share the town. The matcher is not wrong, the data is
	// ambiguous — and picking one would write a reference into the log that may
	// well be the other. So on an award where a QSO can only ever count for one
	// reference, an ambiguous match assigns NONE and the contact appears under
	// "missing references", where the operator picks. Their choice then sticks:
	// a manual reference overrides the matcher.
	OneRefPerQSO bool `json:"one_ref_per_qso,omitempty"`

	// OrRules are ordered FALLBACK searches for the primary one above: they are
	// tried IN ORDER and only while nothing has matched yet — the first rule that
	// yields a reference wins and the rest are skipped (short-circuit, like a
	// chain of "else if"). Lets a Chinese province (WAPC) be found first from the
	// province NAME in the address, and only if that fails, from a city regex
	// (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also
	// re-tagged, possibly differently, by a later rule.
	OrRules []OrRule `json:"or_rules,omitempty"`

	// --- Scope ---
	DXCCFilter []int    `json:"dxcc_filter"`           // limit to these DXCC entities (nil = any)
	ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
	ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
	Emission   []string `json:"emission,omitempty"`    // CW | DIGITAL | PHONE (empty = all)

	// --- Confirmation ---
	Confirm  []string `json:"confirm"`            // worked-confirmed: lotw|qsl|eqsl|qrzcom|hamlog|custom
	Validate []string `json:"validate,omitempty"` // validated/granted sources
	// The "custom" source, for confirmations OpsLog has no dedicated column for:
	// ConfirmField names a QSO field or an ADIF extras key, ConfirmValue the
	// value(s) that count (comma-separated, case-insensitive).
	//
	// An EMPTY ConfirmValue means "any non-empty value confirms" — which is what
	// the OpsLog card marker needs: APP_OPSLOG_QSL_RCVD stores the timestamp of
	// the day the card arrived, not a Y/N flag. The same shape serves a key
	// stamped by an outside source (a club's CSV imported into an extras field),
	// so one mechanism covers every "confirmed somewhere else" case instead of a
	// new checkbox per site.
	ConfirmField string `json:"confirm_field,omitempty"`
	ConfirmValue string `json:"confirm_value,omitempty"`
	// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
	// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
	// controls have been removed from the editor — a checkbox that quietly does
	// nothing is worse than no checkbox, because it is trusted. Wire these up (in
	// internal/adif) before showing them again.
	GrantCodes          string `json:"grant_codes,omitempty"`           // ADIF credit grant codes
	ExportCreditGranted bool   `json:"export_credit_granted,omitempty"` // write ADIF credit_granted

	Total   int  `json:"total"`   // known denominator (0 = unknown / derive from list)
	Builtin bool `json:"builtin"` // shipped default (informational)

	// --- Catalog updates ---
	// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
	// you fix a definition (a better OR chain, a corrected reference list) and want
	// that fix to reach operators who already run the award: on startup, a catalog
	// award whose Version is higher than the stored one REPLACES it, definition and
	// references. Awards created by the operator have no version and are never touched.
	Version int `json:"version,omitempty"`
	// UserEdited marks an award the operator has changed. A catalog update then
	// SKIPS it: their work outranks ours. Set the moment the award (or its reference
	// list) is saved to something other than what the catalog ships.
	UserEdited bool `json:"user_edited,omitempty"`
}

// SameContent reports whether two definitions describe the same award — ignoring
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
// which say where a definition came from, not what it does. Used to decide whether
// a save actually changed anything.
func (d Def) SameContent(o Def) bool {
	a, b := d, o
	a.Version, b.Version = 0, 0
	a.UserEdited, b.UserEdited = false, false
	a.Builtin, b.Builtin = false, false
	ja, err1 := json.Marshal(a)
	jb, err2 := json.Marshal(b)
	if err1 != nil || err2 != nil {
		return false
	}
	return string(ja) == string(jb)
}

// OrRule is one additional search OR'd with the award's primary matching rule.
// Same knobs as the primary (field + how to match), plus Prefix which is
// prepended to each reference it finds so a captured value can be normalised to
// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74").
type OrRule struct {
	Field       string `json:"field"`                  // QSO field to scan
	MatchBy     string `json:"match_by,omitempty"`     // "code" | "description" | "pattern"
	ExactMatch  bool   `json:"exact_match,omitempty"`  // match the whole field vs substring
	Pattern     string `json:"pattern,omitempty"`      // Go regexp; group 1 = reference
	LeadingStr  string `json:"leading_str,omitempty"`  // strip this prefix before matching
	TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
	Prefix      string `json:"prefix,omitempty"`       // prepended to each found reference
}

// catalogFS holds the built-in award definitions as DATA, not Go code.
//
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
// recompile and a release for every new one, which is absurd for something that
// changes far more often than the engine that reads it. They now live one JSON per
// award in catalog/, embedded in the binary: adding an award is adding a file.
//
// This is the SEED only. Once a user has awards in their database, that database
// is the source of truth — the catalog never overwrites their edits behind their
// back.
//
//go:embed catalog/*.json
var catalogFS embed.FS

// CatalogEntry is one award in the catalog: its definition AND its reference list.
//
// The references are the point. An award's definition is a few lines; what makes
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
// A catalog that shipped definitions only would hand every user an empty shell —
// which is exactly the trap this design is meant to avoid.
//
// References stay as raw JSON so this package (the matching ENGINE) never has to
// import the reference-store package. The caller decodes them.
type CatalogEntry struct {
	Def        Def             `json:"def"`
	References json.RawMessage `json:"references,omitempty"`
}

// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
//
//	{"def": {...}, "references": [...]}                  — a catalog entry
//	{"version":1, "awards":[{"def":…,"references":…}]}   — an exported bundle
//
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
// can be dropped straight into catalog/ and shipped to everyone, with no
// conversion step. That is the whole loop — create an award, export it, drop it in,
// everybody has it.
type catalogFile struct {
	Def        Def             `json:"def"`
	References json.RawMessage `json:"references,omitempty"`
	Awards     []CatalogEntry  `json:"awards,omitempty"`
	// A bare Def (the original catalog shape) is still read via the fields above
	// being empty and Code being set at the top level.
	Code string `json:"code,omitempty"`
}

// Catalog returns every built-in award: definition + references, sorted by code.
//
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
// how a user's award list quietly reorders itself on a rebuild.
func Catalog() []CatalogEntry {
	entries, err := catalogFS.ReadDir("catalog")
	if err != nil {
		return nil // embedded: can only fail if the build is broken
	}
	out := make([]CatalogEntry, 0, len(entries))
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
			continue
		}
		b, err := catalogFS.ReadFile("catalog/" + e.Name())
		if err != nil {
			continue
		}
		var f catalogFile
		if err := json.Unmarshal(b, &f); err != nil {
			// A malformed file must not take the other awards down with it.
			continue
		}
		switch {
		case len(f.Awards) > 0: // an exported bundle, dropped in as-is
			for _, a := range f.Awards {
				if strings.TrimSpace(a.Def.Code) != "" {
					out = append(out, a)
				}
			}
		case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
			out = append(out, CatalogEntry{Def: f.Def, References: f.References})
		case strings.TrimSpace(f.Code) != "": // a bare Def
			var d Def
			if err := json.Unmarshal(b, &d); err == nil {
				out = append(out, CatalogEntry{Def: d})
			}
		}
	}
	// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
	// trusting the flag in the file: you drop in an award you exported, its JSON
	// says builtin:false (you wrote it, it wasn't built-in then), and it would
	// quietly miss every future catalog correction. Making the author remember to
	// flip a flag is exactly the kind of step nobody can guess.
	for i := range out {
		out[i].Def.Builtin = true
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
	return out
}

// Defaults are the built-in award definitions seeded on first run.
func Defaults() []Def {
	cat := Catalog()
	out := make([]Def, 0, len(cat))
	for _, e := range cat {
		out = append(out, e.Def)
	}
	return out
}

// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
// any. Awards whose list is seeded from code (DXCC entities, French departments)
// or fetched online (POTA/SOTA/WWFF) carry none.
func CatalogRefs(code string) (json.RawMessage, bool) {
	code = strings.ToUpper(strings.TrimSpace(code))
	for _, e := range Catalog() {
		if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
			return e.References, true
		}
	}
	return nil, false
}

// Migrate upgrades award definitions saved before the richer model existed.
// Such defs have Type=="" and the zero value for the new fields (notably
// Valid==false, which would otherwise hide every legacy award). For each legacy
// def it enables the award, fills the matching/confirmation fields from the
// matching built-in default (preserving the user's field/filters/confirm), and
// fixes the DDFM capture pattern. Returns the (possibly) migrated slice and
// whether anything changed. Idempotent: a def with Type!="" is left untouched.
func Migrate(defs []Def) ([]Def, bool) {
	defaults := map[string]Def{}
	for _, d := range Defaults() {
		defaults[strings.ToUpper(d.Code)] = d
	}
	const oldDDFM = `(?i)\bD(\d{1,2}[AB]?)\b`
	changed := false
	out := make([]Def, len(defs))
	for i, d := range defs {
		if d.Type != "" {
			out[i] = d // already on the new model
			continue
		}
		changed = true
		d.Valid = true // legacy defs predate the Valid flag → enable them
		if def, ok := defaults[strings.ToUpper(d.Code)]; ok {
			d.Type = def.Type
			d.ExactMatch = def.ExactMatch
			d.Dynamic = def.Dynamic
			d.Protected = def.Protected
			if len(d.Validate) == 0 {
				d.Validate = def.Validate
			}
			// Fix DDFM's capture group ("06" → "D06") so refs match the list.
			if strings.EqualFold(d.Code, "DDFM") && (d.Pattern == "" || d.Pattern == oldDDFM) {
				d.Pattern = def.Pattern
			}
		} else {
			d.Type = TypeQSOFields // sensible default for custom legacy awards
		}
		out[i] = d
	}
	return out, changed
}

// Fields lists the scannable QSO fields for the award editor.
func Fields() []string {
	return []string{
		"dxcc", "cqz", "ituz", "prefix", "callsign",
		"state", "county", "us_county", "cont", "country", "grid", "grid4",
		"iota", "sota_ref", "pota_ref", "wwff",
		"name", "qth", "address", "comment", "note",
	}
}

// BandCount holds distinct-reference counts on one band.
type BandCount struct {
	Band      string `json:"band"`
	Worked    int    `json:"worked"`
	Confirmed int    `json:"confirmed"`
}

// Ref is one reference's status within an award.
type Ref struct {
	Ref       string `json:"ref"`
	Name      string `json:"name,omitempty"`
	Group     string `json:"group,omitempty"`
	SubGrp    string `json:"subgrp,omitempty"`
	Worked    bool   `json:"worked"`
	Confirmed bool   `json:"confirmed"`
	Validated bool   `json:"validated"`
	// Modes / ConfirmedModes are CLASSES, not ADIF modes: "CW", "PHONE",
	// "DIGI". An operator asking "which entities have I worked on CW but not
	// confirmed" does not care whether it was FT8 or RTTY on the digital side,
	// and a list of twenty mode names would not answer the question they asked.
	Modes          []string `json:"modes"`
	ConfirmedModes []string `json:"confirmed_modes"`
	Bands          []string `json:"bands"`
	ConfirmedBands []string `json:"confirmed_bands"`
	ValidatedBands []string `json:"validated_bands"`
}

// Result is an award's computed progress.
type Result struct {
	Code      string      `json:"code"`
	Name      string      `json:"name"`
	Field     string      `json:"field"`
	Worked    int         `json:"worked"`
	Confirmed int         `json:"confirmed"`
	Validated int         `json:"validated"`
	Total     int         `json:"total"`
	Bands     []BandCount `json:"bands"`
	Refs      []Ref       `json:"refs"`
	Error     string      `json:"error,omitempty"` // e.g. bad regexp pattern
}

// ModeClass sorts an ADIF mode into the three classes an operator thinks in:
// CW, PHONE, DIGI. Anything unrecognised returns "" and is simply not counted
// under any class — better than inventing one, since these classes drive a
// filter that decides what the operator is shown.
func ModeClass(mode string) string {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "CW", "CWR":
		return "CW"
	case "SSB", "USB", "LSB", "AM", "FM", "DV", "PHONE", "DIGITALVOICE":
		return "PHONE"
	case "":
		return ""
	}
	// Everything else that a logbook actually carries is a data mode: FT8, FT4,
	// RTTY, PSK31, JS8, Q65, MSK144, OLIVIA, VARA… Listing them exhaustively
	// would mean a new mode silently vanishing from the filter the day it
	// appears, which is worse than treating an unknown data mode as data.
	return "DIGI"
}

// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
type NameResolver func(field, ref string) string

type refAgg struct {
	bands          map[string]struct{}
	modes          map[string]struct{}
	confirmedModes map[string]struct{}
	confirmedBands map[string]struct{}
	validatedBands map[string]struct{}
	anyConfirmed   bool
	anyValidated   bool
}

// refList is the per-award reference data Compute needs (a thin view of
// awardref.Ref, kept local so the award package stays storage-agnostic).
type refList struct {
	byCode      map[string]RefMeta // uppercased code → metadata
	codes       []string           // codes in input order (for stable unworked listing)
	withPattern []string           // codes whose reference declares a regex (usually none)
	names       []nameCode         // (uppercased name → code) for MatchBy="description"
}

// nameCode pairs a reference's uppercased description with its code, for
// description-based matching (e.g. WAJA finding a prefecture NAME in the QTH).
type nameCode struct{ name, code string }

// RefMeta is one reference's metadata for the engine: enough to enforce a
// predefined list, per-reference DXCC scoping, a per-reference pattern, and to
// label results.
type RefMeta struct {
	Code     string
	Name     string
	Group    string
	SubGrp   string
	DXCCList []int // nil = any
	Pattern  string
	re       *regexp.Regexp
	Valid    bool
	// Per-reference validity window, ISO "2006-01-02". A reference is not
	// forever: a park is delisted, a county is merged, a castle loses its
	// reference number. A QSO made while it existed still counts — it was a valid
	// contact on the day — and one made after it stopped existing does not.
	//
	// Empty means "no window of its own", and the award's own ValidFrom/ValidTo
	// then govern, as they already do for every QSO in the award (see inScope).
	// That fallback is deliberately NOT duplicated here: two places enforcing the
	// same dates is two places for them to disagree.
	ValidFrom string
	ValidTo   string
}

// activeOn reports whether the reference existed on the day of the QSO.
//
// Compared as ISO date strings rather than parsed times on purpose: the stored
// values are "2025-08-01"-shaped and lexical order on that shape IS
// chronological order, so this cannot fail on a malformed date the way a parse
// can — a reference with a typo in its window keeps counting instead of silently
// vanishing from an operator's totals.
func (m RefMeta) activeOn(day string) bool {
	if m.ValidFrom != "" && day < m.ValidFrom {
		return false
	}
	if m.ValidTo != "" && day > m.ValidTo {
		return false
	}
	return true
}

// NewRefList builds the engine's reference view from (code, meta) pairs.
// compileAwardRE compiles an award / reference regex. Award patterns are run
// over free-text log fields (QTH, NOTES) whose capitalization is arbitrary — a
// log may hold "Tokyo", "TOKYO" or "TOKIO" — so we match case-insensitively by
// default. An author who set their own flag group (e.g. "(?s)") is respected.
func compileAwardRE(p string) (*regexp.Regexp, error) {
	p = strings.TrimSpace(p)
	if p == "" {
		return nil, errors.New("empty pattern")
	}
	if !strings.HasPrefix(p, "(?") {
		p = "(?i)" + p
	}
	return regexp.Compile(p)
}

func NewRefList(metas []RefMeta) refList {
	rl := refList{byCode: make(map[string]RefMeta, len(metas))}
	for _, m := range metas {
		code := normalizeRef(m.Code)
		if code == "" {
			continue
		}
		if p := strings.TrimSpace(m.Pattern); p != "" {
			if re, err := compileAwardRE(p); err == nil {
				m.re = re
				rl.withPattern = append(rl.withPattern, code)
			}
		}
		m.Code = code
		if _, dup := rl.byCode[code]; !dup {
			rl.codes = append(rl.codes, code)
			if nm := strings.ToUpper(strings.TrimSpace(m.Name)); nm != "" {
				rl.names = append(rl.names, nameCode{name: nm, code: code})
			}
		}
		rl.byCode[code] = m
	}
	return rl
}

// Compute runs every definition over the QSOs in a single pass. refMetas maps an
// award code to its reference metadata; awards present there with Dynamic=false
// are "predefined" (only listed references count, and the full list — including
// unworked references — appears in the result).
func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf NameResolver) []Result {
	refLists := make(map[string]refList, len(refMetas))
	for code, metas := range refMetas {
		refLists[strings.ToUpper(strings.TrimSpace(code))] = NewRefList(metas)
	}

	// Pre-compile award-level patterns once.
	res := make([]*regexp.Regexp, len(defs))
	perr := make([]string, len(defs))
	for i := range defs {
		if p := strings.TrimSpace(defs[i].Pattern); p != "" {
			re, err := compileAwardRE(p)
			if err != nil {
				perr[i] = "bad pattern: " + err.Error()
			} else {
				res[i] = re
			}
		}
	}

	agg := make([]map[string]*refAgg, len(defs))
	for i := range defs {
		agg[i] = map[string]*refAgg{}
	}

	for qi := range qsos {
		q := &qsos[qi]
		for i := range defs {
			d := &defs[i]
			if perr[i] != "" || !inScope(d, q) {
				continue
			}
			rl, hasList := refLists[strings.ToUpper(d.Code)]
			refs := candidates(d, res[i], q, rl, hasList)
			if len(refs) == 0 {
				continue
			}
			band := strings.ToLower(strings.TrimSpace(q.Band))
			modeClass := ModeClass(q.Mode)
			isConf := confirmed(q, d.Confirm, d)
			isVal := confirmed(q, d.Validate, d)
			for _, ref := range refs {
				a := agg[i][ref]
				if a == nil {
					a = &refAgg{
						bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{},
						validatedBands: map[string]struct{}{},
						modes:          map[string]struct{}{}, confirmedModes: map[string]struct{}{},
					}
					agg[i][ref] = a
				}
				if band != "" {
					a.bands[band] = struct{}{}
				}
				if modeClass != "" {
					a.modes[modeClass] = struct{}{}
				}
				if isConf {
					a.anyConfirmed = true
					if band != "" {
						a.confirmedBands[band] = struct{}{}
					}
					if modeClass != "" {
						a.confirmedModes[modeClass] = struct{}{}
					}
				}
				if isVal {
					a.anyValidated = true
					if band != "" {
						a.validatedBands[band] = struct{}{}
					}
				}
			}
		}
	}

	out := make([]Result, len(defs))
	for i := range defs {
		d := &defs[i]
		rl, hasList := refLists[strings.ToUpper(d.Code)]
		predefined := hasList && !d.Dynamic
		r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]}
		bandWorked := map[string]int{}
		bandConfirmed := map[string]int{}
		for ref, a := range agg[i] {
			r.Worked++
			if a.anyConfirmed {
				r.Confirmed++
			}
			if a.anyValidated {
				r.Validated++
			}
			rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
				Modes: setToSorted(a.modes), ConfirmedModes: setToSorted(a.confirmedModes),
				Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
			labelRef(&rf, d, ref, rl, hasList, nameOf)
			r.Refs = append(r.Refs, rf)
			for b := range a.bands {
				bandWorked[b]++
			}
			for b := range a.confirmedBands {
				bandConfirmed[b]++
			}
		}
		// Predefined awards: the full list is the denominator, and unworked
		// references are listed too (greyed in the UI).
		if predefined {
			r.Total = len(rl.codes)
			for _, code := range rl.codes {
				if _, worked := agg[i][code]; worked {
					continue
				}
				m := rl.byCode[code]
				if !m.Valid {
					continue
				}
				rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}, ValidatedBands: []string{}}
				if rf.Name == "" && nameOf != nil {
					rf.Name = nameOf(d.Field, code)
				}
				r.Refs = append(r.Refs, rf)
			}
		}
		sort.Slice(r.Refs, func(a, b int) bool {
			if r.Refs[a].Worked != r.Refs[b].Worked {
				return r.Refs[a].Worked // worked first
			}
			if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
				return r.Refs[a].Confirmed
			}
			return natLess(r.Refs[a].Ref, r.Refs[b].Ref)
		})
		for _, b := range sortedBands(bandWorked) {
			r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
		}
		// Never return nil slices: they marshal to JSON null, and the UI calls
		// .filter/.length on them (an award with nothing worked yet — e.g. a
		// freshly-created WWFF/WAJA — would otherwise white-screen the panel).
		if r.Refs == nil {
			r.Refs = []Ref{}
		}
		if r.Bands == nil {
			r.Bands = []BandCount{}
		}
		out[i] = r
	}
	return out
}

// MatchQSO returns the reference codes a single QSO contributes to for one
// award (respecting scope + predefined enforcement). metas is the award's
// reference list (empty/nil for dynamic awards). Used for cell drill-down.
func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
	if !inScope(&d, q) {
		return nil
	}
	var re *regexp.Regexp
	if p := strings.TrimSpace(d.Pattern); p != "" {
		if c, err := compileAwardRE(p); err == nil {
			re = c
		} else {
			return nil
		}
	}
	rl := NewRefList(metas)
	return candidates(&d, re, q, rl, len(metas) > 0)
}

// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
// comma list). Honoured by MatchQSO regardless of how the award matches.
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"

// manualRefs returns the reference codes the operator assigned to award `code`
// on this QSO (from the ManualRefsKey extra).
func manualRefs(q *qso.QSO, code string) []string {
	if q == nil || q.Extras == nil {
		return nil
	}
	raw := strings.TrimSpace(q.Extras[ManualRefsKey])
	if raw == "" {
		return nil
	}
	code = strings.ToUpper(strings.TrimSpace(code))
	var out []string
	for _, entry := range strings.Split(raw, ";") {
		entry = strings.TrimSpace(entry)
		at := strings.IndexByte(entry, '@')
		if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
			continue
		}
		for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
			if r = strings.TrimSpace(r); r != "" {
				out = append(out, r)
			}
		}
	}
	return out
}

// Confirmed reports whether a QSO satisfies any of the given confirmation
// sources (lotw|qsl|eqsl|qrzcom|custom). Exported for the statistics view.
// The Def is needed for the "custom" source, which reads the field IT names.
func Confirmed(q *qso.QSO, d Def, sources []string) bool { return confirmed(q, sources, &d) }

// InScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission, dates) — independent of whether a reference was
// found. Used to surface "in scope but no reference" gaps (e.g. a French QSO
// missing its department for DDFM).
func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }

// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
func EmissionOf(mode string) string { return emissionOf(mode) }

// USCountyKey normalises a QSO's state + cnty into the canonical "STATE,COUNTY"
// match code used by the US Counties (USA-CA) award, so the reference list (in
// that same form) matches whatever shape the logbook holds. It is the SINGLE
// source of truth: cmd/cntygen builds the reference codes by calling it, so the
// two sides can never drift.
//
// Real logs are a mess — "MA,MIDDLESEX", bare "Middlesex" with the state in its
// own column, mixed case, county-type suffixes, the odd "0", plus the FIPS list
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
//   - if cnty already carries "ST,County", split on the first comma; else take
//     the state from the STATE column;
//   - upper-case; fold accents (the FIPS list writes "Doña Ana", callbooks and
//     logs write "Dona Ana"); drop periods and apostrophes; hyphens→space;
//     expand the parenthetical shapes GeoNames uses for county-equivalents
//     ("Baltimore (city)", "Nome (CA)") and drop its "City and County of"
//     style prefixes; strip a trailing County/Parish/Borough/Census Area/
//     Municipality; fold Saint(e)→St(e); collapse whitespace;
//   - fold the counties renamed since the USA-CA list was drawn onto the name
//     the award still uses, so a QSO there counts for something (see renamed);
//   - require a 2-letter state and a non-empty county, else no match ("").
func USCountyKey(state, cnty string) string {
	s := strings.TrimSpace(cnty)
	if s == "" || s == "0" {
		return ""
	}
	var st, co string
	if i := strings.IndexByte(s, ','); i >= 0 {
		st, co = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
	} else {
		st, co = strings.TrimSpace(state), s
	}
	co = strings.ToUpper(foldAccents(co))
	co = strings.ReplaceAll(co, ".", "")
	co = strings.ReplaceAll(co, "'", "")
	co = strings.ReplaceAll(co, "-", " ")
	co = expandCountyEquivalent(co)
	for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
		if strings.HasSuffix(co, suf) {
			co = strings.TrimSuffix(co, suf)
			break
		}
	}
	switch {
	case strings.HasPrefix(co, "SAINTE "):
		co = "STE " + co[len("SAINTE "):]
	case strings.HasPrefix(co, "SAINT "):
		co = "ST " + co[len("SAINT "):]
	}
	// Drop ALL internal spaces last: FIPS writes DeKalb/DuPage/LaSalle as one
	// word, logs often split them ("De Kalb"). Removing spaces on both sides
	// folds those together and can't collide two real counties in one state.
	co = strings.ReplaceAll(co, " ", "")
	st = strings.ToUpper(st)
	if len(st) != 2 || co == "" {
		return ""
	}
	if alias, ok := renamed[st+"/"+co]; ok {
		co = alias
	}
	// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
	// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
	// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
	// this internal match key uses "/".
	return st + "/" + co
}

// labelRef fills a worked reference's name/group from the reference list (or the
// name resolver as a fallback).
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
	if hasList {
		if m, ok := rl.byCode[code]; ok {
			rf.Name, rf.Group, rf.SubGrp = m.Name, m.Group, m.SubGrp
		}
	}
	if rf.Name == "" && nameOf != nil {
		rf.Name = nameOf(d.Field, code)
	}
}

// candidates extracts the reference(s) a QSO contributes to an award, enforcing
// a predefined list when one applies.
// searchOne runs one matching rule (the primary or an OR rule) over a QSO and
// returns the reference codes it finds, each prefixed with `prefix` (so a
// captured "74" becomes "D74"). predefined enables list-aware matching.
func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string {
	raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing))
	if raw == "" {
		return nil
	}
	byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")

	var found []string
	// codesAreFinal: this branch produced references straight from the award's
	// LIST, so they are already whole codes. The blanket prefix at the end must
	// leave them alone — prefixing a code that is already "D74" yields "DD74".
	codesAreFinal := false
	switch {
	case re != nil:
		// Award-level regex: capture group 1 (or whole match) for each hit.
		found = regexTokens(re, raw)
	case byDesc:
		// Match references by their DESCRIPTION/name appearing in the field
		// (e.g. WAJA finds the prefecture name inside the QTH). ExactMatch means
		// the field equals the name; otherwise the name is a substring of it.
		codesAreFinal = true
		up := strings.ToUpper(raw)
		for _, nc := range rl.names {
			if exact {
				if up == nc.name {
					found = append(found, nc.code)
				}
			} else if strings.Contains(up, nc.name) {
				found = append(found, nc.code)
			}
		}
		// A reference may also declare its own regex to broaden matching beyond
		// its plain name (e.g. Tokyo's `\bTok[iy]o\b` also catches "TOKIO"). Test
		// those too — the name match and the pattern are OR'd.
		for _, code := range rl.withPattern {
			if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
				found = append(found, code)
			}
		}
	case predefined && !exact:
		// "Search reference inside the field": look up each token of the field in
		// the list — O(tokens), not O(all references) — plus test the few
		// references that declare a regex.
		codesAreFinal = true
		for _, tok := range tokenize(raw) {
			if _, ok := rl.byCode[tok]; ok {
				found = append(found, tok)
				continue
			}
			// The field may carry the reference WITHOUT the award's letter: a French
			// operator writes "74" in STATE while the DDFM codes are "D74". Prefix is
			// exactly what that case is for, so try the prefixed form HERE — applying
			// it after the lookup (as the blanket pass below used to) could never
			// help, because the lookup is the step that failed. Without this, the
			// only way to match a bare department was to write a regex, in a mode
			// where the operator had chosen "code" and not "pattern".
			if prefix != "" {
				if _, ok := rl.byCode[prefix+tok]; ok {
					found = append(found, prefix+tok)
				}
			}
		}
		for _, code := range rl.withPattern {
			if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
				found = append(found, code)
			}
		}
	default:
		// Whole field value is the candidate, split on comma/semicolon so a
		// multi-reference field (e.g. an n-fer POTA QSO "US-6544,US-0680")
		// counts each reference separately.
		found = splitRefs(raw)
	}
	if prefix != "" && !codesAreFinal {
		for i := range found {
			found[i] = prefix + found[i]
		}
	}
	return found
}

// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
//
// An award that silently matches nothing is the hardest kind of bug to see: the
// UI shows an empty column and the operator has no way to tell whether the QSO is
// out of scope, the field is empty, the rule looked in the wrong place, or the
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
// reports every step it took.

// Rejected is a candidate a rule produced that did not survive.
type Rejected struct {
	Candidate string `json:"candidate"`
	Reason    string `json:"reason"`
}

// Step is one matching rule as it actually ran.
type Step struct {
	Rule       string     `json:"rule"`                  // "primary", "OR 1", …
	Field      string     `json:"field"`                 // the QSO field it scanned
	MatchBy    string     `json:"match_by,omitempty"`    // code | description | pattern
	Exact      bool       `json:"exact,omitempty"`       // whole field IS the reference
	Pattern    string     `json:"pattern,omitempty"`     // the rule's regex, if any
	FieldValue string     `json:"field_value,omitempty"` // what the field actually held
	Candidates []string   `json:"candidates,omitempty"`  // what the rule produced, before validation
	Kept       []string   `json:"kept,omitempty"`        // what survived the reference list
	Rejected   []Rejected `json:"rejected,omitempty"`    // and what didn't, with the reason
	Skipped    bool       `json:"skipped,omitempty"`     // never ran: an earlier rule already matched
	Error      string     `json:"error,omitempty"`       // e.g. a bad regex, which SKIPS the rule
}

// Explanation is the full account of one QSO against one award.
type Explanation struct {
	Code       string   `json:"code"`
	InScope    bool     `json:"in_scope"`
	ScopeError string   `json:"scope_error,omitempty"` // why the QSO is out of scope
	Predefined bool     `json:"predefined"`            // matches are validated against a reference list
	RefCount   int      `json:"ref_count"`             // size of that list
	Steps      []Step   `json:"steps"`
	Manual     []string `json:"manual,omitempty"` // references the operator assigned by hand
	// Superseded is what the matcher had found before the operator's choice
	// replaced it — shown in the trace so a correction is explainable rather
	// than mysterious.
	Superseded []string `json:"superseded,omitempty"`
	// Ambiguous lists the references that matched when the award allows only one
	// — none of them was kept. Shown so the operator sees WHICH ones to choose
	// between rather than an unexplained blank.
	Ambiguous []string `json:"ambiguous,omitempty"`
	Result    []string `json:"result"` // what the QSO finally counts for
}

// Explain runs the matcher on a single QSO and reports what it did. It goes
// through the SAME code path as Compute — not a re-implementation — so what it
// shows is what actually happens.
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
	ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
	rl := NewRefList(metas)
	ex.Predefined = len(metas) > 0 && !d.Dynamic
	ex.RefCount = len(metas)

	var why string
	if !inScopeWhy(&d, q, &why) {
		ex.ScopeError = why
		return ex // out of scope: no rule ever runs, and saying so IS the answer
	}
	ex.InScope = true

	var re *regexp.Regexp
	if p := strings.TrimSpace(d.Pattern); p != "" {
		c, err := compileAwardRE(p)
		if err != nil {
			ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
				Error: "bad regex: " + err.Error()})
			return ex
		}
		re = c
	}
	candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
	return ex
}

func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
	return candidatesTrace(d, re, q, rl, hasList, nil)
}

// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
// passes it) each rule records what it scanned, what it produced and what was
// rejected. The tracing MUST live inside the real matcher rather than in a
// parallel "explain" implementation — a trace that can drift from the code it
// describes is worse than no trace, because it is believed.
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
	predefined := hasList && !d.Dynamic
	// The day of the contact, for per-reference validity windows.
	day := q.QSODate.Format("2006-01-02")

	// run executes one rule and, when tracing, records it.
	run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
		raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
		kept := keepRefs(predefined, rl, raw, day)
		if ex != nil {
			s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
				FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
				Candidates: raw, Kept: kept}
			keptSet := map[string]struct{}{}
			for _, k := range kept {
				keptSet[k] = struct{}{}
			}
			for _, c := range raw {
				n := normalizeRef(c)
				if _, ok := keptSet[n]; ok {
					continue
				}
				s.Rejected = append(s.Rejected, rejection(predefined, rl, n, day))
			}
			ex.Steps = append(ex.Steps, s)
		}
		return kept
	}

	// Primary search first; the OR rules are ordered FALLBACKS — try the next
	// only while nothing has matched yet, and stop at the first that yields a
	// reference (short-circuit). So a province already found by NAME isn't also
	// re-derived, possibly differently, from a later city-regex rule.
	//
	// The short-circuit tests what a rule REALLY yielded — the references that
	// survive the predefined list — not its raw candidates. A rule can always
	// produce a raw candidate and still find nothing: "the whole ADDRESS field is
	// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
	// Testing the raw candidate would call that a hit, skip every fallback, and
	// only then drop it as unlisted — leaving the QSO unmatched even though the
	// next rule ("find the code inside the QTH") would have found BG.
	found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, d.Prefix)
	for i := range d.OrRules {
		r := &d.OrRules[i]
		label := fmt.Sprintf("OR %d", i+1)
		if len(found) > 0 {
			if ex != nil {
				ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
					Pattern: r.Pattern, Skipped: true})
			}
			continue // an earlier rule already matched — fallbacks short-circuit
		}
		var rre *regexp.Regexp
		if p := strings.TrimSpace(r.Pattern); p != "" {
			c, err := compileAwardRE(p)
			if err != nil {
				if ex != nil {
					ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
						Error: "bad regex: " + err.Error()})
				}
				continue // skip a rule with a bad regex rather than failing the award
			}
			rre = c
		}
		found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
	}

	// Merge operator-assigned references (manual override, ManualRefsKey). Lets
	// the operator tag a QSO for an award whose field/description matching can't
	// auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so
	// a contact whose address doesn't spell it out needs the province picked by
	// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
	// awards panel and the per-QSO refs editor — honours overrides too. For a
	// predefined award the ref is still validated against the list below.
	manual := keepRefs(predefined, rl, manualRefs(q, d.Code), day)
	if ex != nil {
		ex.Manual = manual
	}
	// An operator's choice REPLACES what the matcher found. It does not join it.
	//
	// Adding to it was the behaviour, and it made a correction impossible: a
	// German address matching two or three DOKs kept them all, so deleting the
	// wrong ones and assigning the right one lasted exactly until the next
	// recompute, which put them straight back. The operator was arguing with
	// the matcher and could not win.
	//
	// One manual entry is stored per award (setOverrideRef drops the previous
	// one), so this is an override in the storage as well as in the name.
	if len(manual) > 0 {
		if ex != nil {
			ex.Superseded = dedupe(found)
		}
		found = manual
	}
	out := dedupe(found)
	// Ambiguity is not a result. See OneRefPerQSO.
	if d.OneRefPerQSO && len(manual) == 0 && len(out) > 1 {
		if ex != nil {
			ex.Ambiguous = out
			ex.Result = []string{}
		}
		return nil
	}
	if ex != nil {
		ex.Result = out
	}
	return out
}

// rejection explains why a candidate the operator can SEE in the trace did not
// become a reference. "Nothing matched" is the least useful thing a matcher can
// say; every one of this week's award bugs was a rejection with a plain reason
// that nothing was printing.
func rejection(predefined bool, rl refList, code, day string) Rejected {
	switch {
	case code == "":
		return Rejected{Candidate: code, Reason: "empty"}
	case !predefined:
		return Rejected{Candidate: code, Reason: "duplicate"}
	}
	m, ok := rl.byCode[code]
	if !ok {
		return Rejected{Candidate: code, Reason: "not in the award's reference list"}
	}
	// Spell the dates out. "Did not count" on a contact the operator remembers
	// making is exactly the moment they need to be told it is the REFERENCE that
	// has a window, not their log that is wrong.
	if !m.activeOn(day) {
		switch {
		case m.ValidTo != "" && day > m.ValidTo:
			return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference ceased to exist on %s, after this QSO of %s", m.ValidTo, day)}
		default:
			return Rejected{Candidate: code, Reason: fmt.Sprintf("the reference did not exist until %s, after this QSO of %s", m.ValidFrom, day)}
		}
	}
	if !m.Valid {
		return Rejected{Candidate: code, Reason: "listed but disabled"}
	}
	return Rejected{Candidate: code, Reason: "duplicate"}
}

// keepRefs reduces a rule's raw candidates to the references that actually count.
// For a predefined award that means the codes on the list, enabled. The
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
// so we do NOT additionally require the QSO's entity to match the reference's own
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
// 291). Per-reference DXCC stays metadata for the picker.
func keepRefs(predefined bool, rl refList, found []string, day string) []string {
	if !predefined {
		out := make([]string, 0, len(found))
		for _, c := range found {
			if c = normalizeRef(c); c != "" {
				out = append(out, c)
			}
		}
		return dedupe(out)
	}
	var out []string
	seen := map[string]struct{}{}
	for _, c := range found {
		c = normalizeRef(c)
		m, ok := rl.byCode[c]
		if !ok || !m.Valid {
			continue
		}
		// The reference has to have existed on the day of the contact.
		if !m.activeOn(day) {
			continue
		}
		if _, dup := seen[c]; dup {
			continue
		}
		seen[c] = struct{}{}
		out = append(out, c)
	}
	return out
}

func regexTokens(re *regexp.Regexp, raw string) []string {
	matches := re.FindAllStringSubmatch(raw, -1)
	out := make([]string, 0, len(matches))
	for _, m := range matches {
		ref := m[0]
		if len(m) > 1 && m[1] != "" {
			ref = m[1]
		}
		if ref = normalizeRef(ref); ref != "" {
			out = append(out, ref)
		}
	}
	return dedupe(out)
}

func dedupe(in []string) []string {
	if len(in) <= 1 {
		return in
	}
	seen := make(map[string]struct{}, len(in))
	out := in[:0]
	for _, s := range in {
		if _, ok := seen[s]; ok {
			continue
		}
		seen[s] = struct{}{}
		out = append(out, s)
	}
	return out
}

// tokenize splits a field into uppercased tokens on any non-alphanumeric run,
// keeping '-' and '/' which appear inside reference codes (e.g. "FR-11553").
// The whole trimmed value is also returned so single-token fields match.
func tokenize(raw string) []string {
	up := strings.ToUpper(strings.TrimSpace(raw))
	if up == "" {
		return nil
	}
	out := []string{up}
	cur := strings.Builder{}
	for _, r := range up {
		if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == '-' || r == '/' {
			cur.WriteRune(r)
		} else if cur.Len() > 0 {
			out = append(out, cur.String())
			cur.Reset()
		}
	}
	if cur.Len() > 0 {
		out = append(out, cur.String())
	}
	return out
}

// stripAffix removes a leading and/or trailing literal string before matching.
func stripAffix(s, lead, trail string) string {
	s = strings.TrimSpace(s)
	if lead != "" {
		s = strings.TrimPrefix(s, lead)
	}
	if trail != "" {
		s = strings.TrimSuffix(s, trail)
	}
	return s
}

func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }

// splitRefs splits a field value on comma/semicolon into normalized references,
// so a multi-reference field (n-fer POTA "US-6544,US-0680") yields one entry
// per reference. A value with no separator yields a single reference.
func splitRefs(raw string) []string {
	if !strings.ContainsAny(raw, ",;") {
		return []string{normalizeRef(raw)}
	}
	var out []string
	for _, p := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' }) {
		if n := normalizeRef(p); n != "" {
			out = append(out, n)
		}
	}
	return dedupe(out)
}

func isDigit(b byte) bool { return b >= '0' && b <= '9' }

// natLess is a natural ("human") comparison: digit runs compare as numbers, so
// references sort 1,2,…,9,10,11 (not 1,10,11,2) and "D2A" before "D10".
func natLess(a, b string) bool {
	ia, ib := 0, 0
	for ia < len(a) && ib < len(b) {
		ca, cb := a[ia], b[ib]
		if isDigit(ca) && isDigit(cb) {
			ja, jb := ia, ib
			for ja < len(a) && isDigit(a[ja]) {
				ja++
			}
			for jb < len(b) && isDigit(b[jb]) {
				jb++
			}
			na := strings.TrimLeft(a[ia:ja], "0")
			nb := strings.TrimLeft(b[ib:jb], "0")
			if len(na) != len(nb) {
				return len(na) < len(nb) // fewer digits = smaller number
			}
			if na != nb {
				return na < nb
			}
			ia, ib = ja, jb
		} else {
			if ca != cb {
				return ca < cb
			}
			ia++
			ib++
		}
	}
	return len(a)-ia < len(b)-ib
}

// inScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission category, validity dates).
func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }

// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
// which runs this for every QSO × every award, passes nil and pays nothing).
// Keeping both behind one function is the point: a scope check that disagrees with
// the scope check it explains would be worse than no explanation at all.
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
	fail := func(format string, args ...any) bool {
		if why != nil {
			*why = fmt.Sprintf(format, args...)
		}
		return false
	}
	if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
		return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
	}
	if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
		return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
	}
	if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
		return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
	}
	if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
		return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
	}
	if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
		return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
	}
	if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
		return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
	}
	return true
}

func containsFold(list []string, v string) bool {
	v = strings.TrimSpace(v)
	for _, x := range list {
		if strings.EqualFold(strings.TrimSpace(x), v) {
			return true
		}
	}
	return false
}

// emissionOf maps an ADIF mode to its broad emission category.
func emissionOf(mode string) string {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "CW":
		return "CW"
	case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "PHONE", "C4FM":
		return "PHONE"
	case "":
		return ""
	default:
		return "DIGITAL"
	}
}

// fieldRaw returns the raw string value of a QSO field (computed for numeric /
// derived fields). Unknown fields yield "".
func fieldRaw(field string, q *qso.QSO) string {
	switch strings.ToLower(strings.TrimSpace(field)) {
	case "dxcc":
		if q.DXCC != nil && *q.DXCC > 0 {
			return strconv.Itoa(*q.DXCC)
		}
	case "cqz":
		if q.CQZ != nil && *q.CQZ > 0 {
			return strconv.Itoa(*q.CQZ)
		}
	case "ituz":
		if q.ITUZ != nil && *q.ITUZ > 0 {
			return strconv.Itoa(*q.ITUZ)
		}
	case "prefix":
		return wpxPrefix(q.Callsign)
	case "callsign":
		return q.Callsign
	case "state":
		return q.State
	case "county":
		// CNTY as it stands, with nothing prepended. us_county below answers a
		// different question — it keys the county to its state, because two US
		// states each have a Jefferson County. Outside the United States that
		// prefixing is wrong: an RDA district (RO-19) and a Japanese city code
		// are already unique, and a state is not what qualifies them.
		return q.County
	case "us_county":
		return USCountyKey(q.State, q.County)
	case "cont":
		return q.Continent
	case "country":
		return q.Country
	case "grid":
		return q.Grid
	case "grid4":
		// VUCC: distinct 4-character grid squares. A QSO on a grid line carries
		// several in VUCC_GRIDS; otherwise the 4-char prefix of GRIDSQUARE. The
		// comma-joined result is split into one reference per square downstream.
		return grid4Refs(q)
	case "iota":
		return q.IOTA
	case "sota_ref":
		return q.SOTARef
	case "pota_ref":
		return q.POTARef
	case "name":
		return q.Name
	case "qth":
		return q.QTH
	case "address":
		return q.Address
	case "comment":
		return q.Comment
	case "note", "notes":
		return q.Notes
	case "wwff":
		if q.Extras != nil {
			if v := strings.TrimSpace(q.Extras["WWFF_REF"]); v != "" {
				return v
			}
			if strings.EqualFold(q.Extras["SIG"], "WWFF") {
				return q.Extras["SIG_INFO"]
			}
		}
	}
	return ""
}

// grid4Refs returns the distinct 4-character grid squares a QSO contributes —
// from VUCC_GRIDS (a comma list when the contact straddles grid lines) if set,
// else the 4-char prefix of GRIDSQUARE. Joined with commas so the matcher
// counts each square separately (VUCC awards).
func grid4Refs(q *qso.QSO) string {
	src := strings.TrimSpace(q.VUCCGrids)
	if src == "" {
		src = strings.TrimSpace(q.Grid)
	}
	if src == "" {
		return ""
	}
	seen := map[string]struct{}{}
	var out []string
	for _, tok := range strings.FieldsFunc(src, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
		g := strings.ToUpper(strings.TrimSpace(tok))
		if len(g) > 4 {
			g = g[:4]
		}
		if g == "" {
			continue
		}
		if _, ok := seen[g]; ok {
			continue
		}
		seen[g] = struct{}{}
		out = append(out, g)
	}
	return strings.Join(out, ",")
}

func dxccAllowed(dxcc *int, filter []int) bool {
	if dxcc == nil {
		return false
	}
	for _, f := range filter {
		if *dxcc == f {
			return true
		}
	}
	return false
}

// confirmed reports whether the QSO satisfies any accepted confirmation source.
// ADIF *_QSL_RCVD values Y (confirmed) and V (verified) both count.
func confirmed(q *qso.QSO, sources []string, d *Def) bool {
	for _, s := range sources {
		switch s {
		case "lotw":
			if isYes(q.LOTWRcvd) {
				return true
			}
		case "qsl":
			if isYes(q.QSLRcvd) {
				return true
			}
		case "eqsl":
			if isYes(q.EQSLRcvd) {
				return true
			}
		case "qrzcom":
			// The DOWNLOAD status, not the upload one: uploading a QSO to QRZ is
			// us telling them, not them confirming it back.
			if isYes(q.QRZComDownloadStatus) {
				return true
			}
		case "hamlog":
			// HAMLOG.online, a first-class source rather than something to express
			// through "custom" — its confirmations feed its own award programme
			// and operators reach for it the same way they reach for LoTW.
			//
			// Read from the ADIF EXTRAS, not a column: the ADIF standard names a
			// field for hamlog.EU (HAMLOGEU_QSO_UPLOAD_STATUS) and none for
			// hamlog.ONLINE, and borrowing the other site's field would write a
			// falsehood into every exported log.
			if hamlogConfirmed(q) {
				return true
			}
		case "custom":
			if customConfirmed(q, d) {
				return true
			}
		}
	}
	return false
}

// HamlogQSLKey is where a HAMLOG.online confirmation is recorded.
//
// THEIR field name, taken from a real export:
//
//	<APP_HAMLOG_QSO_CFM:1>Y
//
// Using the site's own key rather than one of ours is what makes an import
// simply work: a log downloaded from HAMLOG carries its confirmations into
// OpsLog with nothing to rename and no mapping to configure. The guesses this
// replaced (APP_HAMLOG_QSL and friends) were all wrong, which is the argument
// for reading a real file before naming a field.
const HamlogQSLKey = "APP_HAMLOG_QSO_CFM"

// hamlogAltKeys are older or hand-written spellings still honoured on read.
// APP_OPSLOG_HAMLOG_QSL is the one OpsLog itself wrote before a real export was
// available; a log stamped with it keeps counting.
var hamlogAltKeys = []string{"APP_OPSLOG_HAMLOG_QSL", "APP_HAMLOG_QSL", "APP_HAMLOGONLINE_QSL", "HAMLOG_QSL_RCVD"}

// hamlogConfirmed reports whether a QSO carries a HAMLOG.online confirmation.
//
// Any non-empty value counts, except an explicit "N": their export could carry
// a date, a Y, or a match id, and demanding one of them would silently confirm
// nothing on the two shapes we guessed wrong.
func hamlogConfirmed(q *qso.QSO) bool {
	if q == nil || q.Extras == nil {
		return false
	}
	for _, k := range append([]string{HamlogQSLKey}, hamlogAltKeys...) {
		v := strings.TrimSpace(q.Extras[k])
		if v == "" {
			continue
		}
		if strings.EqualFold(v, "N") || strings.EqualFold(v, "NO") {
			continue
		}
		return true
	}
	return false
}

// customConfirmed answers the operator-defined confirmation source: the field
// named by ConfirmField, optionally required to hold one of ConfirmValue's
// comma-separated values.
//
// A source that names no field confirms NOTHING. That is deliberate: ticking
// "custom" without saying what it means used to mark every QSO as unconfirmed
// anyway, and silently marking them all CONFIRMED instead would be far worse.
func customConfirmed(q *qso.QSO, d *Def) bool {
	if d == nil {
		return false
	}
	field := strings.TrimSpace(d.ConfirmField)
	if field == "" {
		return false
	}
	got := strings.TrimSpace(customFieldValue(q, field))
	if got == "" {
		return false
	}
	want := strings.TrimSpace(d.ConfirmValue)
	if want == "" {
		return true // any value at all counts — see ConfirmField's doc
	}
	for _, w := range strings.Split(want, ",") {
		if w = strings.TrimSpace(w); w != "" && strings.EqualFold(w, got) {
			return true
		}
	}
	return false
}

// customFieldValue resolves the field a custom confirmation names: one of the
// engine's known QSO fields first, then the ADIF extras — which is where the
// OpsLog card marker (APP_OPSLOG_QSL_RCVD) and anything stamped by an outside
// import actually live.
func customFieldValue(q *qso.QSO, field string) string {
	if v := strings.TrimSpace(fieldRaw(field, q)); v != "" {
		return v
	}
	if q.Extras == nil {
		return ""
	}
	return q.Extras[strings.ToUpper(strings.TrimSpace(field))]
}

func isYes(v string) bool {
	switch strings.ToUpper(strings.TrimSpace(v)) {
	case "Y", "V":
		return true
	}
	return false
}

func setToSorted(m map[string]struct{}) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
	"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
	// Microwave. A band missing from this order sorts to the end of an award's
	// band columns rather than in frequency order — and 3 cm was missing.
	"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}

func sortedBands(m map[string]int) []string {
	idx := map[string]int{}
	for i, b := range bandOrder {
		idx[b] = i
	}
	out := make([]string, 0, len(m))
	for b := range m {
		out = append(out, b)
	}
	sort.Slice(out, func(a, b int) bool {
		ia, oka := idx[out[a]]
		ib, okb := idx[out[b]]
		if oka && okb {
			return ia < ib
		}
		if oka != okb {
			return oka
		}
		return out[a] < out[b]
	})
	return out
}

// HamlogAltKeys exposes the alternate spellings so a reader outside this package
// (the confirmations import) accepts exactly what the award engine accepts.
func HamlogAltKeys() []string { return append([]string(nil), hamlogAltKeys...) }
