// Package lookup queries callsign databases (QRZ.com, HamQTH) and caches
// results locally so we don't re-hit the network for known calls.
package lookup

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"math"
	"strings"
	"sync"
	"time"
	"unicode"

	"hamlog/internal/db"
)

// ErrNotFound is returned by providers when a callsign is unknown.
var ErrNotFound = errors.New("callsign not found")

// Result is the normalized lookup output regardless of provider.
type Result struct {
	Callsign  string  `json:"callsign"`
	Name      string  `json:"name,omitempty"`
	QTH       string  `json:"qth,omitempty"`
	Address   string  `json:"address,omitempty"`
	State     string  `json:"state,omitempty"`
	County    string  `json:"cnty,omitempty"`
	Country   string  `json:"country,omitempty"`
	Grid      string  `json:"grid,omitempty"`
	Lat       float64 `json:"lat,omitempty"`
	Lon       float64 `json:"lon,omitempty"`
	DXCC      int     `json:"dxcc,omitempty"`
	CQZ       int     `json:"cqz,omitempty"`
	ITUZ      int     `json:"ituz,omitempty"`
	Continent string  `json:"cont,omitempty"`
	Email     string  `json:"email,omitempty"`
	QSLVia    string  `json:"qsl_via,omitempty"`
	// Web is the operator's own site. The QSO table has had a `web` column all
	// along and nothing ever filled it, because no provider mapping read the
	// field.
	Web string `json:"web,omitempty"`
	// IOTA is the island reference (EU-048) for an operator on one. QRZ sends it
	// and nothing used to read it — and since no live activation feed exists for
	// IOTA the way it does for POTA, the callbook record is the practical source.
	IOTA string `json:"iota,omitempty"`
	// RDA is the Russian District Award reference (KE-29). HamQTH publishes it
	// as <oblast>, described in their own documentation as "something like
	// district (Russian stations)"; QRZ.com has no equivalent — its county and
	// state fields are USA-only.
	//
	// It is the CURRENT district, which is why it may only ever be applied to a
	// contact being made now. A Russian station can move, and stamping today's
	// district on a three-year-old QSO rewrites a correct contact into a wrong
	// one. DXLab solves the general case with dated activity records; we solve
	// the case that matters by never dating anything but the present.
	RDA string `json:"rda,omitempty"`
	// Zip is the postal code. HamQTH and QRZ both send one.
	Zip       string    `json:"zip,omitempty"`
	ImageURL  string    `json:"image_url,omitempty"` // profile picture URL
	Source    string    `json:"source"`              // "qrz", "hamqth", or "cache"
	FetchedAt time.Time `json:"fetched_at"`
}

// Provider is the contract implemented by QRZ, HamQTH, etc.
type Provider interface {
	Name() string
	Lookup(ctx context.Context, callsign string) (Result, error)
}

// DXCCResolver fills the country / zones / continent when the providers
// don't (or when no provider returned anything). Decoupled via interface so
// `lookup` doesn't import the dxcc package directly.
type DXCCResolver interface {
	Resolve(callsign string) (dxccNum int, country, continent string, cqz, ituz int, lat, lon float64, ok bool)
}

// Manager composes a cache with one or more providers.
// Lookup tries the cache first, then each enabled provider in order.
type Manager struct {
	mu        sync.RWMutex
	providers []Provider
	cache     *Cache
	dxcc      DXCCResolver
}

func NewManager(cache *Cache) *Manager {
	return &Manager{cache: cache}
}

// SetDXCCResolver wires the cty.dat-backed fallback that fills country/
// zones when the provider chain comes up dry or short.
func (m *Manager) SetDXCCResolver(r DXCCResolver) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.dxcc = r
}

// SetProviders replaces the provider chain. Safe to call at any time
// (e.g. after the user updates credentials in settings).
func (m *Manager) SetProviders(p ...Provider) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.providers = p
}

// Lookup returns a Result for the callsign. Falls back through providers
// when one returns ErrNotFound or fails.
// forceKey marks a context as a FORCED (operator-requested) lookup, which
// bypasses the cache on the way in and refreshes it on the way out. Carried on
// the context rather than as a parameter so every existing caller — and the
// Provider interface — stays untouched.
type forceKey struct{}

// WithForce returns a context that makes Lookup skip the cache.
func WithForce(ctx context.Context) context.Context {
	return context.WithValue(ctx, forceKey{}, true)
}

func isForced(ctx context.Context) bool {
	v, _ := ctx.Value(forceKey{}).(bool)
	return v
}

func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
	call := strings.ToUpper(strings.TrimSpace(callsign))
	if call == "" {
		return Result{}, fmt.Errorf("empty callsign")
	}

	m.mu.RLock()
	providers := append([]Provider(nil), m.providers...)
	dxcc := m.dxcc
	m.mu.RUnlock()

	// A FORCED lookup skips the cache. The cache is right for the automatic
	// lookup that fires as you type, but it also freezes a wrong answer for the
	// whole TTL: an operator who upgraded their QRZ subscription kept getting the
	// thin free-account record for a month, and clearing the cache by hand was
	// the only way out. A lookup the operator asked for by clicking is a
	// deliberate act and must reach the provider.
	if !isForced(ctx) {
		if r, ok := m.cache.Get(ctx, call); ok {
			r.Source = "cache"
			// Re-assert the authoritative DXCC fields (country/zones/continent)
			// from cty.dat on every cache hit — cheap (in-memory) and lets a
			// corrected entity mapping (e.g. Sicily → Italy) heal stale cached
			// rows without waiting for the TTL to expire.
			fillFromDXCC(&r, dxcc)
			normalizeNames(&r)
			return r, nil
		}
	}

	var lastErr error
	// An operational suffix (/M, /P, …) is never registered as such: skip the
	// futile query on the slashed form and let the home-call pass below do the one
	// request that can actually answer.
	_, opOnly := stripOpSuffix(call)
	if opOnly {
		LogSink("lookup: %s carries only an operational suffix — querying the bare call", call)
	} else {
		for _, p := range providers {
			r, err := p.Lookup(ctx, call)
			if err == nil {
				r.Callsign = call
				r.Source = p.Name()
				r.FetchedAt = time.Now().UTC()
				normalizeNames(&r)
				// Cached BEFORE the cty.dat pass, so the row is a copy of the
				// callbook page rather than of our conclusions about it. Every
				// read runs the pass again (see the cache-hit path above), so a
				// later cty.dat update reaches old rows — and a value we derived
				// can never come back looking like something the page said.
				_ = m.cache.Put(ctx, r)
				fillFromDXCC(&r, dxcc)
				return r, nil
			}
			if errors.Is(err, ErrNotFound) {
				lastErr = err
				continue
			}
			lastErr = fmt.Errorf("%s: %w", p.Name(), err)
		}
	}

	// Portable / slashed call not found under its full form: the operator's
	// record lives under the HOME call (JW/OR1A → OR1A, DL/F4NIE → F4NIE). Look
	// THAT up for the name/QTH/QSL info, then overwrite the location-determining
	// fields with the SLASHED call's entity (JW = Svalbard, not OR1A's Belgium).
	if home := homeCall(call); home != "" && home != call {
		for _, p := range providers {
			r, err := p.Lookup(ctx, home)
			if err != nil {
				// Logged, because this is where a portable lookup silently dies: the
				// error is swallowed to try the next provider, and the operator only
				// ever sees the cty.dat fallback with no clue why.
				LogSink("lookup: %s → home call %s failed on %s: %v", call, home, p.Name(), err)
				continue
			}
			r.Callsign = call
			r.Source = p.Name()
			r.FetchedAt = time.Now().UTC()
			// The home record's location is the operator's HOME — clear it so
			// cty.dat fills in where they actually are.
			//
			// UNLESS the suffix says nothing about location. /QRP is a statement
			// about power, not about place: M0BFS/QRP is M0BFS, at home, running
			// five watts. Wiping the grid there threw away the one field the
			// operator was looking the call up for, and it came back empty while
			// the same lookup without the suffix answered perfectly.
			if !saysNothingAboutLocation(call) {
				clearHomeLocation(&r)
			}
			normalizeNames(&r)
			_ = m.cache.Put(ctx, r) // the page as it was; cty.dat is applied on read
			fillFromDXCC(&r, dxcc)  // entity/zones/lat-lon from the FULL (slashed) call
			return r, nil
		}
	}

	// All providers exhausted (not-found or errored). Try the cty.dat
	// resolver as a last resort — at least we can hand back country/zones
	// even for unknown callsigns. Not cached: a "cty.dat-only" result
	// shouldn't suppress a later real lookup if the user adds creds.
	if dxcc != nil {
		var r Result
		r.Callsign = call
		if fillFromDXCC(&r, dxcc) {
			r.Source = "cty.dat"
			r.FetchedAt = time.Now().UTC()
			return r, nil
		}
	}

	if lastErr == nil {
		if len(providers) == 0 {
			lastErr = fmt.Errorf("no lookup provider configured")
		} else {
			lastErr = ErrNotFound
		}
	}
	return Result{}, lastErr
}

// LogSink receives this package's diagnostic lines (which call was actually
// queried, and why a lookup fell back). Set to applog.Printf by the app.
var LogSink = func(string, ...any) {}

// opSuffixes are OPERATIONAL suffixes: they describe how the operator is working
// — mobile, maritime, aeronautical, portable, low power — not who they are or
// where. No provider has a record filed under "F4LYI/M", so querying that form
// is a round trip that cannot succeed.
//
// It was worse than merely wasted: it spent the lookup's time budget, so the
// home-call retry that followed ran out of time and the entry fell back to
// cty.dat for every /M and /P call, even though the operator was on QRZ. These
// go straight to the bare callsign instead.
//
// Everything else after a slash is NOT this: JW/, VP8/ change the DXCC entity,
// and /8 or /W6 change the call area. Those forms can be registered in their own
// right and must be looked up exactly as entered.
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}

// nonLocationSuffixes say nothing about WHERE the operator is.
//
// /QRP is a statement about power. /M and /P and their kin are not: mobile and
// portable both mean "somewhere other than the home station", which is exactly
// why the home record's location is discarded for them. Keeping that distinction
// is the difference between a grid that is stale and a grid that is absent.
var nonLocationSuffixes = map[string]bool{"QRP": true}

// saysNothingAboutLocation reports a call whose every suffix leaves the operator
// at their registered address — so the home record's location can be trusted.
func saysNothingAboutLocation(call string) bool {
	parts := strings.Split(strings.ToUpper(strings.TrimSpace(call)), "/")
	if len(parts) < 2 {
		return false
	}
	base := strings.TrimSpace(parts[0])
	if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
		return false // "JW/OR1A": the first part is a prefix — a location change
	}
	for _, p := range parts[1:] {
		if !nonLocationSuffixes[strings.TrimSpace(p)] {
			return false
		}
	}
	return true
}

// stripOpSuffix returns the bare callsign when call carries nothing but
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
// part isn't callsign-shaped.
func stripOpSuffix(call string) (string, bool) {
	if !strings.ContainsRune(call, '/') {
		return call, false
	}
	parts := strings.Split(call, "/")
	base := strings.TrimSpace(parts[0])
	if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
		return call, false // "JW/OR1A": the first part is a prefix, not the callsign
	}
	for _, p := range parts[1:] {
		if !opSuffixes[strings.ToUpper(strings.TrimSpace(p))] {
			return call, false
		}
	}
	return base, true
}

// homeCall extracts the operator's home callsign from a slashed/portable call
// so its provider record (name/QTH/QSL) can be fetched when the full form isn't
// registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO →
// F4BPO. The home call is the "/"-part that looks like a real callsign (has a
// digit, ≥3 chars); the longest such part wins (handles PREFIX/HOMECALL where
// both could qualify, e.g. VP8/F4BPO). Returns "" if none qualifies.
func homeCall(call string) string {
	if !strings.ContainsRune(call, '/') {
		return call
	}
	best := ""
	for _, p := range strings.Split(call, "/") {
		p = strings.TrimSpace(p)
		if len(p) < 3 || !strings.ContainsAny(p, "0123456789") {
			continue // a prefix (JW, DL) or a suffix (P, M, MM, QRP, a digit)
		}
		if len(p) > len(best) {
			best = p
		}
	}
	return best
}

// normalizeNames title-cases the human-readable text fields so a QRZ/HamQTH
// reply in ALL CAPS ("NOEL CHENAVARD", "VETRAZ-MONTHOUX") is stored and shown
// consistently ("Noel Chenavard", "Vetraz-Monthoux"). State/zones/grid are
// left untouched (codes like CT must stay as-is).
func normalizeNames(r *Result) {
	r.Name = titleCase(r.Name)
	r.QTH = titleCase(r.QTH)
	r.Address = titleCase(r.Address)
	// 3 decimals (~110 m) is plenty for a contact's coordinates and keeps
	// the displayed/exported value tidy.
	r.Lat = round3(r.Lat)
	r.Lon = round3(r.Lon)
}

func round3(f float64) float64 { return math.Round(f*1000) / 1000 }

// titleCase lowercases the whole string then capitalises the first letter of
// each word. Word boundaries are any non-alphanumeric rune (space, hyphen,
// apostrophe, slash…), so "vetraz-monthoux" → "Vetraz-Monthoux" and
// "o'brien" → "O'Brien". Digits never get a leading capital ("74140" stays).
func titleCase(s string) string {
	s = strings.TrimSpace(s)
	if s == "" {
		return ""
	}
	runes := []rune(strings.ToLower(s))
	atWordStart := true
	for i, r := range runes {
		switch {
		case unicode.IsLetter(r):
			if atWordStart {
				runes[i] = unicode.ToUpper(r)
			}
			atWordStart = false
		case unicode.IsDigit(r):
			atWordStart = false
		default:
			atWordStart = true
		}
	}
	return string(runes)
}

// fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from
// the cty.dat resolver. cty.dat is the authoritative source for DXCC
// mapping, so Country/Continent/CQZ/ITUZ are ALWAYS overridden when it
// has an answer — QRZ tends to return the political country (Greece for
// SV5*, Russia for UA9*) instead of the DXCC entity (Dodecanese,
// Asiatic Russia). Lat/Lon are filled only when empty so a more precise
// home QTH from QRZ wins over the cty.dat entity centroid.
//
// For slashed callsigns (IT9/DK6XZ, DL/F4NIE…) the provider returned the
// home-call's entity which is wrong for portable operations; we keep the
// Name/QTH/Address from the provider (still useful for QSL) but reset
// the DXCC number since QRZ's value is wrong and we don't have an entity
// → DXCC# table yet.
// Returns true if any field was filled.
// clearHomeLocation drops the fields that say WHERE a callbook record's operator
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is
// not wrong; their county is.
// sameEntityName reports whether two country names denote the same entity.
//
// The two come from different vocabularies — the callbook writes "Germany"
// where cty.dat writes "Fed. Rep. of Germany", "United States" where the ADIF
// list says "United States of America" — so they are compared on their
// significant words with the boilerplate of officialdom removed, and one
// containing the other counts as a match.
//
// Deliberately generous. Getting it wrong in the strict direction discards a
// correct grid, which is the fault this exists to fix; getting it wrong in the
// generous direction keeps a location from a neighbouring entity, which is the
// state of every callbook record that has no page for the portable call anyway.
func sameEntityName(a, b string) bool {
	na, nb := entityKey(a), entityKey(b)
	if na == "" || nb == "" {
		return false
	}
	return na == nb || strings.Contains(na, nb) || strings.Contains(nb, na)
}

var entityNoise = map[string]bool{
	"THE": true, "OF": true, "FED": true, "REP": true, "REPUBLIC": true,
	"FEDERAL": true, "FEDERATION": true, "DEM": true, "DEMOCRATIC": true,
	"STATE": true, "KINGDOM": true, "AMERICA": true,
}

// entityKey reduces a country name to its significant letters.
func entityKey(s string) string {
	s = strings.ToUpper(strings.TrimSpace(s))
	var b strings.Builder
	for _, tok := range strings.FieldsFunc(s, func(r rune) bool {
		return r == ' ' || r == '.' || r == ',' || r == '-'
	}) {
		if entityNoise[tok] {
			continue
		}
		b.WriteString(tok)
	}
	return b.String()
}

func clearHomeLocation(r *Result) {
	r.Country, r.Continent = "", ""
	r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
	r.Lat, r.Lon = 0, 0
	r.Grid, r.State, r.County = "", "", ""
}

func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
	if dxcc == nil {
		return false
	}
	dxccNum, country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
	if !ok {
		return false
	}
	filled := false

	// A subdivision belongs to an ENTITY, so a record describing one entity has
	// nothing to say about a station operating from another.
	//
	// TI8/W2RE came back as a Costa Rica contact in Dutchess County, New York, on
	// square FN31 — W2RE's home details, from a QRZ page that carries the home
	// mailing address as most portable pages do. CNTY is *defined* as a US
	// county, so that is not a cosmetic slip: it is a US county award credit
	// recorded against a Costa Rica QSO, and a distance and beam heading computed
	// from a square 3,600 km from where the station actually is.
	//
	// The home-call fallback above already clears these, but it only runs when NO
	// provider had the slashed form. An operator with a page for their portable
	// call never reached it. Cleared here instead, where the operating entity is
	// known — which also heals rows already in the cache, since every cache hit
	// comes back through here.
	//
	// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
	// and there the home details ARE where the operator is.
	// UNLESS THE RECORD IS ABOUT THE OPERATION ITSELF.
	//
	// Some compound calls have a callbook page of their own, filed under the
	// slashed form and describing where the station actually is: QRZ's HP/WE9G
	// carries Altos del Maria, Panama, square EJ98xq. Clearing that threw away
	// the one field the lookup existed to find, and the QSO was logged with no
	// grid at all while the page plainly showed one.
	//
	// The record's OWN country is what tells the two apart: a page for the
	// operation names the entity being operated from, a home page names home.
	if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
		if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
			if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
				if !sameEntityName(r.Country, country) {
					clearHomeLocation(r)
					filled = true
				}
			}
		}
	}
	if country != "" {
		r.Country = country
		filled = true
	}
	if cont != "" {
		r.Continent = cont
		filled = true
	}
	// Zones FILL, they do not override.
	//
	// The rule above is right for the country and wrong for the zones, because
	// they answer different questions. An entity is what a callsign IS, and
	// cty.dat is the authority on that. A zone is where the station SITS, and a
	// large entity has many: Asiatic Russia spans CQ 16 to 23 and ITU 20 to 34,
	// and cty.dat carries one representative pair for the whole country. Stamping
	// it on every UA0 threw away the callbook's per-station answer and recorded a
	// WAZ credit for a zone the operator had not worked — RU0LL is CQ 19, ITU 34,
	// and was logged 17/30.
	//
	// So the callbook wins where it spoke, and cty.dat fills the silence.
	if cqz != 0 && r.CQZ == 0 {
		r.CQZ = cqz
		filled = true
	}
	if ituz != 0 && r.ITUZ == 0 {
		r.ITUZ = ituz
		filled = true
	}
	if lat != 0 && r.Lat == 0 {
		r.Lat = lat
		filled = true
	}
	if lon != 0 && r.Lon == 0 {
		r.Lon = lon
		filled = true
	}
	// cty.dat is authoritative for the *operating* entity: it strips benign
	// suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
	// Use its DXCC# when known — this overrides the provider's home-call
	// value AND fixes portable calls like F4BPO/P (same entity, must keep
	// France's 227). Only when cty.dat can't map a slashed call do we drop
	// the provider's number rather than mislabel.
	if dxccNum != 0 {
		if r.DXCC != dxccNum {
			r.DXCC = dxccNum
			filled = true
		}
	} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
		r.DXCC = 0
		filled = true
	}
	return filled
}

// ----- Cache -----

// Cache is a SQLite-backed cache of lookup results with a TTL.
//
// A ttl of zero means NO CACHE: every lookup goes to the provider. That is a
// real thing to want — an operator correcting their own QRZ record, or chasing
// a DXpedition whose page changes during the operation, otherwise waits out the
// cache before OpsLog will look again.
type Cache struct {
	db  *sql.DB
	ttl time.Duration
}

// NewCache builds the cache. A ttl of zero here is the CONSTRUCTOR default
// (thirty days), not "off": at startup the settings have not been read yet, and
// starting with no cache would hammer the provider for the first seconds of
// every launch. Switching it off is a decision the operator makes, through
// SetTTL, once their settings are known.
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
	if ttl <= 0 {
		ttl = 30 * 24 * time.Hour
	}
	return &Cache{db: db, ttl: ttl}
}

// SetTTL updates the cache lifetime.
//
// ZERO switches the cache OFF — nothing is read from it and nothing is written
// to it. A NEGATIVE value is meaningless and is ignored, rather than being
// rounded into one of the two meanings above.
func (c *Cache) SetTTL(ttl time.Duration) {
	if ttl >= 0 {
		c.ttl = ttl
	}
}

// Enabled reports whether anything is being cached at all.
func (c *Cache) Enabled() bool { return c != nil && c.ttl > 0 }

// Get returns the cached result if present and not expired.
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
	if !c.Enabled() {
		return Result{}, false
	}
	row := c.db.QueryRowContext(ctx, `
        SELECT callsign, name, qth, address, state, cnty, country, grid,
               lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
               web, zip, iota, source, fetched_at
        FROM callsign_cache WHERE callsign = ?`, callsign)
	var (
		r                                         Result
		name, qth, addr, state, cnty              sql.NullString
		country, grid, cont, email, qslVia, image sql.NullString
		web, zip, iotaRef                         sql.NullString
		src                                       string
		dxcc, cqz, ituz                           sql.NullInt64
		lat, lon                                  sql.NullFloat64
		fetched                                   string
	)
	if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
		&country, &grid, &lat, &lon,
		&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image, &web, &zip, &iotaRef,
		&src, &fetched); err != nil {
		return Result{}, false
	}
	t, err := time.Parse("2006-01-02T15:04:05.000Z", fetched)
	if err != nil {
		t, _ = time.Parse(time.RFC3339, fetched)
	}
	if time.Since(t) > c.ttl {
		return Result{}, false
	}
	// A row written before the iota column existed has NULL there, and the cache
	// lasts thirty days — so without this every callsign already looked up would
	// go a month without its island reference, which is exactly what the first
	// test of the feature ran into.
	//
	// NULL and '' are deliberately different here: Put writes an empty string for
	// an operator with no island, so only a row that predates the column reads as
	// invalid. One refetch per such callsign, the next time it is used.
	if !iotaRef.Valid {
		return Result{}, false
	}
	r.Name = name.String
	r.QTH = qth.String
	r.Address = addr.String
	r.State = state.String
	r.County = cnty.String
	r.Country = country.String
	r.Grid = grid.String
	r.Lat = lat.Float64
	r.Lon = lon.Float64
	r.Continent = cont.String
	r.Email = email.String
	r.Web = web.String
	r.Zip = zip.String
	r.IOTA = strings.ToUpper(iotaRef.String)
	r.QSLVia = qslVia.String
	r.ImageURL = image.String
	r.DXCC = int(dxcc.Int64)
	r.CQZ = int(cqz.Int64)
	r.ITUZ = int(ituz.Int64)
	r.Source = src
	r.FetchedAt = t
	return r, true
}

// Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
func (c *Cache) Put(ctx context.Context, r Result) error {
	if !c.Enabled() {
		// Nothing reads it, so writing would only grow the table — and leave
		// stale rows waiting for the day the cache is switched back on.
		return nil
	}
	updateCols := []string{
		"name", "qth", "address", "state", "cnty",
		"country", "grid", "lat", "lon",
		"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip", "iota",
		"source", "fetched_at",
	}
	// The lookup cache always lives in the local SQLite database, so SQLite
	// upsert syntax is used unconditionally.
	sets := make([]string, len(updateCols))
	for i, c := range updateCols {
		sets[i] = c + " = excluded." + c
	}
	q := `
        INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
                                   country, grid, lat, lon,
                                   dxcc, cqz, ituz, cont, email, qsl_via, image_url,
                                   web, zip, iota, source, fetched_at)
        VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?,?, ?,?)
        ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
	_, err := c.db.ExecContext(ctx, q,
		r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
		nullable(r.State), nullable(r.County),
		nullable(r.Country), nullable(r.Grid),
		nullableFloat(r.Lat), nullableFloat(r.Lon),
		nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
		nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
		nullable(r.ImageURL), nullable(r.Web), nullable(r.Zip),
		// NOT nullable(): an operator with no island must store '', so that a NULL
		// keeps its one meaning — a row written before the column existed.
		r.IOTA,
		r.Source, db.NowISO(),
	)
	return err
}

func nullableFloat(f float64) any {
	if f == 0 {
		return nil
	}
	return f
}

// Clear empties the cache. Useful for "Refresh cache" admin actions.
func (c *Cache) Clear(ctx context.Context) error {
	_, err := c.db.ExecContext(ctx, `DELETE FROM callsign_cache`)
	return err
}

func nullable(s string) any {
	if s == "" {
		return nil
	}
	return s
}
func nullableInt(n int) any {
	if n == 0 {
		return nil
	}
	return n
}
