package content

import (
	"bytes"
	"encoding/json"
	"fmt"
	"html/template"
	"net/http"
	"strings"
	"sync"

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

// metricKeyByLabel maps a record row's display label back to its metric key
// for the format dispatch (content.py's _METRIC_KEY_BY_LABEL). An unknown
// label is a payload-contract break and 500s, like the Python's KeyError.
var metricKeyByLabel = map[string]string{
	"High": "tmax", "Low": "tmin", "Feels-like": "feels",
	"Humidity": "humid", "Wind": "wind", "Gust": "gust", "Precip": "precip",
}

// --- shared ctx builders -----------------------------------------------------
//
// Where an contentapi.* struct's exported field names already match what a
// template needs (checked against internal/render/templates' own header
// comments — the authoritative contract), it is passed straight through
// rather than re-wrapped in a map: one fewer hand-typed key string to drift,
// and Go's ordinary pointer/struct nil semantics ({{if .X}}, auto-deref on
// field access) already do the right thing. Only genuinely COMPUTED display
// values (temp spans, range-bar geometry, tier classes) still build a map.

// findMonth returns the fmtMonths entry with the given slug, or nil (the
// template guards every use with {{if .Warmest}} etc., matching Python's
// next((m for m in months if m["slug"] == slug), None)).
func findMonth(months []map[string]any, slug string) any {
	for _, m := range months {
		if m["Slug"] == slug {
			return m
		}
	}
	return nil
}

// fmtMonths is content.py's _fmt_months: each payload month plus its
// display-formatted high/low/precip and range-bar geometry. Name/Slug/HighF/
// LowF/RangeLoF/RangeHiF/PrecipF are read directly off the payload elsewhere
// in the template (e.g. {{$.Fmt.TempClass .HighF}}), so they ride along
// alongside the computed High/Low/Precip/Bar rather than being dropped.
func fmtMonths(unit format.Unit, months []contentapi.CityMonth) []map[string]any {
	out := make([]map[string]any, 0, len(months))
	for _, m := range months {
		out = append(out, map[string]any{
			"Name": m.Name, "Slug": m.Slug,
			"HighF": m.HighF, "LowF": m.LowF,
			"RangeLoF": m.RangeLoF, "RangeHiF": m.RangeHiF,
			"PrecipF": m.PrecipF,
			"High":    format.Temp(unit, m.HighF),
			"Low":     format.Temp(unit, m.LowF),
			"Precip":  format.Precip(unit, m.PrecipF),
			"Bar":     format.MonthRangeBar(m.RangeLoF, m.RangeHiF),
		})
	}
	return out
}

// --- per-city climate page ---------------------------------------------------

// CityPage renders /climate/{slug} (content.py's city_page).
//
// ctx keys: Section, City, Display, Name, YearRange, NYears, Months,
// Warmest, Coldest, Wettest, Records, Today, ToolHref, Flavor, Event,
// CompareURL, Breadcrumb, CanonicalPath, PageTitle, PageDescription,
// JSONLDStr (+ the shared keys respondHTML adds).
func (h *Handlers) CityPage(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	j, err := h.api.City(slug, origin(r))
	if err != nil {
		h.apiError(w, err)
		return
	}
	ctx, unit, err := h.cityCtx(j)
	if err != nil {
		h.serverError(w, err)
		return
	}
	h.respondHTML(w, r, "city.html.tmpl", unit, ctx)
}

// cityCtx builds the render context — split from the handler so the port's
// behaviour tests can assert on it without the (separately-owned) templates.
func (h *Handlers) cityCtx(j *contentapi.CityPayload) (map[string]any, format.Unit, error) {
	// The backend decides the page's display unit (its own F_COUNTRIES
	// logic); month/records pages derive it from the country code instead —
	// preserved as-is from the Python.
	unit := format.Unit(j.DefaultUnit)
	months := fmtMonths(unit, j.Months)

	var today any
	if j.TodayVsNormal != nil {
		cards := make([]map[string]any, 0, len(j.TodayVsNormal.Cards))
		for _, c := range j.TodayVsNormal.Cards {
			cards = append(cards, map[string]any{
				"Label": c.Label, "Metric": c.Metric, "ValueF": c.ValueF,
				"Percentile": c.Percentile, "Grade": c.Grade, "Cls": c.Cls,
				"Value": format.Fmt(unit, c.Metric, c.ValueF),
			})
		}
		today = map[string]any{"Date": j.TodayVsNormal.Date, "Cards": cards}
	}

	jsonld, err := compactJSONLD(j.JSONLD)
	if err != nil {
		return nil, unit, err
	}

	// Flavor/Event: contentapi.Flavor/Event's own fields (Extract/Title/URL,
	// Text/URL) already match what the template reads, so dereference-or-nil
	// is enough — no map wrapping. A typed-nil *Flavor stored in the ctx
	// directly would make {{.Flavor.Extract}} hard-error on a nil pointer
	// (unlike a plain missing map key, which degrades to empty); dereferencing
	// first avoids that and still renders {{if .Flavor}} correctly (Go
	// template truthiness on a nil `any` is false; on a struct value, true).
	var flavor, event any
	if j.Flavor != nil {
		flavor = *j.Flavor
	}
	if j.Event != nil {
		event = *j.Event
	}

	ctx := map[string]any{
		"Section": "climate",
		"City":    j.City, "Display": j.Display, "Name": j.City.Name,
		"YearRange": j.YearRange, "NYears": j.NYears,
		"Months":  months,
		"Warmest": findMonth(months, j.WarmestMonthSlug),
		"Coldest": findMonth(months, j.ColdestMonthSlug),
		"Wettest": findMonth(months, j.WettestMonthSlug),
		"Records": j.AllTimeRecords, "Today": today,
		// The full href, not just the "lat,lon" fragment: composing it here
		// (rather than {{.Base}}/#{{.ToolHash}} in the template) avoids
		// html/template's URL-context escaper %-escaping the comma.
		"ToolHref":        fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, j.City.Lat, j.City.Lon),
		"Flavor":          flavor,
		"Event":           event,
		"CompareURL":      fmt.Sprintf("%s/compare#loc=%.4f,%.4f", h.cfg.Base, j.City.Lat, j.City.Lon),
		"Breadcrumb":      j.Breadcrumb,
		"CanonicalPath":   j.CanonicalPath,
		"PageTitle":       j.PageTitle,
		"PageDescription": j.PageDescription,
		// template.JS, NOT template.HTML: this value is interpolated inside
		// <script type="application/ld+json">...</script>. html/template's
		// contextual escaper treats <script> bodies as JAVASCRIPT context
		// regardless of the script's `type` attribute — template.HTML's trust
		// guarantee only covers HTML markup context, so inside <script> it
		// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
		// quoted JS string with every inner quote backslash-escaped, i.e. the
		// whole JSON-LD payload doubly wrapped in a string literal instead of
		// being emitted as the raw JSON object search engines expect.
		// template.JS is the type that means "trusted JS source, emit as-is".
		"JSONLDStr": template.JS(jsonld),
	}
	return ctx, unit, nil
}

// fetchWithCity runs a page's primary API call and the shared City lookup
// concurrently instead of sequentially: both are independent single-slug
// fetches (City never depends on primary's result), so waiting for one to
// fully round-trip before even starting the other was pure latency with
// nothing to show for it. Concurrent instead cuts that leg to roughly
// whichever call is slower.
//
// If primary fails, its error wins even when City also fails or hasn't
// finished — matching the old sequential code's behavior exactly (it never
// even called City() once primary had already failed). The one real
// trade-off: City() is now ALWAYS launched, even on a request that's about
// to 404 from primary, so an invalid slug costs one extra (wasted, cheap)
// backend lookup it previously skipped. Worth it: a bad slug is the rare
// path; a good one is the common path this speeds up.
func fetchWithCity[T any](api API, slug string, primary func() (T, error)) (T, contentapi.CityInfo, error) {
	var (
		pVal  T
		pErr  error
		cPay  *contentapi.CityPayload
		cErr  error
		group sync.WaitGroup
	)
	group.Add(2)
	go func() {
		defer group.Done()
		pVal, pErr = primary()
	}()
	go func() {
		defer group.Done()
		cPay, cErr = api.City(slug, "") // no origin: the Python called city(slug) bare here too
	}()
	group.Wait()

	if pErr != nil {
		return pVal, contentapi.CityInfo{}, pErr
	}
	if cErr != nil {
		var zero T
		return zero, contentapi.CityInfo{}, cErr
	}
	return pVal, cPay.City, nil
}

// --- month page --------------------------------------------------------------

// MonthPage renders /climate/{slug}/{month} (content.py's month_page).
//
// ctx keys: Section, City, Display, Name, MonthName, MonthSlug,
// YearRange, NYears, AvgHigh, AvgLow, AvgHighCls, AvgLowCls, Stats,
// Records, ToolHref, CompareURL, Prev, Next, Breadcrumb, CanonicalPath,
// PageTitle, PageDescription. `Stats` entries are {Label, Value} maps (the
// Jinja iterated (label, value) tuples).
func (h *Handlers) MonthPage(w http.ResponseWriter, r *http.Request) {
	slug, month := r.PathValue("slug"), r.PathValue("month")
	j, city, err := fetchWithCity(h.api, slug, func() (*contentapi.MonthPayload, error) {
		return h.api.CityMonth(slug, month)
	})
	if err != nil {
		h.apiError(w, err)
		return
	}
	ctx, unit, err := h.monthCtx(j, city)
	if err != nil {
		h.serverError(w, err)
		return
	}
	h.respondHTML(w, r, "month.html.tmpl", unit, ctx)
}

// monthCtx builds the month page's render context (split out for tests).
func (h *Handlers) monthCtx(j *contentapi.MonthPayload, city contentapi.CityInfo) (map[string]any, format.Unit, error) {
	slug, month := city.Slug, j.MonthSlug
	unit := format.UnitForCountry(city.CountryCode)

	var stats []map[string]any
	addStat := func(label string, value template.HTML) {
		stats = append(stats, map[string]any{"Label": label, "Value": value})
	}
	rangeStat := func(pair []*float64) (template.HTML, error) {
		if len(pair) != 2 {
			return "", fmt.Errorf("month %s/%s: typical range has %d entries, want 2", slug, month, len(pair))
		}
		lo, hi := format.Temp(unit, pair[0]), format.Temp(unit, pair[1])
		return template.HTML(string(lo) + " to " + string(hi)), nil
	}
	if j.AvgHighF != nil {
		addStat("Average high", format.Temp(unit, j.AvgHighF))
		v, err := rangeStat(j.TypicalHighRangeF)
		if err != nil {
			return nil, unit, err
		}
		addStat("Typical high range", v)
	}
	if j.AvgLowF != nil {
		addStat("Average low", format.Temp(unit, j.AvgLowF))
		v, err := rangeStat(j.TypicalLowRangeF)
		if err != nil {
			return nil, unit, err
		}
		addStat("Typical low range", v)
	}
	if j.AvgPrecipF != nil {
		addStat("Average daily precipitation", format.Precip(unit, j.AvgPrecipF))
	}

	records := make([]map[string]any, 0, len(j.Records))
	for _, rec := range j.Records {
		metric, ok := metricKeyByLabel[rec.Label]
		if !ok {
			return nil, unit, fmt.Errorf("month %s/%s: unknown record label %q", slug, month, rec.Label)
		}
		records = append(records, map[string]any{
			"Label": rec.Label, "HighTag": rec.HighTag, "LowTag": rec.LowTag,
			"High": format.Fmt(unit, metric, rec.HighF), "HighDate": rec.HighDate,
			"Low": format.Fmt(unit, metric, rec.LowF), "LowDate": rec.LowDate,
		})
	}

	display := city.Name
	if j.Display != nil { // j.get("display", city["name"])
		display = *j.Display
	}

	// Prev/Next: MonthLink's Name/Slug fields already match what the template
	// reads; dereference-or-nil for the same reason as Flavor/Event above — a
	// typed-nil *MonthLink stored as `any` would hard-error on field access
	// where a plain nil degrades to empty.
	var prev, next any
	if j.Prev != nil {
		prev = *j.Prev
	}
	if j.Next != nil {
		next = *j.Next
	}

	ctx := map[string]any{
		"Section": "climate", "City": city, "Display": display, "Name": city.Name,
		"MonthName": j.MonthName, "MonthSlug": j.MonthSlug,
		"YearRange": j.YearRange, "NYears": j.NYears,
		"AvgHigh": format.Temp(unit, j.AvgHighF), "AvgLow": format.Temp(unit, j.AvgLowF),
		"AvgHighCls": format.TempClass(j.AvgHighF), "AvgLowCls": format.TempClass(j.AvgLowF),
		"Stats": stats, "Records": records,
		"ToolHref":   fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, city.Lat, city.Lon),
		"CompareURL": fmt.Sprintf("%s/compare#loc=%.4f,%.4f", h.cfg.Base, city.Lat, city.Lon),
		"Prev":       prev, "Next": next,
		"Breadcrumb":      j.Breadcrumb,
		"CanonicalPath":   j.CanonicalPath,
		"PageTitle":       j.PageTitle,
		"PageDescription": j.PageDescription,
	}
	return ctx, unit, nil
}

// --- records page ------------------------------------------------------------

// fmtExtreme / fmtPeriodSide: content.py's _fmt_extreme/_fmt_period_extremes —
// {'warm': {'value_f','date'}|None, 'cold': …} becomes the {'Cls','Txt',
// 'Date'} shape the "extremes" template block reads. Stays a computed map
// (unlike Prev/Flavor/etc.): the raw contentapi.Extreme struct has no Cls/Txt
// fields — those are derived display values, not API passthrough.
func fmtExtreme(unit format.Unit, e *contentapi.Extreme) any {
	if e == nil {
		return nil
	}
	v := e.ValueF
	return map[string]any{
		"Txt": format.Temp(unit, &v), "Cls": format.TempClass(&v), "Date": e.Date,
	}
}

func fmtPeriodSide(unit format.Unit, s contentapi.PeriodSide) map[string]any {
	return map[string]any{"Warm": fmtExtreme(unit, s.Warm), "Cold": fmtExtreme(unit, s.Cold)}
}

// RecordsPage renders /climate/{slug}/records (content.py's records_page).
//
// ctx keys: Section, City, Display, Name, YearRange, NYears, Rows,
// Monthly, Seasonal, Hemisphere, AllTime, CanonicalPath, Breadcrumb,
// PageTitle, PageDescription, JSONLDStr. Row maps carry Label, High,
// HighDate, HighF, Low, LowDate, LowF — HighF/LowF only for
// temperature metrics, nil otherwise, as the Python set them.
func (h *Handlers) RecordsPage(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("slug")
	o := origin(r)
	j, city, err := fetchWithCity(h.api, slug, func() (*contentapi.RecordsPayload, error) {
		return h.api.CityRecords(slug, o)
	})
	if err != nil {
		h.apiError(w, err)
		return
	}
	ctx, unit, err := h.recordsCtx(j, city)
	if err != nil {
		h.serverError(w, err)
		return
	}
	h.respondHTML(w, r, "records.html.tmpl", unit, ctx)
}

// recordsCtx builds the records page's render context (split out for tests).
func (h *Handlers) recordsCtx(j *contentapi.RecordsPayload, city contentapi.CityInfo) (map[string]any, format.Unit, error) {
	unit := format.UnitForCountry(city.CountryCode)

	rows := make([]map[string]any, 0, len(j.Rows))
	for _, rec := range j.Rows {
		metric, ok := metricKeyByLabel[rec.Label]
		if !ok {
			return nil, unit, fmt.Errorf("records %s: unknown row label %q", city.Slug, rec.Label)
		}
		// The precip row's "low" is the longest dry spell, not a value — the
		// backend signals it with dry_streak_days and the date the spell began.
		var low any
		lowDate := derefOr(rec.LowDate, "")
		if rec.DryStreakDays != nil {
			low = fmt.Sprintf("%d-day dry spell", *rec.DryStreakDays)
			if lowDate == "" {
				lowDate = "—" // r["low_date"] or "—"
			}
		} else {
			low = format.Fmt(unit, metric, rec.LowF)
		}
		isTemp := metric == "tmax" || metric == "tmin" || metric == "feels"
		var highF, lowF any
		if isTemp {
			highF, lowF = rec.HighF, rec.LowF
		}
		rows = append(rows, map[string]any{
			"Label": rec.Label,
			"High":  format.Fmt(unit, metric, rec.HighF), "HighDate": rec.HighDate,
			"HighF": highF,
			"Low":   low, "LowDate": lowDate,
			"LowF": lowF,
		})
	}

	monthly := make([]map[string]any, 0, len(j.Monthly))
	for _, m := range j.Monthly {
		monthly = append(monthly, map[string]any{
			"Name": m.Name, "Slug": m.Slug,
			"High": fmtPeriodSide(unit, m.High), "Low": fmtPeriodSide(unit, m.Low),
		})
	}
	seasonal := make([]map[string]any, 0, len(j.Seasonal))
	for _, s := range j.Seasonal {
		seasonal = append(seasonal, map[string]any{
			"Name": s.Name, "Span": s.Span,
			"High": fmtPeriodSide(unit, s.High), "Low": fmtPeriodSide(unit, s.Low),
		})
	}

	jsonld, err := compactJSONLD(j.JSONLD)
	if err != nil {
		return nil, unit, err
	}

	ctx := map[string]any{
		"Section": "climate", "City": city,
		"Display": derefOr(j.Display, city.Name), "Name": city.Name,
		"YearRange": j.YearRange, "NYears": j.NYears,
		"Rows": rows, "Monthly": monthly, "Seasonal": seasonal,
		"Hemisphere": j.Hemisphere, "AllTime": j.AllTimeRecords,
		"ToolHref":        fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, city.Lat, city.Lon),
		"CanonicalPath":   j.CanonicalPath,
		"Breadcrumb":      j.Breadcrumb,
		"PageTitle":       j.PageTitle,
		"PageDescription": j.PageDescription,
		// template.JS, NOT template.HTML: this value is interpolated inside
		// <script type="application/ld+json">...</script>. html/template's
		// contextual escaper treats <script> bodies as JAVASCRIPT context
		// regardless of the script's `type` attribute — template.HTML's trust
		// guarantee only covers HTML markup context, so inside <script> it
		// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
		// quoted JS string with every inner quote backslash-escaped, i.e. the
		// whole JSON-LD payload doubly wrapped in a string literal instead of
		// being emitted as the raw JSON object search engines expect.
		// template.JS is the type that means "trusted JS source, emit as-is".
		"JSONLDStr": template.JS(jsonld),
	}
	return ctx, unit, nil
}

func derefOr(s *string, fallback string) string {
	if s != nil {
		return *s
	}
	return fallback
}

// --- hub / glossary / about / privacy ---------------------------------------

// HubPage renders /climate (content.py's hub_page).
//
// ctx keys: Section, Groups, NCities, NCountries, CanonicalPath,
// Breadcrumb, PageTitle, PageDescription. NOTE: the Python's `groups` was
// a dict {country: cities} iterated with .items() — insertion-ordered. Go
// maps don't preserve order, so Groups is the ordered []contentapi.HubCountry
// slice straight from the payload — HubCountry/HubCity's own fields already
// match what the template reads, so this needs no rebuilding at all.
func (h *Handlers) HubPage(w http.ResponseWriter, r *http.Request) {
	hub, err := h.api.Hub()
	if err != nil {
		h.apiError(w, err)
		return
	}
	h.respondHTML(w, r, "hub.html.tmpl", "", h.hubCtx(hub))
}

// hubCtx builds the hub page's render context (split out for tests).
func (h *Handlers) hubCtx(hub *contentapi.HubPayload) map[string]any {
	return map[string]any{
		"Section":         "climate",
		"Groups":          hub.Countries,
		"NCities":         hub.NCities,
		"NCountries":      hub.NCountries,
		"CanonicalPath":   "/climate",
		"Breadcrumb":      []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Climate", "")},
		"PageTitle":       h.pages["hub"].Title,
		"PageDescription": h.pages["hub"].Description,
	}
}

// GlossaryIndex renders /glossary. ctx keys: Terms (Slug/Term/Short/Body, in
// file order — contentdata.GlossaryTerm's own fields already match, so the
// list passes straight through), CanonicalPath, Breadcrumb, PageTitle,
// PageDescription.
func (h *Handlers) GlossaryIndex(w http.ResponseWriter, r *http.Request) {
	ctx := map[string]any{
		"Terms":           h.glossary.Terms,
		"CanonicalPath":   "/glossary",
		"Breadcrumb":      []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Glossary", "")},
		"PageTitle":       h.pages["glossary_index"].Title,
		"PageDescription": h.pages["glossary_index"].Description,
	}
	h.respondHTML(w, r, "glossary.html.tmpl", "", ctx)
}

// GlossaryTerm renders /glossary/{term}. ctx keys: Term, Body (raw HTML with
// {base} substituted per-request, the Python's _glossary_body), Others
// (Slug/Term of every other entry), CanonicalPath, Breadcrumb, PageTitle,
// PageDescription, JSONLDStr (a DefinedTerm object built inline here — the
// Jinja original interpolated term|tojson/page_description|tojson/base_url
// directly into the block rather than routing through content.py's ctx dict,
// so this port has to build the same bytes in Go instead).
func (h *Handlers) GlossaryTerm(w http.ResponseWriter, r *http.Request) {
	slug := r.PathValue("term")
	entry, ok := h.glossary.Get(slug)
	if !ok {
		writeDetail(w, http.StatusNotFound, "Unknown term.")
		return
	}
	ctx, err := h.glossaryTermCtx(slug, entry, origin(r)+h.cfg.Base)
	if err != nil {
		h.serverError(w, err)
		return
	}
	h.respondHTML(w, r, "glossary_term.html.tmpl", "", ctx)
}

// glossaryTermJSONLDPrefix/suffix bracket the term page's DefinedTerm
// object exactly as the Jinja original wrote it inline in the template
// (glossary_term.html.j2's {% block jsonld %}) rather than through
// content.py's ctx dict:
//
//	{"@context":"https://schema.org","@type":"DefinedTerm","name":<tojson term>,
//	 "description":<tojson short>,"inDefinedTermSet":"<base_url>/glossary"}
const (
	glossaryTermJSONLDPrefix = `{"@context":"https://schema.org","@type":"DefinedTerm","name":`
	glossaryTermJSONLDMiddle = `,"description":`
	glossaryTermJSONLDSuffix = `,"inDefinedTermSet":"`
)

// glossaryTermCtx builds the term page's render context (split out for
// tests). baseURL is origin(r)+cfg.Base — the request-scoped value
// respondHTML also stamps as ctx["BaseURL"], needed a step early here because
// it feeds the DefinedTerm JSON-LD, which must be ready before render.
func (h *Handlers) glossaryTermCtx(slug string, entry contentdata.GlossaryTerm, baseURL string) (map[string]any, error) {
	others := make([]contentdata.GlossaryTerm, 0, len(h.glossary.Terms)-1)
	for _, t := range h.glossary.Terms {
		if t.Slug != slug {
			others = append(others, t)
		}
	}

	// tojson both string fields exactly like Jinja's |tojson filter did
	// (toJSON is the same helper glossary_term.html.tmpl's inline JSON-LD
	// used to use directly — reused here since this build now happens in Go).
	name, err := toJSON(entry.Term)
	if err != nil {
		return nil, err
	}
	desc, err := toJSON(entry.Short)
	if err != nil {
		return nil, err
	}
	jsonld := glossaryTermJSONLDPrefix + string(name) +
		glossaryTermJSONLDMiddle + string(desc) +
		glossaryTermJSONLDSuffix + baseURL + `/glossary"}`

	return map[string]any{
		"Term":          entry.Term,
		"Body":          template.HTML(strings.ReplaceAll(entry.Body, "{base}", h.cfg.Base)),
		"CanonicalPath": "/glossary/" + slug,
		"Breadcrumb": []contentapi.Crumb{
			crumb("Home", h.cfg.Base+"/"),
			crumb("Glossary", h.cfg.Base+"/glossary"),
			crumb(entry.Term, ""),
		},
		"PageTitle":       fmt.Sprintf("%s: what it means | Thermograph", entry.Term),
		"PageDescription": entry.Short,
		"Others":          others,
		"JSONLDStr":       template.JS(jsonld), // see the JSONLDStr comment on cityCtx for why JS, not HTML
	}, nil
}

// AboutPage renders /about.
func (h *Handlers) AboutPage(w http.ResponseWriter, r *http.Request) {
	ctx := map[string]any{
		"CanonicalPath":   "/about",
		"Breadcrumb":      []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("About", "")},
		"PageTitle":       h.pages["about"].Title,
		"PageDescription": h.pages["about"].Description,
	}
	h.respondHTML(w, r, "about.html.tmpl", "", ctx)
}

// PrivacyPage renders /privacy.
func (h *Handlers) PrivacyPage(w http.ResponseWriter, r *http.Request) {
	ctx := map[string]any{
		"CanonicalPath":   "/privacy",
		"Breadcrumb":      []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Privacy", "")},
		"PageTitle":       h.pages["privacy"].Title,
		"PageDescription": h.pages["privacy"].Description,
	}
	h.respondHTML(w, r, "privacy.html.tmpl", "", ctx)
}

// --- homepage ----------------------------------------------------------------

// homeTitle/homeDescription are the literal strings the Jinja original wrote
// directly into home.html.j2's {% block title/description %} overrides,
// never routed through content.py's ctx dict — see the comment at homeCtx's
// call site for why this port sets them in Go instead.
const (
	homeTitle       = "Thermograph: how unusual is your weather?"
	homeDescription = "See how today's weather compares to decades of local climate history for any place on Earth. Daily high, low, feels-like, humidity, wind and rain, each graded by percentile."
)

// homeJSONLDPrefix is content.py's _HOME_JSONLD serialized exactly as
// json.dumps() with default separators emitted it (spaces after ':' and ',',
// key order preserved, True -> true) — only the request-dependent "url" is
// appended at render time. Kept as a literal so the bytes cannot drift from
// what the Python shipped.
const homeJSONLDPrefix = `{"@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": `

func homeJSONLD(url string) (string, error) {
	// json.dumps escaping for the one dynamic value (Python didn't escape
	// HTML-significant characters, so the encoder must not either).
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(url); err != nil {
		return "", err
	}
	return homeJSONLDPrefix + strings.TrimRight(buf.String(), "\n") + "}", nil
}

// HomePage renders / (content.py's home_page).
//
// ctx keys: Section, BrandTag, MainClass, PageTitle, PageDescription,
// CanonicalPath, Unusual, Stale, Ranked, Cities, JSONLDStr.
// contentapi.HomeUnusual/HomeRanked/HomeCity's own
// fields already match what the template reads, so Ranked/Cities pass
// straight through and only Unusual needs dereference-or-nil (a *HomeUnusual
// stored typed-nil in the ctx would hard-error on field access; nil `any`
// degrades to empty instead). Ranked's Lat/Lon stay json.Number so the
// backend's exact decimal text prints into the /day#lat=…&lon=… fragment.
func (h *Handlers) HomePage(w http.ResponseWriter, r *http.Request) {
	hp, err := h.api.Home()
	if err != nil {
		h.apiError(w, err)
		return
	}
	ctx, err := h.homeCtx(hp, origin(r))
	if err != nil {
		h.serverError(w, err)
		return
	}
	h.respondHTML(w, r, "home.html.tmpl", "", ctx)
}

// homeCtx builds the homepage render context (split out for tests). o is the
// request origin, baked into the JSON-LD url.
func (h *Handlers) homeCtx(hp *contentapi.HomePayload, o string) (map[string]any, error) {
	var unusual any
	if hp.Unusual != nil {
		unusual = *hp.Unusual
	}

	jsonld, err := homeJSONLD(o + h.cfg.Base + "/")
	if err != nil {
		return nil, err
	}

	ctx := map[string]any{
		"Section":   "home",
		"BrandTag":  "p", // the hero headline owns the sole h1; the brand degrades to <p>
		"MainClass": "",
		// The Jinja original overrode base.html.j2's {% block title/description
		// %} with these exact literals directly in home.html.j2 -- content.py's
		// home_page never set page_title/page_description in its ctx dict
		// either. This port's base is split into sequential chunks rather than
		// Jinja blocks (see render.go), so there is no override point once
		// base_head_start has already emitted <title>; setting them here is
		// the equivalent for this one route.
		"PageTitle":       homeTitle,
		"PageDescription": homeDescription,
		"CanonicalPath":   "/",
		"Unusual":         unusual,
		"Stale":           hp.Stale,
		"Ranked":          hp.Ranked,
		"Cities":          hp.Cities,
		// template.JS, NOT template.HTML: this value is interpolated inside
		// <script type="application/ld+json">...</script>. html/template's
		// contextual escaper treats <script> bodies as JAVASCRIPT context
		// regardless of the script's `type` attribute — template.HTML's trust
		// guarantee only covers HTML markup context, so inside <script> it
		// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
		// quoted JS string with every inner quote backslash-escaped, i.e. the
		// whole JSON-LD payload doubly wrapped in a string literal instead of
		// being emitted as the raw JSON object search engines expect.
		// template.JS is the type that means "trusted JS source, emit as-is".
		"JSONLDStr": template.JS(jsonld),
	}
	return ctx, nil
}
