package clusterrecovery

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

	"gorm.io/gorm"

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

type fakePlanSource struct {
	resolved map[string]*ResolvedSource
	errors   map[string]error
	calls    int
}

func (f *fakePlanSource) Resolve(_ context.Context, candidate Candidate) (*ResolvedSource, error) {
	f.calls++
	key := candidateKey(candidate)
	if err := f.errors[key]; err != nil {
		return nil, err
	}
	resolved := f.resolved[key]
	if resolved == nil {
		return nil, ErrSourceNotFound
	}
	copyResolved := *resolved
	copyResolved.Candidate = candidate
	return &copyResolved, nil
}

type fakeReadStore struct {
	snapshots map[string]*Snapshot
	errors    map[string]error
	calls     int
}

func (f *fakeReadStore) ReadSnapshot(_ context.Context, _ ObjectKind, tenantID uint64, clusterID string) (*Snapshot, error) {
	f.calls++
	key := fmt.Sprintf("%d:%s", tenantID, clusterID)
	if err := f.errors[key]; err != nil {
		return nil, err
	}
	if snapshot := f.snapshots[key]; snapshot != nil {
		return snapshot, nil
	}
	return &Snapshot{Organization: testOrganization(tenantID)}, nil
}

func TestPlannerCreatesParentBeforeChild(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	child := CandidateFromSeed(testSeed(now).Candidates[0])
	parent := Candidate{
		Kind:        ObjectKindResourcePool,
		ClusterID:   "631161640854749185",
		TenantID:    child.TenantID,
		ClusterType: cloudevent.ClusterTypeTidbxResourcePool,
		Evidence:    []Evidence{{Type: EvidenceDerivedParent, ObservedAt: now.Add(-time.Hour), ParentOf: child.ClusterID}},
	}
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(child): {
			Desired:         testDesiredCluster(child, "active", parent.ClusterID),
			SourceUpdatedAt: now.Add(-time.Hour),
			Parent:          &parent,
		},
		candidateKey(parent): {
			Desired:         testDesiredPool(parent, "active"),
			SourceUpdatedAt: now.Add(-time.Hour),
		},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{}, errors: map[string]error{}}
	planner := fixedPlanner(source, store, now)
	output, err := planner.Plan(context.Background(), testSeed(now))
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if len(output.CreateManifest.Actions) != 2 {
		t.Fatalf("create actions = %d", len(output.CreateManifest.Actions))
	}
	if output.CreateManifest.Actions[0].Kind != ObjectKindResourcePool || output.CreateManifest.Actions[1].Kind != ObjectKindMgmtCluster {
		t.Fatalf("actions are not parent-first: %+v", output.CreateManifest.Actions)
	}
	if got := output.CreateManifest.Actions[1].DependencyIDs; len(got) != 1 || got[0] != parent.ClusterID {
		t.Fatalf("child dependencies = %v", got)
	}
	if store.calls != 2 {
		t.Fatalf("store reads = %d, want 2", store.calls)
	}
}

func TestPlannerCreatesSharedResourcePoolForSystemOrganization(t *testing.T) {
	now := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
	seed := &Seed{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		IncidentStart: now.Add(-2 * time.Hour),
		IncidentEnd:   now.Add(-30 * time.Minute),
		Candidates: []SeedCandidate{{
			ClusterID:      "631176026126286850",
			TenantID:       0,
			ClusterType:    cloudevent.ClusterTypeTidbxResourcePool,
			Evidence:       EvidenceClusterEventDLQ,
			EventCreatedAt: now.Add(-time.Hour),
		}},
	}
	candidate := CandidateFromSeed(seed.Candidates[0])
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {
			Desired:         testDesiredPool(candidate, "active"),
			SourceUpdatedAt: now.Add(-time.Hour),
		},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{
		"0:631176026126286850": {Organization: testOrganization(0)},
	}, errors: map[string]error{}}

	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if len(output.CreateManifest.Actions) != 1 || output.CreateManifest.Actions[0].TenantID != 0 ||
		output.CreateManifest.Actions[0].Kind != ObjectKindResourcePool {
		t.Fatalf("unexpected shared-pool create manifest: %+v", output.CreateManifest.Actions)
	}
	if got := output.Report.Counts[ClassificationCreate]; got != 1 {
		t.Fatalf("CREATE count = %d", got)
	}
}

func TestPlannerClassifiesUpdateWithoutRawValues(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	candidate := CandidateFromSeed(seed.Candidates[0])
	desired := testDesiredCluster(candidate, "active", "")
	current := *desired
	current.ID = 100
	current.OrgID = 10
	current.UpdatedAt = now.Add(-2 * time.Hour)
	current.Status = "modifying"
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Desired: desired, SourceUpdatedAt: now.Add(-time.Hour)},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{
		fmt.Sprintf("%d:%s", candidate.TenantID, candidate.ClusterID): {
			Organization: testOrganization(candidate.TenantID),
			Cluster:      &current,
		},
	}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if len(output.UpdateManifest.Actions) != 1 || len(output.CreateManifest.Actions) != 0 {
		t.Fatalf("unexpected manifests: create=%d update=%d", len(output.CreateManifest.Actions), len(output.UpdateManifest.Actions))
	}
	action := output.UpdateManifest.Actions[0]
	if len(action.Fields) != 1 || action.Fields[0] != "status" {
		t.Fatalf("update fields = %v", action.Fields)
	}
	if !isSHA256(action.BeforeChecksum) || !isSHA256(action.DesiredChecksum) {
		t.Fatalf("missing checksums: %+v", action)
	}
	if output.Report.Results[0].Changes[0].BeforeHash == "modifying" || output.Report.Results[0].Changes[0].AfterHash == "active" {
		t.Fatal("plan exposed raw changed values")
	}
}

func TestPlannerBlocksUpdateWhenImmutableCreationTimeDiffers(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	candidate := CandidateFromSeed(seed.Candidates[0])
	desired := testDesiredCluster(candidate, "active", "")
	current := *desired
	current.OrgID = 10
	current.CreatedAt = current.CreatedAt.Add(time.Minute)
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Desired: desired, SourceUpdatedAt: now.Add(-time.Hour)},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{
		fmt.Sprintf("%d:%s", candidate.TenantID, candidate.ClusterID): {
			Organization: testOrganization(candidate.TenantID),
			Cluster:      &current,
		},
	}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatal(err)
	}
	if output.Report.Counts[ClassificationConflict] != 1 || output.Report.Results[0].ReasonCode != "clinic_immutable_mismatch" ||
		len(output.UpdateManifest.Actions) != 0 {
		t.Fatalf("report=%+v update=%+v", output.Report, output.UpdateManifest.Actions)
	}
}

func TestPlannerSkipsDeletedAndUnsupportedWithoutStoreWrites(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	seed.Candidates = append(seed.Candidates, SeedCandidate{
		ClusterID:      "2",
		TenantID:       10392,
		ClusterType:    "not_supported",
		Evidence:       EvidenceClusterEventDLQ,
		EventCreatedAt: now.Add(-time.Hour),
	})
	candidate := CandidateFromSeed(seed.Candidates[0])
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Terminal: true, SourceUpdatedAt: now.Add(-time.Hour)},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if output.Report.Counts[ClassificationSkipDeleted] != 1 || output.Report.Counts[ClassificationSkipUnsupported] != 1 {
		t.Fatalf("counts = %+v", output.Report.Counts)
	}
	if store.calls != 0 {
		t.Fatalf("dry-run store was called %d times", store.calls)
	}
	if len(output.CreateManifest.Actions) != 0 || len(output.UpdateManifest.Actions) != 0 {
		t.Fatal("skip-only plan unexpectedly contains actions")
	}
}

func TestPlannerCreatesDeletedHistoryRecord(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	candidate := CandidateFromSeed(seed.Candidates[0])
	desired := terminalDesired(testDesiredCluster(candidate, "active", ""), now.Add(-time.Hour))
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Desired: desired, SourceUpdatedAt: now.Add(-time.Hour), Terminal: true},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if output.Report.Counts[ClassificationCreate] != 1 || len(output.CreateManifest.Actions) != 1 ||
		!output.CreateManifest.Actions[0].Terminal || len(output.UpdateManifest.Actions) != 0 {
		t.Fatalf("unexpected terminal create plan: report=%+v manifest=%+v", output.Report, output.CreateManifest)
	}
}

func TestPlannerUpdatesActiveRowToDeletedHistory(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	candidate := CandidateFromSeed(seed.Candidates[0])
	current := testDesiredCluster(candidate, "active", "")
	current.ID = 100
	current.OrgID = 10
	desired := terminalDesired(testDesiredCluster(candidate, "active", ""), now.Add(-time.Hour))
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Desired: desired, SourceUpdatedAt: now.Add(-time.Hour), Terminal: true},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{
		fmt.Sprintf("%d:%s", candidate.TenantID, candidate.ClusterID): {
			Organization: testOrganization(candidate.TenantID),
			Cluster:      current,
		},
	}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if output.Report.Counts[ClassificationUpdate] != 1 || len(output.UpdateManifest.Actions) != 1 ||
		!output.UpdateManifest.Actions[0].Terminal {
		t.Fatalf("unexpected terminal update plan: report=%+v manifest=%+v", output.Report, output.UpdateManifest)
	}
	wantFields := []string{"deleted_at", "end_time", "status"}
	if got := output.UpdateManifest.Actions[0].Fields; !slices.Equal(got, wantFields) {
		t.Fatalf("terminal update fields = %v, want %v", got, wantFields)
	}
}

func TestPlannerNoopsMatchingDeletedHistory(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	candidate := CandidateFromSeed(seed.Candidates[0])
	desired := terminalDesired(testDesiredCluster(candidate, "active", ""), now.Add(-time.Hour))
	current := *desired
	current.ID = 100
	current.OrgID = 10
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Desired: desired, SourceUpdatedAt: now.Add(-time.Hour), Terminal: true},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{
		fmt.Sprintf("%d:%s", candidate.TenantID, candidate.ClusterID): {
			Organization: testOrganization(candidate.TenantID),
			Cluster:      &current,
		},
	}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if output.Report.Counts[ClassificationNoop] != 1 || len(output.CreateManifest.Actions) != 0 || len(output.UpdateManifest.Actions) != 0 {
		t.Fatalf("unexpected terminal noop plan: %+v", output.Report)
	}
}

func TestPlannerBlocksChildWhenParentCannotBeRecovered(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	child := CandidateFromSeed(seed.Candidates[0])
	parent := Candidate{Kind: ObjectKindResourcePool, ClusterID: "631161640854749185", TenantID: child.TenantID, ClusterType: cloudevent.ClusterTypeTidbxResourcePool}
	source := &fakePlanSource{
		resolved: map[string]*ResolvedSource{
			candidateKey(child): {Desired: testDesiredCluster(child, "active", parent.ClusterID), SourceUpdatedAt: now.Add(-time.Hour), Parent: &parent},
		},
		errors: map[string]error{candidateKey(parent): ErrSourceNotFound},
	}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if output.Report.Counts[ClassificationBlockedParent] != 1 || output.Report.Counts[ClassificationSkipNotFound] != 1 {
		t.Fatalf("counts = %+v", output.Report.Counts)
	}
	if len(output.CreateManifest.Actions) != 0 {
		t.Fatalf("blocked child produced create action: %+v", output.CreateManifest.Actions)
	}
}

func TestPlannerFailsClosedOnOrganizationMismatch(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	candidate := CandidateFromSeed(seed.Candidates[0])
	desired := testDesiredCluster(candidate, "active", "")
	current := *desired
	current.OrgID = 999
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{
		candidateKey(candidate): {Desired: desired, SourceUpdatedAt: now.Add(-time.Hour)},
	}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{
		fmt.Sprintf("%d:%s", candidate.TenantID, candidate.ClusterID): {Organization: testOrganization(candidate.TenantID), Cluster: &current},
	}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if output.Report.Counts[ClassificationConflict] != 1 {
		t.Fatalf("counts = %+v", output.Report.Counts)
	}
	if len(output.UpdateManifest.Actions) != 0 {
		t.Fatal("ownership conflict produced update action")
	}
}

func TestPlannerRejectsSameIDAcrossObjectKinds(t *testing.T) {
	now := time.Date(2026, 7, 20, 3, 0, 0, 0, time.UTC)
	seed := testSeed(now)
	seed.Candidates = append(seed.Candidates, SeedCandidate{
		ClusterID:      seed.Candidates[0].ClusterID,
		TenantID:       seed.Candidates[0].TenantID,
		ClusterType:    cloudevent.ClusterTypeTidbxResourcePool,
		Evidence:       EvidenceClusterEventDLQ,
		EventCreatedAt: now.Add(-time.Hour),
	})
	source := &fakePlanSource{resolved: map[string]*ResolvedSource{}, errors: map[string]error{}}
	store := &fakeReadStore{snapshots: map[string]*Snapshot{}, errors: map[string]error{}}
	output, err := fixedPlanner(source, store, now).Plan(context.Background(), seed)
	if err != nil {
		t.Fatalf("Plan() error = %v", err)
	}
	if output.Report.Counts[ClassificationConflict] != 2 || source.calls != 0 || store.calls != 0 {
		t.Fatalf("counts=%+v source_calls=%d store_calls=%d", output.Report.Counts, source.calls, store.calls)
	}
}

func TestClassifySourceErrorDoesNotExposeWrappedMessage(t *testing.T) {
	classification, reason := classifySourceError(errors.New("sensitive downstream detail"))
	if classification != ClassificationSourceError || reason != "source_read_error" {
		t.Fatalf("classification=%s reason=%s", classification, reason)
	}
}

func fixedPlanner(source Source, store ReadStore, now time.Time) *Planner {
	planner := NewPlanner(source, store)
	planner.now = func() time.Time { return now }
	planner.runID = func() (string, error) { return "staging-test-run", nil }
	return planner
}

func testSeed(now time.Time) *Seed {
	return &Seed{
		SchemaVersion: SchemaVersion,
		Environment:   StagingEnvironment,
		IncidentStart: now.Add(-2 * time.Hour),
		IncidentEnd:   now.Add(-30 * time.Minute),
		Candidates: []SeedCandidate{{
			ClusterID:      "10914645658854034992",
			TenantID:       10392,
			ClusterType:    cloudevent.ClusterTypeTidbxInstance,
			Evidence:       EvidenceClusterEventDLQ,
			EventCreatedAt: now.Add(-time.Hour),
		}},
	}
}

func testOrganization(tenantID uint64) *db.Organization {
	return &db.Organization{
		Model:      gorm.Model{ID: 10},
		Name:       "masked-test-org",
		DbaasOrgID: uint(tenantID),
		OrgType:    string(view.CloudDataSource),
	}
}

func testDesiredCluster(candidate Candidate, status, parentID string) *db.Cluster {
	return &db.Cluster{
		Model:        gorm.Model{CreatedAt: time.Date(2026, 7, 19, 1, 0, 0, 0, time.UTC)},
		ClusterID:    candidate.ClusterID,
		ClusterType:  "cloud",
		TenantID:     candidate.TenantID,
		Name:         "masked-test-cluster",
		Status:       status,
		Provider:     "aws",
		Region:       "us-west-2",
		DeployType:   "premium",
		DeployTypeV2: "premium",
		Version:      "v8.5.0",
		ParentID:     parentID,
	}
}

func testDesiredPool(candidate Candidate, status string) *db.Cluster {
	cluster := testDesiredCluster(candidate, status, "")
	cluster.DeployType = "ng"
	cluster.DeployTypeV2 = "premium-resource-pool"
	return cluster
}

func terminalDesired(cluster *db.Cluster, deletedAt time.Time) *db.Cluster {
	copyCluster := *cluster
	copyCluster.Status = "deleted"
	copyCluster.DeletedAt = gorm.DeletedAt{Time: deletedAt.UTC(), Valid: true}
	copyCluster.EndTime = uint64(deletedAt.Unix())
	return &copyCluster
}
