package app

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/app/installation"
	"github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/provider"
)

const (
	githubInitialSetupProviderTimeout = 2 * time.Minute
	githubInitialSetupFinalizeTimeout = 30 * time.Second
)

type githubInitialSetupApprovalPlan struct {
	RequestID               string
	Targets                 []githubConnectionTargetSnapshot
	Config                  provider.GitHubAppConfig
	ProviderMutationStarted bool
}

func isGitHubInitialSetupApprovalRequest(request AppInstallationRequestView) bool {
	return request.ApprovalRequired && isGitHubApp(request.AppID) &&
		strings.TrimSpace(request.RequestKind) == AppInstallationRequestKindInstall &&
		len(request.ConnectionTargets) > 0
}

// approveGitHubInitialSetupRequestForUser owns the external-mutation saga for
// a member's initial-connection install request. Provider I/O never runs inside
// a database transaction: Begin reads and validates an immutable pending plan,
// exact provider verification happens next, Mark durably enters the point of
// no return, PATCH uses the same encrypted secret on every retry, and Finalize
// atomically activates App/config/claim/all selected connections and the audit
// row.
func (store *Store) approveGitHubInitialSetupRequestForUser(
	ctx context.Context,
	userID string,
	workspaceID string,
	requestID string,
	params DecideAppInstallationRequestParams,
) (AppInstallationRequestView, error) {
	current, err := store.appInstallationRequestByID(ctx, workspaceID, requestID)
	if err != nil {
		return AppInstallationRequestView{}, err
	}
	if strings.TrimSpace(current.Status) == AppInstallationRequestStatusApproved {
		return current, nil
	}

	plan, err := store.beginGitHubInitialSetupApproval(ctx, userID, workspaceID, requestID)
	if err != nil {
		return AppInstallationRequestView{}, err
	}
	providerCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), githubInitialSetupProviderTimeout)
	defer cancel()
	verifiedTargets := make([]githubConnectionTargetSnapshot, 0, len(plan.Targets))
	for _, expected := range plan.Targets {
		verified, err := store.verifyGitHubConnectionInstallation(
			providerCtx, plan.Config, expected.InstallationID,
		)
		if err != nil {
			// Provider 5xx/network errors remain retryable. Deterministic 4xx,
			// ownership, suspension, permission, or selection failures can never
			// make the immutable pending proposal valid again and must be projected
			// as cannot_apply rather than an endlessly retryable approval.
			if plan.ProviderMutationStarted {
				return AppInstallationRequestView{}, err
			}
			return AppInstallationRequestView{}, markAppInstallationApprovalStaleIfDeterministic(err)
		}
		actual := githubConnectionTargetSnapshot{
			InstallationID: verified.ExternalSubjectRef,
			AccountLogin:   verified.DisplayLabel,
			AccountID:      strconv.FormatInt(verified.AccountID, 10),
		}
		if actual != expected {
			if plan.ProviderMutationStarted && sameGitHubConnectionTargetIdentities(
				[]githubConnectionTargetSnapshot{expected},
				[]githubConnectionTargetSnapshot{actual},
			) {
				// GitHub organization logins are mutable. After PATCH may have
				// succeeded, installation id + immutable account id retain authority;
				// Finalize stores the current safe display label.
			} else if plan.ProviderMutationStarted {
				return AppInstallationRequestView{}, conflict("GitHub installation changed. Retry requires operator recovery.")
			} else {
				return AppInstallationRequestView{}, markAppInstallationApprovalStale(
					conflict("GitHub installation changed. Submit a new setup request."),
				)
			}
		}
		verifiedTargets = append(verifiedTargets, actual)
	}
	if err := store.markGitHubInitialSetupProviderMutationStarted(
		providerCtx, userID, workspaceID, plan,
	); err != nil {
		return AppInstallationRequestView{}, err
	}
	if err := store.patchGitHubAppWebhookForInitialSetup(providerCtx, plan.Config); err != nil {
		return AppInstallationRequestView{}, err
	}

	finalizeCtx, finalizeCancel := context.WithTimeout(
		context.WithoutCancel(ctx), githubInitialSetupFinalizeTimeout,
	)
	defer finalizeCancel()
	return store.finalizeGitHubInitialSetupApproval(
		finalizeCtx, userID, workspaceID, plan, verifiedTargets, params,
	)
}

func (store *Store) beginGitHubInitialSetupApproval(
	ctx context.Context,
	userID string,
	workspaceID string,
	requestID string,
) (githubInitialSetupApprovalPlan, error) {
	expectedApprover, err := store.loadAuthorizedHumanWorkspaceMember(
		ctx, store.db, userID, workspaceID, serviceInstallationApproveAction,
	)
	if err != nil {
		return githubInitialSetupApprovalPlan{}, err
	}
	var plan githubInitialSetupApprovalPlan
	err = coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
		if err := installation.LockAppInstallationMutationScope(ctx, tx, registry.GitHubAppID); err != nil {
			return err
		}
		request, err := loadAppInstallationRequestForUpdate(ctx, tx, workspaceID, requestID)
		if err != nil {
			return err
		}
		if !isGitHubInitialSetupApprovalRequest(request) {
			err := conflict("GitHub setup request is missing its selected connections. Submit a new request.")
			if isGitHubApp(request.AppID) &&
				strings.TrimSpace(request.RequestKind) == AppInstallationRequestKindInstall &&
				strings.TrimSpace(request.Status) == AppInstallationRequestStatusPending {
				return markAppInstallationApprovalStale(err)
			}
			return err
		}
		currentApprover, err := store.loadAuthorizedHumanWorkspaceMemberForUpdate(
			ctx, tx, userID, workspaceID, serviceInstallationApproveAction,
		)
		if err != nil {
			return err
		}
		if strings.TrimSpace(currentApprover.Member.ID) != strings.TrimSpace(expectedApprover.Member.ID) {
			return conflict("App change approver membership changed.")
		}
		switch strings.TrimSpace(request.Status) {
		case AppInstallationRequestStatusApproved:
			return nil
		case AppInstallationRequestStatusPending:
			if err := requireAppInstallationRequestApplicable(request); err != nil {
				return err
			}
			requester, err := store.loadAuthorizedHumanWorkspaceMemberForUpdate(
				ctx, tx, request.RequestedByUserID, workspaceID, workspaceAccessAction,
			)
			if err != nil {
				return markAppInstallationApprovalStaleIfDeterministic(err)
			}
			if strings.TrimSpace(requester.Member.ID) != strings.TrimSpace(request.RequestedByMemberID) {
				return markAppInstallationApprovalStale(conflict("App change requester membership changed. Submit a new request."))
			}
			if err := requireAppApprovalRequester(requester); err != nil {
				return markAppInstallationApprovalStaleIfDeterministic(err)
			}
		case AppInstallationRequestStatusSetupPatching:
			// The same immutable request/secret/selection is the only allowed
			// recovery authority after provider success becomes ambiguous.
		case AppInstallationRequestStatusRejected:
			return conflict("App installation request was already rejected.")
		default:
			return conflict("App change request has an invalid state.")
		}

		config, err := store.validateGitHubInitialSetupApprovalSnapshotInTx(
			ctx, tx, workspaceID, request,
		)
		if err != nil {
			if strings.TrimSpace(request.Status) == AppInstallationRequestStatusPending {
				return markAppInstallationApprovalStaleIfDeterministic(err)
			}
			return err
		}
		targets, err := parseGitHubConnectionTargetSnapshots(
			appInstallationRequestStoredRequestedConfig(request),
		)
		if err != nil || len(targets) == 0 {
			targetErr := conflict("GitHub setup request is missing its selected connections. Submit a new request.")
			if strings.TrimSpace(request.Status) == AppInstallationRequestStatusPending {
				return markAppInstallationApprovalStale(targetErr)
			}
			return targetErr
		}
		plan = githubInitialSetupApprovalPlan{
			RequestID: strings.TrimSpace(request.ID),
			Targets:   targets,
			Config:    githubAppConfigFromSetupParams(workspaceID, config),
			ProviderMutationStarted: strings.TrimSpace(request.Status) ==
				AppInstallationRequestStatusSetupPatching,
		}
		return nil
	})
	return plan, err
}

func (store *Store) validateGitHubInitialSetupApprovalSnapshotInTx(
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
	request AppInstallationRequestView,
) (provider.UpsertGitHubAppConfigParams, error) {
	prepared, err := prepareInstalledAppConfigurationAtVersion(
		ctx, tx, request.AppID, request.RequestedVersion, nil,
	)
	if err != nil {
		return provider.UpsertGitHubAppConfigParams{}, markAppInstallationApprovalStaleIfDeterministic(err)
	}
	if err := authorizeAppInstallationRequest(ctx, tx, workspaceID, prepared); err != nil {
		return provider.UpsertGitHubAppConfigParams{}, markAppInstallationApprovalStaleIfDeterministic(err)
	}
	if !equalSortedScopes(prepared.version.Scopes, request.RequestedScopes) {
		return provider.UpsertGitHubAppConfigParams{}, markAppInstallationApprovalStale(
			conflict("App change request scopes changed. Submit a new request."),
		)
	}
	if configured, err := githubAppConfigExistsForUpdate(ctx, tx, workspaceID, true); err != nil {
		return provider.UpsertGitHubAppConfigParams{}, err
	} else if configured {
		return provider.UpsertGitHubAppConfigParams{}, markAppInstallationApprovalStale(
			conflict("GitHub configuration changed. Submit a new installation request."),
		)
	}
	if strings.TrimSpace(request.Status) == AppInstallationRequestStatusPending {
		blocked, err := githubSetupBlockedForMember(ctx, tx, workspaceID, true)
		if err != nil {
			return provider.UpsertGitHubAppConfigParams{}, err
		}
		if blocked {
			return provider.UpsertGitHubAppConfigParams{}, markAppInstallationApprovalStale(
				conflict("Another GitHub App installation already won the activation race."),
			)
		}
	}
	params, found, err := store.verifiedGitHubAppConfigFromStoredMap(
		appInstallationRequestStoredRequestedConfig(request),
	)
	if err != nil {
		return provider.UpsertGitHubAppConfigParams{}, err
	}
	if !found {
		return provider.UpsertGitHubAppConfigParams{}, conflict("GitHub setup proposal is missing. Restart setup.")
	}
	switch strings.TrimSpace(request.Status) {
	case AppInstallationRequestStatusPending:
		// Pending requests are independent contenders and intentionally hold no
		// App/workspace claim. The authoritative claim is acquired only by Mark,
		// after every exact provider GET succeeds and immediately before PATCH.
	case AppInstallationRequestStatusSetupPatching:
		err = requireGitHubAppPatchingClaimForUpdate(
			ctx, tx, params.AppID, workspaceID, request.ID,
		)
	}
	if err != nil {
		return provider.UpsertGitHubAppConfigParams{}, err
	}
	return params, nil
}

func (store *Store) markGitHubInitialSetupProviderMutationStarted(
	ctx context.Context,
	userID string,
	workspaceID string,
	plan githubInitialSetupApprovalPlan,
) error {
	expectedApprover, err := store.loadAuthorizedHumanWorkspaceMember(
		ctx, store.db, userID, workspaceID, serviceInstallationApproveAction,
	)
	if err != nil {
		return err
	}
	return coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
		if err := installation.LockAppInstallationMutationScope(ctx, tx, registry.GitHubAppID); err != nil {
			return err
		}
		request, err := loadAppInstallationRequestForUpdate(ctx, tx, workspaceID, plan.RequestID)
		if err != nil {
			return err
		}
		if !isGitHubInitialSetupApprovalRequest(request) ||
			!slices.Equal(request.ConnectionTargets, githubConnectionTargetIDs(plan.Targets)) {
			return conflict("GitHub installation selection changed. Submit a new request.")
		}
		currentApprover, err := store.loadAuthorizedHumanWorkspaceMemberForUpdate(
			ctx, tx, userID, workspaceID, serviceInstallationApproveAction,
		)
		if err != nil {
			return err
		}
		if currentApprover.Member.ID != expectedApprover.Member.ID {
			return conflict("App change approver membership changed.")
		}
		config, err := store.validateGitHubInitialSetupApprovalSnapshotInTx(
			ctx, tx, workspaceID, request,
		)
		if err != nil {
			return err
		}
		if config.AppID != plan.Config.AppID || !strings.EqualFold(config.AppSlug, plan.Config.AppSlug) ||
			strings.TrimSpace(config.WebhookSecret) != strings.TrimSpace(plan.Config.WebhookSecret) {
			return conflict("GitHub setup request changed. Submit a new request.")
		}
		if request.Status == AppInstallationRequestStatusSetupPatching {
			return nil
		}
		if request.Status != AppInstallationRequestStatusPending {
			return conflict("App change request has an invalid state.")
		}
		now := time.Now().UTC()
		if err := markGitHubAppClaimPatchingInTx(
			ctx, tx, config.AppID, workspaceID, request.ID, false, now,
		); err != nil {
			return markAppInstallationApprovalStaleIfDeterministic(err)
		}
		result, err := tx.Exec(ctx, `
UPDATE service_installation_requests
SET status = $1,
    updated_at = $2
WHERE id = $3
  AND workspace_id = $4
  AND status = $5
`, AppInstallationRequestStatusSetupPatching, now, request.ID,
			strings.TrimSpace(workspaceID), AppInstallationRequestStatusPending)
		if err != nil {
			return fmt.Errorf("mark GitHub initial setup provider mutation started: %w", err)
		}
		if result.RowsAffected() != 1 {
			return conflict("App change request decision changed.")
		}
		return nil
	})
}

func (store *Store) patchGitHubAppWebhookForInitialSetup(
	ctx context.Context,
	config provider.GitHubAppConfig,
) error {
	if strings.TrimSpace(store.publicBaseURL) == "" {
		return fmt.Errorf("public base URL is required for GitHub webhook setup")
	}
	jwtToken, err := buildGitHubAppJWTForStore(config)
	if err != nil {
		return conflict("GitHub App credentials changed. Submit a new setup request.")
	}
	payload, err := json.Marshal(map[string]string{
		"url":          strings.TrimRight(store.publicBaseURL, "/") + "/api/apps/github/webhook",
		"content_type": "json",
		"secret":       strings.TrimSpace(config.WebhookSecret),
	})
	if err != nil {
		return fmt.Errorf("encode GitHub webhook update: %w", err)
	}
	baseURL := strings.TrimRight(strings.TrimSpace(store.githubAPIBaseURL), "/")
	if baseURL == "" {
		baseURL = "https://api.github.com"
	}
	request, err := http.NewRequestWithContext(
		ctx, http.MethodPatch, baseURL+"/app/hook/config", strings.NewReader(string(payload)),
	)
	if err != nil {
		return fmt.Errorf("build GitHub webhook update: %w", err)
	}
	request.Header.Set("Accept", "application/vnd.github+json")
	request.Header.Set("Authorization", "Bearer "+jwtToken)
	request.Header.Set("Content-Type", "application/json")
	response, err := store.appHTTPClient.Do(request)
	if err != nil {
		return fmt.Errorf("update GitHub App webhook: %w", err)
	}
	defer response.Body.Close()
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
		return fmt.Errorf("update GitHub App webhook: upstream status %d", response.StatusCode)
	}
	return nil
}

func (store *Store) finalizeGitHubInitialSetupApproval(
	ctx context.Context,
	userID string,
	workspaceID string,
	plan githubInitialSetupApprovalPlan,
	verifiedTargets []githubConnectionTargetSnapshot,
	decision DecideAppInstallationRequestParams,
) (AppInstallationRequestView, error) {
	expected, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return AppInstallationRequestView{}, err
	}
	err = coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
		if err := installation.LockAppInstallationMutationScope(ctx, tx, registry.GitHubAppID); err != nil {
			return err
		}
		request, err := loadAppInstallationRequestForUpdate(ctx, tx, workspaceID, plan.RequestID)
		if err != nil {
			return err
		}
		if request.Status == AppInstallationRequestStatusApproved {
			return nil
		}
		if request.Status != AppInstallationRequestStatusSetupPatching ||
			!slices.Equal(request.ConnectionTargets, githubConnectionTargetIDs(plan.Targets)) ||
			!sameGitHubConnectionTargetIdentities(verifiedTargets, plan.Targets) {
			return conflict("GitHub setup completion state changed. Retry approval.")
		}
		current, err := store.revalidateGitHubSetupMemberForUpdate(
			ctx, tx, userID, workspaceID, expected,
		)
		if err != nil {
			return err
		}
		config, err := store.validateGitHubInitialSetupApprovalSnapshotInTx(
			ctx, tx, workspaceID, request,
		)
		if err != nil {
			return err
		}
		if config.AppID != plan.Config.AppID || strings.TrimSpace(config.WebhookSecret) != strings.TrimSpace(plan.Config.WebhookSecret) {
			return conflict("GitHub setup request changed. Retry approval.")
		}
		now := time.Now().UTC()
		installationID, err := store.finalizeGitHubAppSetupForWorkspaceAdminInTx(
			ctx, tx, request.RequestedByUserID, workspaceID, request.RequestedByMemberID,
			config, request.RequestedVersion, request.ID, now,
		)
		if err != nil {
			return err
		}
		for _, target := range verifiedTargets {
			if _, err := store.upsertAppConnectionInTx(ctx, tx, UpsertAppConnectionParams{
				InstallationID:     installationID,
				ExternalSubjectRef: target.InstallationID,
				DisplayLabel:       target.AccountLogin,
				Credentials: map[string]any{
					"webhook_secret": config.WebhookSecret,
				},
				CreatedByUserID: strings.TrimSpace(userID),
			}, "", now); err != nil {
				return err
			}
			if err := store.setGitHubServiceEventSubscriptionsBlockedInTx(
				ctx, tx, workspaceID, target.InstallationID, false, now,
			); err != nil {
				return err
			}
		}
		connections, err := store.serviceActorStore().InTx(tx).ListActiveConnections(ctx, installationID)
		if err != nil {
			return err
		}
		actualConnectionIDs := make([]string, 0, len(connections))
		for _, connection := range connections {
			actualConnectionIDs = append(actualConnectionIDs, strings.TrimSpace(connection.ExternalSubjectRef))
		}
		actualConnectionIDs, err = normalizedGitHubInstallationIDs(actualConnectionIDs)
		if err != nil || !slices.Equal(actualConnectionIDs, githubConnectionTargetIDs(verifiedTargets)) {
			return conflict("GitHub connection state is inconsistent. Retry approval.")
		}

		// Terminal audit rows keep only the safe App metadata and the validated
		// installation id/login snapshot. Credential envelopes and even masked
		// placeholders are deleted entirely once the durable App config owns the
		// credentials.
		resolvedRequested, err := appInstallationRequestTerminalRequestedConfig(request)
		if err != nil {
			return err
		}
		resolvedRequested[githubConnectionTargetsSnapshotKey] = githubConnectionTargetSnapshotMap(verifiedTargets)
		resolvedRequestedJSON, err := model.MarshalJSONObject(
			"resolved GitHub setup request config", resolvedRequested,
		)
		if err != nil {
			return fmt.Errorf("redact approved GitHub setup config: %w", err)
		}
		resolvedBaseJSON, err := model.MarshalJSONObject(
			"resolved GitHub setup request base", map[string]any{},
		)
		if err != nil {
			return fmt.Errorf("redact approved GitHub setup base: %w", err)
		}
		result, err := tx.Exec(ctx, `
UPDATE service_installation_requests
SET status = $1,
    decided_by_member_id = $2,
    decision_reason = $3,
    service_installation_id = $4,
    base_config = $5,
    requested_config = $6,
    updated_at = $7,
    decided_at = $7
WHERE id = $8
  AND workspace_id = $9
  AND status = $10
`, AppInstallationRequestStatusApproved, current.Member.ID, strings.TrimSpace(decision.Reason),
			installationID, resolvedBaseJSON, resolvedRequestedJSON, now, request.ID,
			strings.TrimSpace(workspaceID), AppInstallationRequestStatusSetupPatching)
		if err != nil {
			return fmt.Errorf("approve GitHub initial setup request: %w", err)
		}
		if result.RowsAffected() != 1 {
			return conflict("App installation request decision changed.")
		}
		return nil
	})
	if err != nil {
		return AppInstallationRequestView{}, err
	}
	return store.appInstallationRequestByID(ctx, workspaceID, plan.RequestID)
}
