package ngoperation

import (
	"context"
	"errors"
	"strconv"
	"time"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"go.uber.org/mock/gomock"

	baseapi "github.com/tidbcloud/devops/api"
	opent "github.com/tidbcloud/devops/entity/operation"
	"github.com/tidbcloud/devops/pkg/infra/clients/coresvccli"
	"github.com/tidbcloud/devops/pkg/infra/clients/mgmthttpcli"
	"github.com/tidbcloud/devops/pkg/infra/errs"
	"github.com/tidbcloud/devops/pkg/infra/utils/repoopt"
	"github.com/tidbcloud/devops/pkg/service/operation/actions"
	"github.com/tidbcloud/devops/pkg/service/operation/repo"
	apiv1 "github.com/tidbcloud/infra-provider/api/tidbcloud/cluster-agent/v1"
	nextgenv1 "github.com/tidbcloud/tidb-management-service/api/spec/global/nextgen/v1"
	mgmtv1 "github.com/tidbcloud/tidb-management-service/api/spec/tidb_mgmt_service/v1"
	"google.golang.org/grpc"
)

const (
	replaceNodeTestResourcePoolID       = "2045660260714151936"
	replaceNodeTestResourcePoolIDUint64 = 2045660260714151936
)

type replaceNodeTestTxContextKey struct{}

var _ = Describe("ReplaceNodeForNextGenResourcePool", func() {
	baseResourcePool := func() *nextgenv1.ResourcePoolProfile {
		return &nextgenv1.ResourcePoolProfile{
			ResourcePool: &nextgenv1.ResourcePool{
				Id: replaceNodeTestResourcePoolIDUint64,
			},
		}
	}

	baseInstance := func(params map[opent.ParamName]string) *opent.Instance {
		result := &opent.Instance{
			ID:        42,
			Type:      opent.OperationTargetResourcePool,
			Operation: opent.OperationTypeReplaceNodeForNextGenResourcePool,
			Params: map[opent.ParamName]string{
				opent.ParamResourcePoolID: replaceNodeTestResourcePoolID,
			},
			Outputs: map[opent.OutputName]string{},
		}
		for k, v := range params {
			result.Params[k] = v
		}
		return result
	}

	newActionWithResourcePoolErr := func(ctrl *gomock.Controller, agent *fakeReplaceNodeAgent, opRepo repo.OperationRepo, resourcePoolErr error) *replaceNode {
		mgmt := mgmthttpcli.NewMockMgmtHTTPClient(ctrl)
		if agent != nil {
			mgmt.EXPECT().
				NextGenClusterAgent("", replaceNodeTestResourcePoolID).
				Return(agent).
				AnyTimes()
		}

		return &replaceNode{comps: &actions.Components{
			CoreSvc: &coresvccli.CoreSvcCli{
				Mgmt: &coresvccli.MgmtService{ResourcePool: &fakeReplaceNodeResourcePoolServiceClient{
					resourcePool: baseResourcePool(),
					err:          resourcePoolErr,
				}},
			},
			MgmtHTTP: mgmt,
			OpRepo:   opRepo,
		}}
	}

	newAction := func(ctrl *gomock.Controller, agent *fakeReplaceNodeAgent, opRepo repo.OperationRepo) *replaceNode {
		return newActionWithResourcePoolErr(ctrl, agent, opRepo, nil)
	}

	Describe("getParam", func() {
		It("accepts pod_name target", func() {
			action := &replaceNode{}

			req, err := action.getParam(baseInstance(map[opent.ParamName]string{
				opent.ParamPodName: "db-1-tidb-0",
			}))

			Expect(err).NotTo(HaveOccurred())
			Expect(req.OperationID).To(Equal("42"))
			Expect(req.ResourcePoolID).To(Equal(replaceNodeTestResourcePoolID))
			Expect(req.PodName).To(Equal("db-1-tidb-0"))
			Expect(req.InstanceID).To(BeEmpty())
			Expect(req.Force).To(BeFalse())
		})

		It("sets force from force_replace", func() {
			req, err := (&replaceNode{}).getParam(baseInstance(map[opent.ParamName]string{
				opent.ParamPodName:      "db-1-tidb-0",
				opent.ParamForceReplace: "true",
			}))

			Expect(err).NotTo(HaveOccurred())
			Expect(req.Force).To(BeTrue())
		})

		It("accepts instance_id target", func() {
			action := &replaceNode{}

			req, err := action.getParam(baseInstance(map[opent.ParamName]string{
				opent.ParamInstanceID: "i-0733d30f7fe215f74",
			}))

			Expect(err).NotTo(HaveOccurred())
			Expect(req.PodName).To(BeEmpty())
			Expect(req.InstanceID).To(Equal("i-0733d30f7fe215f74"))
		})

		It("rejects missing or multiple targets", func() {
			action := &replaceNode{}

			_, err := action.getParam(baseInstance(nil))
			Expect(err).To(MatchError(ContainSubstring("exactly one")))

			_, err = action.getParam(baseInstance(map[opent.ParamName]string{
				opent.ParamPodName:    "db-1-tidb-0",
				opent.ParamInstanceID: "i-0733d30f7fe215f74",
			}))
			Expect(err).To(MatchError(ContainSubstring("exactly one")))
		})

		It("rejects missing resource pool", func() {
			action := &replaceNode{}
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			delete(instance.Params, opent.ParamResourcePoolID)

			_, err := action.getParam(instance)

			Expect(err).To(MatchError(ContainSubstring("resource_pool_id is required")))
		})
	})

	Describe("PreCheck", func() {
		It("rejects another active replace-node operation for the same resource pool", func() {
			ctrl := gomock.NewController(GinkgoT())
			opRepo := &fakeReplaceNodeOperationRepo{instances: []*opent.Instance{{
				ID:        99,
				Type:      opent.OperationTargetResourcePool,
				Operation: opent.OperationTypeReplaceNodeForNextGenResourcePool,
				Phase:     opent.InstancePhaseRunning,
				Params: map[opent.ParamName]string{
					opent.ParamResourcePoolID: replaceNodeTestResourcePoolID,
				},
			}}}
			action := newAction(ctrl, nil, opRepo)

			err := action.PreCheck(context.Background(), baseInstance(map[opent.ParamName]string{
				opent.ParamPodName: "db-1-tidb-0",
			}))

			Expect(err).To(MatchError(ContainSubstring("already in progress")))
		})

		It("allows precheck without operation repo and documents the test fallback", func() {
			ctrl := gomock.NewController(GinkgoT())
			action := newAction(ctrl, nil, nil)

			err := action.PreCheck(context.Background(), baseInstance(map[opent.ParamName]string{
				opent.ParamPodName: "db-1-tidb-0",
			}))

			Expect(err).NotTo(HaveOccurred())
		})
	})

	Describe("Start and Result", func() {
		type resultScenario struct {
			getResp             *mgmthttpcli.ReplaceNodeResp
			getErr              error
			prepareAge          time.Duration
			commitAge           time.Duration
			hasCommitMarker     bool
			wantStatus          opent.ActionStatus
			wantOutputSubstring string
			wantAbortCalls      int
			wantCommitCalls     int
			wantReplacementNode string
			wantPhase           string
			wantMessage         string
		}

		It("starts with prepare and records prepare outputs", func() {
			ctrl := gomock.NewController(GinkgoT())
			agent := &fakeReplaceNodeAgent{
				prepareResp: &mgmthttpcli.ReplaceNodeResp{
					Phase:      replaceNodePhasePreparing,
					PodName:    "db-1-tidb-0",
					OldNode:    "node-a",
					WarmUpPod:  "replace-node-warm-up",
					WarmUpNode: "",
				},
			}
			action := newAction(ctrl, agent, nil)

			result, err := action.Start(context.Background(), baseInstance(map[opent.ParamName]string{
				opent.ParamPodName: "db-1-tidb-0",
			}))

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusRunning))
			Expect(agent.prepareCalls).To(Equal(1))
			Expect(result.InstanceOutputs[outputReplaceNodePrepareStartedAt]).NotTo(BeEmpty())
			Expect(result.InstanceOutputs[outputReplaceNodePhase]).To(Equal(replaceNodePhasePreparing))
		})

		It("starts with instance_id target", func() {
			ctrl := gomock.NewController(GinkgoT())
			agent := &fakeReplaceNodeAgent{
				prepareResp: &mgmthttpcli.ReplaceNodeResp{
					Phase:      replaceNodePhasePreparing,
					InstanceID: "i-0733d30f7fe215f74",
					OldNode:    "node-a",
				},
			}
			action := newAction(ctrl, agent, nil)

			result, err := action.Start(context.Background(), baseInstance(map[opent.ParamName]string{
				opent.ParamInstanceID: "i-0733d30f7fe215f74",
			}))

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusRunning))
			Expect(agent.prepareReq.PodName).To(BeEmpty())
			Expect(agent.prepareReq.InstanceID).To(Equal("i-0733d30f7fe215f74"))
			Expect(result.InstanceOutputs[outputReplaceNodeInstanceID]).To(Equal("i-0733d30f7fe215f74"))
		})

		It("ignores not-found abort cleanup when prepare fails before state exists", func() {
			ctrl := gomock.NewController(GinkgoT())
			agent := &fakeReplaceNodeAgent{
				prepareErr: errors.New("prepare failed"),
				abortErr:   errs.APIErrorf(baseapi.ErrCodeNotFound, "replace node operation 42 state not found"),
			}
			action := newAction(ctrl, agent, nil)

			result, err := action.Start(context.Background(), baseInstance(map[opent.ParamName]string{
				opent.ParamPodName: "db-1-tidb-0",
			}))

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusFailed))
			Expect(result.ActionOutput).To(ContainSubstring("prepare replace node failed"))
			Expect(result.ActionOutput).NotTo(ContainSubstring("abort also failed"))
			Expect(agent.abortCalls).To(Equal(1))
		})

		It("commits after prepare reaches prepared and persists commit boundary", func() {
			ctrl := gomock.NewController(GinkgoT())
			opRepo := &fakeReplaceNodeOperationRepo{}
			agent := &fakeReplaceNodeAgent{
				getResp: &mgmthttpcli.ReplaceNodeResp{
					Phase:      replaceNodePhasePrepared,
					PodName:    "db-1-tidb-0",
					WarmUpNode: "node-b",
				},
				commitResp: &mgmthttpcli.ReplaceNodeResp{
					Phase:      replaceNodePhaseCommitted,
					PodName:    "db-1-tidb-0",
					WarmUpNode: "node-b",
				},
			}
			action := newAction(ctrl, agent, opRepo)
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			instance.Outputs[outputReplaceNodePrepareStartedAt] = strconv.FormatInt(time.Now().Unix(), 10)

			result, err := action.Result(context.Background(), instance)

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusRunning))
			Expect(agent.getCalls).To(Equal(1))
			Expect(agent.prepareCalls).To(Equal(0))
			Expect(agent.commitCalls).To(Equal(1))
			Expect(instance.Outputs[outputReplaceNodeCommitStartedAt]).NotTo(BeEmpty())
			Expect(opRepo.patchCalls).To(Equal(1))
		})

		It("persists commit marker outside the action retry transaction", func() {
			ctrl := gomock.NewController(GinkgoT())
			opRepo := &fakeReplaceNodeOperationRepo{}
			agent := &fakeReplaceNodeAgent{
				getResp:    &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhasePrepared},
				commitResp: &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhaseCommitted},
			}
			action := newAction(ctrl, agent, opRepo)
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			instance.Outputs[outputReplaceNodePrepareStartedAt] = strconv.FormatInt(time.Now().Unix(), 10)
			ctx := context.WithValue(context.Background(), replaceNodeTestTxContextKey{}, "transactional")

			result, err := action.Result(ctx, instance)

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusRunning))
			Expect(agent.commitCalls).To(Equal(1))
			Expect(opRepo.patchCalls).To(Equal(1))
			Expect(opRepo.patchContexts).To(HaveLen(1))
			Expect(opRepo.patchContexts[0].Value(replaceNodeTestTxContextKey{})).To(BeNil())
			Expect(instance.Outputs[outputReplaceNodeCommitStartedAt]).NotTo(BeEmpty())
		})

		DescribeTable("handles Result phase and pre-commit error scenarios",
			func(tc resultScenario) {
				ctrl := gomock.NewController(GinkgoT())
				agent := &fakeReplaceNodeAgent{
					getResp:   tc.getResp,
					getErr:    tc.getErr,
					abortResp: &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhaseAborted, Message: "prepare timeout cleanup"},
				}
				action := newAction(ctrl, agent, nil)
				instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
				instance.Outputs[outputReplaceNodePrepareStartedAt] = strconv.FormatInt(time.Now().Add(-tc.prepareAge).Unix(), 10)
				if tc.hasCommitMarker {
					instance.Outputs[outputReplaceNodeCommitStartedAt] = strconv.FormatInt(time.Now().Add(-tc.commitAge).Unix(), 10)
				}

				result, err := action.Result(context.Background(), instance)

				Expect(err).NotTo(HaveOccurred())
				Expect(result.Result).To(Equal(tc.wantStatus))
				if tc.wantOutputSubstring != "" {
					Expect(result.ActionOutput).To(ContainSubstring(tc.wantOutputSubstring))
				}
				if tc.wantReplacementNode != "" {
					Expect(result.InstanceOutputs[outputReplaceNodeReplacementNode]).To(Equal(tc.wantReplacementNode))
				}
				if tc.wantPhase != "" {
					Expect(result.InstanceOutputs[outputReplaceNodePhase]).To(Equal(tc.wantPhase))
				}
				if tc.wantMessage != "" {
					Expect(result.InstanceOutputs[outputReplaceNodeMessage]).To(Equal(tc.wantMessage))
				}
				Expect(agent.getCalls).To(Equal(1))
				Expect(agent.abortCalls).To(Equal(tc.wantAbortCalls))
				Expect(agent.commitCalls).To(Equal(tc.wantCommitCalls))
				if tc.wantAbortCalls > 0 {
					Expect(result.InstanceOutputs[outputReplaceNodePhase]).To(Equal(replaceNodePhaseAborted))
				}
			},
			Entry("retries transient get error before commit until prepare timeout", resultScenario{
				getErr:              errors.New("temporary unavailable"),
				wantStatus:          opent.ActionStatusRunning,
				wantOutputSubstring: "retrying until prepare timeout",
			}),
			Entry("retries empty get response before commit instead of panicking", resultScenario{
				wantStatus:          opent.ActionStatusRunning,
				wantOutputSubstring: "empty response",
			}),
			Entry("retries transient get error after commit until commit timeout", resultScenario{
				getErr:              errors.New("region service unavailable"),
				hasCommitMarker:     true,
				wantStatus:          opent.ActionStatusRunning,
				wantOutputSubstring: "retrying until commit timeout",
			}),
			Entry("fails transient get error after commit timeout without abort", resultScenario{
				getErr:              errors.New("region service unavailable"),
				hasCommitMarker:     true,
				commitAge:           16 * time.Minute,
				wantStatus:          opent.ActionStatusFailed,
				wantOutputSubstring: "get replace node operation failed after commit timeout",
			}),
			Entry("aborts transient get error after prepare timeout", resultScenario{
				getErr:              errors.New("temporary unavailable"),
				prepareAge:          16 * time.Minute,
				wantStatus:          opent.ActionStatusFailed,
				wantOutputSubstring: "get replace node operation timed out",
				wantAbortCalls:      1,
				wantMessage:         "prepare timeout cleanup",
			}),
			Entry("aborts when prepare times out", resultScenario{
				getResp:             &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhasePreparing},
				prepareAge:          16 * time.Minute,
				wantStatus:          opent.ActionStatusFailed,
				wantOutputSubstring: "prepare replace node timed out",
				wantAbortCalls:      1,
				wantMessage:         "prepare timeout cleanup",
			}),
			Entry("succeeds when get reports replacement success after commit", resultScenario{
				getResp: &mgmthttpcli.ReplaceNodeResp{
					Phase:           replaceNodePhaseSucceeded,
					ReplacementPod:  "db-1-tidb-0",
					ReplacementNode: "node-b",
				},
				hasCommitMarker:     true,
				wantStatus:          opent.ActionStatusSucceeded,
				wantReplacementNode: "node-b",
				wantPhase:           replaceNodePhaseSucceeded,
			}),
			Entry("fails when get reports failed phase", resultScenario{
				getResp:             &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhaseFailed, Message: "replacement node mismatch"},
				hasCommitMarker:     true,
				wantStatus:          opent.ActionStatusFailed,
				wantOutputSubstring: "replace node operation failed",
				wantPhase:           replaceNodePhaseFailed,
				wantMessage:         "replacement node mismatch",
			}),
			Entry("fails when get reports aborted phase", resultScenario{
				getResp:             &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhaseAborted, Message: "aborted by cancel"},
				wantStatus:          opent.ActionStatusFailed,
				wantOutputSubstring: "replace node operation was aborted",
				wantPhase:           replaceNodePhaseAborted,
				wantMessage:         "aborted by cancel",
			}),
		)

		It("restores commit marker when get reports committed without local marker", func() {
			ctrl := gomock.NewController(GinkgoT())
			opRepo := &fakeReplaceNodeOperationRepo{}
			agent := &fakeReplaceNodeAgent{
				getResp: &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhaseCommitted},
			}
			action := newAction(ctrl, agent, opRepo)
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			instance.Outputs[outputReplaceNodePrepareStartedAt] = strconv.FormatInt(time.Now().Unix(), 10)

			result, err := action.Result(context.Background(), instance)

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusRunning))
			Expect(agent.commitCalls).To(Equal(0))
			Expect(opRepo.patchCalls).To(Equal(1))
			Expect(instance.Outputs[outputReplaceNodeCommitStartedAt]).NotTo(BeEmpty())
		})

		It("does not commit when commit marker persistence fails", func() {
			ctrl := gomock.NewController(GinkgoT())
			opRepo := &fakeReplaceNodeOperationRepo{patchErr: errors.New("patch failed")}
			agent := &fakeReplaceNodeAgent{
				getResp:    &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhasePrepared},
				commitResp: &mgmthttpcli.ReplaceNodeResp{Phase: replaceNodePhaseCommitted},
			}
			action := newAction(ctrl, agent, opRepo)
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			instance.Outputs[outputReplaceNodePrepareStartedAt] = strconv.FormatInt(time.Now().Unix(), 10)

			result, err := action.Result(context.Background(), instance)

			Expect(err).To(MatchError("patch failed"))
			Expect(result).To(BeNil())
			Expect(agent.commitCalls).To(Equal(0))
			Expect(instance.Outputs[outputReplaceNodeCommitStartedAt]).To(BeEmpty())
		})

		It("keeps running when resource pool existence check fails after commit started", func() {
			ctrl := gomock.NewController(GinkgoT())
			action := newActionWithResourcePoolErr(ctrl, nil, nil, errors.New("failed to load sharedPool"))
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			instance.Outputs[outputReplaceNodeCommitStartedAt] = strconv.FormatInt(time.Now().Unix(), 10)

			result, err := action.Result(context.Background(), instance)

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusRunning))
			Expect(result.ActionOutput).To(ContainSubstring("resource pool existence check failed after commit started"))
		})

		It("fails resource pool existence check after commit timeout", func() {
			ctrl := gomock.NewController(GinkgoT())
			action := newActionWithResourcePoolErr(ctrl, nil, nil, errors.New("failed to load sharedPool"))
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			instance.Outputs[outputReplaceNodeCommitStartedAt] = strconv.FormatInt(time.Now().Add(-16*time.Minute).Unix(), 10)

			result, err := action.Result(context.Background(), instance)

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusFailed))
			Expect(result.ActionOutput).To(ContainSubstring("resource pool existence check failed after commit timeout"))
		})

		It("does not abort when commit times out", func() {
			ctrl := gomock.NewController(GinkgoT())
			agent := &fakeReplaceNodeAgent{
				getResp: &mgmthttpcli.ReplaceNodeResp{Phase: "REPLACE_NODE_PHASE_COMMITTING"},
			}
			action := newAction(ctrl, agent, nil)
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			instance.Outputs[outputReplaceNodeCommitStartedAt] = strconv.FormatInt(time.Now().Add(-16*time.Minute).Unix(), 10)

			result, err := action.Result(context.Background(), instance)

			Expect(err).NotTo(HaveOccurred())
			Expect(result.Result).To(Equal(opent.ActionStatusFailed))
			Expect(result.ActionOutput).To(ContainSubstring("commit replace node timed out"))
			Expect(result.InstanceOutputs[outputReplaceNodePhase]).To(Equal("REPLACE_NODE_PHASE_COMMITTING"))
			Expect(agent.abortCalls).To(Equal(0))
		})
	})

	Describe("OnCancel", func() {
		It("aborts before commit", func() {
			ctrl := gomock.NewController(GinkgoT())
			agent := &fakeReplaceNodeAgent{abortResp: &mgmthttpcli.ReplaceNodeResp{}}
			action := newAction(ctrl, agent, nil)

			cancelled, err := action.OnCancel(context.Background(), baseInstance(map[opent.ParamName]string{
				opent.ParamPodName: "db-1-tidb-0",
			}))

			Expect(err).NotTo(HaveOccurred())
			Expect(cancelled).To(BeTrue())
			Expect(agent.abortCalls).To(Equal(1))
		})

		It("ignores abort rejection after the remote operation is committed", func() {
			ctrl := gomock.NewController(GinkgoT())
			agent := &fakeReplaceNodeAgent{abortErr: errors.New("status: 412 Precondition Failed, body: operation already committed")}
			action := newAction(ctrl, agent, nil)

			cancelled, err := action.OnCancel(context.Background(), baseInstance(map[opent.ParamName]string{
				opent.ParamPodName: "db-1-tidb-0",
			}))

			Expect(err).NotTo(HaveOccurred())
			Expect(cancelled).To(BeFalse())
			Expect(agent.abortCalls).To(Equal(1))
		})

		It("does not abort after commit starts", func() {
			ctrl := gomock.NewController(GinkgoT())
			agent := &fakeReplaceNodeAgent{}
			action := newAction(ctrl, agent, nil)
			instance := baseInstance(map[opent.ParamName]string{opent.ParamPodName: "db-1-tidb-0"})
			instance.Outputs[outputReplaceNodeCommitStartedAt] = strconv.FormatInt(time.Now().Unix(), 10)

			cancelled, err := action.OnCancel(context.Background(), instance)

			Expect(err).NotTo(HaveOccurred())
			Expect(cancelled).To(BeFalse())
			Expect(agent.abortCalls).To(Equal(0))
		})
	})

	Describe("helpers", func() {
		It("rewrites invalid timestamp outputs to the fallback time", func() {
			fallback := time.Unix(12345, 0)
			outputs := map[opent.OutputName]string{outputReplaceNodePrepareStartedAt: "bad timestamp"}

			got := ensureReplaceNodeOutputTime(outputs, outputReplaceNodePrepareStartedAt, fallback)

			Expect(got).To(Equal(fallback))
			Expect(outputs[outputReplaceNodePrepareStartedAt]).To(Equal("12345"))
		})
	})
})

type fakeReplaceNodeAgent struct {
	prepareResp *mgmthttpcli.ReplaceNodeResp
	prepareErr  error
	getResp     *mgmthttpcli.ReplaceNodeResp
	getErr      error
	commitResp  *mgmthttpcli.ReplaceNodeResp
	commitErr   error
	abortResp   *mgmthttpcli.ReplaceNodeResp
	abortErr    error

	prepareCalls int
	getCalls     int
	commitCalls  int
	abortCalls   int

	prepareReq *mgmthttpcli.PrepareReplaceNodeReq
}

func (f *fakeReplaceNodeAgent) Restart(context.Context, *apiv1.RestartReq) (*apiv1.RestartResp, error) {
	return nil, errors.New("not implemented")
}

func (f *fakeReplaceNodeAgent) ExecuteCommand(context.Context, *apiv1.ExecuteCommandReq) (*apiv1.ExecuteCommandResp, error) {
	return nil, errors.New("not implemented")
}

func (f *fakeReplaceNodeAgent) GetClusterTopology(context.Context, *apiv1.GetClusterTopologyReq) (*apiv1.GetClusterTopologyResp, error) {
	return nil, errors.New("not implemented")
}

func (f *fakeReplaceNodeAgent) PrepareReplaceNode(_ context.Context, req *mgmthttpcli.PrepareReplaceNodeReq) (*mgmthttpcli.ReplaceNodeResp, error) {
	f.prepareCalls++
	f.prepareReq = req
	if f.prepareErr != nil {
		return nil, f.prepareErr
	}
	return f.prepareResp, nil
}

func (f *fakeReplaceNodeAgent) GetReplaceNodeOperation(context.Context, *mgmthttpcli.ReplaceNodeOperationReq) (*mgmthttpcli.ReplaceNodeResp, error) {
	f.getCalls++
	if f.getErr != nil {
		return nil, f.getErr
	}
	return f.getResp, nil
}

func (f *fakeReplaceNodeAgent) CommitReplaceNode(context.Context, *mgmthttpcli.ReplaceNodeOperationReq) (*mgmthttpcli.ReplaceNodeResp, error) {
	f.commitCalls++
	if f.commitErr != nil {
		return nil, f.commitErr
	}
	return f.commitResp, nil
}

func (f *fakeReplaceNodeAgent) AbortReplaceNode(context.Context, *mgmthttpcli.ReplaceNodeOperationReq) (*mgmthttpcli.ReplaceNodeResp, error) {
	f.abortCalls++
	if f.abortErr != nil {
		return nil, f.abortErr
	}
	return f.abortResp, nil
}

type fakeReplaceNodeOperationRepo struct {
	instances     []*opent.Instance
	patchErr      error
	patchCalls    int
	patchContexts []context.Context
}

func (f *fakeReplaceNodeOperationRepo) ListInstances(context.Context, ...repoopt.Option) ([]*opent.Instance, error) {
	return f.instances, nil
}

func (f *fakeReplaceNodeOperationRepo) GetInstance(context.Context, uint64) (*opent.Instance, error) {
	Fail("unexpected GetInstance call")
	return nil, nil
}

func (f *fakeReplaceNodeOperationRepo) CreateInstance(context.Context, *opent.Instance) (*opent.Instance, error) {
	Fail("unexpected CreateInstance call")
	return nil, nil
}

func (f *fakeReplaceNodeOperationRepo) PatchInstance(ctx context.Context, current *opent.Instance, patch ...repo.PatchInstance) error {
	f.patchCalls++
	f.patchContexts = append(f.patchContexts, ctx)
	if f.patchErr != nil {
		return f.patchErr
	}
	for _, p := range patch {
		if err := p(current); err != nil {
			return err
		}
	}
	return nil
}

func (f *fakeReplaceNodeOperationRepo) CountInstance(context.Context, ...repoopt.Option) (int64, error) {
	Fail("unexpected CountInstance call")
	return 0, nil
}

type fakeReplaceNodeResourcePoolServiceClient struct {
	mgmtv1.ResourcePoolServiceClient
	resourcePool *nextgenv1.ResourcePoolProfile
	err          error
}

func (f *fakeReplaceNodeResourcePoolServiceClient) GetResourcePool(_ context.Context, _ *nextgenv1.GetResourcePoolRequest, _ ...grpc.CallOption) (*nextgenv1.ResourcePoolProfile, error) {
	if f.err != nil {
		return nil, f.err
	}
	return f.resourcePool, nil
}
