package app_test

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"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/service/appruntime"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/app/runtime"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/provider"
	"github.com/tidbcloud/AgentTeam/internal/store/serviceactor"
)

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	admin := RegisterUserForOrganizationTests(t, client, server.URL, "apps-admin@example.com", "apps-admin")

	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(admin.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installation-requests", map[string]any{
		"appSlug": "gitea",
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	var installed app.AppInstallationView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations", map[string]any{
		"appSlug": "gitea",
		"config":  map[string]any{},
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &installed)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, installed.Enabled)
	require.Equal(t, "gitea", installed.AppSlug)
	require.Equal(t, "https://gitea.com/assets/img/logo.svg", installed.AvatarURL)
	require.NotNil(t, installed.Config, "direct install must acknowledge even an empty persisted config")
	var requestCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT COUNT(*) FROM service_installation_requests WHERE workspace_id = $1`, workspaceID).Scan(&requestCount))
	require.Zero(t, requestCount)

	var configured app.AppInstallationView
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations/"+installed.ID, map[string]any{
		"expectedBaseConfig": map[string]any{},
		"config":             map[string]any{},
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &configured)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, installed.ID, configured.ID)
	require.NotNil(t, configured.Config, "direct configure must acknowledge even an empty persisted config")
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT COUNT(*) FROM service_installation_requests WHERE workspace_id = $1`, workspaceID).Scan(&requestCount))
	require.Zero(t, requestCount)

	var uninstalled app.AppInstallationView
	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations/"+installed.ID, nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &uninstalled)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, uninstalled.Enabled)

	var membershipCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE member_id = $1
  AND removed_at IS NULL
  AND effective_to_seq IS NULL
`, installed.MemberID).Scan(&membershipCount))
	require.Equal(t, 0, membershipCount)
}

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

	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, "apps-owner@example.com", "apps-owner")
	member := RegisterUserForOrganizationTests(t, client, server.URL, "apps-member@example.com", "apps-member")

	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

	allChannelID := WorkspaceAllChannelID(t, runtime, workspaceID)
	now := time.Now().UTC()
	memberID := "mem_apps_install_member"
	_, err := runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', 'Apps Member', $3, 'member', 'active', $4, $4)
`, memberID, workspaceID, member.User.ID, now)
	require.NoError(t, err)
	ensureAppAPITestScopeMembership(t, runtime.Ctx, runtime.Pool, allChannelID, memberID, now)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations", map[string]any{
		"appSlug": "gitea",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	var apps []registry.AppDefinitionView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/apps", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &apps)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, apps)

	var ownerInstall app.AppInstallationView
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations", map[string]any{
		"appSlug": "gitea",
		"config":  map[string]any{},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerInstall)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, ownerInstall.Config, "direct install must acknowledge even an empty persisted config")
	require.Empty(t, ownerInstall.Config)

	var installations []map[string]any
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &installations)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, installations, 1)
	_, hasConfig := installations[0]["config"]
	require.False(t, hasConfig)
}

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

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

	client := server.Client()
	admin := RegisterUserForOrganizationTests(t, client, server.URL, "direct-app-admin@example.com", "direct-app-admin")
	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(admin.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	const slug = "http-direct-admin-app"
	body := map[string]any{
		"slug": slug,
		"name": "HTTP Direct Admin App",
		"manifest": map[string]any{
			"schema_version": "1",
			"slug":           slug,
			"name":           "HTTP Direct Admin App",
			"version":        "1.0.0",
			"scopes":         []string{app.AppMentionScopeRead, app.AppRuntimeScopeMessageWrite},
			"capabilities": map[string]any{
				"runtime": map[string]any{
					"runtime": map[string]any{
						"transports":        []string{serviceactor.RuntimeTransportKindRunnerStream},
						"default_transport": serviceactor.RuntimeTransportKindRunnerStream,
					},
					"events": []string{registry.AppEventMessageMention},
					"scopes": []string{app.AppMentionScopeRead, app.AppRuntimeScopeMessageWrite},
					"config_schema": map[string]any{
						"type":                 "object",
						"additionalProperties": true,
					},
				},
			},
		},
		"config": map[string]any{},
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/custom-app-requests", body, WorkspaceHeaders(admin.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusForbidden, response.StatusCode)

	var direct dto.CustomAppInstallationResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/custom-apps", body, WorkspaceHeaders(admin.AccessToken, workspaceID), &direct)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, direct.Installation.ID)
	require.Equal(t, slug, direct.Installation.AppSlug)
	require.NotNil(t, direct.Installation.Config, "direct App install must acknowledge even an empty persisted config")
	require.NotNil(t, direct.Installation.RuntimeCapability)
	require.True(t, direct.Installation.RuntimeCapability.Supported)
	require.Equal(t, []string{serviceactor.RuntimeTransportKindRunnerStream}, direct.Installation.RuntimeCapability.Transports)
	require.NotEmpty(t, direct.RunnerToken)
	encodedDirect, err := json.Marshal(direct)
	require.NoError(t, err)
	require.NotContains(t, string(encodedDirect), `"requestKind"`)
	require.NotContains(t, string(encodedDirect), `"status"`)

	runnerService := appruntime.NewRunnerService(appruntime.RunnerServiceOptions{Store: runtime.Store.Graph.ServiceActor, Authority: runtime.Store.App()})
	authenticated, err := runnerService.AuthenticateRunner(runtime.Ctx, direct.RunnerToken)
	require.NoError(t, err)
	require.Equal(t, direct.Installation.ID, authenticated.ServiceInstallationID)
	var requestCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT COUNT(*) FROM service_installation_requests WHERE workspace_id = $1`, workspaceID).Scan(&requestCount))
	require.Zero(t, requestCount)

	var uninstalled dto.AppInstallationResponse
	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations/"+direct.Installation.ID, nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &uninstalled)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, uninstalled.Enabled)

	var catalog []registry.AppDefinitionView
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/apps", nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &catalog)
	require.Equal(t, http.StatusOK, response.StatusCode)
	foundDeletedApp := false
	for _, definition := range catalog {
		if definition.ID == direct.Installation.AppID {
			foundDeletedApp = true
		}
	}
	require.False(t, foundDeletedApp)
	var definitionCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT COUNT(*) FROM service_definitions WHERE id = $1`, direct.Installation.AppID).Scan(&definitionCount))
	require.Zero(t, definitionCount)
	var attributionCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT COUNT(*) FROM extension_catalog_attributions WHERE service_definition_id = $1`, direct.Installation.AppID).Scan(&attributionCount))
	require.Zero(t, attributionCount)
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT COUNT(*) FROM service_installation_requests WHERE workspace_id = $1`, workspaceID).Scan(&requestCount))
	require.Zero(t, requestCount)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations", map[string]any{
		"appId":  direct.Installation.AppID,
		"config": map[string]any{},
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
}

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

	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")
	requester := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-approval-requester@example.com", "gitea-approval-requester")
	otherMember := RegisterUserForOrganizationTests(t, client, server.URL, "gitea-approval-other@example.com", "gitea-approval-other")

	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

	allChannelID := WorkspaceAllChannelID(t, runtime, workspaceID)
	now := time.Now().UTC()
	requesterMemberID := "mem_gitea_approval_requester"
	otherMemberID := "mem_gitea_approval_other"
	_, err := runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES
    ($1, $2, 'human', 'Gitea Approval Requester', $3, 'member', 'active', $5, $5),
    ($4, $2, 'human', 'Gitea Approval Other Member', $6, 'member', 'active', $5, $5)
`, requesterMemberID, workspaceID, requester.User.ID, otherMemberID, now, otherMember.User.ID)
	require.NoError(t, err)
	ensureAppAPITestScopeMembership(t, runtime.Ctx, runtime.Pool, allChannelID, requesterMemberID, now)
	ensureAppAPITestScopeMembership(t, runtime.Ctx, runtime.Pool, allChannelID, otherMemberID, now)

	const (
		apiToken      = "http-gitea-api-token-must-not-leak"
		webhookSecret = "http-gitea-webhook-secret-must-not-leak"
	)
	requestPath := server.URL + "/api/v1/workspaces/" + workspaceID + "/app-installation-requests"
	var created map[string]any
	response = DoJSON(t, client, http.MethodPost, requestPath, map[string]any{
		"appSlug": "gitea",
		"config": map[string]any{
			"instance_url":   "https://gitea.example.com",
			"api_token":      apiToken,
			"webhook_secret": webhookSecret,
			"display_name":   "Team Gitea",
		},
		"reason": "Connect the team Gitea instance",
	}, WorkspaceHeaders(requester.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "pending", created["status"])
	require.NotEmpty(t, created["id"])
	requireGiteaAppInstallationRequestHTTPMinimal(t, created, apiToken, webhookSecret)

	// The browser read model is requester-scoped: the requester can recover the
	// durable pending state after a reload, while administrators and peer members
	// cannot discover the proposal or its provider credentials.
	var requesterPending []map[string]any
	response = DoJSON(t, client, http.MethodGet, requestPath, nil, WorkspaceHeaders(requester.AccessToken, workspaceID), &requesterPending)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, requesterPending, 1)
	require.Equal(t, created["id"], requesterPending[0]["id"])
	require.Equal(t, "gitea", requesterPending[0]["appSlug"])
	require.Equal(t, "install", requesterPending[0]["requestKind"])
	require.Equal(t, "pending", requesterPending[0]["status"])
	pendingJSON, err := json.Marshal(requesterPending)
	require.NoError(t, err)
	require.NotContains(t, string(pendingJSON), apiToken)
	require.NotContains(t, string(pendingJSON), webhookSecret)
	require.NotContains(t, string(pendingJSON), "requestedConfig")

	for _, viewer := range []string{owner.AccessToken, otherMember.AccessToken} {
		var peerPending []map[string]any
		response = DoJSON(t, client, http.MethodGet, requestPath, nil, WorkspaceHeaders(viewer, workspaceID), &peerPending)
		require.Equal(t, http.StatusOK, response.StatusCode)
		require.Empty(t, peerPending)
	}

	requestID, ok := created["id"].(string)
	require.True(t, ok)
	var approved map[string]any
	response = DoJSON(
		t,
		client,
		http.MethodPost,
		requestPath+"/"+requestID+":approve",
		map[string]any{"reason": "Approved for workspace use"},
		WorkspaceHeaders(owner.AccessToken, workspaceID),
		&approved,
	)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "approved", approved["status"])
	requireGiteaAppInstallationRequestHTTPMinimal(t, approved, apiToken, webhookSecret)
}

func requireGiteaAppInstallationRequestHTTPMinimal(
	t testing.TB,
	payload map[string]any,
	apiToken string,
	webhookSecret string,
) {
	t.Helper()
	require.Len(t, payload, 3)
	require.NotEmpty(t, payload["id"])
	require.Equal(t, "install", payload["requestKind"])

	encoded, err := json.Marshal(payload)
	require.NoError(t, err)
	serialized := string(encoded)
	for _, forbidden := range []string{
		apiToken,
		webhookSecret,
		`"_storage"`,
		`"ciphertext"`,
		"aes-gcm-v1",
		`"requestedConfig"`,
		`"requestedScopes"`,
		`"api_token"`,
		`"webhook_secret"`,
	} {
		require.NotContains(t, serialized, forbidden)
	}
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	admin := RegisterUserForOrganizationTests(t, client, server.URL, "apps-schema-admin@example.com", "apps-schema-admin")
	member := RegisterUserForOrganizationTests(t, client, server.URL, "apps-schema-member@example.com", "apps-schema-member")

	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(admin.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	allChannelID := WorkspaceAllChannelID(t, runtime, workspaceID)
	now := time.Now().UTC()
	const memberID = "mem_apps_schema_requester"
	_, err := runtime.Pool.Exec(runtime.Ctx, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', 'Apps Schema Member', $3, 'member', 'active', $4, $4)
`, memberID, workspaceID, member.User.ID, now)
	require.NoError(t, err)
	ensureAppAPITestScopeMembership(t, runtime.Ctx, runtime.Pool, allChannelID, memberID, now)

	for _, testCase := range []struct {
		appSlug   string
		wantError string
	}{
		{appSlug: "github", wantError: "GitHub install requests must contain githubSetupProposalId, one or more connectionTargets, and an optional reason"},
		{appSlug: "gitea", wantError: `unsupported Gitea configuration field "token"`},
	} {
		t.Run(testCase.appSlug, func(t *testing.T) {
			var installErr struct {
				Error string `json:"error"`
			}
			response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installation-requests", map[string]any{
				"appSlug": testCase.appSlug,
				"config":  map[string]any{"token": "must-never-enter-approval"},
			}, WorkspaceHeaders(member.AccessToken, workspaceID), &installErr)
			require.Equal(t, http.StatusBadRequest, response.StatusCode)
			require.Contains(t, installErr.Error, testCase.wantError)
		})
	}
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	admin := RegisterUserForOrganizationTests(t, client, server.URL, "apps-webhook@example.com", "apps-webhook")

	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(admin.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	installed := requestAndApproveAppForAPIIntegrationTest(t, runtime, admin.User.ID, workspaceID, "github", nil)
	require.True(t, installed.Enabled)

	_, err := runtime.Store.App().Provider().SaveGitHubAppConfigForWorkspaceAdmin(runtime.Ctx, admin.User.ID, workspaceID, provider.UpsertGitHubAppConfigParams{
		AppID:         4242,
		AppSlug:       "agentteam-http",
		AppName:       "AgentTeam HTTP",
		HTMLURL:       "https://github.com/apps/agentteam-http",
		ClientID:      "Iv1.http",
		ClientSecret:  "client-secret",
		PrivateKeyPEM: TestGitHubPrivateKeyPEM(t),
		WebhookSecret: "github-secret",
		OwnerLogin:    "acme",
		OwnerID:       7,
	})
	require.NoError(t, err)

	allChannelID := WorkspaceAllChannelID(t, runtime, workspaceID)
	upsertAppConnectionForAppAPITests(t, runtime, installed.ID, admin.User.ID, "42", "acme", map[string]any{"webhookSecret": "github-secret"})

	var createResult app.ServiceEventSubscriptionActionResult
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations/"+installed.ID+"/service-event-subscriptions", map[string]any{
		"target":     allChannelID,
		"prRefOrUrl": "https://github.com/acme/agentteam/pull/123",
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &createResult)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "github:acme/agentteam#123", createResult.Subscription.ResourceKey)

	var (
		watchID       string
		bindingID     string
		watchStatus   string
		bindingStatus string
		resourceKey   string
		watchTarget   string
		eventFilters  string
	)
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT subscription.id, subscription.service_target_binding_id, subscription.status,
       tab.status, subscription.external_resource_key, subscription.target_ref,
       subscription.event_filters
FROM service_event_subscriptions subscription
JOIN service_target_bindings tab ON tab.id = subscription.service_target_binding_id
WHERE subscription.workspace_id = $1
  AND subscription.external_resource_key = $2
  AND subscription.service_definition_id = 'app_github'
  AND subscription.subscription_kind = 'pull_request'
`, workspaceID, "github:acme/agentteam#123").Scan(&watchID, &bindingID, &watchStatus, &bindingStatus, &resourceKey, &watchTarget, &eventFilters))
	require.NotEmpty(t, watchID)
	require.NotEmpty(t, bindingID)
	require.Equal(t, "active", watchStatus)
	require.Equal(t, "active", bindingStatus)
	require.Equal(t, "github:acme/agentteam#123", resourceKey)
	require.Equal(t, allChannelID, watchTarget)
	require.JSONEq(t, `{}`, eventFilters)

	payload := []byte(`{
  "action":"submitted",
  "installation":{"id":42},
  "repository":{"full_name":"acme/agentteam"},
  "pull_request":{"number":123,"html_url":"https://github.com/acme/agentteam/pull/123"},
  "review":{"id":123456,"state":"approved","body":"Ship it! Just one nit on the error handling.","html_url":"https://github.com/acme/agentteam/pull/123#pullrequestreview-123456","user":{"login":"octocat"}}
}`)

	webhookResponse := postSignedGitHubWebhook(t, client, server.URL+"/api/apps/github/webhook", "github-secret", "delivery-route-1", payload)
	require.Equal(t, http.StatusOK, webhookResponse.StatusCode)
	require.NoError(t, webhookResponse.Body.Close())

	var page message.AppMessagePage
	require.Eventually(t, func() bool {
		response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, allChannelID, 50), nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &page)
		return response.StatusCode == http.StatusOK && len(page.Messages) == 1
	}, time.Second, 25*time.Millisecond)
	require.Equal(t, "GitHub received a PR review (approved) by octocat on github:acme/agentteam#123\nhttps://github.com/acme/agentteam/pull/123#pullrequestreview-123456\n\n> Ship it! Just one nit on the error handling.", page.Messages[0].Content)
	require.Equal(t, "service", page.Messages[0].AuthorProfile.ActorType)
	require.Equal(t, "github", page.Messages[0].AuthorProfile.TargetID)
	require.Equal(t, "github", page.Messages[0].AuthorProfile.DisplayName)
	require.Equal(t, "https://github.githubassets.com/favicons/favicon.png", page.Messages[0].AuthorProfile.AvatarURL)
	require.Equal(t, "app", page.Messages[0].AuthorProfile.ServiceKind)
	require.Equal(t, "github", page.Messages[0].AuthorProfile.ServiceSlug)
	require.Empty(t, page.Messages[0].Metadata)
	require.Equal(t, message.ContributionSourceKindProviderEvent, page.Messages[0].Source.Kind)
	require.Equal(t, message.ContributionSourceRefTypeServiceEventDelivery, page.Messages[0].Source.RefType)
	require.NotEmpty(t, page.Messages[0].Source.RunID)
	require.Equal(t, page.Messages[0].Source.RunID, page.Messages[0].Source.RefID)
	require.NotEmpty(t, page.Messages[0].Source.EventID)
	require.Equal(t, "approved", page.Messages[0].Source.EventType)
	var sourceSubscriptionID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT service_event_subscription_id
FROM service_event_deliveries
WHERE id = $1
  AND message_id = $2
`, page.Messages[0].Source.RunID, page.Messages[0].ID).Scan(&sourceSubscriptionID))
	require.Equal(t, watchID, sourceSubscriptionID)
	webhookResponse = postSignedGitHubWebhook(t, client, server.URL+"/api/apps/github/webhook", "github-secret", "delivery-route-1", payload)
	require.Equal(t, http.StatusOK, webhookResponse.StatusCode)
	require.NoError(t, webhookResponse.Body.Close())

	response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, allChannelID, 50), nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &page)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, page.Messages, 1)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	admin := RegisterUserForOrganizationTests(t, client, server.URL, "apps-webhook-instance-secret@example.com", "apps-webhook-instance-secret")

	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(admin.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	installed := requestAndApproveAppForAPIIntegrationTest(t, runtime, admin.User.ID, workspaceID, "github", nil)
	require.True(t, installed.Enabled)

	_, err := runtime.Store.App().Provider().SaveGitHubAppConfigForWorkspaceAdmin(runtime.Ctx, admin.User.ID, workspaceID, provider.UpsertGitHubAppConfigParams{
		AppID:         4242,
		AppSlug:       "agentteam-http",
		AppName:       "AgentTeam HTTP",
		HTMLURL:       "https://github.com/apps/agentteam-http",
		ClientID:      "Iv1.http",
		ClientSecret:  "client-secret",
		PrivateKeyPEM: "private-key",
		WebhookSecret: "instance-secret",
		OwnerLogin:    "acme",
		OwnerID:       7,
	})
	require.NoError(t, err)

	allChannelID := WorkspaceAllChannelID(t, runtime, workspaceID)
	upsertAppConnectionForAppAPITests(t, runtime, installed.ID, admin.User.ID, "42", "acme", map[string]any{})

	var createResult app.ServiceEventSubscriptionActionResult
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations/"+installed.ID+"/service-event-subscriptions", map[string]any{
		"target":     allChannelID,
		"prRefOrUrl": "https://github.com/acme/agentteam/pull/123",
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &createResult)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "github:acme/agentteam#123", createResult.Subscription.ResourceKey)

	payload := []byte(`{
  "action":"submitted",
  "installation":{"id":42},
  "repository":{"full_name":"acme/agentteam"},
  "pull_request":{"number":123,"html_url":"https://github.com/acme/agentteam/pull/123"},
  "review":{"id":123456,"state":"approved","html_url":"https://github.com/acme/agentteam/pull/123#pullrequestreview-123456","user":{"login":"octocat"}}
}`)

	webhookResponse := postSignedGitHubWebhook(t, client, server.URL+"/api/apps/github/webhook", "instance-secret", "delivery-instance-secret", payload)
	require.Equal(t, http.StatusOK, webhookResponse.StatusCode)
	require.NoError(t, webhookResponse.Body.Close())

	var page message.AppMessagePage
	require.Eventually(t, func() bool {
		response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, allChannelID, 50), nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &page)
		return response.StatusCode == http.StatusOK && len(page.Messages) == 1
	}, time.Second, 25*time.Millisecond)
	require.Equal(t, "GitHub received a PR review (approved) by octocat on github:acme/agentteam#123\nhttps://github.com/acme/agentteam/pull/123#pullrequestreview-123456", page.Messages[0].Content)
	require.Equal(t, "github", page.Messages[0].AuthorProfile.TargetID)
	require.Equal(t, "service", page.Messages[0].AuthorProfile.ActorType)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	admin := RegisterUserForOrganizationTests(t, client, server.URL, "apps-webhook-manual-fallback@example.com", "apps-webhook-manual-fallback")

	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(admin.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID
	installed := requestAndApproveAppForAPIIntegrationTest(t, runtime, admin.User.ID, workspaceID, "github", nil)
	require.True(t, installed.Enabled)

	_, err := runtime.Store.App().Provider().SaveGitHubAppConfigForWorkspaceAdmin(runtime.Ctx, admin.User.ID, workspaceID, provider.UpsertGitHubAppConfigParams{
		AppID:         4242,
		AppSlug:       "agentteam-http",
		AppName:       "AgentTeam HTTP",
		HTMLURL:       "https://github.com/apps/agentteam-http",
		ClientID:      "Iv1.http",
		ClientSecret:  "client-secret",
		PrivateKeyPEM: "private-key",
		WebhookSecret: "instance-secret",
		OwnerLogin:    "acme",
		OwnerID:       7,
	})
	require.NoError(t, err)

	allChannelID := WorkspaceAllChannelID(t, runtime, workspaceID)
	upsertAppConnectionForAppAPITests(t, runtime, installed.ID, admin.User.ID, "42", "acme", map[string]any{"webhookSecret": "manual-secret"})

	var createResult app.ServiceEventSubscriptionActionResult
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/app-installations/"+installed.ID+"/service-event-subscriptions", map[string]any{
		"target":     allChannelID,
		"prRefOrUrl": "https://github.com/acme/agentteam/pull/123",
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &createResult)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "github:acme/agentteam#123", createResult.Subscription.ResourceKey)

	payload := []byte(`{
  "action":"submitted",
  "installation":{"id":42},
  "repository":{"full_name":"acme/agentteam"},
  "pull_request":{"number":123,"html_url":"https://github.com/acme/agentteam/pull/123"},
  "review":{"id":123456,"state":"approved","html_url":"https://github.com/acme/agentteam/pull/123#pullrequestreview-123456","user":{"login":"octocat"}}
}`)

	webhookResponse := postSignedGitHubWebhook(t, client, server.URL+"/api/apps/github/webhook", "manual-secret", "delivery-manual-secret", payload)
	require.Equal(t, http.StatusOK, webhookResponse.StatusCode)
	require.NoError(t, webhookResponse.Body.Close())

	var page message.AppMessagePage
	require.Eventually(t, func() bool {
		response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, allChannelID, 50), nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &page)
		return response.StatusCode == http.StatusOK && len(page.Messages) == 1
	}, time.Second, 25*time.Millisecond)
	require.Equal(t, "GitHub received a PR review (approved) by octocat on github:acme/agentteam#123\nhttps://github.com/acme/agentteam/pull/123#pullrequestreview-123456", page.Messages[0].Content)
	require.Equal(t, "github", page.Messages[0].AuthorProfile.TargetID)
	require.Equal(t, "service", page.Messages[0].AuthorProfile.ActorType)
}

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

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	admin := RegisterUserForOrganizationTests(t, client, server.URL, "apps-channel-members@example.com", "apps-channel-members")

	var workspaces []dto.WorkspaceResponse
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, AuthHeaders(admin.AccessToken), &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var privateChannel ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name":        "apps-private",
		"description": "private app channel",
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &privateChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	installed := requestAndApproveRunnerAppForAPIIntegrationTest(t, runtime, admin.User.ID, workspaceID, "channel-members-app")

	var addError struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members", map[string]any{
		"serviceMemberId": installed.MemberID,
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &addError)
	require.Equalf(t, http.StatusOK, response.StatusCode, "add App failed: %s", addError.Error)

	var memberList message.ChannelMemberList
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members", nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &memberList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, memberList.Services, 1)
	require.Equal(t, installed.MemberID, memberList.Services[0].ID)
	require.Equal(t, "channel-members-app", memberList.Services[0].DisplayName)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members/service/"+installed.MemberID, nil, WorkspaceHeaders(admin.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var membershipCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
  AND effective_to_seq IS NULL
`, privateChannel.ID, installed.MemberID).Scan(&membershipCount))
	require.Equal(t, 0, membershipCount)

	var updatedMemberList message.ChannelMemberList
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/members", nil, WorkspaceHeaders(admin.AccessToken, workspaceID), &updatedMemberList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, updatedMemberList.Services)
}

func upsertAppConnectionForAppAPITests(
	t *testing.T,
	runtime Runtime,
	installationID string,
	userID string,
	externalSubjectRef string,
	displayLabel string,
	credentials map[string]any,
) runtime.ConnectionView {
	t.Helper()

	connection, err := runtime.Store.App().UpsertAppConnection(runtime.Ctx, app.UpsertAppConnectionParams{
		InstallationID:     installationID,
		ExternalSubjectRef: externalSubjectRef,
		DisplayLabel:       displayLabel,
		Credentials:        credentials,
		CreatedByUserID:    userID,
	})
	require.NoError(t, err)
	return connection
}

func postSignedGitHubWebhook(t *testing.T, client *http.Client, url string, secret string, deliveryID string, payload []byte) *http.Response {
	t.Helper()

	request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(payload))
	require.NoError(t, err)
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set("X-GitHub-Delivery", deliveryID)
	request.Header.Set("X-GitHub-Event", "pull_request_review")
	request.Header.Set("X-Hub-Signature-256", githubWebhookTestSignature(secret, payload))

	response, err := client.Do(request)
	require.NoError(t, err)
	return response
}

func githubWebhookTestSignature(secret string, payload []byte) string {
	mac := hmac.New(sha256.New, []byte(secret))
	_, _ = mac.Write(payload)
	return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
