// endpoints_test.go validates HTTP endpoint behavior and format negotiation.
// Layer: adapters/inbound/httpapi endpoint regression tests.
package httpapi

import (
	"context"
	"encoding/json"
	"math"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"text/template"
	"time"

	"gitea.maximumdirect.net/ejr/feedapi/endpoint"
	apierrors "gitea.maximumdirect.net/ejr/feedapi/errors"
	"gitea.maximumdirect.net/ejr/feedapi/render"
	"gitea.maximumdirect.net/ejr/feedapi/templates"
	"gitea.maximumdirect.net/ejr/feedapi/transport/httpx"
	"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
	"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)

type fakeService struct {
	observation       *model.WeatherObservation
	forecast          *model.WeatherForecastRun
	narrativeForecast *model.WeatherForecastRun
	discussion        *model.WeatherForecastDiscussion
	weatherStoryRun   *model.WeatherStoryRun
	weatherStory      *model.WeatherStory
	alerts            *model.WeatherAlertRun
	conditions        *app.CurrentConditions
	err               error
}

func (s *fakeService) LatestObservation(context.Context) (*model.WeatherObservation, error) {
	return s.observation, s.err
}

func (s *fakeService) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) {
	return s.forecast, s.err
}

func (s *fakeService) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) {
	return s.narrativeForecast, s.err
}

func (s *fakeService) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) {
	return s.discussion, s.err
}

func (s *fakeService) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) {
	return s.weatherStoryRun, s.err
}

func (s *fakeService) LatestWeatherStory(context.Context) (*model.WeatherStory, error) {
	return s.weatherStory, s.err
}

func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
	return s.alerts, s.err
}

func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions, error) {
	return s.conditions, s.err
}

func TestObservationsRejectUnknownQueryParameter(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/observations")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/observations?bogus=1", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}

	var env apierrors.Envelope
	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode error envelope: %v", err)
	}
	if env.Error == nil || env.Error.Code != apierrors.CodeInvalidParameter {
		t.Fatalf("expected invalid_parameter code, got %+v", env.Error)
	}
}

func TestObservationsNoDataReturnsNullEnvelopeData(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/observations")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/observations", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data *json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data != nil {
		t.Fatalf("expected data null, got %s", string(*payload.Data))
	}
}

func TestObservationsPopulatedJSONEnvelope(t *testing.T) {
	now := time.Date(2026, 3, 19, 18, 0, 0, 0, time.UTC)
	h := newHandler(t, &fakeService{
		observation: &model.WeatherObservation{
			StationID:   "KSTL",
			StationName: "St. Louis",
			Timestamp:   now,
		},
	}, "/observations")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/observations", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data struct {
			StationID string `json:"stationId"`
		} `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data.StationID != "KSTL" {
		t.Fatalf("expected stationId KSTL, got %q", payload.Data.StationID)
	}
}

func TestObservationsDefaultPrecisionRoundsToInteger(t *testing.T) {
	h := newHandler(t, &fakeService{
		observation: &model.WeatherObservation{
			Timestamp:     time.Now().UTC(),
			ConditionCode: model.WMOUnknown,
			TemperatureC:  float64Ptr(20.6),
		},
	}, "/observations")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/observations", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	got, ok := payload.Data["temperatureC"].(float64)
	if !ok {
		t.Fatalf("expected temperatureC float, got %#v", payload.Data["temperatureC"])
	}
	if got != 21 {
		t.Fatalf("expected rounded temperatureC 21, got %v", got)
	}
}

func TestObservationsPrecisionTwo(t *testing.T) {
	h := newHandler(t, &fakeService{
		observation: &model.WeatherObservation{
			Timestamp:     time.Now().UTC(),
			ConditionCode: model.WMOUnknown,
			TemperatureC:  float64Ptr(20.678),
		},
	}, "/observations")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/observations?precision=2", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	got, ok := payload.Data["temperatureC"].(float64)
	if !ok {
		t.Fatalf("expected temperatureC float, got %#v", payload.Data["temperatureC"])
	}
	if math.Abs(got-20.68) > 0.00001 {
		t.Fatalf("expected rounded temperatureC 20.68, got %v", got)
	}
}

func TestFormatNegotiationCaseInsensitive(t *testing.T) {
	hXML := newHandler(t, &fakeService{alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}}, "/alerts/active")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=XML", nil)
	hXML.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for xml request, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
		t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
	}

	hText := newHandler(t, &fakeService{forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly}}, "/forecast/hourly")
	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=TEXT", nil)
	hText.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for text request, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
		t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
	}
	if !strings.Contains(w.Body.String(), "Forecast text") {
		t.Fatalf("expected rendered text template body, got %q", w.Body.String())
	}
}

func TestObservationUSUnitsChangesFieldNames(t *testing.T) {
	h := newHandler(t, &fakeService{
		observation: &model.WeatherObservation{
			StationID:     "KSTL",
			Timestamp:     time.Now().UTC(),
			ConditionCode: 1,
			TemperatureC:  float64Ptr(20),
		},
	}, "/observations")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/observations?units=US", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}

	if _, ok := payload.Data["temperatureC"]; ok {
		t.Fatalf("expected temperatureC to be omitted in US payload")
	}
	v, ok := payload.Data["temperatureF"].(float64)
	if !ok {
		t.Fatalf("expected temperatureF in US payload, got %#v", payload.Data["temperatureF"])
	}
	if math.Abs(v-68.0) > 0.0001 {
		t.Fatalf("expected temperatureF ~= 68, got %f", v)
	}
}

func TestAlertsUSUnitsKeepSchema(t *testing.T) {
	h := newHandler(t, &fakeService{
		alerts: &model.WeatherAlertRun{
			AsOf: time.Now().UTC(),
			Alerts: []model.WeatherAlert{{
				ID:       "abc",
				Headline: "A headline",
			}},
		},
	}, "/alerts/active")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/alerts/active?units=us", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	alerts, ok := payload.Data["alerts"].([]any)
	if !ok || len(alerts) != 1 {
		t.Fatalf("expected one alert in response, got %#v", payload.Data["alerts"])
	}
	first, ok := alerts[0].(map[string]any)
	if !ok {
		t.Fatalf("expected first alert object, got %#v", alerts[0])
	}
	if first["id"] != "abc" {
		t.Fatalf("expected alert id abc, got %#v", first["id"])
	}
}

func TestObservationUSUnitsWithXMLFormat(t *testing.T) {
	h := newHandler(t, &fakeService{
		observation: &model.WeatherObservation{
			StationID:     "KSTL",
			Timestamp:     time.Now().UTC(),
			ConditionCode: 1,
			TemperatureC:  float64Ptr(20),
		},
	}, "/observations")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/observations?format=xml&units=us", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
		t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
	}
	if strings.Contains(w.Body.String(), "temperatureC") {
		t.Fatalf("expected metric field temperatureC to be omitted in XML payload: %s", w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "temperatureF") {
		t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
	}
}

func TestForecastUSUnitsWithXMLFormatUppercaseQuery(t *testing.T) {
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Now().UTC(),
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     time.Now().UTC(),
				EndTime:       time.Now().UTC().Add(time.Hour),
				ConditionCode: wmoCodePtr(1),
				TemperatureC:  float64Ptr(10),
			}},
		},
	}, "/forecast/hourly")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=XML&units=US", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
		t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
	}
	body := w.Body.String()
	if strings.Contains(body, "temperatureC") {
		t.Fatalf("expected metric field temperatureC to be omitted in XML payload: %s", body)
	}
	if !strings.Contains(body, "temperatureF") {
		t.Fatalf("expected US field temperatureF in XML payload: %s", body)
	}
}

func TestForecastPrecisionTwo(t *testing.T) {
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Now().UTC(),
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     time.Now().UTC(),
				EndTime:       time.Now().UTC().Add(time.Hour),
				ConditionCode: wmoCodePtr(model.WMOUnknown),
				TemperatureC:  float64Ptr(12.345),
			}},
		},
	}, "/forecast/hourly")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?precision=2", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	periods, ok := payload.Data["periods"].([]any)
	if !ok || len(periods) == 0 {
		t.Fatalf("expected non-empty periods, got %#v", payload.Data["periods"])
	}
	first, ok := periods[0].(map[string]any)
	if !ok {
		t.Fatalf("expected first period object, got %#v", periods[0])
	}
	got, ok := first["temperatureC"].(float64)
	if !ok {
		t.Fatalf("expected temperatureC float, got %#v", first["temperatureC"])
	}
	if math.Abs(got-12.35) > 0.00001 {
		t.Fatalf("expected rounded temperatureC 12.35, got %v", got)
	}
}

func TestForecastJSONOmitsLegacyDescriptionFields(t *testing.T) {
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Now().UTC(),
			Periods: []model.WeatherForecastPeriod{{
				StartTime:       time.Now().UTC(),
				EndTime:         time.Now().UTC().Add(time.Hour),
				ConditionCode:   wmoCodePtr(model.WMOUnknown),
				TextDescription: "Cloudy",
			}},
		},
	}, "/forecast/hourly")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data struct {
			Periods []map[string]any `json:"periods"`
		} `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode forecast payload: %v", err)
	}
	if len(payload.Data.Periods) == 0 {
		t.Fatalf("expected at least one period")
	}

	period := payload.Data.Periods[0]
	for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} {
		if _, ok := period[key]; ok {
			t.Fatalf("unexpected legacy field %q in forecast response period: %#v", key, period)
		}
	}
	if period["textDescription"] != "Cloudy" {
		t.Fatalf("expected textDescription Cloudy, got %#v", period["textDescription"])
	}
}

func TestForecastTimezoneOffsetUppercaseTZConvertsAllTimes(t *testing.T) {
	issuedAt := time.Date(2026, 7, 10, 15, 0, 0, 0, time.UTC)
	updatedAt := issuedAt.Add(30 * time.Minute)
	periodStart := issuedAt.Add(time.Hour)
	periodEnd := periodStart.Add(time.Hour)

	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:   model.ForecastProductHourly,
			IssuedAt:  issuedAt,
			UpdatedAt: &updatedAt,
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     periodStart,
				EndTime:       periodEnd,
				ConditionCode: wmoCodePtr(model.WMOUnknown),
			}},
		},
	}, "/forecast/hourly")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?TZ=-5", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60)
	if payload.Data.UpdatedAt == nil {
		t.Fatalf("expected updatedAt in payload")
	}
	assertOffsetSeconds(t, *payload.Data.UpdatedAt, -5*60*60)
	assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
	assertOffsetSeconds(t, payload.Data.Periods[0].EndTime, -5*60*60)
	if !payload.Data.IssuedAt.UTC().Equal(issuedAt) {
		t.Fatalf("expected issuedAt instant to be preserved")
	}
}

func TestForecastTimezoneAbbreviationCDT(t *testing.T) {
	issuedAt := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC)
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: issuedAt,
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     issuedAt,
				EndTime:       issuedAt.Add(time.Hour),
				ConditionCode: wmoCodePtr(model.WMOUnknown),
			}},
		},
	}, "/forecast/hourly")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60)
	assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
}

func TestForecastTimezoneCityAliasChicago(t *testing.T) {
	issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: issuedAt,
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     issuedAt,
				EndTime:       issuedAt.Add(time.Hour),
				ConditionCode: wmoCodePtr(model.WMOUnknown),
			}},
		},
	}, "/forecast/hourly")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=Chicago", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60)
	assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
}

func TestForecastTimezoneInvalidValueRejected(t *testing.T) {
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()},
	}, "/forecast/hourly")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=not-a-timezone", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}

	var env apierrors.Envelope
	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode error envelope: %v", err)
	}
	if env.Error == nil || env.Error.Code != apierrors.CodeInvalidParameter {
		t.Fatalf("expected invalid_parameter code, got %+v", env.Error)
	}
}

func TestForecastTimezoneConflictingKeyValuesRejected(t *testing.T) {
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()},
	}, "/forecast/hourly")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=CDT&TZ=EST", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestForecastHourlyTodayFiltersByStartDateUTCDefault(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{
				{StartTime: time.Date(2026, 7, 10, 0, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 1, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 2, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 3, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
			},
		},
	}, "/forecast/hourly/today")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	if len(payload.Data.Periods) != 2 {
		t.Fatalf("expected 2 periods, got %d", len(payload.Data.Periods))
	}
	for _, p := range payload.Data.Periods {
		y, m, d := p.StartTime.UTC().Date()
		if y != 2026 || m != time.July || d != 10 {
			t.Fatalf("expected start date 2026-07-10 UTC, got %s", p.StartTime.UTC().Format(time.RFC3339))
		}
	}
}

func TestForecastHourlyTomorrowFiltersByStartDateUTCDefault(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{
				{StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 1, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 15, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 16, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
			},
		},
	}, "/forecast/hourly/tomorrow")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	if len(payload.Data.Periods) != 2 {
		t.Fatalf("expected 2 periods, got %d", len(payload.Data.Periods))
	}
	for _, p := range payload.Data.Periods {
		y, m, d := p.StartTime.UTC().Date()
		if y != 2026 || m != time.July || d != 11 {
			t.Fatalf("expected start date 2026-07-11 UTC, got %s", p.StartTime.UTC().Format(time.RFC3339))
		}
	}
}

func TestForecastHourlyTodayTimezoneAffectsDaySlice(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{
				{StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 4, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 6, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
			},
		},
	}, "/forecast/hourly/today")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today?tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	if len(payload.Data.Periods) != 1 {
		t.Fatalf("expected 1 period, got %d", len(payload.Data.Periods))
	}
	if !payload.Data.Periods[0].StartTime.UTC().Equal(time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC)) {
		t.Fatalf("unexpected filtered period start: %s", payload.Data.Periods[0].StartTime.UTC().Format(time.RFC3339))
	}
	assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
}

func TestForecastHourlyTomorrowTimezoneAffectsDaySlice(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{
				{StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 4, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 6, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
			},
		},
	}, "/forecast/hourly/tomorrow")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow?tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	if len(payload.Data.Periods) != 1 {
		t.Fatalf("expected 1 period, got %d", len(payload.Data.Periods))
	}
	if !payload.Data.Periods[0].StartTime.UTC().Equal(time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC)) {
		t.Fatalf("unexpected filtered period start: %s", payload.Data.Periods[0].StartTime.UTC().Format(time.RFC3339))
	}
	assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
}

func TestForecastHourlyTodaySupportsSameFlags(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     time.Date(2026, 7, 10, 13, 0, 0, 0, time.UTC),
				EndTime:       time.Date(2026, 7, 10, 14, 0, 0, 0, time.UTC),
				ConditionCode: wmoCodePtr(1),
				TemperatureC:  float64Ptr(10.123),
			}},
		},
	}, "/forecast/hourly/today")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today?format=TEXT&units=US&precision=2&tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
		t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
	}
	if !strings.Contains(w.Body.String(), "Forecast text") {
		t.Fatalf("expected rendered text template body, got %q", w.Body.String())
	}
}

func TestForecastHourlyTomorrowSupportsSameFlags(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC),
				EndTime:       time.Date(2026, 7, 11, 14, 0, 0, 0, time.UTC),
				ConditionCode: wmoCodePtr(1),
				TemperatureC:  float64Ptr(10.123),
			}},
		},
	}, "/forecast/hourly/tomorrow")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow?format=XML&units=US&precision=2&tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
		t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
	}
}

func TestForecastHourlyTodayRejectUnknownQueryParameter(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/forecast/hourly/today")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today?bogus=1", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestForecastHourlyTomorrowRejectUnknownQueryParameter(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/forecast/hourly/tomorrow")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow?bogus=1", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestForecastHourlyTodayTimezoneValidation(t *testing.T) {
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()},
	}, "/forecast/hourly/today")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today?tz=not-a-timezone", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestForecastHourlyTomorrowTimezoneValidation(t *testing.T) {
	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()},
	}, "/forecast/hourly/tomorrow")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow?tz=CDT&TZ=EST", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestForecastHourlyTodayNoMatchingPeriodsReturnsDataWithEmptyPeriods(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		forecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductHourly,
			IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC),
				EndTime:       time.Date(2026, 7, 11, 14, 0, 0, 0, time.UTC),
				ConditionCode: wmoCodePtr(model.WMOUnknown),
			}},
		},
	}, "/forecast/hourly/today")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayloadAllowEmpty(t, w)
	if len(payload.Data.Periods) != 0 {
		t.Fatalf("expected empty periods, got %d", len(payload.Data.Periods))
	}
	if payload.Data.IssuedAt.IsZero() {
		t.Fatalf("expected metadata fields to remain populated")
	}
}

func TestForecastNarrativeNoDataReturnsNullEnvelopeData(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/forecast/narrative")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/narrative", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data *json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data != nil {
		t.Fatalf("expected data null, got %s", string(*payload.Data))
	}
}

func TestForecastNarrativePopulatedJSONEnvelope(t *testing.T) {
	h := newHandler(t, &fakeService{
		narrativeForecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductNarrative,
			IssuedAt: time.Now().UTC(),
			Periods: []model.WeatherForecastPeriod{{
				StartTime:       time.Now().UTC(),
				EndTime:         time.Now().UTC().Add(12 * time.Hour),
				Name:            "Tonight",
				ConditionCode:   wmoCodePtr(model.WMOUnknown),
				TextDescription: "Mostly clear overnight.",
			}},
		},
	}, "/forecast/narrative")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/narrative", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data struct {
			Product string `json:"product"`
			Periods []struct {
				Name string `json:"name"`
			} `json:"periods"`
		} `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data.Product != "narrative" {
		t.Fatalf("expected product narrative, got %q", payload.Data.Product)
	}
	if len(payload.Data.Periods) != 1 || payload.Data.Periods[0].Name != "Tonight" {
		t.Fatalf("unexpected narrative periods payload: %+v", payload.Data.Periods)
	}
}

func TestForecastNarrativeTextFormatUsesNarrativeTemplate(t *testing.T) {
	h := newHandler(t, &fakeService{
		narrativeForecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductNarrative,
			IssuedAt: time.Now().UTC(),
		},
	}, "/forecast/narrative")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?format=TEXT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for text request, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
		t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
	}
	if !strings.Contains(w.Body.String(), "Narrative Forecast") {
		t.Fatalf("expected narrative text template body, got %q", w.Body.String())
	}
}

func TestForecastNarrativeSupportsSameFlags(t *testing.T) {
	h := newHandler(t, &fakeService{
		narrativeForecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductNarrative,
			IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{{
				StartTime:     time.Date(2026, 7, 10, 13, 0, 0, 0, time.UTC),
				EndTime:       time.Date(2026, 7, 11, 1, 0, 0, 0, time.UTC),
				ConditionCode: wmoCodePtr(1),
				TemperatureC:  float64Ptr(20.123),
			}},
		},
	}, "/forecast/narrative")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?format=XML&units=US&precision=2&tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
		t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
	}
}

func TestForecastNarrativeRejectUnknownQueryParameter(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/forecast/narrative")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?bogus=1", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestForecastNarrativeTimezoneValidation(t *testing.T) {
	h := newHandler(t, &fakeService{
		narrativeForecast: &model.WeatherForecastRun{Product: model.ForecastProductNarrative, IssuedAt: time.Now().UTC()},
	}, "/forecast/narrative")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?tz=not-a-timezone", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}

	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/forecast/narrative?tz=CDT&TZ=EST", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestForecastNarrativeTodayTimezoneAffectsDaySlice(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		narrativeForecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductNarrative,
			IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{
				{StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 15, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 17, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
			},
		},
	}, "/forecast/narrative/today")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/narrative/today?tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	if len(payload.Data.Periods) != 1 {
		t.Fatalf("expected 1 period, got %d", len(payload.Data.Periods))
	}
	if !payload.Data.Periods[0].StartTime.UTC().Equal(time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC)) {
		t.Fatalf("unexpected filtered period start: %s", payload.Data.Periods[0].StartTime.UTC().Format(time.RFC3339))
	}
	assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
}

func TestForecastNarrativeTomorrowFiltersByStartDateUTCDefault(t *testing.T) {
	setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC))

	h := newHandler(t, &fakeService{
		narrativeForecast: &model.WeatherForecastRun{
			Product:  model.ForecastProductNarrative,
			IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC),
			Periods: []model.WeatherForecastPeriod{
				{StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
				{StartTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)},
			},
		},
	}, "/forecast/narrative/tomorrow")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/forecast/narrative/tomorrow", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	payload := decodeForecastTimePayload(t, w)
	if len(payload.Data.Periods) != 2 {
		t.Fatalf("expected 2 periods, got %d", len(payload.Data.Periods))
	}
	for _, p := range payload.Data.Periods {
		y, m, d := p.StartTime.UTC().Date()
		if y != 2026 || m != time.July || d != 11 {
			t.Fatalf("expected start date 2026-07-11 UTC, got %s", p.StartTime.UTC().Format(time.RFC3339))
		}
	}
}

func TestCurrentConditionsRejectTimezoneQueryParameter(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/conditions/current")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/conditions/current?tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/conditions/current")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/conditions/current", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data *json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data != nil {
		t.Fatalf("expected data null, got %s", string(*payload.Data))
	}
}

func TestCurrentConditionsMetricDefaultJSON(t *testing.T) {
	h := newHandler(t, &fakeService{
		conditions: &app.CurrentConditions{
			TemperatureC:            float64Ptr(10),
			ApparentTemperatureC:    float64Ptr(9),
			DewpointC:               float64Ptr(5),
			RelativeHumidityPercent: float64Ptr(75),
			WindSpeedKmh:            float64Ptr(18),
			WindDirectionDegrees:    float64Ptr(135),
			ConditionCode:           63,
		},
	}, "/conditions/current")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/conditions/current", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if _, ok := payload.Data["temperatureC"]; !ok {
		t.Fatalf("expected temperatureC in metric payload")
	}
	if _, ok := payload.Data["temperatureF"]; ok {
		t.Fatalf("expected temperatureF omitted in metric payload")
	}
	if payload.Data["conditionText"] != "Rain" {
		t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
	}
}

func TestCurrentConditionsUSJSON(t *testing.T) {
	h := newHandler(t, &fakeService{
		conditions: &app.CurrentConditions{
			TemperatureC:            float64Ptr(10),
			ApparentTemperatureC:    float64Ptr(9),
			DewpointC:               float64Ptr(5),
			RelativeHumidityPercent: float64Ptr(75),
			WindSpeedKmh:            float64Ptr(18),
			WindDirectionDegrees:    float64Ptr(135),
			ConditionCode:           63,
		},
	}, "/conditions/current")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/conditions/current?units=us", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if _, ok := payload.Data["temperatureF"]; !ok {
		t.Fatalf("expected temperatureF in us payload")
	}
	if _, ok := payload.Data["temperatureC"]; ok {
		t.Fatalf("expected temperatureC omitted in us payload")
	}
	if _, ok := payload.Data["windSpeedMph"]; !ok {
		t.Fatalf("expected windSpeedMph in us payload")
	}
	if _, ok := payload.Data["windSpeedKmh"]; ok {
		t.Fatalf("expected windSpeedKmh omitted in us payload")
	}
}

func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
	h := newHandler(t, &fakeService{
		conditions: &app.CurrentConditions{
			TemperatureC:  float64Ptr(10),
			WindSpeedKmh:  float64Ptr(18),
			ConditionCode: 2,
		},
	}, "/conditions/current")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/conditions/current?format=xml&units=us", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
		t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
	}
	if !strings.Contains(w.Body.String(), "temperatureF") {
		t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
	}

	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for text request, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
		t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
	}
	if !strings.Contains(w.Body.String(), "Conditions text") {
		t.Fatalf("expected rendered text template body, got %q", w.Body.String())
	}
}

func TestCurrentConditionsRejectUnknownQueryParameter(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/conditions/current")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/conditions/current?bogus=1", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestPrecisionValidationRange(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/conditions/current")

	for _, raw := range []string{"-1", "3", "abc"} {
		w := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodGet, "/conditions/current?precision="+raw, nil)
		h.ServeHTTP(w, req)
		if w.Code != http.StatusBadRequest {
			t.Fatalf("expected 400 for precision=%s, got %d", raw, w.Code)
		}
	}
}

func TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
	h := newHandler(t, &fakeService{
		alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
	}, "/alerts/active")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400, got %d", w.Code)
	}
}

func TestDiscussionNoDataReturnsNullEnvelopeData(t *testing.T) {
	h := newHandler(t, &fakeService{}, "/discussion")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/discussion", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data *json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data != nil {
		t.Fatalf("expected data null, got %s", string(*payload.Data))
	}
}

func TestDiscussionJSONEnvelope(t *testing.T) {
	issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC)
	shortIssuedAt := issuedAt.Add(-5 * time.Minute)
	h := newHandler(t, &fakeService{
		discussion: &model.WeatherForecastDiscussion{
			OfficeID:    "LSX",
			OfficeName:  "National Weather Service Saint Louis MO",
			Product:     model.ForecastDiscussionProductAFD,
			IssuedAt:    issuedAt,
			KeyMessages: []string{"msg one", "msg two"},
			ShortTerm:   &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
			LongTerm:    &model.WeatherForecastDiscussionSection{Text: "Long term text"},
		},
	}, "/discussion")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/discussion", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data struct {
			OfficeID    string   `json:"officeId"`
			Product     string   `json:"product"`
			KeyMessages []string `json:"keyMessages"`
			ShortTerm   *struct {
				Text string `json:"text"`
			} `json:"shortTerm"`
		} `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data.OfficeID != "LSX" {
		t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID)
	}
	if payload.Data.Product != "afd" {
		t.Fatalf("expected product afd, got %q", payload.Data.Product)
	}
	if len(payload.Data.KeyMessages) != 2 {
		t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages))
	}
	if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" {
		t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm)
	}
}

func TestDiscussionSupportsTextAndXMLFormats(t *testing.T) {
	hText := newHandler(t, &fakeService{
		discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
	}, "/discussion")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/discussion?format=TEXT", nil)
	hText.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for text request, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
		t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
	}
	if !strings.Contains(w.Body.String(), "Forecast Discussion") {
		t.Fatalf("expected rendered text template body, got %q", w.Body.String())
	}

	hXML := newHandler(t, &fakeService{
		discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
	}, "/discussion")

	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/discussion?format=XML", nil)
	hXML.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for xml request, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
		t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
	}
}

func TestDiscussionTimezoneQuery(t *testing.T) {
	issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
	updatedAt := issuedAt.Add(30 * time.Minute)
	shortIssuedAt := issuedAt.Add(-15 * time.Minute)
	longIssuedAt := issuedAt.Add(15 * time.Minute)

	for _, tc := range []struct {
		name  string
		query string
		want  int
	}{
		{name: "abbreviation", query: "/discussion?tz=CDT", want: -5 * 60 * 60},
		{name: "alias", query: "/discussion?tz=Chicago", want: -5 * 60 * 60},
		{name: "offset", query: "/discussion?TZ=-5", want: -5 * 60 * 60},
	} {
		t.Run(tc.name, func(t *testing.T) {
			h := newHandler(t, &fakeService{
				discussion: &model.WeatherForecastDiscussion{
					Product:   model.ForecastDiscussionProductAFD,
					IssuedAt:  issuedAt,
					UpdatedAt: &updatedAt,
					ShortTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &shortIssuedAt},
					LongTerm:  &model.WeatherForecastDiscussionSection{IssuedAt: &longIssuedAt},
				},
			}, "/discussion")

			w := httptest.NewRecorder()
			req := httptest.NewRequest(http.MethodGet, tc.query, nil)
			h.ServeHTTP(w, req)

			if w.Code != http.StatusOK {
				t.Fatalf("expected 200, got %d", w.Code)
			}

			var payload discussionTimePayload
			if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
				t.Fatalf("decode discussion payload: %v", err)
			}
			assertOffsetSeconds(t, payload.Data.IssuedAt, tc.want)
			assertOffsetSeconds(t, *payload.Data.UpdatedAt, tc.want)
			assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, tc.want)
			assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, tc.want)
		})
	}
}

func TestDiscussionRejectsInvalidQueryParameters(t *testing.T) {
	tests := []string{
		"/discussion?bogus=1",
		"/discussion?precision=1",
		"/discussion?tz=not-a-timezone",
		"/discussion?tz=CDT&TZ=EST",
	}

	for _, rawURL := range tests {
		h := newHandler(t, &fakeService{
			discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
		}, "/discussion")

		w := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodGet, rawURL, nil)
		h.ServeHTTP(w, req)

		if w.Code != http.StatusBadRequest {
			t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
		}
	}
}

func TestDefinitionsIncludeDiscussion(t *testing.T) {
	_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion")
	_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/key-messages")
	_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/short-term")
	_ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/long-term")
}

func TestDefinitionsIncludeWeatherStories(t *testing.T) {
	_ = definitionForPath(t, Definitions(&fakeService{}), "/weatherstories")
	_ = definitionForPath(t, Definitions(&fakeService{}), "/weatherstories/latest")
}

func TestWeatherStoriesNoDataReturnsNullEnvelopeData(t *testing.T) {
	for _, path := range []string{"/weatherstories", "/weatherstories/latest"} {
		t.Run(path, func(t *testing.T) {
			h := newHandler(t, &fakeService{}, path)

			w := httptest.NewRecorder()
			req := httptest.NewRequest(http.MethodGet, path, nil)
			h.ServeHTTP(w, req)

			if w.Code != http.StatusOK {
				t.Fatalf("expected 200, got %d", w.Code)
			}

			var payload struct {
				Data *json.RawMessage `json:"data"`
			}
			if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
				t.Fatalf("decode envelope: %v", err)
			}
			if payload.Data != nil {
				t.Fatalf("expected data null, got %s", string(*payload.Data))
			}
		})
	}
}

func TestWeatherStoriesJSONEnvelope(t *testing.T) {
	h := newHandler(t, &fakeService{
		weatherStoryRun: sampleWeatherStoryRun(),
	}, "/weatherstories")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/weatherstories?tz=CDT", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data struct {
			OfficeID string `json:"officeId"`
			AsOf     string `json:"asOf"`
			Stories  []struct {
				Title     string `json:"title"`
				UpdatedAt string `json:"updatedAt"`
			} `json:"stories"`
		} `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data.OfficeID != "LSX" {
		t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID)
	}
	if len(payload.Data.Stories) != 2 {
		t.Fatalf("expected 2 stories, got %d", len(payload.Data.Stories))
	}
	if payload.Data.Stories[0].Title != "Rain Chances" {
		t.Fatalf("unexpected first story title: %q", payload.Data.Stories[0].Title)
	}
	if !strings.Contains(payload.Data.AsOf, "-05:00") || !strings.Contains(payload.Data.Stories[0].UpdatedAt, "-05:00") {
		t.Fatalf("expected CDT offset in weather story times, got asOf=%q updatedAt=%q", payload.Data.AsOf, payload.Data.Stories[0].UpdatedAt)
	}
}

func TestWeatherStoriesLatestJSONEnvelope(t *testing.T) {
	run := sampleWeatherStoryRun()
	h := newHandler(t, &fakeService{
		weatherStory: &run.Stories[1],
	}, "/weatherstories/latest")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/weatherstories/latest?tz=Chicago", nil)
	h.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", w.Code)
	}

	var payload struct {
		Data struct {
			Title     string `json:"title"`
			UpdatedAt string `json:"updatedAt"`
		} `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode envelope: %v", err)
	}
	if payload.Data.Title != "More Rain" {
		t.Fatalf("expected latest story title More Rain, got %q", payload.Data.Title)
	}
	if !strings.Contains(payload.Data.UpdatedAt, "-05:00") {
		t.Fatalf("expected Chicago offset in updatedAt, got %q", payload.Data.UpdatedAt)
	}
}

func TestWeatherStoriesFormatNegotiation(t *testing.T) {
	hText := newHandler(t, &fakeService{
		weatherStoryRun: sampleWeatherStoryRun(),
	}, "/weatherstories")

	w := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/weatherstories?format=TEXT&units=US", nil)
	hText.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for text request, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
		t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
	}
	if !strings.Contains(w.Body.String(), "Weather Stories") {
		t.Fatalf("expected rendered weather stories template, got %q", w.Body.String())
	}

	run := sampleWeatherStoryRun()
	hXML := newHandler(t, &fakeService{
		weatherStory: &run.Stories[0],
	}, "/weatherstories/latest")
	w = httptest.NewRecorder()
	req = httptest.NewRequest(http.MethodGet, "/weatherstories/latest?format=XML", nil)
	hXML.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for xml request, got %d", w.Code)
	}
	if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
		t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
	}
}

func TestWeatherStoriesRejectInvalidQuery(t *testing.T) {
	tests := []struct {
		path  string
		query string
	}{
		{path: "/weatherstories", query: "/weatherstories?bogus=1"},
		{path: "/weatherstories", query: "/weatherstories?precision=1"},
		{path: "/weatherstories", query: "/weatherstories?tz=not-a-timezone"},
		{path: "/weatherstories", query: "/weatherstories?tz=CDT&TZ=EST"},
		{path: "/weatherstories/latest", query: "/weatherstories/latest?bogus=1"},
		{path: "/weatherstories/latest", query: "/weatherstories/latest?precision=1"},
		{path: "/weatherstories/latest", query: "/weatherstories/latest?tz=not-a-timezone"},
		{path: "/weatherstories/latest", query: "/weatherstories/latest?tz=CDT&TZ=EST"},
	}

	for _, tt := range tests {
		t.Run(tt.query, func(t *testing.T) {
			run := sampleWeatherStoryRun()
			h := newHandler(t, &fakeService{
				weatherStoryRun: run,
				weatherStory:    &run.Stories[0],
			}, tt.path)

			w := httptest.NewRecorder()
			req := httptest.NewRequest(http.MethodGet, tt.query, nil)
			h.ServeHTTP(w, req)

			if w.Code != http.StatusBadRequest {
				t.Fatalf("expected 400, got %d", w.Code)
			}
		})
	}
}

func TestDiscussionSubresourcesNoDataReturnsNullEnvelopeData(t *testing.T) {
	for _, path := range []string{
		"/discussion/key-messages",
		"/discussion/short-term",
		"/discussion/long-term",
	} {
		t.Run(path, func(t *testing.T) {
			h := newHandler(t, &fakeService{}, path)

			w := httptest.NewRecorder()
			req := httptest.NewRequest(http.MethodGet, path, nil)
			h.ServeHTTP(w, req)

			if w.Code != http.StatusOK {
				t.Fatalf("expected 200, got %d", w.Code)
			}

			var payload struct {
				Data *json.RawMessage `json:"data"`
			}
			if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
				t.Fatalf("decode envelope: %v", err)
			}
			if payload.Data != nil {
				t.Fatalf("expected data null, got %s", string(*payload.Data))
			}
		})
	}
}

func TestDiscussionSubresourcesJSONEnvelopeFocusedFields(t *testing.T) {
	issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC)
	shortIssuedAt := issuedAt.Add(-5 * time.Minute)
	longIssuedAt := issuedAt.Add(10 * time.Minute)
	run := &model.WeatherForecastDiscussion{
		OfficeID:    "LSX",
		OfficeName:  "National Weather Service Saint Louis MO",
		Product:     model.ForecastDiscussionProductAFD,
		IssuedAt:    issuedAt,
		KeyMessages: []string{"msg one", "msg two"},
		ShortTerm:   &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
		LongTerm:    &model.WeatherForecastDiscussionSection{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"},
	}

	t.Run("key messages", func(t *testing.T) {
		h := newHandler(t, &fakeService{discussion: run}, "/discussion/key-messages")
		w := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodGet, "/discussion/key-messages", nil)
		h.ServeHTTP(w, req)

		var payload struct {
			Data struct {
				OfficeID    string   `json:"officeId"`
				KeyMessages []string `json:"keyMessages"`
				ShortTerm   any      `json:"shortTerm"`
				LongTerm    any      `json:"longTerm"`
			} `json:"data"`
		}
		if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
			t.Fatalf("decode envelope: %v", err)
		}
		if payload.Data.OfficeID != "LSX" {
			t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID)
		}
		if len(payload.Data.KeyMessages) != 2 {
			t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages))
		}
		if payload.Data.ShortTerm != nil || payload.Data.LongTerm != nil {
			t.Fatalf("unexpected extra fields in key messages payload")
		}
	})

	t.Run("short term", func(t *testing.T) {
		h := newHandler(t, &fakeService{discussion: run}, "/discussion/short-term")
		w := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodGet, "/discussion/short-term", nil)
		h.ServeHTTP(w, req)

		var payload struct {
			Data struct {
				ShortTerm *struct {
					Text string `json:"text"`
				} `json:"shortTerm"`
				KeyMessages any `json:"keyMessages"`
				LongTerm    any `json:"longTerm"`
			} `json:"data"`
		}
		if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
			t.Fatalf("decode envelope: %v", err)
		}
		if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" {
			t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm)
		}
		if payload.Data.KeyMessages != nil || payload.Data.LongTerm != nil {
			t.Fatalf("unexpected extra fields in short term payload")
		}
	})

	t.Run("long term", func(t *testing.T) {
		h := newHandler(t, &fakeService{discussion: run}, "/discussion/long-term")
		w := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodGet, "/discussion/long-term", nil)
		h.ServeHTTP(w, req)

		var payload struct {
			Data struct {
				LongTerm *struct {
					Text string `json:"text"`
				} `json:"longTerm"`
				KeyMessages any `json:"keyMessages"`
				ShortTerm   any `json:"shortTerm"`
			} `json:"data"`
		}
		if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
			t.Fatalf("decode envelope: %v", err)
		}
		if payload.Data.LongTerm == nil || payload.Data.LongTerm.Text != "Long term text" {
			t.Fatalf("unexpected longTerm payload: %+v", payload.Data.LongTerm)
		}
		if payload.Data.KeyMessages != nil || payload.Data.ShortTerm != nil {
			t.Fatalf("unexpected extra fields in long term payload")
		}
	})
}

func TestDiscussionSubresourcesSupportTextAndXMLFormats(t *testing.T) {
	tests := []struct {
		path         string
		textContains string
	}{
		{path: "/discussion/key-messages", textContains: "Forecast Discussion Key Messages"},
		{path: "/discussion/short-term", textContains: "Forecast Discussion Short Term"},
		{path: "/discussion/long-term", textContains: "Forecast Discussion Long Term"},
	}

	for _, tt := range tests {
		t.Run(tt.path, func(t *testing.T) {
			hText := newHandler(t, &fakeService{
				discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
			}, tt.path)

			w := httptest.NewRecorder()
			req := httptest.NewRequest(http.MethodGet, tt.path+"?format=TEXT", nil)
			hText.ServeHTTP(w, req)

			if w.Code != http.StatusOK {
				t.Fatalf("expected 200 for text request, got %d", w.Code)
			}
			if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
				t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
			}
			if !strings.Contains(w.Body.String(), tt.textContains) {
				t.Fatalf("expected rendered text template body, got %q", w.Body.String())
			}

			hXML := newHandler(t, &fakeService{
				discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
			}, tt.path)

			w = httptest.NewRecorder()
			req = httptest.NewRequest(http.MethodGet, tt.path+"?format=XML", nil)
			hXML.ServeHTTP(w, req)

			if w.Code != http.StatusOK {
				t.Fatalf("expected 200 for xml request, got %d", w.Code)
			}
			if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
				t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
			}
		})
	}
}

func TestDiscussionSubresourcesTimezoneQuery(t *testing.T) {
	issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
	updatedAt := issuedAt.Add(30 * time.Minute)
	shortIssuedAt := issuedAt.Add(-15 * time.Minute)
	longIssuedAt := issuedAt.Add(15 * time.Minute)

	tests := []struct {
		path  string
		query string
		want  int
		check func(*testing.T, discussionFocusedTimePayload, int)
	}{
		{
			path:  "/discussion/key-messages",
			query: "/discussion/key-messages?tz=CDT",
			want:  -5 * 60 * 60,
			check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
				assertOffsetSeconds(t, payload.Data.IssuedAt, want)
				assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
			},
		},
		{
			path:  "/discussion/short-term",
			query: "/discussion/short-term?tz=Chicago",
			want:  -5 * 60 * 60,
			check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
				assertOffsetSeconds(t, payload.Data.IssuedAt, want)
				assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
				assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, want)
			},
		},
		{
			path:  "/discussion/long-term",
			query: "/discussion/long-term?TZ=-5",
			want:  -5 * 60 * 60,
			check: func(t *testing.T, payload discussionFocusedTimePayload, want int) {
				assertOffsetSeconds(t, payload.Data.IssuedAt, want)
				assertOffsetSeconds(t, *payload.Data.UpdatedAt, want)
				assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, want)
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.path, func(t *testing.T) {
			h := newHandler(t, &fakeService{
				discussion: &model.WeatherForecastDiscussion{
					Product:   model.ForecastDiscussionProductAFD,
					IssuedAt:  issuedAt,
					UpdatedAt: &updatedAt,
					ShortTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &shortIssuedAt},
					LongTerm:  &model.WeatherForecastDiscussionSection{IssuedAt: &longIssuedAt},
				},
			}, tt.path)

			w := httptest.NewRecorder()
			req := httptest.NewRequest(http.MethodGet, tt.query, nil)
			h.ServeHTTP(w, req)

			if w.Code != http.StatusOK {
				t.Fatalf("expected 200, got %d", w.Code)
			}

			var payload discussionFocusedTimePayload
			if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
				t.Fatalf("decode discussion payload: %v", err)
			}
			tt.check(t, payload, tt.want)
		})
	}
}

func TestDiscussionSubresourcesRejectInvalidQueryParameters(t *testing.T) {
	tests := []struct {
		path string
		urls []string
	}{
		{
			path: "/discussion/key-messages",
			urls: []string{
				"/discussion/key-messages?bogus=1",
				"/discussion/key-messages?precision=1",
				"/discussion/key-messages?tz=not-a-timezone",
				"/discussion/key-messages?tz=CDT&TZ=EST",
			},
		},
		{
			path: "/discussion/short-term",
			urls: []string{
				"/discussion/short-term?bogus=1",
				"/discussion/short-term?precision=1",
				"/discussion/short-term?tz=not-a-timezone",
				"/discussion/short-term?tz=CDT&TZ=EST",
			},
		},
		{
			path: "/discussion/long-term",
			urls: []string{
				"/discussion/long-term?bogus=1",
				"/discussion/long-term?precision=1",
				"/discussion/long-term?tz=not-a-timezone",
				"/discussion/long-term?tz=CDT&TZ=EST",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.path, func(t *testing.T) {
			for _, rawURL := range tt.urls {
				h := newHandler(t, &fakeService{
					discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()},
				}, tt.path)

				w := httptest.NewRecorder()
				req := httptest.NewRequest(http.MethodGet, rawURL, nil)
				h.ServeHTTP(w, req)

				if w.Code != http.StatusBadRequest {
					t.Fatalf("%s: expected 400, got %d", rawURL, w.Code)
				}
			}
		})
	}
}

func newHandler(t *testing.T, svc Service, path string) http.Handler {
	t.Helper()

	def := definitionForPath(t, Definitions(svc), path)
	return httpx.Adapt(def, httpx.Dependencies{
		Renderers:     testRenderers(t),
		DefaultFormat: render.FormatJSON,
	})
}

func definitionForPath(t *testing.T, defs []endpoint.Definition, path string) endpoint.Definition {
	t.Helper()
	for _, def := range defs {
		if def.Path == path {
			return def
		}
	}
	t.Fatalf("endpoint not found: %s", path)
	return endpoint.Definition{}
}

func testRenderers(t *testing.T) *render.Registry {
	t.Helper()

	reg := render.NewRegistry()
	if err := reg.Register(render.NewJSONRenderer()); err != nil {
		t.Fatal(err)
	}
	if err := reg.Register(render.NewXMLRenderer()); err != nil {
		t.Fatal(err)
	}

	tmplReg := templates.NewRegistry()
	for name, body := range map[string]string{
		"observations.txt.tmpl":            "Observation text",
		"discussion.txt.tmpl":              "Forecast Discussion",
		"discussion_key_messages.txt.tmpl": "Forecast Discussion Key Messages",
		"discussion_short_term.txt.tmpl":   "Forecast Discussion Short Term",
		"discussion_long_term.txt.tmpl":    "Forecast Discussion Long Term",
		"forecast_hourly.txt.tmpl":         "Forecast text",
		"forecast_narrative.txt.tmpl":      "Narrative Forecast",
		"weatherstories.txt.tmpl":          "Weather Stories",
		"weatherstories_latest.txt.tmpl":   "Latest Weather Story",
		"alerts_active.txt.tmpl":           "Alerts text",
		"conditions_current.txt.tmpl":      "Conditions text",
	} {
		tmpl, err := template.New(name).Parse(body)
		if err != nil {
			t.Fatal(err)
		}
		if err := tmplReg.Register(name, tmpl); err != nil {
			t.Fatal(err)
		}
	}
	if err := reg.Register(templates.NewRenderer(tmplReg)); err != nil {
		t.Fatal(err)
	}

	return reg
}

func float64Ptr(v float64) *float64 {
	return &v
}

func wmoCodePtr(v model.WMOCode) *model.WMOCode {
	out := v
	return &out
}

func sampleWeatherStoryRun() *model.WeatherStoryRun {
	return &model.WeatherStoryRun{
		OfficeID: "LSX",
		AsOf:     time.Date(2026, 5, 30, 16, 0, 34, 0, time.UTC),
		Stories: []model.WeatherStory{
			{
				OfficeID:    "LSX",
				StartTime:   time.Date(2026, 5, 30, 13, 46, 0, 0, time.UTC),
				EndTime:     time.Date(2026, 5, 31, 16, 0, 0, 0, time.UTC),
				UpdatedAt:   time.Date(2026, 5, 30, 14, 0, 34, 0, time.UTC),
				Title:       "Rain Chances",
				Description: "Several chances for rain through Monday.",
				AltText:     "Forecast graphic.",
				Priority:    false,
				Order:       1,
				DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1",
			},
			{
				OfficeID:    "LSX",
				StartTime:   time.Date(2026, 5, 30, 15, 46, 0, 0, time.UTC),
				EndTime:     time.Date(2026, 5, 31, 18, 0, 0, 0, time.UTC),
				UpdatedAt:   time.Date(2026, 5, 30, 16, 0, 34, 0, time.UTC),
				Title:       "More Rain",
				Description: "Showers remain possible.",
				AltText:     "Another forecast graphic.",
				Priority:    true,
				Order:       2,
				DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-2",
			},
		},
	}
}

type forecastTimePayload struct {
	Data struct {
		IssuedAt  time.Time  `json:"issuedAt"`
		UpdatedAt *time.Time `json:"updatedAt"`
		Periods   []struct {
			StartTime time.Time `json:"startTime"`
			EndTime   time.Time `json:"endTime"`
		} `json:"periods"`
	} `json:"data"`
}

type discussionTimePayload struct {
	Data struct {
		IssuedAt  time.Time  `json:"issuedAt"`
		UpdatedAt *time.Time `json:"updatedAt"`
		ShortTerm *struct {
			IssuedAt *time.Time `json:"issuedAt"`
		} `json:"shortTerm"`
		LongTerm *struct {
			IssuedAt *time.Time `json:"issuedAt"`
		} `json:"longTerm"`
	} `json:"data"`
}

type discussionFocusedTimePayload struct {
	Data struct {
		IssuedAt  time.Time  `json:"issuedAt"`
		UpdatedAt *time.Time `json:"updatedAt"`
		ShortTerm *struct {
			IssuedAt *time.Time `json:"issuedAt"`
		} `json:"shortTerm"`
		LongTerm *struct {
			IssuedAt *time.Time `json:"issuedAt"`
		} `json:"longTerm"`
	} `json:"data"`
}

func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
	t.Helper()

	payload := decodeForecastTimePayloadAllowEmpty(t, w)
	if len(payload.Data.Periods) == 0 {
		t.Fatalf("expected non-empty periods")
	}
	return payload
}

func decodeForecastTimePayloadAllowEmpty(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
	t.Helper()

	var payload forecastTimePayload
	if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode forecast payload: %v", err)
	}
	return payload
}

func setForecastNowForTest(t *testing.T, ts time.Time) {
	t.Helper()

	prev := forecastNow
	forecastNow = func() time.Time { return ts }
	t.Cleanup(func() {
		forecastNow = prev
	})
}

func assertOffsetSeconds(t *testing.T, ts time.Time, want int) {
	t.Helper()
	_, got := ts.Zone()
	if got != want {
		t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339))
	}
}
