// payload_test.go validates presenter conversion and payload shaping behavior.
// Layer: adapters/inbound/httpapi/presenter unit and schema tests.
package presenter

import (
	"math"
	"testing"
	"time"

	"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
	"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)

func TestObservationPayloadUS(t *testing.T) {
	obs := &model.WeatherObservation{
		StationID:               "KSTL",
		Timestamp:               time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
		ConditionCode:           2,
		TemperatureC:            float64Ptr(20),
		DewpointC:               float64Ptr(10),
		WindSpeedKmh:            float64Ptr(100),
		WindGustKmh:             float64Ptr(80),
		BarometricPressurePa:    float64Ptr(101325),
		VisibilityMeters:        float64Ptr(1609.344),
		ApparentTemperatureC:    float64Ptr(25),
		RelativeHumidityPercent: float64Ptr(50),
	}

	payload := ObservationPayload(obs, UnitsUS, 2)
	converted, ok := payload.(WeatherObservationUS)
	if !ok {
		t.Fatalf("expected WeatherObservationUS payload, got %T", payload)
	}

	assertApprox(t, converted.TemperatureF, 68.0, 0.0001)
	assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001)
	assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001)
	assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001)

	if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil {
		t.Fatalf("expected converted Fahrenheit fields to be populated")
	}
}

func TestForecastPayloadUS(t *testing.T) {
	issuedAt := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
	updatedAt := issuedAt.Add(1 * time.Hour)
	run := &model.WeatherForecastRun{
		LocationID:      "stl",
		IssuedAt:        issuedAt,
		UpdatedAt:       &updatedAt,
		Product:         model.ForecastProductHourly,
		ElevationMeters: float64Ptr(1000),
		Periods: []model.WeatherForecastPeriod{{
			StartTime:             issuedAt,
			EndTime:               issuedAt.Add(1 * time.Hour),
			ConditionCode:         63,
			TemperatureC:          float64Ptr(0),
			TemperatureCMin:       float64Ptr(-5),
			TemperatureCMax:       float64Ptr(5),
			WindSpeedKmh:          float64Ptr(64.37376),
			PrecipitationAmountMm: float64Ptr(25.4),
			SnowfallDepthMM:       float64Ptr(50.8),
		}},
	}

	payload := ForecastPayload(run, UnitsUS, 2, nil)
	converted, ok := payload.(WeatherForecastRunUS)
	if !ok {
		t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
	}

	assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001)
	if len(converted.Periods) != 1 {
		t.Fatalf("expected 1 period, got %d", len(converted.Periods))
	}
	period := converted.Periods[0]
	assertApprox(t, period.TemperatureF, 32.0, 0.0001)
	assertApprox(t, period.TemperatureFMin, 23.0, 0.0001)
	assertApprox(t, period.TemperatureFMax, 41.0, 0.0001)
	assertApprox(t, period.WindSpeedMph, 40.0, 0.0001)
	assertApprox(t, period.PrecipitationAmountIn, 1.0, 0.0001)
	assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
}

func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) {
	loc := time.FixedZone("UTC-05:00", -5*60*60)
	issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
	updatedAt := issuedAt.Add(30 * time.Minute)
	run := &model.WeatherForecastRun{
		Product:   model.ForecastProductHourly,
		IssuedAt:  issuedAt,
		UpdatedAt: &updatedAt,
		Periods: []model.WeatherForecastPeriod{{
			StartTime:     issuedAt.Add(1 * time.Hour),
			EndTime:       issuedAt.Add(2 * time.Hour),
			ConditionCode: model.WMOUnknown,
		}},
	}

	metricPayload := ForecastPayload(run, UnitsMetric, 0, loc)
	metric, ok := metricPayload.(*model.WeatherForecastRun)
	if !ok {
		t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
	}
	assertOffsetSeconds(t, metric.IssuedAt, -5*60*60)
	assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60)
	assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60)
	assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60)
	if !metric.IssuedAt.UTC().Equal(issuedAt) {
		t.Fatalf("expected metric issuedAt to preserve instant")
	}

	usPayload := ForecastPayload(run, UnitsUS, 0, loc)
	us, ok := usPayload.(WeatherForecastRunUS)
	if !ok {
		t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
	}
	assertOffsetSeconds(t, us.IssuedAt, -5*60*60)
	assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60)
	assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60)
	assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60)

	// Source model remains untouched.
	assertOffsetSeconds(t, run.IssuedAt, 0)
	assertOffsetSeconds(t, *run.UpdatedAt, 0)
}

func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
	issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
	updatedAt := issuedAt.Add(time.Hour)
	run := &model.WeatherForecastRun{
		Product:   model.ForecastProductHourly,
		IssuedAt:  issuedAt,
		UpdatedAt: &updatedAt,
		Periods: []model.WeatherForecastPeriod{{
			StartTime:     issuedAt,
			EndTime:       issuedAt.Add(time.Hour),
			ConditionCode: model.WMOUnknown,
		}},
	}

	metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
	metric, ok := metricPayload.(*model.WeatherForecastRun)
	if !ok {
		t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
	}
	if metric == run {
		t.Fatalf("expected metric payload to be copied")
	}
	if metric.UpdatedAt == run.UpdatedAt {
		t.Fatalf("expected updatedAt pointer to be copied")
	}
	if !metric.IssuedAt.Equal(run.IssuedAt) {
		t.Fatalf("expected issuedAt to remain unchanged without timezone flag")
	}
	assertOffsetSeconds(t, metric.IssuedAt, 0)
	assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
}

func TestMetricCopyAndNilHandling(t *testing.T) {
	obs := &model.WeatherObservation{
		TemperatureC: float64Ptr(20.6),
	}
	metric := ObservationPayload(obs, UnitsMetric, 0)
	metricObs, ok := metric.(*model.WeatherObservation)
	if !ok {
		t.Fatalf("expected metric payload to remain model type, got %T", metric)
	}
	if metricObs == obs {
		t.Fatalf("expected metric payload to be copied")
	}
	assertApprox(t, metricObs.TemperatureC, 21, 0.0001)
	assertApprox(t, obs.TemperatureC, 20.6, 0.0001)
	if metricObs.TemperatureC == obs.TemperatureC {
		t.Fatalf("expected temperature pointer copy, got same pointer")
	}

	if ObservationPayload(nil, UnitsUS, 0) != nil {
		t.Fatalf("expected nil observation input to return nil payload")
	}
	if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
		t.Fatalf("expected nil forecast input to return nil payload")
	}
	if AlertsPayload(nil, UnitsUS) != nil {
		t.Fatalf("expected nil alerts input to return nil payload")
	}
	if CurrentConditionsPayload(nil, UnitsUS, 0) != nil {
		t.Fatalf("expected nil current conditions input to return nil payload")
	}
}

func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
	conditions := &app.CurrentConditions{
		TemperatureC:            float64Ptr(20),
		ApparentTemperatureC:    float64Ptr(18),
		DewpointC:               float64Ptr(10),
		RelativeHumidityPercent: float64Ptr(55),
		WindSpeedKmh:            float64Ptr(100),
		WindDirectionDegrees:    float64Ptr(225),
		ConditionCode:           0,
		IsDay:                   boolPtr(true),
	}

	metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2)
	metric, ok := metricPayload.(CurrentConditionsResponse)
	if !ok {
		t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload)
	}
	assertApprox(t, metric.TemperatureC, 20, 0.0001)
	assertApprox(t, metric.WindSpeedKmh, 100, 0.0001)
	if metric.TemperatureF != nil || metric.WindSpeedMph != nil {
		t.Fatalf("expected US fields omitted for metric payload")
	}
	if metric.ConditionText != "Sunny" {
		t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
	}

	usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
	us, ok := usPayload.(CurrentConditionsResponse)
	if !ok {
		t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload)
	}
	assertApprox(t, us.TemperatureF, 68, 0.0001)
	assertApprox(t, us.WindSpeedMph, 62.14, 0.0001)
	if us.TemperatureC != nil || us.WindSpeedKmh != nil {
		t.Fatalf("expected metric fields omitted for US payload")
	}
}

func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
	night := false
	payload := CurrentConditionsPayload(&app.CurrentConditions{
		ConditionCode: 0,
		IsDay:         &night,
	}, UnitsMetric, 0)

	metric, ok := payload.(CurrentConditionsResponse)
	if !ok {
		t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
	}
	if metric.ConditionText != "Clear" {
		t.Fatalf("expected condition text Clear, got %q", metric.ConditionText)
	}
}

func TestCurrentConditionsPayloadRoundsHalfAwayFromZero(t *testing.T) {
	payload := CurrentConditionsPayload(&app.CurrentConditions{
		TemperatureC: float64Ptr(-1.5),
	}, UnitsMetric, 0)

	metric, ok := payload.(CurrentConditionsResponse)
	if !ok {
		t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
	}
	assertApprox(t, metric.TemperatureC, -2, 0.0001)
}

func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
	run := &model.WeatherForecastRun{
		Latitude:        float64Ptr(38.627123),
		Longitude:       float64Ptr(-90.199456),
		ElevationMeters: float64Ptr(10.499),
		Product:         model.ForecastProductHourly,
		IssuedAt:        time.Now().UTC(),
	}

	metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
	metric, ok := metricPayload.(*model.WeatherForecastRun)
	if !ok {
		t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
	}
	assertApprox(t, metric.Latitude, 38.627123, 0.000001)
	assertApprox(t, metric.Longitude, -90.199456, 0.000001)
	assertApprox(t, metric.ElevationMeters, 10, 0.0001)

	usPayload := ForecastPayload(run, UnitsUS, 0, nil)
	us, ok := usPayload.(WeatherForecastRunUS)
	if !ok {
		t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
	}
	assertApprox(t, us.Latitude, 38.627123, 0.000001)
	assertApprox(t, us.Longitude, -90.199456, 0.000001)
}

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

func boolPtr(v bool) *bool {
	return &v
}

func assertApprox(t *testing.T, got *float64, want, eps float64) {
	t.Helper()
	if got == nil {
		t.Fatalf("expected value near %f, got nil", want)
	}
	if math.Abs(*got-want) > eps {
		t.Fatalf("expected %f +/- %f, got %f", want, eps, *got)
	}
}

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