package services

// Pure tests for computeViewSpecBounds — t-paliad-248. Covers every
// TimeHorizon constant in the symmetric date-range fan, including the
// six new ones added when the picker shipped (next_1d / next_14d /
// next_all / past_1d / past_14d / past_all).
//
// Anchored against a fixed `now` so the assertions never drift with the
// wall clock. Each case asserts the bounds shape (open-ended vs.
// closed) and the exact offsets from the anchor day.

import (
	"testing"
	"time"
)

func TestComputeViewSpecBounds_Horizons(t *testing.T) {
	// Anchor: 2026-05-25 14:37:00 UTC. computeViewSpecBounds normalises
	// to startOfDay UTC, so the wall-clock time within the day is
	// irrelevant.
	now := time.Date(2026, 5, 25, 14, 37, 0, 0, time.UTC)
	day := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
	tomorrow := day.AddDate(0, 0, 1)

	cases := []struct {
		name     string
		horizon  TimeHorizon
		wantFrom *time.Time
		wantTo   *time.Time
	}{
		// Future fan.
		{"next_1d", HorizonNext1d, &day, tptr(day.AddDate(0, 0, 1))},
		{"next_7d", HorizonNext7d, &day, tptr(day.AddDate(0, 0, 7))},
		{"next_14d", HorizonNext14d, &day, tptr(day.AddDate(0, 0, 14))},
		{"next_30d", HorizonNext30d, &day, tptr(day.AddDate(0, 0, 30))},
		{"next_90d", HorizonNext90d, &day, tptr(day.AddDate(0, 0, 90))},
		// One-sided unbounded: from today, no upper bound.
		{"next_all", HorizonNextAll, &day, nil},

		// Past fan — upper bound is tomorrow (exclusive end-of-today).
		{"past_1d", HorizonPast1d, tptr(day.AddDate(0, 0, -1)), &tomorrow},
		{"past_7d", HorizonPast7d, tptr(day.AddDate(0, 0, -7)), &tomorrow},
		{"past_14d", HorizonPast14d, tptr(day.AddDate(0, 0, -14)), &tomorrow},
		{"past_30d", HorizonPast30d, tptr(day.AddDate(0, 0, -30)), &tomorrow},
		{"past_90d", HorizonPast90d, tptr(day.AddDate(0, 0, -90)), &tomorrow},
		// One-sided unbounded: no lower bound, up to and including today.
		{"past_all", HorizonPastAll, nil, &tomorrow},

		// Bidirectional unbounded — both nil.
		{"any", HorizonAny, nil, nil},
		{"all", HorizonAll, nil, nil},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := computeViewSpecBounds(now, TimeSpec{Horizon: tc.horizon})
			assertBound(t, "from", got.from, tc.wantFrom)
			assertBound(t, "to", got.to, tc.wantTo)
		})
	}
}

// TestComputeViewSpecBounds_NewHorizonsAreOneSided documents the
// semantic distinction between next_all / past_all (one-sided
// unbounded, with one bound nil and the other set) and the existing
// HorizonAll / HorizonAny (both bounds nil).
func TestComputeViewSpecBounds_NewHorizonsAreOneSided(t *testing.T) {
	now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)

	nextAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonNextAll})
	if nextAll.from == nil {
		t.Fatalf("HorizonNextAll: from must be set (today), got nil")
	}
	if nextAll.to != nil {
		t.Fatalf("HorizonNextAll: to must be nil (no upper bound), got %v", *nextAll.to)
	}

	pastAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonPastAll})
	if pastAll.from != nil {
		t.Fatalf("HorizonPastAll: from must be nil (no lower bound), got %v", *pastAll.from)
	}
	if pastAll.to == nil {
		t.Fatalf("HorizonPastAll: to must be set (tomorrow), got nil")
	}

	any := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonAny})
	if any.from != nil || any.to != nil {
		t.Fatalf("HorizonAny: both bounds must be nil, got from=%v to=%v", any.from, any.to)
	}
}

// TestComputeViewSpecBounds_CustomRoundTrips makes sure the custom
// horizon passes through the caller-supplied from/to verbatim — no
// normalisation, no clamping.
func TestComputeViewSpecBounds_CustomRoundTrips(t *testing.T) {
	now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
	from := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
	to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)

	got := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonCustom, From: &from, To: &to})
	if got.from == nil || !got.from.Equal(from) {
		t.Fatalf("custom from: want %v, got %v", from, got.from)
	}
	if got.to == nil || !got.to.Equal(to) {
		t.Fatalf("custom to: want %v, got %v", to, got.to)
	}
}

func tptr(t time.Time) *time.Time { return &t }

func assertBound(t *testing.T, name string, got *time.Time, want *time.Time) {
	t.Helper()
	switch {
	case got == nil && want == nil:
		return
	case got == nil:
		t.Fatalf("%s: want %v, got nil", name, *want)
	case want == nil:
		t.Fatalf("%s: want nil, got %v", name, *got)
	case !got.Equal(*want):
		t.Fatalf("%s: want %v, got %v", name, *want, *got)
	}
}
