// Package dxcc resolves a callsign to its DXCC entity (country, CQ/ITU
// zones, continent) by longest-prefix-matching against cty.dat — the
// canonical prefix database maintained by AD1C at country-files.com,
// the same file that every contest / logger consumes.
//
// The parser is line-oriented and tolerant: it handles cty.dat's
// per-prefix overrides ((CQ), [ITU], <lat/lon>, {Cont}) and the
// "=CALL" exact-callsign entries. Common operating suffixes (/P, /MM,
// /5, …) are stripped before matching.
package dxcc

import (
	"bufio"
	"io"
	"sort"
	"strconv"
	"strings"
	"sync"
)

// Entity is one DXCC entity entry from cty.dat.
type Entity struct {
	Name      string  `json:"name"`
	Continent string  `json:"continent"`
	CQZone    int     `json:"cqz"`
	ITUZone   int     `json:"ituz"`
	Lat       float64 `json:"lat"`
	Lon       float64 `json:"lon"`
	TZOffset  float64 `json:"tz_offset"`
	Primary   string  `json:"primary_prefix"` // canonical short prefix (F, DL, K, …)
}

// Match is the resolved DXCC info for a callsign. Per-prefix overrides
// from cty.dat are baked in; the Entity pointer is the unmodified parent.
type Match struct {
	Entity    *Entity `json:"entity"`
	Prefix    string  `json:"matched_prefix"`
	CQZone    int     `json:"cqz"`
	ITUZone   int     `json:"ituz"`
	Continent string  `json:"continent"`
	Lat       float64 `json:"lat"`
	Lon       float64 `json:"lon"`
	// Exact marks a hit on cty.dat's "=CALLSIGN" list rather than on a prefix.
	//
	// The two carry very different authority. A prefix match is a rule of thumb
	// about a block of callsigns; an exact entry is somebody having looked at
	// THIS callsign and written down where it was. A second country file may
	// improve on the first kind and should not be allowed to overrule the
	// second.
	Exact bool `json:"exact,omitempty"`
}

type prefixEntry struct {
	prefix       string
	entity       *Entity
	cqOverride   int
	ituOverride  int
	contOverride string
	latOverride  float64
	lonOverride  float64
	hasLatLon    bool
}

// DB is a parsed cty.dat ready for lookups.
type DB struct {
	mu       sync.RWMutex
	entities []*Entity
	exact    map[string]prefixEntry // "=CALLSIGN" entries
	byPrefix []prefixEntry          // sorted longest first
}

// Load parses a cty.dat stream. Safe to call once at startup.
func Load(r io.Reader) (*DB, error) {
	db := &DB{exact: make(map[string]prefixEntry)}
	sc := bufio.NewScanner(r)
	// cty.dat lines can be ~2 KB after wrapping; default 64 KB buffer is fine
	// but we bump it to be safe.
	sc.Buffer(make([]byte, 64*1024), 1024*1024)

	var current *Entity
	var buf strings.Builder

	for sc.Scan() {
		line := strings.TrimRight(sc.Text(), "\r")
		if line == "" {
			continue
		}
		// Entity header lines start at column 0; continuation lines are
		// indented (cty.dat uses 4 spaces).
		if line[0] != ' ' && line[0] != '\t' {
			if e := parseEntityHeader(line); e != nil {
				db.entities = append(db.entities, e)
				current = e
				buf.Reset()
			}
			continue
		}
		if current == nil {
			continue
		}
		buf.WriteString(strings.TrimSpace(line))
		// An entity's prefix list ends with ';' — possibly on a later line.
		if strings.HasSuffix(strings.TrimSpace(line), ";") {
			text := strings.TrimSuffix(buf.String(), ";")
			for _, raw := range strings.Split(text, ",") {
				raw = strings.TrimSpace(raw)
				if raw == "" {
					continue
				}
				entry, exact := parsePrefix(raw, current)
				if exact {
					db.exact[entry.prefix] = entry
				} else {
					db.byPrefix = append(db.byPrefix, entry)
				}
			}
			buf.Reset()
		}
	}
	if err := sc.Err(); err != nil {
		return nil, err
	}
	// Longest prefix first so HasPrefix wins on the most specific match.
	sort.Slice(db.byPrefix, func(i, j int) bool {
		return len(db.byPrefix[i].prefix) > len(db.byPrefix[j].prefix)
	})
	return db, nil
}

// Entities returns the parsed entity list (read-only).
func (db *DB) Entities() []*Entity {
	db.mu.RLock()
	defer db.mu.RUnlock()
	return db.entities
}

// Lookup resolves a callsign to its DXCC match using longest-prefix-match.
// Strips operating suffixes (/P, /MM, /5…) and "operating-from" prefixes
// (DL/F4NIE → uses DL). Returns false if no prefix matches.
func (db *DB) Lookup(callsign string) (Match, bool) {
	db.mu.RLock()
	defer db.mu.RUnlock()
	// Maritime and aeronautical mobile belong to NO entity, and that is not a
	// technicality — the station is at sea or in the air. RI1FJL/MM resolved to
	// Franz Josef Land while the expedition was still on its way there, telling
	// the operator they had worked an entity they had not.
	//
	// The previous choice was deliberate ("the log should still show the
	// operator's country") and it is wrong for exactly that reason: a home
	// country here is not extra information, it is a false claim about where the
	// contact happened.
	if IsMobileNoEntity(callsign) {
		return Match{}, false
	}
	call := normalizeCallsign(callsign)
	if call == "" {
		return Match{}, false
	}
	if e, ok := db.exact[call]; ok {
		m := materialize(e)
		m.Exact = true
		return m, true
	}
	// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
	// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
	// cty.dat carries a bare "KG4" prefix for Guantanamo, so for the other
	// suffix lengths we must skip it and fall through to the USA prefixes.
	skipKG4 := strings.HasPrefix(call, "KG4") && len(call) != len("KG4")+2
	for _, p := range db.byPrefix {
		if skipKG4 && p.prefix == "KG4" {
			continue
		}
		if strings.HasPrefix(call, p.prefix) {
			return materialize(p), true
		}
	}
	return Match{}, false
}

func materialize(e prefixEntry) Match {
	m := Match{
		Entity:    e.entity,
		Prefix:    e.prefix,
		CQZone:    e.entity.CQZone,
		ITUZone:   e.entity.ITUZone,
		Continent: e.entity.Continent,
		Lat:       e.entity.Lat,
		Lon:       e.entity.Lon,
	}
	if e.cqOverride != 0 {
		m.CQZone = e.cqOverride
	}
	if e.ituOverride != 0 {
		m.ITUZone = e.ituOverride
	}
	if e.contOverride != "" {
		m.Continent = e.contOverride
	}
	if e.hasLatLon {
		m.Lat = e.latOverride
		m.Lon = e.lonOverride
	}
	return m
}

// parseEntityHeader parses the colon-separated entity line:
//
//	"France:    14:  27:  EU:   46.00:    -2.00:    -1.0:  F:"
func parseEntityHeader(line string) *Entity {
	parts := strings.Split(line, ":")
	if len(parts) < 8 {
		return nil
	}
	name := strings.TrimSpace(parts[0])
	primary := strings.TrimSpace(parts[7])
	// cty.dat marks non-DXCC entities (WAE / contest-only zone splits such
	// as Sicily *IT9 and African Italy *IG9) with a leading '*' on the
	// primary prefix. Those report under their parent DXCC entity. True
	// DXCC entities — including Sardinia (IS0) and Corsica (TK) — have no
	// '*' and keep their own name. Per-prefix zones/lat-lon are preserved,
	// so e.g. IG9 still resolves to CQ 33 / continent AF under "Italy".
	if strings.HasPrefix(primary, "*") {
		primary = strings.TrimPrefix(primary, "*")
		name = CanonicalEntityName(name)
	}
	e := &Entity{
		Name:      name,
		Continent: strings.TrimSpace(parts[3]),
		Primary:   primary,
	}
	e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
	e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
	e.Lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[4]), 64)
	// cty.dat longitude is "+ for West" (e.g. France 2°E = -2.00, USA 92°W =
	// +91.87). Negate it to the standard "+ for East" the rest of the app uses
	// (grids, bearing/distance math), otherwise every cty.dat-derived azimuth and
	// cluster distance is mirrored east↔west.
	if lon, err := strconv.ParseFloat(strings.TrimSpace(parts[5]), 64); err == nil {
		e.Lon = -lon
	}
	e.TZOffset, _ = strconv.ParseFloat(strings.TrimSpace(parts[6]), 64)
	if e.Name == "" {
		return nil
	}
	return e
}

// parsePrefix peels off cty.dat per-prefix annotations:
//
//	K (5)[7]<35.50/-95.00>{NA}~America/Chicago~
//	=W1AW
func parsePrefix(s string, e *Entity) (prefixEntry, bool) {
	out := prefixEntry{entity: e}
	exact := false
	if strings.HasPrefix(s, "=") {
		exact = true
		s = s[1:]
	}
	// Strip annotations. Order them roughly so we extract before they appear
	// in the prefix slice.
	s = stripAnnotation(s, '(', ')', func(v string) {
		out.cqOverride, _ = strconv.Atoi(v)
	})
	s = stripAnnotation(s, '[', ']', func(v string) {
		out.ituOverride, _ = strconv.Atoi(v)
	})
	s = stripAnnotation(s, '<', '>', func(v string) {
		if a, b, ok := strings.Cut(v, "/"); ok {
			lat, e1 := strconv.ParseFloat(a, 64)
			lon, e2 := strconv.ParseFloat(b, 64)
			if e1 == nil && e2 == nil {
				// Same "+ for West" → "+ for East" flip as the entity header.
				out.latOverride, out.lonOverride = lat, -lon
				out.hasLatLon = true
			}
		}
	})
	s = stripAnnotation(s, '{', '}', func(v string) {
		out.contOverride = strings.TrimSpace(v)
	})
	s = stripAnnotation(s, '~', '~', func(_ string) { /* timezone — ignore */ })
	out.prefix = strings.ToUpper(strings.TrimSpace(s))
	return out, exact
}

// stripAnnotation removes a single ...X...Y... block and invokes cb with the
// inner text. Used for (CQ), [ITU], <lat/lon>, {cont}, ~tz~ annotations.
func stripAnnotation(s string, open, close rune, cb func(string)) string {
	i := strings.IndexRune(s, open)
	if i < 0 {
		return s
	}
	j := strings.IndexRune(s[i+1:], close)
	if j < 0 {
		return s
	}
	cb(s[i+1 : i+1+j])
	return s[:i] + s[i+1+j+1:]
}

// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
// operator's home DXCC. A TRAILING /MM or /AM (maritime/aeronautical mobile) is
// handled in normalizeCallsign (stripped, home entity kept) so a LEADING "MM"
// (the Scotland prefix) isn't mistaken for it.
var suffixModifiers = map[string]bool{
	"P": true, "M": true, "QRP": true, "A": true,
	"PM": true, "LH": true,
}

// normalizeCallsign uppercases, trims, and resolves the "active" call when the
// operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). A trailing /MM or /AM
// (maritime/aeronautical mobile) is stripped so the home entity still resolves
// (YB1SCY/AM → YB1SCY → Indonesia) — strict DXCC says no entity, but the log
// should still show the operator's country.
func normalizeCallsign(s string) string {
	s = strings.ToUpper(strings.TrimSpace(s))
	if !strings.ContainsRune(s, '/') {
		return s
	}
	parts := strings.Split(s, "/")
	keep := parts[:0]
	var areaDigit byte // a single-digit "/N" re-homes the call to call area N
	for i, p := range parts {
		if p == "" {
			continue
		}
		// A TRAILING /MM (maritime) or /AM (aeronautical) mobile, or /B (beacon), is
		// stripped and the operator's home entity is kept, so the contact still
		// resolves to a country in the log (e.g. YB1SCY/AM → Indonesia, 4U1UN/B →
		// 4U1UN → United Nations HQ). A LEADING "MM"/"B" is a PREFIX (MM = Scotland,
		// B = China: B/F4NIE) and must NOT be stripped.
		if i > 0 && (p == "MM" || p == "AM" || p == "B") {
			continue
		}
		if suffixModifiers[p] {
			continue
		}
		if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
			areaDigit = p[0]
			continue
		}
		keep = append(keep, p)
	}
	var main string
	switch len(keep) {
	case 0:
		return s
	case 1:
		main = keep[0]
	default:
		// Two non-modifier parts → operating-from prefix wins (shorter one).
		// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 → W6.
		if len(keep[0]) <= len(keep[1]) {
			main = keep[0]
		} else {
			main = keep[1]
		}
	}
	// Apply the call-area digit: "/N" replaces the area digit of the base call,
	// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
	// Ecuador). This is the same class of rule as KG4 and /MM.
	if areaDigit != 0 {
		main = replaceAreaDigit(main, areaDigit)
	}
	return main
}

// replaceAreaDigit substitutes the CALL-AREA digit of a call with d (used to
// apply a "/N" call-area change). The area digit is the first digit that comes
// AFTER a prefix letter — NOT a leading digit that is part of a digit-first
// prefix (7X, 3A, 4X, 9A, 2E…). So 7X2ARA/4 → 7X4ARA (still Algeria, area 4),
// NOT 4X2ARA (which is Israel — the old first-digit bug). Returns the call
// unchanged if it has no such digit.
func replaceAreaDigit(call string, d byte) string {
	b := []byte(call)
	seenLetter := false
	for i := range b {
		switch {
		case b[i] >= 'A' && b[i] <= 'Z':
			seenLetter = true
		case b[i] >= '0' && b[i] <= '9' && seenLetter:
			b[i] = d
			return string(b)
		}
	}
	return call
}

// IsMobileNoEntity reports a callsign DXCC assigns to no entity: a TRAILING
// /MM (maritime mobile) or /AM (aeronautical mobile).
//
// Trailing only. A LEADING "MM" is the Scotland prefix and "AM" is Spain, so
// MM0ABC and AM5X are ordinary calls — treating those as entity-less would be a
// far larger error than the one this fixes.
func IsMobileNoEntity(callsign string) bool {
	s := strings.ToUpper(strings.TrimSpace(callsign))
	i := strings.LastIndex(s, "/")
	if i < 0 {
		return false
	}
	switch s[i+1:] {
	case "MM", "AM":
		return true
	}
	return false
}
