package app

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/provider"
	"github.com/tidbcloud/AgentTeam/internal/store/serviceactor"
)

const (
	appUninstallBaseInstallationIDKey       = "installation_id"
	appUninstallBaseInstalledVersionKey     = "installed_version"
	appUninstallBaseInstallationConfigKey   = "installation_config"
	appUninstallBaseGiteaConfigFoundKey     = "gitea_config_found"
	appUninstallBaseGiteaConfigIDKey        = "gitea_config_id"
	appUninstallBaseGiteaConfigUpdatedAtKey = "gitea_config_updated_at"
	appUninstallBaseActiveConnectionsKey    = "active_connections"
)

type appInstallationUninstallResult struct {
	view                AppInstallationView
	giteaWebhookCleanup []giteaUninstallWebhookCleanup
}

type appAvatarMediaCleanup struct {
	storageDriver string
	objectKey     string
}

type appAvatarMediaCleanupCollector struct {
	targets []appAvatarMediaCleanup
}

type appAvatarMediaCleanupContextKey struct{}

type giteaUninstallWebhookCleanup struct {
	config    provider.GiteaAppConfig
	target    giteaConnectionTarget
	webhookID int64
}

// appUninstallRequestBaseInTx captures only effective-state identity and CAS
// metadata. It never stores provider plaintext or credential envelopes. For
// Gitea, id+updated_at is sufficient to detect every provider config write
// because all such writes update that row atomically.
func (store *Store) appUninstallRequestBaseInTx(
	ctx context.Context,
	tx coredb.Tx,
	installation serviceactor.Installation,
) (map[string]any, error) {
	base := map[string]any{
		appUninstallBaseInstallationIDKey:     strings.TrimSpace(installation.ID),
		appUninstallBaseInstalledVersionKey:   strings.TrimSpace(installation.InstalledVersion),
		appUninstallBaseInstallationConfigKey: decodeJSONObjectMap(installation.Config),
	}
	connections, err := appUninstallActiveConnectionBaseInTx(ctx, tx, installation.ID)
	if err != nil {
		return nil, err
	}
	base[appUninstallBaseActiveConnectionsKey] = connections
	if !isGiteaApp(installation.ServiceDefinitionID) {
		return base, nil
	}
	var (
		configID        string
		configUpdatedAt time.Time
	)
	err = tx.QueryRow(ctx, `
SELECT id, updated_at
FROM service_provider_configs
WHERE workspace_id = $1
  AND provider_kind = 'gitea'
LIMIT 1
FOR UPDATE
`, strings.TrimSpace(installation.WorkspaceID)).Scan(&configID, &configUpdatedAt)
	if err != nil {
		if coredb.IsNotFound(err) {
			base[appUninstallBaseGiteaConfigFoundKey] = false
			return base, nil
		}
		return nil, fmt.Errorf("capture Gitea configuration for App uninstall request: %w", err)
	}
	base[appUninstallBaseGiteaConfigFoundKey] = true
	base[appUninstallBaseGiteaConfigIDKey] = strings.TrimSpace(configID)
	base[appUninstallBaseGiteaConfigUpdatedAtKey] = configUpdatedAt.UTC().Format(time.RFC3339Nano)
	return base, nil
}

// appUninstallActiveConnectionBaseInTx locks and snapshots only connection
// identity/CAS metadata. Credential bodies never enter an uninstall proposal.
// This makes an uninstall stale when a concurrently pending connect or
// disconnect proposal wins first instead of silently deleting newer state.
func appUninstallActiveConnectionBaseInTx(ctx context.Context, tx coredb.Tx, installationID string) ([]any, error) {
	rows, err := tx.Query(ctx, `
SELECT id, external_subject_ref, display_label, status, updated_at
FROM service_connections
WHERE service_installation_id = $1
  AND status = 'active'
ORDER BY id ASC
FOR UPDATE
`, strings.TrimSpace(installationID))
	if err != nil {
		return nil, fmt.Errorf("capture active connections for App uninstall request: %w", err)
	}
	defer rows.Close()

	connections := make([]any, 0)
	for rows.Next() {
		var (
			id          string
			externalRef string
			label       string
			status      string
			updatedAt   time.Time
		)
		if err := rows.Scan(&id, &externalRef, &label, &status, &updatedAt); err != nil {
			return nil, fmt.Errorf("scan active connection for App uninstall request: %w", err)
		}
		connections = append(connections, map[string]any{
			"id":            strings.TrimSpace(id),
			"external_ref":  strings.TrimSpace(externalRef),
			"display_label": strings.TrimSpace(label),
			"status":        strings.TrimSpace(status),
			"updated_at":    updatedAt.UTC().Format(time.RFC3339Nano),
		})
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate active connections for App uninstall request: %w", err)
	}
	return connections, nil
}

// uninstallAppInstallationInTx is the single destructive implementation used
// by both workspace-admin direct uninstall and approved member proposals. The
// caller owns the canonical App-definition lock before entering this helper.
func (store *Store) uninstallAppInstallationInTx(
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
	installationID string,
	definitionID string,
	actedByUserID string,
	actedByMemberID string,
	expectedRequest *AppInstallationRequestView,
) (appInstallationUninstallResult, error) {
	txStore := store.serviceActorStore().InTx(tx)
	serviceInstallation, err := txStore.LoadInstallationForUpdate(ctx, strings.TrimSpace(installationID))
	if err != nil {
		if expectedRequest != nil && errors.Is(err, ErrNotFound) {
			return appInstallationUninstallResult{}, markAppInstallationApprovalStale(conflict("App installation changed. Submit a new uninstall request."))
		}
		return appInstallationUninstallResult{}, err
	}
	if strings.TrimSpace(serviceInstallation.WorkspaceID) != strings.TrimSpace(workspaceID) ||
		strings.TrimSpace(serviceInstallation.ServiceKind) != serviceactor.KindApp ||
		strings.TrimSpace(serviceInstallation.Status) != serviceactor.StatusActive ||
		strings.TrimSpace(serviceInstallation.ServiceDefinitionID) != strings.TrimSpace(definitionID) ||
		strings.TrimSpace(serviceInstallation.OwnershipKind) == serviceactor.OwnershipKindPersonal {
		if expectedRequest != nil {
			return appInstallationUninstallResult{}, markAppInstallationApprovalStale(conflict("App installation changed. Submit a new uninstall request."))
		}
		return appInstallationUninstallResult{}, ErrNotFound
	}
	if expectedRequest != nil {
		if strings.TrimSpace(expectedRequest.RequestKind) != AppInstallationRequestKindUninstall ||
			strings.TrimSpace(expectedRequest.AppID) != strings.TrimSpace(serviceInstallation.ServiceDefinitionID) ||
			strings.TrimSpace(expectedRequest.InstallationID) != strings.TrimSpace(serviceInstallation.ID) ||
			strings.TrimSpace(expectedRequest.RequestedVersion) != strings.TrimSpace(serviceInstallation.InstalledVersion) {
			return appInstallationUninstallResult{}, markAppInstallationApprovalStale(conflict("App installation changed. Submit a new uninstall request."))
		}
		currentBase, err := store.appUninstallRequestBaseInTx(ctx, tx, serviceInstallation)
		if err != nil {
			return appInstallationUninstallResult{}, err
		}
		if !mapsSemanticallyEqual(currentBase, appInstallationRequestStoredBaseConfig(*expectedRequest)) {
			return appInstallationUninstallResult{}, markAppInstallationApprovalStale(conflict("App installation changed. Submit a new uninstall request."))
		}
	}

	member, err := txStore.LoadMemberByInstallation(ctx, serviceInstallation.ID)
	if err != nil {
		return appInstallationUninstallResult{}, err
	}
	seed, err := loadAppServiceDefinitionSeed(ctx, tx, serviceInstallation.ServiceDefinitionID)
	if err != nil {
		return appInstallationUninstallResult{}, err
	}
	giteaWebhookCleanup, err := store.captureGiteaUninstallWebhookCleanupInTx(ctx, tx, serviceInstallation)
	if err != nil {
		return appInstallationUninstallResult{}, err
	}

	now := time.Now().UTC()
	storedInstallation := model.StoredAppInstallation{
		ID:                serviceInstallation.ID,
		AppID:             serviceInstallation.ServiceDefinitionID,
		AppSlug:           seed.Slug,
		AppName:           seed.Name,
		WorkspaceID:       serviceInstallation.WorkspaceID,
		InstanceKey:       serviceInstallation.InstanceKey,
		MemberID:          member.MemberID,
		Handle:            member.Handle,
		MemberDisplayName: member.DisplayName,
		InstalledVersion:  serviceInstallation.InstalledVersion,
		Config:            serviceInstallation.Config,
		Enabled:           false,
		CreatedByUserID:   strings.TrimSpace(actedByUserID),
		CreatedAt:         serviceInstallation.CreatedAt,
		UpdatedAt:         now,
	}
	if err := store.deleteInstallationDestructiveStateInTx(ctx, tx, storedInstallation, true, actedByMemberID, now); err != nil {
		return appInstallationUninstallResult{}, err
	}

	return appInstallationUninstallResult{
		view: AppInstallationView{
			ID:               storedInstallation.ID,
			AppID:            storedInstallation.AppID,
			AppSlug:          storedInstallation.AppSlug,
			AppName:          storedInstallation.AppName,
			InstanceKey:      storedInstallation.InstanceKey,
			MemberID:         storedInstallation.MemberID,
			Handle:           storedInstallation.Handle,
			InstalledVersion: storedInstallation.InstalledVersion,
			Config:           decodeJSONObjectMap(storedInstallation.Config),
			Enabled:          false,
			CreatedAt:        storedInstallation.CreatedAt,
			UpdatedAt:        storedInstallation.UpdatedAt,
		},
		giteaWebhookCleanup: giteaWebhookCleanup,
	}, nil
}

// deleteInstallationDestructiveStateInTx is the single destructive tail
// shared by workspace uninstall, personal uninstall, and offboarding
// cleanup: installation-scoped subscription blocking, notification
// terminalization, scoped state deletion (workspace-owned provider state
// only when the workspace installation itself goes away), the substrate
// installation row delete, and the sibling-guarded definition drop.
func (store *Store) deleteInstallationDestructiveStateInTx(
	ctx context.Context,
	tx coredb.Tx,
	storedInstallation model.StoredAppInstallation,
	workspaceOwned bool,
	actedByMemberID string,
	now time.Time,
) error {
	if err := blockPullRequestSubscriptionsBeforeAppInstallationDelete(ctx, tx, storedInstallation.WorkspaceID, storedInstallation.ID, storedInstallation.AppSlug, now); err != nil {
		return err
	}
	if err := store.terminalizeAppNotificationsBeforeAppInstallationDelete(ctx, tx, storedInstallation.WorkspaceID, storedInstallation.ID, strings.TrimSpace(actedByMemberID), now); err != nil {
		return err
	}
	if err := store.deleteAppInstallationWorkspaceState(ctx, tx, storedInstallation, workspaceOwned, now); err != nil {
		return err
	}
	// The substrate installation row is removed after the app-owned state:
	// the service_* children cascade on it.
	if _, err := tx.Exec(ctx, `
DELETE FROM service_installations
WHERE id = $1
  AND workspace_id = $2
  AND service_kind = $3
`, storedInstallation.ID, storedInstallation.WorkspaceID, serviceactor.KindApp); err != nil {
		return fmt.Errorf("delete app service installation: %w", err)
	}
	if err := deleteWorkspaceAppDefinition(ctx, tx, storedInstallation, now); err != nil {
		return err
	}
	return nil
}

// deleteWorkspaceAppDefinition removes a workspace-created App after its
// installation has been removed. The workspace attribution is the App's
// ownership record; product Apps have no such row and keep ordinary uninstall
// behavior. Foreign-key cascades remove the App manifest, credentials,
// bindings, subscriptions, and installation-request rows.
func deleteWorkspaceAppDefinition(
	ctx context.Context,
	tx coredb.Tx,
	installation model.StoredAppInstallation,
	now time.Time,
) error {
	workspaceID := strings.TrimSpace(installation.WorkspaceID)
	appID := strings.TrimSpace(installation.AppID)
	// Only a workspace-created App definition with no surviving installation
	// is deletable. Decide that BEFORE touching definition-wide approval
	// state: a surviving personal sibling must keep its Definition AND its
	// approval actions. The caller holds the App-definition mutation-scope
	// lock (no new sibling can appear), but this transaction may have waited
	// on that lock, so the sibling check is a locking current-read: an
	// ordinary aggregate could still resolve the pre-wait RR snapshot and
	// wrongly clear approval actions for a surviving sibling.
	var siblingCount int
	if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM service_installations sibling
WHERE sibling.service_definition_id = $2
  AND sibling.workspace_id = $1
FOR UPDATE
`, workspaceID, appID).Scan(&siblingCount); err != nil {
		return fmt.Errorf("lock workspace App definition siblings: %w", err)
	}
	if siblingCount > 0 {
		return nil
	}
	// The deletable predicate itself is a locking current-read: the
	// definition row and its attribution may have changed while this
	// transaction waited on the definition lock, and a stale snapshot must
	// not clear approval actions for a definition that survives.
	var deletable int
	if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM service_definitions definition
WHERE definition.id = $2
  AND definition.service_kind = 'app'
  AND JSON_UNQUOTE(JSON_EXTRACT(COALESCE(definition.config, JSON_OBJECT()), '$.appKind')) = 'third_party'
  AND EXISTS (
      SELECT 1
      FROM extension_catalog_attributions attribution
      WHERE attribution.workspace_id = $1
        AND attribution.service_definition_id = definition.id
    FOR UPDATE
  )
FOR UPDATE
`, workspaceID, appID).Scan(&deletable); err != nil {
		return fmt.Errorf("check workspace App definition deletability: %w", err)
	}
	if deletable == 0 {
		return nil
	}
	if err := lockWorkspaceAppInstallationRequestsForUpdate(ctx, tx, workspaceID, appID); err != nil {
		return err
	}
	// Invalidate the Approval cards BEFORE deleting service_definitions: the
	// definition delete cascades service_installation_requests (FK), so the
	// exact-ownership join in removeDeletedAppApprovalActions must see the
	// request rows while they still exist.
	if err := removeDeletedAppApprovalActions(ctx, tx, workspaceID, appID, now); err != nil {
		return err
	}
	// A workspace-created App definition is only removed with its last
	// installation: personal installations of the same App (and the shared
	// workspace installation) must survive any single installation's removal.
	deletedServiceDefinition, err := tx.Exec(ctx, `
DELETE FROM service_definitions
WHERE id = $2
  AND service_kind = 'app'
  AND JSON_UNQUOTE(JSON_EXTRACT(COALESCE(config, JSON_OBJECT()), '$.appKind')) = 'third_party'
  AND EXISTS (
      SELECT 1
      FROM extension_catalog_attributions attribution
      WHERE attribution.workspace_id = $1
        AND attribution.service_definition_id = service_definitions.id
  )
  AND NOT EXISTS (
      SELECT 1
      FROM service_installations sibling
      WHERE sibling.service_definition_id = service_definitions.id
        AND sibling.workspace_id = $1
  )
`, workspaceID, appID)
	if err != nil {
		return fmt.Errorf("delete workspace App definition: %w", err)
	}
	if deletedServiceDefinition.RowsAffected() == 0 {
		return nil
	}
	if err := deleteWorkspaceAppAvatarAssetsInTx(ctx, tx, workspaceID, appID); err != nil {
		return err
	}
	if _, err := tx.Exec(ctx, `
DELETE FROM service_action_rate_limit_events
WHERE workspace_id = $1
  AND service_installation_id = $2
`, workspaceID, strings.TrimSpace(installation.ID)); err != nil {
		return fmt.Errorf("delete workspace App rate-limit events: %w", err)
	}
	return nil
}

func deleteWorkspaceAppAvatarAssetsInTx(
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
	appID string,
) error {
	rows, err := tx.Query(ctx, `
SELECT COALESCE(storage_driver, ''), COALESCE(object_key, '')
FROM avatars
WHERE workspace_id = $1
  AND owner_kind = 'app'
  AND owner_id = $2
ORDER BY id ASC
FOR UPDATE
`, strings.TrimSpace(workspaceID), strings.TrimSpace(appID))
	if err != nil {
		return fmt.Errorf("capture workspace App avatar media: %w", err)
	}
	targets := make([]appAvatarMediaCleanup, 0)
	for rows.Next() {
		var target appAvatarMediaCleanup
		if err := rows.Scan(&target.storageDriver, &target.objectKey); err != nil {
			rows.Close()
			return fmt.Errorf("scan workspace App avatar media: %w", err)
		}
		targets = append(targets, target)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return fmt.Errorf("iterate workspace App avatar media: %w", err)
	}
	rows.Close()
	if _, err := tx.Exec(ctx, `
DELETE FROM avatars
WHERE workspace_id = $1
  AND owner_kind = 'app'
  AND owner_id = $2
`, strings.TrimSpace(workspaceID), strings.TrimSpace(appID)); err != nil {
		return fmt.Errorf("delete workspace App avatar assets: %w", err)
	}
	if collector, ok := ctx.Value(appAvatarMediaCleanupContextKey{}).(*appAvatarMediaCleanupCollector); ok && collector != nil {
		collector.targets = append(collector.targets, targets...)
	}
	return nil
}

func withAppAvatarMediaCleanup(ctx context.Context) (context.Context, *appAvatarMediaCleanupCollector) {
	collector := &appAvatarMediaCleanupCollector{}
	return context.WithValue(ctx, appAvatarMediaCleanupContextKey{}, collector), collector
}

// finalizeAppAvatarMediaCleanup runs only after the transaction that removed
// the owning avatar rows commits. Database blobs need no external work; OSS/S3
// targets are deleted best-effort without inheriting a canceled HTTP context.
func (store *Store) finalizeAppAvatarMediaCleanup(ctx context.Context, collector *appAvatarMediaCleanupCollector) {
	if store.mediaObjectStorage == nil || collector == nil || len(collector.targets) == 0 {
		return
	}
	seen := make(map[string]struct{}, len(collector.targets))
	for _, target := range collector.targets {
		storageDriver := strings.TrimSpace(target.storageDriver)
		objectKey := strings.TrimSpace(target.objectKey)
		if storageDriver == "" || objectKey == "" {
			continue
		}
		if _, duplicate := seen[objectKey]; duplicate {
			continue
		}
		seen[objectKey] = struct{}{}
		cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
		err := store.mediaObjectStorage.Delete(cleanupCtx, objectKey)
		cancel()
		if err != nil && store.logger != nil {
			store.logger.Warn(
				"delete App avatar media after catalog removal",
				"storageDriver", storageDriver,
				"objectKey", objectKey,
				"error", err,
			)
		}
	}
}

func lockWorkspaceAppInstallationRequestsForUpdate(
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
	appID string,
) error {
	rows, err := tx.Query(ctx, `
SELECT id
FROM service_installation_requests
WHERE workspace_id = $1
  AND service_definition_id = $2
ORDER BY id
FOR UPDATE
`, strings.TrimSpace(workspaceID), strings.TrimSpace(appID))
	if err != nil {
		return fmt.Errorf("lock workspace App installation requests: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var requestID string
		if err := rows.Scan(&requestID); err != nil {
			return fmt.Errorf("scan workspace App installation request lock: %w", err)
		}
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate workspace App installation request locks: %w", err)
	}
	return nil
}

// removeDeletedAppApprovalActions preserves Approval messages as audit
// evidence while removing every action for a App that is about to be deleted.
// The request rows still exist at this point and are the exact ownership join;
// deleting the service definition immediately afterwards cascades those rows,
// so this action invalidation must happen first.
func removeDeletedAppApprovalActions(
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
	appID string,
	now time.Time,
) error {
	if _, err := tx.Exec(ctx, `
UPDATE service_notification_cards card
JOIN service_notifications notification
  ON notification.id = card.service_notification_id
 AND notification.message_id = card.message_id
JOIN service_installation_requests request
  ON request.workspace_id = notification.workspace_id
 AND request.id = notification.business_object_id
SET card.render_payload_json = JSON_REMOVE(card.render_payload_json, '$.actions'),
    card.action_schema_json = JSON_SET(card.action_schema_json, '$.actions', JSON_ARRAY()),
    card.card_revision = card.card_revision + 1,
    card.updated_at = $3
WHERE notification.workspace_id = $1
  AND notification.business_object_namespace = 'agentteam.approval'
  AND notification.business_object_type = 'app_installation_request'
  AND notification.content_kind = 'interactive_card'
  AND request.service_definition_id = $2
  AND request.service_kind = 'app'
  AND JSON_TYPE(JSON_EXTRACT(card.render_payload_json, '$.actions')) = 'ARRAY'
  AND JSON_LENGTH(JSON_EXTRACT(card.render_payload_json, '$.actions')) > 0
`, strings.TrimSpace(workspaceID), strings.TrimSpace(appID), now.UTC()); err != nil {
		return fmt.Errorf("remove deleted App Approval actions: %w", err)
	}
	return nil
}

func (store *Store) finalizeAppInstallationUninstall(ctx context.Context, result appInstallationUninstallResult) {
	for _, cleanup := range result.giteaWebhookCleanup {
		cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), giteaConnectionAPIRequestTimeout)
		err := store.deleteGiteaConnectionWebhookByID(cleanupCtx, cleanup.config, cleanup.target, cleanup.webhookID)
		cancel()
		if err != nil && store.logger != nil {
			store.logger.Warn("delete gitea webhook after App uninstall", "target", cleanup.target.DisplayLabel, "webhookID", cleanup.webhookID, "error", err)
		}
	}
}

// captureGiteaUninstallWebhookCleanupInTx retains only the current credentials
// and exact webhook ids needed for post-commit cleanup. Current Connection
// creation always stores both the webhook id and instance URL, so uninstall
// does not need a legacy provider-wide hook scan or any durable secret copy.
func (store *Store) captureGiteaUninstallWebhookCleanupInTx(
	ctx context.Context,
	tx coredb.Tx,
	installation serviceactor.Installation,
) ([]giteaUninstallWebhookCleanup, error) {
	if !isGiteaApp(installation.ServiceDefinitionID) {
		return nil, nil
	}
	config, found, err := store.loadGiteaUninstallConfigInTx(ctx, tx, installation.WorkspaceID)
	if err != nil || !found {
		return nil, err
	}
	connections, err := store.serviceActorStore().InTx(tx).ListActiveConnections(ctx, installation.ID)
	if err != nil {
		return nil, err
	}
	currentInstanceURL := strings.TrimRight(strings.TrimSpace(config.InstanceURL), "/")
	cleanup := make([]giteaUninstallWebhookCleanup, 0, len(connections))
	for _, connection := range connections {
		snapshot, err := store.giteaDisconnectConnectionSnapshot(connection)
		if err != nil {
			return nil, err
		}
		if strings.TrimRight(requestSnapshotString(snapshot, giteaConnectionRequestInstanceURLKey), "/") != currentInstanceURL {
			continue
		}
		webhookID := connectionSnapshotInt64(snapshot, giteaConnectionRequestWebhookIDKey)
		if webhookID <= 0 {
			continue
		}
		targetInput := strings.TrimSpace(connection.DisplayLabel)
		if targetInput == "" {
			targetInput = strings.TrimSpace(connection.ExternalSubjectRef)
		}
		target, err := parseGiteaConnectionTargetInput(targetInput)
		if err != nil {
			continue
		}
		cleanup = append(cleanup, giteaUninstallWebhookCleanup{config: config, target: target, webhookID: webhookID})
	}
	return cleanup, nil
}

func (store *Store) loadGiteaUninstallConfigInTx(
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
) (provider.GiteaAppConfig, bool, error) {
	var (
		config            provider.GiteaAppConfig
		encryptedAPIToken string
	)
	err := tx.QueryRow(ctx, `
SELECT provider_key, JSON_UNQUOTE(JSON_EXTRACT(COALESCE(credential_envelope, JSON_OBJECT()), '$.apiToken'))
FROM service_provider_configs
WHERE workspace_id = $1
  AND provider_kind = 'gitea'
LIMIT 1
FOR UPDATE
`, strings.TrimSpace(workspaceID)).Scan(
		&config.InstanceURL,
		&encryptedAPIToken,
	)
	if err != nil {
		if coredb.IsNotFound(err) {
			return provider.GiteaAppConfig{}, false, nil
		}
		return provider.GiteaAppConfig{}, false, fmt.Errorf("load Gitea configuration for App uninstall: %w", err)
	}
	config.APIToken, err = store.decryptGiteaAppConfigSecret(encryptedAPIToken)
	if err != nil {
		return provider.GiteaAppConfig{}, false, err
	}
	config.InstanceURL = strings.TrimSpace(config.InstanceURL)
	return config, true, nil
}

func appUninstallRequestInstallationConfig(request AppInstallationRequestView) (map[string]any, bool) {
	value, ok := appInstallationRequestStoredBaseConfig(request)[appUninstallBaseInstallationConfigKey]
	if !ok || value == nil {
		return nil, false
	}
	config, ok := value.(map[string]any)
	if !ok {
		return nil, false
	}
	return config, true
}
