// 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
	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) 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: 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: 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 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: 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: 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: 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 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 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",
		"forecast_hourly.txt.tmpl":    "Forecast text",
		"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
}

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"`
}

func decodeForecastTimePayload(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)
	}
	if len(payload.Data.Periods) == 0 {
		t.Fatalf("expected non-empty periods")
	}
	return payload
}

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))
	}
}
