package app

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/testkit/httputil"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

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

	fake := newFakeStore()
	fake.apps = []registry.AppDefinitionView{{
		ID:   "app_1",
		Slug: "github",
	}}
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/apps", nil)
	require.Equal(t, http.StatusOK, resp.Code)

	var out []map[string]any
	require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
	require.Len(t, out, 1)
	require.Equal(t, "github", out[0]["slug"])
	require.NotContains(t, out[0], "latestVersion")
	require.NotContains(t, out[0], "scopes")
	require.NotContains(t, out[0], "configSchema")
	require.NotContains(t, out[0], "runtimeCapability")
	require.NotContains(t, out[0], "canManageDefinition")
}

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

	fake := newFakeStore()
	fake.appsErr = httputil.ErrSentinel
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/apps", nil)
	require.Equal(t, http.StatusInternalServerError, resp.Code)
}

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

	fake := newFakeStore()
	fake.apps = []registry.AppDefinitionView{{ID: "app_1", Slug: "github"}}
	fake.projectionReconcileErr = errSentinel
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/apps", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Contains(t, resp.Body.String(), `"slug":"github"`)
	require.Equal(t, 1, fake.projectionReconcileRuns)
	require.NoError(t, fake.projectionContextErr)
	require.True(t, fake.projectionHasDeadline)
}

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

	fake := newFakeStore()
	fake.installations = []app.AppInstallationView{{
		ID: "ainst_1", AppSlug: "github", InstalledVersion: "1.0.0", Grants: []string{"messages.write"},
	}}
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-installations", nil)
	require.Equal(t, http.StatusOK, resp.Code)

	var out []map[string]any
	require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
	require.Len(t, out, 1)
	_, hasConfig := out[0]["config"]
	require.False(t, hasConfig)
	require.NotContains(t, out[0], "installedVersion")
	require.NotContains(t, out[0], "grants")
	require.Equal(t, false, out[0]["canManageApp"])
	require.NotContains(t, out[0], "runtimeCapability")
}

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

	fake := newFakeStore()
	fake.apps = []registry.AppDefinitionView{
		{ID: "app_generic", Slug: "generic"},
		{ID: "app_bespoke", Slug: "bespoke"},
	}
	fake.installations = []app.AppInstallationView{
		{ID: "ainst_generic", AppID: "app_generic", AppSlug: "generic"},
		{ID: "ainst_bespoke", AppID: "app_bespoke", AppSlug: "bespoke"},
	}
	index := &fakeProviderIndex{capabilities: map[string]ProviderConnectionCapability{
		"app_generic": {Domain: "generic-domain"},
	}}
	router := newAppRouter(Dependencies{
		AppStore:           fake,
		ExtensionProviders: index,
	})

	appsResponse := httputil.DoJSON(
		t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/apps", nil)
	require.Equal(t, http.StatusOK, appsResponse.Code)
	var apps []map[string]any
	require.NoError(t, json.Unmarshal(appsResponse.Body.Bytes(), &apps))
	require.Equal(t, map[string]any{"domain": "generic-domain"}, apps[0]["providerConnection"])
	require.NotContains(t, apps[1], "providerConnection")

	installationsResponse := httputil.DoJSON(
		t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-installations", nil)
	require.Equal(t, http.StatusOK, installationsResponse.Code)
	var installations []map[string]any
	require.NoError(t, json.Unmarshal(installationsResponse.Body.Bytes(), &installations))
	require.Equal(t,
		map[string]any{"domain": "generic-domain"},
		installations[0]["providerConnection"],
	)
	require.NotContains(t, installations[1], "providerConnection")
}

func TestInstallationChannelsList(t *testing.T) {
	t.Parallel()
	fake := newFakeStore()
	fake.installationChannels = []app.AppInstallationChannelView{{
		ID: "chn_1", Slug: "release", DisplayName: "Release",
		Description: "Release coordination", Visibility: "private", CanRemove: true,
	}}
	router := newAppRouter(Dependencies{AppStore: fake})
	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-installations/ainst_1/channels", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.JSONEq(t, `[{"id":"chn_1","slug":"release","displayName":"Release","description":"Release coordination","visibility":"private","canRemove":true}]`, resp.Body.String())
}

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

	fake := newFakeStore()
	fake.pendingInstallRequests = []app.AppInstallationRequestView{{
		ID:                     "svcinstreq_pending_1",
		AppID:                  "app_github",
		AppSlug:                "github",
		RequestKind:            app.AppInstallationRequestKindInstall,
		Status:                 app.AppInstallationRequestStatusPending,
		RequestedConfig:        map[string]any{"private": "must-not-leak"},
		RequestedScopes:        []string{"messages.write"},
		RequestedByDisplayName: "Alice",
		CreatedAt:              time.Date(2026, 7, 17, 8, 0, 0, 0, time.UTC),
	}}
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-installation-requests", nil)
	require.Equal(t, http.StatusOK, resp.Code)

	var out []map[string]any
	require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
	require.Len(t, out, 1)
	require.Equal(t, "svcinstreq_pending_1", out[0]["id"])
	require.Equal(t, "app_github", out[0]["appId"])
	require.Equal(t, "github", out[0]["appSlug"])
	require.Equal(t, "install", out[0]["requestKind"])
	require.Equal(t, "pending", out[0]["status"])
	require.NotContains(t, out[0], "requestedConfig")
	require.NotContains(t, out[0], "requestedScopes")
	require.NotContains(t, out[0], "requestedByDisplayName")
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-webhook-deliveries?installationId=ainst_1", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, 25, fake.lastDeliveriesLimit)
	require.Equal(t, "ainst_1", fake.lastDeliveriesInstall)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-webhook-deliveries?limit=5", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, 5, fake.lastDeliveriesLimit)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-webhook-deliveries?limit=abc", nil)
	require.Equal(t, http.StatusBadRequest, resp.Code)
	require.Contains(t, resp.Body.String(), "limit must be an integer")
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installations", map[string]any{
		"appSlug": "github",
		"config":  map[string]any{},
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "github", fake.lastDirectInstallParams.AppSlug)
	require.Empty(t, fake.lastProjectedPendingID)
	require.Empty(t, fake.lastProjectedDecisionID)
	require.Contains(t, resp.Body.String(), `"id":"appinst_direct_1"`)
	require.Contains(t, resp.Body.String(), `"config":{}`)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})
	resp := httputil.DoJSON(t, router, http.MethodPatch, "/api/v1/workspaces/wsp_1/app-installations/appinst_direct_1", map[string]any{
		"expectedBaseConfig": map[string]any{"tone": "brief"},
		"config":             map[string]any{"tone": "detailed"},
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "appinst_direct_1", fake.lastDirectConfigID)
	require.Equal(t, "brief", fake.lastDirectConfigParams.ExpectedBaseConfig["tone"])
	require.Equal(t, "detailed", fake.lastDirectConfigParams.Config["tone"])
	require.Empty(t, fake.lastProjectedPendingID)
	require.Contains(t, resp.Body.String(), `"config":{"tone":"detailed"}`)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})
	manifest := map[string]any{
		"schema_version": "1",
		"slug":           "echo-app",
		"version":        "1.0.0",
		"capabilities": map[string]any{
			"runtime": map[string]any{
				"runtime": map[string]any{
					"transports":        []any{"runner_stream"},
					"default_transport": "runner_stream",
				},
				"events": []any{"agentteam.message.mention"},
				"scopes": []any{"app.mentions.read", "messages.write"},
			},
		},
	}
	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/custom-app-requests", map[string]any{
		"slug":              "echo-app",
		"name":              "Echo App",
		"description":       "Replies to mentions",
		"manifest":          manifest,
		"config":            map[string]any{"tone": "brief"},
		"reason":            "team helper",
		"catalogVisibility": "public",
	})

	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "echo-app", fake.lastAppRequestParams.Slug)
	require.Equal(t, "Echo App", fake.lastAppRequestParams.Name)
	require.Equal(t, "runner_stream", fake.lastAppRequestParams.Manifest["capabilities"].(map[string]any)["runtime"].(map[string]any)["runtime"].(map[string]any)["default_transport"])
	require.Equal(t, "brief", fake.lastAppRequestParams.Config["tone"])
	require.Equal(t, "team helper", fake.lastAppRequestParams.Reason)
	require.Equal(t, "public", fake.lastAppRequestParams.CatalogVisibility)
	require.Equal(t, "svcinstreq_bot_1", fake.lastProjectedPendingID)
	require.Contains(t, resp.Body.String(), `"status":"pending"`)
	require.NotContains(t, resp.Body.String(), "callbackSigningSecret")
	require.NotContains(t, resp.Body.String(), "runnerToken")
}

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

	fake := newFakeStore()
	fake.directAppResult.CallbackSigningSecret = "one-time-callback-secret"
	fake.directAppResult.RunnerToken = "one-time-runner-token"
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/custom-apps", map[string]any{
		"slug":              "echo-app",
		"name":              "Echo App",
		"manifest":          map[string]any{},
		"catalogVisibility": "public",
	})

	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
	require.Equal(t, "echo-app", fake.lastDirectAppParams.Slug)
	require.Equal(t, "public", fake.lastDirectAppParams.CatalogVisibility)
	require.Empty(t, fake.lastProjectedDecisionID)
	require.Empty(t, fake.lastProjectedPendingID)
	require.Contains(t, resp.Body.String(), `"installation":{"id":"appinst_bot_direct_1"`)
	require.Contains(t, resp.Body.String(), `"catalogVisibility":"public"`)
	require.Contains(t, resp.Body.String(), `"config":{}`)
	require.Contains(t, resp.Body.String(), `"callbackSigningSecret":"one-time-callback-secret"`)
	require.Contains(t, resp.Body.String(), `"runnerToken":"one-time-runner-token"`)
	require.NotContains(t, resp.Body.String(), `"requestKind"`)
	require.NotContains(t, resp.Body.String(), `"status"`)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})
	base := "/api/v1/workspaces/wsp_1/app-installations/appinst_bot_1/initial-credentials"

	status := httputil.DoJSON(t, router, http.MethodGet, base, nil)
	require.Equal(t, http.StatusOK, status.Code)
	require.JSONEq(t, `{"status":"ready_to_claim"}`, status.Body.String())
	require.Equal(t, "appinst_bot_1", fake.lastInitialCredentialsID)
	require.Empty(t, status.Header().Get("Cache-Control"), "secret-free status does not need the plaintext response policy")

	claim := httputil.DoJSON(t, router, http.MethodPost, base+"/claim", nil)
	require.Equal(t, http.StatusOK, claim.Code)
	require.Equal(t, "no-store", claim.Header().Get("Cache-Control"))
	require.Contains(t, claim.Body.String(), `"callbackSigningSecret":"initial-callback-secret"`)
	require.Contains(t, claim.Body.String(), `"runnerToken":"initial-runner-token"`)
}

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

	fake := newFakeStore()
	fake.directAppResult.CallbackSigningSecret = "direct-callback"
	fake.directAppResult.RunnerToken = "direct-runner"
	router := newAppRouter(Dependencies{AppStore: fake})

	for _, request := range []struct {
		method string
		path   string
		body   any
	}{
		{method: http.MethodPost, path: "/api/v1/workspaces/wsp_1/custom-apps", body: map[string]any{"slug": "runtime", "name": "App", "manifest": map[string]any{}}},
		{method: http.MethodPost, path: "/api/v1/workspaces/wsp_1/app-installations/appinst_bot_1/initial-credentials/claim"},
		{method: http.MethodPost, path: "/api/v1/workspaces/wsp_1/app-installations/appinst_bot_1/runner-credentials", body: map[string]any{}},
		{method: http.MethodPost, path: "/api/v1/workspaces/wsp_1/app-installations/appinst_bot_1/callback-secret:rotate"},
	} {
		response := httputil.DoJSON(t, router, request.method, request.path, request.body)
		require.Equal(t, http.StatusOK, response.Code, request.path)
		require.Equal(t, "no-store", response.Header().Get("Cache-Control"), request.path)
	}
}

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

	fake := newFakeStore()
	fake.requestInstallResult.RequestKind = app.AppInstallationRequestKindConfigure
	fake.requestInstallResult.InstallationID = "appinst_1"
	fake.requestInstallResult.BaseConfig = map[string]any{"repository": "acme/old"}
	fake.requestInstallResult.RequestedConfig = map[string]any{"repository": "acme/agentteam"}
	fake.requestInstallResult.RequestedScopes = []string{"messages.write"}
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installation-requests", map[string]any{
		"appSlug":            "github",
		"installationId":     "appinst_1",
		"expectedBaseConfig": map[string]any{"repository": "acme/old"},
		"config":             map[string]any{"repository": "acme/agentteam"},
		"reason":             "ship workflow",
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "github", fake.lastInstallReqParams.AppSlug)
	require.Equal(t, "appinst_1", fake.lastInstallReqParams.InstallationID)
	require.Equal(t, "acme/old", fake.lastInstallReqParams.ExpectedBaseConfig["repository"])
	require.Equal(t, "acme/agentteam", fake.lastInstallReqParams.Config["repository"])
	require.Equal(t, "ship workflow", fake.lastInstallReqParams.Reason)
	require.Equal(t, "svcinstreq_1", fake.lastProjectedPendingID)
	require.Contains(t, resp.Body.String(), "svcinstreq_1")
	require.Contains(t, resp.Body.String(), `"requestKind":"configure"`)
	require.Contains(t, resp.Body.String(), `"status":"pending"`)
	require.NotContains(t, resp.Body.String(), `"baseConfig"`)
	require.NotContains(t, resp.Body.String(), "requestedVersion")
	require.NotContains(t, resp.Body.String(), `"requestedConfig"`)
	require.NotContains(t, resp.Body.String(), `"requestedScopes"`)
}

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

	fake := newFakeStore()
	fake.projectPendingErr = errSentinel
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installation-requests", map[string]any{
		"appSlug": "github",
		"reason":  "ship workflow",
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Contains(t, resp.Body.String(), `"id":"svcinstreq_1"`)
	require.Equal(t, "svcinstreq_1", fake.lastProjectedPendingID)
	require.NoError(t, fake.projectionContextErr)
	require.True(t, fake.projectionHasDeadline)
	require.Equal(t, 1, fake.projectionReconcileRuns, "a failed direct projection gets one immediate durable-ledger retry")

	// The request row is the durable retry ledger. A later App catalogue read
	// invokes the bounded workspace reconciler without changing the successful
	// create response into an error.
	fake.projectPendingErr = nil
	resp = httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/apps", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, 2, fake.projectionReconcileRuns)
	require.Equal(t, "wsp_1", fake.projectionReconcileWS)
	require.Equal(t, appInstallationProjectionReconcileLimit, fake.projectionReconcileCap)
	require.NoError(t, fake.projectionContextErr)
	require.True(t, fake.projectionHasDeadline)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})
	body := bytes.NewBufferString(`{"appSlug":"github","reason":"ship workflow"}`)
	request := httptest.NewRequest(
		http.MethodPost,
		"/api/v1/workspaces/wsp_1/app-installation-requests",
		body,
	)
	request.Header.Set("Authorization", "Bearer token")
	request.Header.Set("X-Workspace-Id", "wsp_1")
	request.Header.Set("Content-Type", "application/json")
	canceled, cancel := context.WithCancel(request.Context())
	cancel()
	request = request.WithContext(canceled)

	resp := httptest.NewRecorder()
	router.ServeHTTP(resp, request)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "svcinstreq_1", fake.lastProjectedPendingID)
	require.NoError(t, fake.projectionContextErr, "projection must detach from the canceled HTTP context")
	require.True(t, fake.projectionHasDeadline, "detached projection must remain bounded")
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installation-requests/svcinstreq_1:approve", map[string]any{
		"reason": "approved",
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "svcinstreq_1", fake.lastDecisionReqID)
	require.Equal(t, "approved", fake.lastDecisionReason)
	require.Equal(t, "approve", fake.lastDecisionAction)
	require.Equal(t, "svcinstreq_1", fake.lastProjectedDecisionID)
	require.Contains(t, resp.Body.String(), `"status":"approved"`)
}

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

	tests := []struct {
		name        string
		decisionErr error
		wantStatus  int
	}{
		{
			name: "stale proposal",
			decisionErr: errors.Join(
				app.ErrConflict,
				app.ErrAppInstallationApprovalStale,
			),
			wantStatus: http.StatusConflict,
		},
		{
			name: "deterministic gitea rejection",
			decisionErr: errors.Join(
				app.ErrBadRequest,
				app.ErrGiteaConnectionConfigurationFailed,
			),
			wantStatus: http.StatusBadRequest,
		},
	}
	for _, testCase := range tests {
		testCase := testCase
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			fake := newFakeStore()
			fake.decisionInstallErr = testCase.decisionErr
			router := newAppRouter(Dependencies{AppStore: fake})

			resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installation-requests/svcinstreq_1:approve", map[string]any{
				"reason": "approve request",
			})
			require.Equal(t, testCase.wantStatus, resp.Code)
			require.Equal(t, "svcinstreq_1", fake.lastMarkedRequestID)
			require.Equal(t, "svcinstreq_1", fake.lastProjectedPendingID)
			require.Empty(t, fake.lastProjectedDecisionID)
		})
	}
}

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

	fake := newFakeStore()
	fake.projectDecisionErr = errSentinel
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installation-requests/svcinstreq_1:approve", map[string]any{
		"reason": "approved",
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Contains(t, resp.Body.String(), `"status":"approved"`)
	require.Equal(t, "svcinstreq_1", fake.lastProjectedDecisionID)
	require.NoError(t, fake.projectionContextErr)
	require.True(t, fake.projectionHasDeadline)
	require.Equal(t, 1, fake.projectionReconcileRuns)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installation-requests/svcinstreq_1:reject", map[string]any{
		"reason": "needs owner",
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "svcinstreq_1", fake.lastDecisionReqID)
	require.Equal(t, "needs owner", fake.lastDecisionReason)
	require.Equal(t, "reject", fake.lastDecisionAction)
	require.Equal(t, "svcinstreq_1", fake.lastProjectedDecisionID)
	require.Contains(t, resp.Body.String(), `"status":"rejected"`)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installation-requests/%20:approve", map[string]any{})
	require.Equal(t, http.StatusBadRequest, resp.Code)
	require.Contains(t, resp.Body.String(), "requestId is required")
}

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

	fake := newFakeStore()
	fake.uninstallResult = app.AppInstallationView{ID: "ainst_1", AppSlug: "github", Enabled: false}
	blocker := &httputil.FakeBlocker{}
	router := newAppRouter(Dependencies{AppStore: fake, ServiceEventSubscriptionBlocker: blocker})

	resp := httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/app-installations/ainst_1", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Len(t, blocker.BlockCalls, 1)
	require.Equal(t, "wsp_1", blocker.BlockCalls[0].WorkspaceID)
	require.Equal(t, "github", blocker.BlockCalls[0].Provider)
	require.Equal(t, "", blocker.BlockCalls[0].ConnectionRef)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPatch, "/api/v1/workspaces/wsp_1/app-installations/ainst_1/display-name", map[string]any{
		"name": "Renamed App",
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "ainst_1", fake.lastAppDisplayNameID)
	require.Equal(t, "Renamed App", fake.lastAppDisplayName)
	require.Contains(t, resp.Body.String(), `"appName":"Renamed App"`)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPatch, "/api/v1/workspaces/wsp_1/app-installations/%20/display-name", map[string]any{
		"name": "Renamed App",
	})
	require.Equal(t, http.StatusBadRequest, resp.Code)
	require.Contains(t, resp.Body.String(), "installationId is required")
}

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

	fake := newFakeStore()
	fake.appVisibilityResult.RemovedChannelIDs = []string{"chn_2", "chn_1"}
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPatch, "/api/v1/workspaces/wsp_1/app-installations/ainst_1/catalog-visibility", map[string]any{
		"catalogVisibility": registry.CatalogVisibilityPrivate,
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "ainst_1", fake.lastAppVisibilityID)
	require.Equal(t, registry.CatalogVisibilityPrivate, fake.lastAppCatalogVisibility)
	require.JSONEq(t, `{"catalogVisibility":"private"}`, resp.Body.String())
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodPatch, "/api/v1/workspaces/wsp_1/app-installations/%20/catalog-visibility", map[string]any{
		"catalogVisibility": registry.CatalogVisibilityPublic,
	})
	require.Equal(t, http.StatusBadRequest, resp.Code)
	require.Contains(t, resp.Body.String(), "installationId is required")
}

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

	fake := newFakeStore()
	fake.agentAuthorizations = []app.AgentAppAuthorizationView{{
		AppInstallationID:   "ainst_1",
		AppSlug:             "github",
		AuthorizationStatus: "disabled",
		Supported:           true,
		MCPName:             "github",
	}}
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/agents/agt_1/app-authorizations", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "agt_1", fake.lastAgentAuthAgentID)

	resp = httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/agents/agt_1/app-authorizations", map[string]any{
		"appInstallationId": "ainst_1",
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "agt_1", fake.lastAgentAuthAgentID)
	require.Equal(t, "ainst_1", fake.lastAgentAuthInstallID)

	resp = httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/agents/agt_1/app-authorizations/ainst_1", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "agt_1", fake.lastAgentAuthAgentID)
	require.Equal(t, "ainst_1", fake.lastAgentAuthInstallID)
}

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

	fake := newFakeStore()
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-installations/ainst_1/service-event-subscriptions?limit=0", nil)
	require.Equal(t, http.StatusBadRequest, resp.Code)
	require.Contains(t, resp.Body.String(), "limit must be a positive integer")
}

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

	fake := newFakeStore()
	fake.eventSubscriptions = []app.ServiceEventSubscriptionView{{ID: "trg_1", AppInstallationID: "ainst_1", ResourceKey: "github:acme/agentteam#1"}}
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-installations/ainst_1/service-event-subscriptions?target=chn_1&resource=acme/agentteam%231&status=active&limit=7", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "ainst_1", fake.lastWatchInstallID)
	require.Equal(t, "chn_1", fake.lastWatchTarget)
	require.Equal(t, "acme/agentteam#1", fake.lastWatchResource)
	require.Equal(t, "active", fake.lastWatchOperation)
	require.Equal(t, 7, fake.lastWatchLimit)

	resp = httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installations/ainst_1/service-event-subscriptions", map[string]any{
		"target":     "chn_1",
		"prRefOrUrl": "https://github.com/acme/agentteam/pull/1",
		"events":     []string{"comments", "reviews"},
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "ainst_1", fake.lastWatchInstallID)
	require.Equal(t, "chn_1", fake.lastWatchTarget)
	require.Equal(t, "https://github.com/acme/agentteam/pull/1", fake.lastWatchResource)
	require.Equal(t, []string{"comments", "reviews"}, fake.lastWatchEvents)

	resp = httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/service-event-subscriptions/trg_1:pause", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "trg_1", fake.lastWatchID)
	require.Equal(t, "pause", fake.lastWatchOperation)

	resp = httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/service-event-subscriptions/trg_1:resume", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "resume", fake.lastWatchOperation)

	resp = httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/service-event-subscriptions/trg_1", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "delete", fake.lastWatchOperation)
	require.Equal(t, "service_event", fake.lastWatchAPI)
}

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

	fake := newFakeStore()
	fake.eventSubscriptions = []app.ServiceEventSubscriptionView{{ID: "trg_1", AppInstallationID: "ainst_1", ResourceKey: "github:acme/agentteam#1"}}
	router := newAppRouter(Dependencies{AppStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/app-installations/ainst_1/provider-watches?target=chn_1&resource=acme/agentteam%231&status=active&limit=7", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "provider_watch", fake.lastWatchAPI)
	require.Equal(t, "ainst_1", fake.lastWatchInstallID)
	require.Equal(t, "chn_1", fake.lastWatchTarget)
	require.Equal(t, "acme/agentteam#1", fake.lastWatchResource)
	require.Equal(t, "active", fake.lastWatchOperation)
	require.Equal(t, 7, fake.lastWatchLimit)

	resp = httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/app-installations/ainst_1/provider-watches", map[string]any{
		"target":     "chn_1",
		"prRefOrUrl": "https://github.com/acme/agentteam/pull/1",
	})
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "provider_watch", fake.lastWatchAPI)
	require.Equal(t, "ainst_1", fake.lastWatchInstallID)
	require.Contains(t, resp.Body.String(), `"watch"`)

	resp = httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/provider-watches/trg_1:pause", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Equal(t, "provider_watch", fake.lastWatchAPI)
	require.Equal(t, "trg_1", fake.lastWatchID)
	require.Equal(t, "pause", fake.lastWatchOperation)
	require.Contains(t, resp.Body.String(), `"watch"`)
}

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

	fake := newFakeStore()
	fake.uninstallResult = app.AppInstallationView{ID: "ainst_1", AppSlug: "gitea", Enabled: false}
	blocker := &httputil.FakeBlocker{}
	router := newAppRouter(Dependencies{AppStore: fake, ServiceEventSubscriptionBlocker: blocker})

	resp := httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/app-installations/ainst_1", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Len(t, blocker.BlockCalls, 1)
	require.Equal(t, "gitea", blocker.BlockCalls[0].Provider)
}

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

	fake := newFakeStore()
	fake.uninstallResult = app.AppInstallationView{ID: "ainst_1", AppSlug: "demo", Enabled: false}
	blocker := &httputil.FakeBlocker{}
	router := newAppRouter(Dependencies{AppStore: fake, ServiceEventSubscriptionBlocker: blocker})

	resp := httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/app-installations/ainst_1", nil)
	require.Equal(t, http.StatusOK, resp.Code)
	require.Empty(t, blocker.BlockCalls)
}

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

	fake := newFakeStore()
	fake.uninstallErr = storeerr.ErrNotFound
	blocker := &httputil.FakeBlocker{}
	router := newAppRouter(Dependencies{AppStore: fake, ServiceEventSubscriptionBlocker: blocker})

	resp := httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/app-installations/ainst_1", nil)
	require.Equal(t, http.StatusNotFound, resp.Code)
	require.Empty(t, blocker.BlockCalls)
}
