// Behaviour tests for the content.py port, driven by the same committed
// backend fixtures the Python suite used (frontend/tests/fixtures/*.json,
// captured by scripts/capture-fixtures.sh) and mirroring the assertions of
// frontend/tests/unit/test_rendering.py that belong to this layer. Full
// rendered-page assertions (200 + HTML structure) live with the template
// port / golden-diff phase — here the templates are placeholders, so these
// tests target the routes' status behavior and the exact render contexts the
// handlers build.
package content

import (
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"thermograph/frontend/internal/config"
	"thermograph/frontend/internal/contentapi"
	"thermograph/frontend/internal/contentdata"
	"thermograph/frontend/internal/format"
	"thermograph/frontend/internal/render"
)

const (
	testSlug  = "london-england-gb" // captured into tests/fixtures/ (GB -> Celsius)
	testMonth = "july"
	fakeKey   = "test0000indexnow0000key000000000"
)

// fixturesDir / contentDir reach the repo's committed test fixtures and SSR
// copy from this package directory.
var (
	fixturesDir = filepath.Join("..", "..", "..", "tests", "fixtures")
	contentDir  = filepath.Join("..", "..", "..", "content")
)

func loadFixture[T any](t *testing.T, name string) *T {
	t.Helper()
	raw, err := os.ReadFile(filepath.Join(fixturesDir, name+".json"))
	if err != nil {
		t.Fatalf("fixture %s: %v", name, err)
	}
	out := new(T)
	if err := json.Unmarshal(raw, out); err != nil {
		t.Fatalf("fixture %s: %v", name, err)
	}
	return out
}

// fakeAPI is the Go analogue of conftest.py's monkeypatched api_client.
type fakeAPI struct {
	hub         func() (*contentapi.HubPayload, error)
	sitemap     func() ([]contentapi.SitemapEntry, error)
	indexNowKey func() (string, error)
	home        func() (*contentapi.HomePayload, error)
	city        func(slug, origin string) (*contentapi.CityPayload, error)
	cityMonth   func(slug, month string) (*contentapi.MonthPayload, error)
	cityRecords func(slug, origin string) (*contentapi.RecordsPayload, error)
}

func (f *fakeAPI) Hub() (*contentapi.HubPayload, error)        { return f.hub() }
func (f *fakeAPI) Sitemap() ([]contentapi.SitemapEntry, error) { return f.sitemap() }
func (f *fakeAPI) IndexNowKey() (string, error)                { return f.indexNowKey() }
func (f *fakeAPI) Home() (*contentapi.HomePayload, error)      { return f.home() }
func (f *fakeAPI) City(slug, origin string) (*contentapi.CityPayload, error) {
	return f.city(slug, origin)
}
func (f *fakeAPI) CityMonth(slug, month string) (*contentapi.MonthPayload, error) {
	return f.cityMonth(slug, month)
}
func (f *fakeAPI) CityRecords(slug, origin string) (*contentapi.RecordsPayload, error) {
	return f.cityRecords(slug, origin)
}

func notFound(detail string) error {
	return &contentapi.StatusError{
		StatusCode: 404,
		Body:       fmt.Sprintf(`{"detail":%q}`, detail),
		URL:        "http://backend.invalid/x",
	}
}

var errUnreachable = errors.New("connect: connection refused")

// fixtureAPI wires every method to the committed fixtures, exactly like the
// Python `client` fixture's monkeypatching (unknown slug/month -> a backend
// 404 StatusError).
func fixtureAPI(t *testing.T) *fakeAPI {
	t.Helper()
	city := loadFixture[contentapi.CityPayload](t, "city")
	month := loadFixture[contentapi.MonthPayload](t, "month")
	records := loadFixture[contentapi.RecordsPayload](t, "records")
	home := loadFixture[contentapi.HomePayload](t, "home")
	hub := loadFixture[contentapi.HubPayload](t, "hub")
	sitemap := loadFixture[[]contentapi.SitemapEntry](t, "sitemap")
	return &fakeAPI{
		hub:         func() (*contentapi.HubPayload, error) { return hub, nil },
		sitemap:     func() ([]contentapi.SitemapEntry, error) { return *sitemap, nil },
		indexNowKey: func() (string, error) { return fakeKey, nil },
		home:        func() (*contentapi.HomePayload, error) { return home, nil },
		city: func(slug, origin string) (*contentapi.CityPayload, error) {
			if slug != testSlug {
				return nil, notFound("Unknown city.")
			}
			return city, nil
		},
		cityMonth: func(slug, m string) (*contentapi.MonthPayload, error) {
			if slug != testSlug || m != testMonth {
				return nil, notFound("Unknown month.")
			}
			return month, nil
		},
		cityRecords: func(slug, origin string) (*contentapi.RecordsPayload, error) {
			if slug != testSlug {
				return nil, notFound("Unknown city.")
			}
			return records, nil
		},
	}
}

func testConfig() config.Config {
	return config.Config{
		APIBaseInternal: "http://backend.invalid",
		APIVersion:      "v2",
		Base:            "/thermograph", // matches THERMOGRAPH_BASE in the Python conftest
		AssetBase:       "/thermograph",
		GoogleVerify:    "",
		BingVerify:      "",
	}
}

func newHandlers(t *testing.T, api API) *Handlers {
	t.Helper()
	cfg := testConfig()
	engine, err := render.New(FuncMap(cfg))
	if err != nil {
		t.Fatalf("render.New: %v", err)
	}
	glossary, err := contentdata.LoadGlossary(contentDir)
	if err != nil {
		t.Fatalf("LoadGlossary: %v", err)
	}
	pages, err := contentdata.LoadPages(contentDir)
	if err != nil {
		t.Fatalf("LoadPages: %v", err)
	}
	h, err := New(cfg, api, engine, glossary, pages, nil)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	return h
}

func newMux(t *testing.T, api API) *http.ServeMux {
	t.Helper()
	mux := http.NewServeMux()
	static := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("STATIC")) // stands in for main.go's file server
	})
	h := newHandlers(t, api)
	h.Register(mux, static)
	// main.go ALSO registers a subtree static catch-all ("GET {base}/", via
	// handlers.Server.Register) on the same mux — Register's own doc comment
	// notes this. Only the LAZY IndexNow fallback claims the single-segment
	// slot itself and forwards non-.txt requests to `static`; the EAGER path
	// (key fetched successfully at boot) registers just the exact key-file
	// route and relies on that separate subtree registration for everything
	// else. Mirror both here, or the eager-route test exercises a mux with no
	// handler for e.g. app.js at all — a gap in this test double, not in
	// production routing.
	mux.Handle("GET "+h.cfg.Base+"/", static)
	return mux
}

func get(mux *http.ServeMux, path string) *httptest.ResponseRecorder {
	req := httptest.NewRequest("GET", "http://testserver"+path, nil)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	return rec
}

// --- robots / sitemap --------------------------------------------------------

func TestRobotsTxt(t *testing.T) {
	rec := get(newMux(t, fixtureAPI(t)), "/thermograph/robots.txt")
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	want := "User-agent: *\n" +
		"Allow: /\n" +
		"Disallow: /thermograph/api/\n" +
		"Disallow: /thermograph/alerts\n" +
		"Sitemap: http://testserver/thermograph/sitemap.xml\n"
	if rec.Body.String() != want {
		t.Errorf("body:\n%s", rec.Body.String())
	}
}

func TestSitemapListsCityURLs(t *testing.T) {
	rec := get(newMux(t, fixtureAPI(t)), "/thermograph/sitemap.xml")
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "<urlset") {
		t.Error("missing <urlset")
	}
	for _, frag := range []string{
		"/climate/" + testSlug + "</loc>",
		"/climate/" + testSlug + "/" + testMonth + "</loc>",
		"/climate/" + testSlug + "/records</loc>",
	} {
		if !strings.Contains(body, frag) {
			t.Errorf("missing %q", frag)
		}
	}
	if ct := rec.Header().Get("Content-Type"); ct != "application/xml" {
		t.Errorf("Content-Type %q", ct)
	}
	if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=300" {
		t.Errorf("Cache-Control %q", cc)
	}
	// One full entry, byte-exact: loc + boot-date lastmod + changefreq + priority.
	h := newHandlers(t, fixtureAPI(t))
	wantURL := fmt.Sprintf("<url><loc>http://testserver/thermograph/</loc><lastmod>%s</lastmod>"+
		"<changefreq>daily</changefreq><priority>1.0</priority></url>", h.bootDate)
	if !strings.Contains(body, wantURL) {
		t.Errorf("missing exact entry %q", wantURL)
	}
}

// --- error mapping -----------------------------------------------------------

func TestUnknownCityAndMonthAre404(t *testing.T) {
	mux := newMux(t, fixtureAPI(t))
	for _, path := range []string{
		"/thermograph/climate/nope-not-a-city",
		"/thermograph/climate/" + testSlug + "/nonemonth",
		"/thermograph/climate/nope-not-a-city/records",
	} {
		if rec := get(mux, path); rec.Code != 404 {
			t.Errorf("%s -> %d, want 404", path, rec.Code)
		}
	}
}

func TestGlossaryUnknownTermIs404(t *testing.T) {
	rec := get(newMux(t, fixtureAPI(t)), "/thermograph/glossary/not-a-term")
	if rec.Code != 404 {
		t.Fatalf("status %d", rec.Code)
	}
	if got := rec.Body.String(); got != `{"detail":"Unknown term."}` {
		t.Errorf("body %q", got)
	}
}

// TestBackendUnreachableMapsTo503 is the port of the Python suite's
// backend-down resilience cases: a transport-level failure (no response at
// all) must become a clean 503, not a raw 500.
func TestBackendUnreachableMapsTo503(t *testing.T) {
	api := fixtureAPI(t)
	api.city = func(_, _ string) (*contentapi.CityPayload, error) { return nil, errUnreachable }
	api.cityMonth = func(_, _ string) (*contentapi.MonthPayload, error) { return nil, errUnreachable }
	api.cityRecords = func(_, _ string) (*contentapi.RecordsPayload, error) { return nil, errUnreachable }
	api.hub = func() (*contentapi.HubPayload, error) { return nil, errUnreachable }
	api.home = func() (*contentapi.HomePayload, error) { return nil, errUnreachable }
	api.sitemap = func() ([]contentapi.SitemapEntry, error) { return nil, errUnreachable }
	mux := newMux(t, api)
	for _, path := range []string{
		"/thermograph/climate/" + testSlug,
		"/thermograph/climate/" + testSlug + "/july",
		"/thermograph/climate/" + testSlug + "/records",
		"/thermograph/climate",
		"/thermograph/",
		"/thermograph/sitemap.xml",
	} {
		rec := get(mux, path)
		if rec.Code != 503 {
			t.Errorf("%s -> %d, want 503", path, rec.Code)
		}
		if body := rec.Body.String(); body != `{"detail":"backend unavailable"}` {
			t.Errorf("%s body %q", path, body)
		}
	}
}

// A backend 404/503 status passes straight through with its own detail text.
func TestBackendStatusPassesThrough(t *testing.T) {
	rec := get(newMux(t, fixtureAPI(t)), "/thermograph/climate/nope-not-a-city")
	if rec.Code != 404 {
		t.Fatalf("status %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "Unknown city.") {
		t.Errorf("detail not forwarded: %q", rec.Body.String())
	}
}

// --- IndexNow ----------------------------------------------------------------

func TestIndexNowEagerRoute(t *testing.T) {
	mux := newMux(t, fixtureAPI(t))
	rec := get(mux, "/thermograph/"+fakeKey+".txt")
	if rec.Code != 200 || rec.Body.String() != fakeKey+"\n" {
		t.Errorf("eager key file: %d %q", rec.Code, rec.Body.String())
	}
	// With the eager route registered there is no {token} catch-all: other
	// single-segment paths stay with the static file server.
	if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" {
		t.Errorf("static delegation broken: %q", rec.Body.String())
	}
}

func TestIndexNowLazyFallback(t *testing.T) {
	api := fixtureAPI(t)
	down := true
	api.indexNowKey = func() (string, error) {
		if down {
			return "", errUnreachable
		}
		return fakeKey, nil
	}
	mux := newMux(t, api) // Register's eager fetch fails -> lazy route

	// Backend still down: the key file answers 503 (never a wrong/empty key).
	if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 503 {
		t.Errorf("while down: %d", rec.Code)
	}
	// Backend back up: the correct key serves with no frontend restart.
	down = false
	if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 200 || rec.Body.String() != fakeKey+"\n" {
		t.Errorf("after recovery: %d %q", rec.Code, rec.Body.String())
	}
	// A wrong token is 404, and non-.txt single-segment paths fall through to
	// the static handler the lazy route displaced.
	if rec := get(mux, "/thermograph/wrongkey.txt"); rec.Code != 404 {
		t.Errorf("wrong token: %d", rec.Code)
	}
	if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" {
		t.Errorf("static delegation broken: %q", rec.Body.String())
	}
	// Explicit page routes still beat the {token} pattern on specificity.
	if rec := get(mux, "/thermograph/robots.txt"); !strings.HasPrefix(rec.Body.String(), "User-agent:") {
		t.Errorf("robots displaced by token route: %q", rec.Body.String())
	}
}

// --- ctx builders ------------------------------------------------------------

func TestCityCtx(t *testing.T) {
	h := newHandlers(t, fixtureAPI(t))
	j := loadFixture[contentapi.CityPayload](t, "city")
	ctx, unit, err := h.cityCtx(j)
	if err != nil {
		t.Fatal(err)
	}
	if unit != "C" { // GB city -> Celsius (test_city_page_renders_structure)
		t.Errorf("unit %q", unit)
	}
	months := ctx["Months"].([]map[string]any)
	if len(months) != 12 {
		t.Fatalf("months: %d", len(months))
	}
	// January high 44.5°F -> 7°C, formatted as the converter span.
	if got := months[0]["High"].(template.HTML); got != `<span class="temp" data-temp-f="44.5">7°C</span>` {
		t.Errorf("january high: %q", got)
	}
	if bar := months[0]["Bar"]; bar == nil {
		t.Error("january bar missing")
	}
	warmest := ctx["Warmest"].(map[string]any)
	if warmest["Slug"] != "july" {
		t.Errorf("warmest %v", warmest["Slug"])
	}
	if ctx["Coldest"].(map[string]any)["Slug"] != "february" ||
		ctx["Wettest"].(map[string]any)["Slug"] != "january" {
		t.Error("coldest/wettest lookup broken")
	}
	// Full href, base + "#lat,lon" — not just the bare "lat,lon" fragment, so
	// the template never has to concatenate it (html/template's URL-context
	// escaper would %-escape the comma if it did).
	if ctx["ToolHref"] != "/thermograph/#51.50853,-0.12574" {
		t.Errorf("ToolHref %v", ctx["ToolHref"])
	}
	if ctx["CompareURL"] != "/thermograph/compare#loc=51.5085,-0.1257" {
		t.Errorf("CompareURL %v", ctx["CompareURL"])
	}
	// JSON-LD: compacted raw bytes, key order preserved (starts with @context).
	jsonld := string(ctx["JSONLDStr"].(template.JS))
	if !strings.HasPrefix(jsonld, `{"@context":"https://schema.org"`) {
		t.Errorf("JSONLDStr prefix: %.60s", jsonld)
	}
	if !strings.Contains(jsonld, `"@type":"Dataset"`) {
		t.Error("JSONLDStr lost the Dataset block") // test_city_page_renders_structure
	}
	if strings.Contains(jsonld, ": ") {
		t.Error("JSONLDStr not compact")
	}
	// Today cards: pre-formatted value; percentile stays raw for |ordinal.
	today := ctx["Today"].(map[string]any)
	card := today["Cards"].([]map[string]any)[0]
	if card["Value"].(template.HTML) != `<span class="temp" data-temp-f="79.4">26°C</span>` {
		t.Errorf("today card value: %q", card["Value"])
	}
	if format.PctOrdinal(card["Percentile"]) != "89th" {
		t.Errorf("card percentile ordinal: %v", card["Percentile"])
	}
	if ctx["Display"] != j.Display || ctx["Name"] != "London" {
		t.Error("display/name broken")
	}
	// Breadcrumb: the raw []contentapi.Crumb straight off the payload (its own
	// Name/Href fields already match what the "breadcrumb" template reads).
	// Terminal crumb has nil Href ({{if $c.Href}} -> false).
	bc := ctx["Breadcrumb"].([]contentapi.Crumb)
	if len(bc) == 0 || bc[len(bc)-1].Href != nil {
		t.Errorf("breadcrumb terminal href: %v", bc)
	}
}

func TestMonthCtx(t *testing.T) {
	h := newHandlers(t, fixtureAPI(t))
	j := loadFixture[contentapi.MonthPayload](t, "month")
	city := loadFixture[contentapi.CityPayload](t, "city").City
	ctx, unit, err := h.monthCtx(j, city)
	if err != nil {
		t.Fatal(err)
	}
	if unit != "C" {
		t.Errorf("unit %q", unit)
	}
	stats := ctx["Stats"].([]map[string]any)
	labels := make([]string, len(stats))
	for i, s := range stats {
		labels[i] = s["Label"].(string)
	}
	want := []string{"Average high", "Typical high range", "Average low", "Typical low range", "Average daily precipitation"}
	if strings.Join(labels, "|") != strings.Join(want, "|") {
		t.Errorf("stat labels: %v", labels)
	}
	// "X to Y" concatenation: temp(lo) + " to " + temp(hi).
	rng := string(stats[1]["Value"].(template.HTML))
	if rng != `<span class="temp" data-temp-f="64.3">18°C</span> to <span class="temp" data-temp-f="79.8">27°C</span>` {
		t.Errorf("typical high range: %q", rng)
	}
	// display falls back to the city name when the payload has none
	// (j.get("display", city["name"])).
	if j.Display == nil && ctx["Display"] != "London" {
		t.Errorf("display fallback: %v", ctx["Display"])
	}
	// Record label -> metric dispatch: Humidity is a bare g/m³ figure.
	recs := ctx["Records"].([]map[string]any)
	var humid map[string]any
	for _, r := range recs {
		if r["Label"] == "Humidity" {
			humid = r
		}
	}
	if humid == nil || humid["High"].(template.HTML) != "16.0 g/m³" {
		t.Errorf("humidity record: %v", humid)
	}
	if ctx["AvgHighCls"] != "warm" { // 71.1°F -> [70, 80)
		t.Errorf("AvgHighCls %v", ctx["AvgHighCls"])
	}
	// Prev/Next: the raw contentapi.MonthLink (dereferenced), not a map — its
	// own Name/Slug fields already match what the template reads.
	if prev := ctx["Prev"].(contentapi.MonthLink); prev.Slug != "june" {
		t.Errorf("prev %v", prev)
	}
}

// --- fetchWithCity -----------------------------------------------------------

func TestFetchWithCityHappyPath(t *testing.T) {
	api := &fakeAPI{
		city: func(slug, _ string) (*contentapi.CityPayload, error) {
			return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug, Name: "Testville"}}, nil
		},
	}
	primary, city, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil })
	if err != nil {
		t.Fatal(err)
	}
	if primary != "primary-value" {
		t.Errorf("primary = %q", primary)
	}
	if city.Slug != "testville" || city.Name != "Testville" {
		t.Errorf("city = %+v", city)
	}
}

// When only primary fails, its error must win even though City() also ran
// (and, here, succeeded) -- matching the old sequential code's behavior,
// where City() was never even called once primary had already failed.
func TestFetchWithCityPrimaryErrorWins(t *testing.T) {
	primaryErr := notFound("no such month")
	api := &fakeAPI{
		city: func(slug, _ string) (*contentapi.CityPayload, error) {
			return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug}}, nil
		},
	}
	_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr })
	if err != primaryErr {
		t.Errorf("err = %v, want the primary error", err)
	}
}

// When primary succeeds but City() fails, City's error must surface -- same
// as the old sequential code (it called City() second and returned whatever
// that call did).
func TestFetchWithCityCityErrorSurfaces(t *testing.T) {
	cityErr := notFound("no such city")
	api := &fakeAPI{
		city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr },
	}
	_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil })
	if err != cityErr {
		t.Errorf("err = %v, want the city error", err)
	}
}

// When BOTH fail, primary's error still wins -- the same priority as the
// single-failure case above, just with both goroutines erroring.
func TestFetchWithCityBothErrorPrimaryWins(t *testing.T) {
	primaryErr, cityErr := notFound("primary"), notFound("city")
	api := &fakeAPI{
		city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr },
	}
	_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr })
	if err != primaryErr {
		t.Errorf("err = %v, want the primary error (city error must not win)", err)
	}
}

// Proves the two calls actually run CONCURRENTLY rather than sequentially --
// deterministically, via a rendezvous, not by timing (which would be flaky
// under CI load). Each fake blocks until BOTH have started; if fetchWithCity
// ran them sequentially, the second would never start until the first
// returned, and this would deadlock and fail on the test's own timeout.
func TestFetchWithCityRunsConcurrently(t *testing.T) {
	started := make(chan struct{}, 2)
	release := make(chan struct{})
	rendezvous := func() {
		started <- struct{}{}
		<-release
	}

	api := &fakeAPI{
		city: func(_, _ string) (*contentapi.CityPayload, error) {
			rendezvous()
			return &contentapi.CityPayload{}, nil
		},
	}

	done := make(chan struct{})
	go func() {
		fetchWithCity(api, "testville", func() (string, error) {
			rendezvous()
			return "", nil
		})
		close(done)
	}()

	// Both goroutines must reach the rendezvous before either can proceed --
	// only possible if they were launched concurrently.
	for range 2 {
		select {
		case <-started:
		case <-time.After(2 * time.Second):
			t.Fatal("timed out waiting for both calls to start -- they are not running concurrently")
		}
	}
	close(release)

	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("fetchWithCity did not return after release")
	}
}

func TestMonthCtxUnknownLabelIsError(t *testing.T) {
	h := newHandlers(t, fixtureAPI(t))
	j := loadFixture[contentapi.MonthPayload](t, "month")
	city := loadFixture[contentapi.CityPayload](t, "city").City
	j.Records[0].Label = "Bogus"
	if _, _, err := h.monthCtx(j, city); err == nil {
		t.Error("unknown record label must error (Python KeyError -> 500)")
	}
}

func TestRecordsCtx(t *testing.T) {
	h := newHandlers(t, fixtureAPI(t))
	j := loadFixture[contentapi.RecordsPayload](t, "records")
	city := loadFixture[contentapi.CityPayload](t, "city").City
	ctx, unit, err := h.recordsCtx(j, city)
	if err != nil {
		t.Fatal(err)
	}
	if unit != "C" {
		t.Errorf("unit %q", unit)
	}
	rows := ctx["Rows"].([]map[string]any)
	var precip, high map[string]any
	for _, r := range rows {
		switch r["Label"] {
		case "Precip":
			precip = r
		case "High":
			high = r
		}
	}
	// The precip low is the dry streak, not a formatted value.
	if precip["Low"] != "33-day dry spell" {
		t.Errorf("dry spell: %v", precip["Low"])
	}
	if precip["LowDate"] != "1995-07-31" {
		t.Errorf("dry spell date: %v", precip["LowDate"])
	}
	// HighF/LowF only ride along for temperature metrics.
	if precip["HighF"] != nil {
		t.Errorf("precip HighF should be nil: %v", precip["HighF"])
	}
	if high["HighF"] == nil {
		t.Error("temp HighF missing")
	}
	// Monthly extremes carry the {Txt, Cls, Date} shape the extremes() macro
	// reads; 57.9°F sits in the [45, 58) "cool" tier — boundary-adjacent.
	warm := ctx["Monthly"].([]map[string]any)[0]["High"].(map[string]any)["Warm"].(map[string]any)
	if warm["Cls"] != "cool" || warm["Date"] != "2022-01-01" {
		t.Errorf("monthly warm extreme: %v", warm)
	}
	if warm["Txt"].(template.HTML) != `<span class="temp" data-temp-f="57.9">14°C</span>` {
		t.Errorf("monthly warm txt: %q", warm["Txt"])
	}
	if ctx["Hemisphere"] != j.Hemisphere {
		t.Error("hemisphere lost")
	}
	// ToolHref: the full href, same composition as city/month.
	if ctx["ToolHref"] != fmt.Sprintf("/thermograph/#%.5f,%.5f", city.Lat, city.Lon) {
		t.Errorf("ToolHref %v", ctx["ToolHref"])
	}
}

func TestHubCtx(t *testing.T) {
	h := newHandlers(t, fixtureAPI(t))
	hub := loadFixture[contentapi.HubPayload](t, "hub")
	ctx := h.hubCtx(hub)
	if ctx["NCities"] != 1000 || ctx["NCountries"] != 124 {
		t.Errorf("counts: %v %v", ctx["NCities"], ctx["NCountries"])
	}
	// Groups is the raw []contentapi.HubCountry slice, preserving the
	// backend's country order (the Python dict was insertion-ordered; a Go
	// map would shuffle every render) — HubCountry/HubCity's own fields
	// already match what the template reads, so no rebuilding at all.
	groups := ctx["Groups"].([]contentapi.HubCountry)
	if groups[0].Country != "Afghanistan" {
		t.Errorf("group order lost: %v", groups[0].Country)
	}
	first := groups[0].Cities[0]
	if first.Slug != "kabul-af" || first.Name != "Kabul" {
		t.Errorf("city entry: %v", first)
	}
	if ctx["PageTitle"] == "" || ctx["CanonicalPath"] != "/climate" {
		t.Error("hub meta broken")
	}
}

func TestHomeCtxJSONLD(t *testing.T) {
	h := newHandlers(t, fixtureAPI(t))
	hp := loadFixture[contentapi.HomePayload](t, "home")
	ctx, err := h.homeCtx(hp, "http://example.test")
	if err != nil {
		t.Fatal(err)
	}
	// Byte-exact against Python's json.dumps({**_HOME_JSONLD, "url": ...})
	// (default separators — spaces preserved — and insertion order).
	want := `{"@context": "https://schema.org", "@type": "WebApplication", "name": "Thermograph", ` +
		`"applicationCategory": "WeatherApplication", "operatingSystem": "Web, iOS, Android", ` +
		`"isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, ` +
		`"description": "How unusual is your weather? Any day, anywhere on Earth, graded against 45 years ` +
		`of that place's own history.", "url": "http://example.test/thermograph/"}`
	if got := string(ctx["JSONLDStr"].(template.JS)); got != want {
		t.Errorf("home jsonld:\n got %s\nwant %s", got, want)
	}
	if ctx["BrandTag"] != "p" || ctx["MainClass"] != "" || ctx["Section"] != "home" {
		t.Error("home ctx flags broken")
	}
	// The captured fixture has no hero pick; nil must stay nil (template's
	// {{if .Unusual}} falsiness), not a zero-valued struct.
	if ctx["Unusual"] != nil {
		t.Errorf("unusual: %v", ctx["Unusual"])
	}
	// Cities is the raw []contentapi.HomeCity slice (Slug/Name already match).
	if len(ctx["Cities"].([]contentapi.HomeCity)) != 12 {
		t.Error("city chips lost")
	}
}

func TestHomeRankedPreservesLatLonText(t *testing.T) {
	h := newHandlers(t, fixtureAPI(t))
	v := 100.4
	hp := &contentapi.HomePayload{Ranked: []contentapi.HomeRanked{{
		Cls: "rec-hot", Display: "Testville", Lat: "51.5074", Lon: "-0.1000",
		Value: &v, GradeLabel: "Near Record", MetricLabel: "High", PctLabel: "99",
	}}}
	ctx, err := h.homeCtx(hp, "http://x")
	if err != nil {
		t.Fatal(err)
	}
	// Ranked is the raw []contentapi.HomeRanked slice (Cls/Display/Lat/Lon/
	// Value/... already match what the template reads).
	r := ctx["Ranked"].([]contentapi.HomeRanked)[0]
	// json.Number prints its raw text — "-0.1000" must not become "-0.1".
	if fmt.Sprint(r.Lat) != "51.5074" || fmt.Sprint(r.Lon) != "-0.1000" {
		t.Errorf("lat/lon text: %v %v", r.Lat, r.Lon)
	}
	if r.DateLabel != nil {
		t.Errorf("DateLabel should be nil: %v", r.DateLabel)
	}
}

func TestGlossaryCtx(t *testing.T) {
	h := newHandlers(t, fixtureAPI(t))
	first := h.glossary.Terms[0]
	ctx, err := h.glossaryTermCtx(first.Slug, first, "http://testserver/thermograph")
	if err != nil {
		t.Fatal(err)
	}
	// {base} substitution happens per-request in the body, like the Python's
	// _glossary_body.
	body := string(ctx["Body"].(template.HTML))
	if strings.Contains(body, "{base}") {
		t.Error("body {base} placeholder not substituted")
	}
	if strings.Contains(first.Body, "{base}") && !strings.Contains(body, "/thermograph") {
		t.Error("body {base} not replaced with the configured base")
	}
	if ctx["PageTitle"] != first.Term+": what it means | Thermograph" {
		t.Errorf("PageTitle: %v", ctx["PageTitle"])
	}
	// Others is the raw []contentdata.GlossaryTerm slice (Slug/Term already
	// match what the template reads).
	others := ctx["Others"].([]contentdata.GlossaryTerm)
	if len(others) != len(h.glossary.Terms)-1 {
		t.Errorf("others: %d", len(others))
	}
	for _, o := range others {
		if o.Slug == first.Slug {
			t.Error("others must exclude the current term")
		}
	}
	// JSONLDStr: a raw DefinedTerm object, not a JSON-encoded STRING
	// containing one (the template.HTML-vs-template.JS bug this guards
	// against would wrap the whole thing in quotes with escaped internals).
	jsonld := ctx["JSONLDStr"]
	js, ok := jsonld.(template.JS)
	if !ok {
		t.Fatalf("JSONLDStr type = %T, want template.JS", jsonld)
	}
	want := `{"@context":"https://schema.org","@type":"DefinedTerm","name":"` + first.Term +
		`","description":"` + first.Short + `","inDefinedTermSet":"http://testserver/thermograph/glossary"}`
	if string(js) != want {
		t.Errorf("JSONLDStr:\n got %s\nwant %s", js, want)
	}
}

// --- FuncMap helpers ---------------------------------------------------------

// html/template strips literal <!-- --> comments while parsing (verified
// directly: a template of only `<p>a</p><!-- x --><p>b</p>` renders as
// `<p>a</p><p>b</p>`) -- comment must wrap its output template.HTML to insert
// it as trusted content rather than markup, so a visible comment written in a
// .tmpl file actually survives into what ships. Checked both as a bare
// function call and end to end through a real html/template parse+execute,
// since the function alone proves nothing about whether html/template's
// parser then strips it back out.
func TestCommentSurvivesParsing(t *testing.T) {
	fm := FuncMap(testConfig())
	comment := fm["comment"].(func(string) template.HTML)
	if got := comment("hello"); got != "<!-- hello -->" {
		t.Errorf("comment(%q) = %q", "hello", got)
	}

	tmpl, err := template.New("t").Funcs(fm).Parse(`<p>a</p>{{comment "x"}}<p>b</p>`)
	if err != nil {
		t.Fatal(err)
	}
	var buf strings.Builder
	if err := tmpl.Execute(&buf, nil); err != nil {
		t.Fatal(err)
	}
	if got := buf.String(); got != `<p>a</p><!-- x --><p>b</p>` {
		t.Errorf("comment through a real template = %q", got)
	}
}

// html/template ALSO strips JavaScript `//` line comments from <script>
// bodies, independent of and in addition to the HTML-comment stripping
// TestCommentSurvivesParsing guards -- verified with zero {{ }} actions
// anywhere near the comment, so it isn't specific to how a value gets
// interpolated. jscomment needs template.JS, not template.HTML: inside
// <script>, html/template treats template.HTML as an untrusted value and
// re-escapes it as a quoted JS string (the same failure mode the JSONLDStr
// sites hit before being fixed to template.JS).
func TestJSCommentSurvivesParsing(t *testing.T) {
	fm := FuncMap(testConfig())
	jscomment := fm["jscomment"].(func(string) template.JS)
	if got := jscomment("hello"); got != "// hello" {
		t.Errorf("jscomment(%q) = %q", "hello", got)
	}

	tmpl, err := template.New("t").Funcs(fm).Parse("<script>\nvar x = 1;\n\n{{jscomment \"x\"}}\nvar y = 2;\n</script>")
	if err != nil {
		t.Fatal(err)
	}
	var buf strings.Builder
	if err := tmpl.Execute(&buf, nil); err != nil {
		t.Fatal(err)
	}
	want := "<script>\nvar x = 1;\n\n// x\nvar y = 2;\n</script>"
	if got := buf.String(); got != want {
		t.Errorf("jscomment through a real template:\n got %q\nwant %q", got, want)
	}
}

func TestHeadVerifyHTML(t *testing.T) {
	if headVerifyHTML("", "") != "" {
		t.Error("no tokens -> empty")
	}
	got := headVerifyHTML("gtok", "btok")
	want := "<meta name=\"google-site-verification\" content=\"gtok\">\n  " +
		"<meta name=\"msvalidate.01\" content=\"btok\">"
	if string(got) != want {
		t.Errorf("head_verify: %q", got)
	}
	// Attribute escaping matches markupsafe.
	if !strings.Contains(string(headVerifyHTML(`a"b`, "")), "a&#34;b") {
		t.Error("token not attribute-escaped")
	}
}

func TestToJSON(t *testing.T) {
	// u builds a backslash-u escape the way json.dumps/htmlsafe_json_dumps
	// emit them (lowercase hex, four digits).
	u := func(r rune) string { return fmt.Sprintf(`\u%04x`, r) }
	cases := []struct {
		in, want string
	}{
		{"Feels-like temperature", `"Feels-like temperature"`},
		// Jinja's htmlsafe |tojson escapes the HTML-unsafe <, >, &, '.
		{`a<b>&'c`, `"a` + u('<') + `b` + u('>') + u('&') + u('\'') + `c"`},
		// ...and ensure_ascii turns non-ASCII into escapes.
		{"50°F", `"50` + u('°') + `F"`},
		{"±7", `"` + u('±') + `7"`},
	}
	for _, c := range cases {
		got, err := toJSON(c.in)
		if err != nil {
			t.Fatal(err)
		}
		if string(got) != c.want {
			t.Errorf("toJSON(%q) = %s, want %s", c.in, got, c.want)
		}
	}
}

func TestFuncMapHelpers(t *testing.T) {
	fm := FuncMap(testConfig())
	temp := fm["temp"].(func(any, any) template.HTML)
	// Jinja's {{ temp(-10) }} literal (int) and a nullable payload pointer.
	if got := temp("C", -10); got != `<span class="temp" data-temp-f="-10.0">-23°C</span>` {
		t.Errorf("temp int literal: %q", got)
	}
	if got := temp("", (*float64)(nil)); got != "—" {
		t.Errorf("temp nil pointer: %q", got)
	}
	tc := fm["temp_class"].(func(any) string)
	if tc(nil) != "none" {
		t.Error("temp_class nil")
	}
	ord := fm["ordinal"].(func(any) string)
	if ord(89.1) != "89th" {
		t.Error("ordinal")
	}
}
