package schedule_test

import (
	"context"
	"database/sql"
	"fmt"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbgen"
	"github.com/coder/coder/v2/coderd/database/dbtestutil"
	"github.com/coder/coder/v2/coderd/schedule"
	"github.com/coder/coder/v2/testutil"
)

func TestCalculateAutoStop(t *testing.T) {
	t.Parallel()

	now := time.Now()

	// Wednesday the 8th of February 2023 at midnight. This date was
	// specifically chosen as it doesn't fall on a applicable week for both
	// fortnightly and triweekly restart requirements.
	wednesdayMidnightUTC := time.Date(2023, 2, 8, 0, 0, 0, 0, time.UTC)

	sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *"
	sydneyLoc, err := time.LoadLocation("Australia/Sydney")
	require.NoError(t, err)
	// 10pm on Friday the 10th of February 2023 in Sydney.
	fridayEveningSydney := time.Date(2023, 2, 10, 22, 0, 0, 0, sydneyLoc)
	// 12am on Saturday the 11th of February2023 in Sydney.
	saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc)

	t.Log("now", now)
	t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC)
	t.Log("fridayEveningSydney", fridayEveningSydney)
	t.Log("saturdayMidnightSydney", saturdayMidnightSydney)

	dstIn := time.Date(2023, 10, 1, 2, 0, 0, 0, sydneyLoc)   // 1 hour backward
	dstInQuietHours := "CRON_TZ=Australia/Sydney 30 2 * * *" // never
	// The expected behavior is that we will pick the next time that falls on
	// quiet hours after the DST transition. In this case, it will be the same
	// time the next day.
	dstInQuietHoursExpectedTime := time.Date(2023, 10, 2, 2, 30, 0, 0, sydneyLoc)
	beforeDstIn := time.Date(2023, 10, 1, 0, 0, 0, 0, sydneyLoc)
	saturdayMidnightAfterDstIn := time.Date(2023, 10, 7, 0, 0, 0, 0, sydneyLoc)

	// Wednesday after DST starts.
	duringDst := time.Date(2023, 10, 4, 0, 0, 0, 0, sydneyLoc)
	saturdayMidnightAfterDuringDst := saturdayMidnightAfterDstIn

	dstOut := time.Date(2024, 4, 7, 3, 0, 0, 0, sydneyLoc)                        // 1 hour forward
	dstOutQuietHours := "CRON_TZ=Australia/Sydney 30 3 * * *"                     // twice
	dstOutQuietHoursExpectedTime := time.Date(2024, 4, 7, 3, 30, 0, 0, sydneyLoc) // in reality, this is the first occurrence
	beforeDstOut := time.Date(2024, 4, 7, 0, 0, 0, 0, sydneyLoc)
	saturdayMidnightAfterDstOut := time.Date(2024, 4, 13, 0, 0, 0, 0, sydneyLoc)

	t.Log("dstIn", dstIn)
	t.Log("beforeDstIn", beforeDstIn)
	t.Log("saturdayMidnightAfterDstIn", saturdayMidnightAfterDstIn)
	t.Log("dstOut", dstOut)
	t.Log("beforeDstOut", beforeDstOut)
	t.Log("saturdayMidnightAfterDstOut", saturdayMidnightAfterDstOut)

	cases := []struct {
		name                  string
		now                   time.Time
		templateAllowAutostop bool
		templateDefaultTTL    time.Duration
		// TODO(@dean): remove max_ttl tests
		useMaxTTL                  bool
		templateMaxTTL             time.Duration
		templateRestartRequirement schedule.TemplateRestartRequirement
		userQuietHoursSchedule     string
		// workspaceTTL is usually copied from the template's TTL when the
		// workspace is made, so it takes precedence unless
		// templateAllowAutostop is false.
		workspaceTTL time.Duration

		// expectedDeadline is copied from expectedMaxDeadline if unset.
		expectedDeadline    time.Time
		expectedMaxDeadline time.Time
		errContains         string
	}{
		{
			name:                       "OK",
			now:                        now,
			templateAllowAutostop:      true,
			templateDefaultTTL:         0,
			templateRestartRequirement: schedule.TemplateRestartRequirement{},
			workspaceTTL:               0,
			expectedDeadline:           time.Time{},
			expectedMaxDeadline:        time.Time{},
		},
		{
			name:                       "Delete",
			now:                        now,
			templateAllowAutostop:      true,
			templateDefaultTTL:         0,
			templateRestartRequirement: schedule.TemplateRestartRequirement{},
			workspaceTTL:               0,
			expectedDeadline:           time.Time{},
			expectedMaxDeadline:        time.Time{},
		},
		{
			name:                       "WorkspaceTTL",
			now:                        now,
			templateAllowAutostop:      true,
			templateDefaultTTL:         0,
			templateRestartRequirement: schedule.TemplateRestartRequirement{},
			workspaceTTL:               time.Hour,
			expectedDeadline:           now.Add(time.Hour),
			expectedMaxDeadline:        time.Time{},
		},
		{
			name:                       "TemplateDefaultTTLIgnored",
			now:                        now,
			templateAllowAutostop:      true,
			templateDefaultTTL:         time.Hour,
			templateRestartRequirement: schedule.TemplateRestartRequirement{},
			workspaceTTL:               0,
			expectedDeadline:           time.Time{},
			expectedMaxDeadline:        time.Time{},
		},
		{
			name:                       "WorkspaceTTLOverridesTemplateDefaultTTL",
			now:                        now,
			templateAllowAutostop:      true,
			templateDefaultTTL:         2 * time.Hour,
			templateRestartRequirement: schedule.TemplateRestartRequirement{},
			workspaceTTL:               time.Hour,
			expectedDeadline:           now.Add(time.Hour),
			expectedMaxDeadline:        time.Time{},
		},
		{
			name:                       "TemplateBlockWorkspaceTTL",
			now:                        now,
			templateAllowAutostop:      false,
			templateDefaultTTL:         3 * time.Hour,
			templateRestartRequirement: schedule.TemplateRestartRequirement{},
			workspaceTTL:               4 * time.Hour,
			expectedDeadline:           now.Add(3 * time.Hour),
			expectedMaxDeadline:        time.Time{},
		},
		{
			name:                   "TemplateRestartRequirement",
			now:                    wednesdayMidnightUTC,
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      0,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
		},
		{
			name:                   "TemplateRestartRequirement1HourSkip",
			now:                    saturdayMidnightSydney.Add(-59 * time.Minute),
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      1,          // 1 also means weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.Add(7 * 24 * time.Hour).In(time.UTC),
		},
		{
			// The next restart requirement should be skipped if the
			// workspace is started within 1 hour of it.
			name:                   "TemplateRestartRequirementDaily",
			now:                    fridayEveningSydney,
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b01111111, // daily
				Weeks:      0,          // all weeks
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
		},
		{
			name:                   "TemplateRestartRequirementFortnightly/Skip",
			now:                    wednesdayMidnightUTC,
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      2,          // every 2 weeks
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
		},
		{
			name:                   "TemplateRestartRequirementFortnightly/NoSkip",
			now:                    wednesdayMidnightUTC.AddDate(0, 0, 7),
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      2,          // every 2 weeks
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
		},
		{
			name:                   "TemplateRestartRequirementTriweekly/Skip",
			now:                    wednesdayMidnightUTC,
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      3,          // every 3 weeks
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			// The next triweekly restart requirement happens next week
			// according to the epoch.
			expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
		},
		{
			name:                   "TemplateRestartRequirementTriweekly/NoSkip",
			now:                    wednesdayMidnightUTC.AddDate(0, 0, 7),
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      3,          // every 3 weeks
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC),
		},
		{
			name: "TemplateRestartRequirementOverridesWorkspaceTTL",
			// now doesn't have to be UTC, but it helps us ensure that
			// timezones are compared correctly in this test.
			now:                    fridayEveningSydney.In(time.UTC),
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      0,          // weekly
			},
			workspaceTTL: 3 * time.Hour,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
		},
		{
			name:                   "TemplateRestartRequirementOverridesTemplateDefaultTTL",
			now:                    fridayEveningSydney.In(time.UTC),
			templateAllowAutostop:  true,
			templateDefaultTTL:     3 * time.Hour,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      0,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
		},
		{
			name: "TimeBeforeEpoch",
			// The epoch is 2023-01-02 in each timezone. We set the time to
			// 1 second before 11pm the previous day, as this is the latest time
			// we allow due to our 1h leeway logic.
			now:                    time.Date(2023, 1, 1, 22, 59, 59, 0, sydneyLoc),
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      2,          // every fortnight
			},
			workspaceTTL: 0,
			errContains:  "coder server system clock is incorrect",
		},
		{
			name:                   "DaylightSavings/OK",
			now:                    duringDst,
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      1,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightAfterDuringDst,
		},
		{
			name:                   "DaylightSavings/SwitchMidWeek/In",
			now:                    beforeDstIn,
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      1,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightAfterDstIn,
		},
		{
			name:                   "DaylightSavings/SwitchMidWeek/Out",
			now:                    beforeDstOut,
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      1,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightAfterDstOut,
		},
		{
			name:                   "DaylightSavings/QuietHoursFallsOnDstSwitch/In",
			now:                    beforeDstIn.Add(-24 * time.Hour),
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: dstInQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b01000000, // Sunday
				Weeks:      1,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: dstInQuietHoursExpectedTime,
		},
		{
			name:                   "DaylightSavings/QuietHoursFallsOnDstSwitch/Out",
			now:                    beforeDstOut.Add(-24 * time.Hour),
			templateAllowAutostop:  true,
			templateDefaultTTL:     0,
			userQuietHoursSchedule: dstOutQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b01000000, // Sunday
				Weeks:      1,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: dstOutQuietHoursExpectedTime,
		},

		// TODO(@dean): remove max_ttl tests
		{
			name:                   "RestartRequirementIgnoresMaxTTL",
			now:                    fridayEveningSydney.In(time.UTC),
			templateAllowAutostop:  false,
			templateDefaultTTL:     0,
			useMaxTTL:              false,
			templateMaxTTL:         time.Hour, // should be ignored
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      0,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC),
		},
		{
			name:                   "MaxTTLIgnoresRestartRequirement",
			now:                    fridayEveningSydney.In(time.UTC),
			templateAllowAutostop:  false,
			templateDefaultTTL:     0,
			useMaxTTL:              true,
			templateMaxTTL:         time.Hour, // should NOT be ignored
			userQuietHoursSchedule: sydneyQuietHours,
			templateRestartRequirement: schedule.TemplateRestartRequirement{
				DaysOfWeek: 0b00100000, // Saturday
				Weeks:      0,          // weekly
			},
			workspaceTTL: 0,
			// expectedDeadline is copied from expectedMaxDeadline.
			expectedMaxDeadline: fridayEveningSydney.Add(time.Hour).In(time.UTC),
		},
	}

	for _, c := range cases {
		c := c

		t.Run(c.name, func(t *testing.T) {
			t.Parallel()

			db, _ := dbtestutil.NewDB(t)
			ctx := testutil.Context(t, testutil.WaitLong)

			templateScheduleStore := schedule.MockTemplateScheduleStore{
				GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) {
					return schedule.TemplateScheduleOptions{
						UserAutostartEnabled:  false,
						UserAutostopEnabled:   c.templateAllowAutostop,
						DefaultTTL:            c.templateDefaultTTL,
						MaxTTL:                c.templateMaxTTL,
						UseRestartRequirement: !c.useMaxTTL,
						RestartRequirement:    c.templateRestartRequirement,
					}, nil
				},
			}

			userQuietHoursScheduleStore := schedule.MockUserQuietHoursScheduleStore{
				GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.UserQuietHoursScheduleOptions, error) {
					if c.userQuietHoursSchedule == "" {
						return schedule.UserQuietHoursScheduleOptions{
							Schedule: nil,
						}, nil
					}

					sched, err := schedule.Daily(c.userQuietHoursSchedule)
					if !assert.NoError(t, err) {
						return schedule.UserQuietHoursScheduleOptions{}, err
					}

					return schedule.UserQuietHoursScheduleOptions{
						Schedule: sched,
						UserSet:  false,
					}, nil
				},
			}

			org := dbgen.Organization(t, db, database.Organization{})
			user := dbgen.User(t, db, database.User{
				QuietHoursSchedule: c.userQuietHoursSchedule,
			})
			template := dbgen.Template(t, db, database.Template{
				Name:           "template",
				Provisioner:    database.ProvisionerTypeEcho,
				OrganizationID: org.ID,
				CreatedBy:      user.ID,
			})
			err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
				ID:                           template.ID,
				UpdatedAt:                    database.Now(),
				AllowUserAutostart:           c.templateAllowAutostop,
				RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek),
				RestartRequirementWeeks:      c.templateRestartRequirement.Weeks,
			})
			require.NoError(t, err)
			template, err = db.GetTemplateByID(ctx, template.ID)
			require.NoError(t, err)
			workspaceTTL := sql.NullInt64{}
			if c.workspaceTTL != 0 {
				workspaceTTL = sql.NullInt64{
					Int64: int64(c.workspaceTTL),
					Valid: true,
				}
			}
			workspace := dbgen.Workspace(t, db, database.Workspace{
				TemplateID:     template.ID,
				OrganizationID: org.ID,
				OwnerID:        user.ID,
				Ttl:            workspaceTTL,
			})

			autostop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{
				Database:                    db,
				TemplateScheduleStore:       templateScheduleStore,
				UserQuietHoursScheduleStore: userQuietHoursScheduleStore,
				Now:                         c.now,
				Workspace:                   workspace,
			})
			if c.errContains != "" {
				require.Error(t, err)
				require.ErrorContains(t, err, c.errContains)
				return
			}
			require.NoError(t, err)

			// If the max deadline is set, the deadline should also be set.
			// Default to the max deadline if the deadline is not set.
			if c.expectedDeadline.IsZero() {
				c.expectedDeadline = c.expectedMaxDeadline
			}

			if c.expectedDeadline.IsZero() {
				require.True(t, autostop.Deadline.IsZero())
			} else {
				require.WithinDuration(t, c.expectedDeadline, autostop.Deadline, 15*time.Second, "deadline does not match expected")
			}
			if c.expectedMaxDeadline.IsZero() {
				require.True(t, autostop.MaxDeadline.IsZero())
			} else {
				require.WithinDuration(t, c.expectedMaxDeadline, autostop.MaxDeadline, 15*time.Second, "max deadline does not match expected")
				require.GreaterOrEqual(t, autostop.MaxDeadline.Unix(), autostop.Deadline.Unix(), "max deadline is smaller than deadline")
			}
		})
	}
}

func TestFindWeek(t *testing.T) {
	t.Parallel()

	timezones := []string{
		"UTC",
		"America/Los_Angeles",
		"America/New_York",
		"Europe/Dublin",
		"Europe/London",
		"Europe/Paris",
		"Asia/Kolkata", // India (UTC+5:30)
		"Asia/Tokyo",
		"Australia/Sydney",
		"Australia/Brisbane",
	}

	for _, tz := range timezones {
		tz := tz
		t.Run("Loc/"+tz, func(t *testing.T) {
			t.Parallel()

			loc, err := time.LoadLocation(tz)
			require.NoError(t, err)

			now := time.Now().In(loc)
			currentWeek, err := schedule.WeeksSinceEpoch(now)
			require.NoError(t, err)

			diffMonday := now.Weekday() - time.Monday
			if now.Weekday() == time.Sunday {
				// Sunday is 0, but Monday is the first day of the week in the
				// code.
				diffMonday = 6
			}
			currentWeekMondayExpected := now.AddDate(0, 0, -int(diffMonday))
			require.Equal(t, time.Monday, currentWeekMondayExpected.Weekday())
			y, m, d := currentWeekMondayExpected.Date()
			// Change to midnight.
			currentWeekMondayExpected = time.Date(y, m, d, 0, 0, 0, 0, loc)

			currentWeekMonday, err := schedule.GetMondayOfWeek(now.Location(), currentWeek)
			require.NoError(t, err)
			require.Equal(t, time.Monday, currentWeekMonday.Weekday())
			require.Equal(t, currentWeekMondayExpected, currentWeekMonday)

			t.Log("now", now)
			t.Log("currentWeek", currentWeek)
			t.Log("currentMonday", currentWeekMonday)

			// Loop through every single Monday and Sunday for the next 100
			// years and make sure the week calculations are correct.
			for i := int64(1); i < 52*100; i++ {
				msg := fmt.Sprintf("week %d", i)

				monday := currentWeekMonday.AddDate(0, 0, int(i*7))
				y, m, d := monday.Date()
				monday = time.Date(y, m, d, 0, 0, 0, 0, loc)
				require.Equal(t, monday.Weekday(), time.Monday, msg)
				t.Log(msg, "monday", monday)

				week, err := schedule.WeeksSinceEpoch(monday)
				require.NoError(t, err, msg)
				require.Equal(t, currentWeek+i, week, msg)

				gotMonday, err := schedule.GetMondayOfWeek(monday.Location(), week)
				require.NoError(t, err, msg)
				require.Equal(t, monday, gotMonday, msg)

				// Check that we get the same week number for late Sunday.
				sunday := time.Date(y, m, d+6, 23, 59, 59, 0, loc)
				require.Equal(t, sunday.Weekday(), time.Sunday, msg)
				t.Log(msg, "sunday", sunday)

				week, err = schedule.WeeksSinceEpoch(sunday)
				require.NoError(t, err, msg)
				require.Equal(t, currentWeek+i, week, msg)
			}
		})
	}
}
