// Package format is unit-aware display formatting for the SSR templates —
// the Go port of format.py (itself ported from backend/web/content.py,
// unchanged in behavior: still markup-wrapped spans for the templates,
// operating on the raw floats the content API returns).
//
// One deliberate structural change from the Python: format.py scoped the
// active display unit in a ContextVar (`unit_scope`) so template globals
// could read it implicitly. html/template FuncMaps are engine-global and
// parsed once, so an ambient mutable unit would be a data race across
// requests — instead every unit-dependent function here takes the Unit as an
// explicit first argument, and the page handlers thread it through the render
// context (the reasoning the Python gave for the ContextVar — "a silently
// wrong unit at the one site someone forgets" — is answered in Go by the
// compiler: forgetting the argument fails the build, not the user).
package format

import (
	"encoding/json"
	"fmt"
	"html/template"
	"math"
	"strconv"
	"strings"
)

// Unit is the active display unit: "C", "F", or "" (unset — the Python's
// ContextVar default None). Anything other than "C" formats as Fahrenheit,
// exactly like the Python's `_UNIT.get() == "C"` checks; "" additionally
// means "no data-unit-default attribute on <body>".
type Unit string

// FCountries is the set of countries that actually use Fahrenheit. Mirrors
// backend/api/content_payloads.py's F_COUNTRIES / frontend static/units.js's
// F_REGIONS — a test asserts the backend copy stays identical (the backend
// has its own test asserting the same across its surfaces).
var FCountries = map[string]bool{
	"US": true, "PR": true, "GU": true, "VI": true, "AS": true, "MP": true,
	"UM": true, "BS": true, "BZ": true, "KY": true, "PW": true, "FM": true,
	"MH": true, "LR": true,
}

// Unit-conversion factors (format.py's MM_PER_IN / KMH_PER_MPH).
const (
	MMPerIn   = 25.4
	KmhPerMph = 1.609344
)

// tempTiers are the absolute-temperature colour tiers (°F upper bounds), the
// same 9 tiers the interactive grader uses (format.py's _TEMP_TIERS). Note
// these are ABSOLUTE °F cut points for painting cells — distinct from the
// percentile TEMP_BANDS in bands.go, which grade a day against its own
// history.
var tempTiers = []struct {
	upper float64
	class string
}{
	{20, "rec-cold"}, {32, "very-cold"}, {45, "cold"}, {58, "cool"},
	{70, "normal"}, {80, "warm"}, {90, "hot"}, {100, "very-hot"},
}

// Shared axis for the month range bars (format.py's _AXIS_LO/_AXIS_HI).
const (
	axisLo = -10.0
	axisHi = 115.0
)

// UnitForCountry maps an ISO country code to its display unit: "F" for the
// Fahrenheit-using set, "C" everywhere else (empty/unknown codes are "C").
func UnitForCountry(code string) Unit {
	if FCountries[strings.ToUpper(code)] {
		return "F"
	}
	return "C"
}

// RoundHalfUp rounds like JS's Math.round — floor(x + 0.5) — not Go's
// math.Round (which rounds halves away from zero) nor Python's round()
// (halves to even), so the server and climate.js's repaint never visibly
// disagree. Negative halves round UP (-0.5 -> 0), matching Math.round.
func RoundHalfUp(x float64) int {
	return int(math.Floor(x + 0.5))
}

// cFromF converts °F to whole °C, rounding half-up (format.py's _c).
func cFromF(f float64) int {
	return RoundHalfUp((f - 32) * 5 / 9)
}

// shown is the integer temperature to display in the active unit.
func shown(u Unit, f float64) int {
	if u == "C" {
		return cFromF(f)
	}
	return RoundHalfUp(f)
}

// letter is the displayed unit letter — "C" only when the active unit is
// Celsius, "F" otherwise (including the unset unit, like the Python).
func letter(u Unit) string {
	if u == "C" {
		return "C"
	}
	return "F"
}

// Temp renders a temperature span: the data-temp-f attribute always carries
// the raw °F value (one decimal) for the client-side converter, while the
// visible text is in the active unit. nil renders the em-dash placeholder.
func Temp(u Unit, f *float64) template.HTML {
	if f == nil {
		return "—"
	}
	return template.HTML(fmt.Sprintf(`<span class="temp" data-temp-f="%.1f">%d°%s</span>`,
		*f, shown(u, *f), letter(u)))
}

// TempBare is Temp without the unit letter (and with data-bare for the
// converter): the range strip prints "58°–71°".
func TempBare(u Unit, f *float64) template.HTML {
	if f == nil {
		return "—"
	}
	return template.HTML(fmt.Sprintf(`<span class="temp" data-temp-f="%.1f" data-bare>%d°</span>`,
		*f, shown(u, *f)))
}

// TempText is the plain-text temperature ("71°F"), no span.
func TempText(u Unit, f *float64) string {
	if f == nil {
		return "—"
	}
	return fmt.Sprintf("%d°%s", shown(u, *f), letter(u))
}

// Precip renders a precipitation span; data-precip-in carries the raw inches
// (three decimals) for the converter.
func Precip(u Unit, v *float64) template.HTML {
	if v == nil {
		return "—"
	}
	return template.HTML(fmt.Sprintf(`<span class="precip" data-precip-in="%.3f">%s</span>`,
		*v, PrecipText(u, v)))
}

// PrecipText is the plain-text precipitation: whole millimetres for Celsius
// locales, hundredths of an inch otherwise.
func PrecipText(u Unit, v *float64) string {
	if v == nil {
		return "—"
	}
	if u == "C" {
		return fmt.Sprintf("%d mm", RoundHalfUp(*v*MMPerIn))
	}
	return fmt.Sprintf("%.2f in", *v)
}

// Wind renders a wind-speed span; data-wind-mph carries the raw mph.
func Wind(u Unit, v *float64) template.HTML {
	if v == nil {
		return "—"
	}
	return template.HTML(fmt.Sprintf(`<span class="wind" data-wind-mph="%.1f">%s</span>`,
		*v, WindText(u, v)))
}

// WindText is the plain-text wind speed: km/h for Celsius locales, mph
// otherwise — whole numbers both ways.
func WindText(u Unit, v *float64) string {
	if v == nil {
		return "—"
	}
	if u == "C" {
		return fmt.Sprintf("%d km/h", RoundHalfUp(*v*KmhPerMph))
	}
	return fmt.Sprintf("%d mph", RoundHalfUp(*v))
}

// tempMetrics are the metric keys formatted as temperatures (format.py's
// _TEMP_METRICS).
var tempMetrics = map[string]bool{"tmax": true, "tmin": true, "feels": true}

// Fmt dispatches a metric value to its formatter (format.py's fmt):
// temperatures and precip get their converter spans, humidity is a plain
// absolute figure, and everything else (wind, gust) formats as wind speed.
func Fmt(u Unit, metric string, v *float64) template.HTML {
	if v == nil {
		return "—"
	}
	if tempMetrics[metric] {
		return Temp(u, v)
	}
	switch metric {
	case "precip":
		return Precip(u, v)
	case "humid":
		return template.HTML(fmt.Sprintf("%.1f g/m³", *v))
	}
	return Wind(u, v) // wind, gust
}

// TempClass is the diverging-palette tier name for an absolute Fahrenheit
// temperature (or "none" for a missing value). Each tier's bound is an
// EXCLUSIVE upper edge (f < upper), so exactly 20°F is "very-cold", not
// "rec-cold"; at or above the last bound is "rec-hot".
func TempClass(f *float64) string {
	if f == nil {
		return "none"
	}
	for _, t := range tempTiers {
		if *f < t.upper {
			return t.class
		}
	}
	return "rec-hot"
}

// PctOrdinal renders a percentile as a display ordinal: 66.4 -> "66th",
// 99.6 -> "99th". Floored into 1..99 — an empirical percentile is a rank
// against the sample, so rounding can land on 100 (or 0), and "100th
// percentile" reads as a measurement error rather than "as extreme as it has
// ever been"; the band label ("Near Record") carries the how-extreme part.
//
// Mirrors backend/data/grading.py's pct_ordinal, which stays canonical for
// the in-process surfaces (and static/shared.js's pctOrd client-side); this
// is the SSR-side copy, same formula. floor(x + 0.5), NOT round-half-even:
// Python's round() gives 16 for 16.5 while JavaScript's Math.round gives 17 —
// the same reading would then read "16th" server-side and "17th" client-side.
//
// A non-numeric or missing value renders the em-dash placeholder, like the
// Python's caught TypeError/ValueError (NaN included: math.floor raised
// ValueError on it). Infinities clamp into the 1..99 range (the Python would
// have crashed on OverflowError; they cannot occur in a percentile).
func PctOrdinal(pct any) string {
	f, ok := toFloat(pct)
	if !ok || math.IsNaN(f) {
		return "—"
	}
	var n int
	switch {
	case math.IsInf(f, 1):
		n = 99
	case math.IsInf(f, -1):
		n = 1
	default:
		n = RoundHalfUp(f)
	}
	n = min(99, max(1, n))
	suffix := "th"
	if !(10 <= n%100 && n%100 <= 20) {
		switch n % 10 {
		case 1:
			suffix = "st"
		case 2:
			suffix = "nd"
		case 3:
			suffix = "rd"
		}
	}
	return strconv.Itoa(n) + suffix
}

// toFloat coerces the loosely-typed values templates hand the ordinal filter
// (payload float64s, nullable *float64s, json.Number, template int literals,
// strings — everything Python's float(pct) accepted).
func toFloat(v any) (float64, bool) {
	switch x := v.(type) {
	case nil:
		return 0, false
	case float64:
		return x, true
	case float32:
		return float64(x), true
	case *float64:
		if x == nil {
			return 0, false
		}
		return *x, true
	case int:
		return float64(x), true
	case int64:
		return float64(x), true
	case json.Number:
		f, err := x.Float64()
		return f, err == nil
	case string:
		f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
		return f, err == nil
	}
	return 0, false
}

// AsFloatPtr coerces the loosely-typed values templates hand the formatter:
// nullable *float64 payload fields, plain float64s, and int/float literals
// (the template port writes {{.Fmt.Temp -10.0}} where Jinja had temp(-10)).
// nil (and a nil *float64) mean "missing" — rendered as the em-dash.
func AsFloatPtr(v any) *float64 {
	switch x := v.(type) {
	case nil:
		return nil
	case *float64:
		return x
	case float64:
		return &x
	case int:
		f := float64(x)
		return &f
	}
	return nil
}

// Formatter is the request-scoped, unit-bound formatter the render data
// carries as .Fmt — the Go stand-in for format.py's ContextVar unit scope.
// html/template FuncMaps are engine-global, so the per-request unit rides on
// the data instead, and templates call {{.Fmt.Temp .HighF}} where Jinja had
// {{ temp(m.high_f) }}.
type Formatter struct {
	Unit Unit
}

// NewFormatter binds a formatter to the page's active unit ("" behaves as
// Fahrenheit, like the Python's unset ContextVar).
func NewFormatter(u Unit) *Formatter { return &Formatter{Unit: u} }

// Temp is the template-facing Temp (accepts *float64 fields and literals).
func (f *Formatter) Temp(v any) template.HTML { return Temp(f.Unit, AsFloatPtr(v)) }

// TempBare is the template-facing TempBare.
func (f *Formatter) TempBare(v any) template.HTML { return TempBare(f.Unit, AsFloatPtr(v)) }

// TempClass is the template-facing TempClass (unit-independent — absolute °F
// tiers — but lives on the formatter so templates have one formatting seam).
func (f *Formatter) TempClass(v any) string { return TempClass(AsFloatPtr(v)) }

// RangeBar is the geometry for one month's low->high bar on the shared
// -10..115°F axis: left offset + width as percentages, and the tier colour at
// each end. Left/Width are pre-formatted strings (Python rendered
// round(x, 1) floats, which always print with one decimal — "42.0", not
// "42" — so formatting here keeps the rendered bytes identical).
type RangeBar struct {
	Left  string // e.g. "48.8"
	Width string // e.g. "12.4"; never below "2.0" (a sliver stays visible)
	C1    string // tier colour at the low end (from the UNCLAMPED low)
	C2    string // tier colour at the high end
}

// MonthRangeBar computes the bar, or nil when either end is missing
// (format.py's range_bar). The endpoints are clamped into the axis for
// geometry, but the colours come from the raw values.
func MonthRangeBar(lowF, highF *float64) *RangeBar {
	if lowF == nil || highF == nil {
		return nil
	}
	lo := math.Max(axisLo, math.Min(axisHi, *lowF))
	hi := math.Max(axisLo, math.Min(axisHi, *highF))
	const span = axisHi - axisLo
	return &RangeBar{
		Left:  strconv.FormatFloat((lo-axisLo)/span*100, 'f', 1, 64),
		Width: strconv.FormatFloat(math.Max(2.0, (hi-lo)/span*100), 'f', 1, 64),
		C1:    TempClass(lowF),
		C2:    TempClass(highF),
	}
}
