package app

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/api/dto/converter"
	storeapp "github.com/tidbcloud/AgentTeam/internal/store/app"
)

const maxWebhookPayloadBytes = 10 * 1024 * 1024

const (
	appInstallationProjectionTimeout        = 10 * time.Second
	appInstallationProjectionReconcileLimit = 25
)

// List handles GET /api/v1/workspaces/:workspaceId/apps.
// Returns the list of apps available to the authenticated user in the current workspace.
func (handler *WorkspaceAppHandler) List(r api.WorkspaceScopedRequest) (api.Response, error) {
	apps, err := handler.store.ListAppsForUser(r.Context(), r.UserID(), r.WorkspaceID())
	if err != nil {
		return api.Response{}, err
	}
	handler.reconcileAppInstallationRequestProjections(r.Context(), r.WorkspaceID())

	return api.OK(handler.toAppDefinitionResponses(apps)), nil
}

// InstallationsList handles GET /api/v1/workspaces/:workspaceId/app-installations.
// Returns the list of app installations for the authenticated user in the current workspace.
func (handler *WorkspaceAppHandler) InstallationsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	installations, err := handler.store.ListAppInstallationsForUser(r.Context(), r.UserID(), r.WorkspaceID())
	if err != nil {
		return api.Response{}, err
	}

	return api.OK(handler.toAppInstallationResponses(installations)), nil
}

func (handler *WorkspaceAppHandler) toAppInstallationResponse(item storeapp.AppInstallationView) dto.AppInstallationResponse {
	response := converter.ToAppInstallationResponse(item)
	response.ProviderConnection = handler.providerConnectionCapability(item.AppID)
	return response
}

func (handler *WorkspaceAppHandler) toAppInstallationResponses(items []storeapp.AppInstallationView) []dto.AppInstallationResponse {
	if items == nil {
		return nil
	}
	responses := make([]dto.AppInstallationResponse, 0, len(items))
	for _, item := range items {
		responses = append(responses, handler.toAppInstallationResponse(item))
	}
	return responses
}

func (handler *WorkspaceAppHandler) toDirectAppInstallationResponse(item storeapp.AppInstallationView) dto.DirectAppInstallationResponse {
	response := converter.ToDirectAppInstallationResponse(item)
	response.ProviderConnection = handler.providerConnectionCapability(item.AppID)
	return response
}

func (handler *WorkspaceAppHandler) InstallationChannelsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	channels, err := handler.store.ListAppInstallationChannelsForUser(
		r.Context(), r.UserID(), r.WorkspaceID(), strings.TrimSpace(r.Gin.Param("installationId")),
	)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToAppInstallationChannelResponses(channels)), nil
}

// InstallationExtensionsList returns the App Definition's allowed Extension
// entries together with the Workspace Installation's current selections.
func (handler *WorkspaceAppHandler) InstallationExtensionsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	extensions, err := handler.store.ListInstallationExtensionsForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
	)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToInstallationExtensionResponses(extensions)), nil
}

// InstallationExtensionCreate selects one Extension allowed by the host App.
func (handler *WorkspaceAppHandler) InstallationExtensionCreate(r api.WorkspaceScopedJSON[dto.WorkspaceInstallationExtensionCreateRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	selection, err := handler.store.AddInstallationExtensionForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		r.Body.DefinitionDomain,
		r.Body.ExtensionName,
	)
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(converter.ToInstallationExtensionResponse(selection)), nil
}

// InstallationExtensionUpdate performs exactly one explicit rename or
// enable/disable operation, avoiding ambiguous partial updates.
func (handler *WorkspaceAppHandler) InstallationExtensionUpdate(r api.WorkspaceScopedJSON[dto.WorkspaceInstallationExtensionUpdateRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	selectionID := strings.TrimSpace(r.Gin.Param("selectionId"))
	if installationID == "" || selectionID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId and selectionId are required")
	}
	if (r.Body.ExtensionName == nil) == (r.Body.Enabled == nil) {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "provide exactly one of extensionName or enabled")
	}

	if r.Body.ExtensionName != nil {
		updated, updateErr := handler.store.RenameInstallationExtensionForUser(
			r.Context(),
			r.UserID(),
			r.WorkspaceID(),
			installationID,
			selectionID,
			*r.Body.ExtensionName,
		)
		if updateErr != nil {
			return api.Response{}, updateErr
		}
		handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
		return api.OK(converter.ToInstallationExtensionResponse(updated)), nil
	}
	if *r.Body.Enabled {
		updated, updateErr := handler.store.EnableInstallationExtensionForUser(
			r.Context(),
			r.UserID(),
			r.WorkspaceID(),
			installationID,
			selectionID,
		)
		if updateErr != nil {
			return api.Response{}, updateErr
		}
		handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
		return api.OK(converter.ToInstallationExtensionResponse(updated)), nil
	}
	updated, err := handler.store.DisableInstallationExtensionForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		selectionID,
	)
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(converter.ToInstallationExtensionResponse(updated)), nil
}

// InstallationExtensionDelete removes one Extension from the flattened Agent
// surface while preserving its App settings row as disabled.
func (handler *WorkspaceAppHandler) InstallationExtensionDelete(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	selectionID := strings.TrimSpace(r.Gin.Param("selectionId"))
	if installationID == "" || selectionID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId and selectionId are required")
	}
	selection, err := handler.store.DisableInstallationExtensionForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		selectionID,
	)
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(converter.ToInstallationExtensionResponse(selection)), nil
}

// InstallationConfigure handles a direct generic manifest config mutation for
// a workspace owner/admin or an active administrator of an external App.
// Provider-specific credentials remain on provider routes.
func (handler *WorkspaceAppHandler) InstallationConfigure(r api.WorkspaceScopedJSON[dto.WorkspaceAppInstallationConfigureRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	installation, err := handler.store.ConfigureAppInstallationForWorkspaceAdmin(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		storeapp.ConfigureAppInstallationParams{
			ExpectedBaseConfig: r.Body.ExpectedBaseConfig,
			Config:             r.Body.Config,
		},
	)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(handler.toDirectAppInstallationResponse(installation)), nil
}

// AppInstallationCreate handles the workspace-admin direct external App
// onboarding command. No request projection is performed because the store
// atomically materializes the installation and credentials without a ledger.
func (handler *WorkspaceAppHandler) AppInstallationCreate(r api.WorkspaceScopedJSON[dto.WorkspaceCustomAppCreateRequest]) (api.Response, error) {
	result, err := handler.store.InstallCustomAppForWorkspaceAdmin(r.Context(), r.UserID(), r.WorkspaceID(), storeapp.RequestCustomAppInstallationParams{
		Slug:              r.Body.Slug,
		Name:              r.Body.Name,
		Description:       r.Body.Description,
		AvatarURL:         r.Body.AvatarURL,
		Manifest:          r.Body.Manifest,
		Config:            r.Body.Config,
		CatalogVisibility: r.Body.CatalogVisibility,
	})
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return noStoreOK(converter.ToCustomAppInstallationResponse(result)), nil
}

func noStoreOK(body any) api.Response {
	response := api.OK(body)
	response.Headers = make(http.Header)
	response.Headers.Set("Cache-Control", "no-store")
	return response
}

// AppInstallationRequestCreate handles POST
// /api/v1/workspaces/:workspaceId/custom-app-requests. It is the narrow
// workspace-scoped external App onboarding path; it intentionally does not
// create a developer/creator ownership surface.
func (handler *WorkspaceAppHandler) AppInstallationRequestCreate(r api.WorkspaceScopedJSON[dto.WorkspaceCustomAppRequestCreateRequest]) (api.Response, error) {
	result, err := handler.store.RequestCustomAppInstallationForUser(r.Context(), r.UserID(), r.WorkspaceID(), storeapp.RequestCustomAppInstallationParams{
		Slug:              r.Body.Slug,
		Name:              r.Body.Name,
		Description:       r.Body.Description,
		AvatarURL:         r.Body.AvatarURL,
		Manifest:          r.Body.Manifest,
		Config:            r.Body.Config,
		Reason:            r.Body.Reason,
		CatalogVisibility: r.Body.CatalogVisibility,
	})
	if err != nil {
		return api.Response{}, err
	}
	handler.projectCommittedAppInstallationRequest(r.Context(), result.Request)
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(converter.ToCustomAppRequestResponse(result)), nil
}

// AppAvatarUpdate applies a App-owned immutable avatar asset through the same
// server-authoritative management capability used by configuration, secrets,
// channels, and uninstall.
func (handler *WorkspaceAppHandler) AppAvatarUpdate(r api.WorkspaceScopedJSON[dto.WorkspaceAppAvatarUpdateRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	installation, err := handler.store.UpdateAppAvatarForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		r.Body.AvatarAssetID,
		time.Now().UTC(),
	)
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(dto.AppAvatarResponse{AvatarURL: installation.AvatarURL}), nil
}

// AppManagersList returns active administrator assignments for one canonical
// workspace App. Ordinary members cannot enumerate this authorization fact.
func (handler *WorkspaceAppHandler) AppManagersList(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	managers, err := handler.store.ListAppManagersForUser(
		r.Context(), r.UserID(), r.WorkspaceID(), installationID,
	)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(map[string]any{"managers": converter.ToAppManagerResponses(managers)}), nil
}

// AppManagerAdd grants the complete App management capability to one active
// Human member in the same workspace.
func (handler *WorkspaceAppHandler) AppManagerAdd(r api.WorkspaceScopedJSON[dto.WorkspaceAppManagerAddRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	manager, err := handler.store.AddAppManagerForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		r.Body.MemberID,
		time.Now().UTC(),
	)
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(converter.ToAppManagerResponse(manager)), nil
}

// AppManagerRemove ends one versioned administrator assignment while keeping
// at least one active Human manager for the App.
func (handler *WorkspaceAppHandler) AppManagerRemove(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	managerID := strings.TrimSpace(r.Gin.Param("managerId"))
	if installationID == "" || managerID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId and managerId are required")
	}
	expectedVersion, err := strconv.ParseInt(strings.TrimSpace(r.Gin.Query("expectedVersion")), 10, 64)
	if err != nil || expectedVersion <= 0 {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "expectedVersion must be a positive integer")
	}
	manager, err := handler.store.RemoveAppManagerForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		managerID,
		expectedVersion,
		time.Now().UTC(),
	)
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(converter.ToAppManagerResponse(manager)), nil
}

// InstallationRequestsList handles GET
// /api/v1/workspaces/:workspaceId/app-installation-requests. The store scopes
// the result to the authenticated member and to pending rows, so the browser
// can render durable approval state without exposing peer proposals.
func (handler *WorkspaceAppHandler) InstallationRequestsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	requests, err := handler.store.ListPendingAppInstallationRequestsForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
	)
	if err != nil {
		return api.Response{}, err
	}
	pendingResponses, err := converter.ToPendingAppInstallationRequestResponses(requests)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(pendingResponses), nil
}

// projectCommittedAppInstallationRequest is deliberately best effort: the App
// request row is the source of truth and has already committed before this is
// called. Returning a projection error would tell the client that the business
// mutation rolled back and can cause a duplicate retry. The detached bounded
// context lets projection finish after an HTTP disconnect; any remaining gap
// is discoverable by the opportunistic reconciler below.
func (handler *WorkspaceAppHandler) projectCommittedAppInstallationRequest(
	parent context.Context,
	request storeapp.AppInstallationRequestView,
) {
	if handler.installationRequestProjector == nil {
		handler.logAppInstallationProjectionFailure(request.WorkspaceID, request.ID, fmt.Errorf("projector is not configured"))
		return
	}
	ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), appInstallationProjectionTimeout)
	defer cancel()
	var err error
	switch strings.TrimSpace(request.Status) {
	case storeapp.AppInstallationRequestStatusPending:
		_, err = handler.installationRequestProjector.ProjectPendingAppInstallationRequest(ctx, request)
	case storeapp.AppInstallationRequestStatusApproved, storeapp.AppInstallationRequestStatusRejected:
		_, err = handler.installationRequestProjector.ProjectAppInstallationRequestDecision(ctx, request)
	default:
		err = fmt.Errorf("unsupported request status %q", request.Status)
	}
	if err != nil {
		handler.logAppInstallationProjectionFailure(request.WorkspaceID, request.ID, err)
		// Retry from the durable request row rather than trusting any partial
		// in-memory projection result. A later App catalogue read remains an
		// additional recovery trigger if this bounded retry also fails.
		handler.reconcileAppInstallationRequestProjections(parent, request.WorkspaceID)
	}
}

// reconcileAppInstallationRequestProjections opportunistically drains durable
// projection gaps whenever the App catalogue is opened. Reconciliation errors
// never replace the successful catalogue response; the source request remains
// eligible for the next bounded replay.
func (handler *WorkspaceAppHandler) reconcileAppInstallationRequestProjections(
	parent context.Context,
	workspaceID string,
) {
	if handler.installationRequestProjector == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), appInstallationProjectionTimeout)
	defer cancel()
	if _, err := handler.installationRequestProjector.ReconcileAppInstallationRequestProjections(
		ctx,
		workspaceID,
		appInstallationProjectionReconcileLimit,
	); err != nil {
		handler.logAppInstallationProjectionFailure(workspaceID, "", err)
	}
}

func (handler *WorkspaceAppHandler) logAppInstallationProjectionFailure(
	workspaceID, requestID string,
	err error,
) {
	if handler == nil || handler.logger == nil || err == nil {
		return
	}
	handler.logger.Error(
		"App installation Approval projection did not converge",
		"workspace_id", strings.TrimSpace(workspaceID),
		"request_id", strings.TrimSpace(requestID),
		"error", err,
	)
}

// WebhookDeliveriesList handles GET /api/v1/workspaces/:workspaceId/app-webhook-deliveries.
// Returns the list of webhook deliveries for the authenticated user's app installations.
func (handler *WorkspaceAppHandler) WebhookDeliveriesList(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Query("installationId"))
	limit := 25
	if rawLimit := strings.TrimSpace(r.Gin.Query("limit")); rawLimit != "" {
		parsedLimit, err := strconv.Atoi(rawLimit)
		if err != nil {
			return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "limit must be an integer")
		}
		limit = parsedLimit
	}

	deliveries, err := handler.store.ListAppWebhookDeliveriesForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		limit,
	)
	if err != nil {
		return api.Response{}, err
	}

	return api.OK(converter.ToAppWebhookDeliveryResponses(deliveries)), nil
}

// InstallationUninstall handles DELETE /api/v1/workspaces/:workspaceId/app-installations/:installationId.
// Uninstalls an app installation and blocks related service-event subscriptions if applicable.
func (handler *WorkspaceAppHandler) InstallationUninstall(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}

	installation, err := handler.store.UninstallAppInstallationForUser(r.Context(), r.UserID(), r.WorkspaceID(), installationID)
	if err != nil {
		return api.Response{}, err
	}
	switch strings.ToLower(strings.TrimSpace(installation.AppSlug)) {
	case "github", "gitea":
		blockServiceEventSubscriptions(r.Context(), handler.serviceEventSubscriptionBlocker, handler.logger, r.WorkspaceID(), installation.AppSlug, "")
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())

	return api.OK(handler.toAppInstallationResponse(installation)), nil
}

// AppDisplayNameUpdate handles PATCH
// /api/v1/workspaces/:workspaceId/app-installations/:installationId/display-name.
// The store enforces current active App-manager or workspace-administrator
// authority and limits this narrow mutation to active external App
// installations.
func (handler *WorkspaceAppHandler) AppDisplayNameUpdate(r api.WorkspaceScopedJSON[dto.WorkspaceAppDisplayNameUpdateRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	installation, err := handler.store.UpdateAppDisplayNameForWorkspaceAdmin(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		r.Body.Name,
	)
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(handler.toAppInstallationResponse(installation)), nil
}

// AppVisibilityUpdate handles PATCH
// /api/v1/workspaces/:workspaceId/app-installations/:installationId/catalog-visibility.
// The store keeps audience changes independent from App management authority
// and atomically removes a private App from every channel.
func (handler *WorkspaceAppHandler) AppVisibilityUpdate(r api.WorkspaceScopedJSON[dto.WorkspaceAppVisibilityUpdateRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	result, err := handler.store.UpdateAppVisibilityForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		r.Body.CatalogVisibility,
		time.Now().UTC(),
	)
	if err != nil {
		return api.Response{}, err
	}
	if handler.realtime != nil {
		handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
		if result.CatalogVisibility == "private" {
			// This is deliberately a payload-free workspace refresh. A App manager
			// need not be allowed to discover every private channel from which the
			// App was removed, so channel IDs must not escape through realtime either.
			// Emit even when the store found no active parent membership: a private ->
			// private retry can still repair orphaned child runtime or memberships.
			handler.realtime.EmitChannelUpdated(r.WorkspaceID())
		}
	}
	return api.OK(converter.ToAppVisibilityUpdateResponse(result)), nil
}

// AppPermissionsList handles GET
// /api/v1/workspaces/:workspaceId/app-installations/:installationId/permissions.
// An active App manager or workspace administrator may read the effective App
// permission scopes.
func (handler *WorkspaceAppHandler) AppPermissionsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	view, err := handler.store.ListAppPermissionsForUser(r.Context(), r.UserID(), r.WorkspaceID(), installationID)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToAppPermissionsResponse(view)), nil
}

// AppPermissionsUpdate handles PATCH
// /api/v1/workspaces/:workspaceId/app-installations/:installationId/permissions.
// The store enforces current active App-manager or workspace-administrator
// authority and narrows the effective scopes to a validated subset of the pinned
// manifest capability scopes.
func (handler *WorkspaceAppHandler) AppPermissionsUpdate(r api.WorkspaceScopedJSON[dto.WorkspaceAppPermissionsUpdateRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	result, err := handler.store.UpdateAppPermissionsForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		r.Body.ExpectedPolicyDigest,
		r.Body.Scopes,
		time.Now().UTC(),
	)
	if err != nil {
		return api.Response{}, err
	}
	handler.emitWorkspaceAppsUpdated(r.WorkspaceID())
	return api.OK(converter.ToAppPermissionsUpdateResponse(result)), nil
}

// AgentAppAuthorizationsList handles GET /api/v1/workspaces/:workspaceId/agents/:agentId/app-authorizations.
// It returns installed workspace apps plus the selected agent's current app MCP
// authorization state.
func (handler *WorkspaceAppHandler) AgentAppAuthorizationsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	agentID := strings.TrimSpace(r.Gin.Param("agentId"))
	if agentID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "agentId is required")
	}
	authorizations, err := handler.store.ListAgentAppAuthorizationsForUser(r.Context(), r.UserID(), r.WorkspaceID(), agentID)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToAgentAppAuthorizationResponses(authorizations)), nil
}

// AgentAppAuthorizationEnable handles POST /api/v1/workspaces/:workspaceId/agents/:agentId/app-authorizations.
func (handler *WorkspaceAppHandler) AgentAppAuthorizationEnable(r api.WorkspaceScopedJSON[dto.AgentAppAuthorizationRequest]) (api.Response, error) {
	agentID := strings.TrimSpace(r.Gin.Param("agentId"))
	if agentID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "agentId is required")
	}
	appInstallationID := strings.TrimSpace(r.Body.AppInstallationID)
	if appInstallationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "appInstallationId is required")
	}
	authorization, err := handler.store.EnableAgentAppAuthorizationForUser(r.Context(), r.UserID(), r.WorkspaceID(), agentID, appInstallationID)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToAgentAppAuthorizationResponse(authorization)), nil
}

// AgentAppAuthorizationDisable handles DELETE /api/v1/workspaces/:workspaceId/agents/:agentId/app-authorizations/:appInstallationId.
func (handler *WorkspaceAppHandler) AgentAppAuthorizationDisable(r api.WorkspaceScopedRequest) (api.Response, error) {
	agentID := strings.TrimSpace(r.Gin.Param("agentId"))
	appInstallationID := strings.TrimSpace(r.Gin.Param("appInstallationId"))
	if agentID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "agentId is required")
	}
	if appInstallationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "appInstallationId is required")
	}
	authorization, err := handler.store.DisableAgentAppAuthorizationForUser(r.Context(), r.UserID(), r.WorkspaceID(), agentID, appInstallationID)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToAgentAppAuthorizationResponse(authorization)), nil
}

// ServiceEventSubscriptionsList handles GET /api/v1/workspaces/:workspaceId/app-installations/:installationId/service-event-subscriptions.
func (handler *WorkspaceAppHandler) ServiceEventSubscriptionsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	limit := 0
	if rawLimit := strings.TrimSpace(r.Gin.Query("limit")); rawLimit != "" {
		parsedLimit, err := strconv.Atoi(rawLimit)
		if err != nil || parsedLimit <= 0 {
			return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "limit must be a positive integer")
		}
		limit = parsedLimit
	}
	subscriptions, err := handler.store.ListServiceEventSubscriptionsForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		strings.TrimSpace(r.Gin.Query("target")),
		strings.TrimSpace(r.Gin.Query("resource")),
		strings.TrimSpace(r.Gin.Query("status")),
		limit,
	)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToServiceEventSubscriptionResponses(subscriptions)), nil
}

// ServiceEventSubscriptionCreate handles POST /api/v1/workspaces/:workspaceId/app-installations/:installationId/service-event-subscriptions.
func (handler *WorkspaceAppHandler) ServiceEventSubscriptionCreate(r api.WorkspaceScopedJSON[dto.ServiceEventSubscriptionCreateRequest]) (api.Response, error) {
	result, err := handler.createServiceEventSubscription(r.Context(), r.UserID(), r.WorkspaceID(), strings.TrimSpace(r.Gin.Param("installationId")), r.Body)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToServiceEventSubscriptionActionResultResponse(result)), nil
}

func (handler *WorkspaceAppHandler) createServiceEventSubscription(ctx context.Context, userID string, workspaceID string, installationID string, body dto.ServiceEventSubscriptionCreateRequest) (storeapp.ServiceEventSubscriptionActionResult, error) {
	installationID = strings.TrimSpace(installationID)
	if installationID == "" {
		return storeapp.ServiceEventSubscriptionActionResult{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	return handler.store.CreateServiceEventSubscriptionForUser(
		ctx,
		userID,
		workspaceID,
		installationID,
		body.Target,
		body.PRRefOrURL,
		body.Events,
		body.NotifyPrincipal,
	)
}

// ProviderWatchesList handles the GET /api/v1/workspaces/:workspaceId/app-installations/:installationId/provider-watches route.
func (handler *WorkspaceAppHandler) ProviderWatchesList(r api.WorkspaceScopedRequest) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	limit := 0
	if rawLimit := strings.TrimSpace(r.Gin.Query("limit")); rawLimit != "" {
		parsedLimit, err := strconv.Atoi(rawLimit)
		if err != nil || parsedLimit <= 0 {
			return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "limit must be a positive integer")
		}
		limit = parsedLimit
	}
	watches, err := handler.store.ListProviderWatchesForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		strings.TrimSpace(r.Gin.Query("target")),
		strings.TrimSpace(r.Gin.Query("resource")),
		strings.TrimSpace(r.Gin.Query("status")),
		limit,
	)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToServiceEventSubscriptionResponses(watches)), nil
}

// ProviderWatchCreate handles the POST /api/v1/workspaces/:workspaceId/app-installations/:installationId/provider-watches route.
func (handler *WorkspaceAppHandler) ProviderWatchCreate(r api.WorkspaceScopedJSON[dto.ProviderWatchCreateRequest]) (api.Response, error) {
	installationID := strings.TrimSpace(r.Gin.Param("installationId"))
	if installationID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "installationId is required")
	}
	result, err := handler.store.CreateProviderWatchForUser(
		r.Context(),
		r.UserID(),
		r.WorkspaceID(),
		installationID,
		r.Body.Target,
		r.Body.PRRefOrURL,
		r.Body.Events,
		r.Body.NotifyPrincipal,
	)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToProviderWatchActionResultResponse(result)), nil
}

// ServiceEventSubscriptionDelete handles DELETE /api/v1/workspaces/:workspaceId/service-event-subscriptions/:subscriptionId.
func (handler *WorkspaceAppHandler) ServiceEventSubscriptionDelete(r api.WorkspaceScopedRequest) (api.Response, error) {
	result, err := handler.serviceEventSubscriptionOperation(r, strings.TrimSpace(r.Gin.Param("subscriptionId")), "subscriptionId", "delete")
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToServiceEventSubscriptionActionResultResponse(result)), nil
}

// ServiceEventSubscriptionAction handles POST /api/v1/workspaces/:workspaceId/service-event-subscriptions/:subscriptionId:pause|resume.
func (handler *WorkspaceAppHandler) ServiceEventSubscriptionAction(r api.WorkspaceScopedRequest) (api.Response, error) {
	subscriptionID, action, err := api.ParseActionSegment(r.Gin.Param("subscriptionAction"), "subscriptionId")
	if err != nil {
		return api.Response{}, err
	}
	switch action {
	case "pause", "resume":
		result, err := handler.serviceEventSubscriptionOperation(r, subscriptionID, "subscriptionId", action)
		if err != nil {
			return api.Response{}, err
		}
		return api.OK(converter.ToServiceEventSubscriptionActionResultResponse(result)), nil
	default:
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Unsupported service event subscription action")
	}
}

func (handler *WorkspaceAppHandler) serviceEventSubscriptionOperation(r api.WorkspaceScopedRequest, subscriptionID string, idField string, operation string) (storeapp.ServiceEventSubscriptionActionResult, error) {
	if subscriptionID == "" {
		return storeapp.ServiceEventSubscriptionActionResult{}, api.NewHTTPError(http.StatusBadRequest, idField+" is required")
	}
	var (
		result storeapp.ServiceEventSubscriptionActionResult
		err    error
	)
	switch operation {
	case "pause":
		result, err = handler.store.PauseServiceEventSubscriptionForUser(r.Context(), r.UserID(), r.WorkspaceID(), subscriptionID)
	case "resume":
		result, err = handler.store.ResumeServiceEventSubscriptionForUser(r.Context(), r.UserID(), r.WorkspaceID(), subscriptionID)
	case "delete":
		result, err = handler.store.RemoveServiceEventSubscriptionForUser(r.Context(), r.UserID(), r.WorkspaceID(), subscriptionID)
	default:
		return storeapp.ServiceEventSubscriptionActionResult{}, api.NewHTTPError(http.StatusBadRequest, "Unsupported service event subscription operation")
	}
	if err != nil {
		return storeapp.ServiceEventSubscriptionActionResult{}, err
	}
	return result, nil
}

// ProviderWatchDelete handles the DELETE /api/v1/workspaces/:workspaceId/provider-watches/:watchId route.
func (handler *WorkspaceAppHandler) ProviderWatchDelete(r api.WorkspaceScopedRequest) (api.Response, error) {
	result, err := handler.providerWatchOperation(r, strings.TrimSpace(r.Gin.Param("watchId")), "watchId", "delete")
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToProviderWatchActionResultResponse(result)), nil
}

// ProviderWatchAction handles POST /api/v1/workspaces/:workspaceId/provider-watches/:watchId:pause|resume.
func (handler *WorkspaceAppHandler) ProviderWatchAction(r api.WorkspaceScopedRequest) (api.Response, error) {
	watchID, action, err := api.ParseActionSegment(r.Gin.Param("watchAction"), "watchId")
	if err != nil {
		return api.Response{}, err
	}
	switch action {
	case "pause", "resume":
		result, err := handler.providerWatchOperation(r, watchID, "watchId", action)
		if err != nil {
			return api.Response{}, err
		}
		return api.OK(converter.ToProviderWatchActionResultResponse(result)), nil
	default:
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Unsupported provider watch action")
	}
}

func (handler *WorkspaceAppHandler) providerWatchOperation(r api.WorkspaceScopedRequest, watchID string, idField string, operation string) (storeapp.ProviderWatchActionResult, error) {
	if watchID == "" {
		return storeapp.ProviderWatchActionResult{}, api.NewHTTPError(http.StatusBadRequest, idField+" is required")
	}
	var (
		result storeapp.ProviderWatchActionResult
		err    error
	)
	switch operation {
	case "pause":
		result, err = handler.store.PauseProviderWatchForUser(r.Context(), r.UserID(), r.WorkspaceID(), watchID)
	case "resume":
		result, err = handler.store.ResumeProviderWatchForUser(r.Context(), r.UserID(), r.WorkspaceID(), watchID)
	case "delete":
		result, err = handler.store.RemoveProviderWatchForUser(r.Context(), r.UserID(), r.WorkspaceID(), watchID)
	default:
		return storeapp.ProviderWatchActionResult{}, api.NewHTTPError(http.StatusBadRequest, "Unsupported provider watch operation")
	}
	if err != nil {
		return storeapp.ProviderWatchActionResult{}, err
	}
	return result, nil
}

// Handle handles the built-in GitHub or Gitea provider webhook route.
// Processes incoming provider webhook deliveries through service-event delivery.

func isProviderAppWebhookSlug(appSlug string) bool {
	switch strings.ToLower(strings.TrimSpace(appSlug)) {
	case "github", "gitea":
		return true
	default:
		return false
	}
}

func (handler *WebhookHandler) handleAppWebhookDirect(ctx *gin.Context, appSlug string, payload []byte) {
	result, err := handler.store.HandleAppWebhook(ctx.Request.Context(), appSlug, ctx.Request.Header, payload)
	switch {
	case errors.Is(err, ErrNotFound):
		api.Error(ctx, http.StatusNotFound, "Not found")
		return
	case errors.Is(err, ErrUnauthorized):
		api.Error(ctx, http.StatusUnauthorized, "Unauthorized")
		return
	case err != nil:
		api.StoreError(ctx, err)
		return
	}

	if strings.TrimSpace(result.WorkspaceID) != "" {
		if !api.EnsureWorkspaceOwner(ctx, handler.managedWorkspaces, result.WorkspaceID) {
			return
		}
	}
	if len(result.Deliveries) > 0 && strings.TrimSpace(result.WorkspaceID) != "" {
		deliverFollowupMessages(ctx.Request.Context(), result.WorkspaceID, result.Deliveries, handler.followupDeliverer)
	}

	ctx.Status(http.StatusOK)
}

func (handler *WebhookHandler) Handle(ctx *gin.Context) {
	appSlug := strings.ToLower(strings.TrimSpace(handler.providerSlug))
	if !isProviderAppWebhookSlug(appSlug) {
		api.Error(ctx, http.StatusNotFound, "Not found")
		return
	}

	ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxWebhookPayloadBytes)
	payload, err := io.ReadAll(ctx.Request.Body)
	if err != nil {
		status := http.StatusBadRequest
		var maxBytesError *http.MaxBytesError
		if errors.As(err, &maxBytesError) {
			status = http.StatusRequestEntityTooLarge
		}
		api.Error(ctx, status, "Invalid request body")
		return
	}

	handler.handleAppWebhookDirect(ctx, appSlug, payload)
}
