package brguardian

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

	"github.com/go-logr/logr"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"

	cloudv1 "github.com/tidbcloud/infra-api/api/v1"
	"github.com/tidbcloud/infra-provider/pkg/consts"
	"github.com/tidbcloud/infra-provider/pkg/deploy"
)

const (
	testChildFinalizer   = "backup.cloud.pingcap.com/finalizer"
	testClusterFinalizer = "backup.cloud.pingcap.com/backup-1"
)

type trackingClient struct {
	client.Client
	updates             []string
	conflictClusterOnce bool
	failClusterUpdate   error
}

func (c *trackingClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
	switch obj.(type) {
	case *cloudv1.Cluster:
		c.updates = append(c.updates, "cluster")
		if c.conflictClusterOnce {
			c.conflictClusterOnce = false
			return apierrors.NewConflict(
				schema.GroupResource{Group: "cloud.pingcap.com", Resource: "clusters"},
				obj.GetName(),
				errors.New("conflict"),
			)
		}
		if c.failClusterUpdate != nil {
			return c.failClusterUpdate
		}
	case *cloudv1.Backup:
		c.updates = append(c.updates, "backup")
	}
	return c.Client.Update(ctx, obj, opts...)
}

func guardianTestScheme(t *testing.T) *runtime.Scheme {
	t.Helper()
	scheme := runtime.NewScheme()
	require.NoError(t, cloudv1.AddToScheme(scheme))
	return scheme
}

func TestCreatePersistsChildBeforeCluster(t *testing.T) {
	ctx := context.Background()
	backup := &cloudv1.Backup{
		ObjectMeta: metav1.ObjectMeta{Name: "backup-1", Namespace: "ns-1"},
	}
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{Name: "cluster-1", Namespace: "ns-1"},
	}
	base := fake.NewClientBuilder().
		WithScheme(guardianTestScheme(t)).
		WithObjects(backup, cluster).
		Build()
	local := &trackingClient{Client: base}

	result := New(local, backup, cluster, testChildFinalizer, testClusterFinalizer).Create(ctx)
	require.True(t, result.IsDoneState(), "create failed: %v", result.Error())
	assert.Equal(t, []string{"backup", "cluster"}, local.updates)

	gotBackup := &cloudv1.Backup{}
	require.NoError(t, local.Get(ctx, client.ObjectKeyFromObject(backup), gotBackup))
	assert.Contains(t, gotBackup.Finalizers, testChildFinalizer)

	gotCluster := &cloudv1.Cluster{}
	require.NoError(t, local.Get(ctx, client.ObjectKeyFromObject(cluster), gotCluster))
	assert.Contains(t, gotCluster.Finalizers, testClusterFinalizer)

	result = New(local, backup, cluster, testChildFinalizer, testClusterFinalizer).Create(ctx)
	require.True(t, result.IsDoneState(), "idempotent create failed: %v", result.Error())
	assert.Equal(t, []string{"backup", "cluster"}, local.updates)
}

func TestCreateReturnsPanicOnClusterConflict(t *testing.T) {
	ctx := context.Background()
	backup := &cloudv1.Backup{
		ObjectMeta: metav1.ObjectMeta{Name: "backup-1", Namespace: "ns-1"},
	}
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{Name: "cluster-1", Namespace: "ns-1"},
	}
	base := fake.NewClientBuilder().
		WithScheme(guardianTestScheme(t)).
		WithObjects(backup, cluster).
		Build()
	local := &trackingClient{Client: base, conflictClusterOnce: true}

	result := New(local, backup, cluster, testChildFinalizer, testClusterFinalizer).Create(ctx)
	assert.False(t, result.IsDoneState())
	assert.Error(t, result.Error())
	assert.Equal(t, []string{"backup", "cluster"}, local.updates)

	gotBackup := &cloudv1.Backup{}
	require.NoError(t, local.Get(ctx, client.ObjectKeyFromObject(backup), gotBackup))
	assert.Contains(t, gotBackup.Finalizers, testChildFinalizer)
}

func TestDeleteRequiresCheckpointAndReleasesClusterFirst(t *testing.T) {
	ctx := context.Background()
	backup := &cloudv1.Backup{
		ObjectMeta: metav1.ObjectMeta{
			Name:       "backup-1",
			Namespace:  "ns-1",
			Finalizers: []string{testChildFinalizer},
		},
	}
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Name:       "cluster-1",
			Namespace:  "ns-1",
			Finalizers: []string{testClusterFinalizer},
		},
	}
	base := fake.NewClientBuilder().
		WithScheme(guardianTestScheme(t)).
		WithObjects(backup, cluster).
		Build()
	local := &trackingClient{Client: base}
	guardian := New(local, backup, cluster, testChildFinalizer, testClusterFinalizer)

	result := guardian.Delete(ctx)
	require.True(t, result.IsDoneState())
	assert.Contains(t, backup.Finalizers, testChildFinalizer)
	assert.Empty(t, local.updates)

	require.True(t, MarkDestroyFinished(backup, time.Date(2026, 7, 24, 1, 2, 3, 0, time.FixedZone("test", 8*60*60))))
	result = guardian.Delete(ctx)
	require.True(t, result.IsDoneState(), "delete failed: %v", result.Error())
	assert.Equal(t, []string{"cluster"}, local.updates)
	assert.NotContains(t, backup.Finalizers, testChildFinalizer)

	gotCluster := &cloudv1.Cluster{}
	require.NoError(t, local.Get(ctx, client.ObjectKeyFromObject(cluster), gotCluster))
	assert.NotContains(t, gotCluster.Finalizers, testClusterFinalizer)

	require.NoError(t, local.Update(ctx, backup))
	gotBackup := &cloudv1.Backup{}
	require.NoError(t, local.Get(ctx, client.ObjectKeyFromObject(backup), gotBackup))
	assert.NotContains(t, gotBackup.Finalizers, testChildFinalizer)
}

func TestDeleteRetainsChildFinalizerWhenClusterUpdateFails(t *testing.T) {
	ctx := context.Background()
	backup := &cloudv1.Backup{
		ObjectMeta: metav1.ObjectMeta{
			Name:        "backup-1",
			Namespace:   "ns-1",
			Finalizers:  []string{testChildFinalizer},
			Annotations: map[string]string{consts.DestroyFinishedAtAnnotation: "2026-07-24T00:00:00Z"},
		},
	}
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Name:       "cluster-1",
			Namespace:  "ns-1",
			Finalizers: []string{testClusterFinalizer},
		},
	}
	base := fake.NewClientBuilder().
		WithScheme(guardianTestScheme(t)).
		WithObjects(backup, cluster).
		Build()
	local := &trackingClient{Client: base, failClusterUpdate: errors.New("update failed")}

	result := New(local, backup, cluster, testChildFinalizer, testClusterFinalizer).Delete(ctx)
	assert.False(t, result.IsDoneState())
	assert.Error(t, result.Error())
	assert.Contains(t, backup.Finalizers, testChildFinalizer)
}

func TestDeleteTreatsMissingClusterAsReleased(t *testing.T) {
	ctx := context.Background()
	backup := &cloudv1.Backup{
		ObjectMeta: metav1.ObjectMeta{
			Name:        "backup-1",
			Namespace:   "ns-1",
			Finalizers:  []string{testChildFinalizer},
			Annotations: map[string]string{consts.DestroyFinishedAtAnnotation: "2026-07-24T00:00:00Z"},
		},
	}
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{Name: "cluster-1", Namespace: "ns-1"},
	}
	local := fake.NewClientBuilder().
		WithScheme(guardianTestScheme(t)).
		WithObjects(backup).
		Build()

	result := New(local, backup, cluster, testChildFinalizer, testClusterFinalizer).Delete(ctx)
	require.True(t, result.IsDoneState(), "delete failed: %v", result.Error())
	assert.NotContains(t, backup.Finalizers, testChildFinalizer)
}

func TestMarkDestroyFinishedIsWriteOnceAndUTC(t *testing.T) {
	backup := &cloudv1.Backup{}
	first := time.Date(2026, 7, 24, 9, 2, 3, 0, time.FixedZone("test", 8*60*60))

	require.True(t, MarkDestroyFinished(backup, first))
	assert.Equal(t, "2026-07-24T01:02:03Z", backup.Annotations[consts.DestroyFinishedAtAnnotation])

	require.False(t, MarkDestroyFinished(backup, first.Add(time.Hour)))
	assert.Equal(t, "2026-07-24T01:02:03Z", backup.Annotations[consts.DestroyFinishedAtAnnotation])
}

func TestCheckpointDestroyOnlyMarksErrorFreePass(t *testing.T) {
	now := time.Date(2026, 7, 24, 1, 2, 3, 0, time.UTC)

	t.Run("success", func(t *testing.T) {
		backup := &cloudv1.Backup{}
		res, errs, retriableErrs := CheckpointDestroy(backup, []deploy.Result{deploy.Done()}, now)
		require.NoError(t, errs)
		require.NoError(t, retriableErrs)
		assert.True(t, res.Requeue)
		assert.Equal(t, "2026-07-24T01:02:03Z", backup.Annotations[consts.DestroyFinishedAtAnnotation])
	})

	t.Run("fatal error", func(t *testing.T) {
		backup := &cloudv1.Backup{}
		_, errs, retriableErrs := CheckpointDestroy(backup, []deploy.Result{deploy.Error(errors.New("failed"))}, now)
		require.Error(t, errs)
		require.NoError(t, retriableErrs)
		assert.False(t, DestroyFinished(backup))
	})

	t.Run("retriable error", func(t *testing.T) {
		backup := &cloudv1.Backup{}
		_, errs, retriableErrs := CheckpointDestroy(
			backup,
			[]deploy.Result{deploy.Error(errors.New("retry")).WithRequeuAfter(time.Second)},
			now,
		)
		require.NoError(t, errs)
		require.Error(t, retriableErrs)
		assert.False(t, DestroyFinished(backup))
	})
}

func TestCreatePanicStopsFollowingTasksWhenClusterIsDeleting(t *testing.T) {
	now := metav1.Now()
	backup := &cloudv1.Backup{
		ObjectMeta: metav1.ObjectMeta{Name: "backup-1", Namespace: "ns-1"},
	}
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Name:              "cluster-1",
			Namespace:         "ns-1",
			DeletionTimestamp: &now,
			Finalizers:        []string{"cluster.cloud.pingcap.com/finalizer"},
		},
	}
	local := fake.NewClientBuilder().
		WithScheme(guardianTestScheme(t)).
		WithObjects(backup, cluster).
		Build()
	following := &recordingTask{}
	deployer := deploy.NewDeployer(
		[]deploy.Task{New(local, backup, cluster, testChildFinalizer, testClusterFinalizer), following},
		nil,
		logr.Discard(),
	)

	results := deployer.Deploy(context.Background())
	require.Len(t, results, 1)
	assert.False(t, results[0].IsDoneState())
	assert.Equal(t, 0, following.createCalls)
}

type recordingTask struct {
	createCalls int
}

func (t *recordingTask) Name() string                             { return "Following" }
func (t *recordingTask) Category() deploy.TaskCategory            { return deploy.BRTask }
func (t *recordingTask) MetricsStartTime() *time.Time             { return nil }
func (t *recordingTask) PostCreate(context.Context) deploy.Result { return deploy.Done() }
func (t *recordingTask) Delete(context.Context) deploy.Result     { return deploy.Done() }
func (t *recordingTask) PostDelete(context.Context) deploy.Result { return deploy.Done() }
func (t *recordingTask) Create(context.Context) deploy.Result {
	t.createCalls++
	return deploy.Done()
}
