package clusterrecovery

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

	"github.com/pingcap-inc/clinic/pkg/db"
)

type fakeExecutionStore struct {
	snapshots   map[string]*Snapshot
	applyCreate func(context.Context, ObjectKind, *db.Cluster, string) (*MutationResult, error)
	applyUpdate func(context.Context, ObjectKind, *db.Cluster, string, string, []string) (*MutationResult, error)
	readCalls   int
	createCalls int
	updateCalls int
}

func (f *fakeExecutionStore) ReadSnapshot(_ context.Context, _ ObjectKind, tenantID uint64, clusterID string) (*Snapshot, error) {
	f.readCalls++
	return f.snapshots[fmt.Sprintf("%d:%s", tenantID, clusterID)], nil
}

func (f *fakeExecutionStore) ApplyCreate(ctx context.Context, kind ObjectKind, desired *db.Cluster, checksum string) (*MutationResult, error) {
	f.createCalls++
	return f.applyCreate(ctx, kind, desired, checksum)
}

func (f *fakeExecutionStore) ApplyUpdate(ctx context.Context, kind ObjectKind, desired *db.Cluster, desiredChecksum, beforeChecksum string, fields []string) (*MutationResult, error) {
	f.updateCalls++
	return f.applyUpdate(ctx, kind, desired, desiredChecksum, beforeChecksum, fields)
}

type recordingRollbackSink struct {
	written bool
	bundle  *RollbackBundle
	err     error
}

type driftingExecutionSource struct {
	resolved *ResolvedSource
	calls    int
}

func (s *driftingExecutionSource) Resolve(_ context.Context, candidate Candidate) (*ResolvedSource, error) {
	s.calls++
	resolved := *s.resolved
	resolved.Candidate = candidate
	if s.calls > 1 {
		resolved.SourceUpdatedAt = resolved.SourceUpdatedAt.Add(time.Second)
	}
	return &resolved, nil
}

func (s *recordingRollbackSink) WriteRollbackBundle(bundle *RollbackBundle) error {
	s.written = true
	s.bundle = bundle
	return s.err
}

func TestExecutorRequiresAllWriteGuardsBeforeSourceOrStoreAccess(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest, source, store := createExecutionFixture(t, now)
	executor := fixedExecutor(source, store, now)
	_, err := executor.Execute(context.Background(), manifest, ApplyOptions{
		ExpectedManifestHash: manifest.ManifestHash,
		ApprovalRef:          "reviewed-pr",
		WorkloadIdentity:     "system:serviceaccount:clinic-staging:recovery",
		MutationClass:        MutationCreate,
		Phase:                ApplyPhaseCanary,
		Limit:                1,
	}, &recordingRollbackSink{})
	if !errors.Is(err, ErrApplyGuard) {
		t.Fatalf("expected apply guard, got %v", err)
	}
	if source.calls != 0 || store.readCalls != 0 || store.createCalls != 0 {
		t.Fatalf("guard failure accessed dependencies: source=%d read=%d create=%d", source.calls, store.readCalls, store.createCalls)
	}
}

func TestExecutorWritesRollbackEvidenceBeforeCanaryCreate(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest, source, store := createExecutionFixture(t, now)
	sink := &recordingRollbackSink{}
	store.applyCreate = func(_ context.Context, _ ObjectKind, _ *db.Cluster, _ string) (*MutationResult, error) {
		if !sink.written {
			t.Fatal("mutation ran before rollback evidence was persisted")
		}
		return &MutationResult{Outcome: MutationOutcomeApplied, AfterChecksum: stringsOf('d', 64)}, nil
	}
	receipt, err := fixedExecutor(source, store, now).Execute(context.Background(), manifest, validCanaryOptions(manifest), sink)
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}
	if !sink.written || sink.bundle == nil || len(sink.bundle.Entries) != 1 || sink.bundle.Entries[0].Operation != RollbackDeleteCreated {
		t.Fatalf("rollback bundle = %+v", sink.bundle)
	}
	if store.createCalls != 1 || receipt.Stopped || receipt.Results[0].Outcome != MutationOutcomeApplied || !isSHA256(receipt.ReceiptHash) {
		t.Fatalf("receipt=%+v create_calls=%d", receipt, store.createCalls)
	}
}

func TestExecutorAllowsOnlySourceRevalidatedDeletedHistoryCreate(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest, source, store := createExecutionFixture(t, now)
	candidate := Candidate{Kind: ObjectKindMgmtCluster, ClusterID: "1", TenantID: 2}
	resolved := source.resolved[candidateKey(candidate)]
	resolved.Desired = terminalDesired(resolved.Desired, now.Add(-time.Hour))
	resolved.Terminal = true
	desiredWithOrg := *resolved.Desired
	desiredWithOrg.OrgID = 10
	desiredChecksum, err := desiredClusterChecksum(ObjectKindMgmtCluster, &desiredWithOrg)
	if err != nil {
		t.Fatal(err)
	}
	manifest.Actions[0].Terminal = true
	manifest.Actions[0].DesiredChecksum = desiredChecksum
	if err := FinalizeManifest(manifest); err != nil {
		t.Fatal(err)
	}
	store.applyCreate = func(_ context.Context, _ ObjectKind, desired *db.Cluster, _ string) (*MutationResult, error) {
		if desired.Status != "deleted" || !desired.DeletedAt.Valid || desired.EndTime == 0 {
			t.Fatalf("executor attempted non-terminal create: %+v", desired)
		}
		return &MutationResult{Outcome: MutationOutcomeApplied, AfterChecksum: stringsOf('d', 64)}, nil
	}
	receipt, err := fixedExecutor(source, store, now).Execute(
		context.Background(), manifest, validCanaryOptions(manifest), &recordingRollbackSink{},
	)
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}
	if receipt.Stopped || receipt.Results[0].Outcome != MutationOutcomeApplied {
		t.Fatalf("receipt = %+v", receipt)
	}
}

func TestExecutorStopsBeforeWriteOnSourceDrift(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest, source, store := createExecutionFixture(t, now)
	resolved := source.resolved[candidateKey(Candidate{Kind: ObjectKindMgmtCluster, ClusterID: "1", TenantID: 2})]
	resolved.SourceUpdatedAt = resolved.SourceUpdatedAt.Add(time.Second)
	sink := &recordingRollbackSink{}
	receipt, err := fixedExecutor(source, store, now).Execute(context.Background(), manifest, validCanaryOptions(manifest), sink)
	if !errors.Is(err, ErrSourceDrift) {
		t.Fatalf("expected source drift, got %v", err)
	}
	if sink.written || store.createCalls != 0 || receipt == nil || !receipt.Stopped || receipt.Results[0].ReasonCode != "source_drift" {
		t.Fatalf("sink=%v create=%d receipt=%+v", sink.written, store.createCalls, receipt)
	}
}

func TestExecutorRevalidatesSourceImmediatelyBeforeMutation(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest, sourceFixture, store := createExecutionFixture(t, now)
	resolved := sourceFixture.resolved[candidateKey(Candidate{Kind: ObjectKindMgmtCluster, ClusterID: "1", TenantID: 2})]
	source := &driftingExecutionSource{resolved: resolved}
	sink := &recordingRollbackSink{}
	receipt, err := fixedExecutor(source, store, now).Execute(context.Background(), manifest, validCanaryOptions(manifest), sink)
	if !errors.Is(err, ErrSourceDrift) {
		t.Fatalf("expected immediate source drift, got %v", err)
	}
	if source.calls != 2 || !sink.written || store.createCalls != 0 || receipt == nil || !receipt.Stopped ||
		receipt.Results[0].ReasonCode != "source_drift" {
		t.Fatalf("source_calls=%d sink=%v create=%d receipt=%+v", source.calls, sink.written, store.createCalls, receipt)
	}
}

func TestExecutorStopsWhenMutationEvidenceIsInvalid(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest, source, store := createExecutionFixture(t, now)
	store.applyCreate = func(context.Context, ObjectKind, *db.Cluster, string) (*MutationResult, error) {
		return &MutationResult{Outcome: MutationOutcomeApplied}, nil
	}
	receipt, err := fixedExecutor(source, store, now).Execute(
		context.Background(), manifest, validCanaryOptions(manifest), &recordingRollbackSink{},
	)
	if err == nil || receipt == nil || !receipt.Stopped || len(receipt.Results) != 1 ||
		receipt.Results[0].ReasonCode != "mutation_result_invalid" {
		t.Fatalf("err=%v receipt=%+v", err, receipt)
	}
}

func TestExecutorUpdateRollbackContainsCompletePreimage(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	candidate := Candidate{Kind: ObjectKindMgmtCluster, ClusterID: "1", TenantID: 2}
	desired := mutationDesired("1", 2, 10)
	current := *desired
	current.Status = "modifying"
	current.ID = 99
	current.UpdatedAt = now.Add(-time.Hour)
	beforeChecksum, err := clusterRecordChecksum(&current)
	if err != nil {
		t.Fatal(err)
	}
	desiredChecksum, err := desiredClusterChecksum(ObjectKindMgmtCluster, desired)
	if err != nil {
		t.Fatal(err)
	}
	manifest := &Manifest{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		RunID:         "staging-update-test",
		MutationClass: MutationUpdate,
		IncidentStart: now.Add(-2 * time.Hour),
		IncidentEnd:   now.Add(-time.Hour),
		GeneratedAt:   now,
		ExpiresAt:     now.Add(time.Hour),
		Actions: []Action{{
			Kind:            candidate.Kind,
			ClusterID:       candidate.ClusterID,
			TenantID:        candidate.TenantID,
			SourceUpdatedAt: now.Add(-time.Hour),
			BeforeChecksum:  beforeChecksum,
			DesiredChecksum: desiredChecksum,
			Fields:          []string{"status"},
		}},
	}
	if err := FinalizeManifest(manifest); err != nil {
		t.Fatal(err)
	}
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Desired: desired, SourceUpdatedAt: now.Add(-time.Hour)},
	}, errors: map[string]error{}}
	store := &fakeExecutionStore{snapshots: map[string]*Snapshot{
		"2:1": {Organization: testOrganization(2), Cluster: &current},
	}}
	store.applyUpdate = func(_ context.Context, _ ObjectKind, _ *db.Cluster, _, _ string, _ []string) (*MutationResult, error) {
		return &MutationResult{Outcome: MutationOutcomeApplied, BeforeChecksum: beforeChecksum, AfterChecksum: stringsOf('e', 64)}, nil
	}
	sink := &recordingRollbackSink{}
	options := validCanaryOptions(manifest)
	options.MutationClass = MutationUpdate
	receipt, err := fixedExecutor(source, store, now).Execute(context.Background(), manifest, options, sink)
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}
	if receipt.Results[0].Outcome != MutationOutcomeApplied || sink.bundle.Entries[0].Before == nil ||
		sink.bundle.Entries[0].Before.ID != current.ID || sink.bundle.Entries[0].Before.Status != current.Status {
		t.Fatalf("receipt=%+v rollback=%+v", receipt, sink.bundle)
	}
}

func TestExecutorBatchRequiresMatchingSuccessfulPriorReceiptAndVerification(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest, source, store := createExecutionFixture(t, now)
	manifest.Actions = append(manifest.Actions, manifest.Actions[0])
	manifest.Actions[1].ClusterID = "2"
	if err := FinalizeManifest(manifest); err != nil {
		t.Fatal(err)
	}
	options := ApplyOptions{
		AllowWrite:           true,
		ExpectedManifestHash: manifest.ManifestHash,
		ApprovalRef:          "reviewed-pr",
		WorkloadIdentity:     "system:serviceaccount:clinic-staging:recovery",
		MutationClass:        MutationCreate,
		Phase:                ApplyPhaseBatch,
		Offset:               1,
		Limit:                1,
	}
	_, err := fixedExecutor(source, store, now).Execute(context.Background(), manifest, options, &recordingRollbackSink{})
	if !errors.Is(err, ErrApplyGuard) {
		t.Fatalf("expected apply guard, got %v", err)
	}
}

func TestExecutorBatchMustContinueImmediatelyAfterVerifiedPriorReceipt(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	manifest, source, store := createExecutionFixture(t, now)
	desired := mutationDesired("2", 2, 0)
	desiredWithOrg := *desired
	desiredWithOrg.OrgID = 10
	desiredChecksum, err := desiredClusterChecksum(ObjectKindMgmtCluster, &desiredWithOrg)
	if err != nil {
		t.Fatal(err)
	}
	manifest.Actions = append(manifest.Actions, Action{
		Kind:            ObjectKindMgmtCluster,
		ClusterID:       "2",
		TenantID:        2,
		SourceUpdatedAt: now.Add(-time.Hour),
		DesiredChecksum: desiredChecksum,
	})
	if err := FinalizeManifest(manifest); err != nil {
		t.Fatal(err)
	}
	source.resolved[candidateKey(Candidate{Kind: ObjectKindMgmtCluster, ClusterID: "2", TenantID: 2})] = &ResolvedSource{
		Desired:         desired,
		SourceUpdatedAt: now.Add(-time.Hour),
	}
	store.snapshots["2:2"] = &Snapshot{Organization: testOrganization(2)}

	executor := fixedExecutor(source, store, now)
	canary, err := executor.Execute(context.Background(), manifest, validCanaryOptions(manifest), &recordingRollbackSink{})
	if err != nil {
		t.Fatalf("Canary Execute() error = %v", err)
	}
	batchOptions := ApplyOptions{
		AllowWrite:               true,
		ExpectedManifestHash:     manifest.ManifestHash,
		ApprovalRef:              "reviewed-pr",
		WorkloadIdentity:         "system:serviceaccount:clinic-staging:recovery",
		MutationClass:            MutationCreate,
		Phase:                    ApplyPhaseBatch,
		Offset:                   1,
		Limit:                    1,
		PriorReceipt:             canary,
		ExpectedPriorReceiptHash: canary.ReceiptHash,
		PriorVerificationRef:     "incident-review-canary-passed",
	}
	receipt, err := executor.Execute(context.Background(), manifest, batchOptions, &recordingRollbackSink{})
	if err != nil {
		t.Fatalf("batch Execute() error = %v", err)
	}
	if receipt.Offset != 1 || len(receipt.Results) != 1 || receipt.Results[0].ActionIndex != 1 || receipt.PriorReceiptHash != canary.ReceiptHash {
		t.Fatalf("batch receipt = %+v", receipt)
	}

	batchOptions.Offset = 0
	if err := ValidateApplyRequest(manifest, batchOptions, now); !errors.Is(err, ErrApplyGuard) {
		t.Fatalf("expected non-contiguous batch to fail, got %v", err)
	}
}

func createExecutionFixture(t *testing.T, now time.Time) (*Manifest, *fakePlanSource, *fakeExecutionStore) {
	t.Helper()
	candidate := Candidate{Kind: ObjectKindMgmtCluster, ClusterID: "1", TenantID: 2}
	desired := mutationDesired("1", 2, 0)
	desiredChecksumCandidate := *desired
	desiredChecksumCandidate.OrgID = 10
	desiredChecksum, err := desiredClusterChecksum(ObjectKindMgmtCluster, &desiredChecksumCandidate)
	if err != nil {
		t.Fatal(err)
	}
	manifest := &Manifest{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		RunID:         "staging-create-test",
		MutationClass: MutationCreate,
		IncidentStart: now.Add(-2 * time.Hour),
		IncidentEnd:   now.Add(-time.Hour),
		GeneratedAt:   now,
		ExpiresAt:     now.Add(time.Hour),
		Actions: []Action{{
			Kind:            candidate.Kind,
			ClusterID:       candidate.ClusterID,
			TenantID:        candidate.TenantID,
			SourceUpdatedAt: now.Add(-time.Hour),
			DesiredChecksum: desiredChecksum,
		}},
	}
	if err := FinalizeManifest(manifest); err != nil {
		t.Fatal(err)
	}
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Desired: desired, SourceUpdatedAt: now.Add(-time.Hour)},
	}, errors: map[string]error{}}
	store := &fakeExecutionStore{
		snapshots: map[string]*Snapshot{"2:1": {Organization: testOrganization(2)}},
		applyCreate: func(context.Context, ObjectKind, *db.Cluster, string) (*MutationResult, error) {
			return &MutationResult{Outcome: MutationOutcomeApplied, AfterChecksum: stringsOf('d', 64)}, nil
		},
		applyUpdate: func(context.Context, ObjectKind, *db.Cluster, string, string, []string) (*MutationResult, error) {
			return nil, errors.New("unexpected update")
		},
	}
	return manifest, source, store
}

func fixedExecutor(source Source, store RecoveryStore, now time.Time) *Executor {
	executor := NewExecutor(source, store)
	executor.now = func() time.Time { return now }
	return executor
}

func validCanaryOptions(manifest *Manifest) ApplyOptions {
	return ApplyOptions{
		AllowWrite:           true,
		ExpectedManifestHash: manifest.ManifestHash,
		ApprovalRef:          "reviewed-pr",
		WorkloadIdentity:     "system:serviceaccount:clinic-staging:recovery",
		MutationClass:        manifest.MutationClass,
		Phase:                ApplyPhaseCanary,
		Offset:               0,
		Limit:                1,
	}
}
