package impl

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/dataflow-service/app/repo/entity"
	repo_mock "github.com/tidbcloud/dataflow-service/app/repo/mock"
	grpcli_mock "github.com/tidbcloud/dataflow-service/pkg/grpc/grpcli/mock"
	infraapi_mock "github.com/tidbcloud/dataflow-service/pkg/infra-api/mock"
	cloudv1 "github.com/tidbcloud/infra-api/api/v1"
	"github.com/tidbcloud/pkgv2/bizerr"
	dedicatedv1 "github.com/tidbcloud/tidb-management-service/api/spec/global/dedicated/v1"
	mgmt "github.com/tidbcloud/tidb-management-service/api/spec/tidb_mgmt_service/v1"
)

// Each test creates fresh mocks so failed expectations from one test don't
// pollute the next. We deliberately do not share state via a suite here.

const (
	testReconcileBackupID  uint64 = 4242
	testReconcileClusterID uint64 = 9999
)

func newReconcilerForTest(t *testing.T) (
	*backupHealthCheckerImpl,
	*repo_mock.BackupRepo,
	*grpcli_mock.MgmtSvcGrpcClient,
	*infraapi_mock.BackupFacade,
) {
	t.Helper()
	backupRepo := repo_mock.NewBackupRepo(t)
	mgmtCli := grpcli_mock.NewMgmtSvcGrpcClient(t)
	facade := infraapi_mock.NewBackupFacade(t)
	w := &backupHealthCheckerImpl{
		backupRepo:           backupRepo,
		mgmtCli:              mgmtCli,
		backupRegionalFacade: facade,
	}
	return w, backupRepo, mgmtCli, facade
}

func newRunningBackup() entity.Backup {
	b := entity.Backup{
		Status: entity.BackupStatusRunning,
		// OldBackupName makes CRName() use the early-return path so the test
		// fixture doesn't have to wire a non-nil BackupSchedule.
		OldBackupName: "ab-test-backup",
	}
	b.ID = entity.ID(testReconcileBackupID)
	b.ClusterID = entity.ID(testReconcileClusterID)
	b.TenantID = entity.ID(1)
	b.ProjectID = entity.ID(2)
	// CreatedAt well past the cutoff so the candidate query would surface it.
	b.CreatedAt = time.Now().Add(-2 * time.Hour)
	return b
}

func newAliveCluster() *mgmt.Cluster {
	return &mgmt.Cluster{
		ClusterId:     testReconcileClusterID,
		CloudProvider: "aws",
		Region:        "us-west-2",
		ClusterInfo: &mgmt.Cluster_DedicatedCluster{
			DedicatedCluster: &dedicatedv1.Cluster{
				State: dedicatedv1.Cluster_AVAILABLE,
			},
		},
	}
}

func newCRWithPhase(phase cloudv1.BackupConditionType) *cloudv1.Backup {
	return &cloudv1.Backup{
		Status: cloudv1.BackupStatus{
			Phase:      phase,
			BackupSize: 12345,
			BackupTS:   "456",
		},
	}
}

// expectListReturns wires a generic List() expectation that returns `out`
// regardless of the filter ops the production code passed. We don't assert on
// the queryset construction itself — that's tested by the auto-generated repo.
func expectListReturns(repo *repo_mock.BackupRepo, out []entity.Backup) {
	repo.On("List", mock.Anything, mock.Anything, mock.Anything, mock.Anything,
		mock.Anything, mock.Anything).Return(out, bizerr.BizError(nil)).Maybe()
	repo.On("List", mock.Anything, mock.Anything).Return(out, bizerr.BizError(nil)).Maybe()
}

func TestReconcile_NoStuckBackups_NoOp(t *testing.T) {
	w, repo, _, _ := newReconcilerForTest(t)
	expectListReturns(repo, nil)

	require.NoError(t, w.ReconcileStuckRunningBackups(context.Background()))
}

func TestReconcile_DataplaneFailed_MarksMgmtFailed(t *testing.T) {
	w, repo, mgmtCli, facade := newReconcilerForTest(t)

	stuck := newRunningBackup()
	expectListReturns(repo, []entity.Backup{stuck})
	mgmtCli.On("GetAnyDedicatedClusterBasicByID", mock.Anything, testReconcileClusterID).
		Return(newAliveCluster(), bizerr.BizError(nil)).Once()
	facade.On("GetBackup", mock.Anything, "aws", "us-west-2",
		mock.Anything, mock.Anything, testReconcileClusterID, mock.Anything).
		Return(newCRWithPhase(cloudv1.BackupFailed), nil).Once()

	var saved *entity.Backup
	repo.On("Save", mock.Anything, mock.MatchedBy(func(b *entity.Backup) bool {
		saved = b
		return true
	})).Return(bizerr.BizError(nil)).Once()

	require.NoError(t, w.ReconcileStuckRunningBackups(context.Background()))
	require.NotNil(t, saved)
	require.Equal(t, entity.BackupStatusFailed, saved.Status)
	require.NotNil(t, saved.FinishedAt)
	require.Equal(t, 12345, saved.BackupSize)
}

func TestReconcile_DataplaneComplete_MarksMgmtSuccess(t *testing.T) {
	w, repo, mgmtCli, facade := newReconcilerForTest(t)

	stuck := newRunningBackup()
	expectListReturns(repo, []entity.Backup{stuck})
	mgmtCli.On("GetAnyDedicatedClusterBasicByID", mock.Anything, testReconcileClusterID).
		Return(newAliveCluster(), bizerr.BizError(nil)).Once()
	facade.On("GetBackup", mock.Anything, mock.Anything, mock.Anything,
		mock.Anything, mock.Anything, mock.Anything, mock.Anything).
		Return(newCRWithPhase(cloudv1.BackupComplete), nil).Once()

	var saved *entity.Backup
	repo.On("Save", mock.Anything, mock.MatchedBy(func(b *entity.Backup) bool {
		saved = b
		return true
	})).Return(bizerr.BizError(nil)).Once()

	require.NoError(t, w.ReconcileStuckRunningBackups(context.Background()))
	require.Equal(t, entity.BackupStatusSuccess, saved.Status)
	require.Equal(t, "456", saved.StartTimeStamp)
}

func TestReconcile_DataplaneStillRunning_NoSave(t *testing.T) {
	w, repo, mgmtCli, facade := newReconcilerForTest(t)

	expectListReturns(repo, []entity.Backup{newRunningBackup()})
	mgmtCli.On("GetAnyDedicatedClusterBasicByID", mock.Anything, testReconcileClusterID).
		Return(newAliveCluster(), bizerr.BizError(nil)).Once()
	facade.On("GetBackup", mock.Anything, mock.Anything, mock.Anything,
		mock.Anything, mock.Anything, mock.Anything, mock.Anything).
		Return(newCRWithPhase(cloudv1.BackupRunning), nil).Once()

	require.NoError(t, w.ReconcileStuckRunningBackups(context.Background()))
	repo.AssertNotCalled(t, "Save", mock.Anything, mock.Anything)
}

func TestReconcile_DataplanePhaseEmpty_NoSave(t *testing.T) {
	w, repo, mgmtCli, facade := newReconcilerForTest(t)

	expectListReturns(repo, []entity.Backup{newRunningBackup()})
	mgmtCli.On("GetAnyDedicatedClusterBasicByID", mock.Anything, testReconcileClusterID).
		Return(newAliveCluster(), bizerr.BizError(nil)).Once()
	facade.On("GetBackup", mock.Anything, mock.Anything, mock.Anything,
		mock.Anything, mock.Anything, mock.Anything, mock.Anything).
		Return(newCRWithPhase(""), nil).Once()

	require.NoError(t, w.ReconcileStuckRunningBackups(context.Background()))
	repo.AssertNotCalled(t, "Save", mock.Anything, mock.Anything)
}

func TestReconcile_ClusterDeleted_MarksTerminated(t *testing.T) {
	w, repo, mgmtCli, facade := newReconcilerForTest(t)

	expectListReturns(repo, []entity.Backup{newRunningBackup()})

	deletedCluster := &mgmt.Cluster{
		ClusterId: testReconcileClusterID,
		ClusterInfo: &mgmt.Cluster_DedicatedCluster{
			DedicatedCluster: &dedicatedv1.Cluster{
				State: dedicatedv1.Cluster_DELETED,
			},
		},
	}

	mgmtCli.On("GetAnyDedicatedClusterBasicByID", mock.Anything, testReconcileClusterID).
		Return(deletedCluster, bizerr.BizError(nil)).Once()

	var saved *entity.Backup
	repo.On("Save", mock.Anything, mock.MatchedBy(func(b *entity.Backup) bool {
		saved = b
		return true
	})).Return(bizerr.BizError(nil)).Once()

	require.NoError(t, w.ReconcileStuckRunningBackups(context.Background()))
	require.Equal(t, entity.BackupStatusTerminated, saved.Status)
	require.NotEmpty(t, saved.ErrMsg)

	// Cluster was deleted before we ever called the regional API — it must not
	// have been hit, otherwise we'd waste calls on dead infra.
	facade.AssertNotCalled(t, "GetBackup", mock.Anything,
		mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

func TestReconcile_GetClusterError_SkipsAndContinues(t *testing.T) {
	w, repo, mgmtCli, facade := newReconcilerForTest(t)

	expectListReturns(repo, []entity.Backup{newRunningBackup()})
	mgmtCli.On("GetAnyDedicatedClusterBasicByID", mock.Anything, testReconcileClusterID).
		Return(nil, bizerr.New(0, "transient")).Once()

	require.NoError(t, w.ReconcileStuckRunningBackups(context.Background()))
	repo.AssertNotCalled(t, "Save", mock.Anything, mock.Anything)
	facade.AssertNotCalled(t, "GetBackup", mock.Anything,
		mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

func TestReconcile_GetBackupError_SkipsThisBackupOnly(t *testing.T) {
	w, repo, mgmtCli, facade := newReconcilerForTest(t)

	expectListReturns(repo, []entity.Backup{newRunningBackup()})
	mgmtCli.On("GetAnyDedicatedClusterBasicByID", mock.Anything, testReconcileClusterID).
		Return(newAliveCluster(), bizerr.BizError(nil)).Once()
	facade.On("GetBackup", mock.Anything, mock.Anything, mock.Anything,
		mock.Anything, mock.Anything, mock.Anything, mock.Anything).
		Return(nil, errors.New("regional 503")).Once()

	require.NoError(t, w.ReconcileStuckRunningBackups(context.Background()))
	repo.AssertNotCalled(t, "Save", mock.Anything, mock.Anything)
}
