package controlplane

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"time"

	"github.com/codex-k8s/kodex/libs/go/domain/flowevent"
	"github.com/codex-k8s/kodex/libs/go/grpcutil"
	controlplanev1 "github.com/codex-k8s/kodex/proto/gen/go/kodex/controlplane/v1"
	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
	"google.golang.org/protobuf/types/known/timestamppb"
	"google.golang.org/protobuf/types/known/wrapperspb"
)

// Client is an agent-runner wrapper over control-plane gRPC callbacks.
type Client struct {
	conn        *grpc.ClientConn
	svc         controlplanev1.ControlPlaneServiceClient
	bearerToken string
}

// SessionIdentity groups run identity fields persisted in agent session snapshots.
type SessionIdentity struct {
	RunID              string
	CorrelationID      string
	ProjectID          string
	RepositoryFullName string
	AgentKey           string
	IssueNumber        *int
	BranchName         string
	PRNumber           *int
	PRURL              string
}

// SessionTemplateContext captures template/model context used for this run.
type SessionTemplateContext struct {
	TriggerKind     string
	TemplateKind    string
	TemplateSource  string
	TemplateLocale  string
	Model           string
	ReasoningEffort string
}

// SessionRuntimeState captures session runtime status and codex snapshot files.
type SessionRuntimeState struct {
	Status           string
	SessionID        string
	SessionJSON      json.RawMessage
	CodexSessionPath string
	CodexSessionJSON json.RawMessage
	SnapshotVersion  int64
	SnapshotChecksum string
	StartedAt        time.Time
	FinishedAt       *time.Time
}

// AgentSessionUpsertParams defines payload for session persistence callback.
type AgentSessionUpsertParams struct {
	Identity SessionIdentity
	Template SessionTemplateContext
	Runtime  SessionRuntimeState
}

// AgentSessionUpsertResult returns persisted snapshot metadata for this run.
type AgentSessionUpsertResult struct {
	SnapshotVersion  int64
	SnapshotChecksum string
}

// AgentSessionSnapshot is latest persisted session snapshot for resume.
type AgentSessionSnapshot struct {
	RunID             string
	CorrelationID     string
	ProjectID         string
	RepositoryName    string
	AgentKey          string
	IssueNumber       int
	BranchName        string
	PRNumber          int
	PRURL             string
	TriggerKind       string
	TemplateKind      string
	TemplateSource    string
	TemplateLocale    string
	Model             string
	ReasoningEffort   string
	Status            string
	SessionID         string
	SessionJSON       json.RawMessage
	CodexSessionPath  string
	CodexSessionJSON  json.RawMessage
	SnapshotVersion   int64
	SnapshotChecksum  string
	SnapshotUpdatedAt time.Time
	StartedAt         time.Time
	FinishedAt        time.Time
	CreatedAt         time.Time
	UpdatedAt         time.Time
}

// RunInteractionResumePayload is deterministic interaction outcome fetched for the current run.
type RunInteractionResumePayload struct {
	Payload json.RawMessage
}

// GitHubRateLimitHeaders is a sanitized snapshot of provider headers attached to one runner-side signal.
type GitHubRateLimitHeaders struct {
	RateLimitLimit     *int
	RateLimitRemaining *int
	RateLimitUsed      *int
	RateLimitResetAt   *time.Time
	RateLimitResource  string
	RetryAfterSeconds  *int
	GitHubRequestID    string
	DocumentationURL   string
}

// ReportGitHubRateLimitSignalParams carries one runner-side GitHub rate-limit signal into control-plane.
type ReportGitHubRateLimitSignalParams struct {
	RunID                  string
	SignalID               string
	CorrelationID          string
	ContourKind            string
	SignalOrigin           string
	OperationClass         string
	ProviderStatusCode     int
	OccurredAt             time.Time
	RequestFingerprint     string
	StderrExcerpt          string
	MessageExcerpt         string
	Headers                GitHubRateLimitHeaders
	SessionSnapshotVersion *int64
}

// ReportGitHubRateLimitSignalResult returns canonical wait info accepted by control-plane.
type ReportGitHubRateLimitSignalResult struct {
	WaitID          string
	WaitState       string
	WaitReason      string
	NextStepKind    string
	RunnerAction    string
	ResumeNotBefore *time.Time
}

type ChangeGovernanceScopeHint struct {
	ContextKey  string
	SurfaceKind string
}

type ChangeGovernanceVerificationTarget struct {
	TargetKind string
	TargetRef  string
}

type ChangeGovernanceWaveDraft struct {
	WaveKey             string
	PublishOrder        int
	DominantIntent      string
	BoundedScopeKind    string
	Summary             string
	VerificationTargets []ChangeGovernanceVerificationTarget
}

type ChangeGovernanceArtifactLinkSeed struct {
	ArtifactKind string
	ArtifactRef  string
	RelationKind string
	DisplayLabel string
}

type ReportChangeGovernanceDraftSignalParams struct {
	RunID                string
	SignalID             string
	CorrelationID        string
	ProjectID            string
	RepositoryFullName   string
	IssueNumber          int
	PRNumber             *int
	BranchName           string
	DraftRef             string
	DraftKind            string
	ChangeScopeHints     []ChangeGovernanceScopeHint
	CandidateRiskDrivers []string
	DraftChecksum        string
	OccurredAt           time.Time
}

type ReportChangeGovernanceDraftSignalResult struct {
	PackageID    string
	DraftState   string
	NextStepKind string
}

type PublishChangeGovernanceWaveMapParams struct {
	RunID         string
	PackageID     string
	WaveMapID     string
	CorrelationID string
	Waves         []ChangeGovernanceWaveDraft
	PublishedAt   time.Time
}

type PublishChangeGovernanceWaveMapResult struct {
	PackageID         string
	PublicationState  string
	ProjectionVersion int64
}

type UpsertChangeGovernanceEvidenceSignalParams struct {
	RunID                 string
	PackageID             string
	SignalID              string
	CorrelationID         string
	ScopeKind             string
	ScopeRef              string
	BlockKind             string
	ArtifactLinks         []ChangeGovernanceArtifactLinkSeed
	VerificationStateHint string
	RequiredByTier        bool
	OccurredAt            time.Time
}

type UpsertChangeGovernanceEvidenceSignalResult struct {
	PackageID                 string
	EvidenceCompletenessState string
	VerificationMinimumState  string
	ProjectionVersion         int64
}

// RunGitHubRateLimitResumePayload is the deterministic GitHub rate-limit payload fetched for the current run.
type RunGitHubRateLimitResumePayload struct {
	Payload json.RawMessage
}

// LatestAgentSessionQuery describes latest-session lookup identity.
type LatestAgentSessionQuery struct {
	RepositoryFullName string
	BranchName         string
	AgentKey           string
}

type UpsertRunStatusCommentParams struct {
	RunID                    string
	Phase                    string
	JobName                  string
	JobNamespace             string
	RuntimeMode              string
	Namespace                string
	TriggerKind              string
	PromptLocale             string
	Model                    string
	ReasoningEffort          string
	RunStatus                string
	CodexAuthVerificationURL string
	CodexAuthUserCode        string
}

type runPayloadResponse interface {
	GetFound() bool
	GetPayloadJson() []byte
}

// RunPullRequestLookupParams describes PR metadata lookup scoped to one authenticated run.
type RunPullRequestLookupParams struct {
	ProjectID          string
	RepositoryFullName string
	PRNumber           int
	HeadBranch         string
}

// RunPullRequestLookupResult contains recovered PR metadata.
type RunPullRequestLookupResult struct {
	PRNumber   int
	PRURL      string
	PRState    string
	HeadBranch string
	BaseBranch string
}

// Dial creates control-plane gRPC client with run-bound bearer auth.
func Dial(ctx context.Context, target string, bearerToken string) (*Client, error) {
	conn, err := grpcutil.DialInsecureReady(ctx, strings.TrimSpace(target))
	if err != nil {
		return nil, fmt.Errorf("dial control-plane grpc: %w", err)
	}
	return &Client{
		conn:        conn,
		svc:         controlplanev1.NewControlPlaneServiceClient(conn),
		bearerToken: strings.TrimSpace(bearerToken),
	}, nil
}

// Close closes underlying gRPC connection.
func (c *Client) Close() error {
	if c == nil || c.conn == nil {
		return nil
	}
	return c.conn.Close()
}

// UpsertAgentSession stores or updates run session snapshot.
func (c *Client) UpsertAgentSession(ctx context.Context, params AgentSessionUpsertParams) (AgentSessionUpsertResult, error) {
	identity := params.Identity
	template := params.Template
	runtime := params.Runtime

	request := &controlplanev1.UpsertAgentSessionRequest{
		RunId:               strings.TrimSpace(identity.RunID),
		CorrelationId:       strings.TrimSpace(identity.CorrelationID),
		ProjectId:           optionalString(strings.TrimSpace(identity.ProjectID)),
		RepositoryFullName:  strings.TrimSpace(identity.RepositoryFullName),
		AgentKey:            strings.TrimSpace(identity.AgentKey),
		IssueNumber:         intToOptional(identity.IssueNumber),
		BranchName:          strings.TrimSpace(identity.BranchName),
		PrNumber:            intToOptional(identity.PRNumber),
		PrUrl:               optionalString(strings.TrimSpace(identity.PRURL)),
		TriggerKind:         optionalString(strings.TrimSpace(template.TriggerKind)),
		TemplateKind:        optionalString(strings.TrimSpace(template.TemplateKind)),
		TemplateSource:      optionalString(strings.TrimSpace(template.TemplateSource)),
		TemplateLocale:      optionalString(strings.TrimSpace(template.TemplateLocale)),
		Model:               optionalString(strings.TrimSpace(template.Model)),
		ReasoningEffort:     optionalString(strings.TrimSpace(template.ReasoningEffort)),
		Status:              optionalString(strings.TrimSpace(runtime.Status)),
		SessionId:           optionalString(strings.TrimSpace(runtime.SessionID)),
		SessionJson:         optionalBytes(runtime.SessionJSON),
		CodexCliSessionPath: optionalString(strings.TrimSpace(runtime.CodexSessionPath)),
		CodexCliSessionJson: optionalBytes(runtime.CodexSessionJSON),
		SnapshotVersion:     runtime.SnapshotVersion,
		SnapshotChecksum:    optionalString(strings.TrimSpace(runtime.SnapshotChecksum)),
		StartedAt:           timestamppb.New(runtime.StartedAt.UTC()),
		FinishedAt:          optionalTimestamp(runtime.FinishedAt),
	}

	resp, err := c.svc.UpsertAgentSession(c.withAuth(ctx), request)
	if err != nil {
		return AgentSessionUpsertResult{}, fmt.Errorf("upsert agent session: %w", err)
	}
	return AgentSessionUpsertResult{
		SnapshotVersion:  resp.GetSnapshotVersion(),
		SnapshotChecksum: strings.TrimSpace(resp.GetSnapshotChecksum()),
	}, nil
}

// GetLatestAgentSession loads latest snapshot by repository/branch/agent key.
func (c *Client) GetLatestAgentSession(ctx context.Context, query LatestAgentSessionQuery) (AgentSessionSnapshot, bool, error) {
	resp, err := c.svc.GetLatestAgentSession(c.withAuth(ctx), &controlplanev1.GetLatestAgentSessionRequest{
		RepositoryFullName: strings.TrimSpace(query.RepositoryFullName),
		BranchName:         strings.TrimSpace(query.BranchName),
		AgentKey:           strings.TrimSpace(query.AgentKey),
	})
	if err != nil {
		return AgentSessionSnapshot{}, false, fmt.Errorf("get latest agent session: %w", err)
	}
	if !resp.GetFound() || resp.GetSession() == nil {
		return AgentSessionSnapshot{}, false, nil
	}

	snapshot := resp.GetSession()
	result := AgentSessionSnapshot{
		RunID:             strings.TrimSpace(snapshot.GetRunId()),
		CorrelationID:     strings.TrimSpace(snapshot.GetCorrelationId()),
		ProjectID:         strings.TrimSpace(snapshot.GetProjectId()),
		RepositoryName:    strings.TrimSpace(snapshot.GetRepositoryFullName()),
		AgentKey:          strings.TrimSpace(snapshot.GetAgentKey()),
		IssueNumber:       optionalToInt(snapshot.GetIssueNumber()),
		BranchName:        strings.TrimSpace(snapshot.GetBranchName()),
		PRNumber:          optionalToInt(snapshot.GetPrNumber()),
		PRURL:             strings.TrimSpace(snapshot.GetPrUrl()),
		TriggerKind:       strings.TrimSpace(snapshot.GetTriggerKind()),
		TemplateKind:      strings.TrimSpace(snapshot.GetTemplateKind()),
		TemplateSource:    strings.TrimSpace(snapshot.GetTemplateSource()),
		TemplateLocale:    strings.TrimSpace(snapshot.GetTemplateLocale()),
		Model:             strings.TrimSpace(snapshot.GetModel()),
		ReasoningEffort:   strings.TrimSpace(snapshot.GetReasoningEffort()),
		Status:            strings.TrimSpace(snapshot.GetStatus()),
		SessionID:         strings.TrimSpace(snapshot.GetSessionId()),
		SessionJSON:       json.RawMessage(snapshot.GetSessionJson()),
		CodexSessionPath:  strings.TrimSpace(snapshot.GetCodexCliSessionPath()),
		CodexSessionJSON:  json.RawMessage(snapshot.GetCodexCliSessionJson()),
		SnapshotVersion:   snapshot.GetSnapshotVersion(),
		SnapshotChecksum:  strings.TrimSpace(snapshot.GetSnapshotChecksum()),
		SnapshotUpdatedAt: timestampOrZero(snapshot.GetSnapshotUpdatedAt()),
		StartedAt:         timestampOrZero(snapshot.GetStartedAt()),
		FinishedAt:        timestampOrZero(snapshot.GetFinishedAt()),
		CreatedAt:         timestampOrZero(snapshot.GetCreatedAt()),
		UpdatedAt:         timestampOrZero(snapshot.GetUpdatedAt()),
	}
	return result, true, nil
}

// GetRunInteractionResumePayload loads deterministic interaction resume payload for the authenticated run.
func (c *Client) GetRunInteractionResumePayload(ctx context.Context) (RunInteractionResumePayload, bool, error) {
	return loadRunPayload(ctx, "run interaction resume payload", c.getRunInteractionResumePayload, func(payload json.RawMessage) RunInteractionResumePayload {
		return RunInteractionResumePayload{Payload: payload}
	})
}

// GetRunGitHubRateLimitResumePayload loads deterministic GitHub rate-limit resume payload for the authenticated run.
func (c *Client) GetRunGitHubRateLimitResumePayload(ctx context.Context) (RunGitHubRateLimitResumePayload, bool, error) {
	return loadRunPayload(ctx, "github rate-limit resume payload", c.getRunGitHubRateLimitResumePayload, func(payload json.RawMessage) RunGitHubRateLimitResumePayload {
		return RunGitHubRateLimitResumePayload{Payload: payload}
	})
}

// ReportGitHubRateLimitSignal hands off one agent-runner GitHub rate-limit signal to control-plane.
func (c *Client) ReportGitHubRateLimitSignal(ctx context.Context, params ReportGitHubRateLimitSignalParams) (ReportGitHubRateLimitSignalResult, error) {
	request := &controlplanev1.ReportGitHubRateLimitSignalRequest{
		RunId:              strings.TrimSpace(params.RunID),
		SignalId:           strings.TrimSpace(params.SignalID),
		CorrelationId:      strings.TrimSpace(params.CorrelationID),
		ContourKind:        strings.TrimSpace(params.ContourKind),
		SignalOrigin:       strings.TrimSpace(params.SignalOrigin),
		OperationClass:     strings.TrimSpace(params.OperationClass),
		ProviderStatusCode: int32(params.ProviderStatusCode),
		OccurredAt:         timestamppb.New(params.OccurredAt.UTC()),
		RequestFingerprint: optionalString(strings.TrimSpace(params.RequestFingerprint)),
		StderrExcerpt:      optionalString(strings.TrimSpace(params.StderrExcerpt)),
		MessageExcerpt:     optionalString(strings.TrimSpace(params.MessageExcerpt)),
		GithubHeaders:      githubRateLimitHeadersToProto(params.Headers),
	}
	if params.SessionSnapshotVersion != nil {
		value := *params.SessionSnapshotVersion
		request.SessionSnapshotVersion = &value
	}

	resp, err := c.svc.ReportGitHubRateLimitSignal(c.withAuth(ctx), request)
	if err != nil {
		return ReportGitHubRateLimitSignalResult{}, fmt.Errorf("report github rate-limit signal: %w", err)
	}

	result := ReportGitHubRateLimitSignalResult{
		WaitID:       strings.TrimSpace(resp.GetWaitId()),
		WaitState:    strings.TrimSpace(resp.GetWaitState()),
		WaitReason:   strings.TrimSpace(resp.GetWaitReason()),
		NextStepKind: strings.TrimSpace(resp.GetNextStepKind()),
		RunnerAction: strings.TrimSpace(resp.GetRunnerAction()),
	}
	if ts := resp.GetResumeNotBefore(); ts != nil && ts.IsValid() {
		resumeNotBefore := ts.AsTime().UTC()
		result.ResumeNotBefore = &resumeNotBefore
	}
	return result, nil
}

func (c *Client) ReportChangeGovernanceDraftSignal(ctx context.Context, params ReportChangeGovernanceDraftSignalParams) (ReportChangeGovernanceDraftSignalResult, error) {
	request := &controlplanev1.ReportChangeGovernanceDraftSignalRequest{
		RunId:                strings.TrimSpace(params.RunID),
		SignalId:             strings.TrimSpace(params.SignalID),
		CorrelationId:        strings.TrimSpace(params.CorrelationID),
		ProjectId:            strings.TrimSpace(params.ProjectID),
		RepositoryFullName:   strings.TrimSpace(params.RepositoryFullName),
		IssueNumber:          int32(params.IssueNumber),
		PrNumber:             intToOptional(params.PRNumber),
		BranchName:           strings.TrimSpace(params.BranchName),
		DraftRef:             strings.TrimSpace(params.DraftRef),
		DraftKind:            strings.TrimSpace(params.DraftKind),
		ChangeScopeHints:     mapChangeGovernanceProtoPairs(params.ChangeScopeHints, changeGovernanceScopeHintContextKey, changeGovernanceScopeHintSurfaceKind, buildChangeGovernanceScopeHintProto),
		CandidateRiskDrivers: normalizeStrings(params.CandidateRiskDrivers),
		DraftChecksum:        optionalString(strings.TrimSpace(params.DraftChecksum)),
		OccurredAt:           timestamppb.New(params.OccurredAt.UTC()),
	}

	resp, err := c.svc.ReportChangeGovernanceDraftSignal(c.withAuth(ctx), request)
	if err != nil {
		return ReportChangeGovernanceDraftSignalResult{}, fmt.Errorf("report change governance draft signal: %w", err)
	}
	return ReportChangeGovernanceDraftSignalResult{
		PackageID:    strings.TrimSpace(resp.GetPackageId()),
		DraftState:   strings.TrimSpace(resp.GetDraftState()),
		NextStepKind: strings.TrimSpace(resp.GetNextStepKind()),
	}, nil
}

func (c *Client) PublishChangeGovernanceWaveMap(ctx context.Context, params PublishChangeGovernanceWaveMapParams) (PublishChangeGovernanceWaveMapResult, error) {
	request := &controlplanev1.PublishChangeGovernanceWaveMapRequest{
		RunId:         strings.TrimSpace(params.RunID),
		PackageId:     strings.TrimSpace(params.PackageID),
		WaveMapId:     strings.TrimSpace(params.WaveMapID),
		CorrelationId: strings.TrimSpace(params.CorrelationID),
		Waves:         changeGovernanceWavesToProto(params.Waves),
		PublishedAt:   timestamppb.New(params.PublishedAt.UTC()),
	}

	resp, err := c.svc.PublishChangeGovernanceWaveMap(c.withAuth(ctx), request)
	if err != nil {
		return PublishChangeGovernanceWaveMapResult{}, fmt.Errorf("publish change governance wave map: %w", err)
	}
	return PublishChangeGovernanceWaveMapResult{
		PackageID:         strings.TrimSpace(resp.GetPackageId()),
		PublicationState:  strings.TrimSpace(resp.GetPublicationState()),
		ProjectionVersion: resp.GetProjectionVersion(),
	}, nil
}

func (c *Client) UpsertChangeGovernanceEvidenceSignal(ctx context.Context, params UpsertChangeGovernanceEvidenceSignalParams) (UpsertChangeGovernanceEvidenceSignalResult, error) {
	request := &controlplanev1.UpsertChangeGovernanceEvidenceSignalRequest{
		RunId:                 strings.TrimSpace(params.RunID),
		PackageId:             strings.TrimSpace(params.PackageID),
		SignalId:              strings.TrimSpace(params.SignalID),
		CorrelationId:         strings.TrimSpace(params.CorrelationID),
		ScopeKind:             strings.TrimSpace(params.ScopeKind),
		ScopeRef:              strings.TrimSpace(params.ScopeRef),
		BlockKind:             strings.TrimSpace(params.BlockKind),
		ArtifactLinks:         changeGovernanceArtifactLinksToProto(params.ArtifactLinks),
		VerificationStateHint: strings.TrimSpace(params.VerificationStateHint),
		RequiredByTier:        params.RequiredByTier,
		OccurredAt:            timestamppb.New(params.OccurredAt.UTC()),
	}

	resp, err := c.svc.UpsertChangeGovernanceEvidenceSignal(c.withAuth(ctx), request)
	if err != nil {
		return UpsertChangeGovernanceEvidenceSignalResult{}, fmt.Errorf("upsert change governance evidence signal: %w", err)
	}
	return UpsertChangeGovernanceEvidenceSignalResult{
		PackageID:                 strings.TrimSpace(resp.GetPackageId()),
		EvidenceCompletenessState: strings.TrimSpace(resp.GetEvidenceCompletenessState()),
		VerificationMinimumState:  strings.TrimSpace(resp.GetVerificationMinimumState()),
		ProjectionVersion:         resp.GetProjectionVersion(),
	}, nil
}

// InsertRunFlowEvent persists one run-bound flow event.
func (c *Client) InsertRunFlowEvent(ctx context.Context, runID string, eventType flowevent.EventType, payload json.RawMessage) error {
	if len(payload) == 0 {
		payload = json.RawMessage(`{}`)
	}

	_, err := c.svc.InsertRunFlowEvent(c.withAuth(ctx), &controlplanev1.InsertRunFlowEventRequest{
		RunId:       strings.TrimSpace(runID),
		EventType:   strings.TrimSpace(string(eventType)),
		PayloadJson: []byte(payload),
	})
	if err != nil {
		return fmt.Errorf("insert run flow event: %w", err)
	}
	return nil
}

func (c *Client) GetCodexAuth(ctx context.Context) ([]byte, bool, error) {
	resp, err := c.svc.GetCodexAuth(c.withAuth(ctx), &controlplanev1.GetCodexAuthRequest{})
	if err != nil {
		return nil, false, fmt.Errorf("get codex auth: %w", err)
	}
	if !resp.GetFound() {
		return nil, false, nil
	}
	raw := resp.GetAuthJson()
	if len(raw) == 0 {
		return nil, false, nil
	}
	return append([]byte(nil), raw...), true, nil
}

func (c *Client) UpsertCodexAuth(ctx context.Context, authJSON []byte) error {
	_, err := c.svc.UpsertCodexAuth(c.withAuth(ctx), &controlplanev1.UpsertCodexAuthRequest{
		AuthJson: authJSON,
	})
	if err != nil {
		return fmt.Errorf("upsert codex auth: %w", err)
	}
	return nil
}

func (c *Client) UpsertRunStatusComment(ctx context.Context, params UpsertRunStatusCommentParams) error {
	_, err := c.svc.UpsertRunStatusComment(c.withAuth(ctx), &controlplanev1.UpsertRunStatusCommentRequest{
		RunId:                    strings.TrimSpace(params.RunID),
		Phase:                    strings.TrimSpace(params.Phase),
		JobName:                  optionalString(strings.TrimSpace(params.JobName)),
		JobNamespace:             optionalString(strings.TrimSpace(params.JobNamespace)),
		RuntimeMode:              optionalString(strings.TrimSpace(params.RuntimeMode)),
		Namespace:                optionalString(strings.TrimSpace(params.Namespace)),
		TriggerKind:              optionalString(strings.TrimSpace(params.TriggerKind)),
		PromptLocale:             optionalString(strings.TrimSpace(params.PromptLocale)),
		Model:                    optionalString(strings.TrimSpace(params.Model)),
		ReasoningEffort:          optionalString(strings.TrimSpace(params.ReasoningEffort)),
		RunStatus:                optionalString(strings.TrimSpace(params.RunStatus)),
		CodexAuthVerificationUrl: optionalString(strings.TrimSpace(params.CodexAuthVerificationURL)),
		CodexAuthUserCode:        optionalString(strings.TrimSpace(params.CodexAuthUserCode)),
	})
	if err != nil {
		return fmt.Errorf("upsert run status comment: %w", err)
	}
	return nil
}

func (c *Client) LookupRunPullRequest(ctx context.Context, params RunPullRequestLookupParams) (RunPullRequestLookupResult, bool, error) {
	resp, err := c.svc.LookupRunPullRequest(c.withAuth(ctx), &controlplanev1.LookupRunPullRequestRequest{
		ProjectId:          optionalString(strings.TrimSpace(params.ProjectID)),
		RepositoryFullName: strings.TrimSpace(params.RepositoryFullName),
		PrNumber:           intToOptional(intPtr(params.PRNumber)),
		HeadBranch:         optionalString(strings.TrimSpace(params.HeadBranch)),
	})
	if err != nil {
		return RunPullRequestLookupResult{}, false, fmt.Errorf("lookup run pull request: %w", err)
	}
	if !resp.GetFound() {
		return RunPullRequestLookupResult{}, false, nil
	}
	return RunPullRequestLookupResult{
		PRNumber:   int(resp.GetPrNumber()),
		PRURL:      strings.TrimSpace(resp.GetPrUrl()),
		PRState:    strings.TrimSpace(resp.GetPrState()),
		HeadBranch: strings.TrimSpace(resp.GetHeadBranch()),
		BaseBranch: strings.TrimSpace(resp.GetBaseBranch()),
	}, true, nil
}

func (c *Client) withAuth(ctx context.Context) context.Context {
	token := strings.TrimSpace(c.bearerToken)
	if token == "" {
		return ctx
	}
	return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
}

func optionalString(value string) *string {
	trimmed := strings.TrimSpace(value)
	if trimmed == "" {
		return nil
	}
	return &trimmed
}

func optionalBytes(value json.RawMessage) []byte {
	if len(value) == 0 {
		return nil
	}
	return []byte(value)
}

func getRunScopedPayload(
	ctx context.Context,
	payloadLabel string,
	load func(context.Context) (runPayloadResponse, error),
) (json.RawMessage, bool, error) {
	resp, err := load(ctx)
	if err != nil {
		return nil, false, fmt.Errorf("get %s: %w", payloadLabel, err)
	}
	if !resp.GetFound() {
		return nil, false, nil
	}
	payload := resp.GetPayloadJson()
	if len(payload) == 0 {
		return nil, false, nil
	}
	return append(json.RawMessage(nil), payload...), true, nil
}

func loadRunPayload[T any](
	ctx context.Context,
	payloadLabel string,
	load func(context.Context) (runPayloadResponse, error),
	wrap func(json.RawMessage) T,
) (T, bool, error) {
	payload, found, err := getRunScopedPayload(ctx, payloadLabel, load)
	if err != nil {
		var zero T
		return zero, false, err
	}
	return wrap(payload), found, nil
}

func (c *Client) getRunInteractionResumePayload(ctx context.Context) (runPayloadResponse, error) {
	return c.svc.GetRunInteractionResumePayload(c.withAuth(ctx), &controlplanev1.GetRunInteractionResumePayloadRequest{})
}

func (c *Client) getRunGitHubRateLimitResumePayload(ctx context.Context) (runPayloadResponse, error) {
	return c.svc.GetRunGitHubRateLimitResumePayload(c.withAuth(ctx), &controlplanev1.GetRunGitHubRateLimitResumePayloadRequest{})
}

func intToOptional(value *int) *wrapperspb.Int32Value {
	if value == nil || *value <= 0 {
		return nil
	}
	return wrapperspb.Int32(int32(*value))
}

func intPtr(value int) *int {
	if value <= 0 {
		return nil
	}
	result := value
	return &result
}

func mapChangeGovernanceProtoItems[Input any, Output any](items []Input, build func(Input) (Output, bool)) []Output {
	result := make([]Output, 0, len(items))
	for _, item := range items {
		mapped, ok := build(item)
		if !ok {
			continue
		}
		result = append(result, mapped)
	}
	return result
}

func mapChangeGovernanceProtoPairs[Input any, Output any](
	items []Input,
	firstValue func(Input) string,
	secondValue func(Input) string,
	build func(string, string) Output,
) []Output {
	return mapChangeGovernanceProtoItems(items, func(item Input) (Output, bool) {
		first := strings.TrimSpace(firstValue(item))
		second := strings.TrimSpace(secondValue(item))
		if first == "" && second == "" {
			var zero Output
			return zero, false
		}
		return build(first, second), true
	})
}

func changeGovernanceScopeHintContextKey(item ChangeGovernanceScopeHint) string {
	return item.ContextKey
}

func changeGovernanceScopeHintSurfaceKind(item ChangeGovernanceScopeHint) string {
	return item.SurfaceKind
}

func changeGovernanceVerificationTargetKind(item ChangeGovernanceVerificationTarget) string {
	return item.TargetKind
}

func changeGovernanceVerificationTargetRef(item ChangeGovernanceVerificationTarget) string {
	return item.TargetRef
}

func buildChangeGovernanceScopeHintProto(contextKey string, surfaceKind string) *controlplanev1.ChangeGovernanceScopeHint {
	return &controlplanev1.ChangeGovernanceScopeHint{ContextKey: contextKey, SurfaceKind: surfaceKind}
}

func buildChangeGovernanceVerificationTargetProto(targetKind string, targetRef string) *controlplanev1.ChangeGovernanceVerificationTarget {
	return &controlplanev1.ChangeGovernanceVerificationTarget{TargetKind: targetKind, TargetRef: targetRef}
}

func changeGovernanceWavesToProto(items []ChangeGovernanceWaveDraft) []*controlplanev1.ChangeGovernanceWaveDraft {
	result := make([]*controlplanev1.ChangeGovernanceWaveDraft, 0, len(items))
	for _, item := range items {
		result = append(result, &controlplanev1.ChangeGovernanceWaveDraft{
			WaveKey:             strings.TrimSpace(item.WaveKey),
			PublishOrder:        int32(item.PublishOrder),
			DominantIntent:      strings.TrimSpace(item.DominantIntent),
			BoundedScopeKind:    strings.TrimSpace(item.BoundedScopeKind),
			Summary:             strings.TrimSpace(item.Summary),
			VerificationTargets: mapChangeGovernanceProtoPairs(item.VerificationTargets, changeGovernanceVerificationTargetKind, changeGovernanceVerificationTargetRef, buildChangeGovernanceVerificationTargetProto),
		})
	}
	return result
}

func changeGovernanceArtifactLinksToProto(items []ChangeGovernanceArtifactLinkSeed) []*controlplanev1.ChangeGovernanceArtifactLinkSeed {
	result := make([]*controlplanev1.ChangeGovernanceArtifactLinkSeed, 0, len(items))
	for _, item := range items {
		if strings.TrimSpace(item.ArtifactRef) == "" {
			continue
		}
		result = append(result, &controlplanev1.ChangeGovernanceArtifactLinkSeed{
			ArtifactKind: strings.TrimSpace(item.ArtifactKind),
			ArtifactRef:  strings.TrimSpace(item.ArtifactRef),
			RelationKind: strings.TrimSpace(item.RelationKind),
			DisplayLabel: strings.TrimSpace(item.DisplayLabel),
		})
	}
	return result
}

func normalizeStrings(items []string) []string {
	result := make([]string, 0, len(items))
	for _, item := range items {
		value := strings.TrimSpace(item)
		if value == "" {
			continue
		}
		result = append(result, value)
	}
	return result
}

func optionalToInt(value *wrapperspb.Int32Value) int {
	if value == nil || value.Value <= 0 {
		return 0
	}
	return int(value.Value)
}

func optionalTimestamp(value *time.Time) *timestamppb.Timestamp {
	if value == nil {
		return nil
	}
	return timestamppb.New(value.UTC())
}

func githubRateLimitHeadersToProto(headers GitHubRateLimitHeaders) *controlplanev1.GitHubRateLimitHeaders {
	item := &controlplanev1.GitHubRateLimitHeaders{
		RateLimitLimit:     int32Ptr(headers.RateLimitLimit),
		RateLimitRemaining: int32Ptr(headers.RateLimitRemaining),
		RateLimitUsed:      int32Ptr(headers.RateLimitUsed),
		RateLimitResource:  optionalString(strings.TrimSpace(headers.RateLimitResource)),
		RetryAfterSeconds:  int32Ptr(headers.RetryAfterSeconds),
		GithubRequestId:    optionalString(strings.TrimSpace(headers.GitHubRequestID)),
		DocumentationUrl:   optionalString(strings.TrimSpace(headers.DocumentationURL)),
	}
	if headers.RateLimitResetAt != nil {
		item.RateLimitResetAt = timestamppb.New(headers.RateLimitResetAt.UTC())
	}
	return item
}

func int32Ptr(value *int) *int32 {
	if value == nil {
		return nil
	}
	converted := int32(*value)
	return &converted
}

func timestampOrZero(value *timestamppb.Timestamp) time.Time {
	if value == nil {
		return time.Time{}
	}
	return value.AsTime().UTC()
}
