package award

import (
	"encoding/json"
	"sort"
	"strings"
	"testing"
	"time"

	"hamlog/internal/qso"
)

func TestWPXPrefix(t *testing.T) {
	cases := map[string]string{
		"F4BPO":     "F4",
		"EA8ABC":    "EA8",
		"9A1AA":     "9A1",
		"OH2BH":     "OH2",
		"K1ABC":     "K1",
		"RAEM":      "RA0",
		"F4BPO/P":   "F4",
		"F4BPO/9":   "F9",
		"VP8/F4BPO": "VP8",
		"PA0XYZ":    "PA0",
	}
	for in, want := range cases {
		if got := wpxPrefix(in); got != want {
			t.Errorf("wpxPrefix(%q) = %q, want %q", in, got, want)
		}
	}
}

func ip(n int) *int { return &n }

func TestComputeDXCCAndConfirm(t *testing.T) {
	qsos := []qso.QSO{
		{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA", LOTWRcvd: "Y"},
		{Callsign: "K2DEF", Band: "40m", DXCC: ip(291), State: "NY"},                              // worked, not confirmed
		{Callsign: "DL1XYZ", Band: "20m", DXCC: ip(230), QSLRcvd: "Y"},                            // DXCC Germany confirmed
		{Callsign: "F4BPO", Band: "20m", DXCC: ip(227), Notes: "nice qso D74", EQSLRcvd: "Y"},     // France dept 74 in note
		{Callsign: "F5ABC", Band: "40m", DXCC: ip(227), Notes: "D2A Corsica", QSLRcvd: "Y"},       // France dept 2A, confirmed
	}
	res := Compute(Defaults(), qsos, nil, nil)
	by := map[string]Result{}
	for _, r := range res {
		by[r.Code] = r
	}

	dxcc := by["DXCC"]
	if dxcc.Worked != 3 { // USA, Germany, France
		t.Errorf("DXCC worked = %d, want 3", dxcc.Worked)
	}
	// DXCC confirms on lotw|qsl → USA(lotw) + Germany(qsl) + France(qsl via F5ABC).
	if dxcc.Confirmed != 3 {
		t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed)
	}
	// Validated is the stricter LoTW-only tier: a paper QSL confirms but does
	// NOT validate, so only USA (LoTW) counts.
	if dxcc.Validated != 1 {
		t.Errorf("DXCC validated = %d, want 1 (LoTW only, QSL doesn't validate)", dxcc.Validated)
	}

	was := by["WAS"]
	if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter)
		t.Errorf("WAS worked = %d, want 2", was.Worked)
	}

	// DDFM scans the Note field with pattern D(\d{1,2}[AB]?): 74 and 2A.
	ddfm := by["DDFM"]
	if ddfm.Worked != 2 {
		t.Errorf("DDFM worked = %d, want 2 (refs %v)", ddfm.Worked, refCodes(ddfm))
	}
	if ddfm.Confirmed != 1 { // 2A confirmed via QSL; 74 only eQSL (not accepted)
		t.Errorf("DDFM confirmed = %d, want 1", ddfm.Confirmed)
	}
}

func TestNatLess(t *testing.T) {
	in := []string{"10", "2", "1", "20", "3", "D10", "D2A", "D2", "AL", "AK"}
	want := []string{"1", "2", "3", "10", "20", "AK", "AL", "D2", "D2A", "D10"}
	sort.Slice(in, func(i, j int) bool { return natLess(in[i], in[j]) })
	for i := range want {
		if in[i] != want[i] {
			t.Fatalf("natLess order = %v, want %v", in, want)
		}
	}
}

// A multi-reference field (n-fer POTA) counts each park separately.
func TestComputeMultiRef(t *testing.T) {
	def := Def{Code: "POTA", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: []string{"lotw", "qsl"}, Valid: true}
	qsos := []qso.QSO{
		{Callsign: "W2QMI", Band: "20m", POTARef: "US-6544,US-0680", LOTWRcvd: "Y"},
		{Callsign: "K1ABC", Band: "40m", POTARef: "US-0680"}, // shared park
	}
	r := Compute([]Def{def}, qsos, nil, nil)[0]
	if r.Worked != 2 { // distinct parks: US-6544, US-0680
		t.Errorf("POTA worked = %d, want 2 (%v)", r.Worked, refCodes(r))
	}
}

// WAJA-style award: MatchBy="description", non-exact, scanning the QTH for a
// reference's NAME (the prefecture). Also guards against the nil-slice crash:
// an award with nothing worked must return empty (non-nil) Refs/Bands.
func TestComputeMatchByDescription(t *testing.T) {
	def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
		DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
	qsos := []qso.QSO{
		{Callsign: "JA1ABC", Band: "20m", DXCC: ip(339), QTH: "Tokyo city", LOTWRcvd: "Y"},
		{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "Osaka"},
		{Callsign: "JA9XYZ", Band: "20m", DXCC: ip(339), QTH: "nowhere special"}, // no prefecture name
	}
	refMetas := map[string][]RefMeta{"WAJA": {
		{Code: "100", Name: "Tokyo", Valid: true},
		{Code: "270", Name: "Osaka", Valid: true},
		{Code: "010", Name: "Hokkaido", Valid: true},
	}}
	r := Compute([]Def{def}, qsos, refMetas, nil)[0]
	if r.Worked != 2 { // Tokyo + Osaka found by name inside QTH
		t.Errorf("WAJA worked = %d, want 2 (%v)", r.Worked, refCodes(r))
	}
	if r.Total != 3 { // predefined denominator = list size
		t.Errorf("WAJA total = %d, want 3", r.Total)
	}

	// Nil-slice guard: an award with zero worked refs must still return
	// non-nil (empty) Refs/Bands so the JSON isn't null (UI white-screen).
	empty := Compute([]Def{{Code: "WWFF", Type: TypeReference, Field: "wwff", Dynamic: true, Valid: true}}, nil, nil, nil)[0]
	if empty.Refs == nil || empty.Bands == nil {
		t.Errorf("empty award must have non-nil Refs/Bands, got Refs=%v Bands=%v", empty.Refs, empty.Bands)
	}
}

// OR rules are ordered fallbacks: the primary match wins and short-circuits the
// rules; when the primary finds nothing, the first OR rule that hits wins and
// later rules are skipped. DDFM-style: a French department from the NOTE first,
// else captured from a postal code in the address (Prefix "D" → "D74").
func TestComputeOrRuleFallback(t *testing.T) {
	def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`,
		DXCCFilter: []int{227}, Valid: true,
		OrRules: []OrRule{
			{Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"},
		},
	}
	refMetas := map[string][]RefMeta{"DDFM": {
		{Code: "D74", Name: "Haute-Savoie", Valid: true},
		{Code: "D75", Name: "Paris", Valid: true},
	}}
	worked := func(q qso.QSO) []string {
		r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
		var out []string
		for _, rf := range r.Refs {
			if rf.Worked {
				out = append(out, rf.Ref)
			}
		}
		return out
	}
	// Primary (note "D74") matches → the postal OR rule is skipped, so the
	// address's 75001 does NOT also add D75. Exactly the user's ask: first
	// condition wins, no fall-through.
	if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" {
		t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got)
	}
	// No note dept → fall through to the OR rule → postal 74140 → D74.
	if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" {
		t.Errorf("OR fallback: got %v, want [D74]", got)
	}
}

// The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in
// ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN
// regex (city lists). Confirms the ordered fallback + that a match-by-pattern
// rule with an empty rule-pattern triggers the per-reference regexes.
func TestComputeProvinceCascade(t *testing.T) {
	def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
		DXCCFilter: []int{318}, Valid: true,
		OrRules: []OrRule{
			{Field: "address", MatchBy: "description"},
			{Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes
		},
	}
	refMetas := map[string][]RefMeta{"WAPC": {
		{Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true},
		{Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true},
	}}
	worked := func(q qso.QSO) []string {
		r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
		var out []string
		for _, rf := range r.Refs {
			if rf.Worked {
				out = append(out, rf.Ref)
			}
		}
		return out
	}
	cases := []struct {
		name, qth, addr string
		want            string
	}{
		{"name in qth", "Wuxi, Jiangsu", "", "JS"},
		{"name in address", "Wuxi", "Jiangsu Province", "JS"},
		{"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"},
		{"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"},
	}
	for _, c := range cases {
		got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr})
		if len(got) != 1 || got[0] != c.want {
			t.Errorf("%s: got %v, want [%s]", c.name, got, c.want)
		}
	}
}

// A manually-assigned override (ManualRefsKey) must count in Compute — not just
// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes
// no QSO field) sticks after the operator picks the province by hand. The engine
// only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so
// the ref exists solely as the override.
func TestComputeManualOverride(t *testing.T) {
	def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description",
		DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true}
	qsos := []qso.QSO{
		// Address doesn't name the province → only the override tags it.
		{Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5",
			Extras: map[string]string{ManualRefsKey: "WAPC@JS"}},
	}
	refMetas := map[string][]RefMeta{"WAPC": {
		{Code: "JS", Name: "Jiangsu", Valid: true},
		{Code: "GD", Name: "Guangdong", Valid: true},
	}}
	r := Compute([]Def{def}, qsos, refMetas, nil)[0]
	if r.Worked != 1 {
		t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r))
	}
	var worked []string
	for _, rf := range r.Refs {
		if rf.Worked {
			worked = append(worked, rf.Ref)
		}
	}
	if len(worked) != 1 || worked[0] != "JS" {
		t.Errorf("worked refs = %v, want [JS]", worked)
	}

	// An override for a ref NOT in the predefined list is rejected (no typo refs).
	qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ"
	if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 {
		t.Errorf("bogus override ZZ: worked = %d, want 0", w)
	}
}

// A description-match award must also honour a REFERENCE's own regex (used to
// broaden matching past the plain name) AND match case-insensitively — a log
// QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo
// via its `\bTok[iy]o\b` pattern, which the plain name "Tokyo" can't catch.
func TestComputeDescriptionRefPattern(t *testing.T) {
	def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
		DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
	qsos := []qso.QSO{
		{Callsign: "JA1LZB", Band: "10m", DXCC: ip(339), QTH: "ITABASHIKU, TOKIO"}, // pattern + case
		{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "osaka pref"},        // lowercase name
	}
	refMetas := map[string][]RefMeta{"WAJA": {
		{Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true},
		{Code: "27", Name: "Osaka", Valid: true},
		{Code: "01", Name: "Hokkaido", Valid: true},
	}}
	r := Compute([]Def{def}, qsos, refMetas, nil)[0]
	if r.Worked != 2 {
		t.Errorf("WAJA worked = %d, want 2 (Tokyo via pattern + Osaka via lowercase name); got %v", r.Worked, refCodes(r))
	}
}

// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
// 4-char prefix of GRIDSQUARE.
func TestComputeGrid4VUCC(t *testing.T) {
	def := Def{Code: "VUCC", Type: TypeGrid, Field: "grid4", Dynamic: true,
		Confirm: []string{"lotw", "qsl"}, Valid: true}
	qsos := []qso.QSO{
		{Callsign: "K1ABC", Band: "6m", Grid: "FN31PR", LOTWRcvd: "Y"},          // → FN31
		{Callsign: "W2DEF", Band: "6m", VUCCGrids: "FN20,FN21,FN30,FN31"},        // grid-line: 4 squares
		{Callsign: "W3GHI", Band: "2m", Grid: "FN20XX"},                          // → FN20 (dup of above)
	}
	r := Compute([]Def{def}, qsos, nil, nil)[0]
	// Distinct squares: FN31, FN20, FN21, FN30 = 4
	if r.Worked != 4 {
		t.Errorf("VUCC worked = %d, want 4 (%v)", r.Worked, refCodes(r))
	}
}

// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
// fails on the count, the catalog file is wrong, not the test.
func TestCatalogFFMA(t *testing.T) {
	raw, ok := CatalogRefs("FFMA")
	if !ok {
		t.Fatal("FFMA has no reference list in the embedded catalog")
	}
	var refs []struct {
		Code  string `json:"code"`
		DXCC  int    `json:"dxcc"`
		Valid bool   `json:"valid"`
	}
	if err := json.Unmarshal(raw, &refs); err != nil {
		t.Fatalf("FFMA references: %v", err)
	}
	if len(refs) != 488 {
		t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
	}

	var def Def
	metas := make([]RefMeta, 0, len(refs))
	for _, r := range refs {
		// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
		// water. Pinning a reference to a DXCC entity would reject those contacts.
		if r.DXCC != 0 {
			t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
		}
		metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
	}
	for _, d := range Defaults() {
		if d.Code == "FFMA" {
			def = d
		}
	}
	if def.Total != 488 || def.Field != "grid4" {
		t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
	}

	d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
	qsos := []qso.QSO{
		{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"},   // counts
		{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"},  // a VE3 in an FFMA grid: counts (4c)
		{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
		{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"},   // not an FFMA grid
		{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"},   // wrong band
		{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
	}
	r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
	var got []string
	for _, rf := range r.Refs {
		if rf.Worked {
			got = append(got, rf.Ref)
		}
	}
	sort.Strings(got)
	want := []string{"EM00", "FN25", "FN31", "FN32"}
	if strings.Join(got, " ") != strings.Join(want, " ") {
		t.Fatalf("FFMA worked = %v, want %v", got, want)
	}
	if r.Worked != len(want) || r.Total != 488 {
		t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
	}
}

// WAIP: the primary rule takes the WHOLE address as the province code (exact
// match), which can never be one — but it does produce a raw candidate. The OR
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
// and it must still run: a rule that yields nothing VALID is not a hit.
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
	def := Def{
		Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
		Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
		OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
		Confirm: []string{"lotw", "qsl"},
	}
	metas := []RefMeta{
		{Code: "BG", Name: "Bergamo", Valid: true},
		{Code: "MI", Name: "Milano", Valid: true},
	}
	qsos := []qso.QSO{
		{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
	}
	r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
	if r.Worked != 1 {
		t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
	}
	for _, rf := range r.Refs {
		if rf.Ref == "BG" && rf.Worked {
			return
		}
	}
	t.Errorf("BG not worked; refs = %v", refCodes(r))
}

// Explain must account for the WAIP case exactly: the primary rule produces a
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
func TestExplainAccountsForEveryRule(t *testing.T) {
	def := Def{
		Code: "WAIP", Valid: true, Type: TypeReference,
		Field: "address", MatchBy: "code", ExactMatch: true,
		OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
		Confirm: []string{"lotw"},
	}
	metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
	q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}

	ex := Explain(def, metas, q)
	if !ex.InScope || len(ex.Steps) != 2 {
		t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
	}
	// The primary rule LOOKS like it found something. Saying only "no match" here is
	// what cost hours: the trace has to show the candidate and why it lost.
	p := ex.Steps[0]
	if len(p.Candidates) == 0 {
		t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
	}
	if len(p.Kept) != 0 || len(p.Rejected) == 0 {
		t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
	}
	if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
		t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
	}
	if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
		t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
	}
	if len(ex.Result) != 1 || ex.Result[0] != "BG" {
		t.Errorf("result = %v, want [BG]", ex.Result)
	}

	// A trace that disagrees with the matcher is worse than none: it is believed.
	if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
		t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
	}
}

func TestExplainOutOfScopeSaysWhy(t *testing.T) {
	def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
		ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
	q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
		QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}

	ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
	if ex.InScope {
		t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
	}
	if !strings.Contains(ex.ScopeError, "2m") {
		t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
	}
	if len(ex.Steps) != 0 {
		t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
	}
}

func refCodes(r Result) []string {
	out := make([]string, 0, len(r.Refs))
	for _, rf := range r.Refs {
		out = append(out, rf.Ref)
	}
	return out
}

// Legacy defs (Type=="", Valid==false, old DDFM pattern) get upgraded.
func TestMigrate(t *testing.T) {
	legacy := []Def{
		{Code: "DXCC", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340}, // Valid=false, Type=""
		{Code: "DDFM", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, Total: 96},
		{Code: "MYAWARD", Field: "note"}, // custom legacy
	}
	out, changed := Migrate(legacy)
	if !changed {
		t.Fatal("expected migration to change legacy defs")
	}
	for _, d := range out {
		if !d.Valid {
			t.Errorf("%s should be enabled after migration", d.Code)
		}
		if d.Type == "" {
			t.Errorf("%s should have a type after migration", d.Code)
		}
	}
	if out[1].Pattern != `(?i)\b(D\d{1,2}[AB]?)\b` {
		t.Errorf("DDFM pattern not fixed: %q", out[1].Pattern)
	}
	if out[2].Type != TypeQSOFields {
		t.Errorf("custom legacy award type = %q, want QSOFIELDS", out[2].Type)
	}
	// Idempotent: a second pass changes nothing.
	if _, changed2 := Migrate(out); changed2 {
		t.Error("migration should be idempotent")
	}
}

// Regression: a predefined reference whose own DXCC differs from the QSO's
// entity must still count when the field code matches and the award-level
// DXCC filter allows it (WAS Alaska: state AK, but DXCC entity 6, not 291).
func TestComputePredefinedCrossDXCC(t *testing.T) {
	def := Def{Code: "WAS", Type: TypeQSOFields, Field: "state", ExactMatch: true,
		DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Valid: true}
	qsos := []qso.QSO{
		{Callsign: "KL5DX", Band: "20m", DXCC: ip(6), State: "AK", LOTWRcvd: "Y"}, // Alaska
		{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"},              // continental
	}
	refMetas := map[string][]RefMeta{"WAS": {
		{Code: "AK", Name: "Alaska", DXCCList: []int{291}, Valid: true}, // wrong DXCC on purpose
		{Code: "MA", Name: "Massachusetts", DXCCList: []int{291}, Valid: true},
	}}
	r := Compute([]Def{def}, qsos, refMetas, nil)[0]
	if r.Worked != 2 {
		t.Errorf("WAS worked = %d, want 2 (Alaska must count despite DXCC 6) %v", r.Worked, refCodes(r))
	}
}

// A predefined award only counts references present in its list, lists the
// unworked ones too, and uses the list size as the denominator.
func TestComputePredefinedList(t *testing.T) {
	def := Def{Code: "RAC", Name: "RAC", Type: TypeQSOFields, Field: "state", ExactMatch: true,
		DXCCFilter: []int{1}, Confirm: []string{"lotw", "qsl"}, Validate: []string{"lotw"}, Valid: true}
	qsos := []qso.QSO{
		{Callsign: "VE3AAA", Band: "20m", DXCC: ip(1), State: "ON", LOTWRcvd: "Y"}, // worked+confirmed+validated
		{Callsign: "VE7BBB", Band: "40m", DXCC: ip(1), State: "BC", QSLRcvd: "Y"},   // worked+confirmed (not validated)
		{Callsign: "VE9CCC", Band: "20m", DXCC: ip(1), State: "ZZ"},                 // not a real province → ignored
		{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"},                // wrong DXCC → ignored
	}
	refMetas := map[string][]RefMeta{"RAC": {
		{Code: "ON", Name: "Ontario", Valid: true},
		{Code: "BC", Name: "British Columbia", Valid: true},
		{Code: "AB", Name: "Alberta", Valid: true},
	}}
	r := Compute([]Def{def}, qsos, refMetas, nil)[0]
	if r.Worked != 2 {
		t.Errorf("RAC worked = %d, want 2 (%v)", r.Worked, refCodes(r))
	}
	if r.Confirmed != 2 {
		t.Errorf("RAC confirmed = %d, want 2", r.Confirmed)
	}
	if r.Validated != 1 { // only ON via LoTW
		t.Errorf("RAC validated = %d, want 1", r.Validated)
	}
	if r.Total != 3 { // denominator = list size
		t.Errorf("RAC total = %d, want 3", r.Total)
	}
	if len(r.Refs) != 3 { // ON, BC worked + AB unworked
		t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
	}
}

// The built-in awards moved from Go code into embedded JSON (catalog/*.json).
// A refactor is only a refactor if the behaviour is identical, so pin down what
// the catalog MUST still produce — a typo in a JSON file would otherwise silently
// disable an award, and nobody would notice until a QSO stopped counting.
func TestCatalogDefaults(t *testing.T) {
	defs := Defaults()
	byCode := map[string]Def{}
	for _, d := range defs {
		byCode[d.Code] = d
	}

	// The catalog is MEANT to grow — dropping a JSON in is how an award ships. So
	// assert the ten originals are still there, never that the count is exactly ten:
	// a test that fails the moment you add an award is a test that teaches you to
	// ignore it.
	want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"}
	if len(defs) < len(want) {
		t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode)
	}
	for _, c := range want {
		d, ok := byCode[c]
		if !ok {
			t.Errorf("%s missing from the embedded catalog", c)
			continue
		}
		// Valid=false would hide the award entirely; Builtin=false would let a
		// "reset to defaults" delete it. Both are silent failures.
		if !d.Valid || !d.Builtin {
			t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin)
		}
		if d.Type == "" || d.Field == "" {
			t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field)
		}
		if len(d.Confirm) == 0 {
			t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c)
		}
	}

	// Spot-check the two that carry real matching logic, since a mangled escape in
	// JSON is exactly the failure this test exists to catch.
	if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` {
		t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got)
	}
	if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil {
		t.Errorf("DDFM pattern does not compile: %v", err)
	}
	if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 {
		t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got)
	}
	if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" {
		t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy)
	}
	if !byCode["WPX"].Dynamic {
		t.Error("WPX must be dynamic — its references aren't a fixed list")
	}
	// Deterministic order: an embed walk that reorders would shuffle the user's list.
	for i := 1; i < len(defs); i++ {
		if defs[i-1].Code > defs[i].Code {
			t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code)
		}
	}
}

// The whole point of the catalog: an award you EXPORT from the UI can be dropped
// straight into catalog/ and shipped to everyone — no conversion step. So the
// loader must accept the exported bundle shape, and it must carry the REFERENCES,
// because a WAPC without its provinces and their city regexes is an empty shell.
func TestCatalogAcceptsExportedBundle(t *testing.T) {
	// Exactly what ExportAward writes.
	exported := []byte(`{
	  "version": 1,
	  "exported_at": "2026-07-13T10:00:00Z",
	  "awards": [
	    {
	      "def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true,
	              "type":"QSOFIELDS","field":"address","match_by":"description",
	              "dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34},
	      "references": [
	        {"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true},
	        {"code":"ZJ","name":"Zhejiang","valid":true}
	      ]
	    }
	  ]
	}`)

	var f catalogFile
	if err := json.Unmarshal(exported, &f); err != nil {
		t.Fatalf("an exported bundle must parse as a catalog file: %v", err)
	}
	if len(f.Awards) != 1 {
		t.Fatalf("got %d awards, want 1", len(f.Awards))
	}
	e := f.Awards[0]
	if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" {
		t.Errorf("definition lost in the round-trip: %+v", e.Def)
	}
	if len(e.References) == 0 {
		t.Fatal("references dropped — this is the whole value of sharing an award")
	}
	// The per-reference regex must survive: it is what makes the award actually work.
	var refs []struct {
		Code    string `json:"code"`
		Pattern string `json:"pattern"`
	}
	if err := json.Unmarshal(e.References, &refs); err != nil {
		t.Fatalf("references not decodable: %v", err)
	}
	if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` {
		t.Errorf("reference regex did not survive: %+v", refs)
	}
	if _, err := compileAwardRE(refs[0].Pattern); err != nil {
		t.Errorf("the shipped regex does not compile: %v", err)
	}
}

// A single malformed file in the catalog must not take the other awards down with
// it — one bad JSON should cost you one award, not all of them.
func TestCatalogSurvivesOneBadFile(t *testing.T) {
	if len(Catalog()) < 10 {
		t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog()))
	}
	var f catalogFile
	if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil {
		t.Error("expected a parse error on malformed JSON")
	}
	// Catalog() skips unparseable files rather than returning nil, so the others
	// still load. (Verified structurally: the loader `continue`s on error.)
}

// Anything OpsLog SHIPS is built-in, whatever the file says.
//
// You create an award, export it (its JSON records builtin:false — it wasn't
// built-in when you wrote it), then drop that same file into the catalog to ship
// it. If we trusted the flag, the award would go out to everyone marked "not
// built-in" and would then silently miss every future catalog correction. Making
// the author remember to flip a flag first is a step nobody can guess — so the
// catalog derives it instead.
func TestCatalogForcesBuiltin(t *testing.T) {
	for _, e := range Catalog() {
		if !e.Def.Builtin {
			t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
		}
	}
	// The realistic case: a user-authored award whose JSON says builtin:false.
	if len(Catalog()) == 0 {
		t.Fatal("empty catalog")
	}
}

// An award with NO QSO field at all counts only what the operator assigned by
// hand. WWBOTA is the case: bunker references have no ADIF field anywhere, so
// the definition names no field and the matcher must scan nothing rather than
// fall back on some default. The manual override still applies — it is the only
// thing that can feed such an award.
func TestComputeNoFieldManualOnly(t *testing.T) {
	def := Def{Code: "WWBOTA", Type: TypeReference, Field: "", Dynamic: true, Valid: true}
	qsos := []qso.QSO{
		// Assigned by hand → counts.
		{Callsign: "F4ABC", Band: "40m", Extras: map[string]string{ManualRefsKey: "WWBOTA@B/F-0123"}},
		// Nothing assigned. The other fields are deliberately full: none of them
		// may be scanned just because no field was named.
		{Callsign: "F4DEF", Band: "20m", POTARef: "FF-0001", QTH: "B/F-0999", Comment: "B/F-0888"},
	}
	r := Compute([]Def{def}, qsos, nil, nil)[0]
	if r.Worked != 1 {
		t.Errorf("WWBOTA worked = %d, want 1 (%v)", r.Worked, refCodes(r))
	}
	if got := refCodes(r); len(got) != 1 || got[0] != "B/F-0123" {
		t.Errorf("WWBOTA refs = %v, want [B/F-0123]", got)
	}
}
