package provider_test

import (
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	. "github.com/tidbcloud/AgentTeam/internal/api/testkit"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/provider"
)

func TestGiteaMemberConnectRejectsInvalidTargetBeforeCreatingApproval(t *testing.T) {
	t.Parallel()

	gitea := newGiteaAPITestServer(t, "gitea-invalid-target-token")
	defer gitea.Close()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()

	owner := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-invalid-target-owner@example.com", "gitea-invalid-target-owner")
	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "gitea-invalid-target-member@example.com", "gitea-invalid-target-member")

	installation := requestAndApproveProviderAppForTest(t, runtime, owner.User.ID, workspaceID, "gitea")
	configured := saveGiteaConfigForTest(t, runtime, owner.User.ID, workspaceID, provider.UpsertGiteaAppConfigParams{
		InstanceURL:   gitea.URL(),
		APIToken:      "gitea-invalid-target-token",
		WebhookSecret: "gitea-invalid-target-secret",
		DisplayName:   "Internal Gitea",
	})
	require.True(t, configured.Registered)

	var requestsBefore, notificationsBefore int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM service_installation_requests
WHERE workspace_id = $1
`, workspaceID).Scan(&requestsBefore))
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
`, workspaceID).Scan(&notificationsBefore))

	errorResponse := map[string]any{}
	requestPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/app-installation-requests"
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":            "app_gitea",
		"installationId":   installation.ID,
		"requestKind":      "connect",
		"connectionTarget": "ghost-owner/ghost-repo",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &errorResponse)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	encodedError, err := json.Marshal(errorResponse)
	require.NoError(t, err)
	require.Contains(t, string(encodedError), "Failed to verify Gitea target")
	require.NotContains(t, string(encodedError), gitea.URL())
	require.NotContains(t, string(encodedError), "gitea-invalid-target-token")
	require.NotContains(t, string(encodedError), "/api/v1/repos/")

	var requestsAfter, notificationsAfter int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM service_installation_requests
WHERE workspace_id = $1
`, workspaceID).Scan(&requestsAfter))
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
`, workspaceID).Scan(&notificationsAfter))
	require.Equal(t, requestsBefore, requestsAfter, "invalid provider target must not create pending approval work")
	require.Equal(t, notificationsBefore, notificationsAfter, "invalid provider target must not project an applicant or reviewer card")
}

func TestGiteaConnectApprovalRejectsRequestTimeConfigDriftBeforeProviderIO(t *testing.T) {
	t.Parallel()

	original := newGiteaAPITestServer(t, "gitea-config-snapshot-old-token")
	original.allowRepository("Acme-Team/AgentTeam")
	defer original.Close()
	replacement := newGiteaAPITestServer(t, "gitea-config-snapshot-new-token")
	replacement.allowRepository("Acme-Team/AgentTeam")
	defer replacement.Close()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()

	owner := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-config-snapshot-owner@example.com", "gitea-config-snapshot-owner")
	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "gitea-config-snapshot-member@example.com", "gitea-config-snapshot-member")
	installation := requestAndApproveProviderAppForTest(t, runtime, owner.User.ID, workspaceID, "gitea")
	saveGiteaConfigForTest(t, runtime, owner.User.ID, workspaceID, provider.UpsertGiteaAppConfigParams{
		InstanceURL:   original.URL(),
		APIToken:      "gitea-config-snapshot-old-token",
		WebhookSecret: "gitea-config-snapshot-old-secret",
		DisplayName:   "Original Gitea",
	})

	requestPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/app-installation-requests"
	configPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/providers/gitea/config"
	var request dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":            "app_gitea",
		"installationId":   installation.ID,
		"requestKind":      "connect",
		"connectionTarget": "Acme-Team/AgentTeam",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &request)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, original.repoLookupCount("Acme-Team/AgentTeam"), "submission verifies against the request-time config")
	publicRequest, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.NotContains(t, publicRequest.RequestedConfig, "gitea_config_id")
	require.NotContains(t, publicRequest.RequestedConfig, "gitea_config_updated_at")
	require.NotContains(t, publicRequest.RequestedConfig, "gitea_config_instance_url")
	var storedRequestedConfig string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT requested_config FROM service_installation_requests WHERE id = $1`, request.ID).Scan(&storedRequestedConfig))
	require.Contains(t, storedRequestedConfig, "gitea_config_id")
	require.Contains(t, storedRequestedConfig, "gitea_config_updated_at")

	response = DoJSON(t, client, http.MethodPut, configPath, map[string]any{
		"instance_url":   replacement.URL(),
		"api_token":      "gitea-config-snapshot-new-token",
		"webhook_secret": "gitea-config-snapshot-new-secret",
		"display_name":   "Replacement Gitea",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+request.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	require.Equal(t, 1, original.repoLookupCount("Acme-Team/AgentTeam"), "stale approval performs no more old-provider I/O")
	require.Zero(t, replacement.repoLookupCount("Acme-Team/AgentTeam"), "stale target is never reinterpreted on the new instance")
	require.Zero(t, original.hookCount("Acme-Team/AgentTeam"))
	require.Zero(t, replacement.hookCount("Acme-Team/AgentTeam"))
	stale, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, stale.Status)
}

func TestGiteaDirectConnectionCommitMakesSameTargetProposalStale(t *testing.T) {
	t.Parallel()

	gitea := newGiteaAPITestServer(t, "gitea-direct-first-token")
	gitea.allowRepository("Acme-Team/AgentTeam")
	defer gitea.Close()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()
	owner := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-direct-first-owner@example.com", "gitea-direct-first-owner")
	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	workspaceID := workspaces[0].ID
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "gitea-direct-first-member@example.com", "gitea-direct-first-member")
	installation := requestAndApproveProviderAppForTest(t, runtime, owner.User.ID, workspaceID, "gitea")
	saveGiteaConfigForTest(t, runtime, owner.User.ID, workspaceID, provider.UpsertGiteaAppConfigParams{
		InstanceURL:   gitea.URL(),
		APIToken:      "gitea-direct-first-token",
		WebhookSecret: "gitea-direct-first-secret",
		DisplayName:   "Direct-first Gitea",
	})

	requestPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/app-installation-requests"
	connectionsPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/providers/gitea/connections"
	var connectRequest dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":            "app_gitea",
		"installationId":   installation.ID,
		"requestKind":      "connect",
		"connectionTarget": "Acme-Team/AgentTeam",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &connectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, connectionsPath, map[string]any{"target": "Acme-Team/AgentTeam"}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+connectRequest.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	staleConnect, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, connectRequest.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, staleConnect.Status)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"), "stale approval cannot rotate the direct webhook")

	var connections []provider.AppConnectionView
	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, connections, 1)
	var disconnectRequest dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":          "app_gitea",
		"installationId": installation.ID,
		"requestKind":    "disconnect",
		"connectionId":   connections[0].ID,
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &disconnectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodDelete, connectionsPath+"/"+connections[0].ID, nil, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusNoContent, response.StatusCode)
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+disconnectRequest.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	staleDisconnect, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, disconnectRequest.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, staleDisconnect.Status)
	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, connections)
}

func TestGiteaConnectionsRequireAppApprovalForMemberConnectAndDisconnect(t *testing.T) {
	t.Parallel()

	gitea := newGiteaAPITestServer(t, "gitea-api-token")
	gitea.allowRepository("Acme-Team/AgentTeam")
	defer gitea.Close()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()

	owner := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-approval-owner@example.com", "gitea-approval-owner")
	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "gitea-approval-member@example.com", "gitea-approval-member")

	installation := requestAndApproveProviderAppForTest(t, runtime, owner.User.ID, workspaceID, "gitea")
	configured := saveGiteaConfigForTest(t, runtime, owner.User.ID, workspaceID, provider.UpsertGiteaAppConfigParams{
		InstanceURL:   gitea.URL(),
		APIToken:      "gitea-api-token",
		WebhookSecret: "gitea-webhook-secret",
		DisplayName:   "Internal Gitea",
	})
	require.True(t, configured.Registered)

	requestPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/app-installation-requests"
	var connectRequest dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":            "app_gitea",
		"installationId":   installation.ID,
		"requestKind":      "connect",
		"connectionTarget": "Acme-Team/AgentTeam",
		"reason":           "connect the release repository",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &connectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "connect", connectRequest.RequestKind)
	require.Equal(t, 1, gitea.repoLookupCount("Acme-Team/AgentTeam"), "request submission verifies and canonicalizes the target")
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"), "request submission must not create a webhook")
	storedConnectRequest, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, connectRequest.ID)
	require.NoError(t, err)
	require.Equal(t, "acme-team/agentteam", storedConnectRequest.ConnectionTarget)
	require.Equal(t, "Acme-Team/AgentTeam", storedConnectRequest.ConnectionLabel)

	approvePath := requestPath + "/" + connectRequest.ID + ":approve"
	var approvedConnect dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, approvePath, map[string]any{"reason": "self approve"}, WorkspaceHeaders(member.AccessToken, workspaceID), &approvedConnect)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, 1, gitea.repoLookupCount("Acme-Team/AgentTeam"))
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))

	response = DoJSON(t, client, http.MethodPost, approvePath, map[string]any{"reason": "approved"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &approvedConnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "approved", approvedConnect.Status)
	require.Equal(t, 2, gitea.repoLookupCount("Acme-Team/AgentTeam"))
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))

	// A retried approval converges on the settled request and must not create a
	// second remote webhook.
	response = DoJSON(t, client, http.MethodPost, approvePath, map[string]any{"reason": "retry"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &approvedConnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 2, gitea.repoLookupCount("Acme-Team/AgentTeam"))
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))

	connectionsPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/providers/gitea/connections"
	var connections []provider.AppConnectionView
	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, connections, 1)
	require.Equal(t, "acme-team/agentteam", connections[0].ExternalSubjectRef)
	require.Equal(t, "Acme-Team/AgentTeam", connections[0].DisplayLabel)

	// Rejecting a second connect proposal has no local or remote side effect.
	var rejectedConnect dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":            "app_gitea",
		"installationId":   installation.ID,
		"requestKind":      "connect",
		"connectionTarget": "acme",
		"reason":           "connect another target",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &rejectedConnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+rejectedConnect.ID+":reject", map[string]any{"reason": "not needed"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &rejectedConnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "rejected", rejectedConnect.Status)
	require.Equal(t, 1, gitea.orgLookupCount("acme"), "submission verifies the target but rejection creates no webhook")
	require.Zero(t, gitea.hookCount("acme"))
	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, connections, 1)

	var disconnectRequest dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":          "app_gitea",
		"installationId": installation.ID,
		"requestKind":    "disconnect",
		"connectionId":   connections[0].ID,
		"reason":         "disconnect the repository",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &disconnectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "disconnect", disconnectRequest.RequestKind)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"), "disconnect submission must not mutate provider state")

	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+disconnectRequest.ID+":approve", map[string]any{"reason": "approved"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &disconnectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "approved", disconnectRequest.Status)
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))

	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, connections)

	// Decision retries reconcile the exact old hook id without re-disabling or
	// touching any future connection.
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+disconnectRequest.ID+":approve", map[string]any{"reason": "retry"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &disconnectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))

	// A remote hook and its credentials are scoped to one Gitea instance. AgentTeam
	// must not let configuration move to another instance while an active
	// connection still owns a webhook on the old server.
	var reconnectRequest dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":            "app_gitea",
		"installationId":   installation.ID,
		"requestKind":      "connect",
		"connectionTarget": "Acme-Team/AgentTeam",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &reconnectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+reconnectRequest.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), &reconnectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	oldInstanceHookIDs := gitea.hookIDs("Acme-Team/AgentTeam")
	require.Len(t, oldInstanceHookIDs, 1)

	secondGitea := newGiteaAPITestServer(t, "gitea-api-token")
	secondGitea.allowRepository("Acme-Team/AgentTeam")
	defer secondGitea.Close()
	_, err = runtime.Store.App().SaveGiteaAppConfigForWorkspaceAdmin(runtime.Ctx, owner.User.ID, workspaceID, provider.UpsertGiteaAppConfigParams{
		InstanceURL:   secondGitea.URL(),
		APIToken:      "gitea-api-token",
		WebhookSecret: "second-gitea-webhook-secret",
		DisplayName:   "Second Gitea",
	})
	require.ErrorIs(t, err, app.ErrConflict)
	require.ErrorContains(t, err, "Disconnect all Gitea connections")
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))
	require.Zero(t, secondGitea.hookCount("Acme-Team/AgentTeam"))

	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, connections, 1)
	var oldInstanceDisconnect dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":          "app_gitea",
		"installationId": installation.ID,
		"requestKind":    "disconnect",
		"connectionId":   connections[0].ID,
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &oldInstanceDisconnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+oldInstanceDisconnect.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), &oldInstanceDisconnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"), "disconnect uses the still-current old instance credentials")

	configured = saveGiteaConfigForTest(t, runtime, owner.User.ID, workspaceID, provider.UpsertGiteaAppConfigParams{
		InstanceURL:   secondGitea.URL(),
		APIToken:      "gitea-api-token",
		WebhookSecret: "second-gitea-webhook-secret",
		DisplayName:   "Second Gitea",
	})
	require.Equal(t, secondGitea.URL(), configured.InstanceURL)
}

func TestGiteaAppUninstallDeletesOnlyCurrentConnectionWebhookAfterSettlement(t *testing.T) {
	t.Parallel()

	gitea := newGiteaAPITestServer(t, "gitea-uninstall-token")
	gitea.allowRepository("Acme-Team/AgentTeam")
	defer gitea.Close()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()

	owner := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-uninstall-owner@example.com", "gitea-uninstall-owner")
	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "gitea-uninstall-member@example.com", "gitea-uninstall-member")

	installation := requestAndApproveProviderAppForTest(t, runtime, owner.User.ID, workspaceID, "gitea")
	configured := saveGiteaConfigForTest(t, runtime, owner.User.ID, workspaceID, provider.UpsertGiteaAppConfigParams{
		InstanceURL:   gitea.URL(),
		APIToken:      "gitea-uninstall-token",
		WebhookSecret: "gitea-uninstall-secret",
		DisplayName:   "Gitea before approved uninstall",
	})
	require.True(t, configured.Registered)

	requestPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/app-installation-requests"
	connectionsPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/providers/gitea/connections"
	configPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/providers/gitea/config"

	var connectRequest dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":            "app_gitea",
		"installationId":   installation.ID,
		"requestKind":      "connect",
		"connectionTarget": "Acme-Team/AgentTeam",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &connectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+connectRequest.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), &connectRequest)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "approved", connectRequest.Status)
	ownedHookIDs := gitea.hookIDs("Acme-Team/AgentTeam")
	require.Len(t, ownedHookIDs, 1)

	// Uninstall must use the exact webhook id stored by the current Connection.
	// A provider hook which AgentTeam does not own must survive both approved and
	// admin-direct uninstall; this also prevents a legacy broad hook scan from
	// being reintroduced.
	const unrelatedHookID int64 = 9001
	gitea.addHook("Acme-Team/AgentTeam", unrelatedHookID, "https://example.test/not-agentteam")
	require.ElementsMatch(t, []int64{ownedHookIDs[0], unrelatedHookID}, gitea.hookIDs("Acme-Team/AgentTeam"))

	var rejectedUninstall dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":          "app_gitea",
		"installationId": installation.ID,
		"requestKind":    "uninstall",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &rejectedUninstall)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.ElementsMatch(t, []int64{ownedHookIDs[0], unrelatedHookID}, gitea.hookIDs("Acme-Team/AgentTeam"), "pending uninstall must not call Gitea")
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+rejectedUninstall.ID+":reject", map[string]any{"reason": "keep the App"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &rejectedUninstall)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "rejected", rejectedUninstall.Status)
	require.ElementsMatch(t, []int64{ownedHookIDs[0], unrelatedHookID}, gitea.hookIDs("Acme-Team/AgentTeam"), "rejected uninstall must not call Gitea")

	var approvedUninstall dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":          "app_gitea",
		"installationId": installation.ID,
		"requestKind":    "uninstall",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &approvedUninstall)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+approvedUninstall.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), &approvedUninstall)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "approved", approvedUninstall.Status)
	require.Equal(t, []int64{unrelatedHookID}, gitea.hookIDs("Acme-Team/AgentTeam"), "approved uninstall deletes only the captured Connection webhook id")

	_, found, err := runtime.Store.App().Provider().LoadGiteaAppConfigForWorkspace(runtime.Ctx, workspaceID)
	require.NoError(t, err)
	require.False(t, found)
	installations, err := runtime.Store.App().ListAppInstallationsForUser(runtime.Ctx, member.User.ID, workspaceID)
	require.NoError(t, err)
	require.Empty(t, installations)

	// Reinstall through the retained admin Gitea surface and exercise the same
	// uninstall kernel through the direct admin route.
	var directConfig dto.GiteaAppConfigResponse
	response = DoJSON(t, client, http.MethodPut, configPath, map[string]any{
		"instance_url":   gitea.URL(),
		"api_token":      "gitea-uninstall-token",
		"webhook_secret": "gitea-uninstall-secret",
		"display_name":   "Gitea before direct uninstall",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &directConfig)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, directConfig.Registered)
	installations, err = runtime.Store.App().ListAppInstallationsForUser(runtime.Ctx, member.User.ID, workspaceID)
	require.NoError(t, err)
	require.Len(t, installations, 1)
	directInstallation := installations[0]

	response = DoJSON(t, client, http.MethodPost, connectionsPath, map[string]any{"target": "Acme-Team/AgentTeam"}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	directHookIDs := gitea.hookIDs("Acme-Team/AgentTeam")
	require.Len(t, directHookIDs, 2)
	require.Contains(t, directHookIDs, unrelatedHookID)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations/"+directInstallation.ID, nil, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, []int64{unrelatedHookID}, gitea.hookIDs("Acme-Team/AgentTeam"), "admin-direct uninstall shares the exact-id cleanup kernel")
}

func TestGiteaConnectionApprovalsKeepMultiplePendingProposalsIndependent(t *testing.T) {
	t.Parallel()

	gitea := newGiteaAPITestServer(t, "gitea-multiple-proposals-token")
	gitea.allowRepository("Acme-Team/AgentTeam")
	gitea.allowRepository("Acme-Team/Other")
	defer gitea.Close()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()

	owner := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-multi-owner@example.com", "gitea-multi-owner")
	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "gitea-multi-member@example.com", "gitea-multi-member")

	installation := requestAndApproveProviderAppForTest(t, runtime, owner.User.ID, workspaceID, "gitea")
	configured := saveGiteaConfigForTest(t, runtime, owner.User.ID, workspaceID, provider.UpsertGiteaAppConfigParams{
		InstanceURL:   gitea.URL(),
		APIToken:      "gitea-multiple-proposals-token",
		WebhookSecret: "gitea-multiple-proposals-secret",
		DisplayName:   "Internal Gitea",
	})
	require.True(t, configured.Registered)

	requestPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/app-installation-requests"
	connectionsPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/providers/gitea/connections"
	requestConnect := func(target, reason string) dto.AppInstallationRequestResponse {
		t.Helper()
		var request dto.AppInstallationRequestResponse
		response := DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
			"appId":            "app_gitea",
			"installationId":   installation.ID,
			"requestKind":      "connect",
			"connectionTarget": target,
			"reason":           reason,
		}, WorkspaceHeaders(member.AccessToken, workspaceID), &request)
		require.Equal(t, http.StatusOK, response.StatusCode)
		return request
	}

	firstSameTarget := requestConnect("Acme-Team/AgentTeam", "first same-target proposal")
	secondSameTarget := requestConnect("Acme-Team/AgentTeam", "second same-target proposal")
	otherTarget := requestConnect("Acme-Team/Other", "independent target proposal")
	var uninstallBeforeConnect dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":          "app_gitea",
		"installationId": installation.ID,
		"requestKind":    "uninstall",
		"reason":         "uninstall before connection decision",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &uninstallBeforeConnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEqual(t, firstSameTarget.ID, secondSameTarget.ID)
	require.NotEqual(t, firstSameTarget.ID, otherTarget.ID)

	var pendingCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM service_installation_requests
WHERE workspace_id = $1
  AND service_definition_id = 'app_gitea'
  AND status = 'pending'
`, workspaceID).Scan(&pendingCount))
	require.Equal(t, 4, pendingCount)

	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+firstSameTarget.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), &firstSameTarget)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+uninstallBeforeConnect.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode, "a newly approved connection makes the uninstall snapshot stale")
	staleUninstallAfterConnect, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, uninstallBeforeConnect.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, staleUninstallAfterConnect.Status)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+uninstallBeforeConnect.ID+":reject", map[string]any{"reason": "connection changed"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &uninstallBeforeConnect)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+secondSameTarget.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"), "a stale connect must not rotate the first approved webhook")
	staleSameTarget, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, secondSameTarget.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, staleSameTarget.Status)

	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+otherTarget.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), &otherTarget)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/Other"), "a different target remains independently approvable")

	// The stale proposal remains explicit review work, but it is not a global
	// write lock. A direct same-target refresh succeeds and still leaves one
	// canonical connection/webhook.
	response = DoJSON(t, client, http.MethodPost, connectionsPath, map[string]any{"target": "Acme-Team/AgentTeam"}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+secondSameTarget.ID+":reject", map[string]any{"reason": "superseded"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &secondSameTarget)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var connections []provider.AppConnectionView
	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, connections, 2)
	agentteamConnectionID := ""
	for _, connection := range connections {
		if connection.ExternalSubjectRef == "acme-team/agentteam" {
			agentteamConnectionID = connection.ID
		}
	}
	require.NotEmpty(t, agentteamConnectionID)

	requestDisconnect := func(reason string) dto.AppInstallationRequestResponse {
		t.Helper()
		var request dto.AppInstallationRequestResponse
		response := DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
			"appId":          "app_gitea",
			"installationId": installation.ID,
			"requestKind":    "disconnect",
			"connectionId":   agentteamConnectionID,
			"reason":         reason,
		}, WorkspaceHeaders(member.AccessToken, workspaceID), &request)
		require.Equal(t, http.StatusOK, response.StatusCode)
		return request
	}
	firstDisconnect := requestDisconnect("first disconnect")
	secondDisconnect := requestDisconnect("second disconnect")
	var uninstallBeforeDisconnect dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":          "app_gitea",
		"installationId": installation.ID,
		"requestKind":    "uninstall",
		"reason":         "uninstall before disconnect decision",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &uninstallBeforeDisconnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEqual(t, firstDisconnect.ID, secondDisconnect.ID)

	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+firstDisconnect.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), &firstDisconnect)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+uninstallBeforeDisconnect.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode, "an approved disconnect makes the uninstall snapshot stale")
	staleUninstallAfterDisconnect, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, uninstallBeforeDisconnect.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, staleUninstallAfterDisconnect.Status)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+secondDisconnect.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	staleDisconnect, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, secondDisconnect.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, staleDisconnect.Status)
}

func TestGiteaWorkspaceAdminDirectConfigConnectAndDisconnect(t *testing.T) {
	t.Parallel()

	gitea := newGiteaAPITestServer(t, "gitea-admin-direct-token")
	gitea.allowRepository("Acme-Team/AgentTeam")
	defer gitea.Close()

	runtime := StartRuntime(t)
	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()
	client := server.Client()

	owner := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-admin-direct-owner@example.com", "gitea-admin-direct-owner")
	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(owner.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "gitea-admin-direct-member@example.com", "gitea-admin-direct-member")
	admin := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "gitea-admin-direct-admin@example.com", "gitea-admin-direct-admin")
	var workspaceMembers dto.WorkspaceMemberPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/members", nil, AuthHeaders(owner.AccessToken), &workspaceMembers)
	require.Equal(t, http.StatusOK, response.StatusCode)
	adminMemberID := ""
	for _, workspaceMember := range workspaceMembers.Members {
		if workspaceMember.UserID == admin.User.ID {
			adminMemberID = workspaceMember.MemberID
			break
		}
	}
	require.NotEmpty(t, adminMemberID)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/members/"+adminMemberID, map[string]any{"role": "admin"}, AuthHeaders(owner.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	requestPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/app-installation-requests"
	configPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/providers/gitea/config"
	connectionsPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/providers/gitea/connections"

	// A pending install request is not a global write lock and therefore does not
	// mask the direct routes' real validation errors. No route calls Gitea before
	// a valid first configuration is committed.
	var pendingInstall dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appSlug": "gitea",
		"config": map[string]any{
			"instance_url":   gitea.URL(),
			"api_token":      "gitea-admin-direct-token",
			"webhook_secret": "gitea-admin-direct-secret",
			"display_name":   "Pending Install",
		},
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &pendingInstall)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPut, configPath, map[string]any{
		"instance_url": gitea.URL(), "display_name": "Missing initial secrets",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, connectionsPath, map[string]any{"target": "Acme-Team/AgentTeam"}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	response = DoJSON(t, client, http.MethodDelete, connectionsPath+"/missing", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.Zero(t, gitea.repoLookupCount("Acme-Team/AgentTeam"))
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))

	// Gitea is the one provider-specific direct first-install surface. The PUT
	// atomically installs the builtin App and saves the initial credentials; the
	// ordinary pending proposal remains separately reviewable.
	var configured dto.GiteaAppConfigResponse
	response = DoJSON(t, client, http.MethodPut, configPath, map[string]any{
		"instance_url":   gitea.URL(),
		"api_token":      "gitea-admin-direct-token",
		"webhook_secret": "gitea-admin-direct-secret",
		"display_name":   "Original Gitea",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &configured)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, configured.Registered)
	installations, err := runtime.Store.App().ListAppInstallationsForUser(runtime.Ctx, member.User.ID, workspaceID)
	require.NoError(t, err)
	require.Len(t, installations, 1)
	installation := installations[0]
	require.Equal(t, "app_gitea", installation.AppID)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+pendingInstall.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode, "direct first-install makes the older proposal stale")
	storedPendingInstall, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, pendingInstall.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, storedPendingInstall.Status)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+pendingInstall.ID+":reject", map[string]any{"reason": "superseded by direct setup"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &pendingInstall)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var requestsBeforeDirect int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT COUNT(*) FROM service_installation_requests WHERE workspace_id = $1`, workspaceID).Scan(&requestsBeforeDirect))

	// A member cannot bypass the request ledger through the provider route.
	response = DoJSON(t, client, http.MethodPut, configPath, map[string]any{
		"instance_url":   gitea.URL(),
		"api_token":      "member-token-must-not-apply",
		"webhook_secret": "member-secret-must-not-apply",
		"display_name":   "Member Must Not Apply",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, connectionsPath, map[string]any{"target": "Acme-Team/AgentTeam"}, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Zero(t, gitea.repoLookupCount("Acme-Team/AgentTeam"))
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))

	// The owner can directly edit an installed Gitea configuration. Blank
	// secrets preserve the encrypted current values.
	var directConfig dto.GiteaAppConfigResponse
	response = DoJSON(t, client, http.MethodPut, configPath, map[string]any{
		"instance_url":   gitea.URL(),
		"api_token":      "",
		"webhook_secret": "",
		"display_name":   "Renamed Gitea",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &directConfig)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, directConfig.Registered)
	require.Equal(t, "Renamed Gitea", directConfig.DisplayName)
	storedConfig, found, err := runtime.Store.App().Provider().LoadGiteaAppConfigForWorkspace(runtime.Ctx, workspaceID)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, "gitea-admin-direct-token", storedConfig.APIToken)
	require.Equal(t, "gitea-admin-direct-secret", storedConfig.WebhookSecret)

	// Provider response bodies are untrusted. A malicious Gitea error must not
	// be reflected through the direct endpoint, and a failed webhook create
	// must leave both local and remote connection state unchanged.
	const maliciousUpstreamBody = `{"message":"<script>alert('owned')</script> api_token=leak-me"}`
	gitea.setNextHookCreateResponse(http.StatusUnprocessableEntity, maliciousUpstreamBody)
	response = DoJSON(t, client, http.MethodPost, connectionsPath, map[string]any{"target": "Acme-Team/AgentTeam"}, WorkspaceHeaders(admin.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusBadRequest, response.StatusCode)
	errorBody, err := io.ReadAll(response.Body)
	require.NoError(t, err)
	require.Contains(t, string(errorBody), "Failed to create Gitea webhook")
	require.NotContains(t, string(errorBody), "script")
	require.NotContains(t, string(errorBody), "leak-me")
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))
	var failedConnections []provider.AppConnectionView
	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &failedConnections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, failedConnections)

	var directInstallation dto.ProviderAppInstallationResponse
	response = DoJSON(t, client, http.MethodPost, connectionsPath, map[string]any{"target": "Acme-Team/AgentTeam"}, WorkspaceHeaders(admin.AccessToken, workspaceID), &directInstallation)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, installation.ID, directInstallation.ID)
	require.Equal(t, 2, gitea.repoLookupCount("Acme-Team/AgentTeam"))
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))

	var requestsAfterDirect int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT COUNT(*) FROM service_installation_requests WHERE workspace_id = $1`, workspaceID).Scan(&requestsAfterDirect))
	require.Equal(t, requestsBeforeDirect, requestsAfterDirect, "admin direct writes must not create approval rows")

	var connections []provider.AppConnectionView
	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, connections, 1)

	// A member proposal remains pending review but does not block direct admin
	// config/connect/disconnect commands. Each direct command applies current
	// state; the older proposal becomes stale and cannot overwrite it.
	var pending dto.AppInstallationRequestResponse
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appId":          "app_gitea",
		"installationId": installation.ID,
		"requestKind":    "configure",
		"expectedBaseConfig": map[string]any{
			"instance_url": gitea.URL(),
			"display_name": "Renamed Gitea",
		},
		"config": map[string]any{
			"instance_url":   gitea.URL(),
			"api_token":      "",
			"webhook_secret": "",
			"display_name":   "Pending Member Change",
		},
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &pending)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "pending", pending.Status)
	response = DoJSON(t, client, http.MethodPut, configPath, map[string]any{
		"instance_url": gitea.URL(), "display_name": "Direct Owner Change",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	response = DoJSON(t, client, http.MethodPost, connectionsPath, map[string]any{"target": "Acme-Team/AgentTeam"}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))

	response = DoJSON(t, client, http.MethodDelete, connectionsPath+"/"+connections[0].ID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, 1, gitea.hookCount("Acme-Team/AgentTeam"))
	response = DoJSON(t, client, http.MethodDelete, connectionsPath+"/"+connections[0].ID, nil, WorkspaceHeaders(admin.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusNoContent, response.StatusCode)
	require.Zero(t, gitea.hookCount("Acme-Team/AgentTeam"))

	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+pending.ID+":approve", map[string]any{}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	staleConfig, err := runtime.Store.App().GetAppInstallationRequest(runtime.Ctx, workspaceID, pending.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, staleConfig.Status)
	response = DoJSON(t, client, http.MethodPost, requestPath+"/"+pending.ID+":reject", map[string]any{"reason": "use owner direct action"}, WorkspaceHeaders(owner.AccessToken, workspaceID), &pending)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "rejected", pending.Status)

	response = DoJSON(t, client, http.MethodGet, connectionsPath, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &connections)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, connections)
}
