package adif

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"golang.org/x/text/encoding/charmap"

	"hamlog/internal/qso"
)

// ImportResult summarises an ADIF import for the UI.
type ImportResult struct {
	Total            int      `json:"total"`             // records found in the file
	Imported         int      `json:"imported"`          // successfully inserted
	Updated          int      `json:"updated"`           // existing QSOs refreshed (update-duplicates mode)
	Skipped          int      `json:"skipped"`           // dropped (missing required fields)
	Duplicates       int      `json:"duplicates"`        // matched an existing or earlier-in-file QSO
	DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
	Errors           []string `json:"errors"`            // up to maxErrors error messages
}

const (
	maxErrors           = 50
	maxDuplicateSamples = 200 // cap memory + JSON payload size
)

// Importer streams an ADI file into a QSO repository.
type Importer struct {
	Repo           *qso.Repo
	BatchSize      int  // 0 → 500
	SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
	// UpdateDuplicates, when true, takes precedence over SkipDuplicates:
	// a record matching an existing QSO MERGES its non-empty fields onto
	// that QSO (refreshes QSL/confirmation statuses on re-sync) instead of
	// being skipped or re-inserted.
	UpdateDuplicates bool
	// Enrich, when set, is called on each parsed QSO before dedup/insert.
	// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
	// source file (common with contest loggers) is corrected on the way in.
	Enrich func(*qso.QSO)
	// FieldMap moves ADIF fields around before a record is converted, keyed
	// lowercase source → lowercase destination.
	//
	// Contest software puts things where the operator, not the standard, expects
	// them. The RSGB IOTA contest is the case that prompted this: N1MM and
	// friends export the island reference in STATE, because that is the column
	// their contest module uses for the received exchange. Imported verbatim,
	// every QSO gets a US-state field holding "EU005" and the IOTA award sees
	// nothing at all.
	//
	// Applied to the RAW record, so it works for promoted columns and Extras
	// alike, and so the destination is parsed exactly as if the file had carried
	// it there in the first place. A destination that already has a value is
	// never overwritten — the file's own IOTA tag outranks a guess about STATE.
	FieldMap map[string]string
	// OnProgress, when set, is called periodically with (processed, total)
	// record counts so the UI can show a progress bar. total is an estimate
	// from counting <EOR> tags up front.
	OnProgress func(processed, total int)
}

// ImportFile reads the file at path and imports it into the repo. The
// whole file is loaded into memory so we can do a definitive UTF-8 check
// before parsing — peeking a buffered window misses non-ASCII bytes that
// only appear past the header (typical when the ADIF header is pure ASCII
// but record fields like NAME/QTH have accented chars in Windows-1252).
func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return ImportResult{}, fmt.Errorf("open %s: %w", path, err)
	}
	// Strip UTF-8 BOM if present so the parser sees clean data.
	data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
	return im.importBytes(ctx, data)
}

// pickValueDecoder returns the per-field byte-to-string decoder to use.
// If the file is valid UTF-8 we keep the bytes as-is; otherwise we assume
// Windows-1252 (de-facto encoding of MixW, Log4OM, HRD and most legacy
// Western-European loggers). Decoding has to happen on each field's bytes
// individually, NOT by wrapping the reader, because ADIF declares field
// lengths in source-encoding bytes — e.g. "<QTH:7>YAOUNDÉ" is 7 bytes in
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
// two bytes, and the parser reading 7 bytes after the tag would chop the
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND�" after JSON.
// ValueDecoderFor returns the per-field byte decoder appropriate for a raw
// ADIF payload: identity when it's valid UTF-8, otherwise a Windows-1252
// decoder. Exposed so non-file ingest paths (UDP auto-log from Log4OM /
// JTAlert) transcode accented NAME/QTH fields the same way file import does.
func ValueDecoderFor(data []byte) func([]byte) string {
	return pickValueDecoder(data)
}

func pickValueDecoder(data []byte) func([]byte) string {
	if utf8.Valid(data) {
		return nil // identity
	}
	dec := charmap.Windows1252.NewDecoder()
	return func(b []byte) string {
		out, err := dec.Bytes(b)
		if err != nil {
			return string(b)
		}
		return string(out)
	}
}

// Import streams the ADI content from r into the repo. Assumes UTF-8;
// callers that may receive other encodings should go through ImportFile.
func (im *Importer) Import(ctx context.Context, r interface {
	Read(p []byte) (int, error)
}) (ImportResult, error) {
	data, err := io.ReadAll(r)
	if err != nil {
		return ImportResult{}, fmt.Errorf("read input: %w", err)
	}
	data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
	return im.importBytes(ctx, data)
}

func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult, error) {
	if im.BatchSize <= 0 {
		im.BatchSize = 500
	}
	decode := pickValueDecoder(data)
	res := ImportResult{}
	batch := make([]qso.QSO, 0, im.BatchSize)

	// Up-front record-count estimate (count <EOR> tags, case-insensitive) so
	// the UI progress bar has a denominator. Cheap single scan.
	total := countEOR(data)
	reportProgress := func(force bool) {
		if im.OnProgress != nil && (force || res.Total%200 == 0) {
			im.OnProgress(res.Total, total)
		}
	}

	// One upfront query for every existing dedup key — cheaper than N
	// per-record EXISTS calls. The same map gets new keys appended as we
	// import so duplicates inside the file are caught too. Loaded
	// unconditionally so the "duplicates" count is meaningful even when
	// SkipDuplicates is off (the user still wants to know how many).
	seen, err := im.Repo.ExistingDedupeKeys(ctx)
	if err != nil {
		return res, fmt.Errorf("load dedupe keys: %w", err)
	}

	// Update-duplicates mode needs the existing row's ID per key so it can
	// fetch, merge and write it back. Loaded only when needed (extra query).
	var keyIDs map[string]int64
	if im.UpdateDuplicates {
		keyIDs, err = im.Repo.DedupeKeyIDs(ctx)
		if err != nil {
			return res, fmt.Errorf("load dedupe ids: %w", err)
		}
	}

	flush := func() error {
		if len(batch) == 0 {
			return nil
		}
		n, err := im.Repo.AddBatch(ctx, batch)
		res.Imported += int(n)
		batch = batch[:0]
		return err
	}

	err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
		res.Total++
		reportProgress(false)
		applyFieldMap(rec, im.FieldMap)
		q, ok := recordToQSO(rec)
		if !ok {
			res.Skipped++
			if len(res.Errors) < maxErrors {
				res.Errors = append(res.Errors,
					fmt.Sprintf("record %d: missing required fields (call/band/mode/date)", res.Total))
			}
			return nil
		}
		if im.Enrich != nil {
			im.Enrich(&q)
		}
		key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
		if _, dup := seen[key]; dup {
			res.Duplicates++
			if len(res.DuplicateSamples) < maxDuplicateSamples {
				res.DuplicateSamples = append(res.DuplicateSamples,
					fmt.Sprintf("%s · %s · %s · %s",
						q.Callsign,
						q.QSODate.UTC().Format("2006-01-02 15:04"),
						q.Band, q.Mode))
			}
			if im.UpdateDuplicates {
				if id, ok := keyIDs[key]; ok {
					existing, gerr := im.Repo.GetByID(ctx, id)
					if gerr != nil {
						if len(res.Errors) < maxErrors {
							res.Errors = append(res.Errors,
								fmt.Sprintf("record %d (%s): load existing: %v", res.Total, q.Callsign, gerr))
						}
						return nil
					}
					qso.MergeNonZero(&existing, q)
					if uerr := im.Repo.Update(ctx, existing); uerr != nil {
						if len(res.Errors) < maxErrors {
							res.Errors = append(res.Errors,
								fmt.Sprintf("record %d (%s): update: %v", res.Total, q.Callsign, uerr))
						}
						return nil
					}
					res.Updated++
				}
				return nil
			}
			if im.SkipDuplicates {
				return nil
			}
			// Fall through: insert anyway. Don't add to seen[] — keeps
			// the duplicate count meaningful if the same row appears 3x.
		} else {
			seen[key] = struct{}{}
		}
		batch = append(batch, q)
		if len(batch) >= im.BatchSize {
			return flush()
		}
		return nil
	})
	if err != nil {
		_ = flush()
		return res, err
	}
	if err := flush(); err != nil {
		return res, err
	}
	reportProgress(true) // final 100%
	return res, nil
}

// countEOR estimates the record count by counting case-insensitive <EOR>
// tags. Used only to give the import progress bar a denominator.
func countEOR(data []byte) int {
	n := 0
	for i := 0; i+4 <= len(data); i++ {
		if data[i] != '<' {
			continue
		}
		if (data[i+1] == 'e' || data[i+1] == 'E') &&
			(data[i+2] == 'o' || data[i+2] == 'O') &&
			(data[i+3] == 'r' || data[i+3] == 'R') &&
			(i+4 < len(data) && (data[i+4] == '>' || data[i+4] == ':')) {
			n++
		}
	}
	return n
}

// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
// Anything not in this set ends up in Extras.
var adifPromoted = stringSet(
	// Core
	"call", "qso_date", "time_on", "qso_date_off", "time_off",
	"band", "band_rx", "mode", "submode", "freq", "freq_rx",
	"rst_sent", "rst_rcvd",
	// Contacted
	"name", "qth", "address", "email", "web",
	"gridsquare", "gridsquare_ext", "vucc_grids",
	"country", "state", "cnty",
	"dxcc", "cont", "cqz", "ituz",
	"iota", "sota_ref", "pota_ref",
	"age", "lat", "lon", "rig", "ant",
	// QSL
	"qsl_sent", "qsl_rcvd",
	"qslsdate", "qslrdate", "qsl_via", "qsl_sent_via", "qsl_rcvd_via", "qslmsg", "qslmsg_rcvd",
	"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
	"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
	"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",
	"clublog_qso_download_date", "clublog_qso_download_status",
	// Contest
	"contest_id", "srx", "stx", "srx_string", "stx_string",
	"check", "precedence", "arrl_sect",
	// Sat / propagation
	"prop_mode", "sat_name", "sat_mode", "ant_az", "ant_el", "ant_path",
	// My station
	"station_callsign", "operator",
	"my_gridsquare", "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",
	// Misc
	"tx_pwr", "comment", "notes",
	// ADIF 3.1.7 additional promoted fields
	"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",
)

func stringSet(items ...string) map[string]struct{} {
	m := make(map[string]struct{}, len(items))
	for _, s := range items {
		m[s] = struct{}{}
	}
	return m
}

// RecordToQSO is the exported alias used by the UDP auto-log path so it
// can convert a freshly received ADIF record into a QSO and then enrich
// it with lookup + operating data before inserting.
// applyFieldMap moves values between tags of a record, in place.
//
// The source is CLEARED after the move: a reference that belongs in IOTA should
// not also be left behind in STATE, where it would show up as the contacted
// station's US state in the grid and in every export.
func applyFieldMap(rec Record, m map[string]string) {
	if len(m) == 0 || rec == nil {
		return
	}
	// Read every source first. Applied one at a time, a swap (a→b, b→a) would
	// read back what the previous move had just written.
	orig := make(map[string]string, len(rec))
	for k, v := range rec {
		orig[k] = v
	}
	vals := make(map[string]string, len(m))
	for from := range m {
		if v := strings.TrimSpace(rec[from]); v != "" {
			vals[from] = v
		}
	}
	for from := range m {
		delete(rec, from)
	}
	for from, to := range m {
		v, ok := vals[from]
		if !ok || from == to || to == "" {
			continue
		}
		// Whether the destination was already filled is judged against the record
		// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
		// depend on Go's map iteration order, which is deliberately random.
		//
		// A destination that is itself a mapping source is an explicit swap, and
		// overwriting it is the whole point; anything else keeps what the file
		// carried, because the file's own tag outranks a guess about where a
		// reference might have been put.
		if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
			rec[to] = v
		}
	}
}

func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }

// recordToQSO maps an ADIF record onto a QSO. Returns false if required
// fields are missing. Any ADIF tag we don't promote is stored in Extras.
func recordToQSO(rec Record) (qso.QSO, bool) {
	call := strings.ToUpper(strings.TrimSpace(rec["call"]))
	if call == "" {
		return qso.QSO{}, false
	}
	band := strings.ToLower(strings.TrimSpace(rec["band"]))
	mode := strings.ToUpper(strings.TrimSpace(rec["mode"]))
	submode := strings.ToUpper(strings.TrimSpace(rec["submode"]))
	date := parseDateTime(rec["qso_date"], rec["time_on"])
	if date.IsZero() || band == "" || mode == "" {
		return qso.QSO{}, false
	}
	// ADIF promotes specific digital flavours into the SUBMODE field with
	// a generic parent in MODE (e.g. MODE=MFSK SUBMODE=FT4). Loggers
	// uniformly display the submode in that case — promote it so all
	// downstream code (entry strip, worked-before, exports) sees "FT4"
	// rather than "MFSK". SSB+USB/LSB and RTTY+JOH stay on the parent
	// because the parent IS the displayed mode there.
	if submode != "" && submodeSubsumesParent(submode) {
		mode = submode
		submode = "" // redundant once promoted
	}

	q := qso.QSO{
		Callsign:   call,
		QSODate:    date,
		QSODateOff: parseDateTime(rec["qso_date_off"], rec["time_off"]),
		Band:       band,
		BandRX:     strings.ToLower(rec["band_rx"]),
		Mode:       mode,
		Submode:    submode,
	}
	if hz, ok := parseFreqHz(rec["freq"]); ok {
		q.FreqHz = &hz
	}
	if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
		q.FreqRXHz = &hz
	}
	// RX defaults to TX when the ADIF omits split info: an empty BAND_RX /
	// FREQ_RX means the contact wasn't cross-band/split, so RX = TX.
	if q.BandRX == "" {
		q.BandRX = q.Band
	}
	if q.FreqRXHz == nil && q.FreqHz != nil {
		v := *q.FreqHz
		q.FreqRXHz = &v
	}

	q.RSTSent = rec["rst_sent"]
	q.RSTRcvd = rec["rst_rcvd"]

	// Contacted station
	q.Name = rec["name"]
	q.QTH = rec["qth"]
	q.Address = rec["address"]
	q.Email = rec["email"]
	q.Web = rec["web"]
	q.Grid = strings.ToUpper(rec["gridsquare"])
	q.GridExt = strings.ToUpper(rec["gridsquare_ext"])
	q.VUCCGrids = strings.ToUpper(rec["vucc_grids"])
	q.Country = rec["country"]
	q.State = strings.ToUpper(rec["state"])
	q.County, _ = splitADIFCounty(rec["cnty"])
	if st := rec["state"]; strings.TrimSpace(st) == "" {
		if _, fromCnty := splitADIFCounty(rec["cnty"]); fromCnty != "" {
			q.State = fromCnty
		}
	}
	if v, ok := parseInt(rec["dxcc"]); ok {
		q.DXCC = &v
	}
	q.Continent = strings.ToUpper(rec["cont"])
	if v, ok := parseInt(rec["cqz"]); ok {
		q.CQZ = &v
	}
	if v, ok := parseInt(rec["ituz"]); ok {
		q.ITUZ = &v
	}
	q.IOTA = strings.ToUpper(rec["iota"])
	q.SOTARef = strings.ToUpper(rec["sota_ref"])
	q.POTARef = strings.ToUpper(rec["pota_ref"])
	if v, ok := parseInt(rec["age"]); ok {
		q.Age = &v
	}
	if v, ok := parseFloat(rec["lat"]); ok {
		q.Lat = &v
	}
	if v, ok := parseFloat(rec["lon"]); ok {
		q.Lon = &v
	}
	q.Rig = rec["rig"]
	q.Ant = rec["ant"]

	// QSL
	q.QSLSent = rec["qsl_sent"]
	q.QSLRcvd = rec["qsl_rcvd"]
	q.QSLSentDate = rec["qslsdate"]
	q.QSLRcvdDate = rec["qslrdate"]
	// QSL_VIA is the manager. QSL_SENT_VIA / QSL_RCVD_VIA are the routing
	// method, an enumeration of their own.
	//
	// These used to be one field here: an empty QSL_VIA was filled from
	// QSL_SENT_VIA, on the theory that loggers writing one meant the other.
	// They do not — Log4OM defaults QSL_SENT_VIA to E, and the import put "E"
	// where every panel in OpsLog shows the manager's callsign. Keeping them
	// apart is also what lets an export give them back.
	q.QSLVia = rec["qsl_via"]
	q.QSLSentVia = NormaliseQSLVia(rec["qsl_sent_via"])
	q.QSLRcvdVia = NormaliseQSLVia(rec["qsl_rcvd_via"])
	q.QSLMsg = rec["qslmsg"]
	q.QSLMsgRcvd = rec["qslmsg_rcvd"]
	q.LOTWSent = rec["lotw_qsl_sent"]
	q.LOTWRcvd = rec["lotw_qsl_rcvd"]
	q.LOTWSentDate = rec["lotw_qslsdate"]
	q.LOTWRcvdDate = rec["lotw_qslrdate"]
	q.EQSLSent = rec["eqsl_qsl_sent"]
	q.EQSLRcvd = rec["eqsl_qsl_rcvd"]
	q.EQSLSentDate = rec["eqsl_qslsdate"]
	q.EQSLRcvdDate = rec["eqsl_qslrdate"]
	q.ClublogUploadDate = rec["clublog_qso_upload_date"]
	q.ClublogUploadStatus = rec["clublog_qso_upload_status"]
	q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"]
	q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
	q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
	q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
	q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
	q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
	q.ClublogDownloadDate = rec["clublog_qso_download_date"]
	q.ClublogDownloadStatus = rec["clublog_qso_download_status"]

	// Contest
	q.ContestID = rec["contest_id"]
	if v, ok := parseInt(rec["srx"]); ok {
		q.SRX = &v
	}
	if v, ok := parseInt(rec["stx"]); ok {
		q.STX = &v
	}
	q.SRXString = rec["srx_string"]
	q.STXString = rec["stx_string"]
	q.Check = rec["check"]
	q.Precedence = rec["precedence"]
	q.ARRLSect = strings.ToUpper(rec["arrl_sect"])

	// Sat / propagation
	q.PropMode = strings.ToUpper(rec["prop_mode"])
	q.SatName = strings.ToUpper(rec["sat_name"])
	q.SatMode = rec["sat_mode"]
	if v, ok := parseFloat(rec["ant_az"]); ok {
		q.AntAz = &v
	}
	if v, ok := parseFloat(rec["ant_el"]); ok {
		q.AntEl = &v
	}
	q.AntPath = strings.ToUpper(rec["ant_path"])

	// My station
	q.StationCallsign = strings.ToUpper(rec["station_callsign"])
	q.Operator = strings.ToUpper(rec["operator"])
	q.MyGrid = strings.ToUpper(rec["my_gridsquare"])
	q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"])
	q.MyCountry = rec["my_country"]
	q.MyState = strings.ToUpper(rec["my_state"])
	q.MyCounty, _ = splitADIFCounty(rec["my_cnty"])
	if st := rec["my_state"]; strings.TrimSpace(st) == "" {
		if _, fromCnty := splitADIFCounty(rec["my_cnty"]); fromCnty != "" {
			q.MyState = fromCnty
		}
	}
	q.MyIOTA = strings.ToUpper(rec["my_iota"])
	q.MySOTARef = strings.ToUpper(rec["my_sota_ref"])
	q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"])
	if v, ok := parseInt(rec["my_dxcc"]); ok {
		q.MyDXCC = &v
	}
	if v, ok := parseInt(rec["my_cq_zone"]); ok {
		q.MyCQZone = &v
	}
	if v, ok := parseInt(rec["my_itu_zone"]); ok {
		q.MyITUZone = &v
	}
	if v, ok := parseFloat(rec["my_lat"]); ok {
		q.MyLat = &v
	}
	if v, ok := parseFloat(rec["my_lon"]); ok {
		q.MyLon = &v
	}
	q.MyStreet = rec["my_street"]
	q.MyCity = rec["my_city"]
	q.MyPostalCode = rec["my_postal_code"]
	q.MyRig = rec["my_rig"]
	q.MyAntenna = rec["my_antenna"]

	// Misc
	if v, ok := parseFloat(rec["tx_pwr"]); ok {
		q.TXPower = &v
	}
	q.Comment = rec["comment"]
	q.Notes = rec["notes"]

	// ADIF 3.1.7 additional promoted fields
	q.SIG = rec["sig"]
	q.SIGInfo = rec["sig_info"]
	q.MySIG = rec["my_sig"]
	q.MySIGInfo = rec["my_sig_info"]
	q.WWFFRef = strings.ToUpper(rec["wwff_ref"])
	q.MyWWFFRef = strings.ToUpper(rec["my_wwff_ref"])
	if v, ok := parseFloat(rec["distance"]); ok {
		q.Distance = &v
	}
	if v, ok := parseFloat(rec["rx_pwr"]); ok {
		q.RXPower = &v
	}
	if v, ok := parseFloat(rec["a_index"]); ok {
		q.AIndex = &v
	}
	if v, ok := parseFloat(rec["k_index"]); ok {
		q.KIndex = &v
	}
	if v, ok := parseFloat(rec["sfi"]); ok {
		q.SFI = &v
	}
	q.SKCC = rec["skcc"]
	q.FISTS = rec["fists"]
	q.TenTen = rec["ten_ten"]
	q.ContactedOp = strings.ToUpper(rec["contacted_op"])
	q.EqCall = strings.ToUpper(rec["eq_call"])
	q.PFX = strings.ToUpper(rec["pfx"])
	q.MyName = rec["my_name"]
	q.Class = rec["class"]
	q.DarcDOK = rec["darc_dok"]
	q.MyDarcDOK = rec["my_darc_dok"]
	q.Region = rec["region"]
	q.SilentKey = strings.ToUpper(rec["silent_key"])
	q.SWL = strings.ToUpper(rec["swl"])
	q.QSOComplete = rec["qso_complete"]
	q.QSORandom = strings.ToUpper(rec["qso_random"])
	q.CreditGranted = rec["credit_granted"]
	q.CreditSubmitted = rec["credit_submitted"]
	q.MyARRLSect = strings.ToUpper(rec["my_arrl_sect"])
	q.MyVUCCGrids = strings.ToUpper(rec["my_vucc_grids"])

	// Everything else lands in extras (uppercased ADIF names).
	var extras map[string]string
	for k, v := range rec {
		if _, ok := adifPromoted[k]; ok {
			continue
		}
		v = strings.TrimSpace(v)
		if v == "" {
			continue
		}
		if extras == nil {
			extras = map[string]string{}
		}
		extras[strings.ToUpper(k)] = v
	}
	q.Extras = extras

	return q, true
}

// parseDateTime combines ADIF QSO_DATE (YYYYMMDD) with TIME (HHMMSS or HHMM).
func parseDateTime(date, timeStr string) time.Time {
	date = strings.TrimSpace(date)
	timeStr = strings.TrimSpace(timeStr)
	if len(date) != 8 {
		return time.Time{}
	}
	layout := "20060102"
	val := date
	if len(timeStr) == 4 {
		layout = "200601021504"
		val = date + timeStr
	} else if len(timeStr) == 6 {
		layout = "20060102150405"
		val = date + timeStr
	}
	t, err := time.ParseInLocation(layout, val, time.UTC)
	if err != nil {
		return time.Time{}
	}
	return t.UTC()
}

func parseFreqHz(s string) (int64, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, false
	}
	mhz, err := strconv.ParseFloat(s, 64)
	if err != nil || mhz <= 0 {
		return 0, false
	}
	return int64(mhz*1_000_000 + 0.5), true
}

func parseInt(s string) (int, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, false
	}
	v, err := strconv.Atoi(s)
	if err != nil {
		return 0, false
	}
	return v, true
}

func parseFloat(s string) (float64, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, false
	}
	v, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return 0, false
	}
	return v, true
}

// promotableSubmodes are SUBMODE values that uniquely identify the mode
// every logger displays — the ADIF parent (MFSK, DATA, PSK, JT…) is
// generic and unhelpful to show. SSB's USB/LSB and RTTY's JOH are NOT
// here because operators want to see "SSB" / "RTTY", not the sideband
// or RTTY variant.
var promotableSubmodes = map[string]bool{
	// MFSK family (modern FT/Q65/JS8/MSK144 live here)
	"FT2": true, "FT4": true, "FT8": true, "JS8": true, "MSK144": true, "ISCAT": true,
	"Q65": true, "FST4": true, "FST4W": true,
	"MFSK16": true, "MFSK32": true, "MFSK64": true, "MFSK128": true,
	"OLIVIA": true,
	// JT family (some loggers still parent these under DATA)
	"JT65": true, "JT9": true, "JT4": true, "JT6M": true, "JT44": true, "T10": true,
	// PSK family
	"PSK31": true, "PSK63": true, "PSK125": true, "PSK250": true, "PSK500": true,
	"QPSK31": true, "QPSK63": true, "QPSK125": true, "QPSK250": true, "QPSK500": true,
	// THOR / DOMINO / HELL
	"THOR4": true, "THOR8": true, "THOR16": true, "THOR32": true,
	"DOMINOF": true, "DOMINOEX": true,
	"HELL80": true, "FMHELL": true,
	// DIGITALVOICE variants
	"FREEDV": true,
	// VARA family — Log4OM parents these under DYNAMIC. VarAC chat uses
	// "VARA HF" (with the space) so we match both spellings.
	"VARA": true, "VARA HF": true, "VARA FM": true, "VARAC": true,
}

func submodeSubsumesParent(submode string) bool {
	return promotableSubmodes[submode]
}

// splitADIFCounty reads CNTY, which ADIF defines as "STATE,COUNTY".
//
// Returns the county on its own plus the state the field carried, so an import
// from a logger that follows the standard fills BOTH columns here. Files that
// write the bare county — as OpsLog itself used to — still import unchanged.
//
// The state from the field never overrides an explicit STATE tag: STATE is the
// dedicated field and the more specific statement. It only fills a blank.
func splitADIFCounty(cnty string) (county, state string) {
	cnty = strings.TrimSpace(cnty)
	i := strings.Index(cnty, ",")
	if i < 0 {
		return cnty, ""
	}
	return strings.TrimSpace(cnty[i+1:]), strings.ToUpper(strings.TrimSpace(cnty[:i]))
}
