package format

import (
	"encoding/json"
	"math"
	"os"
	"path/filepath"
	"regexp"
	"testing"
)

func fp(v float64) *float64 { return &v }

func TestRoundHalfUp(t *testing.T) {
	// floor(x+0.5): JS Math.round parity, incl. the half-to-even divergence
	// (16.5 -> 17, where Python's round() says 16) and negative halves
	// rounding UP (-0.5 -> 0), both asserted so a future "simplification" to
	// math.Round (halves away from zero: -0.5 -> -1) fails here.
	cases := []struct {
		in   float64
		want int
	}{
		{16.5, 17}, {16.4, 16}, {16.6, 17}, {-0.5, 0}, {-0.6, -1},
		{-1.5, -1}, {0, 0}, {0.5, 1}, {2.5, 3}, {99.5, 100},
	}
	for _, c := range cases {
		if got := RoundHalfUp(c.in); got != c.want {
			t.Errorf("RoundHalfUp(%v) = %d, want %d", c.in, got, c.want)
		}
	}
}

func TestUnitForCountry(t *testing.T) {
	cases := []struct {
		code string
		want Unit
	}{
		{"US", "F"}, {"us", "F"}, {"LR", "F"}, {"BS", "F"},
		{"GB", "C"}, {"", "C"}, {"XX", "C"}, {"USA", "C"},
	}
	for _, c := range cases {
		if got := UnitForCountry(c.code); got != c.want {
			t.Errorf("UnitForCountry(%q) = %q, want %q", c.code, got, c.want)
		}
	}
}

// parseCountryCodes pulls a set of ISO-3166 alpha-2 codes out of `path` by
// matching `outer` (which must capture the literal's body in group 1) and then
// scanning that body for quoted codes. Returns nil when the file isn't present,
// so a caller can skip rather than fail in a checkout/build context that does
// not carry it.
func parseCountryCodes(t *testing.T, path string, outer *regexp.Regexp) map[string]bool {
	t.Helper()
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil
	}
	m := outer.FindSubmatch(raw)
	if m == nil {
		t.Fatalf("could not find the country-set literal in %s", path)
	}
	codes := regexp.MustCompile(`"([A-Z]{2})"`).FindAllSubmatch(m[1], -1)
	if len(codes) == 0 {
		t.Fatalf("no codes parsed from %s", path)
	}
	set := map[string]bool{}
	for _, c := range codes {
		set[string(c[1])] = true
	}
	return set
}

// assertMatchesFCountries diffs a parsed copy against ours, both directions.
func assertMatchesFCountries(t *testing.T, name string, other map[string]bool) {
	t.Helper()
	if len(other) != len(FCountries) {
		t.Errorf("size mismatch: %s %d vs ours %d", name, len(other), len(FCountries))
	}
	for code := range other {
		if !FCountries[code] {
			t.Errorf("%s has %q, ours does not", name, code)
		}
	}
	for code := range FCountries {
		if !other[code] {
			t.Errorf("ours has %q, %s does not", code, name)
		}
	}
}

// TestFCountriesMatchesBackend re-parses the backend's F_COUNTRIES (the
// source of truth per both CLAUDE.md files) and asserts our set is identical.
// Skips when the monorepo backend checkout isn't present — notably inside
// frontend/Dockerfile's builder stage, whose build context is frontend/ only,
// so backend/ is structurally unreachable there. This check is therefore a
// local/CI-checkout guard, not an image-build one.
func TestFCountriesMatchesBackend(t *testing.T) {
	path := filepath.Join("..", "..", "..", "..", "backend", "api", "content_payloads.py")
	backend := parseCountryCodes(t, path, regexp.MustCompile(`(?s)F_COUNTRIES\s*=\s*frozenset\(\{(.*?)\}\)`))
	if backend == nil {
		t.Skip("backend checkout not present — parity asserted only against the committed copy")
	}
	assertMatchesFCountries(t, "backend", backend)
}

// TestFCountriesMatchesUnitsJS asserts the BROWSER copy — static/units.js's
// F_REGIONS — matches too.
//
// This copy was previously guarded by nothing at all, despite comments in
// backend/api/content_payloads.py, frontend/format.py and format.go each
// claiming "a test asserts all three stay identical": the only check compared
// Go against the backend's Python, and nothing read units.js. The sets happened
// to agree, held in step by convention alone. Client-side unit selection
// disagreeing with server-rendered unit selection means the same page shows °C
// in SSR and °F after hydration — a silent, per-country split.
//
// Unlike the backend check above, static/ IS inside the frontend build context,
// and frontend/Dockerfile copies units.js into the builder stage precisely so
// this runs during the image build — the only place CI executes these tests.
func TestFCountriesMatchesUnitsJS(t *testing.T) {
	for _, path := range []string{
		filepath.Join("..", "..", "..", "static", "units.js"), // repo checkout
		filepath.Join("/", "static", "units.js"),              // Dockerfile builder stage
	} {
		js := parseCountryCodes(t, path, regexp.MustCompile(`(?s)F_REGIONS\s*=\s*new Set\(\[(.*?)\]\)`))
		if js != nil {
			assertMatchesFCountries(t, "static/units.js", js)
			return
		}
	}
	t.Skip("static/units.js not reachable from this working directory")
}

func TestTempSpans(t *testing.T) {
	// Exact rendered bytes — the golden-diff contract.
	if got := Temp("F", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">71°F</span>` {
		t.Errorf("Temp F: %q", got)
	}
	// Celsius display converts the visible number but data-temp-f keeps °F.
	if got := Temp("C", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">22°C</span>` {
		t.Errorf("Temp C: %q", got)
	}
	// Unset unit behaves as Fahrenheit (Python's ContextVar default None).
	if got := Temp("", fp(58.0)); got != `<span class="temp" data-temp-f="58.0">58°F</span>` {
		t.Errorf("Temp unset: %q", got)
	}
	if got := Temp("C", nil); got != "—" {
		t.Errorf("Temp nil: %q", got)
	}
	if got := TempBare("C", fp(58.4)); got != `<span class="temp" data-temp-f="58.4" data-bare>15°</span>` {
		t.Errorf("TempBare: %q", got)
	}
	if got := TempBare("F", nil); got != "—" {
		t.Errorf("TempBare nil: %q", got)
	}
	if got := TempText("C", fp(32.0)); got != "0°C" {
		t.Errorf("TempText: %q", got)
	}
	// Negative + conversion: -40 is where the scales meet.
	if got := TempText("C", fp(-40.0)); got != "-40°C" {
		t.Errorf("TempText -40: %q", got)
	}
	// Round-half-up of the converted value: 31.1°F -> -0.5°C -> 0 (JS parity).
	if got := TempText("C", fp(31.1)); got != "0°C" {
		t.Errorf("TempText 31.1F: %q", got)
	}
}

func TestPrecipAndWind(t *testing.T) {
	if got := Precip("F", fp(0.1)); got != `<span class="precip" data-precip-in="0.100">0.10 in</span>` {
		t.Errorf("Precip F: %q", got)
	}
	// 0.1 in * 25.4 = 2.54 mm -> rounds half-up to 3.
	if got := Precip("C", fp(0.1)); got != `<span class="precip" data-precip-in="0.100">3 mm</span>` {
		t.Errorf("Precip C: %q", got)
	}
	if got := Precip("C", nil); got != "—" {
		t.Errorf("Precip nil: %q", got)
	}
	if got := PrecipText("F", fp(0.005)); got != "0.01 in" {
		t.Errorf("PrecipText: %q", got)
	}
	if got := Wind("F", fp(24.7)); got != `<span class="wind" data-wind-mph="24.7">25 mph</span>` {
		t.Errorf("Wind F: %q", got)
	}
	// 24.7 mph * 1.609344 = 39.75... km/h -> 40.
	if got := Wind("C", fp(24.7)); got != `<span class="wind" data-wind-mph="24.7">40 km/h</span>` {
		t.Errorf("Wind C: %q", got)
	}
	if got := Wind("F", nil); got != "—" {
		t.Errorf("Wind nil: %q", got)
	}
}

func TestFmtDispatch(t *testing.T) {
	if got := Fmt("F", "tmax", fp(97.5)); got != `<span class="temp" data-temp-f="97.5">98°F</span>` {
		t.Errorf("Fmt tmax: %q", got)
	}
	if got := Fmt("F", "precip", fp(1.0)); got != `<span class="precip" data-precip-in="1.000">1.00 in</span>` {
		t.Errorf("Fmt precip: %q", got)
	}
	// Humidity is unit-independent: absolute g/m³ either way.
	if got := Fmt("C", "humid", fp(16.0)); got != "16.0 g/m³" {
		t.Errorf("Fmt humid: %q", got)
	}
	if got := Fmt("F", "gust", fp(38.0)); got != `<span class="wind" data-wind-mph="38.0">38 mph</span>` {
		t.Errorf("Fmt gust: %q", got)
	}
	if got := Fmt("F", "wind", nil); got != "—" {
		t.Errorf("Fmt nil: %q", got)
	}
}

func TestTempClassBoundaries(t *testing.T) {
	// Upper bounds are EXCLUSIVE (f < upper): the value AT each boundary
	// belongs to the tier above it.
	cases := []struct {
		f    float64
		want string
	}{
		{-100, "rec-cold"}, {19.9, "rec-cold"},
		{20, "very-cold"}, {31.9, "very-cold"},
		{32, "cold"}, {44.9, "cold"},
		{45, "cool"}, {57.9, "cool"},
		{58, "normal"}, {69.9, "normal"},
		{70, "warm"}, {79.9, "warm"},
		{80, "hot"}, {89.9, "hot"},
		{90, "very-hot"}, {99.9, "very-hot"},
		{100, "rec-hot"}, {130, "rec-hot"},
	}
	for _, c := range cases {
		if got := TempClass(&c.f); got != c.want {
			t.Errorf("TempClass(%v) = %q, want %q", c.f, got, c.want)
		}
	}
	if got := TempClass(nil); got != "none" {
		t.Errorf("TempClass(nil) = %q, want none", got)
	}
}

func TestPctOrdinal(t *testing.T) {
	cases := []struct {
		in   any
		want string
	}{
		{66.4, "66th"},
		{99.6, "99th"}, // rounds to 100, clamps to 99 — "100th percentile" is never shown
		{100.0, "99th"},
		{0.0, "1st"}, // rounds to 0, clamps to 1
		{0.2, "1st"},
		{1.0, "1st"},
		{2.0, "2nd"},
		{3.0, "3rd"},
		{4.0, "4th"},
		{11.0, "11th"}, // teens are always "th"
		{12.0, "12th"},
		{13.0, "13th"},
		{21.0, "21st"},
		{22.0, "22nd"},
		{23.0, "23rd"},
		{16.5, "17th"}, // floor(x+0.5): JS parity, NOT Python round-half-even (16)
		{50.5, "51st"},
		{fp(42.0), "42nd"}, // nullable payload pointer
		{json.Number("88"), "88th"},
		{"60", "60th"}, // Python float("60") accepted strings
		{nil, "—"},
		{(*float64)(nil), "—"},
		{"garbage", "—"},
		{math.NaN(), "—"}, // Python: math.floor(nan) raised ValueError -> "—"
	}
	for _, c := range cases {
		if got := PctOrdinal(c.in); got != c.want {
			t.Errorf("PctOrdinal(%v) = %q, want %q", c.in, got, c.want)
		}
	}
}

func TestMonthRangeBar(t *testing.T) {
	if MonthRangeBar(nil, fp(70)) != nil || MonthRangeBar(fp(50), nil) != nil {
		t.Fatal("missing endpoint must yield nil bar")
	}
	// 51.3..61.1 on the -10..115 axis: left (51.3+10)/125*100 = 49.04 -> "49.0",
	// width (61.1-51.3)/125*100 = 7.84 -> "7.8".
	b := MonthRangeBar(fp(51.3), fp(61.1))
	if b.Left != "49.0" || b.Width != "7.8" {
		t.Errorf("bar geometry: left %q width %q", b.Left, b.Width)
	}
	if b.C1 != "cool" || b.C2 != "normal" {
		t.Errorf("bar colours: %q %q", b.C1, b.C2)
	}
	// Width floor: a degenerate range still paints a 2.0%% sliver, and the
	// formatted floor keeps Python's "2.0" (str of a rounded float), not "2".
	b = MonthRangeBar(fp(60), fp(60.5))
	if b.Width != "2.0" {
		t.Errorf("min width: %q", b.Width)
	}
	// Endpoints clamp into the axis for geometry, but the tier colours come
	// from the raw values (a -40 low is still rec-cold even though the bar
	// starts at the axis edge).
	b = MonthRangeBar(fp(-40), fp(120))
	if b.Left != "0.0" || b.Width != "100.0" {
		t.Errorf("clamped geometry: left %q width %q", b.Left, b.Width)
	}
	if b.C1 != "rec-cold" || b.C2 != "rec-hot" {
		t.Errorf("clamped colours: %q %q", b.C1, b.C2)
	}
}
