package provider

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"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"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/provider"
)

const (
	githubSetupCompletionTimeout    = 45 * time.Second
	githubSetupLocalFinalizeTimeout = 30 * time.Second
)

func githubNoStoreRedirect(ctx *gin.Context, status int, location string) {
	ctx.Header("Cache-Control", "no-store")
	ctx.Redirect(status, location)
}

// Register handles GET /api/v1/workspaces/:workspaceId/providers/github/registration.
// Initiates GitHub App registration by returning the manifest and form action URL.
func (handler *GitHubHandler) Register(r api.WorkspaceScopedRequest) (api.Response, error) {
	authenticated := r.User
	workspaceID := r.WorkspaceID()

	setupMode, err := handler.githubAppSetupModeForUser(r.Context(), authenticated.User.ID, workspaceID)
	if err != nil {
		return api.Response{}, err
	}

	state, err := handler.newGitHubFlowState("register", authenticated.User.ID, workspaceID, setupMode)
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Failed to prepare GitHub registration")
	}
	if err := handler.issueGitHubFlowCookie(r.Gin, githubRegisterCookieName, state); err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Failed to prepare GitHub registration")
	}

	manifest, err := handler.buildGitHubManifestJSON()
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Failed to build GitHub App manifest")
	}

	formAction := strings.TrimRight(handler.githubConfig.BaseURL, "/") + "/settings/apps/new?state=" + url.QueryEscape(state.Nonce)

	return githubNoStoreOK(gin.H{
		"formAction": formAction,
		"manifest":   manifest,
	}), nil
}

// RegisterCallback handles GET /api/github/callback.
// Processes the GitHub App registration callback, exchanging the code for app credentials and saving the config.
func (handler *GitHubHandler) RegisterCallback(ctx *gin.Context) {
	code := strings.TrimSpace(ctx.Query("code"))
	stateValue := strings.TrimSpace(ctx.Query("state"))
	if code == "" || stateValue == "" {
		api.Error(ctx, http.StatusBadRequest, "Invalid GitHub registration callback")
		return
	}

	state, err := handler.consumeGitHubFlowCookie(ctx, githubRegisterCookieName, "register", stateValue)
	if err != nil {
		api.Error(ctx, http.StatusBadRequest, "Invalid GitHub registration state")
		return
	}
	// The manifest conversion returns provider secrets. Revalidate the current
	// workspace role and provider state before contacting GitHub. Administrators
	// take the direct atomic path; ordinary members may create only a private
	// setup proposal, never an active AgentTeam installation or provider config.
	setupMode, err := handler.githubAppSetupModeForUser(ctx.Request.Context(), state.UserID, state.WorkspaceID)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}
	if setupMode != strings.TrimSpace(state.Mode) {
		api.Error(ctx, http.StatusConflict, "GitHub setup permissions changed. Restart setup.")
		return
	}

	converted, err := handler.exchangeGitHubManifestCode(ctx.Request.Context(), code)
	if err != nil {
		api.Error(ctx, http.StatusBadGateway, "Failed to exchange GitHub manifest code")
		return
	}
	result, err := handler.persistVerifiedGitHubSetup(ctx.Request.Context(), setupMode, state.UserID, state.WorkspaceID, provider.UpsertGitHubAppConfigParams{
		AppID:         converted.ID,
		AppSlug:       converted.Slug,
		AppName:       converted.Name,
		HTMLURL:       converted.HTMLURL,
		ClientID:      converted.ClientID,
		ClientSecret:  converted.ClientSecret,
		PrivateKeyPEM: converted.PEM,
		WebhookSecret: converted.WebhookSecret,
		OwnerLogin:    converted.Owner.Login,
		OwnerID:       converted.Owner.ID,
	})
	if err != nil {
		api.StoreError(ctx, err)
		return
	}

	redirectStatus := "registered"
	if result.mode == app.GitHubAppSetupModeRequester {
		redirectStatus = "ready"
	}
	if strings.TrimSpace(state.ReturnTarget) == githubRegistrationReturnTargetDesktop {
		page, renderErr := renderGitHubRegistrationDesktopCompletionPage(
			handler.githubRegistrationDesktopDeepLink(state.WorkspaceID, redirectStatus),
		)
		if renderErr != nil {
			api.Error(ctx, http.StatusInternalServerError, "Failed to prepare GitHub setup completion")
			return
		}
		ctx.Header("Cache-Control", "no-store")
		ctx.Data(http.StatusOK, "text/html; charset=utf-8", page)
		return
	}
	githubNoStoreRedirect(ctx, http.StatusFound, handler.githubSettingsRedirectURL(redirectStatus))
}

// Install handles GET /api/v1/workspaces/:workspaceId/providers/github/installation.
// Initiates GitHub App installation by returning the installation URL with an OAuth state cookie.
func (handler *GitHubHandler) Install(r api.WorkspaceScopedRequest) (api.Response, error) {
	authenticated := r.User
	workspaceID := r.WorkspaceID()

	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github connection store is not configured")
	}
	setupMode, err := handler.adminStore.GitHubConnectionSetupModeForUser(r.Context(), authenticated.User.ID, workspaceID)
	if err != nil {
		return api.Response{}, err
	}

	config, found, err := handler.store.LoadGitHubAppConfigForWorkspace(r.Context(), workspaceID)
	if err != nil {
		return api.Response{}, err
	}
	if !found {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub App is not registered")
	}

	state, err := handler.newGitHubFlowState("install", authenticated.User.ID, workspaceID, setupMode)
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Failed to prepare GitHub installation")
	}
	if err := handler.issueGitHubFlowCookie(r.Gin, githubInstallCookieName, state); err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Failed to prepare GitHub installation")
	}

	return githubNoStoreOK(gin.H{
		"installUrl": strings.TrimRight(handler.githubConfig.BaseURL, "/") + "/apps/" + url.PathEscape(config.AppSlug) + "/installations/new?state=" + url.QueryEscape(state.Nonce),
	}), nil
}

// SetupProposalInstall handles
// GET /api/v1/workspaces/:workspaceId/providers/github/setup-proposal/:proposalId/installation.
// It starts a GitHub installation flow using only the App slug decrypted from
// the caller's owner-bound, unexpired proposal. Signed state and the HttpOnly
// cookie bind the callback to user/workspace/proposal/TTL; the callback only
// verifies provider state and returns to the proposal UI, never activates AgentTeam
// state or creates a second approval request.
func (handler *GitHubHandler) SetupProposalInstall(r api.WorkspaceScopedRequest) (api.Response, error) {
	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github setup store is not configured")
	}
	proposalID := strings.TrimSpace(r.Gin.Param("proposalId"))
	if proposalID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub setup proposal id is required")
	}
	_, config, err := handler.adminStore.GetGitHubSetupDraftForRequester(
		r.Context(), r.UserID(), r.WorkspaceID(), proposalID,
	)
	if err != nil {
		return api.Response{}, err
	}
	state, err := handler.newGitHubProposalInstallFlowState(
		r.UserID(), r.WorkspaceID(), proposalID,
	)
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Failed to prepare GitHub installation")
	}
	if err := handler.issueGitHubFlowCookie(r.Gin, githubInstallCookieName, state); err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusInternalServerError, "Failed to prepare GitHub installation")
	}
	return githubNoStoreOK(gin.H{
		"installUrl": strings.TrimRight(handler.githubConfig.BaseURL, "/") + "/apps/" +
			url.PathEscape(config.AppSlug) + "/installations/new?state=" + url.QueryEscape(state.Nonce),
	}), nil
}

// Setup handles GET /api/github/setup.
// Processes the GitHub App setup callback, applying the installation and redirecting to settings.
func (handler *GitHubHandler) Setup(ctx *gin.Context) {
	installationID := strings.TrimSpace(ctx.Query("installation_id"))
	stateValue := strings.TrimSpace(ctx.Query("state"))
	setupAction := strings.TrimSpace(strings.ToLower(ctx.Query("setup_action")))
	consumeInstallFlowCookie := func() (githubFlowState, error) {
		return consumeGitHubInstallFlowCookie(func(expectedFlow string) (githubFlowState, error) {
			return handler.consumeGitHubFlowCookie(
				ctx, githubInstallCookieName, expectedFlow, stateValue,
			)
		})
	}
	if installationID == "" {
		// GitHub redirects here after a non-owner requests installation access.
		if stateValue != "" && (setupAction == "" || setupAction == "request") {
			_, _ = consumeInstallFlowCookie()
			githubNoStoreRedirect(ctx, http.StatusFound, handler.githubSettingsRedirectURL("requested"))
			return
		}
		api.Error(ctx, http.StatusBadRequest, "Invalid GitHub setup callback")
		return
	}
	if stateValue == "" {
		api.Error(ctx, http.StatusBadRequest, "Invalid GitHub setup callback")
		return
	}
	if setupAction == "" {
		setupAction = "install"
	}
	if setupAction != "install" && setupAction != "update" {
		api.Error(ctx, http.StatusBadRequest, "Unsupported GitHub setup action")
		return
	}

	state, err := consumeInstallFlowCookie()
	if err != nil {
		// Admin approvals happen in a different browser/session, so there is no
		// requester cookie to bind this callback back to a AgentTeam workspace yet.
		if errors.Is(err, http.ErrNoCookie) {
			githubNoStoreRedirect(ctx, http.StatusFound, handler.githubSettingsRedirectURL("approved"))
			return
		}
		api.Error(ctx, http.StatusBadRequest, "Invalid GitHub installation state")
		return
	}
	if handler.adminStore == nil {
		api.Error(ctx, http.StatusInternalServerError, "GitHub connection store is not configured")
		return
	}
	if strings.TrimSpace(state.Flow) == "proposal_install" {
		_, config, err := handler.adminStore.GetGitHubSetupDraftForRequester(
			ctx.Request.Context(), state.UserID, state.WorkspaceID, state.ProposalID,
		)
		if err != nil {
			api.StoreError(ctx, err)
			return
		}
		installation, err := handler.loadGitHubInstallation(
			ctx.Request.Context(), config, installationID,
		)
		if err != nil {
			api.Error(ctx, http.StatusBadGateway, "Failed to verify GitHub installation")
			return
		}
		if !githubInstallationMeetsManifestRequirements(installation) {
			api.Error(ctx, http.StatusConflict, "The selected GitHub installation requires a permissions update")
			return
		}
		// The proposal remains setup_ready. The browser refreshes its
		// owner-bound installation inventory, selects the complete desired
		// installation set, and submits one App install request for administrator
		// approval.
		githubNoStoreRedirect(ctx, http.StatusFound, handler.githubSettingsRedirectURL("ready"))
		return
	}
	setupMode, err := handler.adminStore.GitHubConnectionSetupModeForUser(ctx.Request.Context(), state.UserID, state.WorkspaceID)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}
	if setupMode != strings.TrimSpace(state.Mode) {
		api.Error(ctx, http.StatusConflict, "GitHub installation permissions changed. Restart installation.")
		return
	}

	if setupMode != app.GitHubAppSetupModeAdmin && setupMode != app.GitHubAppSetupModeRequester {
		api.Error(ctx, http.StatusConflict, "GitHub installation permissions changed. Restart installation.")
		return
	}
	config, canonicalIDs, directBase, err := handler.store.PrepareGitHubConnectionsForUser(
		ctx.Request.Context(), state.UserID, state.WorkspaceID, []string{installationID},
	)
	if err != nil {
		api.StoreError(ctx, err)
		return
	}

	installation, err := handler.loadGitHubInstallation(ctx.Request.Context(), config, installationID)
	if err != nil {
		api.Error(ctx, http.StatusBadGateway, "Failed to verify GitHub installation")
		return
	}
	if !githubInstallationMeetsManifestRequirements(installation) {
		api.Error(ctx, http.StatusConflict, "The selected GitHub installation requires a permissions update")
		return
	}

	if _, err := handler.store.ApplyVerifiedGitHubConnectionsForUser(
		ctx.Request.Context(), state.UserID, state.WorkspaceID,
		[]provider.GitHubVerifiedConnectionTarget{{
			InstallationID: canonicalIDs[0],
			DisplayLabel:   strings.TrimSpace(installation.Account.Login),
			AccountID:      installation.Account.ID,
		}},
		directBase,
	); err != nil {
		api.StoreError(ctx, err)
		return
	}

	githubNoStoreRedirect(ctx, http.StatusFound, handler.githubSettingsRedirectURL("installed"))
}

// ConnectionCreate handles POST /api/v1/workspaces/:workspaceId/providers/github/connections.
// Creates a GitHub connection by verifying the installation and applying it for the authenticated user.
func (handler *GitHubHandler) ConnectionCreate(r api.WorkspaceScopedJSON[dto.GitHubConnectionCreateRequest]) (api.Response, error) {
	request := r.Body
	authenticated := r.User
	workspaceID := r.WorkspaceID()

	installationIDs := append([]string(nil), request.InstallationIDs...)
	if strings.TrimSpace(request.InstallationID) != "" {
		installationIDs = append(installationIDs, request.InstallationID)
	}
	// Connecting to an already-active App is intentionally a single-target
	// operation. Keep this admission check at the HTTP boundary in addition to
	// the store check so no provider/store work can begin for a batch request.
	if len(installationIDs) != 1 {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "Exactly one GitHub installation id is required")
	}
	config, canonicalIDs, directBase, err := handler.store.PrepareGitHubConnectionsForUser(
		r.Context(), authenticated.User.ID, workspaceID, installationIDs,
	)
	if err != nil {
		return api.Response{}, err
	}
	verifiedTargets := make([]provider.GitHubVerifiedConnectionTarget, 0, len(canonicalIDs))
	for _, installationID := range canonicalIDs {
		installation, err := handler.loadGitHubInstallation(r.Context(), config, installationID)
		if err != nil {
			return api.Response{}, api.NewHTTPError(http.StatusBadGateway, "Failed to verify GitHub installation")
		}
		if !githubInstallationMeetsManifestRequirements(installation) {
			return api.Response{}, api.NewHTTPError(http.StatusConflict, "One or more selected GitHub installations require a permissions update")
		}
		verifiedTargets = append(verifiedTargets, provider.GitHubVerifiedConnectionTarget{
			InstallationID: installationID,
			DisplayLabel:   strings.TrimSpace(installation.Account.Login),
			AccountID:      installation.Account.ID,
		})
	}
	result, err := handler.store.ApplyVerifiedGitHubConnectionsForUser(
		r.Context(), authenticated.User.ID, workspaceID, verifiedTargets, directBase,
	)
	if err != nil {
		return api.Response{}, err
	}

	return api.OK(converter.ToProviderAppInstallationResponse(result)), nil
}

// ConfigUpdate handles PUT /api/v1/workspaces/:workspaceId/providers/github/config.
// Updates the GitHub App configuration by recovering credentials from the provided settings URL and private key.
func (handler *GitHubHandler) ConfigUpdate(r api.WorkspaceScopedJSON[dto.GitHubConfigUpdateRequest]) (api.Response, error) {
	request := r.Body
	authenticated := r.User
	workspaceID := r.WorkspaceID()

	if !request.Valid() {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub App settings URL and private key are required")
	}
	result, err := handler.recoverGitHubAppConfig(
		r.Context(),
		authenticated.User.ID,
		workspaceID,
		request.SettingsURL,
		request.PrivateKeyPEM,
		app.GitHubAppSetupModeAdmin,
	)
	if err != nil {
		if status, ok := statusForGitHubFlowError(err); ok {
			return api.Response{}, api.NewHTTPError(status, err.Error())
		}
		return api.Response{}, err
	}

	return api.OK(converter.ToGitHubAppConfigResponse(result.config)), nil
}

// SetupDraftUpdate handles PUT /api/v1/workspaces/:workspaceId/providers/github/setup-draft.
// It verifies reusable GitHub App credentials for a fresh administrator setup,
// persists only an encrypted owner-bound draft, and returns advisory existing
// installations. It does not PATCH the provider webhook or activate any AgentTeam
// App/config/connection state.
func (handler *GitHubHandler) SetupDraftUpdate(r api.WorkspaceScopedJSON[dto.GitHubConfigUpdateRequest]) (api.Response, error) {
	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github setup store is not configured")
	}
	if !r.Body.Valid() {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub App settings URL and private key are required")
	}
	if err := handler.store.AuthorizeGitHubAppRegistrationForUser(r.Context(), r.UserID(), r.WorkspaceID()); err != nil {
		return api.Response{}, err
	}
	mode, err := handler.githubAppSetupModeForUser(r.Context(), r.UserID(), r.WorkspaceID())
	if err != nil {
		return api.Response{}, err
	}
	if mode != app.GitHubAppSetupModeAdmin {
		return api.Response{}, api.NewHTTPError(http.StatusForbidden, "Workspace administrators must prepare direct GitHub setup")
	}

	config, err := handler.verifyGitHubAppRecovery(r.Context(), r.Body.SettingsURL, r.Body.PrivateKeyPEM)
	if err != nil {
		if status, ok := statusForGitHubFlowError(err); ok {
			return api.Response{}, api.NewHTTPError(status, err.Error())
		}
		return api.Response{}, err
	}
	proposal, err := handler.adminStore.SaveVerifiedGitHubSetupDraftForWorkspaceAdmin(
		r.Context(), r.UserID(), r.WorkspaceID(), githubSetupParamsFromConfig(config),
	)
	if err != nil {
		return api.Response{}, err
	}
	// Re-load through the strict owner/mode/TTL boundary before discovery so
	// the provider read uses the exact encrypted snapshot that later completion
	// will consume. A transient list failure does not discard that safe draft;
	// the browser can retry through the draft-scoped GET without re-uploading a
	// private key.
	_, storedConfig, err := handler.adminStore.GetGitHubSetupDraftForWorkspaceAdmin(
		r.Context(), r.UserID(), r.WorkspaceID(), proposal.ID,
	)
	if err != nil {
		return api.Response{}, err
	}
	installations, discoveryErr := handler.listGitHubInstallations(r.Context(), storedConfig)
	return githubNoStoreOK(dto.GitHubSetupDraftResponse{
		Proposal:        converter.ToGitHubSetupProposalResponse(proposal),
		Installations:   githubInstallationResponses(installations),
		DiscoveryFailed: discoveryErr != nil,
	}), nil
}

// SetupDraftInstallationsList handles
// GET /api/v1/workspaces/:workspaceId/providers/github/setup-draft/:draftId/installations.
// It retries advisory discovery through an owner-bound, unexpired admin draft;
// peer admins and ordinary members are rejected before credential decryption or
// provider I/O.
func (handler *GitHubHandler) SetupDraftInstallationsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github setup store is not configured")
	}
	draftID := strings.TrimSpace(r.Gin.Param("draftId"))
	if draftID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub setup draft id is required")
	}
	_, config, err := handler.adminStore.GetGitHubSetupDraftForWorkspaceAdmin(
		r.Context(), r.UserID(), r.WorkspaceID(), draftID,
	)
	if err != nil {
		return api.Response{}, err
	}
	installations, err := handler.listGitHubInstallations(r.Context(), config)
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusBadGateway, "Failed to load GitHub App installations")
	}
	return githubNoStoreOK(githubInstallationResponses(installations)), nil
}

// SetupDraftComplete handles
// POST /api/v1/workspaces/:workspaceId/providers/github/setup-draft/complete.
// After an explicit administrator selection it binds a short mutation lease,
// exactly re-verifies every installation, patches the GitHub App webhook with
// the draft's one-time secret, and atomically installs/configures/connects AgentTeam.
func (handler *GitHubHandler) SetupDraftComplete(r api.WorkspaceScopedJSON[dto.GitHubSetupCompletionRequest]) (api.Response, error) {
	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github setup store is not configured")
	}
	if !r.Body.Valid() {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub setup draft id and installation id are required")
	}
	installationIDs := append([]string(nil), r.Body.InstallationIDs...)
	if strings.TrimSpace(r.Body.InstallationID) != "" {
		installationIDs = append(installationIDs, r.Body.InstallationID)
	}
	config, lease, err := handler.adminStore.BeginGitHubSetupCompletionForWorkspaceAdmin(
		r.Context(), r.UserID(), r.WorkspaceID(), r.Body.SetupProposalID, installationIDs,
	)
	if err != nil {
		return api.Response{}, err
	}

	// Once the short lease exists, finish provider verification and the local
	// saga under a detached, bounded context. Browser cancellation must not
	// discard the only encrypted copy of a webhook secret after GitHub may have
	// accepted the PATCH.
	operationCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), githubSetupCompletionTimeout)
	defer cancel()
	leaseInstallationIDs := append([]string(nil), lease.InstallationIDs...)
	if len(leaseInstallationIDs) == 0 && strings.TrimSpace(lease.InstallationID) != "" {
		leaseInstallationIDs = []string{lease.InstallationID}
	}
	verifiedTargets := make([]app.GitHubSetupCompletionTarget, 0, len(leaseInstallationIDs))
	for _, installationID := range leaseInstallationIDs {
		installation, err := handler.loadGitHubInstallation(operationCtx, config, installationID)
		if err != nil {
			// Keep the same-secret/same-selection lease for retry. Deleting a shared
			// lease here would race a concurrent attempt that may already be about to
			// PATCH GitHub, leaving provider state changed with no local finalize path.
			return api.Response{}, api.NewHTTPError(http.StatusBadGateway, "Failed to verify GitHub installation")
		}
		if !strings.EqualFold(strings.TrimSpace(installation.TargetType), "Organization") {
			return api.Response{}, api.NewHTTPError(http.StatusConflict, "A selected GitHub installation is not an organization installation")
		}
		if !githubInstallationMeetsManifestRequirements(installation) {
			// This is deliberately before Mark/PATCH. A caller can bypass discovery
			// or race a GitHub permission change, so exact completion must recheck
			// the complete eligibility set at the provider mutation boundary.
			return api.Response{}, api.NewHTTPError(http.StatusConflict, "One or more selected GitHub installations require a permissions update")
		}
		verifiedTargets = append(verifiedTargets, app.GitHubSetupCompletionTarget{
			InstallationID: installationID,
			AccountLogin:   strings.TrimSpace(installation.Account.Login),
			AccountID:      installation.Account.ID,
		})
	}

	// From this call onward provider success can be ambiguous. Never abort the
	// lease on a PATCH/finalize error: a retry must reuse the same stored secret
	// and selection instead of rotating or switching them.
	if err := handler.adminStore.MarkGitHubSetupCompletionProviderMutationStartedForWorkspaceAdmin(
		operationCtx, r.UserID(), r.WorkspaceID(), lease, verifiedTargets,
	); err != nil {
		return api.Response{}, err
	}
	if err := handler.updateGitHubAppWebhookConfig(operationCtx, config); err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusBadGateway, "Failed to update the GitHub App webhook")
	}
	// Provider success is the saga's point of no return. Give the local atomic
	// commit an independent detached budget rather than whatever few
	// milliseconds may remain from the provider phase.
	finalizeCtx, finalizeCancel := context.WithTimeout(
		context.WithoutCancel(r.Context()), githubSetupLocalFinalizeTimeout,
	)
	defer finalizeCancel()
	result, err := handler.adminStore.FinalizeGitHubSetupCompletionForWorkspaceAdmin(
		finalizeCtx, r.UserID(), r.WorkspaceID(), lease,
	)
	if err != nil {
		return api.Response{}, err
	}
	return githubNoStoreOK(converter.ToProviderAppInstallationResponse(result)), nil
}

// SetupProposalGet handles GET /api/v1/workspaces/:workspaceId/providers/github/setup-proposal.
// It returns only the authenticated ordinary member's current credential-free
// proposal metadata. An empty proposal is a normal restartable setup state.
func (handler *GitHubHandler) SetupProposalGet(r api.WorkspaceScopedRequest) (api.Response, error) {
	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github setup store is not configured")
	}
	proposal, found, err := handler.adminStore.GetGitHubSetupProposalForUser(
		r.Context(), r.UserID(), r.WorkspaceID(),
	)
	if err != nil {
		return api.Response{}, err
	}
	response := dto.GitHubSetupProposalEnvelope{}
	if found {
		projected := converter.ToGitHubSetupProposalResponse(proposal)
		response.Proposal = &projected
	}
	return githubNoStoreOK(response), nil
}

// SetupProposalUpdate handles PUT /api/v1/workspaces/:workspaceId/providers/github/setup-proposal.
// It verifies an existing GitHub App, persists a private encrypted requester
// draft, and returns advisory eligible organization installations. It never
// PATCHes the GitHub App webhook or activates AgentTeam state; the requester must
// bind the complete desired installation set (one to fifty installations)
// into the later install request and an administrator must approve the durable
// provider-mutation saga.
func (handler *GitHubHandler) SetupProposalUpdate(r api.WorkspaceScopedJSON[dto.GitHubConfigUpdateRequest]) (api.Response, error) {
	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github setup store is not configured")
	}
	if !r.Body.Valid() {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub App settings URL and private key are required")
	}
	mode, err := handler.githubAppSetupModeForUser(r.Context(), r.UserID(), r.WorkspaceID())
	if err != nil {
		return api.Response{}, err
	}
	if mode != app.GitHubAppSetupModeRequester {
		return api.Response{}, api.NewHTTPError(http.StatusForbidden, "Workspace administrators must configure GitHub directly")
	}
	config, err := handler.verifyGitHubAppRecovery(r.Context(), r.Body.SettingsURL, r.Body.PrivateKeyPEM)
	if err != nil {
		if status, ok := statusForGitHubFlowError(err); ok {
			return api.Response{}, api.NewHTTPError(status, err.Error())
		}
		return api.Response{}, err
	}
	proposal, err := handler.adminStore.SaveVerifiedGitHubSetupDraftForRequester(
		r.Context(), r.UserID(), r.WorkspaceID(), githubSetupParamsFromConfig(config),
	)
	if err != nil {
		return api.Response{}, err
	}
	_, storedConfig, err := handler.adminStore.GetGitHubSetupDraftForRequester(
		r.Context(), r.UserID(), r.WorkspaceID(), proposal.ID,
	)
	if err != nil {
		return api.Response{}, err
	}
	installations, discoveryErr := handler.listGitHubInstallations(r.Context(), storedConfig)
	return githubNoStoreOK(dto.GitHubSetupDraftResponse{
		Proposal:        converter.ToGitHubSetupProposalResponse(proposal),
		Installations:   githubInstallationResponses(installations),
		DiscoveryFailed: discoveryErr != nil,
	}), nil
}

// SetupProposalInstallationsList retries provider discovery for one
// unsubmitted requester reuse draft. Store admission is owner-bound and
// rechecks membership, TTL, mode, and the App-global claim before decrypting
// credentials; only minimal eligible organization metadata is returned.
func (handler *GitHubHandler) SetupProposalInstallationsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github setup store is not configured")
	}
	proposalID := strings.TrimSpace(r.Gin.Param("proposalId"))
	if proposalID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub setup proposal id is required")
	}
	_, config, err := handler.adminStore.GetGitHubSetupDraftForRequester(
		r.Context(), r.UserID(), r.WorkspaceID(), proposalID,
	)
	if err != nil {
		return api.Response{}, err
	}
	installations, err := handler.listGitHubInstallations(r.Context(), config)
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusBadGateway, "Failed to load GitHub App installations")
	}
	return githubNoStoreOK(githubInstallationResponses(installations)), nil
}

// ConfigGet handles GET /api/v1/workspaces/:workspaceId/providers/github/config.
// Returns the GitHub App configuration for the current workspace.
func (handler *GitHubHandler) ConfigGet(r api.WorkspaceScopedRequest) (api.Response, error) {
	authenticated := r.User
	workspaceID := r.WorkspaceID()

	config, err := handler.store.GetGitHubAppConfigForUser(r.Context(), authenticated.User.ID, workspaceID)
	if err != nil {
		return api.Response{}, err
	}

	return api.OK(converter.ToGitHubAppConfigResponse(config)), nil
}

// ConfigDelete handles DELETE /api/v1/workspaces/:workspaceId/providers/github/config.
// Deletes the GitHub App configuration. The Store disables connections and
// blocks related service-event subscriptions in the same transaction.
func (handler *GitHubHandler) ConfigDelete(r api.WorkspaceScopedRequest) (api.Response, error) {
	authenticated := r.User
	workspaceID := r.WorkspaceID()

	config, err := handler.store.DeleteGitHubAppConfigForUser(r.Context(), authenticated.User.ID, workspaceID)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(converter.ToGitHubAppConfigResponse(config)), nil
}

// InstallationsList handles GET /api/v1/workspaces/:workspaceId/providers/github/installations.
// It lets every active workspace member discover provider-side installations
// of the workspace's already-installed and configured GitHub App without
// creating AgentTeam connections. The setup-mode gate verifies the active local
// App/config boundary before any private key is loaded or provider request is
// made. POST /connections remains the explicit, reverified mutation boundary:
// every active member may connect exactly one installation directly per request.
func (handler *GitHubHandler) InstallationsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	userID := r.UserID()
	workspaceID := r.WorkspaceID()

	if handler.adminStore == nil {
		return api.Response{}, fmt.Errorf("github connection store is not configured")
	}
	// A GitHub App JWT can enumerate every account where the App is installed.
	// Reuse the role-aware connection admission boundary so an arbitrary member
	// cannot enumerate with a draft, disabled App, or unconfigured workspace.
	if _, err := handler.adminStore.GitHubConnectionSetupModeForUser(r.Context(), userID, workspaceID); err != nil {
		return api.Response{}, err
	}

	config, found, err := handler.store.LoadGitHubAppConfigForWorkspace(r.Context(), workspaceID)
	if err != nil {
		return api.Response{}, err
	}
	if !found {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "GitHub App is not registered")
	}

	installations, err := handler.listGitHubInstallations(r.Context(), config)
	if err != nil {
		return api.Response{}, api.NewHTTPError(http.StatusBadGateway, "Failed to load GitHub App installations")
	}

	return githubNoStoreOK(githubInstallationResponses(installations)), nil
}

func githubInstallationResponses(installations []githubInstallationLookupResponse) []dto.GitHubInstallationResponse {
	result := make([]dto.GitHubInstallationResponse, 0, len(installations))
	for _, installation := range installations {
		if installation.SuspendedAt != nil || !strings.EqualFold(strings.TrimSpace(installation.TargetType), "Organization") {
			continue
		}
		result = append(result, dto.GitHubInstallationResponse{
			InstallationID: strconv.FormatInt(installation.ID, 10),
			AccountLogin:   strings.TrimSpace(installation.Account.Login),
			// The provider comparison is case-insensitive, while the public API
			// contract is the canonical GitHub enum value.
			TargetType: "Organization",
		})
	}
	return result
}

func githubSetupParamsFromConfig(config provider.GitHubAppConfig) provider.UpsertGitHubAppConfigParams {
	return provider.UpsertGitHubAppConfigParams{
		AppID:         config.AppID,
		AppSlug:       strings.TrimSpace(config.AppSlug),
		AppName:       strings.TrimSpace(config.AppName),
		HTMLURL:       strings.TrimSpace(config.HTMLURL),
		ClientID:      strings.TrimSpace(config.ClientID),
		ClientSecret:  strings.TrimSpace(config.ClientSecret),
		PrivateKeyPEM: strings.TrimSpace(config.PrivateKeyPEM),
		WebhookSecret: strings.TrimSpace(config.WebhookSecret),
		OwnerLogin:    strings.TrimSpace(config.OwnerLogin),
		OwnerID:       config.OwnerID,
	}
}

// ConnectionsList handles GET /api/v1/workspaces/:workspaceId/providers/github/connections.
// Returns the list of GitHub connections for the current workspace, including permission upgrade indicators.
func (handler *GitHubHandler) ConnectionsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	authenticated := r.User
	workspaceID := r.WorkspaceID()

	connections, err := handler.store.ListGitHubConnectionsForUser(r.Context(), authenticated.User.ID, workspaceID)
	if err != nil {
		return api.Response{}, err
	}

	config, found, err := handler.store.LoadGitHubAppConfigForWorkspace(r.Context(), workspaceID)
	if err != nil {
		return api.Response{}, err
	}
	if found {
		for index := range connections {
			installation, err := handler.loadGitHubInstallation(r.Context(), config, connections[index].ExternalSubjectRef)
			if err != nil {
				continue
			}
			upgrade := githubInstallationUpgradeRequirements(installation)
			connections[index].MissingPermissions = upgrade.MissingPermissions
			connections[index].MissingEvents = upgrade.MissingEvents
			connections[index].RequiresUpgrade = len(upgrade.MissingPermissions) > 0 || len(upgrade.MissingEvents) > 0
		}
	}

	return api.OK(converter.ToProviderAppConnectionResponses(connections)), nil
}

// Disconnect handles DELETE /api/v1/workspaces/:workspaceId/providers/github/connections/:connectionId.
// Disables a GitHub connection and blocks related service-event subscriptions.
func (handler *GitHubHandler) Disconnect(r api.WorkspaceScopedRequest) (api.Response, error) {
	authenticated := r.User
	workspaceID := r.WorkspaceID()

	connectionID := strings.TrimSpace(r.Gin.Param("connectionId"))
	if connectionID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "connectionId is required")
	}

	base, err := handler.store.PrepareGitHubConnectionDisableForUser(
		r.Context(), authenticated.User.ID, workspaceID, connectionID,
	)
	if err != nil {
		return api.Response{}, err
	}
	if err := handler.store.DisableGitHubConnectionForUser(
		r.Context(), authenticated.User.ID, workspaceID, connectionID, base,
	); err != nil {
		return api.Response{}, err
	}

	return api.NoContent(), nil
}
