package cron

import (
	"context"
	"errors"
	"testing"

	meteringsdkcommon "github.com/pingcap/metering_sdk/common"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/cluster-service-ng/domain/entity"
	"github.com/tidbcloud/cluster-service-ng/pkg/storage/mysql/schema"
	repomocks "github.com/tidbcloud/cluster-service-ng/pkg/storage/repo/mocks"
)

type fakeMeteringWriter struct {
	calls []*meteringsdkcommon.MeteringData
	err   error
}

func (f *fakeMeteringWriter) Write(_ context.Context, data any) error {
	if f.err != nil {
		return f.err
	}
	f.calls = append(f.calls, data.(*meteringsdkcommon.MeteringData))
	return nil
}

func TestManualBackupMeteringJob_Execute(t *testing.T) {
	unlockFn := func() error { return nil }

	tests := []struct {
		name             string
		setupMocks       func(*repomocks.ManualBackupRepo, *repomocks.LockRepo)
		expectedPayloads map[string][]map[string]any
		expectedCalls    int
		expectError      bool
		assertAfter      func(*testing.T, *repomocks.ManualBackupRepo, *fakeMeteringWriter)
	}{
		{
			name: "no backups",
			setupMocks: func(manualBackupRepo *repomocks.ManualBackupRepo, lockRepo *repomocks.LockRepo) {
				lockRepo.EXPECT().Lock(mock.Anything, mock.MatchedBy(func(lock *entity.Lock) bool {
					return lock.Key == "manual_backup_metering" && lock.TimeoutSecs == 10
				})).Return(unlockFn, nil).Once()
				manualBackupRepo.EXPECT().ListUndeletedSucceeded(mock.Anything).Return([]*entity.ManualBackup{}, nil).Once()
			},
			expectedCalls:    0,
			expectedPayloads: map[string][]map[string]any{},
		},
		{
			name: "single backup single pool",
			setupMocks: func(manualBackupRepo *repomocks.ManualBackupRepo, lockRepo *repomocks.LockRepo) {
				lockRepo.EXPECT().Lock(mock.Anything, mock.MatchedBy(func(lock *entity.Lock) bool {
					return lock.Key == "manual_backup_metering" && lock.TimeoutSecs == 10
				})).Return(unlockFn, nil).Once()
				manualBackupRepo.EXPECT().ListUndeletedSucceeded(mock.Anything).Return([]*entity.ManualBackup{
					makeBackup("backup-1", "cluster-1", "pool-1", 5368709120, 2147483648),
				}, nil).Once()
			},
			expectedCalls: 1,
			expectedPayloads: map[string][]map[string]any{
				"pool-1": {
					expectedMeteringRow("cluster-1", "backup-1", 2),
				},
			},
		},
		{
			name: "multiple backups multiple pools",
			setupMocks: func(manualBackupRepo *repomocks.ManualBackupRepo, lockRepo *repomocks.LockRepo) {
				lockRepo.EXPECT().Lock(mock.Anything, mock.MatchedBy(func(lock *entity.Lock) bool {
					return lock.Key == "manual_backup_metering" && lock.TimeoutSecs == 10
				})).Return(unlockFn, nil).Once()
				manualBackupRepo.EXPECT().ListUndeletedSucceeded(mock.Anything).Return([]*entity.ManualBackup{
					makeBackup("backup-1", "cluster-1", "pool-1", 5368709120, 2147483648),
					makeBackup("backup-2", "cluster-2", "pool-2", 3221225472, 1073741824),
					makeBackup("backup-3", "cluster-3", "pool-1", 7516192768, 0),
				}, nil).Once()
			},
			expectedCalls: 2,
			expectedPayloads: map[string][]map[string]any{
				"pool-1": {
					expectedMeteringRow("cluster-1", "backup-1", 2),
					expectedMeteringRow("cluster-3", "backup-3", 0),
				},
				"pool-2": {
					expectedMeteringRow("cluster-2", "backup-2", 1),
				},
			},
		},
		{
			name: "byte rounding",
			setupMocks: func(manualBackupRepo *repomocks.ManualBackupRepo, lockRepo *repomocks.LockRepo) {
				lockRepo.EXPECT().Lock(mock.Anything, mock.MatchedBy(func(lock *entity.Lock) bool {
					return lock.Key == "manual_backup_metering" && lock.TimeoutSecs == 10
				})).Return(unlockFn, nil).Once()
				manualBackupRepo.EXPECT().ListUndeletedSucceeded(mock.Anything).Return([]*entity.ManualBackup{
					makeBackup("backup-1", "cluster-1", "pool-1", 1, 1),
				}, nil).Once()
			},
			expectedCalls: 1,
			expectedPayloads: map[string][]map[string]any{
				"pool-1": {
					expectedMeteringRow("cluster-1", "backup-1", 1),
				},
			},
		},
		{
			name: "lock failure",
			setupMocks: func(manualBackupRepo *repomocks.ManualBackupRepo, lockRepo *repomocks.LockRepo) {
				lockRepo.EXPECT().Lock(mock.Anything, mock.MatchedBy(func(lock *entity.Lock) bool {
					return lock.Key == "manual_backup_metering" && lock.TimeoutSecs == 10
				})).Return(nil, errors.New("lock failed")).Once()
			},
			expectedCalls:    0,
			expectedPayloads: map[string][]map[string]any{},
			assertAfter: func(t *testing.T, manualBackupRepo *repomocks.ManualBackupRepo, _ *fakeMeteringWriter) {
				t.Helper()
				manualBackupRepo.AssertNotCalled(t, "ListUndeletedSucceeded", mock.Anything)
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			manualBackupRepo := repomocks.NewManualBackupRepo(t)
			lockRepo := repomocks.NewLockRepo(t)
			writer := &fakeMeteringWriter{}

			tt.setupMocks(manualBackupRepo, lockRepo)

			job := NewManualBackupMeteringJob(manualBackupRepo, lockRepo, writer, "0 * * * *")
			err := job.Execute(context.Background())

			if tt.expectError {
				require.Error(t, err)
			} else {
				require.NoError(t, err)
			}

			assert.Len(t, writer.calls, tt.expectedCalls)
			assertMeteringPayloads(t, writer.calls, tt.expectedPayloads)

			if tt.assertAfter != nil {
				tt.assertAfter(t, manualBackupRepo, writer)
			}
		})
	}
}

func TestBytesToGB(t *testing.T) {
	tests := []struct {
		name     string
		bytes    uint64
		expected uint64
	}{
		{name: "zero", bytes: 0, expected: 0},
		{name: "one byte rounds up", bytes: 1, expected: 1},
		{name: "exactly one gb", bytes: 1073741824, expected: 1},
		{name: "one byte over one gb", bytes: 1073741825, expected: 2},
		{name: "exactly five gb", bytes: 5368709120, expected: 5},
		{name: "exactly two gb", bytes: 2147483648, expected: 2},
	}

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

func assertMeteringPayloads(t *testing.T, calls []*meteringsdkcommon.MeteringData, expectedPayloads map[string][]map[string]any) {
	t.Helper()
	if !assert.Len(t, calls, len(expectedPayloads)) {
		return
	}

	actualByPool := make(map[string]*meteringsdkcommon.MeteringData, len(calls))
	for _, call := range calls {
		actualByPool[call.SharedPoolID] = call
	}

	if !assert.Len(t, actualByPool, len(expectedPayloads)) {
		return
	}

	for sharedPoolID, expectedRows := range expectedPayloads {
		call, ok := actualByPool[sharedPoolID]
		if !assert.True(t, ok, "missing payload for shared pool %s", sharedPoolID) {
			continue
		}

		assert.Equal(t, "manual-backup", call.Category)
		assert.Equal(t, sharedPoolID, call.SharedPoolID)
		assert.NotEmpty(t, call.SelfID)
		assert.NotContains(t, call.SelfID, "-")
		assert.NotZero(t, call.Timestamp)
		assert.EqualValues(t, 0, call.Timestamp%60)
		assert.Equal(t, expectedRows, call.Data)
	}
}

func expectedMeteringRow(clusterID, backupID string, backupSizeGB uint64) map[string]any {
	return map[string]any{
		"cluster_id":     clusterID,
		"backup_id":      backupID,
		"backup_size_gb": &meteringsdkcommon.MeteringValue{Value: backupSizeGB, Unit: "GB"},
	}
}

func makeBackup(backupID, clusterID, sharedPoolID string, backupSize, restoreSize uint64) *entity.ManualBackup {
	return &entity.ManualBackup{
		ManualBackup: schema.ManualBackup{
			BackupID:     backupID,
			ClusterID:    clusterID,
			SharedPoolID: sharedPoolID,
			BackupSize:   backupSize,
			RestoreSize:  restoreSize,
		},
	}
}
