package automation

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"sync"
	"time"

	"gopkg.in/yaml.v3"

	"github.com/tidbcloud/AgentTeam/internal/automation/blob"
	"github.com/tidbcloud/AgentTeam/internal/automation/dsl"
	"github.com/tidbcloud/AgentTeam/internal/service/automation/runtime"
	automationstore "github.com/tidbcloud/AgentTeam/internal/store/automation"
	"github.com/tidbcloud/AgentTeam/internal/store/kb"
	"github.com/tidbcloud/AgentTeam/internal/store/resource/repos"
	"gorm.io/gorm"
)

const defaultAutomationRunStatus = "queued"
const automationSourceKindAGSRepo = "agent_git_service_repo"

var allowedAutomationRunStatuses = map[string]struct{}{
	"queued": {}, "running": {}, "waiting": {}, "waiting_review": {}, "blocked": {}, "failed": {}, "succeeded": {}, "canceled": {},
}

type AutomationsService struct {
	store                 *automationstore.Store
	kbStore               *kb.Store
	repositoryStore       *repos.Store
	now                   func() time.Time
	sourceResolver        AutomationSourceResolver
	repoCredentialChecker AutomationRepositoryCredentialChecker
	limits                AutomationLimits
	authorizer            AutomationAuthorizer
	blobStore             blob.Store
	credentialsKey        []byte
	runnerSignal          func(workspaceID string)
	contentMigration      automationContentMigrationWorker
	migrationExporter     AutomationContentMigrationExporter
	migrationMappings     AutomationContentMigrationMappingStore
	migrationContent      AutomationContentMigrationVersionStore
	contentAuthority      AutomationContentAuthorityActivator
	contentCutover        AutomationContentCutoverStore
	contentCutoverMu      sync.Mutex
	reconcilerStatus      func() runtime.ReconcilerStatus
	runnerWaitMu          sync.Mutex
	runnerWaiters         map[string]map[chan struct{}]struct{}
}

func NewAutomationsService(store *automationstore.Store, now func() time.Time, sourceResolver AutomationSourceResolver) *AutomationsService {
	return &AutomationsService{store: store, now: now, sourceResolver: sourceResolver, limits: defaultAutomationLimits(), authorizer: AllowAutomationAuthorizer{}}
}

func (s *AutomationsService) SetRepositoryStores(kbStore *kb.Store) {
	if s != nil {
		s.kbStore = kbStore
	}
}

func (s *AutomationsService) SetRepositoryStore(repositoryStore *repos.Store) {
	if s != nil {
		s.repositoryStore = repositoryStore
	}
}

func (s *AutomationsService) SetLimits(limits AutomationLimits) {
	if s != nil {
		s.limits = normalizeAutomationLimits(limits)
	}
}

func (s *AutomationsService) SetAuthorizer(authorizer AutomationAuthorizer) {
	if s != nil {
		if authorizer == nil {
			authorizer = AllowAutomationAuthorizer{}
		}
		s.authorizer = authorizer
	}
}

func (s *AutomationsService) SetBlobStore(blobStore blob.Store) {
	if s != nil {
		s.blobStore = blobStore
	}
}

func (s *AutomationsService) SetRepositoryCredentialChecker(checker AutomationRepositoryCredentialChecker) {
	if s != nil {
		s.repoCredentialChecker = checker
	}
}

func (s *AutomationsService) SetSourceResolver(resolver AutomationSourceResolver) {
	if s != nil {
		s.sourceResolver = resolver
	}
}

func (s *AutomationsService) SetRunnerSignal(signal func(workspaceID string)) {
	if s != nil {
		s.runnerSignal = signal
	}
}

func (s *AutomationsService) SetReconcilerStatus(status func() runtime.ReconcilerStatus) {
	if s != nil {
		s.reconcilerStatus = status
	}
}

type AutomationRunOverview struct {
	ObservedAt     time.Time
	Run            automationstore.AutomationRun
	JobRuns        []automationstore.AutomationJobRun
	QueueJobs      []automationstore.AutomationJob
	RunnerSessions []automationstore.AutomationRunnerSession
	Waits          []automationstore.AutomationWait
	Events         []automationstore.AutomationEvent
	LogChunks      []automationstore.AutomationLogChunk
	Artifacts      []automationstore.AutomationArtifact
	BlobSummaries  []automationstore.AutomationRunBlobSummary
	Metrics        AutomationDashboardMetrics
}

type AutomationWaitTaskCompleteMutation struct {
	TaskID     string
	TaskNumber int64
	Status     string
	ResultJSON string
}

type AutomationWaitEventSignalMutation struct {
	EventType    string
	ResourceType string
	ResourceID   string
	PayloadJSON  string
	ResultJSON   string
}

// AutomationWaitSignalMutation delivers one resource-keyed AgentTeam/service event to
// an active automation wait.
type AutomationWaitSignalMutation struct {
	WaitKind     string
	EventType    string
	ResourceType string
	ResourceID   string
	TaskID       string
	TaskNumber   int64
	Status       string
	PayloadJSON  string
	ResultJSON   string
}

type AutomationRepositoryRunMutation struct {
	RepositoryOwner     string
	RepositoryName      string
	DefinitionPath      string
	GitRef              string
	Branch              string
	BeforeSHA           string
	ActorRef            string
	ActorDisplay        string
	AuthorizationHeader string
	IdempotencyKey      string
	Name                string
	RunName             string
	InputJSON           string
	TriggerKind         string
	TriggerRef          string
	TargetKind          string
	TargetRef           string
	TriggerContextJSON  string
}

type automationStartTrigger func(on any) error

var errAutomationTriggerNotMatched = errors.New("automation trigger not matched")

func manualAutomationStartTrigger(on any) error {
	if dsl.HasManualTrigger(on) {
		return nil
	}
	return invalidArgument("Automation must support manual to be started manually")
}

func (s *AutomationsService) StartRepositoryAutomationRun(ctx context.Context, workspaceID string, subject string, input AutomationRepositoryRunMutation) (automationstore.AutomationRun, error) {
	if err := s.validateScope(workspaceID, subject); err != nil {
		return automationstore.AutomationRun{}, err
	}
	input = normalizeRepositoryRunInput(input)
	limits := s.automationLimits()
	if err := validateRepositoryRunInput(input, limits); err != nil {
		return automationstore.AutomationRun{}, err
	}
	if err := validateAutomationRepository(input.RepositoryOwner, input.RepositoryName); err != nil {
		return automationstore.AutomationRun{}, err
	}
	if err := s.automationAuthorizer().CanStartAutomationRun(ctx, s.automationAuthorizationRequest(ctx, workspaceID, subject, AutomationAuthorizationRequest{RepositoryOwner: input.RepositoryOwner, RepositoryName: input.RepositoryName})); err != nil {
		return automationstore.AutomationRun{}, err
	}
	if input.IdempotencyKey != "" {
		existing, err := s.store.GetRunByIdempotencyKey(ctx, workspaceID, input.IdempotencyKey)
		if err == nil {
			if err := validateRepositoryRunIdempotencyReplay(existing, workspaceID, subject, input); err != nil {
				return automationstore.AutomationRun{}, err
			}
			s.signalRunnerWorkspace(existing.WorkspaceID)
			return existing, nil
		}
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return automationstore.AutomationRun{}, fmt.Errorf("get idempotent automation run: %w", err)
		}
	}
	evaluated, err := s.evaluateRepositoryAutomation(ctx, workspaceID, subject, AutomationRepositoryAutomationPreflightMutation{
		RepositoryOwner:     input.RepositoryOwner,
		RepositoryName:      input.RepositoryName,
		DefinitionPath:      input.DefinitionPath,
		GitRef:              input.GitRef,
		Scenario:            repositoryRunScenario(input),
		InputJSON:           input.InputJSON,
		Branch:              input.Branch,
		AuthorizationHeader: input.AuthorizationHeader,
	})
	if err != nil {
		return automationstore.AutomationRun{}, err
	}
	startEligibility := automationStartEligibilityReport(evaluated.report)
	if !startEligibility.Runnable {
		automationErr := automationStartRejection(startEligibility)
		if input.TriggerKind == "push" && automationPushRejectionCreatesFailedRun(startEligibility) {
			return s.createRejectedPushRun(ctx, workspaceID, subject, input, evaluated.snapshot, evaluated.spec, automationErr)
		}
		return automationstore.AutomationRun{}, automationErr
	}
	if err := s.validateAutomationSecretReferences(ctx, workspaceID, subject, evaluated.snapshot.RepositoryOwner, evaluated.snapshot.RepositoryName, evaluated.spec); err != nil {
		return automationstore.AutomationRun{}, err
	}
	runID, err := newAutomationRunID()
	if err != nil {
		return automationstore.AutomationRun{}, fmt.Errorf("generate automation run id: %w", err)
	}
	now := s.now()
	started := now
	name := input.Name
	if name == "" {
		name = evaluated.spec.Name
	}
	runName := input.RunName
	if runName == "" {
		runName = evaluated.spec.RunName
	}
	repoFullName := evaluated.snapshot.RepositoryOwner + "/" + evaluated.snapshot.RepositoryName
	sourceKind, definitionID := automationSnapshotIdentity(evaluated.snapshot)
	run := automationstore.AutomationRun{ID: runID, WorkspaceID: strings.TrimSpace(workspaceID), IdempotencyKey: optionalStringPtr(input.IdempotencyKey), SourceKind: sourceKind, ContentID: evaluated.snapshot.ContentID, ContentVersionID: evaluated.snapshot.ContentVersionID, RepositoryOwner: evaluated.snapshot.RepositoryOwner, RepositoryName: evaluated.snapshot.RepositoryName, RepositoryFullName: repoFullName, DefinitionPath: evaluated.snapshot.DefinitionPath, GitRef: evaluated.snapshot.GitRef, CommitSHA: evaluated.snapshot.CommitSHA, DefinitionID: definitionID, Name: name, RunName: runName, Status: automationstore.AutomationRunStatusRunning, InputJSON: input.InputJSON, TriggerKind: input.TriggerKind, TriggerRef: input.TriggerRef, TargetKind: input.TargetKind, TargetRef: input.TargetRef, OwnerPrincipalID: strings.TrimSpace(subject), CreatedByPrincipalID: strings.TrimSpace(subject), Metadata: repositoryRunMetadata(input, evaluated.snapshot), StartedAt: &started, CreatedAt: now, UpdatedAt: now}
	rt, err := runtime.BuildInitialRuntime(run, evaluated.spec, now, newAutomationRuntimeID)
	if err != nil {
		if input.TriggerKind == "push" {
			return s.createRejectedPushRun(ctx, workspaceID, subject, input, evaluated.snapshot, evaluated.spec, err)
		}
		return automationstore.AutomationRun{}, err
	}
	if len(rt.Waits) > 0 && len(rt.Jobs) == 0 {
		run.Status = automationstore.AutomationRunStatusWaiting
	}
	created, err := s.store.CreateRunWithRuntimeWithCapacity(ctx, limits.MaxActiveRunsPerWorkspace, run, rt.JobRuns, rt.Events, rt.Jobs, rt.Waits)
	if errors.Is(err, automationstore.ErrActiveRunCapacity) {
		if input.TriggerKind == "push" {
			return s.createRejectedPushRun(ctx, workspaceID, subject, input, evaluated.snapshot, evaluated.spec, invalidArgument("Automation active run limit reached"))
		}
		return automationstore.AutomationRun{}, conflict("Automation active run limit reached")
	}
	if err == nil && input.IdempotencyKey != "" && created.ID != run.ID {
		if replayErr := validateRepositoryRunIdempotencyReplay(created, workspaceID, subject, input); replayErr != nil {
			return automationstore.AutomationRun{}, replayErr
		}
	}
	if err == nil {
		s.signalRunnerWorkspace(created.WorkspaceID)
	}
	return created, err
}

func (s *AutomationsService) startRepositoryAutomationRunFromSnapshot(ctx context.Context, workspaceID string, subject string, input AutomationRepositoryRunMutation, trigger automationStartTrigger) (automationstore.AutomationRun, error) {
	input = normalizeRepositoryRunInput(input)
	limits := s.automationLimits()
	if err := validateRepositoryRunInput(input, limits); err != nil {
		return automationstore.AutomationRun{}, err
	}
	if input.IdempotencyKey != "" {
		existing, err := s.store.GetRunByIdempotencyKey(ctx, workspaceID, input.IdempotencyKey)
		if err == nil {
			if err := validateRepositoryRunIdempotencyReplay(existing, workspaceID, subject, input); err != nil {
				return automationstore.AutomationRun{}, err
			}
			s.signalRunnerWorkspace(existing.WorkspaceID)
			return existing, nil
		}
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return automationstore.AutomationRun{}, fmt.Errorf("get idempotent automation run: %w", err)
		}
	}
	evaluated, err := s.evaluateRepositoryAutomation(ctx, workspaceID, subject, AutomationRepositoryAutomationPreflightMutation{
		RepositoryOwner:     input.RepositoryOwner,
		RepositoryName:      input.RepositoryName,
		DefinitionPath:      input.DefinitionPath,
		GitRef:              input.GitRef,
		Scenario:            repositoryRunScenario(input),
		InputJSON:           input.InputJSON,
		Branch:              input.Branch,
		AuthorizationHeader: input.AuthorizationHeader,
	})
	if err != nil {
		return automationstore.AutomationRun{}, err
	}
	if trigger != nil {
		if err := trigger(evaluated.spec.On); err != nil {
			return automationstore.AutomationRun{}, err
		}
	}
	startEligibility := automationStartEligibilityReport(evaluated.report)
	if !startEligibility.Runnable {
		automationErr := automationStartRejection(startEligibility)
		if input.TriggerKind == "push" && automationPushRejectionCreatesFailedRun(startEligibility) {
			return s.createRejectedPushRun(ctx, workspaceID, subject, input, evaluated.snapshot, evaluated.spec, automationErr)
		}
		return automationstore.AutomationRun{}, automationErr
	}
	if err := s.validateAutomationSecretReferences(ctx, workspaceID, subject, evaluated.snapshot.RepositoryOwner, evaluated.snapshot.RepositoryName, evaluated.spec); err != nil {
		if input.TriggerKind == "push" {
			return s.createRejectedPushRun(ctx, workspaceID, subject, input, evaluated.snapshot, evaluated.spec, err)
		}
		return automationstore.AutomationRun{}, err
	}
	runID, err := newAutomationRunID()
	if err != nil {
		return automationstore.AutomationRun{}, fmt.Errorf("generate automation run id: %w", err)
	}
	now := s.now()
	started := now
	name := input.Name
	if name == "" {
		name = evaluated.spec.Name
	}
	runName := input.RunName
	if runName == "" {
		runName = evaluated.spec.RunName
	}
	repoFullName := evaluated.snapshot.RepositoryOwner + "/" + evaluated.snapshot.RepositoryName
	sourceKind, definitionID := automationSnapshotIdentity(evaluated.snapshot)
	run := automationstore.AutomationRun{ID: runID, WorkspaceID: strings.TrimSpace(workspaceID), IdempotencyKey: optionalStringPtr(input.IdempotencyKey), SourceKind: sourceKind, ContentID: evaluated.snapshot.ContentID, ContentVersionID: evaluated.snapshot.ContentVersionID, RepositoryOwner: evaluated.snapshot.RepositoryOwner, RepositoryName: evaluated.snapshot.RepositoryName, RepositoryFullName: repoFullName, DefinitionPath: evaluated.snapshot.DefinitionPath, GitRef: evaluated.snapshot.GitRef, CommitSHA: evaluated.snapshot.CommitSHA, DefinitionID: definitionID, Name: name, RunName: runName, Status: automationstore.AutomationRunStatusRunning, InputJSON: input.InputJSON, TriggerKind: input.TriggerKind, TriggerRef: input.TriggerRef, TargetKind: input.TargetKind, TargetRef: input.TargetRef, OwnerPrincipalID: strings.TrimSpace(subject), CreatedByPrincipalID: strings.TrimSpace(subject), Metadata: repositoryRunMetadata(input, evaluated.snapshot), StartedAt: &started, CreatedAt: now, UpdatedAt: now}
	rt, err := runtime.BuildInitialRuntime(run, evaluated.spec, now, newAutomationRuntimeID)
	if err != nil {
		if input.TriggerKind == "push" {
			return s.createRejectedPushRun(ctx, workspaceID, subject, input, evaluated.snapshot, evaluated.spec, err)
		}
		return automationstore.AutomationRun{}, err
	}
	if len(rt.Waits) > 0 && len(rt.Jobs) == 0 {
		run.Status = automationstore.AutomationRunStatusWaiting
	}
	created, err := s.store.CreateRunWithRuntimeWithCapacity(ctx, limits.MaxActiveRunsPerWorkspace, run, rt.JobRuns, rt.Events, rt.Jobs, rt.Waits)
	if errors.Is(err, automationstore.ErrActiveRunCapacity) {
		if input.TriggerKind == "push" {
			return s.createRejectedPushRun(ctx, workspaceID, subject, input, evaluated.snapshot, evaluated.spec, invalidArgument("Automation active run limit reached"))
		}
		return automationstore.AutomationRun{}, conflict("Automation active run limit reached")
	}
	if err == nil && input.IdempotencyKey != "" && created.ID != run.ID {
		if replayErr := validateRepositoryRunIdempotencyReplay(created, workspaceID, subject, input); replayErr != nil {
			return automationstore.AutomationRun{}, replayErr
		}
	}
	if err == nil {
		s.signalRunnerWorkspace(created.WorkspaceID)
	}
	return created, err
}

func (s *AutomationsService) signalRunnerWorkspace(workspaceID string) {
	if s == nil {
		return
	}
	workspaceID = strings.TrimSpace(workspaceID)
	if workspaceID == "" {
		return
	}
	s.runnerWaitMu.Lock()
	if s.runnerWaiters != nil {
		waiters := s.runnerWaiters[workspaceID]
		delete(s.runnerWaiters, workspaceID)
		for wait := range waiters {
			close(wait)
		}
	}
	s.runnerWaitMu.Unlock()
	if s.runnerSignal != nil {
		s.runnerSignal(workspaceID)
	}
}

func (s *AutomationsService) ListAutomationRuns(ctx context.Context, workspaceID string, subject string, status string, repositoryFullName string, query string, page int, perPage int) (automationstore.ListRunsResult, error) {
	if err := s.validateScope(workspaceID, subject); err != nil {
		return automationstore.ListRunsResult{}, err
	}
	if err := s.automationAuthorizer().CanReadRun(ctx, s.automationAuthorizationRequest(ctx, workspaceID, subject, AutomationAuthorizationRequest{})); err != nil {
		return automationstore.ListRunsResult{}, err
	}
	status = strings.TrimSpace(status)
	if status != "" {
		if err := validateAutomationRunStatus(status); err != nil {
			return automationstore.ListRunsResult{}, err
		}
	}
	page, perPage = normalizeAutomationPageWithMax(page, perPage, 100)
	return s.store.ListRuns(ctx, strings.TrimSpace(workspaceID), status, strings.TrimSpace(repositoryFullName), strings.TrimSpace(query), page, perPage)
}

func (s *AutomationsService) GetAutomationRun(ctx context.Context, workspaceID string, subject string, runID string) (automationstore.AutomationRun, error) {
	if err := s.validateScope(workspaceID, subject); err != nil {
		return automationstore.AutomationRun{}, err
	}
	if err := s.automationAuthorizer().CanReadRun(ctx, s.automationAuthorizationRequest(ctx, workspaceID, subject, AutomationAuthorizationRequest{})); err != nil {
		return automationstore.AutomationRun{}, err
	}
	run, err := s.store.GetRun(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(runID))
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return automationstore.AutomationRun{}, notFound("Automation run not found")
	}
	if err != nil {
		return automationstore.AutomationRun{}, err
	}
	if err := s.automationAuthorizer().CanReadRun(ctx, s.automationAuthorizationRequest(ctx, workspaceID, subject, AutomationAuthorizationRequest{Run: run})); err != nil {
		return automationstore.AutomationRun{}, err
	}
	return run, err
}

func (s *AutomationsService) RetryAutomationRun(ctx context.Context, workspaceID string, subject string, runID string, authorizationHeader string) (automationstore.AutomationRun, error) {
	previous, err := s.GetAutomationRun(ctx, workspaceID, subject, runID)
	if err != nil {
		return automationstore.AutomationRun{}, err
	}
	if previous.SourceKind != "" && previous.SourceKind != automationSourceKindAGSRepo {
		return automationstore.AutomationRun{}, invalidArgument("Automation run source cannot be retried")
	}
	if !isAutomationRunTerminalStatus(previous.Status) {
		return automationstore.AutomationRun{}, invalidArgument("Automation run can only be retried after it reaches a terminal status")
	}
	gitRef := strings.TrimSpace(previous.CommitSHA)
	if gitRef == "" {
		return automationstore.AutomationRun{}, invalidArgument("Automation run commit SHA is required for retry")
	}
	return s.StartRepositoryAutomationRun(ctx, workspaceID, subject, AutomationRepositoryRunMutation{
		RepositoryOwner:     previous.RepositoryOwner,
		RepositoryName:      previous.RepositoryName,
		DefinitionPath:      previous.DefinitionPath,
		GitRef:              gitRef,
		AuthorizationHeader: strings.TrimSpace(authorizationHeader),
		Name:                previous.Name,
		RunName:             previous.RunName,
		InputJSON:           previous.InputJSON,
		TriggerKind:         "manual_retry",
		TriggerRef:          previous.ID,
		TargetKind:          previous.TargetKind,
		TargetRef:           previous.TargetRef,
	})
}

func (s *AutomationsService) ListAutomationJobRuns(ctx context.Context, workspaceID string, subject string, runID string) ([]automationstore.AutomationJobRun, error) {
	if _, err := s.GetAutomationRun(ctx, workspaceID, subject, runID); err != nil {
		return nil, err
	}
	return s.store.ListJobRuns(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(runID))
}

func (s *AutomationsService) ListAutomationEvents(ctx context.Context, workspaceID string, subject string, runID string, page int, perPage int) ([]automationstore.AutomationEvent, error) {
	if _, err := s.GetAutomationRun(ctx, workspaceID, subject, runID); err != nil {
		return nil, err
	}
	page, perPage = normalizeAutomationPageWithMax(page, perPage, 100)
	return s.store.ListEvents(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(runID), page, perPage)
}

func (s *AutomationsService) ListAutomationWaits(ctx context.Context, workspaceID string, subject string, runID string, status string) ([]automationstore.AutomationWait, error) {
	if _, err := s.GetAutomationRun(ctx, workspaceID, subject, runID); err != nil {
		return nil, err
	}
	status = strings.TrimSpace(status)
	if status != "" && status != automationstore.AutomationWaitStatusActive && status != automationstore.AutomationWaitStatusResolved && status != automationstore.AutomationWaitStatusCanceled {
		return nil, invalidArgument("Automation wait status is invalid")
	}
	return s.store.ListWaits(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(runID), status)
}

func (s *AutomationsService) ResumeAutomationWait(ctx context.Context, workspaceID string, subject string, runID string, waitID string, resultJSON string) (automationstore.AutomationWait, error) {
	return s.resumeAutomationWait(ctx, workspaceID, subject, runID, waitID, "", resultJSON)
}

func (s *AutomationsService) CompleteAutomationTaskWait(ctx context.Context, workspaceID string, subject string, runID string, waitID string, input AutomationWaitTaskCompleteMutation) (automationstore.AutomationWait, error) {
	resultJSON, err := automationWaitTaskResultJSON(input)
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	return s.resumeAutomationWait(ctx, workspaceID, subject, runID, waitID, automationstore.AutomationWaitKindAgentTask, resultJSON)
}

func (s *AutomationsService) SignalAutomationEventWait(ctx context.Context, workspaceID string, subject string, runID string, waitID string, input AutomationWaitEventSignalMutation) (automationstore.AutomationWait, error) {
	resultJSON, err := automationWaitEventResultJSON(input)
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	wait, err := s.lookupAutomationWaitForResume(ctx, workspaceID, subject, runID, waitID)
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	if err := requireMatchingAutomationWaitSignal(input.EventType, wait.ResumeEventType, "event_type", "resume_event_type"); err != nil {
		return automationstore.AutomationWait{}, err
	}
	if err := requireMatchingAutomationWaitSignal(input.ResourceType, wait.ResourceType, "resource_type", "resource_type"); err != nil {
		return automationstore.AutomationWait{}, err
	}
	if err := requireMatchingAutomationWaitSignal(input.ResourceID, wait.ResourceID, "resource_id", "resource_id"); err != nil {
		return automationstore.AutomationWait{}, err
	}
	return s.resumeLoadedAutomationWait(ctx, workspaceID, subject, wait, automationstore.AutomationWaitKindWaitEvent, resultJSON)
}

// DeliverAutomationWaitSignal resolves waits by their resource/event identity.
// Resolved waits replay scheduling only while their run still has scheduler
// work to recover after a prior post-resolution failure.
func (s *AutomationsService) DeliverAutomationWaitSignal(ctx context.Context, workspaceID string, subject string, input AutomationWaitSignalMutation) (automationstore.AutomationWait, error) {
	signal, resultJSON, err := automationWaitDeliverySignal(input)
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	if err := s.validateAutomationWaitSignalResumeScope(ctx, workspaceID, subject); err != nil {
		return automationstore.AutomationWait{}, err
	}
	eventer := runtime.NewEventer(s.store, s.now)
	processedWaitIDs := map[string]struct{}{}
	firstResolved, resolvedAny, err := s.resumeAutomationWaitSignalMatches(ctx, workspaceID, subject, signal, resultJSON, eventer, true, processedWaitIDs)
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	replayFirstResolved, replayResolvedAny, err := s.resumeAutomationWaitSignalMatches(ctx, workspaceID, subject, signal, resultJSON, eventer, false, processedWaitIDs)
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	if !resolvedAny && replayResolvedAny {
		firstResolved = replayFirstResolved
		resolvedAny = true
	}
	if !resolvedAny {
		return s.lookupAutomationWaitSignalReceipt(ctx, workspaceID, subject, signal)
	}
	s.signalRunnerWorkspace(workspaceID)
	return firstResolved, nil
}

func (s *AutomationsService) resumeAutomationWait(ctx context.Context, workspaceID string, subject string, runID string, waitID string, expectedKind string, resultJSON string) (automationstore.AutomationWait, error) {
	wait, err := s.lookupAutomationWaitForResume(ctx, workspaceID, subject, runID, waitID)
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	return s.resumeLoadedAutomationWait(ctx, workspaceID, subject, wait, expectedKind, resultJSON)
}

func (s *AutomationsService) resumeLoadedAutomationWait(ctx context.Context, workspaceID string, subject string, wait automationstore.AutomationWait, expectedKind string, resultJSON string) (automationstore.AutomationWait, error) {
	if strings.TrimSpace(expectedKind) != "" && wait.WaitKind != strings.TrimSpace(expectedKind) {
		return automationstore.AutomationWait{}, invalidArgument("Automation wait kind does not match resume endpoint")
	}
	if err := validateAutomationWaitResultJSON(resultJSON); err != nil {
		return automationstore.AutomationWait{}, err
	}
	eventer := runtime.NewEventer(s.store, s.now)
	resolved, err := eventer.ResumeWait(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(wait.ID), strings.TrimSpace(subject), resultJSON)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return automationstore.AutomationWait{}, notFound("Automation wait not found")
		}
		return automationstore.AutomationWait{}, err
	}
	s.signalRunnerWorkspace(resolved.WorkspaceID)
	return resolved, nil
}

func (s *AutomationsService) lookupAutomationWaitForResume(ctx context.Context, workspaceID string, subject string, runID string, waitID string) (automationstore.AutomationWait, error) {
	if err := s.validateScope(workspaceID, subject); err != nil {
		return automationstore.AutomationWait{}, err
	}
	if err := s.automationAuthorizer().CanResumeWait(ctx, s.automationAuthorizationRequest(ctx, workspaceID, subject, AutomationAuthorizationRequest{})); err != nil {
		return automationstore.AutomationWait{}, err
	}
	run, wait, err := s.lookupAutomationWaitRecord(ctx, workspaceID, runID, waitID)
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	if err := s.automationAuthorizer().CanResumeWait(ctx, s.automationAuthorizationRequest(ctx, workspaceID, subject, AutomationAuthorizationRequest{Run: run, Wait: wait})); err != nil {
		return automationstore.AutomationWait{}, err
	}
	return wait, nil
}

func (s *AutomationsService) lookupAutomationWaitSignalReceipt(ctx context.Context, workspaceID string, subject string, signal automationstore.AutomationWaitSignal) (automationstore.AutomationWait, error) {
	wait, err := s.store.GetWaitBySignal(ctx, strings.TrimSpace(workspaceID), signal)
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return automationstore.AutomationWait{}, notFound("Automation wait signal not found")
	}
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	if wait.Status != automationstore.AutomationWaitStatusResolved {
		return automationstore.AutomationWait{}, notFound("Automation wait signal not found")
	}
	run, err := s.store.GetRun(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(wait.RunID))
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return automationstore.AutomationWait{}, notFound("Automation run not found")
	}
	if err != nil {
		return automationstore.AutomationWait{}, err
	}
	if err := s.automationAuthorizer().CanResumeWait(ctx, s.automationAuthorizationRequest(ctx, workspaceID, subject, AutomationAuthorizationRequest{Run: run, Wait: wait})); err != nil {
		return automationstore.AutomationWait{}, err
	}
	return wait, nil
}

func (s *AutomationsService) lookupAutomationWaitRecord(ctx context.Context, workspaceID string, runID string, waitID string) (automationstore.AutomationRun, automationstore.AutomationWait, error) {
	run, err := s.store.GetRun(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(runID))
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return automationstore.AutomationRun{}, automationstore.AutomationWait{}, notFound("Automation run not found")
	}
	if err != nil {
		return automationstore.AutomationRun{}, automationstore.AutomationWait{}, err
	}
	if strings.TrimSpace(waitID) == "" {
		return automationstore.AutomationRun{}, automationstore.AutomationWait{}, invalidArgument("Automation wait id is required")
	}
	wait, err := s.store.GetWait(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(waitID))
	if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && wait.RunID != strings.TrimSpace(runID)) {
		return automationstore.AutomationRun{}, automationstore.AutomationWait{}, notFound("Automation wait not found")
	}
	if err != nil {
		return automationstore.AutomationRun{}, automationstore.AutomationWait{}, err
	}
	return run, wait, nil
}

func validateAutomationWaitResultJSON(resultJSON string) error {
	return validateOptionalJSON(resultJSON, "Automation wait result_json must be valid JSON")
}

func automationWaitTaskResultJSON(input AutomationWaitTaskCompleteMutation) (string, error) {
	if strings.TrimSpace(input.ResultJSON) != "" {
		return input.ResultJSON, validateAutomationWaitResultJSON(input.ResultJSON)
	}
	status := strings.TrimSpace(input.Status)
	if status == "" {
		status = "done"
	}
	return automationWaitMarshalResult(map[string]any{"task_id": strings.TrimSpace(input.TaskID), "task_number": input.TaskNumber, "status": status})
}

func requireMatchingAutomationWaitSignal(requestValue string, waitValue string, requestField string, waitField string) error {
	expected := strings.TrimSpace(waitValue)
	if expected == "" {
		return nil
	}
	actual := strings.TrimSpace(requestValue)
	if actual == "" {
		return invalidArgument("Automation wait " + requestField + " is required when " + waitField + " is set")
	}
	if actual != expected {
		return invalidArgument("Automation wait " + requestField + " does not match " + waitField)
	}
	return nil
}

func automationWaitEventResultJSON(input AutomationWaitEventSignalMutation) (string, error) {
	if strings.TrimSpace(input.ResultJSON) != "" {
		return input.ResultJSON, validateAutomationWaitResultJSON(input.ResultJSON)
	}
	payload := strings.TrimSpace(input.PayloadJSON)
	if payload != "" {
		if err := validateAutomationWaitResultJSON(payload); err != nil {
			return "", err
		}
	}
	return automationWaitMarshalResult(map[string]any{"event_type": strings.TrimSpace(input.EventType), "resource_type": strings.TrimSpace(input.ResourceType), "resource_id": strings.TrimSpace(input.ResourceID), "payload_json": payload})
}

func automationWaitDeliverySignal(input AutomationWaitSignalMutation) (automationstore.AutomationWaitSignal, string, error) {
	input.WaitKind = strings.TrimSpace(input.WaitKind)
	input.EventType = strings.TrimSpace(input.EventType)
	input.ResourceType = strings.TrimSpace(input.ResourceType)
	input.ResourceID = strings.TrimSpace(input.ResourceID)
	input.TaskID = strings.TrimSpace(input.TaskID)
	input.Status = strings.TrimSpace(input.Status)
	input.PayloadJSON = strings.TrimSpace(input.PayloadJSON)
	input.ResultJSON = strings.TrimSpace(input.ResultJSON)
	switch input.WaitKind {
	case automationstore.AutomationWaitKindAgentTask:
		if input.EventType == "" {
			input.EventType = "agent_task_completed"
		}
		if input.ResourceType == "" {
			input.ResourceType = "agent_task"
		}
		if input.ResourceID == "" {
			input.ResourceID = input.TaskID
		}
		if input.TaskID == "" {
			input.TaskID = input.ResourceID
		}
		if input.TaskID != "" && input.ResourceID != "" && input.TaskID != input.ResourceID {
			return automationstore.AutomationWaitSignal{}, "", invalidArgument("Automation wait task_id does not match resource_id")
		}
		switch input.Status {
		case "":
			input.Status = "done"
		case "completed":
			input.Status = "done"
		case "done":
		default:
			return automationstore.AutomationWaitSignal{}, "", invalidArgument("Automation wait task status must be done")
		}
	case automationstore.AutomationWaitKindWaitEvent:
	default:
		return automationstore.AutomationWaitSignal{}, "", invalidArgument("Automation wait kind is invalid")
	}
	if input.EventType == "" {
		return automationstore.AutomationWaitSignal{}, "", invalidArgument("Automation wait event_type is required")
	}
	if input.ResourceType == "" {
		return automationstore.AutomationWaitSignal{}, "", invalidArgument("Automation wait resource_type is required")
	}
	if input.ResourceID == "" {
		return automationstore.AutomationWaitSignal{}, "", invalidArgument("Automation wait resource_id is required")
	}
	var resultJSON string
	var err error
	switch input.WaitKind {
	case automationstore.AutomationWaitKindAgentTask:
		resultJSON, err = automationWaitTaskResultJSON(AutomationWaitTaskCompleteMutation{TaskID: input.TaskID, TaskNumber: input.TaskNumber, Status: input.Status, ResultJSON: input.ResultJSON})
	case automationstore.AutomationWaitKindWaitEvent:
		resultJSON, err = automationWaitEventResultJSON(AutomationWaitEventSignalMutation{EventType: input.EventType, ResourceType: input.ResourceType, ResourceID: input.ResourceID, PayloadJSON: input.PayloadJSON, ResultJSON: input.ResultJSON})
	}
	if err != nil {
		return automationstore.AutomationWaitSignal{}, "", err
	}
	return automationstore.AutomationWaitSignal{
		WaitKind:     input.WaitKind,
		EventType:    input.EventType,
		ResourceType: input.ResourceType,
		ResourceID:   input.ResourceID,
	}, resultJSON, nil
}

func automationWaitMarshalResult(payload map[string]any) (string, error) {
	encoded, err := json.Marshal(payload)
	if err != nil {
		return "", err
	}
	return string(encoded), nil
}

func (s *AutomationsService) PinAutomationRepo(ctx context.Context, workspaceID string, subject string, owner string, repo string) (automationstore.AutomationPinnedRepo, error) {
	if err := s.validateScope(workspaceID, subject); err != nil {
		return automationstore.AutomationPinnedRepo{}, err
	}
	owner = strings.TrimSpace(owner)
	repo = strings.TrimSpace(repo)
	if owner == "" || repo == "" {
		return automationstore.AutomationPinnedRepo{}, invalidArgument("Owner and repo are required")
	}
	if err := s.bindAutomationRepository(ctx, workspaceID, subject, owner, repo, ""); err != nil {
		return automationstore.AutomationPinnedRepo{}, err
	}
	now := s.now()
	pin := automationstore.AutomationPinnedRepo{
		WorkspaceID: strings.TrimSpace(workspaceID),
		PrincipalID: strings.TrimSpace(subject),
		Owner:       owner,
		Repo:        repo,
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	return s.store.UpsertPinnedRepo(ctx, pin)
}

func (s *AutomationsService) UnpinAutomationRepo(ctx context.Context, workspaceID string, subject string, owner string, repo string) error {
	if err := s.validateScope(workspaceID, subject); err != nil {
		return err
	}
	err := s.store.DeletePinnedRepo(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(subject), strings.TrimSpace(owner), strings.TrimSpace(repo))
	if err != nil {
		return notFound("Pinned automation repo not found")
	}
	return nil
}

func (s *AutomationsService) ListPinnedAutomationRepos(ctx context.Context, workspaceID string, subject string, page int, perPage int) ([]automationstore.AutomationPinnedRepo, error) {
	if err := s.validateScope(workspaceID, subject); err != nil {
		return nil, err
	}
	return s.store.ListPinnedRepos(ctx, strings.TrimSpace(workspaceID), strings.TrimSpace(subject), page, perPage)
}

func (s *AutomationsService) validateScope(workspaceID string, subject string) error {
	if err := s.ensureStore(); err != nil {
		return err
	}
	if strings.TrimSpace(workspaceID) == "" {
		return invalidArgument("Workspace id is required")
	}
	if strings.TrimSpace(subject) == "" {
		return unauthorized("Missing authenticated identity")
	}
	return nil
}

func (s *AutomationsService) ensureStore() error {
	if s == nil || s.store == nil {
		return fmt.Errorf("automations service store is nil")
	}
	return nil
}

func (s *AutomationsService) automationLimits() AutomationLimits {
	if s == nil {
		return defaultAutomationLimits()
	}
	return normalizeAutomationLimits(s.limits)
}

func (s *AutomationsService) automationAuthorizer() AutomationAuthorizer {
	if s == nil || s.authorizer == nil {
		return AllowAutomationAuthorizer{}
	}
	return s.authorizer
}

func (s *AutomationsService) automationAuthorizationRequest(ctx context.Context, workspaceID string, subject string, request AutomationAuthorizationRequest) AutomationAuthorizationRequest {
	request.Actor = automationAuthorizationActor(ctx, subject)
	request.WorkspaceID = strings.TrimSpace(workspaceID)
	request.RepositoryOwner = strings.TrimSpace(request.RepositoryOwner)
	request.RepositoryName = strings.TrimSpace(request.RepositoryName)
	request.RunnerID = strings.TrimSpace(request.RunnerID)
	request.JobRunID = strings.TrimSpace(request.JobRunID)
	request.LogStream = strings.TrimSpace(request.LogStream)
	request.ArtifactID = strings.TrimSpace(request.ArtifactID)
	return request
}

func normalizeRepositoryRunInput(input AutomationRepositoryRunMutation) AutomationRepositoryRunMutation {
	input.RepositoryOwner = strings.TrimSpace(input.RepositoryOwner)
	input.RepositoryName = strings.TrimSpace(input.RepositoryName)
	input.DefinitionPath = normalizeAutomationSourcePath(input.DefinitionPath)
	input.GitRef = strings.TrimSpace(input.GitRef)
	input.Branch = strings.TrimSpace(input.Branch)
	input.BeforeSHA = strings.TrimSpace(input.BeforeSHA)
	input.ActorRef = strings.TrimSpace(input.ActorRef)
	input.ActorDisplay = strings.TrimSpace(input.ActorDisplay)
	input.AuthorizationHeader = strings.TrimSpace(input.AuthorizationHeader)
	input.IdempotencyKey = strings.TrimSpace(input.IdempotencyKey)
	input.Name = strings.TrimSpace(input.Name)
	input.RunName = strings.TrimSpace(input.RunName)
	input.InputJSON = strings.TrimSpace(input.InputJSON)
	input.TriggerKind = strings.TrimSpace(input.TriggerKind)
	input.TriggerRef = strings.TrimSpace(input.TriggerRef)
	input.TargetKind = strings.TrimSpace(input.TargetKind)
	input.TargetRef = strings.TrimSpace(input.TargetRef)
	return input
}

func validateRepositoryRunInput(input AutomationRepositoryRunMutation, limits AutomationLimits) error {
	if input.RepositoryOwner == "" || input.RepositoryName == "" {
		return invalidArgument("Automation repository owner and name are required")
	}
	if !isAutomationSourcePath(input.DefinitionPath) {
		return invalidArgument("Automation definition path must be under .agentteam and end in .yml or .yaml")
	}
	if input.GitRef == "" {
		return invalidArgument("Automation git ref is required")
	}
	if len([]byte(input.InputJSON)) > normalizeAutomationLimits(limits).MaxRunInputBytes {
		return invalidArgument("Automation run input_json exceeds maximum size")
	}
	if len([]byte(input.IdempotencyKey)) > 255 {
		return invalidArgument("Automation run idempotency_key exceeds maximum size")
	}
	if err := validateOptionalJSON(input.InputJSON, "Automation run input_json must be valid JSON"); err != nil {
		return err
	}
	if (input.TriggerKind == "") != (input.TriggerRef == "") {
		return invalidArgument("Automation run trigger kind and ref must be provided together")
	}
	if (input.TargetKind == "") != (input.TargetRef == "") {
		return invalidArgument("Automation run target kind and ref must be provided together")
	}
	if input.TriggerKind == "push" && input.Branch == "" {
		return invalidArgument("Automation push trigger branch is required")
	}
	return nil
}

func validateRepositoryRunIdempotencyReplay(existing automationstore.AutomationRun, workspaceID string, subject string, input AutomationRepositoryRunMutation) error {
	existingTriggerContext := ParseAutomationRunTriggerContext(existing.Metadata)
	existingBranch := ""
	if existingTriggerContext != nil {
		existingBranch = strings.TrimSpace(existingTriggerContext.Branch)
	}
	compareTriggerRef := strings.TrimSpace(input.TriggerKind) != "push"
	if strings.TrimSpace(existing.WorkspaceID) != strings.TrimSpace(workspaceID) ||
		strings.TrimSpace(existing.CreatedByPrincipalID) != strings.TrimSpace(subject) ||
		strings.TrimSpace(existing.OwnerPrincipalID) != strings.TrimSpace(subject) ||
		strings.TrimSpace(existing.RepositoryOwner) != strings.TrimSpace(input.RepositoryOwner) ||
		strings.TrimSpace(existing.RepositoryName) != strings.TrimSpace(input.RepositoryName) ||
		strings.TrimSpace(existing.DefinitionPath) != strings.TrimSpace(input.DefinitionPath) ||
		strings.TrimSpace(existing.GitRef) != strings.TrimSpace(input.GitRef) ||
		strings.TrimSpace(existing.InputJSON) != strings.TrimSpace(input.InputJSON) ||
		strings.TrimSpace(existing.TriggerKind) != strings.TrimSpace(input.TriggerKind) ||
		(compareTriggerRef && strings.TrimSpace(existing.TriggerRef) != strings.TrimSpace(input.TriggerRef)) ||
		strings.TrimSpace(existing.TargetKind) != strings.TrimSpace(input.TargetKind) ||
		strings.TrimSpace(existing.TargetRef) != strings.TrimSpace(input.TargetRef) ||
		(input.TriggerKind == "push" && existingBranch != strings.TrimSpace(strings.TrimPrefix(input.Branch, "refs/heads/"))) {
		return conflict("Automation run idempotency key already belongs to a different run request")
	}
	return nil
}

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

func automationSnapshotIdentity(snapshot AutomationSourceSnapshot) (string, string) {
	if strings.TrimSpace(snapshot.ContentID) != "" && strings.TrimSpace(snapshot.ContentVersionID) != "" {
		return automationSourceKindContentVersion, "contentstore://" + strings.TrimSpace(snapshot.ContentID) + "/" + strings.TrimSpace(snapshot.ContentVersionID) + "/" + normalizeAutomationSourcePath(snapshot.DefinitionPath)
	}
	repository := strings.Trim(strings.TrimSpace(snapshot.RepositoryOwner)+"/"+strings.TrimSpace(snapshot.RepositoryName), "/")
	return automationSourceKindAGSRepo, "ags:" + repository + ":" + normalizeAutomationSourcePath(snapshot.DefinitionPath)
}

func repositoryRunScenario(input AutomationRepositoryRunMutation) string {
	switch strings.TrimSpace(input.TriggerKind) {
	case "schedule":
		return automationPreflightScenarioScheduleTarget
	case "push":
		return automationPreflightScenarioEventPushValidate
	default:
		return automationPreflightScenarioManualStart
	}
}

func repositoryRunTriggerContext(input AutomationRepositoryRunMutation, snapshot AutomationSourceSnapshot) *AutomationTriggerContext {
	if explicit := ParseAutomationRunTriggerContext(strings.TrimSpace(input.TriggerContextJSON)); explicit != nil {
		return explicit
	}
	repositoryFullName := strings.TrimSpace(snapshot.RepositoryOwner) + "/" + strings.TrimSpace(snapshot.RepositoryName)
	switch strings.TrimSpace(input.TriggerKind) {
	case "schedule":
		return &AutomationTriggerContext{
			TriggerKind:          "schedule",
			ScheduleDefinitionID: strings.TrimSpace(input.TargetRef),
			AutomationRunID:      strings.TrimSpace(input.TriggerRef),
		}
	case "push":
		ref := strings.TrimSpace(input.Branch)
		if ref != "" && !strings.HasPrefix(ref, "refs/") {
			ref = "refs/heads/" + ref
		}
		actorDisplay := strings.TrimSpace(input.ActorDisplay)
		if actorDisplay == "" {
			actorDisplay = strings.TrimSpace(input.ActorRef)
		}
		return &AutomationTriggerContext{
			TriggerKind:        "push",
			RepositoryFullName: strings.Trim(repositoryFullName, "/"),
			Ref:                ref,
			Branch:             strings.TrimPrefix(strings.TrimSpace(input.Branch), "refs/heads/"),
			BeforeSHA:          strings.TrimSpace(input.BeforeSHA),
			AfterSHA:           strings.TrimSpace(snapshot.CommitSHA),
			ActorRef:           strings.TrimSpace(input.ActorRef),
			ActorDisplay:       actorDisplay,
			EventID:            strings.TrimSpace(input.TriggerRef),
		}
	default:
		triggerKind := strings.TrimSpace(input.TriggerKind)
		if triggerKind == "" {
			triggerKind = "manual"
		}
		return &AutomationTriggerContext{TriggerKind: triggerKind}
	}
}

func repositoryRunMetadata(input AutomationRepositoryRunMutation, snapshot AutomationSourceSnapshot) string {
	if raw := strings.TrimSpace(input.TriggerContextJSON); raw != "" {
		return automationRunMetadataJSON("", raw)
	}
	return buildAutomationRunMetadata(repositoryRunTriggerContext(input, snapshot))
}

func (s *AutomationsService) createRejectedPushRun(ctx context.Context, workspaceID string, subject string, input AutomationRepositoryRunMutation, snapshot AutomationSourceSnapshot, spec dsl.DefinitionSpec, cause error) (automationstore.AutomationRun, error) {
	errorJSON := AutomationStructuredErrorJSON(cause)
	structured := ParseAutomationRunError(errorJSON)
	if structured == nil {
		structured = &AutomationStructuredError{Message: strings.TrimSpace(cause.Error())}
	}
	if strings.TrimSpace(errorJSON) == "" {
		errorJSON = buildLegacyAutomationRunErrorJSON(structured.Message)
	}
	runID, err := newAutomationRunID()
	if err != nil {
		return automationstore.AutomationRun{}, fmt.Errorf("generate automation run id: %w", err)
	}
	now := s.now()
	started := now
	completed := now
	name := strings.TrimSpace(input.Name)
	if name == "" {
		name = spec.Name
	}
	runName := strings.TrimSpace(input.RunName)
	if runName == "" {
		runName = spec.RunName
	}
	repoFullName := strings.TrimSpace(snapshot.RepositoryOwner) + "/" + strings.TrimSpace(snapshot.RepositoryName)
	run := automationstore.AutomationRun{
		ID:                   runID,
		WorkspaceID:          strings.TrimSpace(workspaceID),
		IdempotencyKey:       optionalStringPtr(input.IdempotencyKey),
		SourceKind:           automationSourceKindAGSRepo,
		RepositoryOwner:      snapshot.RepositoryOwner,
		RepositoryName:       snapshot.RepositoryName,
		RepositoryFullName:   strings.Trim(repoFullName, "/"),
		DefinitionPath:       snapshot.DefinitionPath,
		GitRef:               snapshot.GitRef,
		CommitSHA:            snapshot.CommitSHA,
		DefinitionID:         "ags:" + strings.Trim(repoFullName, "/") + ":" + snapshot.DefinitionPath,
		Name:                 name,
		RunName:              runName,
		Status:               automationstore.AutomationRunStatusFailed,
		InputJSON:            input.InputJSON,
		ErrorJSON:            errorJSON,
		TriggerKind:          input.TriggerKind,
		TriggerRef:           input.TriggerRef,
		TargetKind:           input.TargetKind,
		TargetRef:            input.TargetRef,
		OwnerPrincipalID:     strings.TrimSpace(subject),
		CreatedByPrincipalID: strings.TrimSpace(subject),
		Summary:              strings.TrimSpace(structured.Message),
		Metadata:             repositoryRunMetadata(input, snapshot),
		StartedAt:            &started,
		CompletedAt:          &completed,
		CreatedAt:            now,
		UpdatedAt:            now,
	}
	eventMetadata := run.ErrorJSON
	if strings.TrimSpace(eventMetadata) == "" {
		eventMetadata = buildLegacyAutomationRunErrorJSON("automation push start rejected")
	}
	events := []automationstore.AutomationEvent{{
		ID:           "wfe_run_failed_" + runID + "_" + fmt.Sprint(now.UnixNano()),
		WorkspaceID:  run.WorkspaceID,
		RunID:        run.ID,
		EventType:    "run_failed",
		Summary:      "Automation run failed",
		MetadataJSON: eventMetadata,
		CreatedAt:    now,
	}}
	if strings.TrimSpace(run.Summary) != "" {
		events[0].Summary = run.Summary
	}
	created, createErr := s.store.CreateRunWithRuntimeWithCapacity(ctx, 0, run, nil, events, nil)
	if createErr != nil {
		return automationstore.AutomationRun{}, createErr
	}
	return created, nil
}

func buildLegacyAutomationRunErrorJSON(message string) string {
	message = strings.TrimSpace(message)
	if message == "" {
		return ""
	}
	encoded, err := json.Marshal(map[string]string{"message": message})
	if err != nil {
		return ""
	}
	return string(encoded)
}

func validateAutomationSourceBytes(content string, limits AutomationLimits) error {
	if len([]byte(content)) > normalizeAutomationLimits(limits).MaxAutomationSourceBytes {
		return invalidArgument("Automation source exceeds maximum size")
	}
	return nil
}

func decodeAutomationSpec(raw string) (dsl.DefinitionSpec, error) {
	var spec dsl.DefinitionSpec
	trimmed := strings.TrimSpace(raw)
	if err := json.Unmarshal([]byte(trimmed), &spec); err == nil {
		return spec, nil
	}
	var decoded any
	if err := yaml.Unmarshal([]byte(trimmed), &decoded); err != nil {
		return dsl.DefinitionSpec{}, err
	}
	encoded, err := json.Marshal(decoded)
	if err != nil {
		return dsl.DefinitionSpec{}, err
	}
	if err := json.Unmarshal(encoded, &spec); err != nil {
		return dsl.DefinitionSpec{}, err
	}
	return spec, nil
}

func isAutomationRunTerminalStatus(status string) bool {
	switch strings.TrimSpace(status) {
	case automationstore.AutomationRunStatusSucceeded, automationstore.AutomationRunStatusFailed, automationstore.AutomationRunStatusCanceled:
		return true
	default:
		return false
	}
}

func validateAutomationRunStatus(status string) error {
	if _, ok := allowedAutomationRunStatuses[strings.TrimSpace(status)]; !ok {
		return invalidArgument("Automation run status is invalid")
	}
	return nil
}

func normalizeAutomationPageWithMax(page int, perPage int, maxPerPage int) (int, int) {
	if page < 1 {
		page = 1
	}
	if perPage < 1 {
		perPage = 30
	}
	if maxPerPage <= 0 {
		maxPerPage = 100
	}
	if perPage > maxPerPage {
		perPage = maxPerPage
	}
	return page, perPage
}

func newAutomationRunID() (string, error)                  { return newAutomationPrefixedID("wfr_") }
func newAutomationRuntimeID(prefix string) (string, error) { return newAutomationPrefixedID(prefix) }
func newAutomationPrefixedID(prefix string) (string, error) {
	var bytes [8]byte
	if _, err := rand.Read(bytes[:]); err != nil {
		return "", err
	}
	return prefix + hex.EncodeToString(bytes[:]), nil
}
