// Package content is the Go port of content.py: the server-rendered,
// crawlable content pages (climate hub / per-city / month / records /
// glossary / about / privacy / homepage) plus robots.txt, sitemap.xml and the
// IndexNow key file. Every route handler fetches its data from the backend's
// SSR content API (internal/contentapi) — page_title / page_description /
// canonical_path / breadcrumb / jsonld are all computed by the backend
// (backend/api/content_payloads.py), never here.
//
// Render-context convention: handlers hand templates a map[string]any whose
// keys are the EXPORTED-Go-style PascalCase names the templates (see
// internal/render/templates) access — e.g. content.py's snake_case
// `year_range` context key is `YearRange` here. This does NOT mirror the
// Jinja context names verbatim: html/template's field/map-key resolution is
// case-sensitive with no fallback, and a map has no compile-time check for a
// wrong or stale key, so a mismatch fails SILENTLY (a missing string key
// renders empty, not an error — only an index/range over the resulting nil
// can panic). PascalCase was chosen to read as ordinary Go field access in
// the templates ({{.Display}}, not {{.display}}), and every template's own
// header comment documents the exact field set it expects — treat that
// comment as the authoritative contract when adding or renaming a key here.
// Nested display objects are maps/slices with the same PascalCase discipline.
//
// The one Jinja construct with no Go analogue is dict iteration order:
// Python dicts preserve insertion order, Go maps do not, so anything the
// templates iterate (hub country groups, glossary terms) is a SLICE here, in
// the same order the Python dict would have yielded.
package content

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"time"

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

// API is the slice of contentapi.Client the handlers consume — an interface
// so tests can substitute fixture-backed fakes exactly the way the Python
// suite monkeypatched api_client (tests/conftest.py's `client` fixture).
// *contentapi.Client satisfies it.
type API interface {
	Hub() (*contentapi.HubPayload, error)
	Sitemap() ([]contentapi.SitemapEntry, error)
	IndexNowKey() (string, error)
	Home() (*contentapi.HomePayload, error)
	City(slug, origin string) (*contentapi.CityPayload, error)
	CityMonth(slug, month string) (*contentapi.MonthPayload, error)
	CityRecords(slug, origin string) (*contentapi.RecordsPayload, error)
}

// requiredPages are the pages.yaml keys the handlers read (content.py's
// PAGES[...] lookups). Python failed lazily — a KeyError 500 on first hit of
// the page — but a missing key is a content-file bug that should break the
// boot, same philosophy as the loader's own fail-loud validation.
var requiredPages = []string{"hub", "glossary_index", "about", "privacy"}

// Handlers owns every content route. Construct with New, wire with Register.
type Handlers struct {
	cfg      config.Config
	api      API
	engine   *render.Engine
	glossary *contentdata.Glossary
	pages    map[string]contentdata.Page

	// bootDate is a stable per-process "content last built" date — crawlers
	// should see sitemap <lastmod> change on a real rebuild (a fresh
	// process), not churn on every request (content.py's _BOOT_DATE).
	bootDate string

	log *log.Logger
}

// New builds the handler set. The engine must have been constructed with
// FuncMap(cfg) (the template helpers close over the same config). logger may
// be nil (falls back to the standard logger).
func New(cfg config.Config, api API, engine *render.Engine, glossary *contentdata.Glossary, pages map[string]contentdata.Page, logger *log.Logger) (*Handlers, error) {
	for _, key := range requiredPages {
		if _, ok := pages[key]; !ok {
			return nil, fmt.Errorf("pages.yaml: missing required page %q", key)
		}
	}
	if logger == nil {
		logger = log.Default()
	}
	return &Handlers{
		cfg:      cfg,
		api:      api,
		engine:   engine,
		glossary: glossary,
		pages:    pages,
		bootDate: time.Now().Format("2006-01-02"),
		log:      logger,
	}, nil
}

// Register attaches every content route (the port of content.register).
// static is the same handler main.go mounts for static assets: the lazy
// IndexNow fallback route has to claim the whole single-segment pattern
// ({BASE}/{token} — Go's mux cannot express the Python's "/{token}.txt"
// suffix wildcard) and must hand non-.txt requests (app.js, style.css, …)
// back to the file server, so explicit page routes still win on precedence
// and everything else keeps serving. Go 1.22 mux precedence (most-specific
// pattern wins) replaces Starlette's registration-order rule; "GET" patterns
// serve HEAD too, covering the Python's methods=["GET", "HEAD"] routes.
func (h *Handlers) Register(mux *http.ServeMux, static http.Handler) {
	base := h.cfg.Base
	mux.HandleFunc("GET "+base+"/robots.txt", h.RobotsTxt)
	mux.HandleFunc("GET "+base+"/sitemap.xml", h.SitemapXML)
	h.registerIndexNow(mux, static)
	if base == "" {
		mux.HandleFunc("GET /{$}", h.HomePage)
	} else {
		mux.HandleFunc("GET "+base+"/{$}", h.HomePage)
	}
	mux.HandleFunc("GET "+base+"/about", h.AboutPage)
	mux.HandleFunc("GET "+base+"/privacy", h.PrivacyPage)
	mux.HandleFunc("GET "+base+"/glossary", h.GlossaryIndex)
	mux.HandleFunc("GET "+base+"/glossary/{term}", h.GlossaryTerm)
	mux.HandleFunc("GET "+base+"/climate", h.HubPage)
	mux.HandleFunc("GET "+base+"/climate/{slug}", h.CityPage)
	mux.HandleFunc("GET "+base+"/climate/{slug}/records", h.RecordsPage)
	mux.HandleFunc("GET "+base+"/climate/{slug}/{month}", h.MonthPage)
}

// origin reconstructs the browser-facing origin of a request.
// x-forwarded-host takes precedence over Host: when reached via backend's
// internal proxy fallback (no Caddy in front — see _proxy_to_frontend in the
// backend's web/app.py), Host is the internal hop's own address, not the
// browser-facing one.
func origin(r *http.Request) string {
	proto := r.Header.Get("X-Forwarded-Proto")
	if proto == "" {
		if r.TLS != nil {
			proto = "https"
		} else {
			proto = "http"
		}
	}
	host := r.Header.Get("X-Forwarded-Host")
	if host == "" {
		host = r.Host // covers both the Host header and the URL netloc
	}
	return proto + "://" + host
}

// respondHTML renders a template and writes it with the weak-ETag /
// If-None-Match handling of content.py's _respond_html. It stamps the shared
// context keys every page gets (Base / AssetBase / AssetBaseURL / Origin /
// BaseURL / UnitDefault / Fmt) plus defaults for the keys base.html.j2 read
// through |default() or compared while possibly undefined (BrandTag,
// MainClass, Section) — Jinja tolerated undefined names, html/template's eq
// does not, so the defaults move here.
func (h *Handlers) respondHTML(w http.ResponseWriter, r *http.Request, tmpl string, unit format.Unit, ctx map[string]any) {
	o := origin(r)
	// og:image (an Open Graph tag, which the spec requires be absolute) is
	// the one place a *static asset* reference needs an absolute URL always,
	// not just when THERMOGRAPH_API_BASE_PUBLIC happens to be set —
	// AssetBase is already absolute in that case, otherwise it's the same
	// relative Base base_url itself is built from, so prefixing with this
	// request's own origin recovers exactly today's behavior in the default
	// topology.
	assetBaseURL := h.cfg.AssetBase
	if !isAbsoluteURL(assetBaseURL) {
		assetBaseURL = o + assetBaseURL
	}
	ctx["Base"] = h.cfg.Base
	ctx["AssetBase"] = h.cfg.AssetBase
	ctx["AssetBaseURL"] = assetBaseURL
	ctx["Origin"] = o
	ctx["BaseURL"] = o + h.cfg.Base
	// The request-scoped unit-bound formatter templates call as
	// {{.Fmt.Temp v}} / {{.Fmt.TempBare v}} / {{.Fmt.TempClass v}} — set once
	// here (not by each page's own ctx builder) since every page that embeds
	// base.html.tmpl can reach it, and respondHTML already has the unit.
	setDefault(ctx, "Fmt", format.NewFormatter(unit))
	setDefault(ctx, "UnitDefault", string(unit)) // "" -> no data-unit-default attribute
	setDefault(ctx, "BrandTag", "h1")            // base.html.j2: brand_tag|default('h1')
	setDefault(ctx, "MainClass", "content")      // base.html.j2: main_class|default('content')
	setDefault(ctx, "Section", "")               // base.html.j2 compares it; undefined is not a Go option

	body, err := h.engine.RenderBytes(tmpl, ctx)
	if err != nil {
		h.serverError(w, fmt.Errorf("render %s: %w", tmpl, err))
		return
	}
	render.WriteHTML(w, r, body)
}

func setDefault(ctx map[string]any, key string, v any) {
	if _, ok := ctx[key]; !ok {
		ctx[key] = v
	}
}

func isAbsoluteURL(s string) bool {
	return len(s) >= 7 && (s[:7] == "http://" || (len(s) >= 8 && s[:8] == "https://"))
}

// crumb builds one locally-constructed breadcrumb element (the hub/glossary/
// about pages assemble their own trails; href nil marks the terminal crumb).
// contentapi.Crumb's own Name/Href fields are exactly what the "breadcrumb"
// template reads ({{$c.Name}}/{{$c.Href}}), so API-sourced breadcrumbs
// (j.Breadcrumb) pass straight through with no wrapping at all — this helper
// exists only for the trails a page assembles itself.
func crumb(name, href string) contentapi.Crumb {
	c := contentapi.Crumb{Name: name}
	if href != "" {
		c.Href = &href
	}
	return c
}

// apiError is the port of content.py's _raise_api_error: the backend's 404
// (unknown city/month) and 503 (warming) map straight through — same status
// codes the pre-split in-process code raised. Any other error never got a
// response at all — a backend blip or restart — so it becomes a 503 too,
// rather than surfacing as a raw 500.
func (h *Handlers) apiError(w http.ResponseWriter, err error) {
	var se *contentapi.StatusError
	if errors.As(err, &se) {
		writeDetail(w, se.StatusCode, se.Body)
		return
	}
	writeDetail(w, http.StatusServiceUnavailable, "backend unavailable")
}

// writeDetail writes the {"detail": ...} JSON error body FastAPI's
// HTTPException produced, so error responses keep their shape across the
// rewrite (the backend's own error text rides through as the detail string,
// exactly like detail=e.response.text did).
func writeDetail(w http.ResponseWriter, status int, detail string) {
	body, err := json.Marshal(map[string]string{"detail": detail})
	if err != nil {
		body = []byte(`{"detail":"error"}`)
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	w.Write(body)
}

// serverError is the unhandled-exception path (Jinja/template failures,
// malformed payloads): log it, answer a plain 500 like the Python framework
// did for an uncaught exception.
func (h *Handlers) serverError(w http.ResponseWriter, err error) {
	h.log.Printf("content: %v", err)
	http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}

// compactJSONLD re-emits the backend's jsonld bytes with insignificant
// whitespace removed — the Go analogue of json.dumps(j["jsonld"],
// ensure_ascii=False, separators=(",", ":")). Compacting the RAW bytes (not
// unmarshal + re-marshal) is what preserves the backend's key order; Go maps
// would sort keys and break golden-diff parity.
func compactJSONLD(raw json.RawMessage) (string, error) {
	var buf bytes.Buffer
	if err := json.Compact(&buf, raw); err != nil {
		return "", fmt.Errorf("jsonld: %w", err)
	}
	return buf.String(), nil
}
