// Package rda resolves a Russian callsign to the RDA district it operated from
// ON A GIVEN DAY.
//
// # Why the day matters
//
// A Russian station can move district, and an expedition operates from a dozen
// in a fortnight. Asking "which district is this callsign in?" therefore has no
// single answer, and answering it with today's district would rewrite a correct
// three-year-old contact into a false one — silently, with nothing downstream
// able to tell. Every lookup here takes a date for that reason.
//
// # The data
//
// Compiled from RDA.mdb, the reference database distributed for the award
// (58 188 callsigns). Two shapes live in its RDA column and they mean different
// things:
//
//	KK-08                              the district the station operates from,
//	                                   with no history — the compiler knows of
//	                                   no change for this call
//	<2021-07-29,2021-07-29,TO-16>…     dated activity records, one per period
//
// 43 294 callsigns are of the first kind and 14 894 of the second. A dated
// record covering the QSO day is an ANSWER; a bare current district is an
// assumption, true for a station that never moved and wrong for one that did.
// Lookup reports which it gave, and the caller decides whether an assumption is
// good enough — see Match.Dated.
//
// # Regenerating
//
// There is no updater: the source is an Access database, and reading one needs
// cgo, which this project does not use. It is converted once, by hand, and the
// result embedded. To redo it with a newer RDA.mdb (PowerShell, needs the ACE
// OLEDB provider that ships with Office):
//
//	$conn = New-Object System.Data.OleDb.OleDbConnection(
//	  "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=RDA.mdb;")
//	SELECT CallSign, RDA, DXCCID FROM [RDACallsigns] ORDER BY CallSign
//
// writing one line per callsign as
//
//	CALL;DXCC;CURRENT;YYYYMMDD,YYYYMMDD,DIST;YYYYMMDD,YYYYMMDD,DIST…
//
// where CURRENT may be empty, then gzip it to rdadb.txt.gz.
package rda

import (
	"bufio"
	"compress/gzip"
	"embed"
	"strconv"
	"strings"
	"sync"
	"time"
)

//go:embed rdadb.txt.gz
var files embed.FS

// span is one dated activity record: the district, and the inclusive day range
// it applies to as YYYYMMDD integers (comparing those is comparing dates, with
// no timezone to get wrong).
type span struct {
	from, to uint32
	district string
}

type entry struct {
	dxcc    int
	current string // district with no history; "" when only dated records exist
	spans   []span
}

var (
	once  sync.Once
	byCal map[string]entry
	loadN int
)

// Match is what a lookup found.
type Match struct {
	// District is the RDA reference, e.g. "KE-29".
	District string
	// Dated is true when a dated activity record covered the day asked for.
	//
	// False means the district came from the callsign's CURRENT entry, which
	// carries no dates at all. For the great majority of stations that is the
	// same thing — the database records a history precisely for the ones that
	// moved — but it is an assumption and not a fact, and a caller writing into
	// a log years old should be told which of the two it is getting.
	Dated bool
	// DXCC is the entity the database files the callsign under (15, 54 or 126).
	DXCC int
}

func load() {
	byCal = make(map[string]entry, 60000)
	f, err := files.Open("rdadb.txt.gz")
	if err != nil {
		return
	}
	defer f.Close()
	zr, err := gzip.NewReader(f)
	if err != nil {
		return
	}
	defer zr.Close()
	sc := bufio.NewScanner(zr)
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // one expedition's history is long
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		parts := strings.Split(line, ";")
		if len(parts) < 3 {
			continue
		}
		e := entry{current: parts[2]}
		e.dxcc, _ = strconv.Atoi(parts[1])
		for _, p := range parts[3:] {
			f := strings.Split(p, ",")
			if len(f) != 3 {
				continue
			}
			from, err1 := strconv.ParseUint(f[0], 10, 32)
			to, err2 := strconv.ParseUint(f[1], 10, 32)
			if err1 != nil || err2 != nil {
				continue
			}
			e.spans = append(e.spans, span{from: uint32(from), to: uint32(to), district: f[2]})
		}
		byCal[parts[0]] = e
		loadN++
	}
}

// Count returns how many callsigns the database holds (0 before the first use).
func Count() int {
	once.Do(load)
	return loadN
}

// Lookup resolves a callsign on a given day.
//
// The match is EXACT on the callsign, deliberately. A suffix is not decoration:
// RA3YG/P is somewhere other than RA3YG by definition, and quietly falling back
// to the base call would answer with the home district for a contact made from
// a field — which is the precise error this package exists to avoid.
func Lookup(call string, day time.Time) (Match, bool) {
	once.Do(load)
	c := strings.ToUpper(strings.TrimSpace(call))
	if c == "" {
		return Match{}, false
	}
	e, ok := byCal[c]
	if !ok {
		return Match{}, false
	}
	d := ymd(day)
	// A dated record wins whenever one covers the day. Several can overlap — an
	// expedition working two districts in a day — and the FIRST is taken rather
	// than a guess between them: the database lists them in the order its
	// compiler recorded them, and inventing a tie-break here would be inventing
	// data.
	if d != 0 {
		for _, s := range e.spans {
			if d >= s.from && d <= s.to {
				return Match{District: s.district, Dated: true, DXCC: e.dxcc}, true
			}
		}
	}
	if e.current != "" {
		return Match{District: e.current, Dated: false, DXCC: e.dxcc}, true
	}
	// Dated records exist but none covers this day. That is an answer in itself
	// — the station was somewhere the database does not know about — and it is
	// reported as "not found" rather than by handing back a neighbouring period.
	return Match{}, false
}

// ymd turns a time into YYYYMMDD, or 0 for the zero time.
func ymd(t time.Time) uint32 {
	if t.IsZero() {
		return 0
	}
	t = t.UTC()
	return uint32(t.Year())*10000 + uint32(t.Month())*100 + uint32(t.Day())
}
