// Package contentapi is the HTTP client for the backend's SSR content API
// (backend/api/content_routes.py) — the Go port of api_client.py, mirroring
// its behavior exactly:
//
//   - A short TTL cache sits in front of every call: climate data changes at
//     most hourly (the archive top-up + hourly recent-forecast refresh), so
//     without this a page-view burst on one city would cost one backend round
//     trip per request instead of one per cache window. This is in addition to
//     (not instead of) the backend's own derived-store/ETag caching — this
//     cache saves the network hop entirely; the backend's saves the recompute.
//   - Bounded LRU: City()/CityRecords() fold the browser-facing origin
//     (client-controlled via Host/X-Forwarded-Host) into the cache key, so an
//     unbounded map is a cheap memory-exhaustion vector — spoof enough
//     distinct Host values and the cache grows forever.
//   - Per-key single-flight: without it, N concurrent requests that miss the
//     same cache key (cold start, or right after TTL expiry on a hot city)
//     each fire their own backend call — a thundering herd. One lock per
//     in-flight key makes every caller but the first block on, then reuse,
//     that first caller's result instead of duplicating the request.
//
// Note on ETag / If-None-Match: this internal client never sends conditional
// requests — the Python client didn't either. The ETag half of the caching
// contract lives on the SERVING side (the W/"sha1…" ETags this service stamps
// on rendered HTML, see internal/render) and in the browser (static/cache.js's
// If-None-Match refetch of /api/v2/cell). Between this process and the backend
// the freshness mechanism is purely the TTL cache below.
package contentapi

import (
	"container/list"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync"
	"time"
)

// DefaultCacheMaxEntries mirrors api_client.py's _CACHE_MAX_ENTRIES.
const DefaultCacheMaxEntries = 2048

// StatusError is a non-2xx backend response — the analogue of
// httpx.HTTPStatusError. Callers map StatusCode straight through (the
// backend's 404 unknown-city and 503 warming become the page's own status);
// any other error from a Client method never got a response at all (a backend
// blip or restart) and maps to a 503, same as content.py's _raise_api_error.
type StatusError struct {
	StatusCode int
	Body       string // response body text, forwarded as the error detail
	URL        string
}

func (e *StatusError) Error() string {
	return fmt.Sprintf("backend returned %d for %s", e.StatusCode, e.URL)
}

// Options configures New. BaseURL is required; zero values elsewhere take the
// documented defaults.
type Options struct {
	// BaseURL is THERMOGRAPH_API_BASE_INTERNAL (e.g. http://backend:8137).
	BaseURL string
	// BasePrefix is the backend's own THERMOGRAPH_BASE, normalized like
	// config.Config.Base ("" or "/x"). Both processes are always configured
	// with the same value in every real deployment, so reusing frontend's
	// keeps this client correct under a sub-path deployment instead of only
	// working when BASE is empty/root.
	BasePrefix string
	// APIVersion is the pinned content-API version (default "v2"). See the
	// API-version pinning contract in frontend CLAUDE.md before changing.
	APIVersion string
	// TTL is the response-cache lifetime (THERMOGRAPH_SSR_CACHE_TTL);
	// default 600s.
	TTL time.Duration
	// MaxCacheEntries caps the LRU (default DefaultCacheMaxEntries).
	MaxCacheEntries int
	// HTTPClient overrides the default pooled client (tests). The default
	// mirrors api_client.py's httpx.Client: 10s total timeout, up to 100
	// connections with 20 kept alive.
	HTTPClient *http.Client
}

// Client is safe for concurrent use.
type Client struct {
	baseURL    string
	basePrefix string
	apiVersion string
	ttl        time.Duration
	maxEntries int
	http       *http.Client

	mu    sync.Mutex
	cache map[string]*list.Element
	order *list.List // front = most recently used

	inflightMu sync.Mutex
	inflight   map[string]*sync.Mutex
}

type cacheEntry struct {
	key  string
	ts   time.Time
	data any
}

// New builds a Client. It does not touch the network.
func New(o Options) *Client {
	if o.APIVersion == "" {
		o.APIVersion = "v2"
	}
	if o.TTL == 0 {
		o.TTL = 600 * time.Second
	}
	if o.MaxCacheEntries == 0 {
		o.MaxCacheEntries = DefaultCacheMaxEntries
	}
	hc := o.HTTPClient
	if hc == nil {
		hc = &http.Client{
			Timeout: 10 * time.Second, // httpx.Client(timeout=10.0)
			Transport: &http.Transport{
				MaxConnsPerHost:     100, // httpx.Limits(max_connections=100, …)
				MaxIdleConnsPerHost: 20,  // …max_keepalive_connections=20
			},
		}
	}
	return &Client{
		baseURL:    strings.TrimRight(o.BaseURL, "/"),
		basePrefix: o.BasePrefix,
		apiVersion: o.APIVersion,
		ttl:        o.TTL,
		maxEntries: o.MaxCacheEntries,
		http:       hc,
		cache:      make(map[string]*list.Element),
		order:      list.New(),
		inflight:   make(map[string]*sync.Mutex),
	}
}

func (c *Client) cacheGet(key string) any {
	c.mu.Lock()
	defer c.mu.Unlock()
	el, ok := c.cache[key]
	if !ok {
		return nil
	}
	ent := el.Value.(*cacheEntry)
	if time.Since(ent.ts) >= c.ttl {
		c.order.Remove(el)
		delete(c.cache, key)
		return nil
	}
	c.order.MoveToFront(el)
	return ent.data
}

func (c *Client) cachePut(key string, data any) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if el, ok := c.cache[key]; ok {
		ent := el.Value.(*cacheEntry)
		ent.ts, ent.data = time.Now(), data
		c.order.MoveToFront(el)
	} else {
		c.cache[key] = c.order.PushFront(&cacheEntry{key: key, ts: time.Now(), data: data})
	}
	for len(c.cache) > c.maxEntries {
		oldest := c.order.Back() // evict least-recently-used
		c.order.Remove(oldest)
		delete(c.cache, oldest.Value.(*cacheEntry).key)
	}
}

// get GETs {BaseURL}{BasePrefix}{path}, cached for TTL. decode turns the
// response body into the typed payload that gets cached and returned.
//
// origin (e.g. "https://thermograph.org") is the ORIGINAL browser-facing
// request's origin, not this internal call's — forwarded as Host/
// X-Forwarded-Proto so the backend's own _origin(request) (which already
// prefers those headers) builds jsonld "url" fields against the public origin
// instead of this internal http://backend:8137-style address. Folded into the
// cache key: harmless when there's one canonical origin (the default
// same-domain prod topology), and correct when there's more than one. Empty
// string means "no origin to forward".
func (c *Client) get(path, origin string, decode func([]byte) (any, error)) (any, error) {
	cacheKey := path
	if origin != "" {
		cacheKey = origin + "|" + path
	}
	if hit := c.cacheGet(cacheKey); hit != nil {
		return hit, nil
	}

	c.inflightMu.Lock()
	lock, ok := c.inflight[cacheKey]
	if !ok {
		lock = &sync.Mutex{}
		c.inflight[cacheKey] = lock
	}
	c.inflightMu.Unlock()

	lock.Lock()
	defer lock.Unlock()
	// Someone else may have filled it while we waited on the flight lock.
	if hit := c.cacheGet(cacheKey); hit != nil {
		return hit, nil
	}
	// Mirror the Python's finally: the in-flight entry goes away whether the
	// fetch succeeded or not — errors are never cached.
	defer func() {
		c.inflightMu.Lock()
		delete(c.inflight, cacheKey)
		c.inflightMu.Unlock()
	}()

	body, err := c.fetch(path, origin)
	if err != nil {
		return nil, err
	}
	data, err := decode(body)
	if err != nil {
		return nil, fmt.Errorf("decode %s: %w", path, err)
	}
	c.cachePut(cacheKey, data)
	return data, nil
}

func (c *Client) fetch(path, origin string) ([]byte, error) {
	url := c.baseURL + c.basePrefix + path
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	if origin != "" {
		// origin.partition("://"): everything after the separator is the host
		// (empty if the separator is missing, same as the Python).
		proto, host, _ := strings.Cut(origin, "://")
		req.Host = host
		req.Header.Set("X-Forwarded-Proto", proto)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err // transport-level: callers map to 503
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		return nil, &StatusError{StatusCode: resp.StatusCode, Body: string(body), URL: url}
	}
	return body, nil
}

// decodeInto adapts a typed unmarshal to get's decode callback.
func decodeInto[T any](body []byte) (any, error) {
	v := new(T)
	if err := json.Unmarshal(body, v); err != nil {
		return nil, err
	}
	return v, nil
}

// Hub returns the climate-hub payload (/content/hub).
func (c *Client) Hub() (*HubPayload, error) {
	v, err := c.get(fmt.Sprintf("/api/%s/content/hub", c.apiVersion), "", decodeInto[HubPayload])
	if err != nil {
		return nil, err
	}
	return v.(*HubPayload), nil
}

// Sitemap returns every sitemap entry (/content/sitemap).
func (c *Client) Sitemap() ([]SitemapEntry, error) {
	v, err := c.get(fmt.Sprintf("/api/%s/content/sitemap", c.apiVersion), "", decodeInto[[]SitemapEntry])
	if err != nil {
		return nil, err
	}
	return *v.(*[]SitemapEntry), nil
}

// IndexNowKey returns the IndexNow verification key (/content/indexnow-key).
func (c *Client) IndexNowKey() (string, error) {
	v, err := c.get(fmt.Sprintf("/api/%s/content/indexnow-key", c.apiVersion), "", decodeInto[indexNowPayload])
	if err != nil {
		return "", err
	}
	return v.(*indexNowPayload).Key, nil
}

// Home returns the homepage payload (/content/home).
func (c *Client) Home() (*HomePayload, error) {
	v, err := c.get(fmt.Sprintf("/api/%s/content/home", c.apiVersion), "", decodeInto[HomePayload])
	if err != nil {
		return nil, err
	}
	return v.(*HomePayload), nil
}

// City returns one city's climate-page payload. A *StatusError with
// StatusCode 404 (unknown city) or 503 (warming) maps straight through to the
// page response, same status codes the old in-process code raised. origin ""
// omits the Host/X-Forwarded-Proto forwarding.
func (c *Client) City(slug, origin string) (*CityPayload, error) {
	v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s", c.apiVersion, slug), origin, decodeInto[CityPayload])
	if err != nil {
		return nil, err
	}
	return v.(*CityPayload), nil
}

// CityMonth returns one city-month payload.
func (c *Client) CityMonth(slug, month string) (*MonthPayload, error) {
	v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s/month/%s", c.apiVersion, slug, month), "", decodeInto[MonthPayload])
	if err != nil {
		return nil, err
	}
	return v.(*MonthPayload), nil
}

// CityRecords returns one city's records-page payload.
func (c *Client) CityRecords(slug, origin string) (*RecordsPayload, error) {
	v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s/records", c.apiVersion, slug), origin, decodeInto[RecordsPayload])
	if err != nil {
		return nil, err
	}
	return v.(*RecordsPayload), nil
}
