package bindingmanage

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
	"github.com/tidbcloud/AgentTeam/internal/store/workflow/binding"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
	bindinglang "github.com/tidbcloud/AgentTeam/internal/workflow/binding"
	"github.com/tidbcloud/AgentTeam/internal/workflow/plan"
	"github.com/tidbcloud/AgentTeam/internal/workflow/planmanage"
)

// Repository is the transactional persistence boundary required by Binding
// management. Schedule occurrence acceptance intentionally belongs to the
// later Invocation transaction and is not exposed as a partial cursor action
// by this authoring/lifecycle service.
type Repository interface {
	CreateBinding(context.Context, binding.CreateBindingParams) (binding.CreateResult, error)
	LoadBinding(context.Context, string, string) (binding.Binding, error)
	ListBindings(context.Context, binding.ListBindingsParams) ([]binding.Binding, error)
	UpdateBindingMetadata(context.Context, binding.UpdateBindingMetadataParams) (binding.Binding, error)
	CreateRevision(context.Context, binding.CreateRevisionParams) (binding.CreateRevisionResult, error)
	LoadRevision(context.Context, string, string, int64) (binding.Revision, error)
	ListRevisions(context.Context, binding.ListRevisionsParams) ([]binding.Revision, error)
	ActivateRevision(context.Context, binding.ActivateRevisionParams) (binding.ActivateResult, error)
	DisableBinding(context.Context, binding.DisableBindingParams) (binding.Binding, error)
	ArchiveBinding(context.Context, binding.ArchiveBindingParams) (binding.Binding, error)
	RestoreBinding(context.Context, binding.RestoreBindingParams) (binding.Binding, error)
}

// PlanVersionResolver returns an exact, verified immutable PlanVersion. It
// never accepts current/default selectors. Resolve is the active-only read
// used by standalone validation. Inspect also exposes archived Plans so a
// mutation can reproduce its request digest and reach the repository's
// idempotency receipt before the repository applies its commit-time active
// Plan fence.
type PlanVersionResolver interface {
	ResolveVersionForBinding(context.Context, string, string, string) (planmanage.ResolvedVersionForBinding, error)
	InspectVersionForBinding(context.Context, string, string, string) (planmanage.InspectedVersionForBinding, error)
}

// AgentOwnerVerifier is the trusted store-backed boundary used by the shared
// service to bind Agent provenance to its current authoritative managing human.
// The Service never trusts an Agent Actor/Principal pair supplied by an adapter
// without independently resolving both the Agent member and human membership.
type AgentOwnerVerifier interface {
	GetAgentForUser(context.Context, string, string, string) (agent.AgentView, error)
	GetWorkspaceForUser(context.Context, string, string) (workspace.Summary, error)
}

type Service struct {
	repository  Repository
	plans       PlanVersionResolver
	agentOwners AgentOwnerVerifier
	clock       Clock
}

type Option func(*Service)

func WithClock(clock Clock) Option {
	return func(service *Service) {
		if clock != nil {
			service.clock = clock
		}
	}
}

func WithAgentOwnerVerifier(verifier AgentOwnerVerifier) Option {
	return func(service *Service) {
		service.agentOwners = verifier
	}
}

func New(repository Repository, plans PlanVersionResolver, options ...Option) *Service {
	service := &Service{repository: repository, plans: plans, clock: systemClock{}}
	for _, option := range options {
		if option != nil {
			option(service)
		}
	}
	return service
}

func (service *Service) validate() error {
	if service == nil || service.repository == nil {
		return fmt.Errorf("workflow Binding repository is not configured")
	}
	if service.plans == nil {
		return fmt.Errorf("workflow Binding PlanVersion resolver is not configured")
	}
	if service.clock == nil {
		return fmt.Errorf("workflow Binding clock is not configured")
	}
	return nil
}

// ReplayCommand recovers one already-committed mutation before any current
// owner, Agent-manager, membership, Plan, or aggregate lookup. A miss grants no
// authority; callers must then resolve the current principal and enter the
// ordinary mutation method, whose repository transaction repeats the same
// receipt fence.
func (service *Service) ReplayCommand(ctx context.Context, input ReplayCommandInput) (ReplayCommandResult, bool, error) {
	if err := service.validate(); err != nil {
		return ReplayCommandResult{}, false, err
	}
	workspaceID, err := requiredID("workspace id", input.WorkspaceID)
	if err != nil {
		return ReplayCommandResult{}, false, err
	}
	actor, err := normalizeActor(input.Actor)
	if err != nil {
		return ReplayCommandResult{}, false, err
	}
	if actor.Type != ActorTypeAgent {
		return ReplayCommandResult{}, false, invalidRequest("workflow Binding command replay requires an Agent actor")
	}
	idempotencyKey, err := normalizeIdempotencyKey(input.IdempotencyKey)
	if err != nil {
		return ReplayCommandResult{}, false, err
	}
	operation, err := bindingCommandOperation(input.Action)
	if err != nil {
		return ReplayCommandResult{}, false, err
	}
	repository, ok := service.repository.(interface {
		ReplayCommand(context.Context, binding.ReplayCommandParams) (binding.ReplayCommandResult, bool, error)
	})
	if !ok {
		return ReplayCommandResult{}, false, fmt.Errorf("workflow Binding command replay repository is not configured")
	}
	result, found, err := repository.ReplayCommand(ctx, binding.ReplayCommandParams{
		WorkspaceID: workspaceID, Actor: storeActor(actor), Operation: operation,
		IdempotencyKey: idempotencyKey, RequestDigest: strings.TrimSpace(input.RequestDigest),
	})
	if err != nil {
		return ReplayCommandResult{}, found, mapRepositoryError(err)
	}
	if !found {
		return ReplayCommandResult{}, false, nil
	}
	binding := bindingFromStore(result.Binding)
	replay := ReplayCommandResult{Binding: binding}
	if result.Revision != nil {
		revision, err := revisionFromStore(*result.Revision)
		if err != nil {
			return ReplayCommandResult{}, true, err
		}
		replay.Revision = &revision
	}
	return replay, true, nil
}

func bindingCommandOperation(action string) (string, error) {
	switch strings.TrimSpace(action) {
	case "create":
		return binding.CommandOperationCreate, nil
	case "update":
		return binding.CommandOperationMetadataUpdate, nil
	case "revise":
		return binding.CommandOperationRevisionCreate, nil
	case "activate":
		return binding.CommandOperationRevisionActivate, nil
	case "disable":
		return binding.CommandOperationDisable, nil
	case "archive":
		return binding.CommandOperationArchive, nil
	case "restore":
		return binding.CommandOperationRestore, nil
	default:
		return "", invalidRequest("unsupported Binding command replay action")
	}
}

func (service *Service) Create(ctx context.Context, input CreateInput) (Binding, Revision, error) {
	if err := service.validate(); err != nil {
		return Binding{}, Revision{}, err
	}
	workspaceID, err := requiredID("workspace id", input.WorkspaceID)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	planID, err := requiredID("Plan id", input.PlanID)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	key, err := normalizeBindingKey(input.Key)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	displayName, err := normalizeDisplayName(input.DisplayName)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	description, err := normalizeDescription(input.DescriptionMD)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	owner, actor, err := service.normalizeMutationIdentity(ctx, workspaceID, input.Owner, input.Actor)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	idempotencyKey, err := normalizeIdempotencyKey(input.IdempotencyKey)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	normalized, err := service.inspectAndNormalizeForMutation(ctx, workspaceID, planID, input.PlanVersionID, input.Source, input.InputPolicy)
	if err != nil {
		return Binding{}, Revision{}, configurationMutationError(err)
	}
	now := service.clock.Now().UTC()
	nextOccurrence, err := initialOccurrence(normalized, now, input.Activate)
	if err != nil {
		return Binding{}, Revision{}, configurationMutationError(err)
	}
	result, err := service.repository.CreateBinding(ctx, binding.CreateBindingParams{
		WorkspaceID: workspaceID, PlanID: planID, Key: key, DisplayName: displayName, DescriptionMD: description,
		PlanVersionID: normalized.Spec.PlanVersionID, SourceKind: string(normalized.Spec.Source.Type),
		ConfigJSON: normalized.ConfigJSON, Digest: normalized.Digest, Activate: input.Activate,
		NextOccurrenceAt: nextOccurrence, Owner: storeOwner(owner), Actor: storeActor(actor),
		IdempotencyKey: idempotencyKey, RequestDigest: input.RequestDigest, Now: now,
	})
	if err != nil {
		return Binding{}, Revision{}, mapRepositoryError(err)
	}
	revision, err := revisionFromStore(result.Revision)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	return bindingFromStore(result.Binding), revision, nil
}

func (service *Service) List(ctx context.Context, input ListInput) (BindingPage, error) {
	if err := service.validate(); err != nil {
		return BindingPage{}, err
	}
	normalized, err := normalizeList(input)
	if err != nil {
		return BindingPage{}, err
	}
	cursor, err := decodeBindingCursor(normalized.Cursor)
	if err != nil {
		return BindingPage{}, err
	}
	values, err := service.repository.ListBindings(ctx, binding.ListBindingsParams{
		WorkspaceID: normalized.WorkspaceID, PlanID: normalized.PlanID, State: normalized.State,
		IncludeArchived: normalized.IncludeArchived, Limit: normalized.Limit + 1,
		BeforeUpdatedAt: cursor.updatedAt, BeforeID: cursor.id,
	})
	if err != nil {
		return BindingPage{}, mapRepositoryError(err)
	}
	hasMore := len(values) > normalized.Limit
	if hasMore {
		values = values[:normalized.Limit]
	}
	page := BindingPage{Items: make([]Binding, 0, len(values))}
	for _, value := range values {
		page.Items = append(page.Items, bindingFromStore(value))
	}
	if hasMore {
		last := values[len(values)-1]
		page.NextCursor, err = encodeBindingCursor(last.UpdatedAt, last.ID)
		if err != nil {
			return BindingPage{}, err
		}
	}
	return page, nil
}

func (service *Service) Get(ctx context.Context, workspaceID string, bindingID string) (Binding, error) {
	if err := service.validate(); err != nil {
		return Binding{}, err
	}
	workspaceID, bindingID, err := requiredScope(workspaceID, bindingID)
	if err != nil {
		return Binding{}, err
	}
	value, err := service.repository.LoadBinding(ctx, workspaceID, bindingID)
	if err != nil {
		return Binding{}, mapRepositoryError(err)
	}
	return bindingFromStore(value), nil
}

func (service *Service) UpdateMetadata(ctx context.Context, input UpdateMetadataInput) (Binding, error) {
	if err := service.validate(); err != nil {
		return Binding{}, err
	}
	workspaceID, bindingID, err := requiredScope(input.WorkspaceID, input.BindingID)
	if err != nil {
		return Binding{}, err
	}
	if err := positiveRevision("expected Binding revision", input.ExpectedRevision); err != nil {
		return Binding{}, err
	}
	if input.DisplayName == nil && input.DescriptionMD == nil {
		return Binding{}, invalidRequest("display name or description is required")
	}
	var displayName *string
	if input.DisplayName != nil {
		value, err := normalizeDisplayName(*input.DisplayName)
		if err != nil {
			return Binding{}, err
		}
		displayName = &value
	}
	var description *string
	if input.DescriptionMD != nil {
		value, err := normalizeDescription(*input.DescriptionMD)
		if err != nil {
			return Binding{}, err
		}
		description = &value
	}
	principal, actor, err := service.normalizeMutationIdentity(ctx, workspaceID, input.Principal, input.Actor)
	if err != nil {
		return Binding{}, err
	}
	idempotencyKey, err := normalizeIdempotencyKey(input.IdempotencyKey)
	if err != nil {
		return Binding{}, err
	}
	if _, err := service.requireOwner(ctx, workspaceID, bindingID, principal); err != nil {
		return Binding{}, err
	}
	value, err := service.repository.UpdateBindingMetadata(ctx, binding.UpdateBindingMetadataParams{
		WorkspaceID: workspaceID, BindingID: bindingID, ExpectedRevision: input.ExpectedRevision,
		DisplayName: displayName, DescriptionMD: description, Actor: storeActor(actor),
		IdempotencyKey: idempotencyKey, RequestDigest: input.RequestDigest, Now: service.clock.Now(),
	})
	if err != nil {
		return Binding{}, mapRepositoryError(err)
	}
	return bindingFromStore(value), nil
}

func (service *Service) Validate(ctx context.Context, input ValidateInput) (Validation, error) {
	if err := service.validate(); err != nil {
		return Validation{}, err
	}
	workspaceID, err := requiredID("workspace id", input.WorkspaceID)
	if err != nil {
		return Validation{}, err
	}
	planID, err := requiredID("Plan id", input.PlanID)
	if err != nil {
		return Validation{}, err
	}
	normalized, err := service.resolveAndNormalize(ctx, workspaceID, planID, input.PlanVersionID, input.Source, input.InputPolicy)
	if err != nil {
		var validation bindinglang.ValidationError
		if errors.As(err, &validation) {
			return Validation{Diagnostics: []Diagnostic{diagnosticFromValidation(validation)}}, nil
		}
		return Validation{}, mapPlanError(err)
	}
	return Validation{Diagnostics: []Diagnostic{}, Digest: normalized.Digest}, nil
}

func (service *Service) CreateRevision(ctx context.Context, input CreateRevisionInput) (Binding, Revision, error) {
	if err := service.validate(); err != nil {
		return Binding{}, Revision{}, err
	}
	workspaceID, bindingID, err := requiredScope(input.WorkspaceID, input.BindingID)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	if err := positiveRevision("expected Binding revision", input.ExpectedRevision); err != nil {
		return Binding{}, Revision{}, err
	}
	principal, actor, err := service.normalizeMutationIdentity(ctx, workspaceID, input.Principal, input.Actor)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	idempotencyKey, err := normalizeIdempotencyKey(input.IdempotencyKey)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	stable, err := service.repository.LoadBinding(ctx, workspaceID, bindingID)
	if err != nil {
		return Binding{}, Revision{}, mapRepositoryError(err)
	}
	if err := authorizeOwner(stable, principal); err != nil {
		return Binding{}, Revision{}, err
	}
	normalized, err := service.inspectAndNormalizeForMutation(ctx, workspaceID, stable.PlanID, input.PlanVersionID, input.Source, input.InputPolicy)
	if err != nil {
		return Binding{}, Revision{}, configurationMutationError(err)
	}
	result, err := service.repository.CreateRevision(ctx, binding.CreateRevisionParams{
		WorkspaceID: workspaceID, BindingID: bindingID, ExpectedRevision: input.ExpectedRevision,
		PlanVersionID: normalized.Spec.PlanVersionID, SourceKind: string(normalized.Spec.Source.Type),
		ConfigJSON: normalized.ConfigJSON, Digest: normalized.Digest, Actor: storeActor(actor),
		IdempotencyKey: idempotencyKey, RequestDigest: input.RequestDigest, Now: service.clock.Now(),
	})
	if err != nil {
		return Binding{}, Revision{}, mapRepositoryError(err)
	}
	revision, err := revisionFromStore(result.Revision)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	return bindingFromStore(result.Binding), revision, nil
}

func (service *Service) ListRevisions(ctx context.Context, input ListRevisionsInput) (RevisionPage, error) {
	if err := service.validate(); err != nil {
		return RevisionPage{}, err
	}
	workspaceID, bindingID, err := requiredScope(input.WorkspaceID, input.BindingID)
	if err != nil {
		return RevisionPage{}, err
	}
	if input.Limit == 0 {
		input.Limit = defaultPageLimit
	}
	if input.Limit < 1 || input.Limit > maxPageLimit {
		return RevisionPage{}, invalidRequest(fmt.Sprintf("limit must be between 1 and %d", maxPageLimit))
	}
	before, err := decodeRevisionCursor(input.Cursor)
	if err != nil {
		return RevisionPage{}, err
	}
	if _, err := service.repository.LoadBinding(ctx, workspaceID, bindingID); err != nil {
		return RevisionPage{}, mapRepositoryError(err)
	}
	values, err := service.repository.ListRevisions(ctx, binding.ListRevisionsParams{
		WorkspaceID: workspaceID, BindingID: bindingID, Limit: input.Limit + 1, BeforeRevision: before,
	})
	if err != nil {
		return RevisionPage{}, mapRepositoryError(err)
	}
	hasMore := len(values) > input.Limit
	if hasMore {
		values = values[:input.Limit]
	}
	page := RevisionPage{Items: make([]Revision, 0, len(values))}
	for _, value := range values {
		revision, err := revisionFromStore(value)
		if err != nil {
			return RevisionPage{}, err
		}
		page.Items = append(page.Items, revision)
	}
	if hasMore {
		page.NextCursor, err = encodeRevisionCursor(values[len(values)-1].Revision)
		if err != nil {
			return RevisionPage{}, err
		}
	}
	return page, nil
}

func (service *Service) GetRevision(ctx context.Context, workspaceID string, bindingID string, revisionNumber int64) (Revision, error) {
	if err := service.validate(); err != nil {
		return Revision{}, err
	}
	workspaceID, bindingID, err := requiredScope(workspaceID, bindingID)
	if err != nil {
		return Revision{}, err
	}
	if err := positiveRevision("Binding revision", revisionNumber); err != nil {
		return Revision{}, err
	}
	value, err := service.repository.LoadRevision(ctx, workspaceID, bindingID, revisionNumber)
	if err != nil {
		return Revision{}, mapRepositoryError(err)
	}
	return revisionFromStore(value)
}

func (service *Service) Activate(ctx context.Context, input ActivateInput) (Binding, Revision, error) {
	if err := service.validate(); err != nil {
		return Binding{}, Revision{}, err
	}
	workspaceID, bindingID, err := requiredScope(input.WorkspaceID, input.BindingID)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	if err := positiveRevision("Binding revision", input.Revision); err != nil {
		return Binding{}, Revision{}, err
	}
	if err := positiveRevision("expected Binding revision", input.ExpectedRevision); err != nil {
		return Binding{}, Revision{}, err
	}
	principal, actor, err := service.normalizeMutationIdentity(ctx, workspaceID, input.Principal, input.Actor)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	if _, err := service.requireOwner(ctx, workspaceID, bindingID, principal); err != nil {
		return Binding{}, Revision{}, err
	}
	idempotencyKey, err := normalizeIdempotencyKey(input.IdempotencyKey)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	stored, err := service.repository.LoadRevision(ctx, workspaceID, bindingID, input.Revision)
	if err != nil {
		return Binding{}, Revision{}, mapRepositoryError(err)
	}
	spec, err := bindinglang.DecodeConfig(stored.PlanID, stored.PlanVersionID, stored.ConfigJSON)
	if err != nil {
		return Binding{}, Revision{}, fmt.Errorf("decode persisted Binding revision for activation: %w", err)
	}
	if !bindinglang.VerifyDigest(spec, stored.Digest) {
		return Binding{}, Revision{}, fmt.Errorf("persisted Binding revision digest mismatch")
	}
	now := service.clock.Now().UTC()
	nextOccurrence, err := initialOccurrenceFromSpec(spec, now, true)
	if err != nil {
		return Binding{}, Revision{}, configurationMutationError(err)
	}
	result, err := service.repository.ActivateRevision(ctx, binding.ActivateRevisionParams{
		WorkspaceID: workspaceID, BindingID: bindingID, Revision: input.Revision,
		ExpectedRevision: input.ExpectedRevision, NextOccurrenceAt: nextOccurrence,
		Actor: storeActor(actor), IdempotencyKey: idempotencyKey, RequestDigest: input.RequestDigest, Now: now,
	})
	if err != nil {
		return Binding{}, Revision{}, mapRepositoryError(err)
	}
	revision, err := revisionFromStore(result.Revision)
	if err != nil {
		return Binding{}, Revision{}, err
	}
	return bindingFromStore(result.Binding), revision, nil
}

func (service *Service) Disable(ctx context.Context, input LifecycleInput) (Binding, error) {
	return service.lifecycle(ctx, input, "disable")
}

func (service *Service) Archive(ctx context.Context, input LifecycleInput) (Binding, error) {
	return service.lifecycle(ctx, input, "archive")
}

func (service *Service) Restore(ctx context.Context, input LifecycleInput) (Binding, error) {
	return service.lifecycle(ctx, input, "restore")
}

func (service *Service) lifecycle(ctx context.Context, input LifecycleInput, operation string) (Binding, error) {
	if err := service.validate(); err != nil {
		return Binding{}, err
	}
	workspaceID, bindingID, err := requiredScope(input.WorkspaceID, input.BindingID)
	if err != nil {
		return Binding{}, err
	}
	if err := positiveRevision("expected Binding revision", input.ExpectedRevision); err != nil {
		return Binding{}, err
	}
	principal, actor, err := service.normalizeMutationIdentity(ctx, workspaceID, input.Principal, input.Actor)
	if err != nil {
		return Binding{}, err
	}
	idempotencyKey, err := normalizeIdempotencyKey(input.IdempotencyKey)
	if err != nil {
		return Binding{}, err
	}
	if _, err := service.requireOwner(ctx, workspaceID, bindingID, principal); err != nil {
		return Binding{}, err
	}
	now := service.clock.Now()
	var value binding.Binding
	switch operation {
	case "disable":
		value, err = service.repository.DisableBinding(ctx, binding.DisableBindingParams{
			WorkspaceID: workspaceID, BindingID: bindingID, ExpectedRevision: input.ExpectedRevision,
			Actor: storeActor(actor), IdempotencyKey: idempotencyKey, RequestDigest: input.RequestDigest, Now: now,
		})
	case "archive":
		value, err = service.repository.ArchiveBinding(ctx, binding.ArchiveBindingParams{
			WorkspaceID: workspaceID, BindingID: bindingID, ExpectedRevision: input.ExpectedRevision,
			Actor: storeActor(actor), IdempotencyKey: idempotencyKey, RequestDigest: input.RequestDigest, Now: now,
		})
	case "restore":
		value, err = service.repository.RestoreBinding(ctx, binding.RestoreBindingParams{
			WorkspaceID: workspaceID, BindingID: bindingID, ExpectedRevision: input.ExpectedRevision,
			Actor: storeActor(actor), IdempotencyKey: idempotencyKey, RequestDigest: input.RequestDigest, Now: now,
		})
	default:
		return Binding{}, fmt.Errorf("unsupported Binding lifecycle operation %q", operation)
	}
	if err != nil {
		return Binding{}, mapRepositoryError(err)
	}
	return bindingFromStore(value), nil
}

func (service *Service) requireOwner(ctx context.Context, workspaceID string, bindingID string, principal Owner) (binding.Binding, error) {
	value, err := service.repository.LoadBinding(ctx, workspaceID, bindingID)
	if err != nil {
		return binding.Binding{}, mapRepositoryError(err)
	}
	if err := authorizeOwner(value, principal); err != nil {
		return binding.Binding{}, err
	}
	return value, nil
}

func authorizeOwner(value binding.Binding, principal Owner) error {
	if value.Owner.UserID == principal.UserID && value.Owner.MemberID == principal.MemberID {
		return nil
	}
	return RequestError{Kind: storeerr.ErrForbidden, Code: ErrorCodeForbidden, Message: "only the Binding execution owner may mutate this Binding"}
}

// normalizeMutationIdentity is the provenance boundary shared by every
// Binding mutation. Human provenance is self-authenticating only when it is
// identical to the authoritative human principal injected by the transport. Agent
// provenance is never accepted from input alone: the service independently
// asks its trusted store-backed verifier for both the managed Agent member and
// the managing human's current workspace membership.
func (service *Service) normalizeMutationIdentity(ctx context.Context, workspaceID string, principal Owner, actor Actor) (Owner, Actor, error) {
	principal, err := normalizeOwner(principal)
	if err != nil {
		return Owner{}, Actor{}, err
	}
	actor, err = normalizeActor(actor)
	if err != nil {
		return Owner{}, Actor{}, err
	}
	switch actor.Type {
	case ActorTypeHuman:
		if actor.ID != principal.UserID || actor.MemberID != principal.MemberID {
			return Owner{}, Actor{}, mutationIdentityForbidden()
		}
		return principal, actor, nil
	case ActorTypeAgent:
		if service.agentOwners == nil {
			return Owner{}, Actor{}, mutationIdentityForbidden()
		}
		agentView, err := service.agentOwners.GetAgentForUser(ctx, principal.UserID, workspaceID, actor.ID)
		if err != nil {
			return Owner{}, Actor{}, mapAgentOwnerVerificationError(err)
		}
		membership, err := service.agentOwners.GetWorkspaceForUser(ctx, principal.UserID, workspaceID)
		if err != nil {
			return Owner{}, Actor{}, mapAgentOwnerVerificationError(err)
		}
		if strings.TrimSpace(agentView.AgentID) != actor.ID || strings.TrimSpace(agentView.MemberID) != actor.MemberID ||
			strings.TrimSpace(membership.WorkspaceID) != workspaceID || strings.TrimSpace(membership.MemberUserID) != principal.UserID ||
			strings.TrimSpace(membership.MemberID) != principal.MemberID {
			return Owner{}, Actor{}, mutationIdentityForbidden()
		}
		return principal, actor, nil
	default:
		// normalizeActor rejects this branch; keep the switch fail-closed if the
		// accepted actor types ever change independently.
		return Owner{}, Actor{}, mutationIdentityForbidden()
	}
}

func mapAgentOwnerVerificationError(err error) error {
	if errors.Is(err, storeerr.ErrForbidden) || errors.Is(err, storeerr.ErrNotFound) || errors.Is(err, storeerr.ErrUnauthorized) {
		return mutationIdentityForbidden()
	}
	return fmt.Errorf("verify Workflow Binding Agent owner: %w", err)
}

func mutationIdentityForbidden() error {
	return RequestError{
		Kind: storeerr.ErrForbidden, Code: ErrorCodeForbidden,
		Message: "Binding mutation Actor is not bound to the authenticated principal",
	}
}

func (service *Service) resolveAndNormalize(ctx context.Context, workspaceID string, planID string, versionID string, source []byte, inputPolicy []byte) (bindinglang.NormalizedRevision, error) {
	versionID, err := requiredID("Plan version id", versionID)
	if err != nil {
		return bindinglang.NormalizedRevision{}, err
	}
	resolved, err := service.plans.ResolveVersionForBinding(ctx, workspaceID, planID, versionID)
	if err != nil {
		return bindinglang.NormalizedRevision{}, err
	}
	return normalizeAgainstPlanVersion(workspaceID, planID, versionID, resolved.Version, resolved.Artifact, source, inputPolicy)
}

// inspectAndNormalizeForMutation intentionally accepts an archived Plan at the
// read/validation phase. The repository checks an idempotency receipt first,
// then locks and requires an active Plan for every genuinely new write. Using
// the active-only resolver here would make a previously successful command
// impossible to replay after its Plan was archived.
func (service *Service) inspectAndNormalizeForMutation(ctx context.Context, workspaceID string, planID string, versionID string, source []byte, inputPolicy []byte) (bindinglang.NormalizedRevision, error) {
	versionID, err := requiredID("Plan version id", versionID)
	if err != nil {
		return bindinglang.NormalizedRevision{}, err
	}
	inspected, err := service.plans.InspectVersionForBinding(ctx, workspaceID, planID, versionID)
	if err != nil {
		return bindinglang.NormalizedRevision{}, err
	}
	return normalizeAgainstPlanVersion(workspaceID, planID, versionID, inspected.Version, inspected.Artifact, source, inputPolicy)
}

func normalizeAgainstPlanVersion(workspaceID string, planID string, versionID string, version planmanage.Version, artifact *plan.Artifact, source []byte, inputPolicy []byte) (bindinglang.NormalizedRevision, error) {
	if artifact == nil || strings.TrimSpace(version.ID) != versionID || strings.TrimSpace(version.PlanID) != planID || strings.TrimSpace(version.WorkspaceID) != workspaceID {
		return bindinglang.NormalizedRevision{}, RequestError{
			Kind: storeerr.ErrBadRequest, Code: ErrorCodePlanVersionInvalid,
			Message: "resolved PlanVersion does not match the requested workspace and Plan",
		}
	}
	spec, err := bindinglang.DecodeRevisionParts(planID, versionID, source, inputPolicy)
	if err != nil {
		return bindinglang.NormalizedRevision{}, err
	}
	return bindinglang.NormalizeRevision(spec, artifact.Contracts.Input)
}

func initialOccurrence(normalized bindinglang.NormalizedRevision, now time.Time, activate bool) (*time.Time, error) {
	return initialOccurrenceFromSpec(normalized.Spec, now, activate)
}

func initialOccurrenceFromSpec(spec bindinglang.RevisionSpec, now time.Time, activate bool) (*time.Time, error) {
	if !activate {
		return nil, nil
	}
	switch spec.Source.Type {
	case bindinglang.SourceTypeSchedule:
		if spec.Source.Schedule == nil {
			return nil, bindinglang.ValidationError{Code: "binding.source_union", Path: "$.source", Message: "schedule source requires source.schedule"}
		}
		return bindinglang.InitialOccurrence(*spec.Source.Schedule, now.UTC())
	default:
		return nil, bindinglang.ValidationError{Code: "binding.source_type", Path: "$.source.type", Message: "source type must be schedule"}
	}
}

func diagnosticFromValidation(value bindinglang.ValidationError) Diagnostic {
	return Diagnostic{Severity: "error", Code: value.Code, Path: value.Path, Message: value.Message}
}

func configurationMutationError(err error) error {
	var validation bindinglang.ValidationError
	if errors.As(err, &validation) {
		return validationFailed(diagnosticFromValidation(validation))
	}
	return mapPlanError(err)
}

func mapPlanError(err error) error {
	if err == nil {
		return nil
	}
	var requestError RequestError
	if errors.As(err, &requestError) {
		return err
	}
	if code, _, ok := planmanage.ErrorDetails(err); ok {
		switch code {
		case planmanage.ErrorCodeArchived:
			return RequestError{Kind: storeerr.ErrConflict, Code: ErrorCodePlanArchived, Message: "Plan is archived", Cause: err}
		case planmanage.ErrorCodeNotFound:
			return RequestError{Kind: storeerr.ErrNotFound, Code: ErrorCodePlanNotFound, Message: "Plan or exact PlanVersion was not found", Cause: err}
		case planmanage.ErrorCodeInvalidRequest:
			return RequestError{Kind: storeerr.ErrBadRequest, Code: ErrorCodePlanVersionInvalid, Message: "The PlanVersion is invalid for this operation", Cause: err}
		}
	}
	return err
}

func mapRepositoryError(err error) error {
	if err == nil {
		return nil
	}
	var requestError RequestError
	if errors.As(err, &requestError) {
		return err
	}
	var revision binding.RevisionConflictError
	if errors.As(err, &revision) {
		return RequestError{
			Kind: storeerr.ErrConflict, Code: ErrorCodeRevisionConflict, Message: "The Binding changed while the request was being processed; reload the current revision and retry",
			Details: RevisionConflictDetails{Resource: "binding", ExpectedRevision: revision.Expected, CurrentRevision: revision.Actual},
			Cause:   revision,
		}
	}
	var idempotency binding.IdempotencyConflictError
	if errors.As(err, &idempotency) {
		return RequestError{Kind: storeerr.ErrConflict, Code: ErrorCodeIdempotencyConflict, Message: "The same idempotency key was already used for a different request", Cause: idempotency}
	}
	switch {
	case errors.Is(err, binding.ErrPlanArchived):
		return RequestError{Kind: storeerr.ErrConflict, Code: ErrorCodePlanArchived, Message: "Plan is archived", Cause: err}
	case errors.Is(err, binding.ErrArchived):
		return RequestError{Kind: storeerr.ErrConflict, Code: ErrorCodeArchived, Message: "Binding is archived", Cause: err}
	case errors.Is(err, binding.ErrInvalidState):
		return RequestError{Kind: storeerr.ErrConflict, Code: ErrorCodeInvalidState, Message: "The Binding is in a state that does not allow this operation", Cause: err}
	case errors.Is(err, storeerr.ErrNotFound):
		return RequestError{Kind: storeerr.ErrNotFound, Code: ErrorCodeNotFound, Message: "Binding resource not found", Cause: err}
	case errors.Is(err, storeerr.ErrBadRequest):
		return RequestError{Kind: storeerr.ErrBadRequest, Code: ErrorCodeInvalidRequest, Message: "Binding request is invalid", Cause: err}
	case errors.Is(err, storeerr.ErrConflict):
		return RequestError{Kind: storeerr.ErrConflict, Code: ErrorCodeConflict, Message: "Binding mutation conflicts with the current state", Cause: err}
	default:
		return err
	}
}
