package streamgate

import (
	"context"
	"errors"
	"strings"
	"testing"
	"time"
)

func buildValidEvent(t *testing.T, kind EventKind, ts time.Time) NormalizedEvent {
	t.Helper()
	var ev NormalizedEvent
	var err error
	switch kind {
	case EventKindResponseStart:
		ev, err = NewResponseStartEvent("ch", 200, map[string]string{}, ts)
	case EventKindTextDelta:
		ev, err = NewTextDeltaEvent("ch", "delta", ts)
	case EventKindTerminal:
		ev, err = NewTerminalEvent("ch", ts)
	case EventKindProviderError:
		desc, _ := NewExternalDescriptor("provider_error", "err", "provider_failure", "")
		ev, err = NewProviderErrorEvent("ch", desc, FailureCauseChain{}, ts)
	default:
		t.Fatalf("unhandled event kind: %v", kind)
	}
	if err != nil {
		t.Fatalf("buildValidEvent %v: %v", kind, err)
	}
	return ev
}

// TestEvidenceBatchBaseDisposition verifies the canonical base disposition logic:
// - terminal batch uses the disposition of the single terminal or provider-error control event.
// - non-terminal batch uses the disposition of the last event in Events().
// - empty events with staged response start returns hold.
// - empty events with no staged response start returns an error.
func TestEvidenceBatchBaseDisposition(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	startEv := buildValidEvent(t, EventKindResponseStart, ts)
	deltaEv := buildValidEvent(t, EventKindTextDelta, ts)
	termEv := buildValidEvent(t, EventKindTerminal, ts)
	errEv := buildValidEvent(t, EventKindProviderError, ts)

	// 1. Non-terminal batch: [startEv, deltaEv] -> last event is deltaEv -> release_candidate
	b1, err := NewEvidenceBatch(
		[]NormalizedEvent{startEv, deltaEv},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)
	if err != nil {
		t.Fatalf("NewEvidenceBatch b1: %v", err)
	}
	disp1, err := b1.BaseDisposition()
	if err != nil {
		t.Fatalf("b1.BaseDisposition: %v", err)
	}
	if disp1 != BaseDispositionReleaseCandidate {
		t.Errorf("b1 disposition = %v, want %v", disp1, BaseDispositionReleaseCandidate)
	}

	// 2. Terminal batch: [deltaEv, termEv] -> terminal control event -> terminal_success_candidate
	b2, err := NewEvidenceBatch(
		[]NormalizedEvent{deltaEv, termEv},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, true, CommitStateTransportUncommitted, ts,
	)
	if err != nil {
		t.Fatalf("NewEvidenceBatch b2: %v", err)
	}
	disp2, err := b2.BaseDisposition()
	if err != nil {
		t.Fatalf("b2.BaseDisposition: %v", err)
	}
	if disp2 != BaseDispositionTerminalSuccessCandidate {
		t.Errorf("b2 disposition = %v, want %v", disp2, BaseDispositionTerminalSuccessCandidate)
	}

	// 3. Provider error terminal batch: [errEv] -> terminal_error_candidate
	b3, err := NewEvidenceBatch(
		[]NormalizedEvent{errEv},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, true, CommitStateTransportUncommitted, ts,
	)
	if err != nil {
		t.Fatalf("NewEvidenceBatch b3: %v", err)
	}
	disp3, err := b3.BaseDisposition()
	if err != nil {
		t.Fatalf("b3.BaseDisposition: %v", err)
	}
	if disp3 != BaseDispositionTerminalErrorCandidate {
		t.Errorf("b3 disposition = %v, want %v", disp3, BaseDispositionTerminalErrorCandidate)
	}

	// 4. Staged response start only: empty events, valid stagedStart -> hold
	stagedStart, err := NewResponseStart("ch", 200, map[string]string{}, ts)
	if err != nil {
		t.Fatalf("NewResponseStart: %v", err)
	}
	b4, err := NewEvidenceBatch(
		nil,
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		&stagedStart, false, CommitStateTransportUncommitted, ts,
	)
	if err != nil {
		t.Fatalf("NewEvidenceBatch b4: %v", err)
	}
	disp4, err := b4.BaseDisposition()
	if err != nil {
		t.Fatalf("b4.BaseDisposition: %v", err)
	}
	if disp4 != BaseDispositionHold {
		t.Errorf("b4 disposition = %v, want %v", disp4, BaseDispositionHold)
	}

	// 5. Empty events and no staged start -> error
	bEmpty := EvidenceBatch{capturedAt: ts, commitState: CommitStateTransportUncommitted}
	if _, err := bEmpty.BaseDisposition(); err == nil {
		t.Error("bEmpty.BaseDisposition() should return error")
	}
}

// TestArbitrationResultValidationAndImmutability verifies constructor validation,
// required/forbidden fields, and defensive copying of nested objects in ArbitrationResult.
func TestArbitrationResultValidationAndImmutability(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate

	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv := buildValidEvent(t, EventKindTextDelta, ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	// Release action with filterID or intent must be rejected
	if _, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "f.1", "", nil, nil); err == nil {
		t.Error("Release action with filterID should return error")
	}
	if _, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "", "", &intent, nil); err == nil {
		t.Error("Release action with intent should return error")
	}

	// Recover action requires filterID and intent
	if _, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "", "r.1", &intent, nil); err == nil {
		t.Error("Recover action without filterID should return error")
	}
	if _, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", nil, nil); err == nil {
		t.Error("Recover action without intent should return error")
	}

	// Replacement action requires filterID and replacement
	if _, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, nil); err == nil {
		t.Error("Replacement action without replacement proposal should return error")
	}

	// Valid Recover result accessor immutability
	res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}
	if res.Action() != ArbitrationActionRecover {
		t.Errorf("Action() = %v, want %v", res.Action(), ArbitrationActionRecover)
	}
	if res.FilterID() != "f.1" || res.RuleID() != "r.1" {
		t.Errorf("FilterID/RuleID = %s/%s, want f.1/r.1", res.FilterID(), res.RuleID())
	}
	gotIntent := res.RecoveryIntent()
	if gotIntent == nil || gotIntent.Priority() != 100 {
		t.Errorf("RecoveryIntent() priority = %v, want 100", gotIntent)
	}

	// Mutate local intent reference; res.RecoveryIntent() must remain unaffected
	intent = RecoveryIntent{}
	if res.RecoveryIntent() == nil || res.RecoveryIntent().Priority() != 100 {
		t.Error("ArbitrationResult RecoveryIntent was mutated by caller")
	}

	// Valid Replacement result
	resRep, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
	if err != nil {
		t.Fatalf("NewArbitrationResult replacement: %v", err)
	}
	if resRep.Action() != ArbitrationActionReplacement {
		t.Errorf("Action() = %v, want %v", resRep.Action(), ArbitrationActionReplacement)
	}
	if resRep.ReplacementProposal() == nil || len(resRep.ReplacementProposal().Events()) != 1 {
		t.Error("ReplacementProposal accessor failed")
	}
}

// TestArbitrationResultReplacementRejectsRecoveryIntent verifies that a
// replacement action with a recovery intent attached is rejected by both the
// constructor and Validate. This is the regression for the original bug where
// intentCopy = nil silently erased a caller-provided intent when a replacement
// was present.
func TestArbitrationResultReplacementRejectsRecoveryIntent(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv := buildValidEvent(t, EventKindTextDelta, ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	// Constructor: replacement + intent must be rejected.
	_, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", &intent, &prop)
	if err == nil {
		t.Error("NewArbitrationResult(replacement, intent, replacement) should return error")
	} else if !strings.Contains(err.Error(), "must not carry recovery intent") {
		t.Errorf("error = %v, want 'must not carry recovery intent'", err)
	}

	// Validate must also reject the same state.
	res := ArbitrationResult{
		action:          ArbitrationActionReplacement,
		baseDisposition: baseDisp,
		filterID:        "f.1",
		ruleID:          "r.1",
		intent:          &intent,
		replacement:     &prop,
	}
	if err := res.Validate(); err == nil {
		t.Error("Validate() on replacement+intent must return error")
	} else if !strings.Contains(err.Error(), "must not carry recovery intent") && !strings.Contains(err.Error(), "and no intent") {
		t.Errorf("Validate error = %v, want 'must not carry recovery intent' or 'and no intent'", err)
	}

	// The caller's intent must remain unchanged after the failed constructor.
	if intent.Priority() != 100 {
		t.Errorf("caller intent was mutated: priority = %d", intent.Priority())
	}
}

// TestArbitrationResultRecoverReplacementRequireRuleID verifies that recover
// and replacement actions reject empty ruleID.
func TestArbitrationResultRecoverReplacementRequireRuleID(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv := buildValidEvent(t, EventKindTextDelta, ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	// Recover without ruleID.
	_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "", &intent, nil)
	if err == nil {
		t.Error("NewArbitrationResult(recover, empty ruleID) should return error")
	} else if !strings.Contains(err.Error(), "requires rule id") {
		t.Errorf("error = %v, want 'requires rule id'", err)
	}

	// Replacement without ruleID.
	_, err = NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "", nil, &prop)
	if err == nil {
		t.Error("NewArbitrationResult(replacement, empty ruleID) should return error")
	} else if !strings.Contains(err.Error(), "requires rule id") {
		t.Errorf("error = %v, want 'requires rule id'", err)
	}

	// Validate must also reject empty ruleID for recover.
	resRecover := ArbitrationResult{
		action:          ArbitrationActionRecover,
		baseDisposition: baseDisp,
		filterID:        "f.1",
		ruleID:          "",
		intent:          &intent,
	}
	if err := resRecover.Validate(); err == nil {
		t.Error("Validate() on recover with empty ruleID must return error")
	} else if !strings.Contains(err.Error(), "requires filter id, rule id, and intent") {
		t.Errorf("Validate error = %v, want 'requires filter id, rule id, and intent'", err)
	}

	// Validate must also reject empty ruleID for replacement.
	resReplace := ArbitrationResult{
		action:          ArbitrationActionReplacement,
		baseDisposition: baseDisp,
		filterID:        "f.1",
		ruleID:          "",
		replacement:     &prop,
	}
	if err := resReplace.Validate(); err == nil {
		t.Error("Validate() on replacement with empty ruleID must return error")
	} else if !strings.Contains(err.Error(), "requires filter id, rule id, and replacement proposal") {
		t.Errorf("Validate error = %v, want 'requires filter id, rule id, and replacement proposal'", err)
	}
}

// TestArbitrationResultRecoverRequiresIntentAndFilter verifies that the
// constructor rejects recover without intent or without filter, and that
// Validate agrees.
func TestArbitrationResultRecoverRequiresIntentAndFilter(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)

	// Recover without intent.
	_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", nil, nil)
	if err == nil {
		t.Error("NewArbitrationResult(recover, no intent) should return error")
	}

	// Recover without filter.
	_, err = NewArbitrationResult(ArbitrationActionRecover, baseDisp, "", "r.1", &intent, nil)
	if err == nil {
		t.Error("NewArbitrationResult(recover, no filter) should return error")
	}

	// Validate must also reject.
	res := ArbitrationResult{
		action:          ArbitrationActionRecover,
		baseDisposition: baseDisp,
		filterID:        "",
		ruleID:          "r.1",
		intent:          &intent,
	}
	if err := res.Validate(); err == nil {
		t.Error("Validate() on recover without filter must return error")
	}
}

// TestArbitrationResultReplacementRequiresProposal verifies that the
// constructor rejects replacement without a replacement proposal.
func TestArbitrationResultReplacementRequiresProposal(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate

	// Replacement without proposal.
	_, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, nil)
	if err == nil {
		t.Error("NewArbitrationResult(replacement, no proposal) should return error")
	}
}

// TestArbitrationResultRecoverRejectsReplacement verifies that recover rejects
// a non-nil replacement proposal.
func TestArbitrationResultRecoverRejectsReplacement(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv := buildValidEvent(t, EventKindTextDelta, ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, &prop)
	if err == nil {
		t.Error("NewArbitrationResult(recover, with replacement) should return error")
	} else if !strings.Contains(err.Error(), "must not carry replacement proposal") {
		t.Errorf("error = %v, want 'must not carry replacement proposal'", err)
	}
}

// TestArbitrationResultDeterministicAcrossPermutations verifies that the same
// set of outcomes in any input order produces an identical ArbitrationResult.
func TestArbitrationResultDeterministicAcrossPermutations(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	makeOutcome := func(id string, priority int, decision FilterDecision) EpochFilterOutcome {
		oc, _ := NewFilterOutcomeEvaluated(decision)
		o, _ := NewEpochFilterOutcome(id, priority, oc, FilterEnforcementBlocking)
		return o
	}

	// Two recover candidates with different priorities.
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent100, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	violDec1, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "beta.filter", "r.beta", evidence, &intent100)
	intent200, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 200)
	violDec2, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "alpha.filter", "r.alpha", evidence, &intent200)

	// Order 1: low priority first, high priority second.
	set1, _ := NewEvaluationSet([]EpochFilterOutcome{
		makeOutcome("beta.filter", 100, violDec1),
		makeOutcome("alpha.filter", 200, violDec2),
	})

	// Order 2: high priority first, low priority second.
	set2, _ := NewEvaluationSet([]EpochFilterOutcome{
		makeOutcome("alpha.filter", 200, violDec2),
		makeOutcome("beta.filter", 100, violDec1),
	})

	// Order 3: reversed from order 1.
	set3, _ := NewEvaluationSet([]EpochFilterOutcome{
		makeOutcome("alpha.filter", 200, violDec2),
		makeOutcome("beta.filter", 100, violDec1),
	})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	res1, err := Arbitrate(context.Background(), eq, set1)
	if err != nil {
		t.Fatalf("Arbitrate set1: %v", err)
	}
	res2, err := Arbitrate(context.Background(), eq, set2)
	if err != nil {
		t.Fatalf("Arbitrate set2: %v", err)
	}
	res3, err := Arbitrate(context.Background(), eq, set3)
	if err != nil {
		t.Fatalf("Arbitrate set3: %v", err)
	}

	// All three must agree on action, filterID, ruleID, and intent priority.
	checkResult := func(r ArbitrationResult, label string) {
		if r.Action() != ArbitrationActionRecover {
			t.Errorf("%s: Action = %v, want recover", label, r.Action())
		}
		if r.FilterID() != "alpha.filter" {
			t.Errorf("%s: FilterID = %s, want alpha.filter", label, r.FilterID())
		}
		if r.RuleID() != "r.alpha" {
			t.Errorf("%s: RuleID = %s, want r.alpha", label, r.RuleID())
		}
		if r.RecoveryIntent() == nil || r.RecoveryIntent().Priority() != 200 {
			t.Errorf("%s: RecoveryIntent priority = %v, want 200", label, r.RecoveryIntent())
		}
	}
	checkResult(res1, "set1")
	checkResult(res2, "set2")
	checkResult(res3, "set3")
}

// TestArbitrationResultTerminalPrecedenceOverRecoverReplacement verifies that
// terminal failures always take precedence over recover/replacement candidates,
// regardless of priority.
func TestArbitrationResultTerminalPrecedenceOverRecoverReplacement(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	makeOutcome := func(id string, kind FilterDecisionKind, priority int, decision FilterDecision) EpochFilterOutcome {
		oc, _ := NewFilterOutcomeEvaluated(decision)
		o, _ := NewEpochFilterOutcome(id, priority, oc, FilterEnforcementBlocking)
		return o
	}

	// Fatal at priority 1 with recover at priority 999.
	fatalDec, _ := NewFilterDecision(FilterDecisionKindFatal, "c.1", "fatal.filter", "r.fatal", evidence, nil)
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent999, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 999)
	violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "recover.filter", "r.recover", evidence, &intent999)

	set, _ := NewEvaluationSet([]EpochFilterOutcome{
		makeOutcome("fatal.filter", FilterDecisionKindFatal, 1, fatalDec),
		makeOutcome("recover.filter", FilterDecisionKindViolation, 999, violDec),
	})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionTerminal {
		t.Errorf("Action = %v, want terminal (fatal beats high-priority recover)", res.Action())
	}
	if res.FilterID() != "fatal.filter" {
		t.Errorf("FilterID = %s, want fatal.filter", res.FilterID())
	}
}

// TestArbitrationResultObserveOnlyExcludedFromOverride verifies that observe-only
// outcomes are never selected as action candidates.
func TestArbitrationResultObserveOnlyExcludedFromOverride(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	makeOutcome := func(id string, decision FilterDecision, enf FilterEnforcement) EpochFilterOutcome {
		oc, _ := NewFilterOutcomeEvaluated(decision)
		o, _ := NewEpochFilterOutcome(id, 100, oc, enf)
		return o
	}

	// Only observe-only violation.
	violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "obs.filter", "r.1", evidence, nil)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{
		makeOutcome("obs.filter", violDec, FilterEnforcementObserveOnly),
	})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionRelease {
		t.Errorf("Action = %v, want release (observe-only must not override)", res.Action())
	}
}

// TestArbitrationResultRecoverVsReplacementPriority verifies that when both
// recover and replacement candidates exist, they are sorted together by
// priority desc / filterID asc, and the highest-priority candidate wins.
func TestArbitrationResultRecoverVsReplacementPriority(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	makeEvalOutcome := func(id string, priority int, decision FilterDecision) EpochFilterOutcome {
		oc, _ := NewFilterOutcomeEvaluated(decision)
		o, _ := NewEpochFilterOutcome(id, priority, oc, FilterEnforcementBlocking)
		return o
	}

	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent100, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "recover.filter", "r.recover", evidence, &intent100)

	repEv, _ := NewTextDeltaEvent("ch", "replacement", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})
	replDec, _ := NewFilterDecisionWithReplacement(FilterDecisionKindReplacement, "c.1", "repl.filter", "r.repl", evidence, nil, &prop)

	// Recover at priority 100, replacement at priority 200.
	set, _ := NewEvaluationSet([]EpochFilterOutcome{
		makeEvalOutcome("recover.filter", 100, violDec),
		makeEvalOutcome("repl.filter", 200, replDec),
	})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	// Replacement has higher priority (200 > 100), so it should win.
	if res.Action() != ArbitrationActionReplacement {
		t.Errorf("Action = %v, want replacement (higher priority wins)", res.Action())
	}
	if res.FilterID() != "repl.filter" {
		t.Errorf("FilterID = %s, want repl.filter", res.FilterID())
	}
}

// TestArbitrationResultEmptySetDefaultAction verifies that an empty evaluation
// set falls through to the base disposition action.
func TestArbitrationResultEmptySetDefaultAction(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	// Non-terminal batch -> release_candidate -> release.
	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)
	set, _ := NewEvaluationSet(nil)
	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate empty set: %v", err)
	}
	if res.Action() != ArbitrationActionRelease {
		t.Errorf("Action = %v, want release", res.Action())
	}

	// Terminal success batch -> terminal_success_candidate -> terminal.
	eq2, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, true, CommitStateTransportUncommitted, ts,
	)
	res2, err := Arbitrate(context.Background(), eq2, set)
	if err != nil {
		t.Fatalf("Arbitrate empty terminal set: %v", err)
	}
	if res2.Action() != ArbitrationActionTerminal {
		t.Errorf("Action = %v, want terminal", res2.Action())
	}
}

// TestArbitrationResultDeferredNonTerminalHold verifies that blocking deferred
// in a non-terminal batch yields hold.
func TestArbitrationResultDeferredNonTerminalHold(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	oc := NewFilterOutcomeDeferredByRequirement()
	ef, _ := NewEpochFilterOutcome("defer.filter", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionHold {
		t.Errorf("Action = %v, want hold", res.Action())
	}
}

// TestArbitrationResultDeferredTerminalBatchYieldsTerminal verifies that
// blocking deferred in a terminal batch yields terminal.
func TestArbitrationResultDeferredTerminalBatchYieldsTerminal(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, true, CommitStateTransportUncommitted, ts,
	)

	oc := NewFilterOutcomeDeferredByRequirement()
	ef, _ := NewEpochFilterOutcome("defer.filter", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionTerminal {
		t.Errorf("Action = %v, want terminal", res.Action())
	}
}

// TestArbitrationResultIntentPriorityMismatchError verifies that a violation
// decision whose recovery intent has a different priority than the filter's
// effective priority is rejected.
func TestArbitrationResultIntentPriorityMismatchError(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent50, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 50)
	violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "f.1", "r.1", evidence, &intent50)

	oc, _ := NewFilterOutcomeEvaluated(violDec)
	ef, _ := NewEpochFilterOutcome("f.1", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	_, err := Arbitrate(context.Background(), eq, set)
	if err == nil {
		t.Error("Arbitrate with intent priority mismatch should return error")
	} else if !strings.Contains(err.Error(), "priority 50 mismatch") {
		t.Errorf("error = %v, want priority mismatch", err)
	}
}

// TestArbitrationResultRecoveryIntentPreserved verifies that the recovery intent
// priority and directive are preserved through the constructor.
func TestArbitrationResultRecoveryIntentPreserved(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 42)

	res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}

	ri := res.RecoveryIntent()
	if ri == nil {
		t.Fatal("RecoveryIntent() returned nil")
	}
	if ri.Priority() != 42 {
		t.Errorf("RecoveryIntent().Priority() = %d, want 42", ri.Priority())
	}
}

// TestArbitrationResultReplacementProposalPreserved verifies that the replacement
// proposal events are preserved through the constructor.
func TestArbitrationResultReplacementProposalPreserved(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	res, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}

	rp := res.ReplacementProposal()
	if rp == nil {
		t.Fatal("ReplacementProposal() returned nil")
	}
	events := rp.Events()
	if len(events) != 1 {
		t.Fatalf("ReplacementProposal events = %d, want 1", len(events))
	}
	text, _ := events[0].AsTextDelta()
	if text != "replaced" {
		t.Errorf("ReplacementProposal event text = %q, want 'replaced'", text)
	}
}

// TestArbitrationResultDefensiveCopyOnCallerMutation verifies that mutating
// the caller's intent and replacement after construction does not affect
// the stored result.
func TestArbitrationResultDefensiveCopyOnCallerMutation(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	// Mutate intent after construction.
	_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}
	intent2, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 999)
	_ = intent2
	// Re-construct to verify defensive copy.
	res2, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
	if err != nil {
		t.Fatalf("NewArbitrationResult 2: %v", err)
	}
	// The stored intent should still have priority 100.
	if res2.RecoveryIntent().Priority() != 100 {
		t.Errorf("Stored intent priority = %d, want 100", res2.RecoveryIntent().Priority())
	}

	// Mutate replacement after construction.
	_, err = NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
	if err != nil {
		t.Fatalf("NewArbitrationResult replacement: %v", err)
	}
	repEv2, _ := NewTextDeltaEvent("ch", "mutated", ts)
	prop2, _ := NewReplacementProposal([]NormalizedEvent{repEv2})
	_ = prop2
	// Re-construct to verify defensive copy.
	resRep2, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
	if err != nil {
		t.Fatalf("NewArbitrationResult replacement 2: %v", err)
	}
	if len(resRep2.ReplacementProposal().Events()) != 1 {
		t.Fatalf("Stored replacement events = %d, want 1", len(resRep2.ReplacementProposal().Events()))
	}
}

// TestArbitrationResultFilterRuleIDValidation verifies that filterID and ruleID
// are validated for stable token grammar.
func TestArbitrationResultFilterRuleIDValidation(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate

	// Bad filter id grammar.
	_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "BAD-ID!", "r.1", nil, nil)
	if err == nil {
		t.Error("NewArbitrationResult with bad filter id should return error")
	}

	// Bad rule id grammar.
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	_, err = NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "BAD-ID!", &intent, nil)
	if err == nil {
		t.Error("NewArbitrationResult with bad rule id should return error")
	}

	// Rule id without filter id.
	_, err = NewArbitrationResult(ArbitrationActionRecover, baseDisp, "", "r.1", &intent, nil)
	if err == nil {
		t.Error("NewArbitrationResult with rule id but no filter id should return error")
	}
}

// TestArbitrationResultBaseDispositionValidation verifies that invalid base
// dispositions are rejected.
func TestArbitrationResultBaseDispositionValidation(t *testing.T) {
	badDisp := BaseEventDisposition("invalid")
	_, err := NewArbitrationResult(ArbitrationActionRelease, badDisp, "", "", nil, nil)
	if err == nil {
		t.Error("NewArbitrationResult with invalid base disposition should return error")
	}
}

// TestArbitrationResultActionValidation verifies that invalid actions are rejected.
func TestArbitrationResultActionValidation(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate
	badAction := ArbitrationAction("invalid")
	_, err := NewArbitrationResult(badAction, baseDisp, "", "", nil, nil)
	if err == nil {
		t.Error("NewArbitrationResult with invalid action should return error")
	}
}

// TestArbitrationResultReleaseHoldNoFilterRule verifies that release and hold
// actions reject filterID and ruleID.
func TestArbitrationResultReleaseHoldNoFilterRule(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate

	// Release with filterID.
	_, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "f.1", "", nil, nil)
	if err == nil {
		t.Error("Release with filterID should return error")
	}

	// Release with ruleID.
	_, err = NewArbitrationResult(ArbitrationActionRelease, baseDisp, "f.1", "r.1", nil, nil)
	if err == nil {
		t.Error("Release with ruleID should return error")
	}

	// Hold with filterID.
	_, err = NewArbitrationResult(ArbitrationActionHold, baseDisp, "f.1", "", nil, nil)
	if err == nil {
		t.Error("Hold with filterID should return error")
	}
}

// TestArbitrationResultTerminalNoIntentReplacement verifies that terminal
// action rejects intent and replacement.
func TestArbitrationResultTerminalNoIntentReplacement(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionTerminalSuccessCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	// Terminal with intent.
	_, err := NewArbitrationResult(ArbitrationActionTerminal, baseDisp, "", "", &intent, nil)
	if err == nil {
		t.Error("Terminal with intent should return error")
	}

	// Terminal with replacement.
	_, err = NewArbitrationResult(ArbitrationActionTerminal, baseDisp, "", "", nil, &prop)
	if err == nil {
		t.Error("Terminal with replacement should return error")
	}
}

// TestArbitrationResultUnsetContextReturnsError verifies that Arbitrate returns
// the context error when the context is already cancelled.
func TestArbitrationResultUnsetContextReturnsError(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)
	set, _ := NewEvaluationSet(nil)

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	_, err := Arbitrate(ctx, eq, set)
	if err == nil {
		t.Error("Arbitrate with cancelled context should return error")
	} else if !errors.Is(err, context.Canceled) {
		t.Errorf("error = %v, want context.Canceled", err)
	}
}

// TestArbitrationResultBaseDispositionPreserved verifies that the base
// disposition from the evidence batch is preserved in the result.
func TestArbitrationResultBaseDispositionPreserved(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	// Terminal error candidate.
	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindProviderError, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, true, CommitStateTransportUncommitted, ts,
	)
	set, _ := NewEvaluationSet(nil)
	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.BaseDisposition() != BaseDispositionTerminalErrorCandidate {
		t.Errorf("BaseDisposition = %v, want terminal_error_candidate", res.BaseDisposition())
	}

	// Terminal success candidate.
	eq2, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, true, CommitStateTransportUncommitted, ts,
	)
	res2, err := Arbitrate(context.Background(), eq2, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res2.BaseDisposition() != BaseDispositionTerminalSuccessCandidate {
		t.Errorf("BaseDisposition = %v, want terminal_success_candidate", res2.BaseDisposition())
	}
}

// TestArbitrationResultDecisionArbiterWrapper verifies that DecisionArbiter
// delegates to the package-level Arbitrate function.
func TestArbitrationResultDecisionArbiterWrapper(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "f.1", "r.1", evidence, &intent)
	oc, _ := NewFilterOutcomeEvaluated(violDec)
	ef, _ := NewEpochFilterOutcome("f.1", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	arbiter := NewDecisionArbiter()
	res, err := arbiter.Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("DecisionArbiter.Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionRecover {
		t.Errorf("Action = %v, want recover", res.Action())
	}
	if res.FilterID() != "f.1" || res.RuleID() != "r.1" {
		t.Errorf("FilterID/RuleID = %s/%s, want f.1/r.1", res.FilterID(), res.RuleID())
	}
}

// TestArbitrationResultMultipleRecoverCandidatesSort verifies that multiple
// recover candidates are sorted by priority desc, filterID asc.
func TestArbitrationResultMultipleRecoverCandidatesSort(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	makeOutcome := func(id string, priority int, decision FilterDecision) EpochFilterOutcome {
		oc, _ := NewFilterOutcomeEvaluated(decision)
		o, _ := NewEpochFilterOutcome(id, priority, oc, FilterEnforcementBlocking)
		return o
	}

	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent100, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	violDec1, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "z.filter", "r.1", evidence, &intent100)
	violDec2, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "a.filter", "r.2", evidence, &intent100)

	// Same priority, different filter IDs: a.filter should win over z.filter.
	set, _ := NewEvaluationSet([]EpochFilterOutcome{
		makeOutcome("z.filter", 100, violDec1),
		makeOutcome("a.filter", 100, violDec2),
	})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.FilterID() != "a.filter" {
		t.Errorf("FilterID = %s, want a.filter (asc tie-breaker)", res.FilterID())
	}
	if res.RuleID() != "r.2" {
		t.Errorf("RuleID = %s, want r.2", res.RuleID())
	}
}

// TestArbitrationResultEvaluationErrorTerminal verifies that evaluation errors
// produce terminal outcomes.
func TestArbitrationResultEvaluationErrorTerminal(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	evalError, _ := NewFilterOutcomeEvaluationError("runtime.guardrail.timeout")
	ef, _ := NewEpochFilterOutcome("err.filter", 100, evalError, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionTerminal {
		t.Errorf("Action = %v, want terminal", res.Action())
	}
	if res.FilterID() != "err.filter" {
		t.Errorf("FilterID = %s, want err.filter", res.FilterID())
	}
}

// TestArbitrationResultViolationNoIntentTerminal verifies that a violation
// decision without a recovery intent produces terminal.
func TestArbitrationResultViolationNoIntentTerminal(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "f.1", "r.1", evidence, nil)
	oc, _ := NewFilterOutcomeEvaluated(violDec)
	ef, _ := NewEpochFilterOutcome("f.1", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionTerminal {
		t.Errorf("Action = %v, want terminal (violation without intent)", res.Action())
	}
}

// TestArbitrationResultPassDecisionDoesNotOverride verifies that pass decisions
// do not override the base disposition.
func TestArbitrationResultPassDecisionDoesNotOverride(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	passDec, _ := NewFilterDecision(FilterDecisionKindPass, "c.1", "f.1", "r.1", evidence, nil)
	oc, _ := NewFilterOutcomeEvaluated(passDec)
	ef, _ := NewEpochFilterOutcome("f.1", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	// Terminal batch with pass -> terminal.
	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, true, CommitStateTransportUncommitted, ts,
	)

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionTerminal {
		t.Errorf("Action = %v, want terminal (pass does not override terminal base)", res.Action())
	}
}

// TestArbitrationResultNotApplicableDoesNotBlock verifies that not_applicable
// outcomes do not block release.
func TestArbitrationResultNotApplicableDoesNotBlock(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	oc := NewFilterOutcomeNotApplicableForEpoch()
	ef, _ := NewEpochFilterOutcome("na.filter", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionRelease {
		t.Errorf("Action = %v, want release (not_applicable does not block)", res.Action())
	}
}

// TestArbitrationResultStagedStartHold verifies that a staged response start
// batch with no active outcomes yields hold.
func TestArbitrationResultStagedStartHold(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	st, _ := NewResponseStart("ch", 200, map[string]string{}, ts)
	eq, _ := NewEvidenceBatch(nil, map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{}, &st, false, CommitStateTransportUncommitted, ts)
	set, _ := NewEvaluationSet(nil)

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionHold {
		t.Errorf("Action = %v, want hold", res.Action())
	}
}

// TestArbitrationResultReplacementProposalEventsPreserved verifies that the
// replacement proposal events are deep-copied and accessible.
func TestArbitrationResultReplacementProposalEventsPreserved(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	repEv1, _ := NewTextDeltaEvent("ch", "event1", ts)
	repEv2, _ := NewTextDeltaEvent("ch", "event2", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv1, repEv2})

	res, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}

	events := res.ReplacementProposal().Events()
	if len(events) != 2 {
		t.Fatalf("Events count = %d, want 2", len(events))
	}
	text1, _ := events[0].AsTextDelta()
	text2, _ := events[1].AsTextDelta()
	if text1 != "event1" || text2 != "event2" {
		t.Errorf("Events = [%q, %q], want [event1, event2]", text1, text2)
	}
}

// TestArbitrationResultRecoverIntentDefensiveCopy verifies that the recovery
// intent returned by RecoveryIntent() is a defensive copy.
func TestArbitrationResultRecoverIntentDefensiveCopy(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)

	res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}

	// Get the intent twice; they should be independent copies.
	ri1 := res.RecoveryIntent()
	ri2 := res.RecoveryIntent()
	if ri1 == ri2 {
		t.Error("RecoveryIntent() should return independent copies")
	}
	if ri1.Priority() != ri2.Priority() {
		t.Errorf("Priorities differ: %d vs %d", ri1.Priority(), ri2.Priority())
	}
}

// TestArbitrationResultReplacementDefensiveCopy verifies that the replacement
// proposal returned by ReplacementProposal() is a defensive copy.
func TestArbitrationResultReplacementDefensiveCopy(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate
	repEv, _ := NewTextDeltaEvent("ch", "replaced", time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	res, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}

	rp1 := res.ReplacementProposal()
	rp2 := res.ReplacementProposal()
	if rp1 == rp2 {
		t.Error("ReplacementProposal() should return independent copies")
	}
	if len(rp1.Events()) != len(rp2.Events()) {
		t.Errorf("Event counts differ: %d vs %d", len(rp1.Events()), len(rp2.Events()))
	}
}

// TestArbitrationResultFilterIDAndRuleIDAccessors verifies that FilterID() and
// RuleID() return the correct values.
func TestArbitrationResultFilterIDAndRuleIDAccessors(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)

	res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "my.filter.id", "my.rule.id", &intent, nil)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}

	if res.FilterID() != "my.filter.id" {
		t.Errorf("FilterID() = %q, want 'my.filter.id'", res.FilterID())
	}
	if res.RuleID() != "my.rule.id" {
		t.Errorf("RuleID() = %q, want 'my.rule.id'", res.RuleID())
	}
}

// TestArbitrationResultEmptyFilterRuleIDForRelease verifies that release action
// returns empty filterID and ruleID.
func TestArbitrationResultEmptyFilterRuleIDForRelease(t *testing.T) {
	baseDisp := BaseDispositionReleaseCandidate
	res, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "", "", nil, nil)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}

	if res.FilterID() != "" {
		t.Errorf("FilterID() = %q, want empty", res.FilterID())
	}
	if res.RuleID() != "" {
		t.Errorf("RuleID() = %q, want empty", res.RuleID())
	}
	if res.RecoveryIntent() != nil {
		t.Error("RecoveryIntent() should be nil for release")
	}
	if res.ReplacementProposal() != nil {
		t.Error("ReplacementProposal() should be nil for release")
	}
}

// TestArbitrationResultHoldActionNoIntentReplacement verifies that hold action
// rejects intent and replacement.
func TestArbitrationResultHoldActionNoIntentReplacement(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionHold
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	_, err := NewArbitrationResult(ArbitrationActionHold, baseDisp, "", "", &intent, nil)
	if err == nil {
		t.Error("Hold with intent should return error")
	}

	_, err = NewArbitrationResult(ArbitrationActionHold, baseDisp, "", "", nil, &prop)
	if err == nil {
		t.Error("Hold with replacement should return error")
	}
}

// TestArbitrationResultRecoverWithReplacementError verifies that recover with
// replacement proposal is rejected.
func TestArbitrationResultRecoverWithReplacementError(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	_, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, &prop)
	if err == nil {
		t.Error("Recover with replacement should return error")
	}
}

// TestArbitrationResultReplacementWithIntentError verifies that replacement
// with intent is rejected.
func TestArbitrationResultReplacementWithIntentError(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	_, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", &intent, &prop)
	if err == nil {
		t.Error("Replacement with intent should return error")
	}
}

// TestArbitrationResultValidateConsistentWithConstructor verifies that the
// constructor and Validate agree on what is valid and invalid.
func TestArbitrationResultValidateConsistentWithConstructor(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	baseDisp := BaseDispositionReleaseCandidate
	dir, _ := NewRecoveryDirectiveExact("req.ref")
	intent, _ := NewRecoveryIntent(RecoveryStrategyExactReplay, dir, "rule.violated", 100)
	repEv, _ := NewTextDeltaEvent("ch", "replaced", ts)
	prop, _ := NewReplacementProposal([]NormalizedEvent{repEv})

	// Valid recover.
	res, err := NewArbitrationResult(ArbitrationActionRecover, baseDisp, "f.1", "r.1", &intent, nil)
	if err != nil {
		t.Fatalf("NewArbitrationResult: %v", err)
	}
	if err := res.Validate(); err != nil {
		t.Errorf("Validate() on valid recover = %v", err)
	}

	// Valid replacement.
	resRep, err := NewArbitrationResult(ArbitrationActionReplacement, baseDisp, "f.1", "r.1", nil, &prop)
	if err != nil {
		t.Fatalf("NewArbitrationResult replacement: %v", err)
	}
	if err := resRep.Validate(); err != nil {
		t.Errorf("Validate() on valid replacement = %v", err)
	}

	// Invalid recover (no ruleID) must fail Validate.
	badRecover := ArbitrationResult{
		action:          ArbitrationActionRecover,
		baseDisposition: baseDisp,
		filterID:        "f.1",
		ruleID:          "",
		intent:          &intent,
	}
	if err := badRecover.Validate(); err == nil {
		t.Error("Validate() on recover without ruleID should return error")
	}

	// Invalid replacement (no ruleID) must fail Validate.
	badReplace := ArbitrationResult{
		action:          ArbitrationActionReplacement,
		baseDisposition: baseDisp,
		filterID:        "f.1",
		ruleID:          "",
		replacement:     &prop,
	}
	if err := badReplace.Validate(); err == nil {
		t.Error("Validate() on replacement without ruleID should return error")
	}
}

// TestArbitrationResultDeferredByRequirementTerminalBatch verifies that
// blocking deferred in a terminal batch yields terminal.
func TestArbitrationResultDeferredByRequirementTerminalBatch(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTerminal, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, true, CommitStateTransportUncommitted, ts,
	)

	oc := NewFilterOutcomeDeferredByRequirement()
	ef, _ := NewEpochFilterOutcome("defer.filter", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionTerminal {
		t.Errorf("Action = %v, want terminal", res.Action())
	}
}

// TestArbitrationResultNonBlockingDeferredDoesNotBlock verifies that a
// non-blocking deferred outcome does not produce hold.
func TestArbitrationResultNonBlockingDeferredDoesNotBlock(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	// Non-blocking deferred (subscribed + trigger not ready + !blocksRelease).
	oc := NewFilterOutcomeNotApplicableForEpoch()
	ef, _ := NewEpochFilterOutcome("nonblock.defer", 100, oc, FilterEnforcementBlocking)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionRelease {
		t.Errorf("Action = %v, want release (non-blocking deferred does not block)", res.Action())
	}
}

// TestArbitrationResultObserveOnlyViolationDoesNotOverride verifies that
// observe-only violations do not override the base action.
func TestArbitrationResultObserveOnlyViolationDoesNotOverride(t *testing.T) {
	ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
	evidence := buildValidEvidence(t, ts)

	violDec, _ := NewFilterDecision(FilterDecisionKindViolation, "c.1", "obs.filter", "r.1", evidence, nil)
	oc, _ := NewFilterOutcomeEvaluated(violDec)
	ef, _ := NewEpochFilterOutcome("obs.filter", 100, oc, FilterEnforcementObserveOnly)
	set, _ := NewEvaluationSet([]EpochFilterOutcome{ef})

	eq, _ := NewEvidenceBatch(
		[]NormalizedEvent{buildValidEvent(t, EventKindTextDelta, ts)},
		map[string][]NormalizedEvent{}, map[string][]NormalizedEvent{},
		nil, false, CommitStateTransportUncommitted, ts,
	)

	res, err := Arbitrate(context.Background(), eq, set)
	if err != nil {
		t.Fatalf("Arbitrate: %v", err)
	}
	if res.Action() != ArbitrationActionRelease {
		t.Errorf("Action = %v, want release (observe-only violation does not override)", res.Action())
	}
}
