package main

// One-shot generator: emits internal/uls/ctcounty_gen.go, the Connecticut
// ZIP → legal county table. Not part of the build.
//
// Why it has to exist
// -------------------
// internal/uls resolves a US callsign to a county through GeoNames' ZIP table.
// The Census replaced Connecticut's eight counties with nine PLANNING REGIONS
// as county-equivalents in 2022, and GeoNames followed: 418 of Connecticut's
// 429 ZIPs now report "Capitol Region", "Lower Connecticut River Valley" and
// so on. CQ's USA-CA award did not follow, callbooks did not follow, and no
// operator's log did either — so every Connecticut station resolved to a name
// nothing could match, showed as a new county for ever, and counted for no
// award. Roughly 15 000 US callsigns.
//
// A planning region is NOT a renamed county — it is built from towns drawn
// from several different counties — so there is no name-to-name mapping to be
// had. The ZIP is the only handle, hence this table.
//
// Sources (both public, both free)
// --------------------------------
//	geonames  US.txt from https://download.geonames.org/export/zip/US.zip
//	          — the ZIP list itself, and each ZIP's centroid.
//	crosswalk https://www2.census.gov/geo/docs/maps-data/data/rel2020/
//	          zcta520/tab20_zcta520_county20_natl.txt
//	          — the 2020 ZCTA↔county relationship file, which predates the
//	            change and therefore still carries the eight real counties.
//
// The crosswalk covers 278 of the 418 affected ZIPs. The rest are PO-box-only
// ZIPs, which have no ZCTA at all; each is given the county of the nearest
// resolved ZIP centroid. That is sound here because a PO-box ZIP sits inside
// the town it serves, and Connecticut's counties are tens of kilometres across
// — the fallback can only err on a ZIP that straddles a county line, which the
// ZIP-to-county approach is already documented as accepting (~98%).
//
// Usage:
//
//	go run ./cmd/ctzipgen US.txt tab20_zcta520_county20_natl.txt > internal/uls/ctcounty_gen.go

import (
	"bufio"
	"fmt"
	"math"
	"os"
	"sort"
	"strconv"
	"strings"
)

type zipPt struct {
	zip      string
	lat, lon float64
	county   string // from the crosswalk, "" if the ZIP has no ZCTA
	geoName  string // what GeoNames says today
}

func main() {
	if len(os.Args) < 3 {
		fmt.Fprintln(os.Stderr, "usage: ctzipgen US.txt tab20_zcta520_county20_natl.txt")
		os.Exit(2)
	}

	// 1. Every Connecticut ZIP, with its centroid, from GeoNames.
	var pts []*zipPt
	byZip := map[string]*zipPt{}
	f, err := os.Open(os.Args[1])
	must(err)
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
	for sc.Scan() {
		c := strings.Split(sc.Text(), "\t")
		if len(c) < 11 || strings.ToUpper(strings.TrimSpace(c[4])) != "CT" {
			continue
		}
		z := strings.TrimSpace(c[1])
		if z == "" || byZip[z] != nil {
			continue
		}
		p := &zipPt{zip: z, lat: atof(c[9]), lon: atof(c[10]), geoName: strings.TrimSpace(c[5])}
		byZip[z] = p
		pts = append(pts, p)
	}
	must(sc.Err())
	f.Close()

	// 2. The 2020 county for each ZCTA. A ZCTA can straddle a county line, so
	//    keep the county holding the largest share of its land area.
	best := map[string]float64{}
	f2, err := os.Open(os.Args[2])
	must(err)
	sc2 := bufio.NewScanner(f2)
	sc2.Buffer(make([]byte, 0, 64*1024), 1<<20)
	sc2.Scan() // header
	for sc2.Scan() {
		c := strings.Split(sc2.Text(), "|")
		if len(c) < 18 || !strings.HasPrefix(c[9], "09") {
			continue
		}
		p := byZip[strings.TrimSpace(c[1])]
		if p == nil {
			continue
		}
		area := atof(c[16])
		if p.county != "" && area <= best[p.zip] {
			continue
		}
		best[p.zip] = area
		p.county = strings.TrimSuffix(strings.TrimSpace(c[10]), " County")
	}
	must(sc2.Err())
	f2.Close()

	// 3. Fill the PO-box-only ZIPs from the nearest ZIP the crosswalk resolved.
	var anchors []*zipPt
	for _, p := range pts {
		if p.county != "" {
			anchors = append(anchors, p)
		}
	}
	if len(anchors) == 0 {
		fmt.Fprintln(os.Stderr, "ctzipgen: crosswalk resolved nothing — wrong file?")
		os.Exit(1)
	}
	filled := 0
	for _, p := range pts {
		if p.county != "" {
			continue
		}
		nearest, bestD := "", math.MaxFloat64
		for _, a := range anchors {
			if d := haversine(p.lat, p.lon, a.lat, a.lon); d < bestD {
				bestD, nearest = d, a.county
			}
		}
		p.county = nearest
		filled++
	}

	// 4. Self-check: the ZIPs GeoNames still labels with a real county must
	//    agree with what we derived, or the derivation is wrong.
	real := map[string]bool{
		"Fairfield": true, "Hartford": true, "Litchfield": true, "Middlesex": true,
		"New Haven": true, "New London": true, "Tolland": true, "Windham": true,
	}
	checked, bad := 0, 0
	for _, p := range pts {
		if !real[p.geoName] {
			continue
		}
		checked++
		if p.geoName != p.county {
			bad++
			fmt.Fprintf(os.Stderr, "MISMATCH %s: geonames %q, derived %q\n", p.zip, p.geoName, p.county)
		}
	}
	fmt.Fprintf(os.Stderr, "ctzipgen: %d zips, %d from crosswalk, %d by nearest; cross-check %d/%d agree\n",
		len(pts), len(pts)-filled, filled, checked-bad, checked)
	if bad > 0 {
		os.Exit(1)
	}

	sort.Slice(pts, func(i, j int) bool { return pts[i].zip < pts[j].zip })

	var b strings.Builder
	b.WriteString("// Code generated by cmd/ctzipgen. DO NOT EDIT.\n\n")
	b.WriteString("package uls\n\n")
	b.WriteString("// ctCounty maps a Connecticut ZIP to its legal county.\n")
	b.WriteString("//\n")
	b.WriteString("// GeoNames reports Connecticut's 2022 planning regions instead, which no\n")
	b.WriteString("// award, callbook or log uses. See cmd/ctzipgen for how this was built and\n")
	b.WriteString("// why a name-to-name mapping cannot work.\n")
	fmt.Fprintf(&b, "var ctCounty = map[string]string{\n")
	for _, p := range pts {
		fmt.Fprintf(&b, "\t%q: %q,\n", p.zip, p.county)
	}
	b.WriteString("}\n")
	fmt.Print(b.String())
}

func atof(s string) float64 {
	v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
	return v
}

// haversine returns the great-circle distance in km. Connecticut is small
// enough that a flat approximation would do, but this costs nothing and cannot
// be wrong near the state's edges.
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
	const r = 6371.0
	dLat := (lat2 - lat1) * math.Pi / 180
	dLon := (lon2 - lon1) * math.Pi / 180
	a := math.Sin(dLat/2)*math.Sin(dLat/2) +
		math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLon/2)*math.Sin(dLon/2)
	return 2 * r * math.Asin(math.Sqrt(a))
}

func must(err error) {
	if err != nil {
		fmt.Fprintln(os.Stderr, "ctzipgen:", err)
		os.Exit(1)
	}
}
