package agentapi_test

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

	"github.com/google/uuid"

	"cdr.dev/slog/sloggers/slogtest"
	"github.com/coder/coder/v2/coderd/agentapi"
	"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/database/dbtime"
	"github.com/coder/coder/v2/coderd/util/ptr"
	"github.com/coder/coder/v2/testutil"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

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

	// We test the below in multiple timezones specifically
	// chosen to trigger timezone-related bugs.
	timezones := []string{
		"Asia/Kolkata",        // No DST, positive fractional offset
		"Canada/Newfoundland", // DST, negative fractional offset
		"Europe/Paris",        // DST, positive offset
		"US/Arizona",          // No DST, negative offset
		"UTC",                 // Baseline
	}

	for _, tt := range []struct {
		name                          string
		transition                    database.WorkspaceTransition
		jobCompletedAt                sql.NullTime
		buildDeadlineOffset           *time.Duration
		maxDeadlineOffset             *time.Duration
		workspaceTTL                  time.Duration
		templateTTL                   time.Duration
		templateActivityBump          time.Duration
		templateDisallowsUserAutostop bool
		expectedBump                  time.Duration
		// If the tests get queued, we need to be able to set the next autostart
		// based on the actual time the unit test is running.
		nextAutostart func(now time.Time) time.Time
	}{
		{
			name:                "NotFinishedYet",
			transition:          database.WorkspaceTransitionStart,
			jobCompletedAt:      sql.NullTime{},
			buildDeadlineOffset: ptr.Ref(8 * time.Hour),
			workspaceTTL:        8 * time.Hour,
			expectedBump:        0,
		},
		{
			name:                "ManualShutdown",
			transition:          database.WorkspaceTransitionStart,
			jobCompletedAt:      sql.NullTime{Valid: true, Time: dbtime.Now()},
			buildDeadlineOffset: nil,
			expectedBump:        0,
		},
		{
			name:                "NotTimeToBumpYet",
			transition:          database.WorkspaceTransitionStart,
			jobCompletedAt:      sql.NullTime{Valid: true, Time: dbtime.Now()},
			buildDeadlineOffset: ptr.Ref(8 * time.Hour),
			workspaceTTL:        8 * time.Hour,
			expectedBump:        0,
		},
		{
			// Expected bump is 0 because the original deadline is more than 1 hour
			// out, so a bump would decrease the deadline.
			name:                "BumpLessThanDeadline",
			transition:          database.WorkspaceTransitionStart,
			jobCompletedAt:      sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
			buildDeadlineOffset: ptr.Ref(8*time.Hour - 30*time.Minute),
			workspaceTTL:        8 * time.Hour,
			expectedBump:        0,
		},
		{
			name:                "TimeToBump",
			transition:          database.WorkspaceTransitionStart,
			jobCompletedAt:      sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
			buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
			workspaceTTL:        8 * time.Hour,
			expectedBump:        time.Hour,
		},
		{
			name:                "TimeToBumpNextAutostart",
			transition:          database.WorkspaceTransitionStart,
			jobCompletedAt:      sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
			buildDeadlineOffset: ptr.Ref(-30 * time.Minute),
			workspaceTTL:        8 * time.Hour,
			expectedBump:        8*time.Hour + 30*time.Minute,
			nextAutostart:       func(now time.Time) time.Time { return now.Add(time.Minute * 30) },
		},
		{
			name:                "MaxDeadline",
			transition:          database.WorkspaceTransitionStart,
			jobCompletedAt:      sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)},
			buildDeadlineOffset: ptr.Ref(time.Minute), // last chance to bump!
			maxDeadlineOffset:   ptr.Ref(time.Minute * 30),
			workspaceTTL:        8 * time.Hour,
			expectedBump:        time.Minute * 30,
		},
		{
			// A workspace that is still running, has passed its deadline, but has not
			// yet been auto-stopped should still bump the deadline.
			name:                "PastDeadlineStillBumps",
			transition:          database.WorkspaceTransitionStart,
			jobCompletedAt:      sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)},
			buildDeadlineOffset: ptr.Ref(-time.Minute),
			workspaceTTL:        8 * time.Hour,
			expectedBump:        time.Hour,
		},
		{
			// A stopped workspace should never bump.
			name:                "StoppedWorkspace",
			transition:          database.WorkspaceTransitionStop,
			jobCompletedAt:      sql.NullTime{Valid: true, Time: dbtime.Now().Add(-time.Minute)},
			buildDeadlineOffset: ptr.Ref(-time.Minute),
			workspaceTTL:        8 * time.Hour,
		},
		{
			// A workspace built from a template that disallows user autostop should bump
			// by the template TTL instead.
			name:                          "TemplateDisallowsUserAutostop",
			transition:                    database.WorkspaceTransitionStart,
			jobCompletedAt:                sql.NullTime{Valid: true, Time: dbtime.Now().Add(-3 * time.Hour)},
			buildDeadlineOffset:           ptr.Ref(-30 * time.Minute),
			workspaceTTL:                  2 * time.Hour,
			templateTTL:                   10 * time.Hour,
			templateDisallowsUserAutostop: true,
			expectedBump:                  10*time.Hour + (time.Minute * 30),
			nextAutostart:                 func(now time.Time) time.Time { return now.Add(time.Minute * 30) },
		},
		{
			// Custom activity bump duration specified on the template.
			name:                 "TemplateCustomActivityBump",
			transition:           database.WorkspaceTransitionStart,
			jobCompletedAt:       sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
			buildDeadlineOffset:  ptr.Ref(-30 * time.Minute),
			workspaceTTL:         8 * time.Hour,
			templateActivityBump: 5 * time.Hour, // instead of default 1h
			expectedBump:         5 * time.Hour,
		},
		{
			// Activity bump duration is 0.
			name:                 "TemplateCustomActivityBumpZero",
			transition:           database.WorkspaceTransitionStart,
			jobCompletedAt:       sql.NullTime{Valid: true, Time: dbtime.Now().Add(-30 * time.Minute)},
			buildDeadlineOffset:  ptr.Ref(-30 * time.Minute),
			workspaceTTL:         8 * time.Hour,
			templateActivityBump: -1, // negative values get changed to 0 in the test
			expectedBump:         0,
		},
	} {
		tt := tt
		for _, tz := range timezones {
			tz := tz
			t.Run(tt.name+"/"+tz, func(t *testing.T) {
				t.Parallel()
				nextAutostart := tt.nextAutostart
				if tt.nextAutostart == nil {
					nextAutostart = func(now time.Time) time.Time { return time.Time{} }
				}

				var (
					now   = dbtime.Now()
					ctx   = testutil.Context(t, testutil.WaitShort)
					log   = slogtest.Make(t, nil)
					db, _ = dbtestutil.NewDB(t, dbtestutil.WithTimezone(tz))
					org   = dbgen.Organization(t, db, database.Organization{})
					user  = dbgen.User(t, db, database.User{
						Status: database.UserStatusActive,
					})
					_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
						UserID:         user.ID,
						OrganizationID: org.ID,
					})
					templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
						OrganizationID: org.ID,
						CreatedBy:      user.ID,
					})
					template = dbgen.Template(t, db, database.Template{
						OrganizationID:  org.ID,
						ActiveVersionID: templateVersion.ID,
						CreatedBy:       user.ID,
					})
					ws = dbgen.Workspace(t, db, database.Workspace{
						OwnerID:        user.ID,
						OrganizationID: org.ID,
						TemplateID:     template.ID,
						Ttl:            sql.NullInt64{Valid: true, Int64: int64(tt.workspaceTTL)},
					})
					job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
						OrganizationID: org.ID,
						CompletedAt:    tt.jobCompletedAt,
					})
					_ = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
						JobID: job.ID,
					})
					buildID = uuid.New()
				)

				activityBump := 1 * time.Hour
				if tt.templateActivityBump < 0 {
					// less than 0 => 0
					activityBump = 0
				} else if tt.templateActivityBump != 0 {
					activityBump = tt.templateActivityBump
				}
				require.NoError(t, db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
					ID:                template.ID,
					UpdatedAt:         dbtime.Now(),
					AllowUserAutostop: !tt.templateDisallowsUserAutostop,
					DefaultTTL:        int64(tt.templateTTL),
					ActivityBump:      int64(activityBump),
				}), "unexpected error updating template schedule")

				var buildNumber int32 = 1
				// Insert a number of previous workspace builds.
				for i := 0; i < 5; i++ {
					insertPrevWorkspaceBuild(t, db, org.ID, templateVersion.ID, ws.ID, database.WorkspaceTransitionStart, buildNumber)
					buildNumber++
					insertPrevWorkspaceBuild(t, db, org.ID, templateVersion.ID, ws.ID, database.WorkspaceTransitionStop, buildNumber)
					buildNumber++
				}

				// dbgen.WorkspaceBuild automatically sets deadline to now+1 hour if not set
				var buildDeadline time.Time
				if tt.buildDeadlineOffset != nil {
					buildDeadline = now.Add(*tt.buildDeadlineOffset)
				}
				var maxDeadline time.Time
				if tt.maxDeadlineOffset != nil {
					maxDeadline = now.Add(*tt.maxDeadlineOffset)
				}
				err := db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
					ID:                buildID,
					CreatedAt:         dbtime.Now(),
					UpdatedAt:         dbtime.Now(),
					BuildNumber:       buildNumber,
					InitiatorID:       user.ID,
					Reason:            database.BuildReasonInitiator,
					WorkspaceID:       ws.ID,
					JobID:             job.ID,
					TemplateVersionID: templateVersion.ID,
					Transition:        tt.transition,
					Deadline:          buildDeadline,
					MaxDeadline:       maxDeadline,
				})
				require.NoError(t, err, "unexpected error inserting workspace build")
				bld, err := db.GetWorkspaceBuildByID(ctx, buildID)
				require.NoError(t, err, "unexpected error fetching inserted workspace build")

				// Validate our initial state before bump
				require.Equal(t, tt.transition, bld.Transition, "unexpected transition before bump")
				require.Equal(t, tt.jobCompletedAt.Time.UTC(), job.CompletedAt.Time.UTC(), "unexpected job completed at before bump")
				require.Equal(t, buildDeadline.UTC(), bld.Deadline.UTC(), "unexpected build deadline before bump")
				require.Equal(t, maxDeadline.UTC(), bld.MaxDeadline.UTC(), "unexpected max deadline before bump")
				require.Equal(t, tt.workspaceTTL, time.Duration(ws.Ttl.Int64), "unexpected workspace TTL before bump")

				// Wait a bit before bumping as dbtime is rounded to the nearest millisecond.
				// This should also hopefully be enough for Windows time resolution to register
				// a tick (win32 max timer resolution is apparently between 0.5 and 15.6ms)
				<-time.After(testutil.IntervalFast)

				// Bump duration is measured from the time of the bump, so we measure from here.
				start := dbtime.Now()
				agentapi.ActivityBumpWorkspace(ctx, log, db, bld.WorkspaceID, nextAutostart(start))
				end := dbtime.Now()

				// Validate our state after bump
				updatedBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, bld.WorkspaceID)
				require.NoError(t, err, "unexpected error getting latest workspace build")
				require.Equal(t, bld.MaxDeadline.UTC(), updatedBuild.MaxDeadline.UTC(), "max_deadline should not have changed")
				if tt.expectedBump == 0 {
					assert.Equal(t, bld.UpdatedAt.UTC(), updatedBuild.UpdatedAt.UTC(), "should not have bumped updated_at")
					assert.Equal(t, bld.Deadline.UTC(), updatedBuild.Deadline.UTC(), "should not have bumped deadline")
					return
				}
				assert.NotEqual(t, bld.UpdatedAt.UTC(), updatedBuild.UpdatedAt.UTC(), "should have bumped updated_at")
				if tt.maxDeadlineOffset != nil {
					assert.Equal(t, bld.MaxDeadline.UTC(), updatedBuild.MaxDeadline.UTC(), "new deadline must equal original max deadline")
					return
				}

				// Assert that the bump occurred between start and end. 1min buffer on either side.
				expectedDeadlineStart := start.Add(tt.expectedBump).Add(time.Minute * -1)
				expectedDeadlineEnd := end.Add(tt.expectedBump).Add(time.Minute)
				require.GreaterOrEqual(t, updatedBuild.Deadline, expectedDeadlineStart, "new deadline should be greater than or equal to start")
				require.LessOrEqual(t, updatedBuild.Deadline, expectedDeadlineEnd, "new deadline should be less than or equal to end")
			})
		}
	}
}

func insertPrevWorkspaceBuild(t *testing.T, db database.Store, orgID, tvID, workspaceID uuid.UUID, transition database.WorkspaceTransition, buildNumber int32) {
	t.Helper()

	job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
		OrganizationID: orgID,
	})
	_ = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
		JobID: job.ID,
	})
	_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
		BuildNumber:       buildNumber,
		WorkspaceID:       workspaceID,
		JobID:             job.ID,
		TemplateVersionID: tvID,
		Transition:        transition,
	})
}
