package streamgate

import (
	"context"
	"errors"
	"fmt"
	"slices"
)

const (
	// MaxRequestFaultRecoveryAttempts is the MVP absolute request-total cap.
	// The initial provider attempt is not counted.
	MaxRequestFaultRecoveryAttempts = 3
)

var (
	// ErrRecoveryCallerCanceled indicates that request cancellation won the
	// recovery race.
	ErrRecoveryCallerCanceled = errors.New("streamgate: recovery caller canceled")

	// ErrRecoveryTerminalCommitted indicates that the downstream terminal was
	// already committed.
	ErrRecoveryTerminalCommitted = errors.New("streamgate: recovery terminal already committed")

	// ErrRecoverySideEffect indicates that retry could duplicate a completed
	// tool call or another externally visible side effect.
	ErrRecoverySideEffect = errors.New("streamgate: recovery blocked by side effect")

	// ErrRecoveryPlanReentry indicates that the same plan id was already used
	// for this request.
	ErrRecoveryPlanReentry = errors.New("streamgate: recovery plan re-entry")

	// ErrRecoveryCommitIneligible indicates that the strategy cannot run in the
	// current downstream commit state.
	ErrRecoveryCommitIneligible = errors.New("streamgate: recovery strategy is not eligible for commit state")

	// ErrRecoveryRequestBudgetExhausted is checked before any strategy-specific
	// cap so changing strategies cannot bypass the request-total cap.
	ErrRecoveryRequestBudgetExhausted = errors.New("streamgate: request fault recovery budget exhausted")

	// ErrRecoveryPlanNotReady indicates that optional preparation has not
	// produced a final typed directive yet.
	ErrRecoveryPlanNotReady = errors.New("streamgate: recovery plan is not ready for rebuild or dispatch")

	// ErrRecoveryStrategyBudgetExhausted indicates the selected fault strategy
	// has no remaining dispatches.
	ErrRecoveryStrategyBudgetExhausted = errors.New("streamgate: strategy fault recovery budget exhausted")

	// ErrManagedTrajectoryExhausted indicates that attempt, caller, or context
	// allowance leaves no tokens for another managed continuation.
	ErrManagedTrajectoryExhausted = errors.New("streamgate: managed continuation trajectory exhausted")

	// ErrRecoverySnapshotLimitExceeded indicates that a snapshot reference or
	// rebuilt request exceeded its retained or peak memory limits.
	ErrRecoverySnapshotLimitExceeded = errors.New("streamgate: recovery snapshot limit exceeded")
)

var faultRecoveryStrategies = []RecoveryStrategy{
	RecoveryStrategyExactReplay,
	RecoveryStrategyContinuationRepair,
	RecoveryStrategySchemaRepair,
}

func isFaultRecoveryStrategy(strategy RecoveryStrategy) bool {
	switch strategy {
	case RecoveryStrategyExactReplay, RecoveryStrategyContinuationRepair, RecoveryStrategySchemaRepair:
		return true
	default:
		return false
	}
}

// RecoveryResumeMode identifies whether a recovery replaces an uncommitted
// provider attempt or continues an already-open downstream stream.
type RecoveryResumeMode string

const (
	RecoveryResumeModeReplaceAttempt RecoveryResumeMode = "replace_attempt"
	RecoveryResumeModeContinueStream RecoveryResumeMode = "continue_stream"
)

// Validate returns nil for a known recovery resume mode.
func (m RecoveryResumeMode) Validate() error {
	switch m {
	case RecoveryResumeModeReplaceAttempt, RecoveryResumeModeContinueStream:
		return nil
	default:
		return errors.New("streamgate: unknown recovery resume mode: " + string(m))
	}
}

// RecoveryPreparationStatus is the immutable lifecycle state of optional
// plan preparation.
type RecoveryPreparationStatus string

const (
	RecoveryPreparationNotRequired RecoveryPreparationStatus = "not_required"
	RecoveryPreparationRequired    RecoveryPreparationStatus = "required"
	RecoveryPreparationPrepared    RecoveryPreparationStatus = "prepared"
)

// Validate returns nil for a known preparation status.
func (s RecoveryPreparationStatus) Validate() error {
	switch s {
	case RecoveryPreparationNotRequired, RecoveryPreparationRequired, RecoveryPreparationPrepared:
		return nil
	default:
		return errors.New("streamgate: unknown recovery preparation status: " + string(s))
	}
}

// RecoveryPolicySnapshot is the immutable request-start snapshot of fault
// recovery limits. Missing strategy limits inherit the request-total limit
// only when the supplied map is empty; in a non-empty map, missing strategies
// are disabled.
type RecoveryPolicySnapshot struct {
	maxRequestFaultRecoveries int
	strategyLimits            map[RecoveryStrategy]int
}

// NewRecoveryPolicySnapshot validates and snapshots request-total and
// strategy-specific fault recovery limits.
func NewRecoveryPolicySnapshot(maxTotal int, strategyLimits map[RecoveryStrategy]int) (RecoveryPolicySnapshot, error) {
	if maxTotal < 0 || maxTotal > MaxRequestFaultRecoveryAttempts {
		return RecoveryPolicySnapshot{}, fmt.Errorf(
			"streamgate: max request fault recoveries must be between 0 and %d",
			MaxRequestFaultRecoveryAttempts,
		)
	}

	limits := make(map[RecoveryStrategy]int, len(strategyLimits))
	if len(strategyLimits) == 0 {
		for _, strategy := range faultRecoveryStrategies {
			limits[strategy] = maxTotal
		}
	} else {
		for strategy, limit := range strategyLimits {
			if !isFaultRecoveryStrategy(strategy) {
				return RecoveryPolicySnapshot{}, errors.New("streamgate: recovery policy contains non-fault strategy")
			}
			if limit < 0 || limit > maxTotal {
				return RecoveryPolicySnapshot{}, errors.New("streamgate: recovery strategy limit must be between zero and request-total limit")
			}
			limits[strategy] = limit
		}
	}

	snapshot := RecoveryPolicySnapshot{
		maxRequestFaultRecoveries: maxTotal,
		strategyLimits:            limits,
	}
	if err := snapshot.Validate(); err != nil {
		return RecoveryPolicySnapshot{}, err
	}
	return snapshot, nil
}

// Validate returns nil when the policy snapshot is internally consistent.
func (p RecoveryPolicySnapshot) Validate() error {
	if p.maxRequestFaultRecoveries < 0 || p.maxRequestFaultRecoveries > MaxRequestFaultRecoveryAttempts {
		return errors.New("streamgate: recovery policy request-total limit is out of range")
	}
	for strategy, limit := range p.strategyLimits {
		if !isFaultRecoveryStrategy(strategy) {
			return errors.New("streamgate: recovery policy contains non-fault strategy")
		}
		if limit < 0 || limit > p.maxRequestFaultRecoveries {
			return errors.New("streamgate: recovery policy strategy limit is out of range")
		}
	}
	return nil
}

// MaxRequestFaultRecoveries returns the request-total fault recovery cap.
func (p RecoveryPolicySnapshot) MaxRequestFaultRecoveries() int {
	return p.maxRequestFaultRecoveries
}

// StrategyLimit returns a fault strategy limit. A missing strategy in an
// explicitly partial policy is disabled and therefore returns zero.
func (p RecoveryPolicySnapshot) StrategyLimit(strategy RecoveryStrategy) int {
	return p.strategyLimits[strategy]
}

// StrategyLimits returns a defensive copy of all configured strategy limits.
func (p RecoveryPolicySnapshot) StrategyLimits() map[RecoveryStrategy]int {
	return copyRecoveryStrategyCounts(p.strategyLimits)
}

func copyRecoveryPolicySnapshot(p RecoveryPolicySnapshot) RecoveryPolicySnapshot {
	return RecoveryPolicySnapshot{
		maxRequestFaultRecoveries: p.maxRequestFaultRecoveries,
		strategyLimits:            copyRecoveryStrategyCounts(p.strategyLimits),
	}
}

// RecoveryUsageSnapshot is the immutable request-local fault dispatch ledger.
// Managed continuation is deliberately absent from this ledger.
type RecoveryUsageSnapshot struct {
	requestFaultRecoveries int
	strategyUsage          map[RecoveryStrategy]int
}

// NewRecoveryUsageSnapshot validates and snapshots total and per-strategy
// fault recovery dispatch usage.
func NewRecoveryUsageSnapshot(totalUsed int, strategyUsage map[RecoveryStrategy]int) (RecoveryUsageSnapshot, error) {
	usage := RecoveryUsageSnapshot{
		requestFaultRecoveries: totalUsed,
		strategyUsage:          copyRecoveryStrategyCounts(strategyUsage),
	}
	if err := usage.Validate(); err != nil {
		return RecoveryUsageSnapshot{}, err
	}
	return usage, nil
}

// Validate returns nil when total usage exactly accounts for each fault
// strategy dispatch.
func (u RecoveryUsageSnapshot) Validate() error {
	if u.requestFaultRecoveries < 0 {
		return errors.New("streamgate: recovery request usage must not be negative")
	}
	sum := 0
	for strategy, used := range u.strategyUsage {
		if !isFaultRecoveryStrategy(strategy) {
			return errors.New("streamgate: recovery usage contains non-fault strategy")
		}
		if used < 0 {
			return errors.New("streamgate: recovery strategy usage must not be negative")
		}
		sum += used
	}
	if sum != u.requestFaultRecoveries {
		return errors.New("streamgate: recovery request usage must equal summed strategy usage")
	}
	return nil
}

// ValidateAgainst checks this usage snapshot against a policy snapshot.
func (u RecoveryUsageSnapshot) ValidateAgainst(policy RecoveryPolicySnapshot) error {
	if err := policy.Validate(); err != nil {
		return err
	}
	if err := u.Validate(); err != nil {
		return err
	}
	if u.requestFaultRecoveries > policy.maxRequestFaultRecoveries {
		return errors.New("streamgate: recovery request usage exceeds policy")
	}
	for strategy, used := range u.strategyUsage {
		if used > policy.StrategyLimit(strategy) {
			return errors.New("streamgate: recovery strategy usage exceeds policy")
		}
	}
	return nil
}

// RequestFaultRecoveries returns total fault dispatches already consumed.
func (u RecoveryUsageSnapshot) RequestFaultRecoveries() int {
	return u.requestFaultRecoveries
}

// StrategyUsage returns dispatches already consumed by a fault strategy.
func (u RecoveryUsageSnapshot) StrategyUsage(strategy RecoveryStrategy) int {
	return u.strategyUsage[strategy]
}

// StrategyUsages returns a defensive copy of all per-strategy usage.
func (u RecoveryUsageSnapshot) StrategyUsages() map[RecoveryStrategy]int {
	return copyRecoveryStrategyCounts(u.strategyUsage)
}

// RecoveryBudget combines the immutable request-start policy and current
// request-local fault usage snapshots. Managed continuation is intentionally
// excluded and uses ManagedTrajectoryBudget.
type RecoveryBudget struct {
	policy RecoveryPolicySnapshot
	usage  RecoveryUsageSnapshot
}

// NewRecoveryBudget creates a validated immutable fault budget snapshot.
func NewRecoveryBudget(policy RecoveryPolicySnapshot, usage RecoveryUsageSnapshot) (RecoveryBudget, error) {
	budget := RecoveryBudget{
		policy: copyRecoveryPolicySnapshot(policy),
		usage:  copyRecoveryUsageSnapshot(usage),
	}
	if err := budget.Validate(); err != nil {
		return RecoveryBudget{}, err
	}
	return budget, nil
}

// Validate returns nil when usage is valid under the snapshotted policy.
func (b RecoveryBudget) Validate() error {
	return b.usage.ValidateAgainst(b.policy)
}

// PolicySnapshot returns a defensive copy of the policy half.
func (b RecoveryBudget) PolicySnapshot() RecoveryPolicySnapshot {
	return copyRecoveryPolicySnapshot(b.policy)
}

// UsageSnapshot returns a defensive copy of the usage half.
func (b RecoveryBudget) UsageSnapshot() RecoveryUsageSnapshot {
	return copyRecoveryUsageSnapshot(b.usage)
}

func copyRecoveryUsageSnapshot(u RecoveryUsageSnapshot) RecoveryUsageSnapshot {
	return RecoveryUsageSnapshot{
		requestFaultRecoveries: u.requestFaultRecoveries,
		strategyUsage:          copyRecoveryStrategyCounts(u.strategyUsage),
	}
}

func copyRecoveryStrategyCounts(in map[RecoveryStrategy]int) map[RecoveryStrategy]int {
	if in == nil {
		return nil
	}
	out := make(map[RecoveryStrategy]int, len(in))
	for strategy, count := range in {
		out[strategy] = count
	}
	return out
}

func (u RecoveryUsageSnapshot) nextFaultDispatch(
	strategy RecoveryStrategy,
	policy RecoveryPolicySnapshot,
) (RecoveryUsageSnapshot, int, int, error) {
	if !isFaultRecoveryStrategy(strategy) {
		return RecoveryUsageSnapshot{}, 0, 0, errors.New("streamgate: managed continuation does not consume fault budget")
	}
	if err := u.ValidateAgainst(policy); err != nil {
		return RecoveryUsageSnapshot{}, 0, 0, err
	}

	// Request-total is deliberately checked first to prevent strategy changes
	// from bypassing the shared cap.
	if u.requestFaultRecoveries >= policy.maxRequestFaultRecoveries {
		return RecoveryUsageSnapshot{}, 0, 0, ErrRecoveryRequestBudgetExhausted
	}
	if u.StrategyUsage(strategy) >= policy.StrategyLimit(strategy) {
		return RecoveryUsageSnapshot{}, 0, 0, ErrRecoveryStrategyBudgetExhausted
	}

	next := copyRecoveryUsageSnapshot(u)
	if next.strategyUsage == nil {
		next.strategyUsage = make(map[RecoveryStrategy]int)
	}
	next.requestFaultRecoveries++
	next.strategyUsage[strategy]++
	return next, next.requestFaultRecoveries, next.strategyUsage[strategy], nil
}

// ManagedTrajectoryBudget is an immutable request-local allowance snapshot for
// provider output-cap continuation. RebuiltPromptTokens already includes the
// preserved assistant prefix, so cumulative output is not subtracted again
// from context allowance.
type ManagedTrajectoryBudget struct {
	providerAttemptCap       int
	contextWindow            int
	reserve                  int
	rebuiltPromptTokens      int
	callerLogicalOutputCap   *int
	callerLogicalOutputUsage int
}

// NewManagedTrajectoryBudget creates a validated trajectory budget. A nil
// callerLogicalOutputCap means the caller did not set a logical output cap.
func NewManagedTrajectoryBudget(
	providerAttemptCap int,
	contextWindow int,
	reserve int,
	rebuiltPromptTokens int,
	callerLogicalOutputCap *int,
	callerLogicalOutputUsage int,
) (ManagedTrajectoryBudget, error) {
	var capCopy *int
	if callerLogicalOutputCap != nil {
		value := *callerLogicalOutputCap
		capCopy = &value
	}
	budget := ManagedTrajectoryBudget{
		providerAttemptCap:       providerAttemptCap,
		contextWindow:            contextWindow,
		reserve:                  reserve,
		rebuiltPromptTokens:      rebuiltPromptTokens,
		callerLogicalOutputCap:   capCopy,
		callerLogicalOutputUsage: callerLogicalOutputUsage,
	}
	if err := budget.Validate(); err != nil {
		return ManagedTrajectoryBudget{}, err
	}
	return budget, nil
}

// Validate returns nil for a consistent trajectory ledger. Exhaustion is a
// valid state and is reported by NextAllowance, not by validation.
func (b ManagedTrajectoryBudget) Validate() error {
	if b.providerAttemptCap <= 0 {
		return errors.New("streamgate: managed provider attempt cap must be positive")
	}
	if b.contextWindow <= 0 {
		return errors.New("streamgate: managed context window must be positive")
	}
	if b.reserve < 0 {
		return errors.New("streamgate: managed context reserve must not be negative")
	}
	if b.rebuiltPromptTokens < 0 {
		return errors.New("streamgate: managed rebuilt prompt tokens must not be negative")
	}
	if b.callerLogicalOutputUsage < 0 {
		return errors.New("streamgate: managed caller output usage must not be negative")
	}
	if b.callerLogicalOutputCap != nil {
		if *b.callerLogicalOutputCap < 0 {
			return errors.New("streamgate: managed caller output cap must not be negative")
		}
		if b.callerLogicalOutputUsage > *b.callerLogicalOutputCap {
			return errors.New("streamgate: managed caller output usage exceeds cap")
		}
	}
	return nil
}

// NextAllowance returns min(attempt cap, optional remaining caller logical
// cap, context window - rebuilt prompt tokens - reserve), clamped at zero.
func (b ManagedTrajectoryBudget) NextAllowance() int {
	allowance := b.providerAttemptCap
	contextRemaining := b.contextWindow - b.rebuiltPromptTokens - b.reserve
	if contextRemaining < allowance {
		allowance = contextRemaining
	}
	if b.callerLogicalOutputCap != nil {
		callerRemaining := *b.callerLogicalOutputCap - b.callerLogicalOutputUsage
		if callerRemaining < allowance {
			allowance = callerRemaining
		}
	}
	if allowance < 0 {
		return 0
	}
	return allowance
}

func (b ManagedTrajectoryBudget) ProviderAttemptCap() int  { return b.providerAttemptCap }
func (b ManagedTrajectoryBudget) ContextWindow() int       { return b.contextWindow }
func (b ManagedTrajectoryBudget) Reserve() int             { return b.reserve }
func (b ManagedTrajectoryBudget) RebuiltPromptTokens() int { return b.rebuiltPromptTokens }
func (b ManagedTrajectoryBudget) CallerLogicalOutputUsage() int {
	return b.callerLogicalOutputUsage
}

// CallerLogicalOutputCap returns the cap and whether the caller set one.
func (b ManagedTrajectoryBudget) CallerLogicalOutputCap() (int, bool) {
	if b.callerLogicalOutputCap == nil {
		return 0, false
	}
	return *b.callerLogicalOutputCap, true
}

func copyManagedTrajectoryBudget(b ManagedTrajectoryBudget) ManagedTrajectoryBudget {
	out := b
	if b.callerLogicalOutputCap != nil {
		value := *b.callerLogicalOutputCap
		out.callerLogicalOutputCap = &value
	}
	return out
}

// RecoveryContributor identifies the selected semantic consumer/filter/rule
// without carrying evidence or raw content.
type RecoveryContributor struct {
	consumerID StableToken
	filterID   StableToken
	ruleID     StableToken
}

func NewRecoveryContributor(consumerID, filterID, ruleID string) (RecoveryContributor, error) {
	consumer, err := NewStableTokenRequired("consumerID", consumerID)
	if err != nil {
		return RecoveryContributor{}, err
	}
	filter, err := NewStableTokenRequired("filterID", filterID)
	if err != nil {
		return RecoveryContributor{}, err
	}
	rule, err := NewStableTokenRequired("ruleID", ruleID)
	if err != nil {
		return RecoveryContributor{}, err
	}
	return RecoveryContributor{consumerID: consumer, filterID: filter, ruleID: rule}, nil
}

func (c RecoveryContributor) Validate() error {
	if err := validateStableTokenRequired("consumerID", c.consumerID.value); err != nil {
		return err
	}
	if err := validateStableTokenRequired("filterID", c.filterID.value); err != nil {
		return err
	}
	return validateStableTokenRequired("ruleID", c.ruleID.value)
}

func (c RecoveryContributor) ConsumerID() string { return c.consumerID.value }
func (c RecoveryContributor) FilterID() string   { return c.filterID.value }
func (c RecoveryContributor) RuleID() string     { return c.ruleID.value }

// RecoveryRequestSnapshotRef is a raw-free, bounded reference to a
// request-local ingress snapshot.
type RecoveryRequestSnapshotRef struct {
	snapshotRef   StableToken
	retainedBytes uint64
	peakBytes     uint64
	maxBytes      uint64
}

// NewRecoveryRequestSnapshotRef validates and creates an immutable snapshot reference.
func NewRecoveryRequestSnapshotRef(
	snapshotRef string,
	retainedBytes, peakBytes, maxBytes uint64,
) (RecoveryRequestSnapshotRef, error) {
	refToken, err := NewStableTokenRequired("snapshotRef", snapshotRef)
	if err != nil {
		return RecoveryRequestSnapshotRef{}, err
	}
	ref := RecoveryRequestSnapshotRef{
		snapshotRef:   refToken,
		retainedBytes: retainedBytes,
		peakBytes:     peakBytes,
		maxBytes:      maxBytes,
	}
	if err := ref.Validate(); err != nil {
		return RecoveryRequestSnapshotRef{}, err
	}
	return ref, nil
}

// Validate returns nil when the snapshot reference accounting is internally consistent.
func (r RecoveryRequestSnapshotRef) Validate() error {
	if err := validateStableTokenRequired("snapshotRef", r.snapshotRef.value); err != nil {
		return err
	}
	if r.maxBytes == 0 {
		return errors.New("streamgate: recovery request snapshot max bytes must be positive")
	}
	if r.retainedBytes > r.peakBytes || r.peakBytes > r.maxBytes {
		return ErrRecoverySnapshotLimitExceeded
	}
	return nil
}

func (r RecoveryRequestSnapshotRef) SnapshotRef() string   { return r.snapshotRef.value }
func (r RecoveryRequestSnapshotRef) RetainedBytes() uint64 { return r.retainedBytes }
func (r RecoveryRequestSnapshotRef) PeakBytes() uint64     { return r.peakBytes }
func (r RecoveryRequestSnapshotRef) MaxBytes() uint64      { return r.maxBytes }

// RecoveryEligibilityInput is the mutable call-site input to plan creation.
// NewRecoveryPlan validates and defensively snapshots every mutable member.
type RecoveryEligibilityInput struct {
	PlanID                 string
	IdempotencyKey         string
	Intent                 RecoveryIntent
	Contributors           []RecoveryContributor
	CommitState            CommitState
	CallerCanceled         bool
	HasToolSideEffect      bool
	HasCompleteToolCall    bool
	PreviouslyUsedPlanIDs  []string
	Policy                 RecoveryPolicySnapshot
	Usage                  RecoveryUsageSnapshot
	ManagedTrajectory      *ManagedTrajectoryBudget
	TerminalReason         TerminalReason
	PreparerID             string
	PreparationSnapshotRef string
	RequiredCapabilities   []string
	FailureCauses          FailureCauseChain
}

// RecoveryPlan is an immutable, transport-agnostic recovery decision. It does
// not contain raw request/auth data, provider selection, or executable callbacks.
type RecoveryPlan struct {
	planID                 StableToken
	idempotencyKey         StableToken
	intent                 RecoveryIntent
	contributors           []RecoveryContributor
	resumeMode             RecoveryResumeMode
	commitState            CommitState
	policy                 RecoveryPolicySnapshot
	usage                  RecoveryUsageSnapshot
	requestAttempt         int
	strategyAttempt        int
	managedTrajectory      *ManagedTrajectoryBudget
	managedAllowance       int
	managedFinalized       bool
	terminalReason         TerminalReason
	preparerID             StableToken
	preparationSnapshotRef StableToken
	preparationStatus      RecoveryPreparationStatus
	requiredCapabilities   []StableToken
	failureCauses          FailureCauseChain
}

// NewRecoveryPlan validates eligibility and returns an immutable plan. Merely
// creating or preparing a plan never consumes recovery budget.
func NewRecoveryPlan(input RecoveryEligibilityInput) (RecoveryPlan, error) {
	planID, err := NewStableTokenRequired("planID", input.PlanID)
	if err != nil {
		return RecoveryPlan{}, err
	}
	idempotencyKey, err := NewStableTokenRequired("idempotencyKey", input.IdempotencyKey)
	if err != nil {
		return RecoveryPlan{}, err
	}
	if err := input.Intent.Validate(); err != nil {
		return RecoveryPlan{}, err
	}
	if len(input.Contributors) == 0 {
		return RecoveryPlan{}, errors.New("streamgate: recovery plan requires at least one contributor")
	}
	contributors := make([]RecoveryContributor, len(input.Contributors))
	copy(contributors, input.Contributors)
	for _, contributor := range contributors {
		if err := contributor.Validate(); err != nil {
			return RecoveryPlan{}, err
		}
	}
	if err := input.CommitState.Validate(); err != nil {
		return RecoveryPlan{}, err
	}
	if err := input.Policy.Validate(); err != nil {
		return RecoveryPlan{}, err
	}
	if err := input.Usage.ValidateAgainst(input.Policy); err != nil {
		return RecoveryPlan{}, err
	}
	if input.CallerCanceled {
		return RecoveryPlan{}, ErrRecoveryCallerCanceled
	}
	if input.CommitState == CommitStateTerminalCommitted {
		return RecoveryPlan{}, ErrRecoveryTerminalCommitted
	}
	if input.HasToolSideEffect || input.HasCompleteToolCall {
		return RecoveryPlan{}, ErrRecoverySideEffect
	}
	for _, usedID := range input.PreviouslyUsedPlanIDs {
		if err := validateStableTokenRequired("previousPlanID", usedID); err != nil {
			return RecoveryPlan{}, err
		}
		if usedID == input.PlanID {
			return RecoveryPlan{}, ErrRecoveryPlanReentry
		}
	}

	preparerID, err := NewStableTokenOptional("preparerID", input.PreparerID)
	if err != nil {
		return RecoveryPlan{}, err
	}
	preparationRef, err := NewStableTokenOptional("preparationSnapshotRef", input.PreparationSnapshotRef)
	if err != nil {
		return RecoveryPlan{}, err
	}
	if (preparerID.value == "") != (preparationRef.value == "") {
		return RecoveryPlan{}, errors.New("streamgate: recovery preparer id and snapshot ref must be set together")
	}
	requiredCapabilities, err := snapshotStableTokens("requiredCapability", input.RequiredCapabilities)
	if err != nil {
		return RecoveryPlan{}, err
	}
	if input.FailureCauses.Len() > MaxFailureCauses {
		return RecoveryPlan{}, errors.New("streamgate: recovery failure cause chain exceeds maximum")
	}
	for _, cause := range input.FailureCauses.All() {
		if err := cause.Validate(); err != nil {
			return RecoveryPlan{}, err
		}
	}

	strategy := input.Intent.Strategy()
	var (
		resumeMode      RecoveryResumeMode
		requestAttempt  int
		strategyAttempt int
		managed         *ManagedTrajectoryBudget
		allowance       int
	)
	switch strategy {
	case RecoveryStrategyExactReplay, RecoveryStrategySchemaRepair:
		if input.CommitState != CommitStateTransportUncommitted {
			return RecoveryPlan{}, ErrRecoveryCommitIneligible
		}
		resumeMode = RecoveryResumeModeReplaceAttempt
	case RecoveryStrategyContinuationRepair, RecoveryStrategyManagedContinuation:
		if input.CommitState != CommitStateStreamOpen {
			return RecoveryPlan{}, ErrRecoveryCommitIneligible
		}
		resumeMode = RecoveryResumeModeContinueStream
	default:
		return RecoveryPlan{}, errors.New("streamgate: recovery plan unknown strategy")
	}

	if strategy == RecoveryStrategyManagedContinuation {
		if input.TerminalReason != TerminalReasonOutputCap {
			return RecoveryPlan{}, errors.New("streamgate: managed continuation requires output-cap terminal reason")
		}
		if input.ManagedTrajectory == nil {
			return RecoveryPlan{}, errors.New("streamgate: managed continuation requires trajectory budget")
		}
		budgetCopy := copyManagedTrajectoryBudget(*input.ManagedTrajectory)
		if err := budgetCopy.Validate(); err != nil {
			return RecoveryPlan{}, err
		}
		managed = &budgetCopy
		allowance = 0
	} else {
		if input.TerminalReason != "" {
			return RecoveryPlan{}, errors.New("streamgate: fault recovery must not carry managed terminal reason")
		}
		if input.ManagedTrajectory != nil {
			return RecoveryPlan{}, errors.New("streamgate: fault recovery must not carry managed trajectory budget")
		}
		_, requestAttempt, strategyAttempt, err = input.Usage.nextFaultDispatch(strategy, input.Policy)
		if err != nil {
			return RecoveryPlan{}, err
		}
	}
	preparationStatus := RecoveryPreparationNotRequired
	if preparerID.value != "" {
		preparationStatus = RecoveryPreparationRequired
	}

	plan := RecoveryPlan{
		planID:                 planID,
		idempotencyKey:         idempotencyKey,
		intent:                 input.Intent,
		contributors:           contributors,
		resumeMode:             resumeMode,
		commitState:            input.CommitState,
		policy:                 copyRecoveryPolicySnapshot(input.Policy),
		usage:                  copyRecoveryUsageSnapshot(input.Usage),
		requestAttempt:         requestAttempt,
		strategyAttempt:        strategyAttempt,
		managedTrajectory:      managed,
		managedAllowance:       allowance,
		managedFinalized:       false,
		terminalReason:         input.TerminalReason,
		preparerID:             preparerID,
		preparationSnapshotRef: preparationRef,
		preparationStatus:      preparationStatus,
		requiredCapabilities:   requiredCapabilities,
		failureCauses:          input.FailureCauses.Copy(),
	}
	if err := plan.Validate(); err != nil {
		return RecoveryPlan{}, err
	}
	return plan, nil
}

// Validate returns nil when the plan remains internally consistent.
func (p RecoveryPlan) Validate() error {
	if err := validateStableTokenRequired("planID", p.planID.value); err != nil {
		return err
	}
	if err := validateStableTokenRequired("idempotencyKey", p.idempotencyKey.value); err != nil {
		return err
	}
	if err := p.intent.Validate(); err != nil {
		return err
	}
	if err := p.resumeMode.Validate(); err != nil {
		return err
	}
	if err := p.commitState.Validate(); err != nil {
		return err
	}
	if len(p.contributors) == 0 {
		return errors.New("streamgate: recovery plan requires contributors")
	}
	seenContributors := make(map[string]struct{}, len(p.contributors))
	for _, contributor := range p.contributors {
		if err := contributor.Validate(); err != nil {
			return err
		}
		key := contributor.ConsumerID() + "\x00" + contributor.FilterID() + "\x00" + contributor.RuleID()
		if _, exists := seenContributors[key]; exists {
			return errors.New("streamgate: recovery plan contains duplicate contributor")
		}
		seenContributors[key] = struct{}{}
	}
	if err := p.usage.ValidateAgainst(p.policy); err != nil {
		return err
	}
	if (p.preparerID.value == "") != (p.preparationSnapshotRef.value == "") {
		return errors.New("streamgate: recovery plan preparer fields mismatch")
	}
	if err := p.preparationStatus.Validate(); err != nil {
		return err
	}
	if err := validateStableTokenIfSet("preparerID", p.preparerID.value); err != nil {
		return err
	}
	if err := validateStableTokenIfSet("preparationSnapshotRef", p.preparationSnapshotRef.value); err != nil {
		return err
	}
	if p.preparerID.value == "" && p.preparationStatus != RecoveryPreparationNotRequired {
		return errors.New("streamgate: recovery plan without preparer has invalid preparation status")
	}
	if p.preparerID.value != "" && p.preparationStatus == RecoveryPreparationNotRequired {
		return errors.New("streamgate: recovery plan with preparer has invalid preparation status")
	}
	if err := validateUniqueStableTokens("requiredCapability", p.requiredCapabilities); err != nil {
		return err
	}
	for _, cause := range p.failureCauses.All() {
		if err := cause.Validate(); err != nil {
			return err
		}
	}

	strategy := p.intent.Strategy()
	switch strategy {
	case RecoveryStrategyExactReplay, RecoveryStrategySchemaRepair:
		if p.resumeMode != RecoveryResumeModeReplaceAttempt || p.commitState != CommitStateTransportUncommitted {
			return errors.New("streamgate: replace recovery plan has invalid commit contract")
		}
	case RecoveryStrategyContinuationRepair, RecoveryStrategyManagedContinuation:
		if p.resumeMode != RecoveryResumeModeContinueStream || p.commitState != CommitStateStreamOpen {
			return errors.New("streamgate: continuation recovery plan has invalid commit contract")
		}
	default:
		return errors.New("streamgate: recovery plan unknown strategy")
	}

	if strategy == RecoveryStrategyManagedContinuation {
		if p.managedTrajectory == nil || p.terminalReason != TerminalReasonOutputCap {
			return errors.New("streamgate: managed recovery plan missing trajectory contract")
		}
		if err := p.managedTrajectory.Validate(); err != nil {
			return err
		}
		if p.managedFinalized {
			if p.managedAllowance <= 0 || p.managedAllowance != p.managedTrajectory.NextAllowance() {
				return errors.New("streamgate: managed recovery plan allowance mismatch")
			}
		} else {
			if p.managedAllowance != 0 {
				return errors.New("streamgate: unfinalized managed recovery plan allowance must be zero")
			}
		}
		if p.requestAttempt != 0 || p.strategyAttempt != 0 {
			return errors.New("streamgate: managed recovery plan must not consume fault attempt ordinals")
		}
	} else {
		if p.managedTrajectory != nil || p.managedAllowance != 0 || p.terminalReason != "" {
			return errors.New("streamgate: fault recovery plan contains managed trajectory fields")
		}
		_, requestAttempt, strategyAttempt, err := p.usage.nextFaultDispatch(strategy, p.policy)
		if err != nil {
			return err
		}
		if p.requestAttempt != requestAttempt || p.strategyAttempt != strategyAttempt {
			return errors.New("streamgate: fault recovery plan attempt ordinal mismatch")
		}
	}
	return nil
}

// UsageAfterDispatch returns the immutable usage snapshot that must be stored
// immediately before outbound dispatch. Managed continuation returns the same
// fault ledger because it uses a separate trajectory budget.
func (p RecoveryPlan) UsageAfterDispatch() (RecoveryUsageSnapshot, error) {
	if err := p.Validate(); err != nil {
		return RecoveryUsageSnapshot{}, err
	}
	if !p.ReadyForDispatch() {
		return RecoveryUsageSnapshot{}, ErrRecoveryPlanNotReady
	}
	if p.intent.Strategy() == RecoveryStrategyManagedContinuation {
		return copyRecoveryUsageSnapshot(p.usage), nil
	}
	next, _, _, err := p.usage.nextFaultDispatch(p.intent.Strategy(), p.policy)
	return next, err
}

func (p RecoveryPlan) PlanID() string                 { return p.planID.value }
func (p RecoveryPlan) IdempotencyKey() string         { return p.idempotencyKey.value }
func (p RecoveryPlan) Strategy() RecoveryStrategy     { return p.intent.Strategy() }
func (p RecoveryPlan) Intent() RecoveryIntent         { return p.intent }
func (p RecoveryPlan) Directive() RecoveryDirective   { return p.intent.Directive() }
func (p RecoveryPlan) Reason() string                 { return p.intent.Reason() }
func (p RecoveryPlan) Priority() int                  { return p.intent.Priority() }
func (p RecoveryPlan) ResumeMode() RecoveryResumeMode { return p.resumeMode }
func (p RecoveryPlan) CommitState() CommitState       { return p.commitState }
func (p RecoveryPlan) RequestAttempt() int            { return p.requestAttempt }
func (p RecoveryPlan) StrategyAttempt() int           { return p.strategyAttempt }
func (p RecoveryPlan) ManagedAllowance() int          { return p.managedAllowance }
func (p RecoveryPlan) ManagedFinalized() bool         { return p.managedFinalized }
func (p RecoveryPlan) TerminalReason() TerminalReason { return p.terminalReason }
func (p RecoveryPlan) PreparerID() string             { return p.preparerID.value }
func (p RecoveryPlan) PreparationSnapshotRef() string { return p.preparationSnapshotRef.value }

func (p RecoveryPlan) PreparationStatus() RecoveryPreparationStatus { return p.preparationStatus }
func (p RecoveryPlan) ReadyForRebuild() bool {
	return p.preparationStatus != RecoveryPreparationRequired
}

func (p RecoveryPlan) ReadyForDispatch() bool {
	if p.intent.Strategy() == RecoveryStrategyManagedContinuation {
		return p.ReadyForRebuild() && p.managedFinalized && p.managedAllowance > 0
	}
	return p.ReadyForRebuild()
}

// FinalizeRebuiltRequest validates a measured rebuilt request draft against the plan,
// calculates the final managed allowance for managed continuation strategies (or 0
// for fault recovery strategies), and returns the finalized RecoveryPlan together
// with an allowance-bound dispatchable RebuiltRequest.
func (p RecoveryPlan) FinalizeRebuiltRequest(draft RebuiltRequestDraft) (RecoveryPlan, RebuiltRequest, error) {
	if err := p.Validate(); err != nil {
		return RecoveryPlan{}, RebuiltRequest{}, err
	}
	if !p.ReadyForRebuild() {
		return RecoveryPlan{}, RebuiltRequest{}, ErrRecoveryPlanNotReady
	}
	if err := draft.Validate(); err != nil {
		return RecoveryPlan{}, RebuiltRequest{}, err
	}
	if draft.PlanID() != p.PlanID() || draft.IdempotencyKey() != p.IdempotencyKey() {
		return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: rebuilt request plan or idempotency key mismatch")
	}
	if !slices.Equal(draft.RequiredCapabilities(), p.RequiredCapabilities()) {
		return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: rebuilt request required capabilities mismatch")
	}

	strategy := p.intent.Strategy()
	if strategy == RecoveryStrategyManagedContinuation {
		if p.managedTrajectory == nil {
			return RecoveryPlan{}, RebuiltRequest{}, errors.New("streamgate: managed recovery plan missing trajectory budget")
		}

		updatedBudget, err := NewManagedTrajectoryBudget(
			p.managedTrajectory.providerAttemptCap,
			p.managedTrajectory.contextWindow,
			p.managedTrajectory.reserve,
			draft.RebuiltPromptTokens(),
			p.managedTrajectory.callerLogicalOutputCap,
			p.managedTrajectory.callerLogicalOutputUsage,
		)
		if err != nil {
			return RecoveryPlan{}, RebuiltRequest{}, err
		}

		allowance := updatedBudget.NextAllowance()
		if allowance <= 0 {
			return RecoveryPlan{}, RebuiltRequest{}, ErrManagedTrajectoryExhausted
		}

		out := p
		out.managedTrajectory = &updatedBudget
		out.managedAllowance = allowance
		out.managedFinalized = true
		if err := out.Validate(); err != nil {
			return RecoveryPlan{}, RebuiltRequest{}, err
		}

		request, err := newRebuiltRequest(draft, allowance)
		if err != nil {
			return RecoveryPlan{}, RebuiltRequest{}, err
		}
		return out, request, nil
	}

	request, err := newRebuiltRequest(draft, 0)
	if err != nil {
		return RecoveryPlan{}, RebuiltRequest{}, err
	}
	return p, request, nil
}

func (p RecoveryPlan) PolicySnapshot() RecoveryPolicySnapshot {
	return copyRecoveryPolicySnapshot(p.policy)
}

func (p RecoveryPlan) UsageSnapshot() RecoveryUsageSnapshot {
	return copyRecoveryUsageSnapshot(p.usage)
}

// FaultBudget returns the immutable fault policy/usage pair used to select
// this plan.
func (p RecoveryPlan) FaultBudget() RecoveryBudget {
	return RecoveryBudget{policy: copyRecoveryPolicySnapshot(p.policy), usage: copyRecoveryUsageSnapshot(p.usage)}
}

func (p RecoveryPlan) ManagedTrajectoryBudget() (*ManagedTrajectoryBudget, bool) {
	if p.managedTrajectory == nil {
		return nil, false
	}
	copyBudget := copyManagedTrajectoryBudget(*p.managedTrajectory)
	return &copyBudget, true
}

func (p RecoveryPlan) Contributors() []RecoveryContributor {
	out := make([]RecoveryContributor, len(p.contributors))
	copy(out, p.contributors)
	return out
}

func (p RecoveryPlan) RequiredCapabilities() []string {
	if p.requiredCapabilities == nil {
		return nil
	}
	out := make([]string, len(p.requiredCapabilities))
	for i, capability := range p.requiredCapabilities {
		out[i] = capability.value
	}
	return out
}

func (p RecoveryPlan) FailureCauses() FailureCauseChain {
	return p.failureCauses.Copy()
}

func (p RecoveryPlan) RequiresPreparation() bool {
	return p.preparerID.value != ""
}

// WithPreparedDirective returns a new final plan after successful one-shot
// preparation. The original plan remains in required state.
func (p RecoveryPlan) WithPreparedDirective(directive RecoveryDirective) (RecoveryPlan, error) {
	if err := p.Validate(); err != nil {
		return RecoveryPlan{}, err
	}
	if p.preparationStatus != RecoveryPreparationRequired {
		return RecoveryPlan{}, errors.New("streamgate: recovery plan is not awaiting preparation")
	}
	if err := directive.Validate(); err != nil {
		return RecoveryPlan{}, err
	}
	if !directive.MatchStrategy(p.intent.Strategy()) {
		return RecoveryPlan{}, errors.New("streamgate: prepared directive does not match recovery strategy")
	}
	intent, err := NewRecoveryIntent(p.intent.Strategy(), directive, p.intent.Reason(), p.intent.Priority())
	if err != nil {
		return RecoveryPlan{}, err
	}
	out := p
	out.intent = intent
	out.preparationStatus = RecoveryPreparationPrepared
	if err := out.Validate(); err != nil {
		return RecoveryPlan{}, err
	}
	return out, nil
}

func snapshotStableTokens(name string, values []string) ([]StableToken, error) {
	if values == nil {
		return nil, nil
	}
	seen := make(map[string]struct{}, len(values))
	out := make([]StableToken, len(values))
	for i, value := range values {
		if _, ok := seen[value]; ok {
			return nil, errors.New("streamgate: duplicate " + name)
		}
		token, err := NewStableTokenRequired(name, value)
		if err != nil {
			return nil, err
		}
		seen[value] = struct{}{}
		out[i] = token
	}
	return out, nil
}

func validateUniqueStableTokens(name string, values []StableToken) error {
	seen := make(map[string]struct{}, len(values))
	for _, value := range values {
		if err := validateStableTokenRequired(name, value.value); err != nil {
			return err
		}
		if _, exists := seen[value.value]; exists {
			return errors.New("streamgate: duplicate " + name)
		}
		seen[value.value] = struct{}{}
	}
	return nil
}

// RebuiltRequestDraft is an unfinalized, raw-free reference to an endpoint/family-specific
// rebuilt request retained by the host. It carries prompt measurements, peak/retained memory
// limits, and required capabilities before post-rebuild allowance finalization.
type RebuiltRequestDraft struct {
	planID               StableToken
	idempotencyKey       StableToken
	requestRef           StableToken
	endpoint             string
	family               string
	retainedBytes        uint64
	peakBytes            uint64
	maxBytes             uint64
	rebuiltPromptTokens  int
	requiredCapabilities []StableToken
}

// NewRebuiltRequestDraft validates and creates an unfinalized rebuilt request draft.
func NewRebuiltRequestDraft(
	planID, requestRef, endpoint, family string,
	retainedBytes, peakBytes, maxBytes uint64,
	rebuiltPromptTokens int,
	requiredCapabilities []string,
) (RebuiltRequestDraft, error) {
	return NewRebuiltRequestDraftWithIdempotency(
		planID, planID, requestRef, endpoint, family,
		retainedBytes, peakBytes, maxBytes, rebuiltPromptTokens, requiredCapabilities,
	)
}

// NewRebuiltRequestDraftWithIdempotency creates a rebuilt request draft that
// preserves the selected plan's independent outbound idempotency key.
func NewRebuiltRequestDraftWithIdempotency(
	planID, idempotencyKey, requestRef, endpoint, family string,
	retainedBytes, peakBytes, maxBytes uint64,
	rebuiltPromptTokens int,
	requiredCapabilities []string,
) (RebuiltRequestDraft, error) {
	planToken, err := NewStableTokenRequired("planID", planID)
	if err != nil {
		return RebuiltRequestDraft{}, err
	}
	idempotencyToken, err := NewStableTokenRequired("idempotencyKey", idempotencyKey)
	if err != nil {
		return RebuiltRequestDraft{}, err
	}
	requestToken, err := NewStableTokenRequired("requestRef", requestRef)
	if err != nil {
		return RebuiltRequestDraft{}, err
	}
	capabilities, err := snapshotStableTokens("requiredCapability", requiredCapabilities)
	if err != nil {
		return RebuiltRequestDraft{}, err
	}
	draft := RebuiltRequestDraft{
		planID:               planToken,
		idempotencyKey:       idempotencyToken,
		requestRef:           requestToken,
		endpoint:             endpoint,
		family:               family,
		retainedBytes:        retainedBytes,
		peakBytes:            peakBytes,
		maxBytes:             maxBytes,
		rebuiltPromptTokens:  rebuiltPromptTokens,
		requiredCapabilities: capabilities,
	}
	if err := draft.Validate(); err != nil {
		return RebuiltRequestDraft{}, err
	}
	return draft, nil
}

func (d RebuiltRequestDraft) Validate() error {
	if err := validateStableTokenRequired("planID", d.planID.value); err != nil {
		return err
	}
	if err := validateStableTokenRequired("idempotencyKey", d.idempotencyKey.value); err != nil {
		return err
	}
	if err := validateStableTokenRequired("requestRef", d.requestRef.value); err != nil {
		return err
	}
	if d.endpoint == "" || d.family == "" {
		return errors.New("streamgate: rebuilt request endpoint and family are required")
	}
	if d.maxBytes == 0 {
		return errors.New("streamgate: rebuilt request max bytes must be positive")
	}
	if d.retainedBytes > d.peakBytes || d.peakBytes > d.maxBytes {
		return ErrRecoverySnapshotLimitExceeded
	}
	if d.rebuiltPromptTokens < 0 {
		return errors.New("streamgate: rebuilt request prompt tokens must not be negative")
	}
	if err := validateUniqueStableTokens("requiredCapability", d.requiredCapabilities); err != nil {
		return err
	}
	return nil
}

func (d RebuiltRequestDraft) PlanID() string           { return d.planID.value }
func (d RebuiltRequestDraft) IdempotencyKey() string   { return d.idempotencyKey.value }
func (d RebuiltRequestDraft) RequestRef() string       { return d.requestRef.value }
func (d RebuiltRequestDraft) Endpoint() string         { return d.endpoint }
func (d RebuiltRequestDraft) Family() string           { return d.family }
func (d RebuiltRequestDraft) RetainedBytes() uint64    { return d.retainedBytes }
func (d RebuiltRequestDraft) PeakBytes() uint64        { return d.peakBytes }
func (d RebuiltRequestDraft) MaxBytes() uint64         { return d.maxBytes }
func (d RebuiltRequestDraft) RebuiltPromptTokens() int { return d.rebuiltPromptTokens }
func (d RebuiltRequestDraft) RequiredCapabilities() []string {
	out := make([]string, len(d.requiredCapabilities))
	for i, capability := range d.requiredCapabilities {
		out[i] = capability.value
	}
	return out
}

// RebuiltRequest is an allowance-bound, dispatchable reference to a rebuilt request.
// It couples measured request parameters with the Core-calculated managed allowance.
type RebuiltRequest struct {
	draft            RebuiltRequestDraft
	managedAllowance int
}

// newRebuiltRequest creates an allowance-bound final rebuilt request from a validated draft
// and managed allowance.
func newRebuiltRequest(draft RebuiltRequestDraft, managedAllowance int) (RebuiltRequest, error) {
	if err := draft.Validate(); err != nil {
		return RebuiltRequest{}, err
	}
	if managedAllowance < 0 {
		return RebuiltRequest{}, errors.New("streamgate: rebuilt request managed allowance must not be negative")
	}
	request := RebuiltRequest{
		draft:            draft,
		managedAllowance: managedAllowance,
	}
	if err := request.Validate(); err != nil {
		return RebuiltRequest{}, err
	}
	return request, nil
}

func (r RebuiltRequest) Validate() error {
	if err := r.draft.Validate(); err != nil {
		return err
	}
	if r.managedAllowance < 0 {
		return errors.New("streamgate: rebuilt request managed allowance must not be negative")
	}
	return nil
}

func (r RebuiltRequest) PlanID() string           { return r.draft.PlanID() }
func (r RebuiltRequest) IdempotencyKey() string   { return r.draft.IdempotencyKey() }
func (r RebuiltRequest) RequestRef() string       { return r.draft.RequestRef() }
func (r RebuiltRequest) Endpoint() string         { return r.draft.Endpoint() }
func (r RebuiltRequest) Family() string           { return r.draft.Family() }
func (r RebuiltRequest) RetainedBytes() uint64    { return r.draft.RetainedBytes() }
func (r RebuiltRequest) PeakBytes() uint64        { return r.draft.PeakBytes() }
func (r RebuiltRequest) MaxBytes() uint64         { return r.draft.MaxBytes() }
func (r RebuiltRequest) RebuiltPromptTokens() int { return r.draft.RebuiltPromptTokens() }
func (r RebuiltRequest) ManagedAllowance() int    { return r.managedAllowance }
func (r RebuiltRequest) RequiredCapabilities() []string {
	return r.draft.RequiredCapabilities()
}

// NormalizedEventSource is the host-owned source of codec-normalized events
// for one provider attempt.
type NormalizedEventSource interface {
	NextEvent(ctx context.Context) (NormalizedEvent, error)
}

// AttemptController owns only the current provider transport. AbortAttempt
// must be idempotent; nil means ownership is closed, including already-closed.
type AttemptController interface {
	AbortAttempt(ctx context.Context) error
}

// AttemptBinding binds a dispatched attempt to its actual admission result and
// normalized event source without exposing provider auth or request body.
type AttemptBinding struct {
	attemptID     StableToken
	model         string
	provider      string
	executionPath string
	eventSource   NormalizedEventSource
	controller    AttemptController
}

func NewAttemptBinding(
	attemptID, model, provider, executionPath string,
	eventSource NormalizedEventSource,
	controller AttemptController,
) (AttemptBinding, error) {
	attemptToken, err := NewStableTokenRequired("attemptID", attemptID)
	if err != nil {
		return AttemptBinding{}, err
	}
	if model == "" || provider == "" || executionPath == "" {
		return AttemptBinding{}, errors.New("streamgate: attempt binding model, provider, and execution path are required")
	}
	if eventSource == nil || controller == nil {
		return AttemptBinding{}, errors.New("streamgate: attempt binding event source and controller are required")
	}
	binding := AttemptBinding{
		attemptID:     attemptToken,
		model:         model,
		provider:      provider,
		executionPath: executionPath,
		eventSource:   eventSource,
		controller:    controller,
	}
	if err := binding.Validate(); err != nil {
		return AttemptBinding{}, err
	}
	return binding, nil
}

// Validate returns nil when the binding contains a complete actual admission
// result and both host ownership seams.
func (b AttemptBinding) Validate() error {
	if err := validateStableTokenRequired("attemptID", b.attemptID.value); err != nil {
		return err
	}
	if b.model == "" || b.provider == "" || b.executionPath == "" {
		return errors.New("streamgate: attempt binding model, provider, and execution path are required")
	}
	if b.eventSource == nil || b.controller == nil {
		return errors.New("streamgate: attempt binding event source and controller are required")
	}
	return nil
}

func (b AttemptBinding) AttemptID() string                  { return b.attemptID.value }
func (b AttemptBinding) Model() string                      { return b.model }
func (b AttemptBinding) Provider() string                   { return b.provider }
func (b AttemptBinding) ExecutionPath() string              { return b.executionPath }
func (b AttemptBinding) EventSource() NormalizedEventSource { return b.eventSource }
func (b AttemptBinding) Controller() AttemptController      { return b.controller }

// AttemptDispatcher performs host admission and concrete provider/model/auth
// calculation for one rebuilt request.
type AttemptDispatcher interface {
	DispatchAttempt(ctx context.Context, request RebuiltRequest) (AttemptBinding, error)
}

// RecoveryPreparationSnapshot is a bounded request-local snapshot seam. It
// exposes only its stable reference and idempotent release lifecycle; a host
// preparer may type-assert its own concrete snapshot without adding raw
// accessors to the shared Core contract.
type RecoveryPreparationSnapshot interface {
	SnapshotRef() string
	Release() error
}

// RecoveryPlanPreparer optionally enriches the typed directive after attempt
// ownership is closed. It must be called at most once per plan/idempotency key
// under the caller's bounded context and must not dispatch or mutate budgets.
type RecoveryPlanPreparer interface {
	PrepareRecoveryPlan(ctx context.Context, plan RecoveryPlan, snapshot RecoveryPreparationSnapshot) (RecoveryDirective, error)
}

// RequestRebuilder applies the selected typed plan to a bounded, request-local
// ingress snapshot. Concrete auth and provider selection remain outside it.
type RequestRebuilder interface {
	RebuildRequest(ctx context.Context, snapshot RecoveryRequestSnapshotRef, plan RecoveryPlan) (RebuiltRequestDraft, error)
}
