package healthcheck

import (
	"testing"
	"time"

	"github.com/onsi/gomega"
)

func TestParseTimeOfDay(t *testing.T) {
	g := gomega.NewWithT(t)

	tests := []struct {
		in     string
		wantOK bool
		want   time.Duration
	}{
		{"00:00", true, 0},
		{"10:00", true, 10 * time.Hour},
		{"14:15", true, 14*time.Hour + 15*time.Minute},
		{"23:59", true, 23*time.Hour + 59*time.Minute},
		// invalid / other formats must fall through to ParseTimeString
		{"24:00", false, 0},
		{"10:60", false, 0},
		{"10:0", false, 0},
		{"1:00", false, 0},
		{"10:00:00", false, 0},
		{"-86400", false, 0},
		{"0", false, 0},
		{"now", false, 0},
		{"2024-01-01", false, 0},
		{"ab:cd", false, 0},
		{"", false, 0},
	}

	for _, tt := range tests {
		t.Run(tt.in, func(t *testing.T) {
			got, ok := ParseTimeOfDay(tt.in)
			g.Expect(ok).To(gomega.Equal(tt.wantOK))
			if tt.wantOK {
				g.Expect(got).To(gomega.Equal(tt.want))
			}
		})
	}
}

func TestResolveTimeOfDayWindow(t *testing.T) {
	g := gomega.NewWithT(t)

	tod := func(s string) time.Duration {
		d, ok := ParseTimeOfDay(s)
		g.Expect(ok).To(gomega.BeTrue())
		return d
	}
	utc := func(y int, m time.Month, d, h, min int) time.Time {
		return time.Date(y, m, d, h, min, 0, 0, time.UTC)
	}

	tests := []struct {
		name               string
		base               time.Time
		start, end         string
		wantStart, wantEnd time.Time
	}{
		{
			name:      "same day window",
			base:      utc(2024, 6, 5, 14, 15),
			start:     "10:00",
			end:       "13:00",
			wantStart: utc(2024, 6, 5, 10, 0),
			wantEnd:   utc(2024, 6, 5, 13, 0),
		},
		{
			name:      "end after base goes to previous day",
			base:      utc(2024, 6, 5, 9, 0),
			start:     "10:00",
			end:       "13:00",
			wantStart: utc(2024, 6, 4, 10, 0),
			wantEnd:   utc(2024, 6, 4, 13, 0),
		},
		{
			name:      "cross midnight, base after window",
			base:      utc(2024, 6, 5, 14, 15),
			start:     "22:00",
			end:       "02:00",
			wantStart: utc(2024, 6, 4, 22, 0),
			wantEnd:   utc(2024, 6, 5, 2, 0),
		},
		{
			name:      "cross midnight, base inside window falls back to last completed",
			base:      utc(2024, 6, 5, 1, 0),
			start:     "22:00",
			end:       "02:00",
			wantStart: utc(2024, 6, 3, 22, 0),
			wantEnd:   utc(2024, 6, 4, 2, 0),
		},
		{
			name:      "full 24h equals legacy last-24h when end==trigger clock",
			base:      utc(2024, 6, 5, 14, 15),
			start:     "14:15",
			end:       "14:15",
			wantStart: utc(2024, 6, 4, 14, 15),
			wantEnd:   utc(2024, 6, 5, 14, 15),
		},
		{
			name:      "start==end full 24h other clock",
			base:      utc(2024, 6, 5, 10, 0),
			start:     "09:00",
			end:       "09:00",
			wantStart: utc(2024, 6, 4, 9, 0),
			wantEnd:   utc(2024, 6, 5, 9, 0),
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			gotStart, gotEnd := ResolveTimeOfDayWindow(tod(tt.start), tod(tt.end), time.UTC, tt.base)
			g.Expect(gotStart).To(gomega.BeTemporally("==", tt.wantStart))
			g.Expect(gotEnd).To(gomega.BeTemporally("==", tt.wantEnd))
		})
	}
}

func TestResolveTimeOfDayWindowDST(t *testing.T) {
	g := gomega.NewWithT(t)
	loc, err := time.LoadLocation("America/New_York")
	if err != nil {
		t.Skip("tzdata for America/New_York not available")
	}
	tod := func(s string) time.Duration {
		d, ok := ParseTimeOfDay(s)
		g.Expect(ok).To(gomega.BeTrue())
		return d
	}

	// Spring-forward day 2024-03-10 (02:00 EST -> 03:00 EDT). A 10:00-13:00
	// window must stay at the wall clock, not drift by the skipped hour.
	base := time.Date(2024, 3, 10, 14, 0, 0, 0, loc)
	start, end := ResolveTimeOfDayWindow(tod("10:00"), tod("13:00"), loc, base)
	// 10:00 EDT = 14:00 UTC, 13:00 EDT = 17:00 UTC.
	g.Expect(start).To(gomega.BeTemporally("==", time.Date(2024, 3, 10, 14, 0, 0, 0, time.UTC)))
	g.Expect(end).To(gomega.BeTemporally("==", time.Date(2024, 3, 10, 17, 0, 0, 0, time.UTC)))

	// A full-day window across the spring-forward boundary spans one calendar
	// day (23h), not 24 absolute hours.
	fdStart, fdEnd := ResolveTimeOfDayWindow(
		tod("14:00"), tod("14:00"), loc, time.Date(2024, 3, 10, 20, 0, 0, 0, loc),
	)
	g.Expect(fdEnd.Sub(fdStart)).To(gomega.Equal(23 * time.Hour))
}

func TestResolveTimeOfDayWindowTimezone(t *testing.T) {
	g := gomega.NewWithT(t)

	loc, err := time.LoadLocation("Asia/Shanghai")
	if err != nil {
		t.Skip("tzdata for Asia/Shanghai not available")
	}

	// base = 06:15 UTC = 14:15 CST; window 10:00-13:00 CST.
	base := time.Date(2024, 6, 5, 6, 15, 0, 0, time.UTC)
	startTOD, _ := ParseTimeOfDay("10:00")
	endTOD, _ := ParseTimeOfDay("13:00")

	gotStart, gotEnd := ResolveTimeOfDayWindow(startTOD, endTOD, loc, base)

	// 10:00 CST = 02:00 UTC, 13:00 CST = 05:00 UTC.
	g.Expect(gotStart).To(gomega.BeTemporally("==", time.Date(2024, 6, 5, 2, 0, 0, 0, time.UTC)))
	g.Expect(gotEnd).To(gomega.BeTemporally("==", time.Date(2024, 6, 5, 5, 0, 0, 0, time.UTC)))
}

// TestResolveRangeA covers the integration path that the cron scanner actually
// uses: turning a strategy's stored A-range expressions into the absolute
// times written to a result. It exercises both the inspection-window path and
// the legacy fallback without needing a database.
func TestResolveRangeA(t *testing.T) {
	g := gomega.NewWithT(t)
	base := time.Date(2024, 6, 5, 14, 15, 0, 0, time.UTC)

	t.Run("HH:MM window resolves as a pair (UTC cron)", func(t *testing.T) {
		start, end := ResolveRangeA("10:00", "13:00", "TZ=UTC 15 14 * * *", base)
		g.Expect(start).NotTo(gomega.BeNil())
		g.Expect(end).NotTo(gomega.BeNil())
		g.Expect(*start).To(gomega.BeTemporally("==", time.Date(2024, 6, 5, 10, 0, 0, 0, time.UTC)))
		g.Expect(*end).To(gomega.BeTemporally("==", time.Date(2024, 6, 5, 13, 0, 0, 0, time.UTC)))
	})

	t.Run("cross-midnight window", func(t *testing.T) {
		start, end := ResolveRangeA("22:00", "02:00", "TZ=UTC 15 14 * * *", base)
		g.Expect(*start).To(gomega.BeTemporally("==", time.Date(2024, 6, 4, 22, 0, 0, 0, time.UTC)))
		g.Expect(*end).To(gomega.BeTemporally("==", time.Date(2024, 6, 5, 2, 0, 0, 0, time.UTC)))
	})

	t.Run("legacy relative seconds fall back to ParseTimeString", func(t *testing.T) {
		start, end := ResolveRangeA("-86400", "0", "TZ=UTC 15 14 * * *", base)
		g.Expect(start).NotTo(gomega.BeNil())
		g.Expect(end).NotTo(gomega.BeNil())
		// "-86400"/"0" are relative to time.Now() inside ParseTimeString, so just
		// assert the ~24h span and that the window is not a fixed HH:MM result.
		g.Expect(end.Sub(*start)).To(gomega.BeNumerically("~", 24*time.Hour, time.Minute))
	})

	t.Run("mixed window/non-window does not resolve as window", func(t *testing.T) {
		// Only start is HH:MM; must not be treated as a pair. "13:00"-like end is
		// absent, so the window branch is skipped and start falls back (and fails
		// ParseTimeString), yielding nil — exactly what validation prevents at write time.
		start, _ := ResolveRangeA("10:00", "-3600", "TZ=UTC 15 14 * * *", base)
		g.Expect(start).To(gomega.BeNil())
	})

	t.Run("empty expressions yield nil", func(t *testing.T) {
		start, end := ResolveRangeA("", "", "", base)
		g.Expect(start).To(gomega.BeNil())
		g.Expect(end).To(gomega.BeNil())
	})
}

func TestExtractCronLocation(t *testing.T) {
	g := gomega.NewWithT(t)

	t.Run("CRON_TZ prefix", func(t *testing.T) {
		if _, err := time.LoadLocation("Asia/Shanghai"); err != nil {
			t.Skip("tzdata not available")
		}
		loc := ExtractCronLocation("CRON_TZ=Asia/Shanghai 15 14 * * *")
		g.Expect(loc.String()).To(gomega.Equal("Asia/Shanghai"))
	})

	t.Run("TZ prefix", func(t *testing.T) {
		loc := ExtractCronLocation("TZ=UTC 0 0 * * *")
		g.Expect(loc.String()).To(gomega.Equal("UTC"))
	})

	t.Run("no prefix falls back to local", func(t *testing.T) {
		loc := ExtractCronLocation("30 2 * * *")
		g.Expect(loc).To(gomega.Equal(time.Local))
	})

	t.Run("invalid zone falls back to local", func(t *testing.T) {
		loc := ExtractCronLocation("CRON_TZ=Invalid/Zone 0 0 * * *")
		g.Expect(loc).To(gomega.Equal(time.Local))
	})
}

func TestValidateTimeRange(t *testing.T) {
	g := gomega.NewWithT(t)

	tests := []struct {
		name    string
		req     HealthCheckStrategyReq
		wantErr bool
	}{
		{
			name:    "both A ends time-of-day",
			req:     HealthCheckStrategyReq{TimeRangeAStart: "10:00", TimeRangeAEnd: "13:00"},
			wantErr: false,
		},
		{
			name:    "mixed time-of-day and relative is rejected",
			req:     HealthCheckStrategyReq{TimeRangeAStart: "10:00", TimeRangeAEnd: "-3600"},
			wantErr: true,
		},
		{
			name:    "legacy relative seconds still valid",
			req:     HealthCheckStrategyReq{TimeRangeAStart: "-86400", TimeRangeAEnd: "0"},
			wantErr: false,
		},
		{
			name:    "empty is valid",
			req:     HealthCheckStrategyReq{},
			wantErr: false,
		},
		{
			name:    "invalid B expression rejected",
			req:     HealthCheckStrategyReq{TimeRangeBStart: "not-a-time"},
			wantErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := tt.req.ValidateTimeRange()
			if tt.wantErr {
				g.Expect(err).To(gomega.HaveOccurred())
			} else {
				g.Expect(err).To(gomega.Succeed())
			}
		})
	}
}
