package br

import (
	"context"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	clusterngv1beta "github.com/tidbcloud/cluster-service-ng/api/cluster-service-ng/v1beta"
	"go.uber.org/mock/gomock"

	mockregionalserver "github.com/tidbcloud/tidb-management-service/nextgen-global-service/domain/adapter/regionalserver/mock_regionalserver"
	"github.com/tidbcloud/tidb-management-service/nextgen-global-service/domain/entity"
	brrepo "github.com/tidbcloud/tidb-management-service/nextgen-global-service/domain/repository/br"
	mockbrrepo "github.com/tidbcloud/tidb-management-service/nextgen-global-service/domain/repository/br/mock_br"
	mockregionrepo "github.com/tidbcloud/tidb-management-service/nextgen-global-service/domain/repository/region/mock_region"
	mockresourcepoolrepo "github.com/tidbcloud/tidb-management-service/nextgen-global-service/domain/repository/resourcepool/mock_resourcepool"
	"github.com/tidbcloud/tidb-management-service/nextgen-global-service/infra/repoimpl/schema"
	"github.com/tidbcloud/tidb-management-service/nextgen-global-service/pkg/config"
	"github.com/tidbcloud/tidb-management-service/utilities/bizerr"
	"github.com/tidbcloud/tidb-management-service/utilities/pagination"
)

func TestSyncMissingBackupsSkipsDeletedCachedSettings(t *testing.T) {
	const (
		clusterID    = uint64(101)
		sharedPoolID = uint64(202)
	)

	newWorker := func(repo brrepo.BackupSettingRepo) *worker {
		slots := make([]map[uint64]map[uint64]bool, MinuteCountOfADay)
		slots[0] = map[uint64]map[uint64]bool{
			sharedPoolID: {clusterID: true},
		}
		setting := &entity.BackupSetting{BackupSetting: &schema.BackupSetting{
			ClusterId:    clusterID,
			SharedPoolId: sharedPoolID,
			State:        schema.BackupSettingStateCreated,
			Schedule: schema.AutoSchedule{
				Type: "per-day",
				At:   0,
			},
		}}
		cfg := &config.BackupConfig{
			SyncConfig: config.SyncBackupsConfig{
				Enable:                     true,
				LoadScheduleChangeInterval: 5 * time.Minute,
			},
			SyncMissingConfig: config.SyncMissingBackupsConfig{
				Enable:    true,
				BatchSize: 1,
			},
		}
		return &worker{
			cfg:               cfg,
			backupSettingRepo: repo,
			backupSyncSlots: &backupSyncSlots{
				cfg:               &cfg.SyncConfig,
				backupSettingRepo: repo,
				lock:              &sync.Mutex{},
				isInit:            true,
				slots:             slots,
				clusterSlots:      map[uint64][]int{clusterID: {0}},
				backupSettings:    map[uint64]*entity.BackupSetting{clusterID: setting},
			},
		}
	}

	t.Run("reconciles deleted setting before taking cache snapshot", func(t *testing.T) {
		ctrl := gomock.NewController(t)
		repo := mockbrrepo.NewMockBackupSettingRepo(ctrl)
		worker := newWorker(repo)
		deleted := &entity.BackupSetting{BackupSetting: &schema.BackupSetting{
			ClusterId:    clusterID,
			SharedPoolId: sharedPoolID,
			State:        schema.BackupSettingStateDeleted,
		}}

		gomock.InOrder(
			repo.EXPECT().UnscopedQuery(t.Context(), gomock.Any(), nil).DoAndReturn(
				func(_ context.Context, filter *brrepo.QueryBackupSettingFilter, _ *pagination.Page) ([]*entity.BackupSetting, bizerr.BizError) {
					require.Equal(t, []schema.BackupSettingState{schema.BackupSettingStateCreated}, filter.States)
					require.NotNil(t, filter.LastScheduleUpdatedAtGte)
					return nil, nil
				},
			),
			repo.EXPECT().UnscopedQuery(t.Context(), gomock.Any(), nil).DoAndReturn(
				func(_ context.Context, filter *brrepo.QueryBackupSettingFilter, _ *pagination.Page) ([]*entity.BackupSetting, bizerr.BizError) {
					require.ElementsMatch(t, []uint64{clusterID}, filter.ClusterIds)
					require.Equal(t, schema.BackupSettingStatesForDeletedCluster, filter.States)
					return []*entity.BackupSetting{deleted}, nil
				},
			),
		)

		require.NoError(t, worker.SyncMissingBackups(t.Context()))
		require.Empty(t, worker.backupSyncSlots.copyBackupSettings())
	})

	t.Run("initializes slots before reconciliation", func(t *testing.T) {
		ctrl := gomock.NewController(t)
		repo := mockbrrepo.NewMockBackupSettingRepo(ctrl)
		worker := newWorker(repo)
		worker.backupSyncSlots.isInit = false
		worker.backupSyncSlots.slots = nil
		worker.backupSyncSlots.clusterSlots = nil
		worker.backupSyncSlots.backupSettings = nil
		now := time.Now().UTC()
		worker.backupSyncSlots.cfg.StartRebuildMinuteAtADay = (now.Hour()*60 + now.Minute() + 10) % MinuteCountOfADay
		worker.backupSyncSlots.cfg.RebuildSlotsIntervalMinutes = MinuteCountOfADay
		active := &entity.BackupSetting{BackupSetting: &schema.BackupSetting{
			ClusterId:             clusterID,
			SharedPoolId:          sharedPoolID,
			State:                 schema.BackupSettingStateCreated,
			LastScheduleUpdatedAt: time.Now().UTC(),
			Schedule: schema.AutoSchedule{
				Type:          "per-day",
				At:            0,
				RetentionDays: 7,
			},
		}}

		gomock.InOrder(
			repo.EXPECT().UnscopedQuery(t.Context(), gomock.Any(), nil).DoAndReturn(
				func(_ context.Context, filter *brrepo.QueryBackupSettingFilter, _ *pagination.Page) ([]*entity.BackupSetting, bizerr.BizError) {
					require.Equal(t, []schema.BackupSettingState{schema.BackupSettingStateCreated}, filter.States)
					require.Nil(t, filter.LastScheduleUpdatedAtGte)
					return []*entity.BackupSetting{active}, nil
				},
			),
			repo.EXPECT().UnscopedQuery(t.Context(), gomock.Any(), nil).Return(nil, nil),
			repo.EXPECT().UnscopedQuery(t.Context(), gomock.Any(), nil).Return(nil, nil),
			repo.EXPECT().Get(t.Context(), clusterID).Return(active, nil),
			repo.EXPECT().ListBackupReconciliationExclusions(
				t.Context(), sharedPoolID, gomock.Any(), gomock.Any(),
			).Return(nil, nil),
		)

		require.NoError(t, worker.SyncMissingBackups(t.Context()))
		require.True(t, worker.backupSyncSlots.isInit)
		require.Len(t, worker.backupSyncSlots.copyBackupSettings(), 1)
	})

	t.Run("evicts setting deleted after cache reconciliation", func(t *testing.T) {
		ctrl := gomock.NewController(t)
		repo := mockbrrepo.NewMockBackupSettingRepo(ctrl)
		worker := newWorker(repo)
		deleted := &entity.BackupSetting{BackupSetting: &schema.BackupSetting{
			ClusterId:    clusterID,
			SharedPoolId: sharedPoolID,
			State:        schema.BackupSettingStateDeleted,
		}}

		gomock.InOrder(
			repo.EXPECT().UnscopedQuery(t.Context(), gomock.Any(), nil).Return(nil, nil),
			repo.EXPECT().UnscopedQuery(t.Context(), gomock.Any(), nil).Return(nil, nil),
			repo.EXPECT().Get(t.Context(), clusterID).Return(deleted, nil),
		)

		require.NoError(t, worker.SyncMissingBackups(t.Context()))
		require.Empty(t, worker.backupSyncSlots.copyBackupSettings())
	})
}

func TestCalculateExpectedBackupsPerDay(t *testing.T) {
	tests := []struct {
		name               string
		scheduleAtMinute   int
		start              time.Time
		end                time.Time
		expectedPointCount int
		expectedPoints     []time.Time
	}{
		{
			name:               "multiple days, start hour before schedule hour, end hour before schedule hour",
			scheduleAtMinute:   1 * 60,
			start:              time.Date(2025, 1, 1, 0, 10, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 5, 0, 10, 0, 0, time.UTC),
			expectedPointCount: 4,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 2, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 3, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 4, 1, 0, 0, 0, time.UTC),
			},
		},
		{
			name:               "multiple days, start hour before schedule hour, end hour after schedule hour",
			scheduleAtMinute:   1 * 60,
			start:              time.Date(2025, 1, 1, 0, 10, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 5, 2, 10, 0, 0, time.UTC),
			expectedPointCount: 5,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 2, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 3, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 4, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 5, 1, 0, 0, 0, time.UTC),
			},
		},
		{
			name:               "multiple days, start hour before schedule hour, end hour equal schedule hour",
			scheduleAtMinute:   1 * 60,
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 5, 1, 0, 0, 0, time.UTC),
			expectedPointCount: 5,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 2, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 3, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 4, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 5, 1, 0, 0, 0, time.UTC),
			},
		},
		{
			name:               "single day, start hour before schedule hour, end hour before schedule hour",
			scheduleAtMinute:   3 * 60,
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 2, 0, 0, 0, time.UTC),
			expectedPointCount: 0,
			expectedPoints:     []time.Time{},
		},
		{
			name:               "single day, start hour before schedule hour, end hour after schedule hour",
			scheduleAtMinute:   3 * 60,
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 3, 0, 0, 0, time.UTC),
			expectedPointCount: 1,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 3, 0, 0, 0, time.UTC),
			},
		},
		{
			name:               "single day, start hour before schedule hour, end hour equal schedule hour",
			scheduleAtMinute:   3 * 60,
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 3, 0, 0, 0, time.UTC),
			expectedPointCount: 1,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 3, 0, 0, 0, time.UTC),
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := calculateExpectedBackupsPerDay(tt.scheduleAtMinute, tt.start, tt.end)
			assert.Len(t, result, tt.expectedPointCount)

			// Verify the times are correct
			for i, timePoint := range result {
				assert.Equal(t, tt.expectedPoints[i], timePoint)
			}
		})
	}
}

func TestFlexibleRetentionMissingBackupUsesDailyWindow(t *testing.T) {
	backupSetting := &entity.BackupSetting{BackupSetting: &schema.BackupSetting{
		Mode: schema.BackupModeFlexibleRetention,
		Schedule: schema.AutoSchedule{
			Type:                   "per-day",
			At:                     5*60 + 3,
			DailyBackupMinuteOfDay: 5*60 + 3,
		},
	}}
	start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
	end := time.Date(2026, 7, 2, 23, 59, 0, 0, time.UTC)

	expected := calculateExpectedBackupsInDuration(backupSetting, start, end)
	assert.Equal(t, []time.Time{
		time.Date(2026, 7, 1, 5, 3, 0, 0, time.UTC),
		time.Date(2026, 7, 2, 5, 3, 0, 0, time.UTC),
	}, expected)

	missing := calculateMissingBackups(backupSetting, expected, []*entity.Backup{
		createTestBackup(time.Date(2026, 7, 1, 5, 3, 0, 0, time.UTC)),
		// Flexible backups use the daily window, so this counts for the July 2 05:03 slot.
		createTestBackup(time.Date(2026, 7, 2, 6, 3, 0, 0, time.UTC)),
	})
	assert.Empty(t, missing)
}

func TestCalculateExpectedBackupsPerHour(t *testing.T) {
	tests := []struct {
		name               string
		scheduleAtMinute   int
		start              time.Time
		end                time.Time
		expectedPointCount int
		expectedPoints     []time.Time
	}{
		{
			name:               "multiple hours, start minute before schedule minute, end minute before schedule minute",
			scheduleAtMinute:   30,
			start:              time.Date(2025, 1, 1, 0, 10, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 3, 10, 0, 0, time.UTC),
			expectedPointCount: 3,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC),
			},
		},
		{
			name:               "multiple hours, start minute before schedule minute, end minute after schedule minute",
			scheduleAtMinute:   30,
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 3, 45, 0, 0, time.UTC),
			expectedPointCount: 4,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 3, 30, 0, 0, time.UTC),
			},
		},
		{
			name:               "multiple hours, start minute before schedule minute, end minute equal schedule minute",
			scheduleAtMinute:   30,
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 3, 30, 0, 0, time.UTC),
			expectedPointCount: 4,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 3, 30, 0, 0, time.UTC),
			},
		},
		{
			name:               "single hour, start minute before schedule minute, end minute before schedule minute",
			scheduleAtMinute:   45,
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 0, 30, 0, 0, time.UTC),
			expectedPointCount: 0,
			expectedPoints:     []time.Time{},
		},
		{
			name:               "single hour, start minute before schedule minute, end minute after schedule minute",
			scheduleAtMinute:   45,
			start:              time.Date(2025, 1, 1, 0, 10, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 0, 50, 0, 0, time.UTC),
			expectedPointCount: 1,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 45, 0, 0, time.UTC),
			},
		},
		{
			name:               "schedule at minute 0 (top of hour)",
			scheduleAtMinute:   0,
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 3, 0, 0, 0, time.UTC),
			expectedPointCount: 4,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 2, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 3, 0, 0, 0, time.UTC),
			},
		},
		{
			name:               "cross-day boundary",
			scheduleAtMinute:   15,
			start:              time.Date(2025, 1, 1, 23, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 2, 1, 0, 0, 0, time.UTC),
			expectedPointCount: 2,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 23, 15, 0, 0, time.UTC),
				time.Date(2025, 1, 2, 0, 15, 0, 0, time.UTC),
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := calculateExpectedBackupsPerHour(tt.scheduleAtMinute, tt.start, tt.end)
			assert.Len(t, result, tt.expectedPointCount)

			// Verify the times are correct
			for i, timePoint := range result {
				assert.Equal(t, tt.expectedPoints[i], timePoint)
			}
		})
	}
}

func TestCalculateExpectedBackupsPerMinute(t *testing.T) {
	tests := []struct {
		name               string
		start              time.Time
		end                time.Time
		expectedPointCount int
		expectedPoints     []time.Time
	}{
		{
			name:               "multiple minutes, start not at top of minute, end not at top of minute",
			start:              time.Date(2025, 1, 1, 0, 0, 10, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 0, 5, 10, 0, time.UTC),
			expectedPointCount: 5,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 3, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 4, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 5, 0, 0, time.UTC),
			},
		},
		{
			name:               "multiple minutes, start at top of minute, end not at top of minute",
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 0, 5, 45, 0, time.UTC),
			expectedPointCount: 6,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 3, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 4, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 5, 0, 0, time.UTC),
			},
		},
		{
			name:               "multiple minutes, start not at top of minute, end at top of minute",
			start:              time.Date(2025, 1, 1, 0, 0, 10, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 0, 5, 0, 0, time.UTC),
			expectedPointCount: 5,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 3, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 4, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 5, 0, 0, time.UTC),
			},
		},
		{
			name:               "multiple minutes, start at top of minute, end at top of minute",
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 0, 5, 0, 0, time.UTC),
			expectedPointCount: 6,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 3, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 4, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 5, 0, 0, time.UTC),
			},
		},
		{
			name:               "single minute, start not at top of minute, end not at top of minute",
			start:              time.Date(2025, 1, 1, 0, 0, 10, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 0, 0, 25, 0, time.UTC),
			expectedPointCount: 0,
			expectedPoints:     []time.Time{},
		},
		{
			name:               "single minute, start at top of minute, end not at top of minute",
			start:              time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 0, 0, 10, 0, time.UTC),
			expectedPointCount: 1,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
			},
		},
		{
			name:               "cross-hour boundary",
			start:              time.Date(2025, 1, 1, 0, 59, 30, 0, time.UTC),
			end:                time.Date(2025, 1, 1, 1, 1, 0, 0, time.UTC),
			expectedPointCount: 2,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 1, 1, 0, 0, time.UTC),
			},
		},
		{
			name:               "cross-day boundary",
			start:              time.Date(2025, 1, 1, 23, 58, 30, 0, time.UTC),
			end:                time.Date(2025, 1, 2, 0, 1, 0, 0, time.UTC),
			expectedPointCount: 3,
			expectedPoints: []time.Time{
				time.Date(2025, 1, 1, 23, 59, 0, 0, time.UTC),
				time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 2, 0, 1, 0, 0, time.UTC),
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := calculateExpectedBackupsPerMinute(tt.start, tt.end)
			assert.Len(t, result, tt.expectedPointCount)

			// Verify the times are correct
			for i, timePoint := range result {
				assert.Equal(t, tt.expectedPoints[i], timePoint)
			}
		})
	}
}

func TestCalculateMissingBackupsPerDay(t *testing.T) {
	tests := []struct {
		name              string
		scheduleAtMinute  int
		expectedBackups   []time.Time
		actualBackups     []*entity.Backup
		expected          int
		missingTimePoints []time.Time
	}{
		{
			name:             "missing backups",
			scheduleAtMinute: 1 * 60,
			expectedBackups: []time.Time{
				time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 2, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 3, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 4, 1, 0, 0, 0, time.UTC),
			},
			actualBackups: []*entity.Backup{
				createTestBackup(time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC)), // Matches day 1, after schedule time
				createTestBackup(time.Date(2025, 1, 2, 1, 0, 0, 0, time.UTC)),  // Matches day 2, equal schedule time
				createTestBackup(
					time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC),
				), // Matches day 3, before schedule time
			},
			expected: 1, // Missing day 4
			missingTimePoints: []time.Time{
				time.Date(2025, 1, 4, 1, 0, 0, 0, time.UTC),
			},
		},
		{
			name:             "no missing backups",
			scheduleAtMinute: 1 * 60,
			expectedBackups: []time.Time{
				time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 2, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 3, 1, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 4, 1, 0, 0, 0, time.UTC),
			},
			actualBackups: []*entity.Backup{
				createTestBackup(time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC)), // Matches day 1, after schedule time
				createTestBackup(time.Date(2025, 1, 2, 1, 0, 0, 0, time.UTC)),  // Matches day 2, equal schedule time
				createTestBackup(time.Date(2025, 1, 3, 1, 0, 0, 0, time.UTC)),  // Matches day 3, equal schedule time
				createTestBackup(
					time.Date(2025, 1, 4, 0, 0, 0, 0, time.UTC),
				), // Matches day 3, before schedule time
				createTestBackup(time.Date(2025, 1, 4, 1, 0, 0, 0, time.UTC)), // Matches day 4, equal schedule time
			},
			expected:          0,
			missingTimePoints: []time.Time{},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := calculateMissingBackupsPerDay(tt.scheduleAtMinute, tt.expectedBackups, tt.actualBackups)
			assert.Len(t, result, tt.expected)
			assert.Equal(t, tt.missingTimePoints, result)
		})
	}
}

func TestCalculateMissingBackupsPerHour(t *testing.T) {
	tests := []struct {
		name              string
		scheduleAtMinute  int
		expectedBackups   []time.Time
		actualBackups     []*entity.Backup
		expected          int
		missingTimePoints []time.Time
	}{
		{
			name:             "missing backups",
			scheduleAtMinute: 30,
			expectedBackups: []time.Time{
				time.Date(2025, 1, 1, 0, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 3, 30, 0, 0, time.UTC),
			},
			actualBackups: []*entity.Backup{
				createTestBackup(time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC)), // Matches hour 1, equal schedule time
				createTestBackup(time.Date(2025, 1, 1, 3, 0, 0, 0, time.UTC)),  // Matches hour 2, before schedule time
			},
			expected: 2, // Missing hour 0 and hour 3
			missingTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 3, 30, 0, 0, time.UTC),
			},
		},
		{
			name:             "no missing backups",
			scheduleAtMinute: 30,
			expectedBackups: []time.Time{
				time.Date(2025, 1, 1, 0, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 2, 30, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 3, 30, 0, 0, time.UTC),
			},
			actualBackups: []*entity.Backup{
				createTestBackup(time.Date(2025, 1, 1, 0, 45, 0, 0, time.UTC)), // Matches hour 0, after schedule time
				createTestBackup(time.Date(2025, 1, 1, 1, 30, 0, 0, time.UTC)), // Matches hour 1, equal schedule time
				createTestBackup(time.Date(2025, 1, 1, 2, 45, 0, 0, time.UTC)), // Matches hour 2, after schedule time
				createTestBackup(time.Date(2025, 1, 1, 3, 0, 0, 0, time.UTC)),  // Matches hour 2, before schedule time
				createTestBackup(time.Date(2025, 1, 1, 3, 30, 0, 0, time.UTC)), // Matches hour 3, equal schedule time
			},
			expected:          0,
			missingTimePoints: []time.Time{},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := calculateMissingBackupsPerHour(tt.scheduleAtMinute, tt.expectedBackups, tt.actualBackups)
			assert.Len(t, result, tt.expected)
			assert.Equal(t, tt.missingTimePoints, result)
		})
	}
}

func TestCalculateMissingBackupsPerMinute(t *testing.T) {
	tests := []struct {
		name              string
		expectedBackups   []time.Time
		actualBackups     []*entity.Backup
		expected          int
		missingTimePoints []time.Time
	}{
		{
			name: "missing backups",
			expectedBackups: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
			actualBackups: []*entity.Backup{
				createTestBackup(time.Date(2025, 1, 1, 0, 0, 30, 0, time.UTC)), // Matches minute 0
			},
			expected: 1, // Missing minute 1
			missingTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
		},
		{
			name: "no missing backups",
			expectedBackups: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
			actualBackups: []*entity.Backup{
				createTestBackup(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)),
				createTestBackup(time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC)),
			},
			expected:          0,
			missingTimePoints: []time.Time{},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := calculateMissingBackupsPerMinute(tt.expectedBackups, tt.actualBackups)
			assert.Len(t, result, tt.expected)
			assert.Equal(t, tt.missingTimePoints, result)
		})
	}
}

func TestCalculateMissingTimePoints(t *testing.T) {
	tests := []struct {
		name               string
		expectedTimePoints []time.Time
		actualTimePoints   []time.Time
		expected           int
		missingTimePoints  []time.Time
	}{
		{
			name: "missing time points",
			expectedTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC),
			},
			actualTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), // Present
			},
			expected: 2, // Missing minutes 1 and 2
			missingTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC),
			},
		},
		{
			name: "all time points present",
			expectedTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
			actualTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
			expected:          0, // No missing time points
			missingTimePoints: []time.Time{},
		},
		{
			name: "no actual time points",
			expectedTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
			actualTimePoints: []time.Time{},
			expected:         2, // All expected time points are missing
			missingTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
		},
		{
			name: "out of range actual time points",
			expectedTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
			actualTimePoints: []time.Time{
				time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC), // Out of range
				time.Date(2025, 1, 1, 1, 0, 0, 0, time.UTC), // Out of range
			},
			expected: 2, // All expected time points are missing
			missingTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
		},
		{
			name: "duplicate actual time points",
			expectedTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
			actualTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), // Duplicate
			},
			expected: 1, // Only minute 1 is missing
			missingTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
			},
		},
		{
			name: "case with combination of all situations",
			expectedTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 3, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 4, 0, 0, time.UTC),
			},
			actualTimePoints: []time.Time{
				time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), // out of range
				time.Date(2025, 1, 1, 0, 1, 0, 0, time.UTC), // present
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC), // duplicate
				time.Date(2025, 1, 1, 0, 2, 0, 0, time.UTC), // duplicate
				time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), // out of range
			},
			expected: 3,
			missingTimePoints: []time.Time{
				time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 3, 0, 0, time.UTC),
				time.Date(2025, 1, 1, 0, 4, 0, 0, time.UTC),
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := calculateMissingTimePoints(tt.expectedTimePoints, tt.actualTimePoints)
			assert.Len(t, result, tt.expected)
			assert.Equal(t, tt.missingTimePoints, result)
		})
	}
}

func TestCheckAndCalculateBackTraceDuration(t *testing.T) {
	now := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
	loadScheduleChangeInterval := 5 * time.Minute
	onlyCheckMissingHoursAgo := 2
	maxPerDayMissingBackTraceDays := 14
	maxPerHourMissingBackTraceHours := 24 * 7
	maxPerMinuteMissingBackTraceHours := 12
	cfg := &config.BackupConfig{
		SyncConfig: config.SyncBackupsConfig{
			LoadScheduleChangeInterval: loadScheduleChangeInterval,
		},
		SyncMissingConfig: config.SyncMissingBackupsConfig{
			BackTraceEndHoursAgo:         onlyCheckMissingHoursAgo,
			BackTraceStartPerDayDays:     maxPerDayMissingBackTraceDays,
			BackTraceStartPerHourHours:   maxPerHourMissingBackTraceHours,
			BackTraceStartPerMinuteHours: maxPerMinuteMissingBackTraceHours,
		},
	}

	tests := []struct {
		name              string
		backupSetting     *entity.BackupSetting
		now               time.Time
		expectedNeedCheck bool
		expectedStart     *time.Time
		expectedEnd       *time.Time
	}{
		{
			name: "valid per-day backup setting, last schedule updated at is before the start of the back trace duration, and retention days is greater than the back trace duration",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					// make sure the last schedule updated at is before the start of the back trace duration
					LastScheduleUpdatedAt: now.Add(-time.Duration(maxPerDayMissingBackTraceDays+2) * 24 * time.Hour),
					Schedule: schema.AutoSchedule{
						Type: "per-day",
						// make sure the retention days is greater than the back trace duration
						RetentionDays: maxPerDayMissingBackTraceDays + 1,
					},
				},
			},
			now:               now,
			expectedNeedCheck: true,
			expectedStart: func() *time.Time {
				start := now.Add(-time.Duration(maxPerDayMissingBackTraceDays) * 24 * time.Hour)
				return &start
			}(),
			expectedEnd: func() *time.Time {
				end := now.Add(-time.Duration(onlyCheckMissingHoursAgo) * time.Hour)
				return &end
			}(),
		},
		{
			name: "valid per-hour backup setting, last schedule updated at is before the start of the back trace duration, and retention days is greater than the back trace duration",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					// make sure the last schedule updated at is before the start of the back trace duration
					LastScheduleUpdatedAt: now.Add(-time.Duration(maxPerHourMissingBackTraceHours+2) * time.Hour),
					Schedule: schema.AutoSchedule{
						Type: "per-hour",
						// make sure the retention days is greater than the back trace duration
						RetentionDays: maxPerHourMissingBackTraceHours/24 + 2,
					},
				},
			},
			now:               now,
			expectedNeedCheck: true,
			expectedStart: func() *time.Time {
				start := now.Add(-time.Duration(maxPerHourMissingBackTraceHours) * time.Hour)
				return &start
			}(),
			expectedEnd: func() *time.Time {
				end := now.Add(-time.Duration(onlyCheckMissingHoursAgo) * time.Hour)
				return &end
			}(),
		},
		{
			name: "valid per-minute backup setting, last schedule updated at is before the start of the back trace duration, and retention days is greater than the back trace duration",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					// make sure the last schedule updated at is before the start of the back trace duration
					LastScheduleUpdatedAt: now.Add(-time.Duration(maxPerMinuteMissingBackTraceHours+2) * time.Hour),
					Schedule: schema.AutoSchedule{
						Type: "per-minute",
						// make sure the retention days is greater than the back trace duration
						RetentionDays: maxPerMinuteMissingBackTraceHours/24 + 2,
					},
				},
			},
			now:               now,
			expectedNeedCheck: true,
			expectedStart: func() *time.Time {
				start := now.Add(-time.Duration(maxPerMinuteMissingBackTraceHours) * time.Hour)
				return &start
			}(),
			expectedEnd: func() *time.Time {
				end := now.Add(-time.Duration(onlyCheckMissingHoursAgo) * time.Hour)
				return &end
			}(),
		},
		{
			name: "schedule updated too recently",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					LastScheduleUpdatedAt: now.Add(-time.Duration(onlyCheckMissingHoursAgo) * time.Hour).
						Add(time.Hour),
					// after the end of the back trace duration
					Schedule: schema.AutoSchedule{
						Type:          "per-day",
						RetentionDays: 14,
					},
				},
			},
			now:               now,
			expectedNeedCheck: false,
			expectedStart:     nil,
			expectedEnd:       nil,
		},
		{
			name: "schedule updated after start and before end",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					LastScheduleUpdatedAt: now.Add(
						-time.Duration((onlyCheckMissingHoursAgo+maxPerDayMissingBackTraceDays*24)/2) * time.Hour,
					),
					Schedule: schema.AutoSchedule{
						Type: "per-day",
						// make sure the retention days is greater than the back trace duration
						RetentionDays: maxPerDayMissingBackTraceDays + 1,
					},
				},
			},
			now:               now,
			expectedNeedCheck: true,
			expectedStart: func() *time.Time {
				// start time is the last schedule updated at time
				t := now.Add(-time.Duration((onlyCheckMissingHoursAgo+maxPerDayMissingBackTraceDays*24)/2) * time.Hour).
					Add(loadScheduleChangeInterval)
				return &t
			}(),
			expectedEnd: func() *time.Time {
				end := now.Add(-time.Duration(onlyCheckMissingHoursAgo) * time.Hour)
				return &end
			}(),
		},
		{
			name: "short retention period",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					// make sure the last schedule updated at is before the start of the back trace duration
					LastScheduleUpdatedAt: now.Add(-time.Duration(maxPerDayMissingBackTraceDays+2) * 24 * time.Hour),
					Schedule: schema.AutoSchedule{
						Type: "per-day",
						// make sure the retention days is less than the back trace duration
						RetentionDays: maxPerDayMissingBackTraceDays - 1,
					},
				},
			},
			now:               now,
			expectedNeedCheck: true,
			expectedStart: func() *time.Time {
				start := now.Add(-time.Duration(maxPerDayMissingBackTraceDays-1) * 24 * time.Hour)
				return &start
			}(),
			expectedEnd: func() *time.Time {
				end := now.Add(-time.Duration(onlyCheckMissingHoursAgo) * time.Hour)
				return &end
			}(),
		},
		{
			name: "flexible schedule uses new retention fields when legacy retention is zero",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					LastScheduleUpdatedAt: now.Add(-time.Duration(maxPerHourMissingBackTraceHours+2) * time.Hour),
					Schedule: schema.AutoSchedule{
						Type:                      "per-hour",
						NonZeroClockRetentionDays: 3,
						ZeroClockRetentionDays:    3,
					},
				},
			},
			now:               now,
			expectedNeedCheck: true,
			expectedStart: func() *time.Time {
				start := now.Add(-3 * 24 * time.Hour)
				return &start
			}(),
			expectedEnd: func() *time.Time {
				end := now.Add(-time.Duration(onlyCheckMissingHoursAgo) * time.Hour)
				return &end
			}(),
		},
		{
			name: "standard schedule uses longest effective retention",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					LastScheduleUpdatedAt: now.Add(-time.Duration(maxPerHourMissingBackTraceHours+2) * time.Hour),
					Schedule: schema.AutoSchedule{
						Type:                      "per-hour",
						NonZeroClockRetentionDays: 7,
						ZeroClockRetentionDays:    33,
					},
				},
			},
			now:               now,
			expectedNeedCheck: true,
			expectedStart: func() *time.Time {
				start := now.Add(-time.Duration(maxPerHourMissingBackTraceHours) * time.Hour)
				return &start
			}(),
			expectedEnd: func() *time.Time {
				end := now.Add(-time.Duration(onlyCheckMissingHoursAgo) * time.Hour)
				return &end
			}(),
		},
		{
			name: "invalid schedule type",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					LastScheduleUpdatedAt: now.Add(-time.Duration(maxPerDayMissingBackTraceDays+2) * 24 * time.Hour),
					Schedule: schema.AutoSchedule{
						Type:          "invalid-type",
						RetentionDays: 14,
					},
				},
			},
			now:               now,
			expectedNeedCheck: false,
			expectedStart:     nil,
			expectedEnd:       nil,
		},
		{
			name: "start time after end time due to retention",
			backupSetting: &entity.BackupSetting{
				BackupSetting: &schema.BackupSetting{
					LastScheduleUpdatedAt: now.Add(-time.Duration(maxPerDayMissingBackTraceDays+2) * 24 * time.Hour),
					Schedule: schema.AutoSchedule{
						Type:          "per-day",
						RetentionDays: 0, // Very short retention
					},
				},
			},
			now:               now,
			expectedNeedCheck: false,
			expectedStart:     nil,
			expectedEnd:       nil,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			start, end, needCheck := checkAndCalculateBackTraceDuration(
				t.Context(),
				tt.backupSetting,
				tt.now,
				cfg,
				nil,
			)

			assert.Equal(t, tt.expectedNeedCheck, needCheck)

			if tt.expectedNeedCheck {
				assert.NotNil(t, start, "Start time should not be nil when needCheck is true")
				assert.NotNil(t, end, "End time should not be nil when needCheck is true")
				assert.True(t, start.Before(*end), "Start time should be before end time")

				// Verify the exact expected times
				if tt.expectedStart != nil {
					assert.Equal(t, *tt.expectedStart, *start, "Start time should match expected")
				}
				if tt.expectedEnd != nil {
					assert.Equal(t, *tt.expectedEnd, *end, "End time should match expected")
				}

				// Verify start time is in the past
				assert.True(t, start.Before(tt.now), "Start time should be before now")

				// Verify end time is close to now (within 2 hours tolerance for OnlyCheckMissingHoursAgo)
				assert.True(t, end.After(tt.now.Add(-3*time.Hour)), "End time should be close to now")
				assert.True(t, end.Before(tt.now), "End time should be before now")
			} else {
				assert.Nil(t, start, "Start time should be nil when needCheck is false")
				assert.Nil(t, end, "End time should be nil when needCheck is false")
			}
		})
	}
}

func TestPauseExclusionExtendsMissingBackupLookbackWithoutCreatingPauseSlots(t *testing.T) {
	now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
	pauseStart := now.Add(-48 * time.Hour)
	resumeEnd := now.Add(-3 * time.Hour)
	setting := &entity.BackupSetting{BackupSetting: &schema.BackupSetting{
		Mode:                  schema.BackupModeStandardBundle,
		LastScheduleUpdatedAt: now.Add(-10 * 24 * time.Hour),
		Schedule: schema.AutoSchedule{
			Type:          "per-minute",
			RetentionDays: 7,
		},
	}}
	cfg := &config.BackupConfig{
		SyncConfig: config.SyncBackupsConfig{
			LoadScheduleChangeInterval: 5 * time.Minute,
			TraceBackHours:             1,
		},
		SyncMissingConfig: config.SyncMissingBackupsConfig{
			BackTraceEndHoursAgo:         2,
			BackTraceStartPerMinuteHours: 12,
		},
	}
	exclusions := []brrepo.BackupReconciliationExclusion{{StartAt: pauseStart, EndAt: &resumeEnd}}

	start, end, needCheck := checkAndCalculateBackTraceDuration(
		t.Context(),
		setting,
		now,
		cfg,
		exclusions,
	)
	require.True(t, needCheck)
	require.Equal(t, pauseStart.Add(-2*time.Hour), *start)

	expected := calculateExpectedBackupsInDuration(setting, *start, *end)
	expected = excludeBackupTimePoints(expected, exclusions)
	require.Contains(t, expected, pauseStart.Add(-90*time.Minute), "unsynchronized pre-Pause slots must be reconciled")
	require.Contains(t, expected, resumeEnd.Add(time.Minute), "post-Resume slots must still be reconciled")
	for _, point := range expected {
		require.True(t, point.Before(pauseStart) || point.After(resumeEnd),
			"Pause interval must not produce an expected missing backup: %s", point)
	}
}

func TestEffectiveMissingBackupRetentionDays(t *testing.T) {
	tests := []struct {
		name     string
		schedule schema.AutoSchedule
		expected int
	}{
		{name: "legacy schedule", schedule: schema.AutoSchedule{RetentionDays: 14}, expected: 14},
		{name: "flexible schedule", schedule: schema.AutoSchedule{NonZeroClockRetentionDays: 20, ZeroClockRetentionDays: 20}, expected: 20},
		{name: "standard schedule", schedule: schema.AutoSchedule{NonZeroClockRetentionDays: 7, ZeroClockRetentionDays: 33}, expected: 33},
		{
			name:     "legacy value remains effective when longest",
			schedule: schema.AutoSchedule{RetentionDays: 30, NonZeroClockRetentionDays: 7, ZeroClockRetentionDays: 20},
			expected: 30,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.expected, effectiveMissingBackupRetentionDays(tt.schedule))
		})
	}
}

func TestResyncMissingBackupsResolveRegionFromSharedPool(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	sharedPoolId := uint64(629145666588770304)
	clusterId := uint64(10856902268765258922)
	regionId := uint64(42)
	slotTime := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC)
	backupTime := time.Date(2026, 7, 20, 9, 1, 15, 0, time.UTC)

	bs := &entity.BackupSetting{
		BackupSetting: &schema.BackupSetting{
			ClusterId:    clusterId,
			SharedPoolId: sharedPoolId,
			// This is the TiDB Cloud region enum name stored by create_backup_setting,
			// not the regions.name/logical name such as ap-southeast-1-ng-ali.
			RegionId:      "ALICLOUD_AP_SOUTHEAST_1",
			CloudProvider: "ALICLOUD",
			ServicePlan:   "Premium",
			Schedule: schema.AutoSchedule{
				Type: "per-hour",
				At:   0,
			},
		},
	}

	resourcePoolRepo := mockresourcepoolrepo.NewMockRepo(ctrl)
	regionRepo := mockregionrepo.NewMockRepo(ctrl)
	regionalserverCli := mockregionalserver.NewMockClient(ctrl)
	backupRepo := mockbrrepo.NewMockBackupRepo(ctrl)
	backupSettingRepo := mockbrrepo.NewMockBackupSettingRepo(ctrl)

	sharedPool := &entity.ResourcePool{RegionId: regionId}
	region := &entity.Region{}
	resourcePoolRepo.EXPECT().Load(t.Context(), sharedPoolId, false).Return(sharedPool, nil)
	regionRepo.EXPECT().Get(t.Context(), regionId).Return(region, nil)
	regionalserverCli.EXPECT().ListLightweightBackups(
		t.Context(),
		region,
		&clusterngv1beta.ListLightweightBackupsRequest{
			SharedPoolId: "629145666588770304",
			FromPrefix:   "20260720/0858",
			LoadDetails:  true,
			MaxCount:     10,
		},
	).Return(&clusterngv1beta.ListLightweightBackupsResponse{
		Backups: []*clusterngv1beta.LightweightBackup{
			{
				Id:         1784538165,
				Name:       "20260720/090115.meta",
				BackupTs:   467805941393260565,
				BackupTime: backupTime.Format("2006-01-02T15:04:05.000Z"),
				ClusterBackupInfo: []*clusterngv1beta.ClusterBackupInfo{
					{
						ClusterId:       "10856902268765258922",
						BackupSizeBytes: 1024,
					},
				},
			},
		},
	}, nil)
	backupRepo.EXPECT().Create(t.Context(), gomock.Any()).DoAndReturn(
		func(_ context.Context, record *entity.Backup) error {
			require.Equal(t, clusterId, record.ClusterId)
			require.Equal(t, sharedPoolId, record.SharedPoolId)
			require.Equal(t, schema.BackupStateSuccess, record.State)
			require.Equal(t, backupTime, record.BackupTime)
			require.Equal(t, "ALICLOUD_AP_SOUTHEAST_1", record.RegionId)
			require.Equal(t, "20260720/090115.meta", record.KernelBackupName)
			return nil
		},
	)
	backupSettingRepo.EXPECT().GetActiveForUpdate(t.Context(), clusterId).Return(bs, nil)

	w := &worker{
		resourcePoolRepo:  resourcePoolRepo,
		regionRepo:        regionRepo,
		regionalserverCli: regionalserverCli,
		backupSettingRepo: backupSettingRepo,
		backupRepo:        backupRepo,
		transaction:       passthroughPauseFenceTransactioner{},
		backupSyncSlots:   &backupSyncSlots{lock: &sync.Mutex{}},
	}

	err := w.resyncMissingBackups(t.Context(), bs, []time.Time{slotTime})
	require.NoError(t, err)
}

func TestResyncMissingBackupsStopsWhenSettingDeletedBeforeWrite(t *testing.T) {
	ctrl := gomock.NewController(t)
	const (
		clusterID    = uint64(101)
		sharedPoolID = uint64(202)
		regionID     = uint64(303)
	)
	slotTime := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC)
	backupTime := slotTime.Add(time.Minute)
	setting := &entity.BackupSetting{BackupSetting: &schema.BackupSetting{
		ClusterId:    clusterID,
		SharedPoolId: sharedPoolID,
		RegionId:     "AWS_US_WEST_2",
		State:        schema.BackupSettingStateCreated,
		Schedule:     schema.AutoSchedule{Type: "per-hour"},
	}}

	resourcePoolRepo := mockresourcepoolrepo.NewMockRepo(ctrl)
	regionRepo := mockregionrepo.NewMockRepo(ctrl)
	regionalserverCli := mockregionalserver.NewMockClient(ctrl)
	backupSettingRepo := mockbrrepo.NewMockBackupSettingRepo(ctrl)
	backupRepo := mockbrrepo.NewMockBackupRepo(ctrl)
	region := &entity.Region{}

	resourcePoolRepo.EXPECT().Load(t.Context(), sharedPoolID, false).Return(&entity.ResourcePool{RegionId: regionID}, nil)
	regionRepo.EXPECT().Get(t.Context(), regionID).Return(region, nil)
	regionalserverCli.EXPECT().ListLightweightBackups(t.Context(), region, gomock.Any()).Return(
		&clusterngv1beta.ListLightweightBackupsResponse{Backups: []*clusterngv1beta.LightweightBackup{{
			Id:         1,
			Name:       "20260720/090100.meta",
			BackupTime: backupTime.Format("2006-01-02T15:04:05.000Z"),
			ClusterBackupInfo: []*clusterngv1beta.ClusterBackupInfo{{
				ClusterId:       "101",
				BackupSizeBytes: 1,
			}},
		}}}, nil,
	)
	backupSettingRepo.EXPECT().GetActiveForUpdate(t.Context(), clusterID).Return(
		nil,
		bizerr.New(bizerr.ErrCodeRecordNotFound, "backup setting not found"),
	)

	w := &worker{
		resourcePoolRepo:  resourcePoolRepo,
		regionRepo:        regionRepo,
		regionalserverCli: regionalserverCli,
		backupSettingRepo: backupSettingRepo,
		backupRepo:        backupRepo,
		transaction:       passthroughPauseFenceTransactioner{},
		backupSyncSlots: &backupSyncSlots{
			lock:           &sync.Mutex{},
			clusterSlots:   map[uint64][]int{clusterID: nil},
			backupSettings: map[uint64]*entity.BackupSetting{clusterID: setting},
		},
	}

	require.NoError(t, w.resyncMissingBackups(t.Context(), setting, []time.Time{slotTime}))
	require.Empty(t, w.backupSyncSlots.copyBackupSettings())
}

func createTestBackup(createTime time.Time) *entity.Backup {
	return &entity.Backup{
		Backup: &schema.Backup{
			BackupTime:  createTime,
			TriggerType: schema.BackupTriggerTypeAuto,
		},
	}
}
