package qso

import (
	"testing"
	"time"
)

// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never
// alphabetically — the order of that chart IS the information.
func TestBandPlanOrder(t *testing.T) {
	counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332}
	got := sortedBuckets(counts, func(a, b string) bool {
		oa, ob := bandOrder[a], bandOrder[b]
		if oa == 0 {
			oa = 99
		}
		if ob == 0 {
			ob = 99
		}
		if oa != ob {
			return oa < ob
		}
		return a < b
	})
	want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"}
	if len(got) != len(want) {
		t.Fatalf("got %d buckets, want %d", len(got), len(want))
	}
	for i := range want {
		if got[i].Key != want[i] {
			t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got)
		}
	}
}

// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls
// .length/.map on null, which unmounts the whole React tree and leaves a WHITE
// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps
// nil), or a window too long for the hourly chart (Rate nil). Every slice the
// dashboard reads must therefore come back non-nil, even when empty.
func TestStatsNoNilSlices(t *testing.T) {
	var s Stats // the worst case: nothing computed at all
	s.ensureNonNil()

	checks := map[string]bool{
		"ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil,
		"ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil,
		"TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil,
		"Rate": s.Rate == nil, "Gaps": s.Gaps == nil,
	}
	for name, isNil := range checks {
		if isNil {
			t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name)
		}
	}

	// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
	// window that yields no hourly chart must still produce [] and not null.
	base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
	times := []entry{{t: base, op: "A"}, {t: base.Add(2 * time.Minute), op: "A"}, {t: base.Add(5 * time.Minute), op: "B"}}
	var s2 Stats
	s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
	s2.ensureNonNil()
	if s2.Gaps == nil {
		t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened")
	}
	if len(s2.Gaps) != 0 {
		t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps)
	}
}

// Contest metrics over a window. The two traps:
//  1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
//     a run straddling 13:45–14:45 is invisible to clock-hour bucketing, and the
//     rolling figure is the one contesters quote.
//  2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
//     AND QSOs ÷ hours actually operated. Quoting only the latter is how an
//     8-hour effort gets sold as a 48-hour score.
func TestContestPeriodMetrics(t *testing.T) {
	base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
	at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }

	// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
	// then a 2-hour silence, then 3 more.
	var times []entry
	for i := 0; i < 10; i++ {
		times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16
	}
	for i := 0; i < 3; i++ {
		times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
	}

	from := base                  // 12:00
	to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
	var s Stats
	s.periodMetrics(times, from, to, time.Time{}, time.Time{})

	if s.WindowHours != 6 {
		t.Errorf("window = %.1f h, want 6", s.WindowHours)
	}
	// 13 QSOs over a 6 h window.
	if got := s.AvgPerHour; got < 2.16 || got > 2.17 {
		t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got)
	}
	// The rolling hour must find the straddling run of 10 — a clock-hour bucket
	// would only ever see part of it.
	if s.Best60 != 10 {
		t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60)
	}
	if s.PeakHourCount >= 10 {
		t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount)
	}
	// THE INVARIANT: on-air + off-air must close on the window. The first version
	// counted "clock hours containing a QSO" as on-air, which on a real 45 h contest
	// reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on
	// incompatible bases; the operator believed neither, and was right.
	if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) {
		t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close",
			s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60))
	}
	// Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) +
	// the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit:
	// ignoring the lead-in and tail is what broke the budget.
	if s.OffAirMinutes != 40+164+110 {
		t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110)
	}
	if len(s.Gaps) != 3 {
		t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps)
	}
	if s.AvgPerActive <= s.AvgPerHour {
		t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour)
	}
	// The rate timeline covers EVERY hour of the window, silences as zeros.
	if len(s.Rate) != 7 { // 12,13,14,15,16,17,18
		t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate))
	}
	if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 {
		t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3])
	}
}

// The contest RATE SHEET: hour by hour, who made the QSOs.
//
// The invariant that matters: for EVERY hour, the per-operator numbers must sum to
// that hour's total. Derive the two separately and a rate sheet quietly stops
// adding up to its own total row — the sort of error nobody spots until someone
// checks the score by hand.
func TestRateSheetSumsToHourTotal(t *testing.T) {
	base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
	at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }

	times := []entry{
		{t: at(5), op: "F4BPO"}, {t: at(10), op: "F4BPO"}, {t: at(20), op: "F5XYZ"}, // hour 12: 3
		{t: at(70), op: "F5XYZ"}, {t: at(80), op: "F5XYZ"}, // hour 13: 2
		// hour 14 silent
		{t: at(185), op: "F4BPO"}, // hour 15: 1
	}
	var s Stats
	s.periodMetrics(times, base, base.Add(4*time.Hour), time.Time{}, time.Time{})

	if len(s.Rate) != len(s.RateByOp) {
		t.Fatalf("rate rows (%d) and rate-sheet rows (%d) must align 1:1", len(s.Rate), len(s.RateByOp))
	}
	// Both operators present, busiest first (they tie at 3 → alphabetical).
	if len(s.RateOps) != 2 || s.RateOps[0] != "F4BPO" {
		t.Fatalf("rate ops = %v, want [F4BPO F5XYZ]", s.RateOps)
	}
	for h := range s.Rate {
		sum := 0
		for _, n := range s.RateByOp[h] {
			sum += n
		}
		if sum != s.Rate[h].Count {
			t.Errorf("hour %s: operators sum to %d but the hour total is %d — the rate sheet doesn't add up",
				s.Rate[h].Key, sum, s.Rate[h].Count)
		}
		if len(s.RateByOp[h]) != len(s.RateOps) {
			t.Errorf("hour %s: row has %d columns, want %d (one per operator)", s.Rate[h].Key, len(s.RateByOp[h]), len(s.RateOps))
		}
	}
	// The silent hour is a row of zeros, not a missing row.
	if s.Rate[2].Count != 0 || s.RateByOp[2][0] != 0 || s.RateByOp[2][1] != 0 {
		t.Errorf("the silent 14:00 hour must be zeros, got total=%d row=%v", s.Rate[2].Count, s.RateByOp[2])
	}
}

// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
// would invent activity that never happened.
func TestTimeAxisIsContinuous(t *testing.T) {
	first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC)
	last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)

	years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last)
	if len(years) != 18 { // 2009..2026 inclusive
		t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years))
	}
	byKey := map[string]int{}
	for _, b := range years {
		byKey[b.Key] = b.Count
	}
	if byKey["2010"] != 0 || byKey["2018"] != 0 {
		t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"])
	}
	if byKey["2012"] != 1187 || byKey["2026"] != 14415 {
		t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"])
	}

	months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last)
	// May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months.
	if len(months) != 207 {
		t.Fatalf("months = %d, want 207 (continuous)", len(months))
	}
	if months[0].Key != "2009-05" || months[0].Count != 49 {
		t.Errorf("first month = %+v, want 2009-05 / 49", months[0])
	}
	if months[len(months)-1].Key != "2026-07" {
		t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key)
	}
	// Every step is exactly one month — no jumps.
	for i := 1; i < len(months); i++ {
		prev, _ := time.Parse("2006-01", months[i-1].Key)
		cur, _ := time.Parse("2006-01", months[i].Key)
		if !prev.AddDate(0, 1, 0).Equal(cur) {
			t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key)
		}
	}
}
