package controlplane

import (
	"context"
	"database/sql"
	"errors"
	"regexp"
	"strings"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/go-sql-driver/mysql"
	"github.com/pingcap-inc/nexuscrm-controlplane/packs"
)

func TestNormalizeApplyAllSourceRevisionAdvancePlanCanonicalizesLegacyAliasAndRejectsWholeTupleDuplicates(t *testing.T) {
	plan, err := normalizeApplyAllSourceRevisionAdvancePlan([]SourceRevisionAdvanceAuthorization{
		{Name: "crm", Kind: "in_repo", URI: "crm", FromRevision: "old", ToRevision: "new"},
		{Name: "issue", Kind: string(packs.PackSourceKindOfficialRepo), URI: "issue", FromRevision: "old-issue", ToRevision: "new-issue"},
	})
	if err != nil {
		t.Fatalf("normalize plan: %v", err)
	}
	if len(plan) != 2 {
		t.Fatalf("plan length = %d, want 2", len(plan))
	}
	if got := plan[0].Kind; got != string(packs.PackSourceKindOfficialRepo) {
		t.Fatalf("legacy alias kind = %q, want official_repo", got)
	}

	releaseURI := "urn:nexuscrm:release-pack:pingcap-inc:nexuscrm-pingcap-pack:companion"
	releasePlan, err := normalizeApplyAllSourceRevisionAdvancePlan([]SourceRevisionAdvanceAuthorization{
		{Name: "companion", Kind: string(packs.PackSourceKindReleaseRepo), URI: releaseURI, FromRevision: strings.Repeat("a", 40), ToRevision: strings.Repeat("b", 40)},
	})
	if err != nil {
		t.Fatalf("normalize release_repo plan: %v", err)
	}
	if len(releasePlan) != 1 || releasePlan[0].URI != releaseURI {
		t.Fatalf("release_repo plan = %#v, want stable non-pack-name URI", releasePlan)
	}

	_, err = normalizeApplyAllSourceRevisionAdvancePlan([]SourceRevisionAdvanceAuthorization{
		{Name: "crm", Kind: "in_repo", URI: "crm", FromRevision: "old", ToRevision: "new"},
		{Name: "crm", Kind: string(packs.PackSourceKindOfficialRepo), URI: "crm", FromRevision: "older", ToRevision: "newer"},
	})
	var planErr *ApplyAllSourceRevisionAdvancePlanError
	if !errors.As(err, &planErr) || planErr.Stage != ApplyAllSourceRevisionAdvancePlanValidate {
		t.Fatalf("duplicate error = %v, want typed validate error", err)
	}

	_, err = normalizeApplyAllSourceRevisionAdvancePlan([]SourceRevisionAdvanceAuthorization{
		{Name: "crm", Kind: "in_repo:shadow", URI: "crm", FromRevision: "old", ToRevision: "new"},
	})
	if !errors.As(err, &planErr) || planErr.Stage != ApplyAllSourceRevisionAdvancePlanValidate {
		t.Fatalf("delimiter-smuggled kind error = %v, want typed validate error", err)
	}
}

func TestClassifyApplyAllSourceRevisionAdvanceStateMatrix(t *testing.T) {
	advance := SourceRevisionAdvanceAuthorization{
		Name: "crm", Kind: string(packs.PackSourceKindOfficialRepo), URI: "crm", FromRevision: "old", ToRevision: "new",
	}
	tests := []struct {
		name     string
		status   string
		kind     string
		revision string
		want     ApplyAllSourceRevisionAdvancePendingKind
		wantNoop bool
		wantErr  bool
	}{
		{name: "active from advances", status: installedPackStatusActive, kind: string(packs.PackSourceKindOfficialRepo), revision: "old", want: ApplyAllSourceRevisionAdvancePendingAdvance},
		{name: "active target no-op", status: installedPackStatusActive, kind: string(packs.PackSourceKindOfficialRepo), revision: "new", wantNoop: true},
		{name: "legacy alias target canonicalizes", status: installedPackStatusActive, kind: "in_repo", revision: "new", want: ApplyAllSourceRevisionAdvancePendingCanonicalize},
		{name: "failed target retries", status: installedPackStatusFailed, kind: string(packs.PackSourceKindOfficialRepo), revision: "new", want: ApplyAllSourceRevisionAdvancePendingRetry},
		{name: "installing target retries", status: installedPackStatusInstalling, kind: string(packs.PackSourceKindOfficialRepo), revision: "new", want: ApplyAllSourceRevisionAdvancePendingRetry},
		{name: "failed from rejected", status: installedPackStatusFailed, kind: string(packs.PackSourceKindOfficialRepo), revision: "old", wantErr: true},
		{name: "unplanned active revision rejected", status: installedPackStatusActive, kind: string(packs.PackSourceKindOfficialRepo), revision: "other", wantErr: true},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			pending, err := classifyApplyAllSourceRevisionAdvance(installedPackRow{
				Name: "crm", Status: test.status,
				SourceKind:     sql.NullString{Valid: true, String: test.kind},
				SourceURI:      sql.NullString{Valid: true, String: "crm"},
				SourceRevision: sql.NullString{Valid: true, String: test.revision},
			}, advance)
			if test.wantErr {
				if err == nil {
					t.Fatal("classify error = nil, want error")
				}
				return
			}
			if err != nil {
				t.Fatalf("classify: %v", err)
			}
			if test.wantNoop {
				if pending != nil {
					t.Fatalf("pending = %#v, want nil", pending)
				}
				return
			}
			if pending == nil || pending.Kind != test.want {
				t.Fatalf("pending = %#v, want kind %q", pending, test.want)
			}
		})
	}
}

func TestPendingApplyAllSourceRevisionAdvancesReadsFullTupleWithoutMutation(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	expectApplyAllSourceRevisionAdvanceRow(mock, "crm", installedPackStatusActive, string(packs.PackSourceKindOfficialRepo), "old")
	expectApplyAllSourceRevisionAdvanceRow(mock, "issue", installedPackStatusActive, string(packs.PackSourceKindOfficialRepo), "new-issue")
	status, err := PendingApplyAllSourceRevisionAdvances(context.Background(), db, InstallPackOptions{
		ApplyAllSourceRevisionAdvances: []SourceRevisionAdvanceAuthorization{
			{Name: "crm", Kind: "in_repo", URI: "crm", FromRevision: "old", ToRevision: "new"},
			{Name: "issue", Kind: string(packs.PackSourceKindOfficialRepo), URI: "issue", FromRevision: "old-issue", ToRevision: "new-issue"},
		},
	})
	if err != nil {
		t.Fatalf("PendingApplyAllSourceRevisionAdvances: %v", err)
	}
	if len(status.Pending) != 1 || status.Pending[0].Authorization.Name != "crm" || status.Pending[0].Kind != ApplyAllSourceRevisionAdvancePendingAdvance {
		t.Fatalf("pending = %#v, want only crm advance", status.Pending)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestPendingApplyAllSourceRevisionAdvancesReturnsTypedReadError(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(applyAllSourceRevisionAdvanceRowQueryPattern()).WithArgs("crm").WillReturnError(sql.ErrConnDone)

	_, err = PendingApplyAllSourceRevisionAdvances(context.Background(), db, InstallPackOptions{
		ApplyAllSourceRevisionAdvances: []SourceRevisionAdvanceAuthorization{
			{Name: "crm", Kind: string(packs.PackSourceKindOfficialRepo), URI: "crm", FromRevision: "old", ToRevision: "new"},
		},
	})
	var planErr *ApplyAllSourceRevisionAdvancePlanError
	if !errors.As(err, &planErr) || planErr.Stage != ApplyAllSourceRevisionAdvancePlanRead {
		t.Fatalf("error = %v, want typed read error", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestPendingApplyAllSourceRevisionAdvancesToleratesPreIncarnationProjection(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery(applyAllSourceRevisionAdvanceRowQueryPattern()).WithArgs("crm").
		WillReturnError(&mysql.MySQLError{Number: 1054, Message: "Unknown column 'HostRequirementsDigest' in 'field list'"})
	mock.ExpectQuery(`(?s)` + regexp.QuoteMeta("SELECT Id, Name, COALESCE(PackInstallId, ''), Category, Status, COALESCE(IsDeleted, 0)") + `.*` + regexp.QuoteMeta("SourceRevision") + `.*` + regexp.QuoteMeta("FROM _System_InstalledPack")).WithArgs("crm").
		WillReturnError(&mysql.MySQLError{Number: 1054, Message: "Unknown column 'PackInstallId' in 'field list'"})
	mock.ExpectQuery(`(?s)` + regexp.QuoteMeta("SELECT Name, Category, Status, COALESCE(IsDeleted, 0)") + `.*` + regexp.QuoteMeta("SourceRevision") + `.*` + regexp.QuoteMeta("FROM _System_InstalledPack")).WithArgs("crm").
		WillReturnRows(sqlmock.NewRows([]string{
			"Name", "Category", "Status", "IsDeleted", "PackVersion", "ManifestFormatVersion", "PlatformCompatibility",
			"DesiredPayloadHash", "AppliedPayloadHash", "PayloadHashAlgorithm", "SourceKind", "SourceURI", "SourceRevision",
		}).AddRow("crm", string(packs.AppPack), installedPackStatusActive, 0, "1.0.0", packs.SupportedManifestFormatVersion, nil,
			"new-hash", "old-hash", packs.PackPayloadHashAlgorithmV1, string(packs.PackSourceKindOfficialRepo), "crm", "old"))

	status, err := PendingApplyAllSourceRevisionAdvances(context.Background(), db, InstallPackOptions{
		ApplyAllSourceRevisionAdvances: []SourceRevisionAdvanceAuthorization{{
			Name: "crm", Kind: string(packs.PackSourceKindOfficialRepo), URI: "crm", FromRevision: "old", ToRevision: "new",
		}},
	})
	if err != nil {
		t.Fatalf("PendingApplyAllSourceRevisionAdvances: %v", err)
	}
	if len(status.Pending) != 1 || status.Pending[0].Kind != ApplyAllSourceRevisionAdvancePendingAdvance {
		t.Fatalf("pending = %#v, want one stale-read advance", status.Pending)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestRequireApplyAllSourceRevisionAdvancePlanCompletedRejectsEveryPendingKind(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	expectApplyAllSourceRevisionAdvanceRow(mock, "advance", installedPackStatusActive, string(packs.PackSourceKindOfficialRepo), "old-advance")
	expectApplyAllSourceRevisionAdvanceRow(mock, "retry", installedPackStatusFailed, string(packs.PackSourceKindOfficialRepo), "new-retry")
	expectApplyAllSourceRevisionAdvanceRow(mock, "canonicalize", installedPackStatusActive, "in_repo", "new-canonicalize")

	err = requireApplyAllSourceRevisionAdvancePlanCompleted(context.Background(), db, InstallPackOptions{
		ApplyAllSourceRevisionAdvances: []SourceRevisionAdvanceAuthorization{
			{Name: "advance", Kind: string(packs.PackSourceKindOfficialRepo), URI: "advance", FromRevision: "old-advance", ToRevision: "new-advance"},
			{Name: "retry", Kind: string(packs.PackSourceKindOfficialRepo), URI: "retry", FromRevision: "old-retry", ToRevision: "new-retry"},
			{Name: "canonicalize", Kind: string(packs.PackSourceKindOfficialRepo), URI: "canonicalize", FromRevision: "old-canonicalize", ToRevision: "new-canonicalize"},
		},
	})
	if err == nil {
		t.Fatal("completion gate error = nil, want pending-plan rejection")
	}
	for _, fragment := range []string{
		"3 planned source revision advance(s) remain pending",
		"advance(advance:old-advance->new-advance,status=active)",
		"retry(retry:old-retry->new-retry,status=failed)",
		"canonicalize(canonicalize:old-canonicalize->new-canonicalize,status=active)",
	} {
		if !strings.Contains(err.Error(), fragment) {
			t.Fatalf("completion gate error = %q, want %q", err, fragment)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestRequireApplyAllSourceRevisionAdvancePlanCompletedAcceptsExactTargets(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	expectApplyAllSourceRevisionAdvanceRow(mock, "crm", installedPackStatusActive, string(packs.PackSourceKindOfficialRepo), "new")
	err = requireApplyAllSourceRevisionAdvancePlanCompleted(context.Background(), db, InstallPackOptions{
		ApplyAllSourceRevisionAdvances: []SourceRevisionAdvanceAuthorization{{
			Name: "crm", Kind: string(packs.PackSourceKindOfficialRepo), URI: "crm", FromRevision: "old", ToRevision: "new",
		}},
	})
	if err != nil {
		t.Fatalf("completion gate: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestApplyAllGatesCompletedMarkerOnSourceRevisionPlan(t *testing.T) {
	applyAll := parseControlplaneFuncDecl(t, "operations.go", "applyAllWithOptions")
	completionGates := findFuncCallByName(applyAll, "runApplyAllCompletionGates")
	if completionGates == nil {
		t.Fatal("applyAllWithOptions does not call its completion gates")
	}
	gateOwner := parseControlplaneFuncDecl(t, "operations.go", "runApplyAllCompletionGates")
	gate := findFuncCallByName(gateOwner, "requireApplyAllSourceRevisionAdvancePlanCompleted")
	if gate == nil {
		t.Fatal("ApplyAll completion gates do not call the source revision completion gate")
	}
	marker := findFuncCallByName(applyAll, "MarkControlPlaneApplyAllCompleted")
	if marker == nil {
		t.Fatal("applyAllWithOptions does not mark ApplyAll completed")
	}
	if completionGates.Pos() >= marker.Pos() {
		t.Fatal("source revision completion gate must run before the completed marker write")
	}
}

func TestApplyAllExecutesAuthorizedSourceRevisionPlanBeforeLegacyReconcileAndFinalGate(t *testing.T) {
	applyAll := parseControlplaneFuncDecl(t, "operations.go", "applyAllWithOptions")
	execute := findFuncCallByName(applyAll, "applyAllSourceRevisionAdvances")
	legacyReconcile := findFuncCallByName(applyAll, "reconcileActiveInstalledPackSourceState")
	finalGate := findFuncCallByName(applyAll, "runApplyAllCompletionGates")
	if execute == nil || legacyReconcile == nil || finalGate == nil {
		t.Fatalf(
			"ApplyAll source revision wiring missing: execute=%v legacy_reconcile=%v final_gate=%v",
			execute != nil, legacyReconcile != nil, finalGate != nil,
		)
	}
	if execute.Pos() >= legacyReconcile.Pos() || legacyReconcile.Pos() >= finalGate.Pos() {
		t.Fatal("exact source revision execution must precede legacy reconcile and the completed-marker final gate")
	}
}

func TestApplyAllSourceRevisionAdvanceOrchestratorConsumesExactChainInOrder(t *testing.T) {
	execute := parseControlplaneFuncDecl(
		t,
		"apply_all_source_revision_advance_orchestration.go",
		"executeApplyAllSourceRevisionAdvanceAdmission",
	)
	names := []string{
		"prepareApplyAllSourceRevisionAdvanceAttempt",
		"preflightApplyAllSourceRevisionAdvanceSelectedTarget",
		"beginApplyAllSourceRevisionAdvanceAttempt",
		"captureApplyAllSourceRevisionAdvanceSnapshots",
		"registerApplyAllSourceRevisionAdvanceRuntime",
		"executeApplyAllSourceRevisionAdvanceSelectedAtom",
		"prepareApplyAllSourceRevisionAdvanceActivation",
		"completeApplyAllSourceRevisionAdvanceExecution",
		"reconcileApplyAllSourceRevisionAdvanceAmbiguousActivationUnderApplyLock",
		"compensateApplyAllSourceRevisionAdvanceExecution",
		"NotifySourceBackedConnectedAppChanges",
		"reconcileConnectorInboundWorkersAfterPackLifecycle",
		"RefreshCache",
	}
	previous := execute.Pos()
	for _, name := range names {
		call := findFuncCallByName(execute, name)
		if call == nil {
			t.Fatalf("exact source revision orchestrator does not call %s", name)
		}
		if call.Pos() <= previous {
			t.Fatalf("exact source revision orchestrator calls %s out of lifecycle order", name)
		}
		previous = call.Pos()
	}
}

func TestApplyAllSourceRevisionAdvanceFencesGenerationAuthorityBeforeMutation(t *testing.T) {
	orchestrator := parseControlplaneFuncDecl(
		t,
		"apply_all_source_revision_advance_orchestration.go",
		"applyAllSourceRevisionAdvances",
	)
	compose := findFuncCallByName(orchestrator, "composeApplyAllSourceRevisionAdvanceAdmission")
	preflight := findFuncCallByName(orchestrator, "preflightApplyAllSourceRevisionAdvanceGenerationAuthorities")
	pendingCount := findFuncCallByName(orchestrator, "applyAllSourceRevisionAdvanceAdmissionPendingCount")
	execute := findFuncCallByName(orchestrator, "executeApplyAllSourceRevisionAdvanceAdmission")
	canonicalize := findFuncCallByName(orchestrator, "canonicalizeApplyAllSourceRevisionAdvanceInstalledRow")
	if compose == nil || preflight == nil || pendingCount == nil || execute == nil || canonicalize == nil {
		t.Fatalf(
			"ApplyAll generation authority wiring missing: compose=%v preflight=%v pending=%v canonicalize=%v execute=%v",
			compose != nil,
			preflight != nil,
			pendingCount != nil,
			canonicalize != nil,
			execute != nil,
		)
	}
	if compose.Pos() >= preflight.Pos() ||
		preflight.Pos() >= pendingCount.Pos() ||
		preflight.Pos() >= canonicalize.Pos() ||
		preflight.Pos() >= execute.Pos() {
		t.Fatal("ApplyAll must classify generation authority before trusting no-op or performing any mutation")
	}

	preflightFunc := parseControlplaneFuncDecl(
		t,
		"apply_all_source_revision_advance_orchestration.go",
		"preflightApplyAllSourceRevisionAdvanceGenerationAuthorities",
	)
	if findFuncCallByName(preflightFunc, "verifySourceRevisionAdvanceGenerationAuthority") == nil {
		t.Fatal("ApplyAll generation preflight does not bind immutable authority to InstalledPack")
	}
}

func TestApplyAllCompletedMarkerSkipConsultsSourceRevisionPlanBeforeMutation(t *testing.T) {
	applyAll := parseControlplaneFuncDecl(t, "operations.go", "applyAllWithOptions")
	plan := findFuncCallByName(applyAll, "PendingApplyAllSourceRevisionAdvances")
	if plan == nil {
		t.Fatal("applyAllWithOptions does not consult the source revision plan before trusting a completed marker")
	}
	marker := findFuncCallByName(applyAll, "MarkControlPlaneApplyAllRunning")
	if marker == nil {
		t.Fatal("applyAllWithOptions does not mark a mutating ApplyAll running")
	}
	if plan.Pos() >= marker.Pos() {
		t.Fatal("completed-marker source revision classification must run before the first mutating marker write")
	}
}

func TestSourceRevisionAdvanceAuthorizationAcceptsInRepoAliasAndPreservesPayloadPrecondition(t *testing.T) {
	row := installedPackRow{
		Name: "crm", AppliedPayloadHash: sql.NullString{Valid: true, String: "old-hash"},
		SourceKind: sql.NullString{Valid: true, String: "in_repo"}, SourceURI: sql.NullString{Valid: true, String: "crm"},
		SourceRevision: sql.NullString{Valid: true, String: "old"},
	}
	meta := packs.PackHashMetadata{
		SourceKind: string(packs.PackSourceKindOfficialRepo), SourceURI: "crm", SourceRevision: "new", SourceRevisionValid: true,
	}
	auth := SourceRevisionAdvanceAuthorization{Name: "crm", Kind: string(packs.PackSourceKindOfficialRepo), URI: "crm", FromRevision: "old", ToRevision: "new"}
	restore := stubSourceRevisionAdvancePayloadValidation(t, "old-hash")
	defer restore()
	if err := sourceRevisionAdvanceAuthorizationError(context.Background(), nil, row, "crm", meta, &auth); err != nil {
		t.Fatalf("source revision authorization with in_repo row: %v", err)
	}
	auth.Kind = "official_repo:shadow"
	if err := sourceRevisionAdvanceAuthorizationError(context.Background(), nil, row, "crm", meta, &auth); err == nil {
		t.Fatal("source revision authorization accepted delimiter-smuggled kind")
	}
}

func TestSourceIdentityMatchesCanonicalizesOnlySupportedLegacyAlias(t *testing.T) {
	row := installedPackRow{
		SourceKind: sql.NullString{Valid: true, String: "in_repo"}, SourceURI: sql.NullString{Valid: true, String: "crm"},
		SourceRevision: sql.NullString{Valid: true, String: "new"},
	}
	meta := packs.PackHashMetadata{
		SourceKind: string(packs.PackSourceKindOfficialRepo), SourceURI: "crm", SourceRevision: "new", SourceRevisionValid: true,
	}
	if !sourceIdentityMatches(row, meta) {
		t.Fatal("sourceIdentityMatches(in_repo, official_repo) = false, want true")
	}
	row.SourceKind.String = "repository"
	if sourceIdentityMatches(row, meta) {
		t.Fatal("sourceIdentityMatches invented alias = true, want false")
	}
	row.SourceKind.String = "in_repo:shadow"
	if sourceIdentityMatches(row, meta) {
		t.Fatal("sourceIdentityMatches delimiter-smuggled alias = true, want false")
	}
}

func expectApplyAllSourceRevisionAdvanceRow(mock sqlmock.Sqlmock, name, status, kind, revision string) {
	mock.ExpectQuery(applyAllSourceRevisionAdvanceRowQueryPattern()).WithArgs(name).WillReturnRows(
		sqlmock.NewRows([]string{
			"Id", "Name", "PackInstallId", "Category", "Status", "IsDeleted", "PackVersion", "ManifestFormatVersion", "PlatformCompatibility",
			"DesiredPayloadHash", "AppliedPayloadHash", "PayloadHashAlgorithm", "SourceKind", "SourceURI", "SourceRevision", "HostRequirementsDigest",
		}).AddRow("row-"+name, name, "install-"+name, string(packs.AppPack), status, 0, "1.0.0", packs.SupportedManifestFormatVersion, nil,
			"new-hash", "old-hash", packs.PackPayloadHashAlgorithmV1, kind, name, revision, nil),
	)
}

func applyAllSourceRevisionAdvanceRowQueryPattern() string {
	return `(?s)` + regexp.QuoteMeta("SELECT Id, Name, COALESCE(PackInstallId, ''), Category, Status, COALESCE(IsDeleted, 0)") + `.*` + regexp.QuoteMeta("HostRequirementsDigest") + `.*` + regexp.QuoteMeta("FROM _System_InstalledPack") + `.*` + regexp.QuoteMeta("WHERE Name = ?") + `.*` + regexp.QuoteMeta("LIMIT 1")
}

func TestMarkOfflineCandidateCompleteSourceRevisionReplaysCarriesOnlyMintedRetryNames(t *testing.T) {
	plan, err := normalizeApplyAllSourceRevisionAdvancePlan([]SourceRevisionAdvanceAuthorization{
		{Name: "CRM", Kind: string(packs.PackSourceKindOfficialRepo), URI: "CRM", FromRevision: "old", ToRevision: "new"},
		{Name: "companion", Kind: string(packs.PackSourceKindOfficialRepo), URI: "companion", FromRevision: "old-companion", ToRevision: "new-companion"},
	})
	if err != nil {
		t.Fatalf("normalize plan: %v", err)
	}
	if err := markOfflineCandidateCompleteSourceRevisionReplays(plan, map[string]struct{}{"crm": {}}); err != nil {
		t.Fatalf("mark complete replay: %v", err)
	}
	if !plan[0].completeReplay || plan[1].completeReplay {
		t.Fatalf("complete replay plan = %#v, want only crm", plan)
	}
	if err := markOfflineCandidateCompleteSourceRevisionReplays(plan, map[string]struct{}{"missing": {}}); err == nil || !strings.Contains(err.Error(), "outside the normalized plan") {
		t.Fatalf("unknown complete replay error = %v, want fail-closed plan mismatch", err)
	}
}
