// Package qso models a radio contact (QSO) and provides its repository.
package qso

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"reflect"
	"regexp"
	"sort"
	"strings"
	"time"

	"hamlog/internal/db"
)

// MergeNonZero copies every non-zero field of src onto dst. Zero-value src
// fields (empty string, nil pointer, zero time, zero number) are skipped so
// existing data is preserved. Maps (Extras) are merged key-by-key rather than
// replaced. Because an imported QSO has ID==0 and CreatedAt zero, dst's
// identity is naturally preserved. Used by the importer's "update duplicates"
// mode so re-importing an ADIF refreshes QSL/confirmation statuses without
// clobbering fields the source file doesn't carry.
func MergeNonZero(dst *QSO, src QSO) {
	dv := reflect.ValueOf(dst).Elem()
	sv := reflect.ValueOf(src)
	for i := 0; i < dv.NumField(); i++ {
		df, sf := dv.Field(i), sv.Field(i)
		if !df.CanSet() || sf.IsZero() {
			continue
		}
		if sf.Kind() == reflect.Map {
			if df.IsNil() {
				df.Set(reflect.MakeMap(sf.Type()))
			}
			for _, k := range sf.MapKeys() {
				df.SetMapIndex(k, sf.MapIndex(k))
			}
			continue
		}
		df.Set(sf)
	}
}

// QSO represents a contact. Fields are aligned on ADIF naming for
// import/export. Pointers are used to distinguish "absent" from "zero".
// Anything in ADIF that is not a promoted column lands in Extras.
type QSO struct {
	ID         int64     `json:"id"`
	Callsign   string    `json:"callsign"`
	QSODate    time.Time `json:"qso_date"`               // start, UTC
	QSODateOff time.Time `json:"qso_date_off,omitempty"` // end, UTC
	Band       string    `json:"band"`
	BandRX     string    `json:"band_rx,omitempty"`
	Mode       string    `json:"mode"`
	Submode    string    `json:"submode,omitempty"`
	FreqHz     *int64    `json:"freq_hz,omitempty"`
	FreqRXHz   *int64    `json:"freq_rx_hz,omitempty"`

	RSTSent string `json:"rst_sent,omitempty"`
	RSTRcvd string `json:"rst_rcvd,omitempty"`

	// --- Contacted station ---
	Name          string   `json:"name,omitempty"`
	QTH           string   `json:"qth,omitempty"`
	Address       string   `json:"address,omitempty"`
	Email         string   `json:"email,omitempty"`
	Web           string   `json:"web,omitempty"`
	Grid          string   `json:"grid,omitempty"`
	GridExt       string   `json:"gridsquare_ext,omitempty"`
	VUCCGrids     string   `json:"vucc_grids,omitempty"`
	Country       string   `json:"country,omitempty"`
	State         string   `json:"state,omitempty"`
	County        string   `json:"cnty,omitempty"`
	DXCC          *int     `json:"dxcc,omitempty"`
	Continent     string   `json:"cont,omitempty"`
	CQZ           *int     `json:"cqz,omitempty"`
	ITUZ          *int     `json:"ituz,omitempty"`
	IOTA          string   `json:"iota,omitempty"`
	SOTARef       string   `json:"sota_ref,omitempty"`
	POTARef       string   `json:"pota_ref,omitempty"`
	Age           *int     `json:"age,omitempty"`
	Lat           *float64 `json:"lat,omitempty"`
	Lon           *float64 `json:"lon,omitempty"`
	Rig           string   `json:"rig,omitempty"`
	Ant           string   `json:"ant,omitempty"`

	// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
	QSLSent     string `json:"qsl_sent,omitempty"`
	QSLRcvd     string `json:"qsl_rcvd,omitempty"`
	QSLSentDate string `json:"qsl_sent_date,omitempty"`
	QSLRcvdDate string `json:"qsl_rcvd_date,omitempty"`
	QSLVia      string `json:"qsl_via,omitempty"`
	QSLMsg      string `json:"qsl_msg,omitempty"`
	QSLMsgRcvd  string `json:"qslmsg_rcvd,omitempty"`

	LOTWSent     string `json:"lotw_sent,omitempty"`
	LOTWRcvd     string `json:"lotw_rcvd,omitempty"`
	LOTWSentDate string `json:"lotw_sent_date,omitempty"`
	LOTWRcvdDate string `json:"lotw_rcvd_date,omitempty"`

	EQSLSent     string `json:"eqsl_sent,omitempty"`
	EQSLRcvd     string `json:"eqsl_rcvd,omitempty"`
	EQSLSentDate string `json:"eqsl_sent_date,omitempty"`
	EQSLRcvdDate string `json:"eqsl_rcvd_date,omitempty"`

	ClublogUploadDate   string `json:"clublog_qso_upload_date,omitempty"`
	ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
	HRDLogUploadDate    string `json:"hrdlog_qso_upload_date,omitempty"`
	HRDLogUploadStatus  string `json:"hrdlog_qso_upload_status,omitempty"`
	QRZComUploadDate    string `json:"qrzcom_qso_upload_date,omitempty"`
	QRZComUploadStatus  string `json:"qrzcom_qso_upload_status,omitempty"`
	QRZComDownloadDate   string `json:"qrzcom_qso_download_date,omitempty"`
	QRZComDownloadStatus string `json:"qrzcom_qso_download_status,omitempty"`

	// --- Contest ---
	ContestID  string `json:"contest_id,omitempty"`
	SRX        *int   `json:"srx,omitempty"`
	STX        *int   `json:"stx,omitempty"`
	SRXString  string `json:"srx_string,omitempty"`
	STXString  string `json:"stx_string,omitempty"`
	Check      string `json:"check,omitempty"`
	Precedence string `json:"precedence,omitempty"`
	ARRLSect   string `json:"arrl_sect,omitempty"`

	// --- Satellite / propagation ---
	PropMode string   `json:"prop_mode,omitempty"`
	SatName  string   `json:"sat_name,omitempty"`
	SatMode  string   `json:"sat_mode,omitempty"`
	AntAz    *float64 `json:"ant_az,omitempty"`
	AntEl    *float64 `json:"ant_el,omitempty"`
	AntPath  string   `json:"ant_path,omitempty"`

	// --- My station / operator ---
	StationCallsign string   `json:"station_callsign,omitempty"`
	Operator        string   `json:"operator,omitempty"`
	MyGrid          string   `json:"my_grid,omitempty"`
	MyGridExt       string   `json:"my_gridsquare_ext,omitempty"`
	MyCountry       string   `json:"my_country,omitempty"`
	MyState         string   `json:"my_state,omitempty"`
	MyCounty        string   `json:"my_cnty,omitempty"`
	MyIOTA          string   `json:"my_iota,omitempty"`
	MySOTARef       string   `json:"my_sota_ref,omitempty"`
	MyPOTARef       string   `json:"my_pota_ref,omitempty"`
	MyDXCC          *int     `json:"my_dxcc,omitempty"`
	MyCQZone        *int     `json:"my_cq_zone,omitempty"`
	MyITUZone       *int     `json:"my_itu_zone,omitempty"`
	MyLat           *float64 `json:"my_lat,omitempty"`
	MyLon           *float64 `json:"my_lon,omitempty"`
	MyStreet        string   `json:"my_street,omitempty"`
	MyCity          string   `json:"my_city,omitempty"`
	MyPostalCode    string   `json:"my_postal_code,omitempty"`
	MyRig           string   `json:"my_rig,omitempty"`
	MyAntenna       string   `json:"my_antenna,omitempty"`

	// --- Misc ---
	TXPower *float64 `json:"tx_pwr,omitempty"`
	Comment string   `json:"comment,omitempty"`
	Notes   string   `json:"notes,omitempty"`

	// --- ADIF 3.1.7 additional promoted fields ---
	// Kept in one block so columnList / args() / scanQSO stay trivially in
	// sync (they are appended at the end, before extras_json).
	SIG             string   `json:"sig,omitempty"`
	SIGInfo         string   `json:"sig_info,omitempty"`
	MySIG           string   `json:"my_sig,omitempty"`
	MySIGInfo       string   `json:"my_sig_info,omitempty"`
	WWFFRef         string   `json:"wwff_ref,omitempty"`
	MyWWFFRef       string   `json:"my_wwff_ref,omitempty"`
	Distance        *float64 `json:"distance,omitempty"`
	RXPower         *float64 `json:"rx_pwr,omitempty"`
	AIndex          *float64 `json:"a_index,omitempty"`
	KIndex          *float64 `json:"k_index,omitempty"`
	SFI             *float64 `json:"sfi,omitempty"`
	SKCC            string   `json:"skcc,omitempty"`
	FISTS           string   `json:"fists,omitempty"`
	TenTen          string   `json:"ten_ten,omitempty"`
	ContactedOp     string   `json:"contacted_op,omitempty"`
	EqCall          string   `json:"eq_call,omitempty"`
	PFX             string   `json:"pfx,omitempty"`
	MyName          string   `json:"my_name,omitempty"`
	Class           string   `json:"class,omitempty"`
	DarcDOK         string   `json:"darc_dok,omitempty"`
	MyDarcDOK       string   `json:"my_darc_dok,omitempty"`
	Region          string   `json:"region,omitempty"`
	SilentKey       string   `json:"silent_key,omitempty"`
	SWL             string   `json:"swl,omitempty"`
	QSOComplete     string   `json:"qso_complete,omitempty"`
	QSORandom       string   `json:"qso_random,omitempty"`
	CreditGranted   string   `json:"credit_granted,omitempty"`
	CreditSubmitted string   `json:"credit_submitted,omitempty"`
	MyARRLSect      string   `json:"my_arrl_sect,omitempty"`
	MyVUCCGrids     string   `json:"my_vucc_grids,omitempty"`

	// Extras holds ADIF fields not promoted to columns. Keys are uppercase
	// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
	Extras map[string]string `json:"extras,omitempty"`

	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

// ListFilter filters a search in the logbook.
// Any zero/nil field is ignored.
type ListFilter struct {
	Callsign        string `json:"callsign,omitempty"`
	Band            string `json:"band,omitempty"`
	Mode            string `json:"mode,omitempty"`
	StationCallsign string `json:"station_callsign,omitempty"`
	Limit           int    `json:"limit,omitempty"`
	Offset          int    `json:"offset,omitempty"`
}

// Repo accesses the qso table.
type Repo struct {
	db *sql.DB
}

// NewRepo builds a QSO repo on the given connection.
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }

const isoMillis = "2006-01-02T15:04:05.000Z"

// columnList is the canonical list of columns in declaration order. It is
// used by both read and write queries so they stay in sync.
const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submode, freq_hz, freq_rx_hz,
    rst_sent, rst_rcvd,
    name, qth, address, email, web,
    grid, gridsquare_ext, vucc_grids,
    country, state, cnty, dxcc, cont, cqz, ituz, iota, sota_ref, pota_ref,
    age, lat, lon, rig, ant,
    qsl_sent, qsl_rcvd, qsl_sent_date, qsl_rcvd_date, qsl_via, qsl_msg, qslmsg_rcvd,
    lotw_sent, lotw_rcvd, lotw_sent_date, lotw_rcvd_date,
    eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
    clublog_qso_upload_date, clublog_qso_upload_status,
    hrdlog_qso_upload_date, hrdlog_qso_upload_status,
    qrzcom_qso_upload_date, qrzcom_qso_upload_status,
    qrzcom_qso_download_date, qrzcom_qso_download_status,
    contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
    prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
    station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
    my_sota_ref, my_pota_ref, my_dxcc, my_cq_zone, my_itu_zone, my_lat, my_lon,
    my_street, my_city, my_postal_code, my_rig, my_antenna,
    tx_pwr, comment, notes,
    sig, sig_info, my_sig, my_sig_info, wwff_ref, my_wwff_ref,
    distance, rx_pwr, a_index, k_index, sfi,
    skcc, fists, ten_ten, contacted_op, eq_call, pfx, my_name, class,
    darc_dok, my_darc_dok, region, silent_key, swl, qso_complete, qso_random,
    credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
    extras_json`

const selectCols = `id, ` + columnList + `, created_at, updated_at`

// columnCount is derived from columnList at init so they can never drift.
var columnCount = countColumns(columnList)

// insertPlaceholders returns "?,?,?,..." matching columnCount.
var insertPlaceholders = buildInsertPlaceholders()

// insertCols/insertVals append created_at + updated_at so they're set from Go
// (NowISO) on every backend — MySQL has no strftime default, and binding the
// timestamp keeps a single backend-agnostic INSERT. insertArgs pairs with them.
const insertCols = columnList + `, created_at, updated_at`

var insertVals = insertPlaceholders + ",?,?"

// insertArgs returns the column values plus the two timestamps for an INSERT.
func (q *QSO) insertArgs() []any {
	now := db.NowISO()
	return append(q.args(), now, now)
}

func countColumns(s string) int {
	n := 1
	for i := 0; i < len(s); i++ {
		if s[i] == ',' {
			n++
		}
	}
	return n
}

func buildInsertPlaceholders() string {
	out := make([]byte, 0, 2*columnCount)
	for i := 0; i < columnCount; i++ {
		if i > 0 {
			out = append(out, ',')
		}
		out = append(out, '?')
	}
	return string(out)
}

// args returns the QSO field values in the same order as columnList.
func (q *QSO) args() []any {
	extras := encodeExtras(q.Extras)
	return []any{
		q.Callsign, q.QSODate.UTC().Format(isoMillis), nullDateTime(q.QSODateOff), q.Band, q.BandRX, q.Mode, q.Submode, q.FreqHz, q.FreqRXHz,
		q.RSTSent, q.RSTRcvd,
		q.Name, q.QTH, q.Address, q.Email, q.Web,
		q.Grid, q.GridExt, q.VUCCGrids,
		q.Country, q.State, q.County, q.DXCC, q.Continent, q.CQZ, q.ITUZ, q.IOTA, q.SOTARef, q.POTARef,
		q.Age, q.Lat, q.Lon, q.Rig, q.Ant,
		q.QSLSent, q.QSLRcvd, q.QSLSentDate, q.QSLRcvdDate, q.QSLVia, q.QSLMsg, q.QSLMsgRcvd,
		q.LOTWSent, q.LOTWRcvd, q.LOTWSentDate, q.LOTWRcvdDate,
		q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
		q.ClublogUploadDate, q.ClublogUploadStatus,
		q.HRDLogUploadDate, q.HRDLogUploadStatus,
		q.QRZComUploadDate, q.QRZComUploadStatus,
		q.QRZComDownloadDate, q.QRZComDownloadStatus,
		q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
		q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
		q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
		q.MySOTARef, q.MyPOTARef, q.MyDXCC, q.MyCQZone, q.MyITUZone, q.MyLat, q.MyLon,
		q.MyStreet, q.MyCity, q.MyPostalCode, q.MyRig, q.MyAntenna,
		q.TXPower, q.Comment, q.Notes,
		q.SIG, q.SIGInfo, q.MySIG, q.MySIGInfo, q.WWFFRef, q.MyWWFFRef,
		q.Distance, q.RXPower, q.AIndex, q.KIndex, q.SFI,
		q.SKCC, q.FISTS, q.TenTen, q.ContactedOp, q.EqCall, q.PFX, q.MyName, q.Class,
		q.DarcDOK, q.MyDarcDOK, q.Region, q.SilentKey, q.SWL, q.QSOComplete, q.QSORandom,
		q.CreditGranted, q.CreditSubmitted, q.MyARRLSect, q.MyVUCCGrids,
		extras,
	}
}

// nullDateTime renders a time.Time as ISO UTC or nil if zero.
func nullDateTime(t time.Time) any {
	if t.IsZero() {
		return nil
	}
	return t.UTC().Format(isoMillis)
}

// encodeExtras returns a JSON-encoded string or nil if empty.
func encodeExtras(m map[string]string) any {
	if len(m) == 0 {
		return nil
	}
	b, err := json.Marshal(m)
	if err != nil {
		return nil
	}
	return string(b)
}

func decodeExtras(s string) map[string]string {
	if s == "" {
		return nil
	}
	var m map[string]string
	if err := json.Unmarshal([]byte(s), &m); err != nil {
		return nil
	}
	return m
}

// Add inserts a QSO and returns its ID.
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
	q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
	if q.Callsign == "" {
		return 0, fmt.Errorf("empty callsign")
	}
	if q.QSODate.IsZero() {
		q.QSODate = time.Now().UTC()
	}
	res, err := r.db.ExecContext(ctx,
		`INSERT INTO qso (`+insertCols+`) VALUES (`+insertVals+`)`,
		q.insertArgs()...)
	if err != nil {
		return 0, fmt.Errorf("insert qso: %w", err)
	}
	return res.LastInsertId()
}

// batchInsertRows is how many QSOs go into one multi-row INSERT on MySQL. Each
// row carries ~135 columns, so 200 rows ≈ 27k bound parameters — well under
// MySQL's 65535-placeholder limit and a modest packet — while cutting network
// round-trips ~200× versus one INSERT per row (critical for a remote server).
const batchInsertRows = 200

// AddBatch inserts many QSOs inside a single transaction. Empty-callsign records
// are skipped. On MySQL it uses chunked multi-row INSERTs so a 27k-record import
// over a remote link takes seconds, not many minutes; on local SQLite a prepared
// statement per row is already fast.
func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
	if len(qsos) == 0 {
		return 0, nil
	}
	// Normalise and drop empty-callsign records up front.
	rows := make([]QSO, 0, len(qsos))
	for _, q := range qsos {
		q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
		if q.Callsign == "" {
			continue
		}
		if q.QSODate.IsZero() {
			q.QSODate = time.Now().UTC()
		}
		rows = append(rows, q)
	}
	if len(rows) == 0 {
		return 0, nil
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return 0, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	var inserted int64
	if db.IsMySQL() {
		rowPlaceholder := "(" + insertVals + ")"
		for start := 0; start < len(rows); start += batchInsertRows {
			end := start + batchInsertRows
			if end > len(rows) {
				end = len(rows)
			}
			chunk := rows[start:end]
			var sb strings.Builder
			sb.WriteString("INSERT INTO qso (")
			sb.WriteString(insertCols)
			sb.WriteString(") VALUES ")
			args := make([]any, 0, len(chunk)*(columnCount+2))
			for j := range chunk {
				if j > 0 {
					sb.WriteByte(',')
				}
				sb.WriteString(rowPlaceholder)
				args = append(args, chunk[j].insertArgs()...)
			}
			if _, err := tx.ExecContext(ctx, sb.String(), args...); err != nil {
				return inserted, fmt.Errorf("batch insert: %w", err)
			}
			inserted += int64(len(chunk))
		}
	} else {
		stmt, err := tx.PrepareContext(ctx,
			`INSERT INTO qso (`+insertCols+`) VALUES (`+insertVals+`)`)
		if err != nil {
			return 0, fmt.Errorf("prepare batch insert: %w", err)
		}
		defer stmt.Close()
		for i := range rows {
			if _, err := stmt.ExecContext(ctx, rows[i].insertArgs()...); err != nil {
				return inserted, fmt.Errorf("insert qso %q: %w", rows[i].Callsign, err)
			}
			inserted++
		}
	}
	if err := tx.Commit(); err != nil {
		return 0, fmt.Errorf("commit batch: %w", err)
	}
	return inserted, nil
}

// Revision returns a cheap fingerprint of the logbook — row count and the
// highest id — so a client can poll for changes made by OTHER instances on a
// shared MySQL logbook and refresh when it differs. Inserts bump the max id;
// deletes change the count.
func (r *Repo) Revision(ctx context.Context) (string, error) {
	var count, maxID int64
	if err := r.db.QueryRowContext(ctx,
		`SELECT COUNT(*), COALESCE(MAX(id), 0) FROM qso`).Scan(&count, &maxID); err != nil {
		return "", err
	}
	return fmt.Sprintf("%d:%d", count, maxID), nil
}

// GetByID fetches a single QSO by primary key.
func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
	row := r.db.QueryRowContext(ctx,
		`SELECT `+selectCols+` FROM qso WHERE id = ?`, id)
	return scanQSO(row)
}

// UploadRow is a lightweight QSO projection for the QSL Manager grid.
type UploadRow struct {
	ID       int64  `json:"id"`
	QSODate  string `json:"qso_date"` // ISO UTC; the UI formats it
	Callsign string `json:"callsign"`
	Band     string `json:"band"`
	Mode     string `json:"mode"`
	Country  string `json:"country"`
	Status   string `json:"status"` // the matched per-service sent status
}

// uploadStatusCols whitelists the per-service sent-status columns the QSL
// Manager may filter on (guards the dynamic column name in the query).
var uploadStatusCols = map[string]bool{
	"lotw_sent":                 true,
	"eqsl_sent":                 true,
	"qrzcom_qso_upload_status":  true,
	"clublog_qso_upload_status": true,
	"hrdlog_qso_upload_status":  true,
}

// ListForUpload returns QSOs whose per-service sent-status column equals
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
// ListForUploadFull is like ListForUpload but returns FULL QSO rows so the UI
// can show the same rich, column-pickable table as Recent QSOs. column is an
// upload-status column (validated); value is the status to match ("" = not yet
// uploaded).
func (r *Repo) ListForUploadFull(ctx context.Context, column, value string) ([]QSO, error) {
	if !uploadStatusCols[column] {
		return nil, fmt.Errorf("invalid upload column %q", column)
	}
	rows, err := r.db.QueryContext(ctx,
		`SELECT `+selectCols+` FROM qso WHERE COALESCE(`+column+`,'') = ? ORDER BY qso_date DESC, id DESC`, value)
	if err != nil {
		return nil, fmt.Errorf("list for upload: %w", err)
	}
	defer rows.Close()
	out := make([]QSO, 0, 64)
	for rows.Next() {
		q, err := scanQSO(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, q)
	}
	return out, rows.Err()
}

func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
	if !uploadStatusCols[column] {
		return nil, fmt.Errorf("invalid upload column %q", column)
	}
	rows, err := r.db.QueryContext(ctx,
		`SELECT id, qso_date, callsign, COALESCE(band,''), COALESCE(mode,''),
		        COALESCE(country,''), COALESCE(`+column+`,'')
		   FROM qso WHERE COALESCE(`+column+`,'') = ?
		  ORDER BY qso_date DESC`, value)
	if err != nil {
		return nil, fmt.Errorf("list for upload: %w", err)
	}
	defer rows.Close()
	var out []UploadRow
	for rows.Next() {
		var u UploadRow
		if err := rows.Scan(&u.ID, &u.QSODate, &u.Callsign, &u.Band, &u.Mode, &u.Country, &u.Status); err != nil {
			return nil, err
		}
		out = append(out, u)
	}
	return out, rows.Err()
}

// UploadCandidate is a QSO eligible for an on-close upload: its id plus its
// STATION_CALLSIGN, so the caller can keep only the rows that belong to the
// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not
// all be signed under one cert).
type UploadCandidate struct {
	ID             int64
	StationCallsign string
}

// ListUploadCandidates returns QSOs eligible for an on-close upload to a
// service, scanning the whole logbook. For LoTW (column "lotw_sent"), statuses
// is the set of sent-status values to treat as "to send" (e.g. N, R); rows
// already "Y" are excluded. For QRZ/Club Log, statuses is ignored and anything
// whose upload status isn't yet "Y" qualifies.
func (r *Repo) ListUploadCandidates(ctx context.Context, column string, statuses []string) ([]UploadCandidate, error) {
	if !uploadStatusCols[column] {
		return nil, fmt.Errorf("invalid upload column %q", column)
	}
	var where string
	var args []any
	if column == "lotw_sent" {
		if len(statuses) == 0 {
			return nil, nil
		}
		ph := make([]string, len(statuses))
		for i, s := range statuses {
			ph[i] = "?"
			args = append(args, strings.ToUpper(strings.TrimSpace(s)))
		}
		where = "UPPER(COALESCE(lotw_sent,'')) IN (" + strings.Join(ph, ",") + ")"
	} else {
		where = "UPPER(COALESCE(" + column + ",'')) <> 'Y'"
	}
	rows, err := r.db.QueryContext(ctx,
		`SELECT id, COALESCE(station_callsign,'') FROM qso WHERE `+where+` ORDER BY qso_date`, args...)
	if err != nil {
		return nil, fmt.Errorf("list upload candidates: %w", err)
	}
	defer rows.Close()
	var out []UploadCandidate
	for rows.Next() {
		var c UploadCandidate
		if err := rows.Scan(&c.ID, &c.StationCallsign); err != nil {
			return nil, err
		}
		out = append(out, c)
	}
	return out, rows.Err()
}

// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
// rewrite — so it's safe to call from the async upload path.
func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?,
		   updated_at = ? WHERE id = ?`,
		date, db.NowISO(), id)
	if err != nil {
		return fmt.Errorf("mark qrz uploaded %d: %w", id, err)
	}
	return nil
}

// MarkClublogUploaded stamps CLUBLOG_QSO_UPLOAD_STATUS=Y and the upload
// date after a successful Club Log push. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?,
		   updated_at = ? WHERE id = ?`,
		date, db.NowISO(), id)
	if err != nil {
		return fmt.Errorf("mark clublog uploaded %d: %w", id, err)
	}
	return nil
}

// MarkHRDLogUploaded stamps HRDLOG_QSO_UPLOAD_STATUS=Y and the upload date
// after a successful HRDLog.net push. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkHRDLogUploaded(ctx context.Context, id int64, date string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET hrdlog_qso_upload_status = 'Y', hrdlog_qso_upload_date = ?,
		   updated_at = ? WHERE id = ?`,
		date, db.NowISO(), id)
	if err != nil {
		return fmt.Errorf("mark hrdlog uploaded %d: %w", id, err)
	}
	return nil
}

// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a
// successful TQSL upload. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?,
		   updated_at = ? WHERE id = ?`,
		date, db.NowISO(), id)
	if err != nil {
		return fmt.Errorf("mark lotw uploaded %d: %w", id, err)
	}
	return nil
}

// MarkUploadedBatch sets <statusCol>='Y' and <dateCol>=date on EVERY id in one
// UPDATE — used by bulk upload (Club Log / HRDLog) so a 25k-QSO run isn't one
// round-trip per QSO on a remote MySQL. statusCol/dateCol come from a fixed
// whitelist (not user input), so the column interpolation is safe.
func (r *Repo) MarkUploadedBatch(ctx context.Context, statusCol, dateCol, date string, ids []int64) error {
	if len(ids) == 0 {
		return nil
	}
	ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
	args := make([]any, 0, len(ids)+2)
	args = append(args, date, db.NowISO())
	for _, id := range ids {
		args = append(args, id)
	}
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
		args...)
	if err != nil {
		return fmt.Errorf("mark uploaded batch (%d): %w", len(ids), err)
	}
	return nil
}

// MarkEQSLSent stamps EQSL_QSL_SENT=Y and the sent date after a successful
// eQSL e-mail. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET eqsl_sent = 'Y', eqsl_sent_date = ?,
		   updated_at = ? WHERE id = ?`,
		date, db.NowISO(), id)
	if err != nil {
		return fmt.Errorf("mark eqsl sent %d: %w", id, err)
	}
	return nil
}

// bulkEditableCols whitelists the columns BulkSetField may write. Limited to
// TEXT fields where setting one value across many QSOs is meaningful: the
// per-service QSL/upload status fields, plus "my station"/operator fields that
// are naturally constant across a run (grid, antenna, rig, address, …). It
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the
// contacted station's details) and numeric columns (power, zones, lat/lon),
// which would be corrupted or meaningless if bulk-set to a single value.
var bulkEditableCols = map[string]bool{
	// QSL / upload status
	"lotw_sent":                 true,
	"lotw_rcvd":                 true,
	"eqsl_sent":                 true,
	"eqsl_rcvd":                 true,
	"qsl_sent":                  true,
	"qsl_rcvd":                  true,
	"qsl_via":                   true,
	"qrzcom_qso_upload_status":  true,
	"clublog_qso_upload_status": true,
	"hrdlog_qso_upload_status":  true,
	// My station / operator
	"station_callsign": true,
	"operator":         true,
	"my_grid":          true,
	"my_country":       true,
	"my_state":         true,
	"my_cnty":          true,
	"my_iota":          true,
	"my_sota_ref":      true,
	"my_pota_ref":      true,
	"my_wwff_ref":      true,
	"my_street":        true,
	"my_city":          true,
	"my_postal_code":   true,
	"my_rig":           true,
	"my_antenna":       true,
	"my_sig":           true,
	"my_sig_info":      true,
	"my_name":          true,
	"my_arrl_sect":     true,
	"my_darc_dok":      true,
	"my_vucc_grids":    true,
	// Contest — the exchange/label fields that are constant across a run.
	// (srx/stx serial numbers stay excluded: they are per-QSO.)
	"contest_id": true,
	"srx_string": true,
	"stx_string": true,
	"arrl_sect":  true,
	"precedence": true,
	"class":      true,
	// Propagation / satellite — usually identical across a session.
	"prop_mode": true,
	"sat_name":  true,
	"sat_mode":  true,
	// Contacted station activation refs / SIG — meaningful to set across a
	// selected batch (e.g. a park/summit worked repeatedly).
	"pota_ref": true,
	"sota_ref": true,
	"wwff_ref": true,
	"iota":     true,
	"sig":      true,
	"sig_info": true,
	// Misc text
	"comment": true,
	"notes":   true,
	"rig":     true,
	"ant":     true,
}

// BulkSetField sets one whitelisted column to value on every listed QSO in a
// single statement. value "" clears the field. Returns rows affected.
func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value string) (int64, error) {
	if !bulkEditableCols[column] {
		return 0, fmt.Errorf("field %q is not bulk-editable", column)
	}
	if len(ids) == 0 {
		return 0, nil
	}
	ph := make([]string, len(ids))
	args := make([]any, 0, len(ids)+2)
	args = append(args, value, db.NowISO())
	for i, id := range ids {
		ph[i] = "?"
		args = append(args, id)
	}
	res, err := r.db.ExecContext(ctx,
		`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
		args...)
	if err != nil {
		return 0, fmt.Errorf("bulk set %s: %w", column, err)
	}
	n, _ := res.RowsAffected()
	return n, nil
}

// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
func (r *Repo) Update(ctx context.Context, q QSO) error {
	if q.ID == 0 {
		return fmt.Errorf("missing id")
	}
	q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
	if q.Callsign == "" {
		return fmt.Errorf("empty callsign")
	}
	if q.QSODate.IsZero() {
		q.QSODate = time.Now().UTC()
	}
	setClause := buildUpdateSetClause()
	args := append(q.args(), db.NowISO(), q.ID)
	res, err := r.db.ExecContext(ctx,
		`UPDATE qso SET `+setClause+`, updated_at = ? WHERE id = ?`,
		args...)
	if err != nil {
		return fmt.Errorf("update qso %d: %w", q.ID, err)
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return sql.ErrNoRows
	}
	return nil
}

// buildUpdateSetClause turns "col1, col2, …" into "col1 = ?, col2 = ?, …".
func buildUpdateSetClause() string {
	// Quick split — columnList has no escaped commas.
	var out []byte
	col := []byte{}
	flush := func() {
		if len(col) == 0 {
			return
		}
		if len(out) > 0 {
			out = append(out, ',', ' ')
		}
		// trim leading whitespace
		i := 0
		for i < len(col) && (col[i] == ' ' || col[i] == '\n' || col[i] == '\t') {
			i++
		}
		out = append(out, col[i:]...)
		out = append(out, ' ', '=', ' ', '?')
		col = col[:0]
	}
	for i := 0; i < len(columnList); i++ {
		c := columnList[i]
		if c == ',' {
			flush()
			continue
		}
		col = append(col, c)
	}
	flush()
	return string(out)
}

// DeleteAll wipes every QSO from the logbook. Returns the number of rows
// removed. Use with care — there is no recovery short of a DB backup.
// Runs in a transaction and VACUUMs afterwards so the file actually shrinks.
func (r *Repo) DeleteAll(ctx context.Context) (int64, error) {
	res, err := r.db.ExecContext(ctx, `DELETE FROM qso`)
	if err != nil {
		return 0, fmt.Errorf("delete all qso: %w", err)
	}
	n, _ := res.RowsAffected()
	// VACUUM is run outside any transaction; reclaims pages so the .db file
	// stops being a 200 MB ghost after a 25k-row wipe.
	if _, err := r.db.ExecContext(ctx, `VACUUM`); err != nil {
		// Non-fatal: rows are gone, just file isn't shrunk. Surface the err.
		return n, fmt.Errorf("delete ok (%d rows) but vacuum failed: %w", n, err)
	}
	return n, nil
}

// DeleteMany removes several QSOs in one statement. Returns the number deleted.
func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) {
	if len(ids) == 0 {
		return 0, nil
	}
	ph := make([]string, len(ids))
	args := make([]any, len(ids))
	for i, id := range ids {
		ph[i] = "?"
		args[i] = id
	}
	res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+strings.Join(ph, ",")+`)`, args...)
	if err != nil {
		return 0, fmt.Errorf("delete qsos: %w", err)
	}
	n, _ := res.RowsAffected()
	return n, nil
}

// Delete removes a QSO by id.
func (r *Repo) Delete(ctx context.Context, id int64) error {
	res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
	if err != nil {
		return fmt.Errorf("delete qso %d: %w", id, err)
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return sql.ErrNoRows
	}
	return nil
}

// List returns QSOs matching the filter, sorted by date desc.
func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
	q := `SELECT ` + selectCols + ` FROM qso WHERE 1=1`
	args := []any{}
	if f.Callsign != "" {
		// Contains-match so a search for "XYZ" finds F4XYZ, F4XYZ/P, etc.
		q += " AND callsign LIKE ?"
		args = append(args, "%"+f.Callsign+"%")
	}
	if f.Band != "" {
		q += " AND band = ?"
		args = append(args, f.Band)
	}
	if f.Mode != "" {
		q += " AND mode = ?"
		args = append(args, f.Mode)
	}
	if f.StationCallsign != "" {
		q += " AND station_callsign = ?"
		args = append(args, f.StationCallsign)
	}
	q += " ORDER BY qso_date DESC, id DESC"
	if f.Limit <= 0 {
		f.Limit = 500
	}
	// Hard upper bound: 1M is enough to fit any realistic personal log.
	if f.Limit > 1_000_000 {
		f.Limit = 1_000_000
	}
	q += " LIMIT ? OFFSET ?"
	args = append(args, f.Limit, f.Offset)

	rows, err := r.db.QueryContext(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("query qso: %w", err)
	}
	defer rows.Close()

	out := make([]QSO, 0, 64)
	for rows.Next() {
		q, err := scanQSO(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, q)
	}
	return out, rows.Err()
}

// ── Advanced filter builder ──────────────────────────────────────────────
//
// QueryFilter powers the UI's filter builder: a list of field/operator/value
// conditions joined by AND or OR, plus an always-ANDed quick callsign search.
// Every field is validated against filterableColumns so user input can never
// reach the SQL string — only parameterised values do.

// Condition is one "field OP value" clause.
type Condition struct {
	Field string `json:"field"` // db column name (validated against whitelist)
	Op    string `json:"op"`    // eq|ne|gt|lt|ge|le|contains|startswith|endswith|empty|notempty
	Value string `json:"value"`
}

// QueryFilter is a full filter expression.
type QueryFilter struct {
	QuickCallsign string      `json:"quick_callsign,omitempty"` // always-ANDed contains-match
	Conditions    []Condition `json:"conditions,omitempty"`
	Match         string      `json:"match,omitempty"` // "AND" (default) | "OR"
	Limit         int         `json:"limit,omitempty"`
	Offset        int         `json:"offset,omitempty"`
}

// filterableColumns whitelists the columns the filter builder may reference.
// Keep field names identical to DB columns so the frontend can send them
// directly; anything not in this set is rejected.
var filterableColumns = map[string]bool{
	"callsign": true, "qso_date": true, "qso_date_off": true, "band": true, "band_rx": true,
	"mode": true, "submode": true, "freq_hz": true, "freq_rx_hz": true,
	"rst_sent": true, "rst_rcvd": true,
	"name": true, "qth": true, "address": true, "email": true,
	"grid": true, "country": true, "state": true, "cnty": true,
	"dxcc": true, "cont": true, "cqz": true, "ituz": true,
	"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
	"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
	"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
	"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
	"contest_id": true, "srx": true, "stx": true,
	"prop_mode": true, "sat_name": true,
	"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
	"my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true,
	"my_wwff_ref": true, "my_street": true, "my_city": true, "my_postal_code": true,
	"my_rig": true, "my_antenna": true, "my_sig": true, "my_sig_info": true,
	"tx_pwr": true, "comment": true, "notes": true,
}

// dateColumns are stored as full ISO timestamps; a filter on a bare YYYY-MM-DD
// value compares on the date part (see conditionSQL) so day filters are exact.
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}

// bareDateRe matches a plain calendar date with no time component.
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)

// filterableExtras whitelists virtual filter fields stored inside extras_json
// (valid ADIF fields we don't promote to columns). The value is the uppercase
// ADIF/Extras key; the SQL expression uses json_extract.
var filterableExtras = map[string]string{
	"owner_callsign": "OWNER_CALLSIGN",
}

// FilterableFields returns the whitelist (for the frontend to build its field
// dropdown and stay in sync with the backend).
func FilterableFields() []string {
	out := make([]string, 0, len(filterableColumns)+len(filterableExtras))
	for c := range filterableColumns {
		out = append(out, c)
	}
	for c := range filterableExtras {
		out = append(out, c)
	}
	sort.Strings(out)
	return out
}

// columnExpr resolves a filter field to a safe SQL expression — either a
// OfflineQueueKey is the ADIF extras key stamped on a QSO that was parked in the
// offline safety file. It survives the ADIF round-trip and makes the replay
// IDEMPOTENT: if the app dies between "inserted into the DB" and "removed from
// the file", the next replay sees the id already in the log and skips it instead
// of creating a duplicate.
const OfflineQueueKey = "APP_OPSLOG_QUEUEID"

// ExistsByQueueID reports whether a QSO carrying this offline-queue id is already
// in the logbook — the guard that makes replaying the safety file safe to repeat.
func (r *Repo) ExistsByQueueID(ctx context.Context, qid string) (bool, error) {
	qid = strings.TrimSpace(qid)
	if qid == "" {
		return false, nil
	}
	expr := "json_extract(extras_json, '$." + OfflineQueueKey + "')"
	if db.IsMySQL() {
		expr = "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + OfflineQueueKey + "'))"
	}
	var n int
	if err := r.db.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM qso WHERE `+expr+` = ?`, qid).Scan(&n); err != nil {
		return false, err
	}
	return n > 0, nil
}

// whitelisted column name or a json_extract over extras_json.
func columnExpr(field string) (string, bool) {
	f := strings.ToLower(strings.TrimSpace(field))
	if filterableColumns[f] {
		return f, true
	}
	if key, ok := filterableExtras[f]; ok {
		if db.IsMySQL() {
			// JSON_EXTRACT errors on an invalid/empty document, so guard with
			// NULLIF; JSON_UNQUOTE strips the quotes MySQL adds around strings.
			return "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + key + "'))", true
		}
		return "json_extract(extras_json, '$." + key + "')", true
	}
	return "", false
}

// conditionSQL turns one condition into a parameterised predicate.
func conditionSQL(c Condition) (string, []any, error) {
	col, ok := columnExpr(c.Field)
	if !ok {
		return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
	}
	v := c.Value
	// Date columns hold full ISO timestamps ("2020-01-01T12:34:56.000Z"). When
	// the user filters on a bare calendar date, compare only the date part so
	// "= 2020-01-01" matches that whole day and "<= 2020-12-31" includes it
	// (a raw string compare would otherwise drop times on the boundary day).
	if dateColumns[strings.ToLower(strings.TrimSpace(c.Field))] && bareDateRe.MatchString(strings.TrimSpace(v)) {
		col = "substr(" + col + ",1,10)"
		v = strings.TrimSpace(v)
	}
	switch c.Op {
	case "eq":
		return col + " = ?", []any{v}, nil
	case "ne":
		return col + " <> ?", []any{v}, nil
	case "gt":
		return col + " > ?", []any{v}, nil
	case "lt":
		return col + " < ?", []any{v}, nil
	case "ge":
		return col + " >= ?", []any{v}, nil
	case "le":
		return col + " <= ?", []any{v}, nil
	case "contains":
		return col + " LIKE ?", []any{"%" + v + "%"}, nil
	case "startswith":
		return col + " LIKE ?", []any{v + "%"}, nil
	case "endswith":
		return col + " LIKE ?", []any{"%" + v}, nil
	case "empty":
		return "IFNULL(" + col + ",'') = ''", nil, nil
	case "notempty":
		return "IFNULL(" + col + ",'') <> ''", nil, nil
	default:
		return "", nil, fmt.Errorf("unknown operator %q", c.Op)
	}
}

// buildWhere assembles the predicate (everything after WHERE) + args.
func buildWhere(f QueryFilter) (string, []any, error) {
	pred := "1=1"
	var args []any
	if qc := strings.TrimSpace(f.QuickCallsign); qc != "" {
		pred += " AND callsign LIKE ?"
		args = append(args, "%"+qc+"%")
	}
	if len(f.Conditions) > 0 {
		joiner := " AND "
		if strings.EqualFold(strings.TrimSpace(f.Match), "OR") {
			joiner = " OR "
		}
		parts := make([]string, 0, len(f.Conditions))
		for _, c := range f.Conditions {
			if strings.TrimSpace(c.Field) == "" {
				continue
			}
			p, a, err := conditionSQL(c)
			if err != nil {
				return "", nil, err
			}
			parts = append(parts, p)
			args = append(args, a...)
		}
		if len(parts) > 0 {
			pred += " AND (" + strings.Join(parts, joiner) + ")"
		}
	}
	return pred, args, nil
}

// ListFiltered returns QSOs matching a QueryFilter, newest first, limited.
func (r *Repo) ListFiltered(ctx context.Context, f QueryFilter) ([]QSO, error) {
	pred, args, err := buildWhere(f)
	if err != nil {
		return nil, err
	}
	q := `SELECT ` + selectCols + ` FROM qso WHERE ` + pred + ` ORDER BY qso_date DESC, id DESC`
	limit := f.Limit
	if limit <= 0 {
		limit = 500
	}
	if limit > 1_000_000 {
		limit = 1_000_000
	}
	q += " LIMIT ? OFFSET ?"
	args = append(args, limit, f.Offset)

	rows, err := r.db.QueryContext(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("query qso: %w", err)
	}
	defer rows.Close()
	out := make([]QSO, 0, 64)
	for rows.Next() {
		qrow, err := scanQSO(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, qrow)
	}
	return out, rows.Err()
}

// CountFiltered returns how many QSOs match a filter (ignoring limit/offset).
func (r *Repo) CountFiltered(ctx context.Context, f QueryFilter) (int64, error) {
	pred, args, err := buildWhere(f)
	if err != nil {
		return 0, err
	}
	var n int64
	err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM qso WHERE `+pred, args...).Scan(&n)
	return n, err
}

// IterateFiltered streams all QSOs matching a filter (no limit), chronological,
// for an ADIF export of "the current filtered view, no row limit".
func (r *Repo) IterateFiltered(ctx context.Context, f QueryFilter, fn func(QSO) error) error {
	pred, args, err := buildWhere(f)
	if err != nil {
		return err
	}
	rows, err := r.db.QueryContext(ctx,
		`SELECT `+selectCols+` FROM qso WHERE `+pred+` ORDER BY qso_date ASC, id ASC`, args...)
	if err != nil {
		return fmt.Errorf("query qso: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		q, err := scanQSO(rows)
		if err != nil {
			return err
		}
		if err := fn(q); err != nil {
			return err
		}
	}
	return rows.Err()
}

// IterateByIDs streams the QSOs with the given ids, chronological — for
// "export the rows I selected with the mouse".
func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error) error {
	if len(ids) == 0 {
		return nil
	}
	ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
	args := make([]any, len(ids))
	for i, id := range ids {
		args[i] = id
	}
	rows, err := r.db.QueryContext(ctx,
		`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...)
	if err != nil {
		return fmt.Errorf("query qso: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		q, err := scanQSO(rows)
		if err != nil {
			return err
		}
		if err := fn(q); err != nil {
			return err
		}
	}
	return rows.Err()
}

// WorkedBefore summarises prior contacts at two granularities:
//   - by exact callsign  → shown as "this call worked N×"
//   - by DXCC entity     → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
//
// In ham radio the meaningful "new one" is a new DXCC entity, not a brand-new
// callsign — most QSOs to known countries aren't worth flagging.
type WorkedBefore struct {
	Callsign string `json:"callsign"`

	// --- Per-callsign ---
	Count     int           `json:"count"`           // total prior QSOs with this call
	First     time.Time     `json:"first,omitempty"` // oldest call QSO date
	Last      time.Time     `json:"last,omitempty"`  // most recent call QSO date
	Bands     []string      `json:"bands"`           // distinct bands for this call
	Modes     []string      `json:"modes"`           // distinct modes for this call
	BandModes []BandMode    `json:"band_modes"`      // distinct (band, mode) pairs
	Entries   []QSO         `json:"entries"`         // up to maxWorkedEntries most recent (full records)

	// --- Per-DXCC entity (populated when DXCC is known) ---
	DXCC          int        `json:"dxcc,omitempty"`
	DXCCName      string     `json:"dxcc_name,omitempty"` // human country name (best effort)
	DXCCCount     int        `json:"dxcc_count"`
	DXCCFirst     time.Time  `json:"dxcc_first,omitempty"`
	DXCCLast      time.Time  `json:"dxcc_last,omitempty"`
	DXCCBands     []string   `json:"dxcc_bands"`
	DXCCModes     []string   `json:"dxcc_modes"`
	DXCCBandModes []BandMode `json:"dxcc_band_modes"`

	// Status grid driving the band×class matrix in the UI. One entry per
	// (band, class) where ANY QSO exists in this DXCC. Only the highest
	// status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).
	BandStatus []BandStatus `json:"band_status"`
}

// BandStatus is one cell in the worked-before grid.
type BandStatus struct {
	Band   string `json:"band"`   // ADIF lowercase band, e.g. "20m"
	Class  string `json:"class"`  // "PH" | "CW" | "DIG"
	Status string `json:"status"` // "call_c" | "call_w" | "dxcc_c" | "dxcc_w"
}

// modeClass collapses ADIF modes into the three buckets DXers care about.
// Anything not voice and not CW is treated as digital.
func modeClass(mode string) string {
	switch strings.ToUpper(mode) {
	case "SSB", "USB", "LSB", "AM", "FM", "DIGITALVOICE", "PHONE":
		return "PH"
	case "CW":
		return "CW"
	default:
		return "DIG"
	}
}

// BandMode is a (band, mode) pair used for the NEW SLOT check.
type BandMode struct {
	Band string `json:"band"`
	Mode string `json:"mode"`
}

// WorkedEntry is one prior contact row, lean enough to ship to the UI for
// rendering a recent-contacts mini-list.
const maxWorkedEntries = 50

// WorkedBefore returns aggregated history at both callsign and DXCC level.
// dxccHint lets the caller pass a known DXCC number (e.g. from a fresh QRZ
// lookup) when the call has never been worked. If 0, the DXCC is inferred
// from the most recent prior QSO with the same callsign.
func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int) (WorkedBefore, error) {
	wb := WorkedBefore{
		Callsign:      upperTrim(callsign),
		Bands:         []string{},
		Modes:         []string{},
		BandModes:     []BandMode{},
		Entries:       []QSO{},
		DXCCBands:     []string{},
		DXCCModes:     []string{},
		DXCCBandModes: []BandMode{},
	}
	if wb.Callsign == "" {
		return wb, nil
	}

	// ---- Per-callsign stats ----
	if err := r.db.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
		return wb, fmt.Errorf("count worked: %w", err)
	}
	if wb.Count > 0 {
		// Pull the full QSO records (same columns as the Recent QSOs list) so
		// the Worked-before grid can offer the same rich column picker.
		rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
            FROM qso WHERE upper(trim(callsign)) = ?
            ORDER BY qso_date DESC, id DESC
            LIMIT ?`, wb.Callsign, maxWorkedEntries)
		if err != nil {
			return wb, fmt.Errorf("query worked: %w", err)
		}
		bandsSet := map[string]struct{}{}
		modesSet := map[string]struct{}{}
		bmSet := map[string]BandMode{}
		for rows.Next() {
			q, err := scanQSO(rows)
			if err != nil {
				rows.Close()
				return wb, fmt.Errorf("scan worked: %w", err)
			}
			wb.Entries = append(wb.Entries, q)
			if q.Band != "" {
				bandsSet[q.Band] = struct{}{}
			}
			if q.Mode != "" {
				modesSet[q.Mode] = struct{}{}
			}
			if q.Band != "" && q.Mode != "" {
				bmSet[q.Band+"|"+q.Mode] = BandMode{Band: q.Band, Mode: q.Mode}
			}
			if wb.Last.IsZero() {
				wb.Last = q.QSODate
			}
			wb.First = q.QSODate
		}
		rows.Close()

		if wb.Count > maxWorkedEntries {
			var firstStr sql.NullString
			_ = r.db.QueryRowContext(ctx,
				`SELECT MIN(qso_date) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&firstStr)
			if firstStr.Valid {
				wb.First = parseTimeLoose(firstStr.String)
			}
		}
		for b := range bandsSet {
			wb.Bands = append(wb.Bands, b)
		}
		for m := range modesSet {
			wb.Modes = append(wb.Modes, m)
		}
		for _, bm := range bmSet {
			wb.BandModes = append(wb.BandModes, bm)
		}
		sortStrings(wb.Bands)
		sortStrings(wb.Modes)
	}

	// ---- Resolve DXCC ----
	dxcc := dxccHint
	if dxcc == 0 && wb.Count > 0 {
		// Take the most recent non-null DXCC from past QSOs with this call.
		var d sql.NullInt64
		_ = r.db.QueryRowContext(ctx, `
            SELECT dxcc FROM qso
            WHERE upper(trim(callsign)) = ? AND dxcc IS NOT NULL
            ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
		if d.Valid {
			dxcc = int(d.Int64)
		}
	}
	if dxcc <= 0 {
		return wb, nil
	}
	wb.DXCC = dxcc

	// Best-effort country name: latest non-null country for this DXCC.
	var name sql.NullString
	_ = r.db.QueryRowContext(ctx, `
        SELECT country FROM qso
        WHERE dxcc = ? AND country IS NOT NULL AND country != ''
        ORDER BY qso_date DESC LIMIT 1`, dxcc).Scan(&name)
	wb.DXCCName = name.String

	// ---- Per-DXCC stats ----
	if err := r.db.QueryRowContext(ctx, `
        SELECT COUNT(*), MIN(qso_date), MAX(qso_date)
        FROM qso WHERE dxcc = ?`, dxcc).Scan(
		&wb.DXCCCount,
		&nullableTimeScan{&wb.DXCCFirst},
		&nullableTimeScan{&wb.DXCCLast},
	); err != nil {
		return wb, fmt.Errorf("count dxcc: %w", err)
	}
	if wb.DXCCCount == 0 {
		return wb, nil
	}

	if err := r.collectDistinct(ctx, &wb.DXCCBands,
		`SELECT DISTINCT band FROM qso WHERE dxcc = ? AND band IS NOT NULL AND band != ''`, dxcc); err != nil {
		return wb, err
	}
	if err := r.collectDistinct(ctx, &wb.DXCCModes,
		`SELECT DISTINCT mode FROM qso WHERE dxcc = ? AND mode IS NOT NULL AND mode != ''`, dxcc); err != nil {
		return wb, err
	}
	bmRows, err := r.db.QueryContext(ctx,
		`SELECT DISTINCT band, mode FROM qso WHERE dxcc = ?
		  AND band IS NOT NULL AND band != ''
		  AND mode IS NOT NULL AND mode != ''`, dxcc)
	if err != nil {
		return wb, fmt.Errorf("dxcc band_modes: %w", err)
	}
	for bmRows.Next() {
		var b, m string
		if err := bmRows.Scan(&b, &m); err != nil {
			bmRows.Close()
			return wb, err
		}
		wb.DXCCBandModes = append(wb.DXCCBandModes, BandMode{Band: b, Mode: m})
	}
	bmRows.Close()
	sortStrings(wb.DXCCBands)
	sortStrings(wb.DXCCModes)

	// ---- Per-(band, class) status grid ----
	// One pass over every distinct (band, mode) in the DXCC, aggregating
	// "did this call work it?" and "was anything confirmed?" via MAX.
	// Status precedence: call_c > call_w > dxcc_c > dxcc_w.
	// Filter NULL/empty band+mode rows — they'd create a NULL group key
	// that Scan into *string can't handle and would error out the whole
	// WorkedBefore call, blanking the matrix in the UI.
	statusRows, err := r.db.QueryContext(ctx, `
        SELECT band, mode,
               MAX(CASE WHEN upper(trim(callsign)) = ? THEN 1 ELSE 0 END),
               MAX(CASE WHEN upper(trim(callsign)) = ?
                         AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
                        THEN 1 ELSE 0 END),
               MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
                        THEN 1 ELSE 0 END)
        FROM qso
        WHERE dxcc = ?
          AND band IS NOT NULL AND band != ''
          AND mode IS NOT NULL AND mode != ''
        GROUP BY band, mode`, wb.Callsign, wb.Callsign, dxcc)
	if err != nil {
		return wb, fmt.Errorf("band status: %w", err)
	}
	type cellKey struct{ band, class string }
	const (
		stDxccW = 0
		stDxccC = 1
		stCallW = 2
		stCallC = 3
	)
	best := map[cellKey]int{}
	for statusRows.Next() {
		var band, mode string
		var callW, callC, dxccConfirmed int
		if err := statusRows.Scan(&band, &mode, &callW, &callC, &dxccConfirmed); err != nil {
			statusRows.Close()
			return wb, fmt.Errorf("scan band status: %w", err)
		}
		code := stDxccW // row exists ⇒ entity worked at minimum
		if dxccConfirmed == 1 {
			code = stDxccC
		}
		if callW == 1 {
			code = stCallW
		}
		if callC == 1 {
			code = stCallC
		}
		k := cellKey{band: band, class: modeClass(mode)}
		if cur, ok := best[k]; !ok || code > cur {
			best[k] = code
		}
	}
	statusRows.Close()
	codeStr := [...]string{"dxcc_w", "dxcc_c", "call_w", "call_c"}
	for k, code := range best {
		wb.BandStatus = append(wb.BandStatus, BandStatus{
			Band: k.band, Class: k.class, Status: codeStr[code],
		})
	}
	return wb, nil
}

// collectDistinct fills *out with the first column of every row.
func (r *Repo) collectDistinct(ctx context.Context, out *[]string, query string, args ...any) error {
	rows, err := r.db.QueryContext(ctx, query, args...)
	if err != nil {
		return fmt.Errorf("distinct: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var s string
		if err := rows.Scan(&s); err != nil {
			return err
		}
		*out = append(*out, s)
	}
	return rows.Err()
}

// nullableTimeScan adapts a *time.Time to sql.Scanner for fields that may
// be NULL in aggregate queries (MIN/MAX over an empty subset returns NULL).
type nullableTimeScan struct {
	dst *time.Time
}

func (n *nullableTimeScan) Scan(src any) error {
	if src == nil {
		return nil
	}
	switch v := src.(type) {
	case string:
		*n.dst = parseTimeLoose(v)
	case []byte:
		*n.dst = parseTimeLoose(string(v))
	}
	return nil
}

func upperTrim(s string) string {
	out := []byte(s)
	// strings.TrimSpace + ToUpper without importing strings for this micro-helper.
	start, end := 0, len(out)
	for start < end && out[start] <= ' ' {
		start++
	}
	for end > start && out[end-1] <= ' ' {
		end--
	}
	for i := start; i < end; i++ {
		if out[i] >= 'a' && out[i] <= 'z' {
			out[i] -= 32
		}
	}
	return string(out[start:end])
}

func sortStrings(s []string) {
	// Tiny insertion sort: typical worked-before slices are < 20 items.
	for i := 1; i < len(s); i++ {
		for j := i; j > 0 && s[j-1] > s[j]; j-- {
			s[j-1], s[j] = s[j], s[j-1]
		}
	}
}

// IterateAll streams every QSO in the database through fn, ordered by
// qso_date ascending so an ADIF export is chronological. Constant memory
// regardless of table size — the alternative (loading all 25k+ rows into
// a slice) wastes ~20MB for no good reason.
func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
	rows, err := r.db.QueryContext(ctx,
		`SELECT `+selectCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
	if err != nil {
		return fmt.Errorf("query qso: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		q, err := scanQSO(rows)
		if err != nil {
			return err
		}
		if err := fn(q); err != nil {
			return err
		}
	}
	return rows.Err()
}

// awardCols is the column set the award engine can read — every field reachable
// from refField/inScope/confirmed in internal/award plus enrichQSOForAwards:
// the reference fields (dxcc, zones, continent, country, state, grid/vucc, iota,
// sota, pota, wwff via extras_json, and the free-text fields name/qth/address/
// comment/notes a custom award may key on), band/mode/qso_date for scoping,
// freq_hz for band recovery, and the qsl/lotw/eqsl rcvd confirmation flags.
// This drops ~125 unused columns so award computation over a remote MySQL
// backend ships a fraction of each row instead of the whole record.
//
// IMPORTANT: when a new award references a QSO field not listed here, add the
// column to this list AND populate it in scanAwardQSO below, or that award will
// silently see an empty value during stats/computation.
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
	`grid, vucc_grids, country, state, cnty, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
	`name, qth, address, comment, notes, ` +
	`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`

// IterateForAwards streams a lightweight projection of every QSO — only the
// fields award computation reads (see awardCols). All other QSO fields are left
// zero. Use IterateAll when the full record is needed. Ordered by date/id like
// IterateAll for deterministic results.
func (r *Repo) IterateForAwards(ctx context.Context, fn func(QSO) error) error {
	rows, err := r.db.QueryContext(ctx,
		`SELECT `+awardCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
	if err != nil {
		return fmt.Errorf("query qso (awards): %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		q, err := scanAwardQSO(rows)
		if err != nil {
			return err
		}
		if err := fn(q); err != nil {
			return err
		}
	}
	return rows.Err()
}

// scanAwardQSO reads one row produced by awardCols into a QSO, populating only
// the award-relevant fields. Column order MUST match awardCols.
func scanAwardQSO(s scanner) (QSO, error) {
	var q QSO
	var (
		qsoDateStr                  string
		freqHz                      sql.NullInt64
		grid, vucc, country, state  sql.NullString
		cnty                        sql.NullString
		cont, iotaRef, sota, pota   sql.NullString
		dxcc, cqz, ituz             sql.NullInt64
		name, qth, address          sql.NullString
		comment, notes              sql.NullString
		qslRcvd, lotwRcvd, eqslRcvd sql.NullString
		extrasJSON                  sql.NullString
	)
	if err := s.Scan(
		&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
		&grid, &vucc, &country, &state, &cnty, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
		&name, &qth, &address, &comment, &notes,
		&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
	); err != nil {
		return QSO{}, fmt.Errorf("scan qso (awards): %w", err)
	}
	q.QSODate = parseTimeLoose(qsoDateStr)
	if freqHz.Valid {
		v := freqHz.Int64
		q.FreqHz = &v
	}
	q.Grid = grid.String
	q.VUCCGrids = vucc.String
	q.Country = country.String
	q.State = state.String
	q.County = cnty.String
	q.Continent = cont.String
	if cqz.Valid {
		v := int(cqz.Int64)
		q.CQZ = &v
	}
	if ituz.Valid {
		v := int(ituz.Int64)
		q.ITUZ = &v
	}
	if dxcc.Valid {
		v := int(dxcc.Int64)
		q.DXCC = &v
	}
	q.IOTA = iotaRef.String
	q.SOTARef = sota.String
	q.POTARef = pota.String
	q.Name = name.String
	q.QTH = qth.String
	q.Address = address.String
	q.Comment = comment.String
	q.Notes = notes.String
	q.QSLRcvd = qslRcvd.String
	q.LOTWRcvd = lotwRcvd.String
	q.EQSLRcvd = eqslRcvd.String
	q.Extras = decodeExtras(extrasJSON.String)
	return q, nil
}

// EntitySlot bundles every (band, mode) tuple ever worked for a given
// DXCC entity name. Used by the cluster spot colouring code to decide
// NEW / NEW SLOT / WORKED in constant time after one batched query.
type EntitySlot struct {
	Country string
	Bands   map[string]struct{}            // bands worked, any mode
	Modes   map[string]struct{}            // modes worked, any band
	Slots   map[string]map[string]struct{} // band → modes worked
}

// EntitySlotMap returns slot data for every QSO, grouped by DXCC entity NUMBER.
//
// keyFor maps a QSO (its callsign + stored DXCC + stored country) to a DXCC
// entity number. Keying by NUMBER — not name — is what makes the cluster
// "new / new-band / new-slot" check robust: QRZ's "Turkey" and cty.dat's
// "Asiatic Turkey" are the same entity (390), and a logged Lord Howe Island
// QSO (stored DXCC 147) matches a VJ2L spot even though cty.dat resolves the
// logged callsign "VK2/SP9FIH" to Australia by prefix. The caller decides the
// precedence (stored DXCC → stored country → cty.dat prefix). keyFor returning
// 0 (unresolvable) skips the QSO.
//
// One DB scan regardless of input size. Cheap to call per cluster batch.
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int) (map[int]*EntitySlot, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
		 WHERE band IS NOT NULL AND band != ''
		   AND mode IS NOT NULL AND mode != ''`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make(map[int]*EntitySlot, 256)
	for rows.Next() {
		var call, country, band, mode string
		var storedDXCC int
		if err := rows.Scan(&call, &storedDXCC, &country, &band, &mode); err != nil {
			return nil, err
		}
		key := 0
		if keyFor != nil {
			key = keyFor(call, storedDXCC, country)
		} else {
			key = storedDXCC
		}
		if key == 0 {
			continue
		}
		e, ok := out[key]
		if !ok {
			e = &EntitySlot{
				Country: country,
				Bands:   make(map[string]struct{}),
				Modes:   make(map[string]struct{}),
				Slots:   make(map[string]map[string]struct{}),
			}
			out[key] = e
		}
		e.Bands[band] = struct{}{}
		e.Modes[mode] = struct{}{}
		bandSlots, ok := e.Slots[band]
		if !ok {
			bandSlots = make(map[string]struct{})
			e.Slots[band] = bandSlots
		}
		bandSlots[mode] = struct{}{}
	}
	return out, rows.Err()
}

// WorkedCallsigns returns the set of every callsign ever logged (uppercased).
// One pass, used by the cluster spot colouring to flag "already worked this
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make(map[string]struct{}, 1024)
	for rows.Next() {
		var c string
		if err := rows.Scan(&c); err != nil {
			return nil, err
		}
		out[c] = struct{}{}
	}
	return out, rows.Err()
}

// WorkedCountyKeys returns the set of counties already worked, keyed by the
// caller-supplied normaliser (award.USCountyKey — passed in to avoid importing
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
// are considered. Empty keys (unresolvable state/county) are skipped.
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
		 WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make(map[string]struct{}, 1024)
	for rows.Next() {
		var state, cnty string
		if err := rows.Scan(&state, &cnty); err != nil {
			return nil, err
		}
		if k := keyFn(state, cnty); k != "" {
			out[k] = struct{}{}
		}
	}
	return out, rows.Err()
}

// WorkedCallBandModeKeys returns the set of every worked "CALL|BAND|MODE" key
// (all upper-cased), loaded in one pass. It backs the in-memory worked-index the
// alert engine checks per cluster spot — a DB query per spot cannot keep up with
// an FT8 skimmer firehose (and hammers a remote MySQL).
func (r *Repo) WorkedCallBandModeKeys(ctx context.Context) (map[string]struct{}, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT upper(callsign), upper(band), upper(mode) FROM qso WHERE callsign != ''`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make(map[string]struct{}, 4096)
	for rows.Next() {
		var c, b, m string
		if err := rows.Scan(&c, &b, &m); err != nil {
			return nil, err
		}
		out[c+"|"+b+"|"+m] = struct{}{}
	}
	return out, rows.Err()
}

// WorkedPOTARefs returns the set of POTA park references already worked
// (upper-cased). A QSO's pota_ref may hold several comma-separated parks
// (an n-fer); each is added separately.
func (r *Repo) WorkedPOTARefs(ctx context.Context) (map[string]struct{}, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT DISTINCT pota_ref FROM qso WHERE pota_ref IS NOT NULL AND pota_ref != ''`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make(map[string]struct{}, 256)
	for rows.Next() {
		var ref string
		if err := rows.Scan(&ref); err != nil {
			return nil, err
		}
		for _, p := range strings.Split(ref, ",") {
			if p = strings.ToUpper(strings.TrimSpace(p)); p != "" {
				out[p] = struct{}{}
			}
		}
	}
	return out, rows.Err()
}

// Count returns the total number of QSOs in the database.
func (r *Repo) Count(ctx context.Context) (int64, error) {
	var n int64
	err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM qso`).Scan(&n)
	return n, err
}

// RecentRate counts QSOs whose start time falls within each trailing window from
// `now` — the live "QSO rate" meter shown in the header. It scans only the most
// recently inserted rows (ORDER BY id DESC LIMIT), since any QSO in the last hour
// was inserted recently; that keeps it cheap even on a large log. qso_date is the
// repo's text column, parsed with parseTimeLoose (backend-format agnostic).
func (r *Repo) RecentRate(ctx context.Context, now time.Time, windows ...time.Duration) ([]int, error) {
	counts := make([]int, len(windows))
	// 400 rows covers a full hour even at a blistering contest rate (>300/h); any
	// QSO inside the trailing windows is among the most recently inserted.
	rows, err := r.db.QueryContext(ctx, `SELECT qso_date FROM qso ORDER BY id DESC LIMIT 400`)
	if err != nil {
		return counts, err
	}
	defer rows.Close()
	now = now.UTC()
	for rows.Next() {
		var dateStr sql.NullString
		if err := rows.Scan(&dateStr); err != nil {
			return counts, err
		}
		t := parseTimeLoose(dateStr.String).UTC()
		if t.IsZero() || t.After(now) {
			continue
		}
		age := now.Sub(t)
		for i, w := range windows {
			if age <= w {
				counts[i]++
			}
		}
	}
	return counts, rows.Err()
}

// ExistingDedupeKeys returns a set of every QSO key currently in the DB,
// used by the ADIF importer to skip records that would re-create the
// same contact. The key is callsign|YYYY-MM-DDTHH:MM|band|mode — minute
// precision so two loggers that wrote a few seconds apart still match.
//
// On a 25k-row table this returns ~25k strings (~2MB RAM) in one pass —
// far cheaper than N exists-queries during the import loop.
func (r *Repo) ExistingDedupeKeys(ctx context.Context) (map[string]struct{}, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT callsign, substr(qso_date, 1, 16), band, mode
		FROM qso`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make(map[string]struct{}, 1024)
	for rows.Next() {
		var call, when, band, mode string
		if err := rows.Scan(&call, &when, &band, &mode); err != nil {
			return nil, err
		}
		out[DedupeKey(call, when, band, mode)] = struct{}{}
	}
	return out, rows.Err()
}

// DedupeKey is the canonical dedupe identity for a QSO. Exposed so the
// importer can compute the key from in-flight records and check against
// the same map ExistingDedupeKeys returns.
func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
	return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
}

// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT id, callsign, substr(qso_date, 1, 16), band, mode
		FROM qso`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := make(map[string]int64, 1024)
	for rows.Next() {
		var id int64
		var call, when, band, mode string
		if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
			return nil, err
		}
		out[DedupeKey(call, when, band, mode)] = id
	}
	return out, rows.Err()
}

// matchRef is one local QSO's time + id, for time-window confirmation matching.
type matchRef struct {
	when time.Time
	id   int64
}

// MatchIndex matches a downloaded confirmation to a local QSO within a TIME
// WINDOW (not an exact minute): QSL confirmations carry the OTHER station's
// logged time, which drifts a minute or two from ours. The MODE must match, with
// ONE equivalence: the phone modes SSB/USB/LSB are interchangeable (a station may
// confirm USB where we logged SSB). Every other mode is exact (FT8 only matches
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
type MatchIndex struct {
	byMode map[string][]matchRef // call|band|canonMode → refs
}

// canonMode folds the phone sidebands into a single "SSB" bucket; every other
// mode is returned uppercased and unchanged (exact-match).
func canonMode(mode string) string {
	m := strings.ToUpper(strings.TrimSpace(mode))
	switch m {
	case "SSB", "USB", "LSB":
		return "SSB"
	}
	return m
}

func matchKeyMode(call, band, mode string) string {
	return strings.ToUpper(call) + "|" + strings.ToLower(band) + "|" + canonMode(mode)
}

// parseQSODate reads the stored qso_date (ISO "2006-01-02T15:04[:05…]") to minute
// precision as UTC. Zero time on failure (never matched).
func parseQSODate(s string) time.Time {
	if len(s) >= 16 {
		if t, err := time.Parse("2006-01-02T15:04", s[:16]); err == nil {
			return t
		}
	}
	return time.Time{}
}

// BuildMatchIndex scans the log once and indexes every QSO by call+band(+mode)
// with its time, for windowed confirmation matching. When ownerCall is non-empty
// the index is SCOPED to QSOs whose station_callsign is that call (or blank —
// legacy QSOs that predate station-callsign stamping), so a confirmation download
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
// another (e.g. TM2Q).
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
	idx := &MatchIndex{byMode: map[string][]matchRef{}}
	query := `SELECT id, callsign, qso_date, band, mode FROM qso`
	var args []any
	if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
		query += ` WHERE UPPER(TRIM(COALESCE(station_callsign,''))) IN ('', ?)`
		args = append(args, oc)
	}
	rows, err := r.db.QueryContext(ctx, query, args...)
	if err != nil {
		return idx, err
	}
	defer rows.Close()
	for rows.Next() {
		var id int64
		var call, when, band, mode string
		if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
			return idx, err
		}
		idx.add(call, band, mode, parseQSODate(when), id)
	}
	return idx, rows.Err()
}

// add inserts a QSO into the index (also used to register a freshly-inserted
// "add not-found" QSO so later confirmations in the same batch can match it).
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
	mk := matchKeyMode(call, band, mode)
	idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
}

// Add registers a QSO in the index (exported wrapper for callers that inserted a
// not-found confirmation).
func (idx *MatchIndex) Add(call, band, mode string, when time.Time, id int64) {
	idx.add(call, band, mode, when, id)
}

// Match returns the id of the local QSO closest in time to `when` (within
// `window`) for the given call/band/mode. The mode must match (phone sidebands
// folded together by canonMode). Ok is false when nothing is within the window.
func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window time.Duration) (int64, bool) {
	return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window)
}

func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
	var best int64
	bestD := window + time.Second
	found := false
	for _, rf := range refs {
		if rf.when.IsZero() {
			continue
		}
		d := when.Sub(rf.when)
		if d < 0 {
			d = -d
		}
		if d <= window && d < bestD {
			bestD, best, found = d, rf.id, true
		}
	}
	return best, found
}

// ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
// ConfirmedSets captures confirmed combinations for the QSL Manager's NEW flags.
// Modes are grouped into CLASSES (Phone/CW/Digital) — a digital confirmation is a
// "new mode/slot" only if no digital mode was confirmed there before (RTTY and FT8
// are the same DIGI class). Raw-mode granularity lives only in the cluster/matrix.
type ConfirmedSets struct {
	DXCC map[int]bool    // dxcc entity confirmed
	Band map[string]bool // "dxcc|band"
	Mode map[string]bool // "dxcc|class"
	Slot map[string]bool // "dxcc|band|class"
}

// Key builders for ConfirmedSets. Band is mode-agnostic; Mode/Slot use the class.
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
func ModeClassKey(dxcc int, mode string) string {
	return fmt.Sprintf("%d|%s", dxcc, modeClass(mode))
}
func SlotClassKey(dxcc int, band, mode string) string {
	return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), modeClass(mode))
}

// SlotKey is the RAW-mode slot key, kept for the cluster/matrix new-slot flag.
func SlotKey(dxcc int, band, mode string) string {
	return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
}

// SlotStats is the worked/confirmed tally for the QSL Manager, counting slots by
// mode CLASS (Phone / CW / Digital) — NOT raw mode. Raw-mode granularity (RTTY ≠
// FT8) is kept only for the cluster/matrix "new slot" flag; the totals here match
// how slots are conventionally counted (a band in a class, not in each digital
// sub-mode).
type SlotStats struct {
	SlotsWorked    int `json:"slots_worked"`    // distinct DXCC × band × class, all QSOs
	SlotsConfirmed int `json:"slots_confirmed"` // distinct DXCC × band × class, confirmed
	DXCCWorked     int `json:"dxcc_worked"`     // distinct DXCC entities worked
	DXCCConfirmed  int `json:"dxcc_confirmed"`  // distinct DXCC entities confirmed
	// Per-class slot breakdown (Phone / CW / Digital) so the numbers are checkable.
	PHWorked     int `json:"ph_worked"`
	PHConfirmed  int `json:"ph_confirmed"`
	CWWorked     int `json:"cw_worked"`
	CWConfirmed  int `json:"cw_confirmed"`
	DIGWorked    int `json:"dig_worked"`
	DIGConfirmed int `json:"dig_confirmed"`
}

// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,'')),
		       CASE WHEN lotw_rcvd='Y' OR qsl_rcvd='Y' THEN 1 ELSE 0 END
		FROM qso`)
	if err != nil {
		return SlotStats{}, err
	}
	defer rows.Close()
	dxccW, slotW := map[int]bool{}, map[string]bool{}
	dxccC, slotC := map[int]bool{}, map[string]bool{}
	// Per-class distinct slots (worked "w" / confirmed "c").
	clsW := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
	clsC := map[string]map[string]bool{"PH": {}, "CW": {}, "DIG": {}}
	for rows.Next() {
		var dxcc, conf int
		var band, mode string
		if err := rows.Scan(&dxcc, &band, &mode, &conf); err != nil {
			return SlotStats{}, err
		}
		if dxcc == 0 {
			continue
		}
		dxccW[dxcc] = true
		if conf == 1 {
			dxccC[dxcc] = true
		}
		if band == "" {
			continue // no band → counts for DXCC but not for a slot
		}
		key := SlotClassKey(dxcc, band, mode)
		cl := modeClass(mode)
		slotW[key] = true
		clsW[cl][key] = true
		if conf == 1 {
			slotC[key] = true
			clsC[cl][key] = true
		}
	}
	return SlotStats{
		SlotsWorked: len(slotW), SlotsConfirmed: len(slotC),
		DXCCWorked: len(dxccW), DXCCConfirmed: len(dxccC),
		PHWorked: len(clsW["PH"]), PHConfirmed: len(clsC["PH"]),
		CWWorked: len(clsW["CW"]), CWConfirmed: len(clsC["CW"]),
		DIGWorked: len(clsW["DIG"]), DIGConfirmed: len(clsC["DIG"]),
	}, rows.Err()
}

// confirmedCols whitelists the received-status columns ConfirmedSlots may
// OR together (guards the dynamic SQL).
var confirmedCols = map[string]bool{
	"lotw_rcvd":                  true,
	"qsl_rcvd":                   true,
	"eqsl_rcvd":                  true,
	"qrzcom_qso_download_status": true,
}

// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos, counting
// only the given received-status columns as "confirmed". This lets the caller
// scope award-relevant confirmations per service — e.g. LoTW download uses
// {lotw_rcvd, qsl_rcvd} (the award-valid sources), QRZ uses
// {qrzcom_qso_download_status}.
func (r *Repo) ConfirmedSlots(ctx context.Context, cols []string) (ConfirmedSets, error) {
	sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Mode: map[string]bool{}, Slot: map[string]bool{}}
	var conds []string
	for _, c := range cols {
		if confirmedCols[c] {
			conds = append(conds, c+" = 'Y'")
		}
	}
	if len(conds) == 0 {
		return sets, nil
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,''))
		FROM qso
		WHERE `+strings.Join(conds, " OR "))
	if err != nil {
		return sets, err
	}
	defer rows.Close()
	for rows.Next() {
		var dxcc int
		var band, mode string
		if err := rows.Scan(&dxcc, &band, &mode); err != nil {
			return sets, err
		}
		if dxcc == 0 {
			continue
		}
		sets.DXCC[dxcc] = true
		sets.Band[BandKey(dxcc, band)] = true
		sets.Mode[ModeClassKey(dxcc, mode)] = true
		sets.Slot[SlotClassKey(dxcc, band, mode)] = true
	}
	return sets, rows.Err()
}

// MarkQRZConfirmed stamps QRZCOM_QSO_DOWNLOAD_STATUS=Y and the date on a QSO
// confirmed via a QRZ.com download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET qrzcom_qso_download_status = 'Y', qrzcom_qso_download_date = ?,
		   updated_at = ? WHERE id = ?`,
		date, db.NowISO(), id)
	if err != nil {
		return fmt.Errorf("mark qrz confirmed %d: %w", id, err)
	}
	return nil
}

// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET eqsl_rcvd = 'Y', eqsl_rcvd_date = ?,
		   updated_at = ? WHERE id = ?`,
		date, db.NowISO(), id)
	if err != nil {
		return fmt.Errorf("mark eqsl confirmed %d: %w", id, err)
	}
	return nil
}

// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
		   updated_at = ? WHERE id = ?`,
		date, db.NowISO(), id)
	if err != nil {
		return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
	}
	return nil
}

// scanner is what both *sql.Row and *sql.Rows satisfy for our needs.
type scanner interface {
	Scan(dest ...any) error
}

// scanQSO reads one row produced by selectCols into a QSO.
// Long but flat — kept in field declaration order to make audits easy.
func scanQSO(s scanner) (QSO, error) {
	var q QSO
	var (
		qsoDateStr                            string
		qsoDateOffStr                         sql.NullString
		bandRx, submode                       sql.NullString
		freqHz, freqRX                        sql.NullInt64
		rstS, rstR                            sql.NullString
		name, qth, addr, email, web           sql.NullString
		grid, gridExt, vucc                   sql.NullString
		country, state, cnty                  sql.NullString
		dxcc, cqz, ituz                       sql.NullInt64
		cont, iota, sota, pota                sql.NullString
		age                                   sql.NullInt64
		lat, lon                              sql.NullFloat64
		rig, ant                              sql.NullString
		qslSent, qslRcvd                      sql.NullString
		qslSentDate, qslRcvdDate              sql.NullString
		qslVia, qslMsg, qslMsgRcvd            sql.NullString
		lotwSent, lotwRcvd                    sql.NullString
		lotwSentDate, lotwRcvdDate            sql.NullString
		eqslSent, eqslRcvd                    sql.NullString
		eqslSentDate, eqslRcvdDate            sql.NullString
		clublogDate, clublogStatus            sql.NullString
		hrdlogDate, hrdlogStatus              sql.NullString
		qrzcomDate, qrzcomStatus              sql.NullString
		qrzcomDlDate, qrzcomDlStatus          sql.NullString
		contestID                             sql.NullString
		srx, stx                              sql.NullInt64
		srxStr, stxStr                        sql.NullString
		checkField, precedence, arrlSect      sql.NullString
		propMode, satName, satMode            sql.NullString
		antAz, antEl                          sql.NullFloat64
		antPath                               sql.NullString
		stCall, op, myGrid, myGridExt         sql.NullString
		myCountry, myState, myCnty, myIOTA    sql.NullString
		mySOTA, myPOTA                        sql.NullString
		myDXCC, myCQZ, myITUZ                 sql.NullInt64
		myLat, myLon                          sql.NullFloat64
		myStreet, myCity, myPostal            sql.NullString
		myRig, myAntenna                      sql.NullString
		txp                                   sql.NullFloat64
		comment, notes                        sql.NullString
		sig, sigInfo, mySig, mySigInfo        sql.NullString
		wwffRef, myWWFFRef                     sql.NullString
		distance, rxPwr, aIndex, kIndex, sfi  sql.NullFloat64
		skcc, fists, tenTen                    sql.NullString
		contactedOp, eqCall, pfx, myName       sql.NullString
		class, darcDOK, myDarcDOK, region      sql.NullString
		silentKey, swl, qsoComplete, qsoRandom sql.NullString
		creditGranted, creditSubmitted         sql.NullString
		myARRLSect, myVUCCGrids                 sql.NullString
		extrasJSON                            sql.NullString
		createdStr, updatedStr                string
	)
	if err := s.Scan(
		&q.ID, &q.Callsign, &qsoDateStr, &qsoDateOffStr, &q.Band, &bandRx, &q.Mode, &submode, &freqHz, &freqRX,
		&rstS, &rstR,
		&name, &qth, &addr, &email, &web,
		&grid, &gridExt, &vucc,
		&country, &state, &cnty, &dxcc, &cont, &cqz, &ituz, &iota, &sota, &pota,
		&age, &lat, &lon, &rig, &ant,
		&qslSent, &qslRcvd, &qslSentDate, &qslRcvdDate, &qslVia, &qslMsg, &qslMsgRcvd,
		&lotwSent, &lotwRcvd, &lotwSentDate, &lotwRcvdDate,
		&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
		&clublogDate, &clublogStatus,
		&hrdlogDate, &hrdlogStatus,
		&qrzcomDate, &qrzcomStatus,
		&qrzcomDlDate, &qrzcomDlStatus,
		&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
		&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
		&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
		&mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon,
		&myStreet, &myCity, &myPostal, &myRig, &myAntenna,
		&txp, &comment, &notes,
		&sig, &sigInfo, &mySig, &mySigInfo, &wwffRef, &myWWFFRef,
		&distance, &rxPwr, &aIndex, &kIndex, &sfi,
		&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
		&darcDOK, &myDarcDOK, &region, &silentKey, &swl, &qsoComplete, &qsoRandom,
		&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
		&extrasJSON, &createdStr, &updatedStr,
	); err != nil {
		return QSO{}, fmt.Errorf("scan qso: %w", err)
	}
	q.QSODate = parseTimeLoose(qsoDateStr)
	q.QSODateOff = parseTimeLoose(qsoDateOffStr.String)
	q.CreatedAt = parseTimeLoose(createdStr)
	q.UpdatedAt = parseTimeLoose(updatedStr)
	q.BandRX = bandRx.String
	q.Submode = submode.String
	if freqHz.Valid {
		v := freqHz.Int64
		q.FreqHz = &v
	}
	if freqRX.Valid {
		v := freqRX.Int64
		q.FreqRXHz = &v
	}
	q.RSTSent = rstS.String
	q.RSTRcvd = rstR.String
	q.Name = name.String
	q.QTH = qth.String
	q.Address = addr.String
	q.Email = email.String
	q.Web = web.String
	q.Grid = grid.String
	q.GridExt = gridExt.String
	q.VUCCGrids = vucc.String
	q.Country = country.String
	q.State = state.String
	q.County = cnty.String
	if dxcc.Valid {
		v := int(dxcc.Int64)
		q.DXCC = &v
	}
	q.Continent = cont.String
	if cqz.Valid {
		v := int(cqz.Int64)
		q.CQZ = &v
	}
	if ituz.Valid {
		v := int(ituz.Int64)
		q.ITUZ = &v
	}
	q.IOTA = iota.String
	q.SOTARef = sota.String
	q.POTARef = pota.String
	if age.Valid {
		v := int(age.Int64)
		q.Age = &v
	}
	if lat.Valid {
		v := lat.Float64
		q.Lat = &v
	}
	if lon.Valid {
		v := lon.Float64
		q.Lon = &v
	}
	q.Rig = rig.String
	q.Ant = ant.String
	q.QSLSent = qslSent.String
	q.QSLRcvd = qslRcvd.String
	q.QSLSentDate = qslSentDate.String
	q.QSLRcvdDate = qslRcvdDate.String
	q.QSLVia = qslVia.String
	q.QSLMsg = qslMsg.String
	q.QSLMsgRcvd = qslMsgRcvd.String
	q.LOTWSent = lotwSent.String
	q.LOTWRcvd = lotwRcvd.String
	q.LOTWSentDate = lotwSentDate.String
	q.LOTWRcvdDate = lotwRcvdDate.String
	q.EQSLSent = eqslSent.String
	q.EQSLRcvd = eqslRcvd.String
	q.EQSLSentDate = eqslSentDate.String
	q.EQSLRcvdDate = eqslRcvdDate.String
	q.ClublogUploadDate = clublogDate.String
	q.ClublogUploadStatus = clublogStatus.String
	q.HRDLogUploadDate = hrdlogDate.String
	q.HRDLogUploadStatus = hrdlogStatus.String
	q.QRZComUploadDate = qrzcomDate.String
	q.QRZComUploadStatus = qrzcomStatus.String
	q.QRZComDownloadDate = qrzcomDlDate.String
	q.QRZComDownloadStatus = qrzcomDlStatus.String
	q.ContestID = contestID.String
	if srx.Valid {
		v := int(srx.Int64)
		q.SRX = &v
	}
	if stx.Valid {
		v := int(stx.Int64)
		q.STX = &v
	}
	q.SRXString = srxStr.String
	q.STXString = stxStr.String
	q.Check = checkField.String
	q.Precedence = precedence.String
	q.ARRLSect = arrlSect.String
	q.PropMode = propMode.String
	q.SatName = satName.String
	q.SatMode = satMode.String
	if antAz.Valid {
		v := antAz.Float64
		q.AntAz = &v
	}
	if antEl.Valid {
		v := antEl.Float64
		q.AntEl = &v
	}
	q.AntPath = antPath.String
	q.StationCallsign = stCall.String
	q.Operator = op.String
	q.MyGrid = myGrid.String
	q.MyGridExt = myGridExt.String
	q.MyCountry = myCountry.String
	q.MyState = myState.String
	q.MyCounty = myCnty.String
	q.MyIOTA = myIOTA.String
	q.MySOTARef = mySOTA.String
	q.MyPOTARef = myPOTA.String
	if myDXCC.Valid {
		v := int(myDXCC.Int64)
		q.MyDXCC = &v
	}
	if myCQZ.Valid {
		v := int(myCQZ.Int64)
		q.MyCQZone = &v
	}
	if myITUZ.Valid {
		v := int(myITUZ.Int64)
		q.MyITUZone = &v
	}
	if myLat.Valid {
		v := myLat.Float64
		q.MyLat = &v
	}
	if myLon.Valid {
		v := myLon.Float64
		q.MyLon = &v
	}
	q.MyStreet = myStreet.String
	q.MyCity = myCity.String
	q.MyPostalCode = myPostal.String
	q.MyRig = myRig.String
	q.MyAntenna = myAntenna.String
	if txp.Valid {
		v := txp.Float64
		q.TXPower = &v
	}
	q.Comment = comment.String
	q.Notes = notes.String
	q.SIG = sig.String
	q.SIGInfo = sigInfo.String
	q.MySIG = mySig.String
	q.MySIGInfo = mySigInfo.String
	q.WWFFRef = wwffRef.String
	q.MyWWFFRef = myWWFFRef.String
	if distance.Valid {
		v := distance.Float64
		q.Distance = &v
	}
	if rxPwr.Valid {
		v := rxPwr.Float64
		q.RXPower = &v
	}
	if aIndex.Valid {
		v := aIndex.Float64
		q.AIndex = &v
	}
	if kIndex.Valid {
		v := kIndex.Float64
		q.KIndex = &v
	}
	if sfi.Valid {
		v := sfi.Float64
		q.SFI = &v
	}
	q.SKCC = skcc.String
	q.FISTS = fists.String
	q.TenTen = tenTen.String
	q.ContactedOp = contactedOp.String
	q.EqCall = eqCall.String
	q.PFX = pfx.String
	q.MyName = myName.String
	q.Class = class.String
	q.DarcDOK = darcDOK.String
	q.MyDarcDOK = myDarcDOK.String
	q.Region = region.String
	q.SilentKey = silentKey.String
	q.SWL = swl.String
	q.QSOComplete = qsoComplete.String
	q.QSORandom = qsoRandom.String
	q.CreditGranted = creditGranted.String
	q.CreditSubmitted = creditSubmitted.String
	q.MyARRLSect = myARRLSect.String
	q.MyVUCCGrids = myVUCCGrids.String
	q.Extras = decodeExtras(extrasJSON.String)
	return q, nil
}

// parseTimeLoose tries several common ISO layouts returned by SQLite.
func parseTimeLoose(s string) time.Time {
	if s == "" {
		return time.Time{}
	}
	for _, layout := range []string{isoMillis, "2006-01-02T15:04:05Z", "2006-01-02 15:04:05"} {
		if t, err := time.Parse(layout, s); err == nil {
			return t.UTC()
		}
	}
	return time.Time{}
}
