package approvalapp

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/service/appruntime"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	appregistry "github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/approval"
	"github.com/tidbcloud/AgentTeam/internal/store/provider"
)

func TestDeterministicAppChangeReviewFailureClassification(t *testing.T) {

	tests := []struct {
		name    string
		err     error
		request app.AppInstallationRequestView
		want    bool
	}{
		{
			name: "gitea typed provider rejection",
			err:  fmt.Errorf("wrapped: %w", app.ErrGiteaConnectionConfigurationFailed),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GiteaAppID, RequestKind: app.AppInstallationRequestKindConnect,
			},
			want: true,
		},
		{
			name: "plain github connection conflict may belong to the acting reviewer",
			err:  fmt.Errorf("wrapped: %w", app.ErrConflict),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GitHubAppID, RequestKind: app.AppInstallationRequestKindDisconnect,
			},
			want: false,
		},
		{
			name: "plain provider forbidden may belong to the acting reviewer",
			err:  fmt.Errorf("wrapped: %w", app.ErrForbidden),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GiteaAppID, RequestKind: app.AppInstallationRequestKindConnect,
			},
			want: false,
		},
		{
			name: "ordinary bad request remains retryable",
			err:  fmt.Errorf("wrapped: %w", app.ErrBadRequest),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GiteaAppID, RequestKind: app.AppInstallationRequestKindConnect,
			},
			want: false,
		},
		{
			name: "gitea configure stale sentinel",
			err:  fmt.Errorf("wrapped: %w", app.ErrAppInstallationApprovalStale),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GiteaAppID, RequestKind: app.AppInstallationRequestKindConfigure,
			},
			want: true,
		},
		{
			name: "github install stale sentinel",
			err:  fmt.Errorf("wrapped: %w", app.ErrAppInstallationApprovalStale),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GitHubAppID, RequestKind: app.AppInstallationRequestKindInstall,
			},
			want: true,
		},
		{
			name: "github uninstall stale sentinel",
			err:  fmt.Errorf("wrapped: %w", app.ErrAppInstallationApprovalStale),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GitHubAppID, RequestKind: app.AppInstallationRequestKindUninstall,
			},
			want: true,
		},
		{
			name: "temporary github setup verification conflict remains retryable",
			err:  fmt.Errorf("wrapped: %w", app.ErrConflict),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GitHubAppID, RequestKind: app.AppInstallationRequestKindInstall,
			},
			want: false,
		},
		{
			name: "plain gitea configure conflict is not enough",
			err:  fmt.Errorf("wrapped: %w", app.ErrConflict),
			request: app.AppInstallationRequestView{
				AppID: appregistry.GiteaAppID, RequestKind: app.AppInstallationRequestKindConfigure,
			},
			want: false,
		},
		{
			name: "non-provider install stale sentinel",
			err:  fmt.Errorf("wrapped: %w", app.ErrAppInstallationApprovalStale),
			request: app.AppInstallationRequestView{
				AppID: "app_custom", RequestKind: app.AppInstallationRequestKindInstall,
			},
			want: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			require.Equal(t, tt.want, isDeterministicAppChangeReviewFailure(tt.err, tt.request))
		})
	}
	require.True(t, isPotentialAppChangeReviewFailure(app.ErrAppInstallationApprovalStale))
}

func TestProviderConnectionReviewRetryExhaustion(t *testing.T) {

	giteaRequest := app.AppInstallationRequestView{
		AppID: appregistry.GiteaAppID, RequestKind: app.AppInstallationRequestKindConnect,
	}
	transient := fmt.Errorf("provider transport failed")
	require.False(t, isProviderConnectionReviewRetryExhausted(1, transient, giteaRequest))
	require.False(t, isProviderConnectionReviewRetryExhausted(2, transient, giteaRequest))
	require.True(t, isProviderConnectionReviewRetryExhausted(3, transient, giteaRequest))
	require.True(t, isProviderConnectionReviewRetryExhausted(9, transient, giteaRequest))
	require.False(t, isProviderConnectionReviewRetryExhausted(9, app.ErrForbidden, giteaRequest))
	require.False(t, isProviderConnectionReviewRetryExhausted(9, app.ErrConflict, giteaRequest))
	require.False(t, isProviderConnectionReviewRetryExhausted(9, app.ErrNotFound, giteaRequest))

	nonProviderRequest := app.AppInstallationRequestView{
		AppID: "app_custom", RequestKind: app.AppInstallationRequestKindInstall,
	}
	require.False(t, isProviderConnectionReviewRetryExhausted(9, transient, nonProviderRequest))
	providerConfigureRequest := app.AppInstallationRequestView{
		AppID: appregistry.GiteaAppID, RequestKind: app.AppInstallationRequestKindConfigure,
	}
	require.False(t, isProviderConnectionReviewRetryExhausted(9, transient, providerConfigureRequest))
}

func TestApprovalAppStaleProviderConfigureConvergesToRejectOnlyWithoutRetry(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 21, 6, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_stale_gitea_configure"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
		PublicBaseURL:           "https://agentteam.example.test",
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	handler := NewHandler(approval.New(db), notifications, projector, sealedEmptySubjectRegistry())
	registry := appruntime.NewInternalHandlerRegistry()
	require.NoError(t, registry.Register(HandlerKey, handler))
	registry.Seal()
	dispatcher := appruntime.NewInternalDispatcher(appruntime.InternalDispatcherOptions{
		Store: notifications, Registry: registry, InstanceID: "approval-app-stale-gitea-configure", ClaimLimit: 10,
	})

	const instanceURL = "https://gitea.example.test"
	_, err := notifications.SaveGiteaAppConfigForWorkspaceAdmin(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		provider.UpsertGiteaAppConfigParams{
			InstanceURL:   instanceURL,
			APIToken:      "gitea-configure-token",
			WebhookSecret: "gitea-configure-webhook-secret",
			DisplayName:   "Original Gitea",
		},
	)
	require.NoError(t, err)

	var giteaInstallationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, appregistry.GiteaAppID).Scan(&giteaInstallationID))

	requestConfigure := func(displayName string) app.AppInstallationRequestView {
		t.Helper()
		request, requestErr := notifications.RequestAppInstallationForUser(
			ctx,
			"usr_approval_bot_member",
			workspaceID,
			app.RequestAppInstallationParams{
				AppID:          appregistry.GiteaAppID,
				InstallationID: giteaInstallationID,
				RequestKind:    app.AppInstallationRequestKindConfigure,
				ExpectedBaseConfig: map[string]any{
					"instance_url": instanceURL,
					"display_name": "Original Gitea",
				},
				Config: map[string]any{
					"instance_url":   instanceURL,
					"api_token":      "",
					"webhook_secret": "",
					"display_name":   displayName,
				},
			},
		)
		require.NoError(t, requestErr)
		return request
	}

	winner := requestConfigure("Winning Gitea")
	stale := requestConfigure("Stale Gitea")
	staleCards, err := projector.ProjectPendingAppInstallationRequest(ctx, stale)
	require.NoError(t, err)
	require.Len(t, staleCards, 1)

	approved, err := notifications.ApproveAppInstallationRequestForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		winner.ID,
		app.DecideAppInstallationRequestParams{Reason: "first configuration wins"},
	)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusApproved, approved.Status)

	var currentCard app.AppNotificationResult
	for _, card := range staleCards {
		record, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		if record.RecipientMemberID != nil && *record.RecipientMemberID == "mem_approval_bot_admin" {
			currentCard = card
		}
	}
	require.NotEmpty(t, currentCard.NotificationID)
	_, err = notifications.InvokeAppNotificationActionForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		app.AppNotificationActionCommand{
			MessageID:      currentCard.MessageID,
			ActionKey:      ActionApprove,
			CardRevision:   1,
			ClientActionID: "approve-stale-gitea-configuration",
			Input:          map[string]any{},
		},
	)
	require.NoError(t, err)

	var approvalAppInstallationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, DefinitionID).Scan(&approvalAppInstallationID))
	dispatched, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: approvalAppInstallationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed)
	require.Zero(t, dispatched.Retried)

	stillPending, err := notifications.GetAppInstallationRequest(ctx, workspaceID, stale.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, stillPending.Status)
	require.Equal(t, app.AppInstallationRequestReviewStateCannotBeApplied, stillPending.ReviewState)
	_, err = notifications.ApproveAppInstallationRequestForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		stale.ID,
		app.DecideAppInstallationRequestParams{Reason: "must stay fenced"},
	)
	require.ErrorIs(t, err, app.ErrAppInstallationApprovalStale)
	reviewers, applicants := providerChangeFailureNotifications(t, ctx, notifications, workspaceID, stale.ID)
	require.Len(t, reviewers, 1)
	require.Len(t, applicants, 1)
	for _, record := range reviewers {
		require.Equal(t, app.AppNotificationStatusActive, record.Status)
		card, loadErr := notifications.LoadAppNotificationCardInTx(ctx, db, record.MessageID, false)
		require.NoError(t, loadErr)
		var document struct {
			Text    string `json:"text"`
			TextKey string `json:"textKey"`
			Actions []struct {
				ActionKey string `json:"actionKey"`
			} `json:"actions"`
		}
		require.NoError(t, json.Unmarshal(card.RenderPayloadJSON, &document))
		require.Equal(t, appInstallationChangeCannotBeAppliedText, document.Text)
		require.Equal(t, appInstallationChangeCannotBeAppliedTextKey, document.TextKey)
		require.Len(t, document.Actions, 1)
		require.Equal(t, ActionReject, document.Actions[0].ActionKey)
	}
	require.Equal(t, app.AppNotificationStatusActive, applicants[0].Status)
	require.Equal(t, int64(1), applicants[0].Revision)

	// The reject-only state lives on the durable request row, not only in the
	// reviewer cards that happened to exist when the stale approval ran. A
	// newly promoted administrator must inherit it and never receive a fresh
	// Approve action for the same immutable loser proposal.
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin' WHERE id = 'mem_approval_bot_reader'`)
	reprojected, err := projector.ProjectPendingAppInstallationRequest(ctx, stillPending)
	require.NoError(t, err)
	require.Len(t, reprojected, 2)
	reviewers, applicants = providerChangeFailureNotifications(t, ctx, notifications, workspaceID, stale.ID)
	require.Len(t, reviewers, 2)
	require.Len(t, applicants, 1)
	for _, record := range reviewers {
		card, loadErr := notifications.LoadAppNotificationCardInTx(ctx, db, record.MessageID, false)
		require.NoError(t, loadErr)
		var document struct {
			TextKey string `json:"textKey"`
			Actions []struct {
				ActionKey string `json:"actionKey"`
			} `json:"actions"`
		}
		require.NoError(t, json.Unmarshal(card.RenderPayloadJSON, &document))
		require.Equal(t, appInstallationChangeCannotBeAppliedTextKey, document.TextKey)
		require.Len(t, document.Actions, 1)
		require.Equal(t, ActionReject, document.Actions[0].ActionKey)
	}
}

func TestApprovalAppReconcilesDurableRejectOnlyFenceAfterProjectionCrash(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 21, 6, 30, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_reject_only_recovery"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())

	request, err := notifications.RequestAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		app.RequestAppInstallationParams{
			AppSlug: "gitea",
			Config: map[string]any{
				"instance_url":   "https://gitea.example.test",
				"api_token":      "approval-app-recovery-token",
				"webhook_secret": "approval-app-recovery-webhook-secret",
				"display_name":   "Recovery Gitea",
			},
			Reason: "exercise durable reject-only projection recovery",
		},
	)
	require.NoError(t, err)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 1)

	reviewers, applicants := providerChangeFailureNotifications(t, ctx, notifications, workspaceID, request.ID)
	require.Len(t, reviewers, 1)
	require.Len(t, applicants, 1)
	loadReviewDocument := func(record app.AppNotificationRecord) struct {
		TextKey string `json:"textKey"`
		Actions []struct {
			ActionKey string `json:"actionKey"`
		} `json:"actions"`
	} {
		t.Helper()
		card, loadErr := notifications.LoadAppNotificationCardInTx(ctx, db, record.MessageID, false)
		require.NoError(t, loadErr)
		var document struct {
			TextKey string `json:"textKey"`
			Actions []struct {
				ActionKey string `json:"actionKey"`
			} `json:"actions"`
		}
		require.NoError(t, json.Unmarshal(card.RenderPayloadJSON, &document))
		return document
	}

	before := loadReviewDocument(reviewers[0])
	require.Empty(t, before.TextKey)
	require.Len(t, before.Actions, 2)
	require.Equal(t, ActionApprove, before.Actions[0].ActionKey)

	// Simulate a process crash after the canonical request-row fence commits but
	// before any reviewer card is rewritten. Reconciliation must discover the
	// content mismatch from durable state rather than relying on action retries.
	marked, err := notifications.MarkAppInstallationRequestCannotBeApplied(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestReviewStateCannotBeApplied, marked.ReviewState)

	reconciled, err := projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	reviewers, applicants = providerChangeFailureNotifications(t, ctx, notifications, workspaceID, request.ID)
	require.Len(t, reviewers, 1)
	require.Len(t, applicants, 1)
	after := loadReviewDocument(reviewers[0])
	require.Equal(t, appInstallationChangeCannotBeAppliedTextKey, after.TextKey)
	require.Len(t, after.Actions, 1)
	require.Equal(t, ActionReject, after.Actions[0].ActionKey)

	// A fully converged marker/card pair must fall out of the bounded gap scan;
	// otherwise an old reject-only request would permanently hot-agentteam and starve
	// newer projection gaps ordered behind it.
	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Zero(t, reconciled)
}

func TestApprovalAppRejectOnlyReconciliationIgnoresMoreThanFiveHundredHistoryRows(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 21, 6, 45, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_reject_only_history_bound"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())

	request, err := notifications.RequestAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		app.RequestAppInstallationParams{
			AppSlug: "gitea",
			Config: map[string]any{
				"instance_url":   "https://gitea.example.test",
				"api_token":      "approval-app-history-token",
				"webhook_secret": "approval-app-history-webhook-secret",
				"display_name":   "History-bound Gitea",
			},
			Reason: "exercise active-card projection independently from terminal history",
		},
	)
	require.NoError(t, err)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 1)

	var sourceNotificationID, sourceMessageID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT notification.id, notification.message_id
FROM service_notifications notification
WHERE notification.workspace_id = $1
  AND notification.business_object_namespace = $2
  AND notification.business_object_type = $3
  AND notification.business_object_id = $4
  AND notification.recipient_basis_kind = $5
LIMIT 1
`, workspaceID, BusinessNamespace, BusinessTypeAppInstallationRequest, request.ID,
		RecipientBasisAppInstallationApplicant).Scan(&sourceNotificationID, &sourceMessageID))

	// Create enough older terminal generations to fill the repository's bounded
	// general history read. The current reviewer card remains active and newer,
	// so marker fanout must obtain it from the dedicated active-card query rather
	// than assuming it appears in the first 500 historical rows.
	const historyCount = maxAppInstallationProjectionNotifications + 1
	seriesParts := make([]string, 0, historyCount)
	for ordinal := 1; ordinal <= historyCount; ordinal++ {
		seriesParts = append(seriesParts, fmt.Sprintf("SELECT %d AS ordinal", ordinal))
	}
	seriesSQL := strings.Join(seriesParts, " UNION ALL ")
	_, err = db.Exec(ctx, fmt.Sprintf(`
INSERT INTO scope_messages (
    id, workspace_id, scope_id, seq, workspace_global_seq,
    author_member_id, author_type, message_type, content, metadata,
    created_at, updated_at
)
SELECT CONCAT('msg_reject_only_history_', history.ordinal),
       source.workspace_id,
       source.scope_id,
       1000000 + history.ordinal,
       1000000 + history.ordinal,
       source.author_member_id,
       source.author_type,
       source.message_type,
       source.content,
       source.metadata,
       TIMESTAMPADD(SECOND, history.ordinal - 1000, source.created_at),
       TIMESTAMPADD(SECOND, history.ordinal - 1000, source.created_at)
FROM scope_messages source
CROSS JOIN (%s) history
WHERE source.id = $1
`, seriesSQL), sourceMessageID)
	require.NoError(t, err)
	_, err = db.Exec(ctx, fmt.Sprintf(`
INSERT INTO service_notifications (
    id, workspace_id, service_definition_id, service_installation_id,
    service_member_id, service_target_binding_id, target_kind, target_ref,
    scope_id, recipient_member_id, recipient_policy_version,
    recipient_basis_kind, recipient_basis_ref, recipient_fact_version,
    message_id, content_kind, client_notification_id, create_payload_digest,
    business_object_namespace, business_object_type, business_object_id,
    business_object_version, business_object_url, status, revision,
    caused_by_actor_type, caused_by_actor_id, execution_actor_type,
    execution_actor_id, correlation_id, resolved_at, created_at, updated_at
)
SELECT CONCAT('btn_reject_only_history_', history.ordinal),
       source.workspace_id,
       source.service_definition_id,
       source.service_installation_id,
       source.service_member_id,
       source.service_target_binding_id,
       source.target_kind,
       source.target_ref,
       source.scope_id,
       source.recipient_member_id,
       source.recipient_policy_version,
       source.recipient_basis_kind,
       source.recipient_basis_ref,
       source.recipient_fact_version,
       CONCAT('msg_reject_only_history_', history.ordinal),
       source.content_kind,
       CONCAT('app-installation-history:', history.ordinal),
       source.create_payload_digest,
       source.business_object_namespace,
       source.business_object_type,
       source.business_object_id,
       source.business_object_version,
       source.business_object_url,
       'resolved',
       source.revision,
       source.caused_by_actor_type,
       source.caused_by_actor_id,
       source.execution_actor_type,
       source.execution_actor_id,
       source.correlation_id,
       TIMESTAMPADD(SECOND, history.ordinal - 1000, source.created_at),
       TIMESTAMPADD(SECOND, history.ordinal - 1000, source.created_at),
       TIMESTAMPADD(SECOND, history.ordinal - 1000, source.created_at)
FROM service_notifications source
CROSS JOIN (%s) history
WHERE source.id = $1
`, seriesSQL), sourceNotificationID)
	require.NoError(t, err)

	historyWindow, err := notifications.ListAppNotificationsByBusinessObject(
		ctx, workspaceID, IdentityID, BusinessNamespace,
		BusinessTypeAppInstallationRequest, request.ID,
		maxAppInstallationProjectionNotifications,
	)
	require.NoError(t, err)
	require.Len(t, historyWindow, maxAppInstallationProjectionNotifications)
	for _, notification := range historyWindow {
		require.Equal(t, app.AppNotificationStatusResolved, notification.Status)
	}

	marked, err := notifications.MarkAppInstallationRequestCannotBeApplied(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestReviewStateCannotBeApplied, marked.ReviewState)

	reconciled, err := projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	reviewerRecords, err := notifications.ListActiveAppNotificationCardsByBusinessObject(
		ctx, workspaceID, IdentityID, BusinessNamespace,
		BusinessTypeAppInstallationRequest, request.ID,
		RecipientBasisAppInstallationReviewer, maxAppInstallationReviewers+1,
	)
	require.NoError(t, err)
	require.Len(t, reviewerRecords, 1)
	card, err := notifications.LoadAppNotificationCardInTx(ctx, db, reviewerRecords[0].MessageID, false)
	require.NoError(t, err)
	var document struct {
		TextKey string `json:"textKey"`
		Actions []struct {
			ActionKey string `json:"actionKey"`
		} `json:"actions"`
	}
	require.NoError(t, json.Unmarshal(card.RenderPayloadJSON, &document))
	require.Equal(t, appInstallationChangeCannotBeAppliedTextKey, document.TextKey)
	require.Len(t, document.Actions, 1)
	require.Equal(t, ActionReject, document.Actions[0].ActionKey)

	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Zero(t, reconciled)
}

func TestApprovalAppPersistentTransientProviderConnectionFailureRetriesTwiceThenConverges(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 21, 5, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_gitea_retry_bound"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin' WHERE id = 'mem_approval_bot_reader'`)

	const leakMarker = "transient-upstream-secret-detail"
	var approvalPhase atomic.Bool
	var transientCalls atomic.Int64
	gitea := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		if strings.TrimSpace(request.Header.Get("Authorization")) != "token gitea-retry-token" {
			http.Error(writer, "unauthorized", http.StatusUnauthorized)
			return
		}
		switch {
		case request.Method == http.MethodGet && request.URL.Path == "/api/v1/repos/acme/agentteam":
			if approvalPhase.Load() {
				transientCalls.Add(1)
				writer.Header().Set("Content-Type", "application/json")
				writer.WriteHeader(http.StatusInternalServerError)
				_, _ = writer.Write([]byte(`{"message":"` + leakMarker + `"}`))
				return
			}
			writer.Header().Set("Content-Type", "application/json")
			_, _ = writer.Write([]byte(`{"full_name":"acme/agentteam","name":"agentteam","owner":{"login":"acme"}}`))
		default:
			http.NotFound(writer, request)
		}
	}))
	defer gitea.Close()

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
		HTTPClient:              gitea.Client(),
		PublicBaseURL:           "https://agentteam.example.test",
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	handler := NewHandler(approval.New(db), notifications, projector, sealedEmptySubjectRegistry())
	registry := appruntime.NewInternalHandlerRegistry()
	require.NoError(t, registry.Register(HandlerKey, handler))
	registry.Seal()
	var dispatchNow time.Time
	dispatcher := appruntime.NewInternalDispatcher(appruntime.InternalDispatcherOptions{
		Store: notifications, Registry: registry, InstanceID: "approval-app-gitea-retry-bound", ClaimLimit: 10,
		Now: func() time.Time { return dispatchNow }, BaseBackoff: time.Second, MaxBackoff: time.Second,
	})

	_, err := notifications.SaveGiteaAppConfigForWorkspaceAdmin(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		provider.UpsertGiteaAppConfigParams{
			InstanceURL:   gitea.URL,
			APIToken:      "gitea-retry-token",
			WebhookSecret: "gitea-retry-webhook-secret",
			DisplayName:   "Retry-bound Gitea",
		},
	)
	require.NoError(t, err)

	var giteaInstallationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, appregistry.GiteaAppID).Scan(&giteaInstallationID))

	request, err := notifications.RequestAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		app.RequestAppInstallationParams{
			AppID:            appregistry.GiteaAppID,
			InstallationID:   giteaInstallationID,
			RequestKind:      app.AppInstallationRequestKindConnect,
			ConnectionTarget: "acme/agentteam",
			Reason:           "exercise bounded transient retries",
		},
	)
	require.NoError(t, err)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 2)

	var currentCard app.AppNotificationResult
	for _, card := range cards {
		record, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		if record.RecipientMemberID != nil && *record.RecipientMemberID == "mem_approval_bot_admin" {
			currentCard = card
		}
	}
	require.NotEmpty(t, currentCard.NotificationID)

	approvalPhase.Store(true)
	_, err = notifications.InvokeAppNotificationActionForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		app.AppNotificationActionCommand{
			MessageID:      currentCard.MessageID,
			ActionKey:      ActionApprove,
			CardRevision:   1,
			ClientActionID: "approve-gitea-persistent-transient-failure",
			Input:          map[string]any{},
		},
	)
	require.NoError(t, err)
	// Anchor the shared dispatcher/scheduler clock only after the action and its
	// delivery exist. Full-package TiDB runs can spend long enough in setup that
	// a clock captured before configuration and projection is no longer safely
	// ahead of the newly inserted delivery. Keep a generous margin as TiDB also
	// normalizes persisted timestamps to the column precision.
	dispatchNow = time.Now().UTC().Add(time.Hour)

	var approvalAppInstallationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, DefinitionID).Scan(&approvalAppInstallationID))

	assertReviewerCards := func(wantTextKey string, wantActions []string, wantRevision int64) {
		t.Helper()
		reviewerRecords, applicantRecords := providerChangeFailureNotifications(
			t, ctx, notifications, workspaceID, request.ID,
		)
		require.Len(t, reviewerRecords, 2)
		require.Len(t, applicantRecords, 1)
		for _, record := range reviewerRecords {
			require.Equal(t, app.AppNotificationStatusActive, record.Status)
			require.Equal(t, wantRevision, record.Revision)
			card, loadErr := notifications.LoadAppNotificationCardInTx(ctx, db, record.MessageID, false)
			require.NoError(t, loadErr)
			var document struct {
				TextKey string `json:"textKey"`
				Actions []struct {
					ActionKey string `json:"actionKey"`
				} `json:"actions"`
			}
			require.NoError(t, json.Unmarshal(card.RenderPayloadJSON, &document))
			require.Equal(t, wantTextKey, document.TextKey)
			actionKeys := make([]string, 0, len(document.Actions))
			for _, action := range document.Actions {
				actionKeys = append(actionKeys, action.ActionKey)
			}
			require.Equal(t, wantActions, actionKeys)
			require.NotContains(t, string(card.RenderPayloadJSON), leakMarker)
			require.NotContains(t, string(card.RenderPayloadJSON), gitea.URL)
		}
		require.Equal(t, app.AppNotificationStatusActive, applicantRecords[0].Status)
		require.Equal(t, int64(1), applicantRecords[0].Revision)
	}

	for attemptNumber := 1; attemptNumber <= 2; attemptNumber++ {
		dispatched, dispatchErr := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
			WorkspaceID: workspaceID, ServiceInstallationID: approvalAppInstallationID,
			HandlerKey: HandlerKey, Limit: 10, Now: dispatchNow,
		})
		require.NoError(t, dispatchErr)
		require.Equal(t, 1, dispatched.Claimed, "attempt %d must claim the pending delivery", attemptNumber)
		require.Zero(t, dispatched.Completed, "attempt %d must not complete", attemptNumber)
		require.Equal(t, 1, dispatched.Retried, "attempt %d must schedule a retry", attemptNumber)
		require.Len(t, dispatched.Items, 1, "attempt %d must return one dispatch item", attemptNumber)
		require.NotNil(t, dispatched.Items[0].RetryAt, "attempt %d must expose its retry time", attemptNumber)
		var storedAttemptNumber int
		require.NoError(t, db.QueryRow(ctx, `
SELECT attempt_number
FROM service_event_delivery_attempts
WHERE workspace_id = $1 AND id = $2
`, workspaceID, dispatched.Items[0].AttemptID).Scan(&storedAttemptNumber))
		require.Equal(t, attemptNumber, storedAttemptNumber, "dispatch iteration must match the durable attempt number")
		stillPending, loadErr := notifications.GetAppInstallationRequest(ctx, workspaceID, request.ID)
		require.NoError(t, loadErr)
		require.Equal(t, app.AppInstallationRequestStatusPending, stillPending.Status)
		assertReviewerCards("", []string{ActionApprove, ActionReject}, 1)
		// Advance both ClaimDeliveryEnvelopes' explicit clock and the dispatcher's
		// ScheduleDeliveryAttemptRetry clock well beyond the persisted boundary.
		dispatchNow = dispatched.Items[0].RetryAt.Add(time.Minute)
	}

	third, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: approvalAppInstallationID,
		HandlerKey: HandlerKey, Limit: 10, Now: dispatchNow,
	})
	require.NoError(t, err)
	require.Equal(t, 1, third.Claimed, "attempt 3 must claim the due retry")
	require.Equal(t, 1, third.Completed, "attempt 3 must complete with the safe failure card")
	require.Zero(t, third.Retried, "attempt 3 must stop retrying")
	require.Len(t, third.Items, 1, "attempt 3 must return one dispatch item")
	var thirdAttemptNumber int
	require.NoError(t, db.QueryRow(ctx, `
SELECT attempt_number
FROM service_event_delivery_attempts
WHERE workspace_id = $1 AND id = $2
`, workspaceID, third.Items[0].AttemptID).Scan(&thirdAttemptNumber))
	require.Equal(t, 3, thirdAttemptNumber)
	require.Equal(t, int64(3), transientCalls.Load())

	stillPending, err := notifications.GetAppInstallationRequest(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, stillPending.Status)
	assertReviewerCards(appInstallationChangeCannotBeAppliedTextKey, []string{ActionReject}, 2)
}

func TestApprovalAppPermanentProviderConnectionFailureConvergesReviewersAndStopsRetry(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 21, 4, 30, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_gitea_failure"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin' WHERE id = 'mem_approval_bot_reader'`)

	const leakMarker = "upstream-secret-detail"
	var approvalPhase atomic.Bool
	gitea := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		if strings.TrimSpace(request.Header.Get("Authorization")) != "token gitea-failure-token" {
			http.Error(writer, "unauthorized", http.StatusUnauthorized)
			return
		}
		switch {
		case request.Method == http.MethodGet && request.URL.Path == "/api/v1/repos/acme/agentteam":
			writer.Header().Set("Content-Type", "application/json")
			_, _ = writer.Write([]byte(`{"full_name":"acme/agentteam","name":"agentteam","owner":{"login":"acme"}}`))
		case request.Method == http.MethodGet && request.URL.Path == "/api/v1/repos/acme/agentteam/hooks":
			if approvalPhase.Load() {
				writer.Header().Set("Content-Type", "application/json")
				writer.WriteHeader(http.StatusForbidden)
				_, _ = writer.Write([]byte(`{"message":"` + leakMarker + `","url":"https://gitea.internal/api/v1/repos/acme/agentteam/hooks"}`))
				return
			}
			_, _ = writer.Write([]byte(`[]`))
		default:
			http.NotFound(writer, request)
		}
	}))
	defer gitea.Close()

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
		HTTPClient:              gitea.Client(),
		PublicBaseURL:           "https://agentteam.example.test",
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	handler := NewHandler(approval.New(db), notifications, projector, sealedEmptySubjectRegistry())
	registry := appruntime.NewInternalHandlerRegistry()
	require.NoError(t, registry.Register(HandlerKey, handler))
	registry.Seal()
	dispatcher := appruntime.NewInternalDispatcher(appruntime.InternalDispatcherOptions{
		Store: notifications, Registry: registry, InstanceID: "approval-app-gitea-failure", ClaimLimit: 10,
	})

	_, err := notifications.SaveGiteaAppConfigForWorkspaceAdmin(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		provider.UpsertGiteaAppConfigParams{
			InstanceURL:   gitea.URL,
			APIToken:      "gitea-failure-token",
			WebhookSecret: "gitea-failure-webhook-secret",
			DisplayName:   "Failure-test Gitea",
		},
	)
	require.NoError(t, err)

	var giteaInstallationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, appregistry.GiteaAppID).Scan(&giteaInstallationID))

	request, err := notifications.RequestAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		app.RequestAppInstallationParams{
			AppID:            appregistry.GiteaAppID,
			InstallationID:   giteaInstallationID,
			RequestKind:      app.AppInstallationRequestKindConnect,
			ConnectionTarget: "acme/agentteam",
			Reason:           "connect release automation",
		},
	)
	require.NoError(t, err)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 2)

	var currentCard, peerCard app.AppNotificationResult
	for _, card := range cards {
		record, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		require.NotNil(t, record.RecipientMemberID)
		if *record.RecipientMemberID == "mem_approval_bot_admin" {
			currentCard = card
		} else {
			peerCard = card
		}
	}
	require.NotEmpty(t, currentCard.NotificationID)
	require.NotEmpty(t, peerCard.NotificationID)

	// Exercise the legitimate divergent-reviewer-revision case: a harmless
	// projection refresh advances the peer before the current reviewer acts.
	pendingContent, err := pendingAppInstallationCardContent(request)
	require.NoError(t, err)
	pendingVersion := appInstallationRequestBusinessVersion(request)
	peerUpdated, err := notifications.MutateAppNotificationForBuiltin(
		ctx,
		IdentityID,
		workspaceID,
		app.AppNotificationMutationCommand{
			NotificationID:        peerCard.NotificationID,
			Kind:                  app.AppNotificationMutationUpdate,
			ExpectedRevision:      1,
			ClientMutationID:      "test-divergent-reviewer-revision",
			Content:               &pendingContent,
			BusinessObjectVersion: &pendingVersion,
			CorrelationID:         request.ID,
		},
	)
	require.NoError(t, err)
	require.Equal(t, int64(2), peerUpdated.NotificationRevision)

	approvalPhase.Store(true)
	actionCommand := app.AppNotificationActionCommand{
		MessageID:      currentCard.MessageID,
		ActionKey:      ActionApprove,
		CardRevision:   1,
		ClientActionID: "approve-gitea-permanent-provider-failure",
		Input:          map[string]any{},
	}
	action, err := notifications.InvokeAppNotificationActionForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		actionCommand,
	)
	require.NoError(t, err)

	var approvalAppInstallationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, DefinitionID).Scan(&approvalAppInstallationID))
	dispatched, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: approvalAppInstallationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed)
	require.Zero(t, dispatched.Retried)

	stillPending, err := notifications.GetAppInstallationRequest(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, stillPending.Status)

	reviewerRecords, applicantRecords := providerChangeFailureNotifications(
		t, ctx, notifications, workspaceID, request.ID,
	)
	require.Len(t, reviewerRecords, 2)
	require.Len(t, applicantRecords, 1, "applicant pending receipt remains pending and no result is projected")
	for _, record := range reviewerRecords {
		require.Equal(t, app.AppNotificationStatusActive, record.Status)
		card, loadErr := notifications.LoadAppNotificationCardInTx(ctx, db, record.MessageID, false)
		require.NoError(t, loadErr)
		var document struct {
			TextKey string `json:"textKey"`
			Actions []struct {
				ActionKey string `json:"actionKey"`
			} `json:"actions"`
		}
		require.NoError(t, json.Unmarshal(card.RenderPayloadJSON, &document))
		require.Equal(t, appInstallationChangeCannotBeAppliedTextKey, document.TextKey)
		require.Len(t, document.Actions, 1)
		require.Equal(t, ActionReject, document.Actions[0].ActionKey)
		require.NotContains(t, string(card.RenderPayloadJSON), leakMarker)
		require.NotContains(t, string(card.RenderPayloadJSON), gitea.URL)
		if record.ID == currentCard.NotificationID {
			require.Equal(t, int64(2), record.Revision)
		} else {
			require.Equal(t, int64(3), record.Revision, "peer uses its own revision fence")
		}
	}
	require.Equal(t, app.AppNotificationStatusActive, applicantRecords[0].Status)
	require.Equal(t, int64(1), applicantRecords[0].Revision)

	// Replaying the exact accepted action does not enqueue or apply another
	// mutation and therefore leaves both card revisions stable.
	replayed, err := notifications.InvokeAppNotificationActionForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		actionCommand,
	)
	require.NoError(t, err)
	require.True(t, replayed.AlreadyAccepted)
	require.Equal(t, action.ActionInvocationID, replayed.ActionInvocationID)
	replayDispatch, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: approvalAppInstallationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Zero(t, replayDispatch.Completed)
	replayedReviewers, _ := providerChangeFailureNotifications(t, ctx, notifications, workspaceID, request.ID)
	for _, record := range replayedReviewers {
		if record.ID == currentCard.NotificationID {
			require.Equal(t, int64(2), record.Revision)
		} else {
			require.Equal(t, int64(3), record.Revision)
		}
	}

	currentReviewCard, err := notifications.LoadAppNotificationCardInTx(ctx, db, currentCard.MessageID, false)
	require.NoError(t, err)
	_, err = notifications.InvokeAppNotificationActionForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		app.AppNotificationActionCommand{
			MessageID:      currentCard.MessageID,
			ActionKey:      ActionReject,
			CardRevision:   currentReviewCard.CardRevision,
			ClientActionID: "reject-invalid-gitea-connection",
			Input:          map[string]any{},
		},
	)
	require.NoError(t, err)
	rejectedDispatch, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: approvalAppInstallationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, rejectedDispatch.Completed)
	rejected, err := notifications.GetAppInstallationRequest(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusRejected, rejected.Status)
	finalReviewers, _ := providerChangeFailureNotifications(t, ctx, notifications, workspaceID, request.ID)
	for _, record := range finalReviewers {
		require.Equal(t, app.AppNotificationStatusResolved, record.Status)
	}

	// A deterministic business-state race follows the same non-retry path. The
	// second proposal snapshots an absent connection; a competing connection is
	// then committed before review, so approval sees a stale base without doing
	// any provider I/O.
	approvalPhase.Store(false)
	staleRequest, err := notifications.RequestAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		app.RequestAppInstallationParams{
			AppID:            appregistry.GiteaAppID,
			InstallationID:   giteaInstallationID,
			RequestKind:      app.AppInstallationRequestKindConnect,
			ConnectionTarget: "acme/agentteam",
			Reason:           "stale base regression",
		},
	)
	require.NoError(t, err)
	staleCards, err := projector.ProjectPendingAppInstallationRequest(ctx, staleRequest)
	require.NoError(t, err)
	require.Len(t, staleCards, 2)
	_, err = notifications.UpsertAppConnection(ctx, app.UpsertAppConnectionParams{
		InstallationID:     giteaInstallationID,
		ExternalSubjectRef: "acme/agentteam",
		DisplayLabel:       "acme/agentteam",
		Credentials:        map[string]any{},
		CreatedByUserID:    "usr_approval_bot_member",
	})
	require.NoError(t, err)
	var staleAdminCard app.AppNotificationResult
	for _, card := range staleCards {
		record, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		if record.RecipientMemberID != nil && *record.RecipientMemberID == "mem_approval_bot_admin" {
			staleAdminCard = card
		}
	}
	require.NotEmpty(t, staleAdminCard.NotificationID)
	_, err = notifications.InvokeAppNotificationActionForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		app.AppNotificationActionCommand{
			MessageID:      staleAdminCard.MessageID,
			ActionKey:      ActionApprove,
			CardRevision:   1,
			ClientActionID: "approve-stale-gitea-base",
			Input:          map[string]any{},
		},
	)
	require.NoError(t, err)
	staleDispatch, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: approvalAppInstallationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, staleDispatch.Completed)
	require.Zero(t, staleDispatch.Retried)
	stalePending, err := notifications.GetAppInstallationRequest(ctx, workspaceID, staleRequest.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, stalePending.Status)
	staleReviewers, staleApplicants := providerChangeFailureNotifications(t, ctx, notifications, workspaceID, staleRequest.ID)
	require.Len(t, staleReviewers, 2)
	require.Len(t, staleApplicants, 1)
	for _, record := range staleReviewers {
		card, loadErr := notifications.LoadAppNotificationCardInTx(ctx, db, record.MessageID, false)
		require.NoError(t, loadErr)
		var document struct {
			TextKey string `json:"textKey"`
			Actions []struct {
				ActionKey string `json:"actionKey"`
			} `json:"actions"`
		}
		require.NoError(t, json.Unmarshal(card.RenderPayloadJSON, &document))
		require.Equal(t, appInstallationChangeCannotBeAppliedTextKey, document.TextKey)
		require.Len(t, document.Actions, 1)
		require.Equal(t, ActionReject, document.Actions[0].ActionKey)
	}
}

func providerChangeFailureNotifications(
	t testing.TB,
	ctx context.Context,
	notifications *app.Store,
	workspaceID string,
	requestID string,
) ([]app.AppNotificationRecord, []app.AppNotificationRecord) {
	t.Helper()
	records, err := notifications.ListAppNotificationsByBusinessObject(
		ctx,
		workspaceID,
		IdentityID,
		BusinessNamespace,
		BusinessTypeAppInstallationRequest,
		requestID,
		maxAppInstallationProjectionNotifications,
	)
	require.NoError(t, err)
	reviewers := make([]app.AppNotificationRecord, 0, 2)
	applicants := make([]app.AppNotificationRecord, 0, 2)
	for _, record := range records {
		if record.RecipientBasisKind == nil {
			continue
		}
		switch strings.TrimSpace(*record.RecipientBasisKind) {
		case RecipientBasisAppInstallationReviewer:
			reviewers = append(reviewers, record)
		case RecipientBasisAppInstallationApplicant:
			applicants = append(applicants, record)
		}
	}
	return reviewers, applicants
}
