package qso

import (
	"context"
	"database/sql"
	"fmt"
	"sort"
	"strings"
	"time"
)

// Statistics over the whole logbook.
//
// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP
// BYs. Two reasons: the date maths (year / month / hour of day) would need
// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL —
// which is exactly the kind of thing that silently works on one backend and
// breaks on the other; and a single pass over a few columns of a 30k-row log is
// a few tens of milliseconds, so the complexity buys nothing.

// Bucket is one labelled count (mode, band, operator, entity…).
type Bucket struct {
	Key   string `json:"key"`
	Count int    `json:"count"`
}

// BandCategory is one band's QSO count split by mode category (CW / phone /
// digital), for the per-band mode-split chart.
type BandCategory struct {
	Band  string `json:"band"`
	CW    int    `json:"cw"`
	Phone int    `json:"phone"`
	Data  int    `json:"data"`
	Total int    `json:"total"`
}

// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes
// (SSB and the digital-voice family) count as phone; CW is CW; everything else is
// data. Mirrors the frontend's mode colouring.
func modeCategory(mode string) string {
	switch strings.ToUpper(strings.TrimSpace(mode)) {
	case "CW":
		return "cw"
	case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION":
		return "phone"
	default:
		return "data"
	}
}

// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
// are the expensive minutes: they are where the score went.
type Gap struct {
	Start   string `json:"start"` // RFC3339 — the last QSO before the silence
	End     string `json:"end"`   // the first QSO after it
	Minutes int    `json:"minutes"`
}

// ContestRun is one contest the operator actually took part in, discovered FROM
// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so
// the picker only ever offers contests you really entered, and never an empty one.
type ContestRun struct {
	ID    string `json:"id"`
	Year  int    `json:"year"`
	Count int    `json:"count"`
	Start string `json:"start"` // first QSO, RFC3339
	End   string `json:"end"`   // last QSO
}

// ContestRuns lists every (contest, year) pair present in the logbook, most
// recent first.
func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	type key struct {
		id   string
		year int
	}
	agg := map[key]*ContestRun{}
	for rows.Next() {
		var id, dateStr sql.NullString
		if err := rows.Scan(&id, &dateStr); err != nil {
			return nil, err
		}
		cid := strings.ToUpper(strings.TrimSpace(id.String))
		if cid == "" {
			continue
		}
		t := parseTimeLoose(dateStr.String).UTC()
		if t.IsZero() {
			continue
		}
		k := key{cid, t.Year()}
		c, ok := agg[k]
		if !ok {
			c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)}
			agg[k] = c
		}
		c.Count++
		if t.Format(time.RFC3339) < c.Start {
			c.Start = t.Format(time.RFC3339)
		}
		if t.Format(time.RFC3339) > c.End {
			c.End = t.Format(time.RFC3339)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	out := make([]ContestRun, 0, len(agg))
	for _, c := range agg {
		out = append(out, *c)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Year != out[j].Year {
			return out[i].Year > out[j].Year // most recent first
		}
		return out[i].ID < out[j].ID
	})
	return out, nil
}

// gapThreshold is the silence that counts as "off the air". Short enough to catch
// a real break, long enough not to flag the normal pause between two QSOs.
const gapThreshold = 30 * time.Minute

// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a
// week is generous. This is a READABILITY limit, not a memory one: at 30 days the
// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it
// looks broken, which is exactly how it first shipped. Past this the UI says
// "period too long for an hourly chart" instead of drawing mush.
const rateMaxHours = 7 * 24

// Stats is the whole dashboard payload.
// entityFirst is the first contact ever made with one DXCC entity.
type entityFirst struct {
	at      time.Time
	call    string
	country string
}

type Stats struct {
	// Headline figures.
	Total       int    `json:"total"`
	UniqueCalls int    `json:"unique_calls"`
	Entities    int    `json:"entities"`   // distinct DXCC entities
	Continents  int    `json:"continents"` // distinct continents
	FirstQSO    string `json:"first_qso"`  // RFC3339, "" when the log is empty
	LastQSO     string `json:"last_qso"`

	// The most recent entity worked for the FIRST time, and the contact that did
	// it. "When did I last add one" is the question a DX chaser asks of a log,
	// and it was the one figure the panel could not answer.
	//
	// Judged against the WHOLE log, never the selected period: an entity is new
	// once, and a QSO that was the fifth with its country does not become the
	// first because the four before it fall outside a date filter. Only the
	// period filter decides whether that contact is SHOWN.
	LastNewDXCC     string `json:"last_new_dxcc"`     // country name
	LastNewDXCCNum  int    `json:"last_new_dxcc_num"` // ADIF entity number
	LastNewDXCCCall string `json:"last_new_dxcc_call"`
	LastNewDXCCDate string `json:"last_new_dxcc_date"` // RFC3339, "" if none

	// Confirmations (of Total).
	ConfirmedLoTW int `json:"confirmed_lotw"`
	ConfirmedEQSL int `json:"confirmed_eqsl"`
	ConfirmedQSL  int `json:"confirmed_qsl"`
	ConfirmedAny  int `json:"confirmed_any"`

	// Breakdowns, each sorted most → least (bands keep frequency order).
	ByMode         []Bucket       `json:"by_mode"`
	ByBand         []Bucket       `json:"by_band"`
	ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
	ByOperator     []Bucket       `json:"by_operator"`
	ByStation      []Bucket       `json:"by_station"` // station_callsign (the call put on the air)
	ByContinent    []Bucket       `json:"by_continent"`
	TopEntities    []Bucket       `json:"top_entities"`
	ByYear         []Bucket       `json:"by_year"`  // chronological
	ByMonth        []Bucket       `json:"by_month"` // "YYYY-MM", chronological

	// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
	// the "activity over time" chart's day/week/month/year granularity selector.
	// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
	ByHour    []Bucket `json:"by_hour"`    // the newest QSO's day, hour by hour (00..23)
	ByDay7    []Bucket `json:"by_day7"`    // the last 7 days ending at the newest QSO
	ByDay30   []Bucket `json:"by_day30"`   // the last 30 days
	ByMonth12 []Bucket `json:"by_month12"` // the last 12 months

	// Chronological activity ACROSS THE SELECTED PERIOD, one series per bucket
	// size. These are what the activity chart's day/week/month/year selector
	// drives: the rolling windows above ignored the period filter, so choosing a
	// year in the panel left the chart showing the last 7 days regardless — two
	// controls contradicting each other on screen.
	//
	// A series is EMPTY when it would exceed maxActivityBuckets (per-day over a
	// seventeen-year log is ~6000 bars: unreadable, and pointless to transport).
	// The UI treats an empty series as "too fine for this period" and steps up.
	ByDayWin   []Bucket `json:"by_day_win"`
	ByWeekWin  []Bucket `json:"by_week_win"`
	ByMonthWin []Bucket `json:"by_month_win"`
	ByYearWin  []Bucket `json:"by_year_win"`

	// Rhythm: WHEN the operator is on the air, over the selected period. Distinct
	// question from the above ("how much, lately"), so it gets its own card. The
	// hour histogram used to cover a single day, which is too little to read
	// anything from.
	ByHourOfDay []Bucket `json:"by_hour_of_day"` // 00h..23h UTC
	ByWeekday   []Bucket `json:"by_weekday"`     // Mon..Sun

	// ── Period / contest metrics ──
	// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
	// nothing, but across a contest weekend it is the score. The window is the
	// requested [from,to] when given, else the span of the log.
	WindowStart  string  `json:"window_start"`
	WindowEnd    string  `json:"window_end"`
	WindowHours  float64 `json:"window_hours"`
	AvgPerHour   float64 `json:"avg_per_hour"`   // QSOs ÷ window hours (breaks included — the honest rate)
	AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours

	// On-air and off-air are a TIME BUDGET and must add up to the window:
	//     OnAirMinutes + OffAirMinutes == window
	// The first version counted "clock hours containing at least one QSO" as on-air,
	// so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that
	// gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers
	// measured on incompatible bases can't be compared, and the operator rightly
	// didn't believe either of them.
	OnAirMinutes  int `json:"on_air_minutes"`
	OffAirMinutes int `json:"off_air_minutes"`

	PeakHourKey   string   `json:"peak_hour_key"` // best clock hour (kept for reference)
	PeakHourCount int      `json:"peak_hour_count"`
	Best60        int      `json:"best_60"` // best ROLLING 60 min — the number contesters quote
	Gaps          []Gap    `json:"gaps"`    // the silences that make up OffAirMinutes, longest first
	Rate          []Bucket `json:"rate"`    // QSO per clock hour across the window ("MM-DD HH")

	// The contest RATE SHEET: hour by hour, who made the QSOs.
	// RateOps are the operators, busiest first — that fixed order is also the
	// colour/legend order, so an operator keeps their hue across the whole page.
	// RateByOp[h][o] is operator o's count in hour h; rows align 1:1 with Rate, so
	// the per-operator numbers always sum to the hour's total.
	RateOps  []string `json:"rate_ops"`
	RateByOp [][]int  `json:"rate_by_op"`
}

// entry is one dated QSO with the operator who made it — the pair the contest
// rate sheet needs. A bare timestamp can tell you HOW MANY, never BY WHOM.
type entry struct {
	t  time.Time
	op string
}

// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
// so the band chart reads like a band plan instead of a jumble.
var bandOrder = map[string]int{
	"2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7,
	"20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14,
	"2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19,
}

// yes reports whether an ADIF confirmation flag means "confirmed".
func yes(s string) bool {
	switch strings.ToUpper(strings.TrimSpace(s)) {
	case "Y", "V": // V = verified (LoTW)
		return true
	}
	return false
}

// Stats scans the logbook once and returns every breakdown the dashboard needs,
// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is
// the whole log).
//
// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL
// WHERE. qso_date is a text column whose format differs between the two backends,
// so a string comparison would quietly select the wrong rows on one of them. We
// already parse every date in this pass; filtering on the parsed value is both
// correct and free.
// contestID (with an optional year, 0 = any) narrows the log to one contest. When
// it is set and no explicit window is given, the window becomes the contest's own
// span — so rate, best-hour and off-air figures are computed over the contest
// itself without the operator having to look its dates up.
// Operators returns the distinct operators in the log (upper-cased), with "—"
// for QSOs the station owner logged himself (empty OPERATOR). Sorted, with "—"
// last so the picker reads real callsigns first.
func (r *Repo) Operators(ctx context.Context) ([]string, error) {
	rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT COALESCE(operator,'') FROM qso`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	seen := map[string]struct{}{}
	for rows.Next() {
		var op string
		if err := rows.Scan(&op); err != nil {
			return nil, err
		}
		op = strings.ToUpper(strings.TrimSpace(op))
		if op == "" {
			op = "—"
		}
		seen[op] = struct{}{}
	}
	out := make([]string, 0, len(seen))
	hasOwner := false
	for op := range seen {
		if op == "—" {
			hasOwner = true
			continue
		}
		out = append(out, op)
	}
	sort.Strings(out)
	if hasOwner {
		out = append(out, "—")
	}
	return out, rows.Err()
}

func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int, operator string) (Stats, error) {
	var s Stats
	contestID = strings.ToUpper(strings.TrimSpace(contestID))
	// Operator filter: "" = all operators; "—" = QSOs the station owner logged
	// himself (empty OPERATOR); any other value = that operator's QSOs only.
	opFilter := strings.ToUpper(strings.TrimSpace(operator))

	rows, err := r.db.QueryContext(ctx, `
		SELECT callsign, qso_date, band, mode, cont, country, dxcc,
		       operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id
		FROM qso`)
	if err != nil {
		return s, err
	}
	defer rows.Close()

	var (
		calls       = map[string]struct{}{}
		entities    = map[int]struct{}{}
		modeC       = map[string]int{}
		bandC       = map[string]int{}
		bandCat     = map[string]*BandCategory{} // per band: cw/phone/data
		opC         = map[string]int{}
		stationC    = map[string]int{}
		contC       = map[string]int{}
		entityC     = map[string]int{}
		yearC       = map[string]int{}
		monthC      = map[string]int{}
		times       []entry // every dated QSO (+ its operator), for the rate / gap maths
		first, last time.Time
		// The earliest contact with each entity, for "last new DXCC".
		firstByEntity = map[int]entityFirst{}
	)

	for rows.Next() {
		var (
			call, band, mode, cont, country sql.NullString
			oper, station                   sql.NullString
			lotw, eqsl, paper               sql.NullString
			dxcc                            sql.NullInt64
			dateStr, contestID2             sql.NullString
		)
		if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
			&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
			return s, err
		}

		// The first contact with each entity, over the WHOLE log — recorded before
		// every filter below, because being new is a property of the log and not
		// of the period on screen.
		if dxcc.Valid && dxcc.Int64 > 0 {
			if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
				n := int(dxcc.Int64)
				if cur, seen := firstByEntity[n]; !seen || t.Before(cur.at) {
					firstByEntity[n] = entityFirst{
						at:      t,
						call:    strings.ToUpper(strings.TrimSpace(call.String)),
						country: strings.TrimSpace(country.String),
					}
				}
			}
		}

		// Contest filter first — same reasoning as the window below: a QSO that
		// isn't in this contest must not reach ANY bucket.
		if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
			continue
		}

		// An empty OPERATOR means "the station owner logged it himself" — bucket
		// it as "—". Computed here so the operator filter can also drop QSOs that
		// aren't this operator's before they reach ANY bucket.
		op := strings.ToUpper(strings.TrimSpace(oper.String))
		if op == "" {
			op = "—"
		}
		if opFilter != "" && op != opFilter {
			continue
		}

		// Window first: a QSO outside the period must not reach ANY bucket. Doing
		// this after the counting (the obvious mistake) would leave the mode/band/
		// operator charts showing the whole log while only the trend was filtered.
		// parseTimeLoose is the repo's existing convention for qso_date — it copes
		// with what each backend hands back (SQLite ISO string, MySQL DATETIME).
		t := parseTimeLoose(dateStr.String).UTC()
		dated := !t.IsZero()
		if year > 0 && (!dated || t.Year() != year) {
			continue
		}
		if !from.IsZero() && (!dated || t.Before(from)) {
			continue
		}
		if !to.IsZero() && (!dated || t.After(to)) {
			continue
		}

		s.Total++

		if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" {
			calls[c] = struct{}{}
		}
		if dxcc.Valid && dxcc.Int64 > 0 {
			entities[int(dxcc.Int64)] = struct{}{}
		}
		if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" {
			modeC[m]++
		}
		if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
			bandC[b]++
			bc := bandCat[b]
			if bc == nil {
				bc = &BandCategory{Band: b}
				bandCat[b] = bc
			}
			switch modeCategory(mode.String) {
			case "cw":
				bc.CW++
			case "phone":
				bc.Phone++
			default:
				bc.Data++
			}
			bc.Total++
		}
		// op was resolved above (with the operator filter applied).
		opC[op]++
		if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
			stationC[st]++
		}
		// Bucket a blank/unknown continent under "Unknown" rather than dropping it,
		// so the continent breakdown totals the same as the QSO count (a handful of
		// QSOs with no resolved continent made the donut read a few short).
		if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
			contC[c]++
		} else {
			contC["Unknown"]++
		}
		if c := strings.TrimSpace(country.String); c != "" {
			entityC[c]++
		}

		cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String)
		if cl {
			s.ConfirmedLoTW++
		}
		if el {
			s.ConfirmedEQSL++
		}
		if pl {
			s.ConfirmedQSL++
		}
		if cl || el || pl {
			s.ConfirmedAny++
		}

		// An undated QSO still counts in the mode/band/operator totals above, but
		// it can't be placed on a time axis — leave it out of the trend rather than
		// parking it at year zero.
		if !dated {
			continue
		}
		if first.IsZero() || t.Before(first) {
			first = t
		}
		if last.IsZero() || t.After(last) {
			last = t
		}
		yearC[t.Format("2006")]++
		monthC[t.Format("2006-01")]++
		times = append(times, entry{t: t, op: op})
	}
	if err := rows.Err(); err != nil {
		return s, err
	}

	// The newest of the first-contacts: the last entity added to the log.
	for num, f := range firstByEntity {
		if f.at.After(parseTimeLoose(s.LastNewDXCCDate)) {
			s.LastNewDXCC = f.country
			s.LastNewDXCCNum = num
			s.LastNewDXCCCall = f.call
			s.LastNewDXCCDate = f.at.Format(time.RFC3339)
		}
	}

	s.UniqueCalls = len(calls)
	s.Entities = len(entities)
	s.Continents = len(contC)
	if _, ok := contC["Unknown"]; ok {
		s.Continents-- // "Unknown" is a catch-all bucket, not a real continent
	}
	if !first.IsZero() {
		s.FirstQSO = first.UTC().Format(time.RFC3339)
		s.LastQSO = last.UTC().Format(time.RFC3339)
	}

	s.ByMode = topBuckets(modeC, 0)
	s.ByOperator = topBuckets(opC, 0)
	s.ByStation = topBuckets(stationC, 0)
	s.ByContinent = topBuckets(contC, 0)
	s.TopEntities = topBuckets(entityC, 15)

	// Bands read in band-plan order, not by count — the shape of the chart IS
	// the band plan, and re-sorting it by size would destroy that.
	s.ByBand = sortedBuckets(bandC, 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
	})
	// Per-band CW/phone/data split, in the SAME band-plan order as ByBand.
	s.ByBandCategory = []BandCategory{}
	for _, b := range s.ByBand {
		if bc := bandCat[b.Key]; bc != nil {
			s.ByBandCategory = append(s.ByBandCategory, *bc)
		}
	}
	// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
	// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
	// chart would invent activity that never happened. A gap in the log is real
	// information: it belongs on the chart as zeros.
	s.ByYear = fillYears(yearC, first, last)
	s.ByMonth = fillMonths(monthC, first, last)

	s.periodMetrics(times, from, to, first, last)
	// Anchor the rolling activity windows to the period end when filtered, else to
	// now (so "week" is the last 7 days ending today, like the operator expects).
	ref := to
	if ref.IsZero() {
		ref = time.Now()
	}
	s.recentSeries(times, ref)
	s.windowSeries(times, first, last, from, to)
	s.ensureNonNil()

	return s, nil
}

// maxActivityBuckets caps a chronological series. Beyond this the chart is a
// grey smear and the payload grows for nothing; the series is dropped and the UI
// steps up to a coarser bucket.
const maxActivityBuckets = 750

// windowSeries builds the chronological activity series ACROSS THE SELECTED
// PERIOD, one per bucket size, plus the hour-of-day / weekday rhythm.
//
// The period is [from,to] when the caller filtered, else the span of the log —
// the same window the period metrics use, so every card on the panel answers for
// the same slice of time.
func (s *Stats) windowSeries(times []entry, first, last, from, to time.Time) {
	start, end := from, to
	if start.IsZero() {
		start = first
	}
	if end.IsZero() {
		end = last
	}
	if start.IsZero() || end.IsZero() || end.Before(start) {
		return
	}
	start, end = start.UTC(), end.UTC()

	day := map[string]int{}
	week := map[string]int{}
	month := map[string]int{}
	year := map[string]int{}
	hour := make([]int, 24)
	weekday := make([]int, 7)
	for _, e := range times {
		t := e.t.UTC()
		if t.Before(start) || t.After(end) {
			continue
		}
		day[t.Format("2006-01-02")]++
		// ISO week, so a week is Monday→Sunday everywhere and the key sorts.
		wy, wn := t.ISOWeek()
		week[fmt.Sprintf("%04d-W%02d", wy, wn)]++
		month[t.Format("2006-01")]++
		year[t.Format("2006")]++
		hour[t.Hour()]++
		weekday[(int(t.Weekday())+6)%7]++ // Go weeks start on Sunday; shift to Monday
	}

	// Every bucket in the range is emitted, including the empty ones: a gap in
	// the log is real information and must show as zero, not be closed up.
	d0 := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
	dEnd := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
	if n := int(dEnd.Sub(d0).Hours()/24) + 1; n >= 1 && n <= maxActivityBuckets {
		for d := d0; !d.After(dEnd); d = d.AddDate(0, 0, 1) {
			s.ByDayWin = append(s.ByDayWin, Bucket{Key: d.Format("2006-01-02"), Count: day[d.Format("2006-01-02")]})
		}
	}
	// Weeks: start on the Monday of the first week and step by 7 days.
	w0 := d0.AddDate(0, 0, -((int(d0.Weekday()) + 6) % 7))
	if n := int(dEnd.Sub(w0).Hours()/24)/7 + 1; n >= 1 && n <= maxActivityBuckets {
		for w := w0; !w.After(dEnd); w = w.AddDate(0, 0, 7) {
			wy, wn := w.ISOWeek()
			key := fmt.Sprintf("%04d-W%02d", wy, wn)
			s.ByWeekWin = append(s.ByWeekWin, Bucket{Key: key, Count: week[key]})
		}
	}
	m0 := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
	mEnd := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
	if n := (mEnd.Year()-m0.Year())*12 + int(mEnd.Month()) - int(m0.Month()) + 1; n >= 1 && n <= maxActivityBuckets {
		for m := m0; !m.After(mEnd); m = m.AddDate(0, 1, 0) {
			s.ByMonthWin = append(s.ByMonthWin, Bucket{Key: m.Format("2006-01"), Count: month[m.Format("2006-01")]})
		}
	}
	for y := start.Year(); y <= end.Year(); y++ {
		k := fmt.Sprintf("%04d", y)
		s.ByYearWin = append(s.ByYearWin, Bucket{Key: k, Count: year[k]})
	}

	for h := 0; h < 24; h++ {
		s.ByHourOfDay = append(s.ByHourOfDay, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
	}
	for i, name := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
		s.ByWeekday = append(s.ByWeekday, Bucket{Key: name, Count: weekday[i]})
	}
}

// recentSeries builds the rolling chronological activity windows for the chart's
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
// filtered, else now). Real dates in order — today and the days before it — so the
// axis reads chronologically and empty days are just zero, not misordered/wrapped.
func (s *Stats) recentSeries(times []entry, ref time.Time) {
	ref = ref.UTC()
	day0 := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) // midnight of the anchor day
	hour := make([]int, 24)
	daily := map[string]int{}
	monthly := map[string]int{}
	for _, e := range times {
		t := e.t.UTC()
		if t.Year() == ref.Year() && t.YearDay() == ref.YearDay() {
			hour[t.Hour()]++
		}
		daily[t.Format("2006-01-02")]++
		monthly[t.Format("2006-01")]++
	}
	// Day: the anchor day, hour by hour.
	for h := 0; h < 24; h++ {
		s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
	}
	// Week: the last 7 days ending today (today is last). Label = weekday + day.
	for i := 6; i >= 0; i-- {
		d := day0.AddDate(0, 0, -i)
		s.ByDay7 = append(s.ByDay7, Bucket{Key: d.Format("Mon 2"), Count: daily[d.Format("2006-01-02")]})
	}
	// Month: the last 30 days.
	for i := 29; i >= 0; i-- {
		d := day0.AddDate(0, 0, -i)
		s.ByDay30 = append(s.ByDay30, Bucket{Key: d.Format("2/1"), Count: daily[d.Format("2006-01-02")]})
	}
	// Year: the last 12 months.
	m0 := time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, time.UTC)
	for i := 11; i >= 0; i-- {
		m := m0.AddDate(0, -i, 0)
		s.ByMonth12 = append(s.ByMonth12, Bucket{Key: m.Format("Jan 06"), Count: monthly[m.Format("2006-01")]})
	}
}

// ensureNonNil replaces every nil slice with an empty one.
//
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
// UI then calls .length / .map on null — a TypeError that unmounts the whole React
// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest
// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart
// (Rate nil). The awards code carries the same guard for the same reason.
func (s *Stats) ensureNonNil() {
	if s.ByMode == nil {
		s.ByMode = []Bucket{}
	}
	if s.ByBand == nil {
		s.ByBand = []Bucket{}
	}
	if s.ByOperator == nil {
		s.ByOperator = []Bucket{}
	}
	if s.ByStation == nil {
		s.ByStation = []Bucket{}
	}
	if s.ByContinent == nil {
		s.ByContinent = []Bucket{}
	}
	if s.TopEntities == nil {
		s.TopEntities = []Bucket{}
	}
	if s.ByYear == nil {
		s.ByYear = []Bucket{}
	}
	if s.ByMonth == nil {
		s.ByMonth = []Bucket{}
	}
	// A window series stays EMPTY on purpose when the bucket would be too fine
	// for the period — but it must be [] and not null, so the UI can tell
	// "too fine" from "not computed".
	for _, p := range []*[]Bucket{&s.ByDayWin, &s.ByWeekWin, &s.ByMonthWin, &s.ByYearWin, &s.ByHourOfDay, &s.ByWeekday} {
		if *p == nil {
			*p = []Bucket{}
		}
	}
	if s.Rate == nil {
		s.Rate = []Bucket{}
	}
	if s.Gaps == nil {
		s.Gaps = []Gap{}
	}
	if s.RateOps == nil {
		s.RateOps = []string{}
	}
	if s.RateByOp == nil {
		s.RateByOp = [][]int{}
	}
}

// periodMetrics derives the rate / off-air figures that make a contest window
// readable. The window is the caller's [from,to] when given, else the span of the
// log itself.
//
// Two rates are reported on purpose, because a single one always flatters:
//   - AvgPerHour  = QSOs ÷ the WHOLE window — breaks included. The honest number.
//   - AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
//     how fast you go when you ARE at the radio.
//
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
	if len(times) == 0 {
		return
	}
	sort.Slice(times, func(i, j int) bool { return times[i].t.Before(times[j].t) })

	winStart, winEnd := from, to
	if winStart.IsZero() {
		winStart = first
	}
	if winEnd.IsZero() {
		winEnd = last
	}
	if !winEnd.After(winStart) {
		return
	}
	s.WindowStart = winStart.Format(time.RFC3339)
	s.WindowEnd = winEnd.Format(time.RFC3339)
	s.WindowHours = winEnd.Sub(winStart).Hours()
	if s.WindowHours > 0 {
		s.AvgPerHour = float64(len(times)) / s.WindowHours
	}

	// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
	// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
	hourly := map[string]int{}
	for _, e := range times {
		hourly[e.t.Format("2006-01-02 15")]++
	}
	for k, v := range hourly {
		if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
			s.PeakHourKey, s.PeakHourCount = k, v
		}
	}

	// Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45–
	// 14:45 is invisible to clock-hour bucketing, and it's the figure contesters
	// actually quote. Two pointers over the sorted times: O(n).
	lo := 0
	for hi := range times {
		for times[hi].t.Sub(times[lo].t) >= time.Hour {
			lo++
		}
		if n := hi - lo + 1; n > s.Best60 {
			s.Best60 = n
		}
	}

	// Off-air is a TIME BUDGET, and it has to close on the window:
	//     OnAirMinutes + OffAirMinutes == window
	// So every silence ≥ 30 min counts — including the lead-in before the first QSO
	// and the tail after the last, when an explicit window was asked for. Skipping
	// those (the first version did) makes "on air" and "off air" sum to more than
	// the window, and then neither number is believable.
	addGap := func(a, b time.Time) {
		d := b.Sub(a)
		if d < gapThreshold {
			return
		}
		s.OffAirMinutes += int(d.Minutes())
		s.Gaps = append(s.Gaps, Gap{
			Start:   a.Format(time.RFC3339),
			End:     b.Format(time.RFC3339),
			Minutes: int(d.Minutes()),
		})
	}
	addGap(winStart, times[0].t)      // lead-in
	for i := 1; i < len(times); i++ { // the silences between QSOs
		addGap(times[i-1].t, times[i].t)
	}
	addGap(times[len(times)-1].t, winEnd) // tail

	s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
	if s.OnAirMinutes < 0 {
		s.OnAirMinutes = 0
	}
	if s.OnAirMinutes > 0 {
		s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60)
	}
	sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes })
	if len(s.Gaps) > 10 {
		s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
	}

	// Per-hour rate timeline + the RATE SHEET (who made those QSOs, hour by hour).
	// Every hour of the window, zeros included, so the silences read as silences.
	if s.WindowHours > rateMaxHours {
		return
	}

	// Operators, busiest first. That order is fixed and reused as the colour/legend
	// order, so an operator keeps the same hue everywhere on the page — a chart that
	// repaints its series when the filter changes is a chart nobody can trust.
	opTotals := map[string]int{}
	for _, e := range times {
		opTotals[e.op]++
	}
	s.RateOps = make([]string, 0, len(opTotals))
	for op := range opTotals {
		s.RateOps = append(s.RateOps, op)
	}
	sort.Slice(s.RateOps, func(i, j int) bool {
		a, b := s.RateOps[i], s.RateOps[j]
		if opTotals[a] != opTotals[b] {
			return opTotals[a] > opTotals[b]
		}
		return a < b
	})
	// Never invent a 9th colour: past 8 operators the tail folds into "Other", which
	// is honest and still sums correctly.
	const maxOps = 8
	folded := false
	if len(s.RateOps) > maxOps {
		s.RateOps = append(s.RateOps[:maxOps:maxOps], otherOp)
		folded = true
	}
	opIdx := map[string]int{}
	for i, op := range s.RateOps {
		opIdx[op] = i
	}
	slotFor := func(op string) int {
		if i, ok := opIdx[op]; ok {
			return i
		}
		if folded {
			return len(s.RateOps) - 1 // the "Other" bucket
		}
		return -1
	}

	// hourOps[hourKey][slot] — built from the same `times` as `hourly`, so the
	// per-operator numbers ALWAYS sum to the hour's total. Deriving them separately
	// is how a rate sheet ends up not adding up to its own total row.
	hourOps := map[string][]int{}
	for _, e := range times {
		k := e.t.Format("2006-01-02 15")
		row, ok := hourOps[k]
		if !ok {
			row = make([]int, len(s.RateOps))
			hourOps[k] = row
		}
		if i := slotFor(e.op); i >= 0 {
			row[i]++
		}
	}

	cur := winStart.Truncate(time.Hour)
	end := winEnd.Truncate(time.Hour)
	for !cur.After(end) {
		k := cur.Format("2006-01-02 15")
		s.Rate = append(s.Rate, Bucket{Key: cur.Format("01-02 15"), Count: hourly[k]})
		row := hourOps[k]
		if row == nil {
			row = make([]int, len(s.RateOps)) // a silent hour is zeros, not a missing row
		}
		s.RateByOp = append(s.RateByOp, row)
		cur = cur.Add(time.Hour)
	}
}

// otherOp is where operators past the 8th are folded. Generating a 9th colour is
// never the answer: under colour-blindness it is indistinguishable from one of the
// existing eight.
const otherOp = "Other"

// topBuckets sorts a count map most → least (ties alphabetical) and optionally
// keeps only the top n.
func topBuckets(m map[string]int, n int) []Bucket {
	out := make([]Bucket, 0, len(m))
	for k, v := range m {
		out = append(out, Bucket{Key: k, Count: v})
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Count != out[j].Count {
			return out[i].Count > out[j].Count
		}
		return out[i].Key < out[j].Key
	})
	if n > 0 && len(out) > n {
		out = out[:n]
	}
	return out
}

// sortedBuckets keeps a caller-defined key order (band plan, chronology).
func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
	out := make([]Bucket, 0, len(keys))
	for _, k := range keys {
		out = append(out, Bucket{Key: k, Count: m[k]})
	}
	return out
}

// fillMonths emits EVERY month between the first and last QSO — zeros included —
// so the trend line's x-axis is real time rather than "months that happen to have
// data". A quiet decade must read as a decade at zero, not vanish.
func fillMonths(m map[string]int, first, last time.Time) []Bucket {
	if first.IsZero() {
		return nil
	}
	var out []Bucket
	cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC)
	end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC)
	for !cur.After(end) {
		k := cur.Format("2006-01")
		out = append(out, Bucket{Key: k, Count: m[k]})
		cur = cur.AddDate(0, 1, 0)
	}
	return out
}

// fillYears does the same for the yearly view.
func fillYears(m map[string]int, first, last time.Time) []Bucket {
	if first.IsZero() {
		return nil
	}
	var out []Bucket
	for y := first.Year(); y <= last.Year(); y++ {
		k := fmt.Sprintf("%04d", y)
		out = append(out, Bucket{Key: k, Count: m[k]})
	}
	return out
}
