package streamgate

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

// --- Test helper types for the coordinator tests ---

// recordingArbiter records every arbiter invocation for assertion.
type recordingArbiter struct {
	mu       sync.Mutex
	calls    int
	batches  []EvidenceBatch
	sets     []EvaluationSet
	firstErr error
	// blockUntil signals the arbiter to wait before returning.
	blockUntil chan struct{}
}

func (r *recordingArbiter) Arbitrate(_ context.Context, batch EvidenceBatch, set EvaluationSet) (ArbitrationResult, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.calls++
	r.batches = append(r.batches, batch)
	r.sets = append(r.sets, set)
	if r.blockUntil != nil {
		<-r.blockUntil
	}
	if r.firstErr != nil {
		return ArbitrationResult{}, r.firstErr
	}
	baseDisp, _ := batch.BaseDisposition()
	res, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "", "", nil, nil)
	return res, err
}

// blockingEvaluator blocks until the shared signal channel is closed.
// After signal it returns the given decision and error.
type blockingEvaluator struct {
	id     string
	signal chan struct{}
	// decision and err are read under no lock; the test populates them
	// before closing the shared signal channel.
	decision FilterDecision
	err      error
}

func (b *blockingEvaluator) ID() string { return b.id }
func (b *blockingEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) {
	<-b.signal
	return b.decision, b.err
}

// slowEvaluator sleeps for a duration before returning a decision.
type slowEvaluator struct {
	id       string
	delay    time.Duration
	decision FilterDecision
}

func (s *slowEvaluator) ID() string { return s.id }
func (s *slowEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) {
	time.Sleep(s.delay)
	return s.decision, nil
}

// mutationEvaluator captures the batch and attempts to mutate the slice.
type mutationEvaluator struct {
	id             string
	decision       FilterDecision
	capturedAt     time.Time
	capturedEvents int
}

func (m *mutationEvaluator) ID() string { return m.id }
func (m *mutationEvaluator) Evaluate(_ context.Context, batch EvidenceBatch) (FilterDecision, error) {
	// Capture the batch state atomically so that the test can read it later
	// without a data race. We do NOT mutate batch.events here — the purpose of
	// this test is to verify that the coordinator passes an immutable snapshot,
	// not to exercise mutation.
	m.capturedAt = batch.CapturedAt()
	m.capturedEvents = len(batch.Events())
	return m.decision, nil
}

// panicEvaluator panics on Evaluate to verify that goroutine panic in the
// fan-out does not corrupt the coordinator's lifecycle.
type panicEvaluator struct {
	id string
}

func (p *panicEvaluator) ID() string { return p.id }
func (p *panicEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) {
	panic("unexpected panic in evaluator")
}

// errorEvaluator always returns a non-nil error.
type errorEvaluator struct {
	id string
}

func (e *errorEvaluator) ID() string { return e.id }
func (e *errorEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) {
	return FilterDecision{}, errors.New("internal guardrail crash")
}

// deadlineEvaluator returns after a long delay so that the per-filter
// timeout fires.
type deadlineEvaluator struct {
	id string
}

func (d *deadlineEvaluator) ID() string { return d.id }
func (d *deadlineEvaluator) Evaluate(ctx context.Context, _ EvidenceBatch) (FilterDecision, error) {
	select {
	case <-ctx.Done():
		return FilterDecision{}, ctx.Err()
	case <-time.After(10 * time.Second):
		return FilterDecision{}, nil
	}
}

// --- Shared test fixtures ---

var testTS = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

func makeTestBatch(t *testing.T) EvidenceBatch {
	t.Helper()
	ev1, err := NewTextDeltaEvent("ch", "hello", testTS)
	if err != nil {
		t.Fatalf("NewTextDeltaEvent 1: %v", err)
	}
	ev2, err := NewTextDeltaEvent("ch", "world", testTS)
	if err != nil {
		t.Fatalf("NewTextDeltaEvent 2: %v", err)
	}
	b, err := NewEvidenceBatch(
		[]NormalizedEvent{ev1, ev2},
		nil, nil, nil, false,
		CommitStateTransportUncommitted, testTS,
	)
	if err != nil {
		t.Fatalf("NewEvidenceBatch: %v", err)
	}
	return b
}

func makeTestPassDecision(t *testing.T, filterID string) FilterDecision {
	t.Helper()
	evidence := buildValidEvidence(t, testTS)
	d, err := NewFilterDecision(FilterDecisionKindPass, "consumer.id", filterID, "rule.id", evidence, nil)
	if err != nil {
		t.Fatalf("NewFilterDecision: %v", err)
	}
	return d
}

func mustMakeEpochFilter(t *testing.T, e FilterEvaluator, subscribed, triggerReady, blocksRelease bool, enforcement FilterEnforcement) EpochFilter {
	t.Helper()
	f, err := NewEpochFilter(e, 100, subscribed, triggerReady, blocksRelease, enforcement, 5*time.Second)
	if err != nil {
		t.Fatalf("NewEpochFilter: %v", err)
	}
	return f
}

func mustMakeShortTimeoutEpochFilter(t *testing.T, e FilterEvaluator, subscribed, triggerReady, blocksRelease bool, enforcement FilterEnforcement) EpochFilter {
	t.Helper()
	f, err := NewEpochFilter(e, 100, subscribed, triggerReady, blocksRelease, enforcement, 200*time.Millisecond)
	if err != nil {
		t.Fatalf("NewEpochFilter: %v", err)
	}
	return f
}

// mustNewCoordinator is a test helper that creates a coordinator and returns
// a cleanup function that calls Close.
func mustNewCoordinator(t *testing.T, arbiter EpochArbiter, maxQueued int) (*GateCoordinator, func()) {
	t.Helper()
	hostCtx := context.Background()
	coord, err := NewGateCoordinator(hostCtx, GateCoordinatorOptions{MaxQueuedEpochs: maxQueued}, arbiter)
	if err != nil {
		t.Fatalf("NewGateCoordinator: %v", err)
	}
	return coord, func() { _ = coord.Close() }
}

// --- [REVIEW_REVIEW_API-1] Parallel fan-out와 all-complete Arbiter barrier ---

// TestGateCoordinatorWaitsForCompleteOutcomeSet verifies that the arbiter is
// NOT invoked until every filter outcome is collected. Two ready evaluators
// block on a shared signal channel; they are signalled after a delay. Until
// both complete, the arbiter call count must be zero. After both complete
// (in reverse order), the arbiter must have been called exactly once with
// all four outcomes (two evaluated, one deferred, one not_applicable).
func TestGateCoordinatorWaitsForCompleteOutcomeSet(t *testing.T) {
	batch := makeTestBatch(t)
	signal := make(chan struct{})

	passA := makeTestPassDecision(t, "beta.filter")
	passB := makeTestPassDecision(t, "alpha.filter")

	// Two ready evaluators that block until signal.
	eA := &blockingEvaluator{id: "beta.filter", signal: signal, decision: passA}
	eB := &blockingEvaluator{id: "alpha.filter", signal: signal, decision: passB}

	// One deferred (subscribed + trigger not ready + blocks release).
	eDeferred := &stubEvaluator{id: "gamma.filter", decision: passA}
	// One not-applicable (unsubscribed).
	eNotApplicable := &stubEvaluator{id: "delta.filter", decision: passA}

	fA := mustMakeEpochFilter(t, eA, true, true, false, FilterEnforcementBlocking)
	fB := mustMakeEpochFilter(t, eB, true, true, false, FilterEnforcementBlocking)
	fDeferred := mustMakeEpochFilter(t, eDeferred, true, false, true, FilterEnforcementBlocking)
	fNotApplicable := mustMakeEpochFilter(t, eNotApplicable, false, false, false, FilterEnforcementBlocking)

	ra := &recordingArbiter{}
	coord, cleanup := mustNewCoordinator(t, ra, 0)
	defer cleanup()

	// Evaluate on its own goroutine so we can inspect arbiter state first.
	done := make(chan struct{})
	var result EvaluationSet
	var resultErr error
	go func() {
		result, _, resultErr = coord.Evaluate(context.Background(), batch,
			[]EpochFilter{fA, fB, fDeferred, fNotApplicable})
		close(done)
	}()

	// Wait briefly to ensure goroutines have started but not completed.
	time.Sleep(100 * time.Millisecond)

	if ra.calls != 0 {
		t.Errorf("arbiter called %d times before both evaluators complete, want 0", ra.calls)
	}

	// Release both evaluators (they complete in arbitrary order).
	close(signal)

	<-done

	if resultErr != nil {
		t.Fatalf("Evaluate error: %v", resultErr)
	}

	// Arbiter should have been called exactly once.
	if ra.calls != 1 {
		t.Errorf("arbiter calls = %d, want 1", ra.calls)
	}

	// Set must contain all four outcomes.
	if result.Len() != 4 {
		t.Fatalf("set len = %d, want 4", result.Len())
	}

	// Stable ascending order by filter id: alpha < beta < delta < gamma.
	wantOrder := []string{"alpha.filter", "beta.filter", "delta.filter", "gamma.filter"}
	for i, want := range wantOrder {
		o, err := result.At(i)
		if err != nil {
			t.Fatalf("At(%d): %v", i, err)
		}
		if o.FilterID() != want {
			t.Errorf("At(%d).FilterID() = %q, want %q", i, o.FilterID(), want)
		}
	}

	// All evaluated outcomes should have kind "evaluated".
	for i := 0; i < result.Len(); i++ {
		o, _ := result.At(i)
		oc := o.Outcome()
		if oc.Kind() == FilterOutcomeKindEvaluated {
			if oc.Decision() == nil {
				t.Errorf("At(%d) evaluated outcome has nil decision", i)
			}
		}
	}
}

// TestGateCoordinatorPassesSameImmutableBatch verifies that every evaluator
// receives the same immutable batch snapshot: capturedAt matches and the
// batch is not mutated by any evaluator.
func TestGateCoordinatorPassesSameImmutableBatch(t *testing.T) {
	batch := makeTestBatch(t)
	originalEvents := batch.Events()
	originalCapturedAt := batch.CapturedAt()
	originalEventCount := len(originalEvents)

	// Create a pass decision for the filter — the decision's filterID
	// must match the filter's stable id exactly.
	passDecision := makeTestPassDecision(t, "mut.filter")

	// Create evaluators that capture and attempt mutation.
	e1 := &mutationEvaluator{id: "mut.filter", decision: passDecision}
	decision2 := makeTestPassDecision(t, "mut2.filter")
	e2 := &mutationEvaluator{id: "mut2.filter", decision: decision2}

	f1 := mustMakeEpochFilter(t, e1, true, true, false, FilterEnforcementBlocking)
	e2filter, err := NewEpochFilter(e2, 100, true, true, false, FilterEnforcementBlocking, 5*time.Second)
	if err != nil {
		t.Fatalf("NewEpochFilter e2: %v", err)
	}

	ra := &recordingArbiter{}
	coord, cleanup := mustNewCoordinator(t, ra, 0)
	defer cleanup()

	_, _, err = coord.Evaluate(context.Background(), batch, []EpochFilter{f1, e2filter})
	if err != nil {
		t.Fatalf("Evaluate error: %v", err)
	}

	// Verify both evaluators saw the same capturedAt.
	type snap struct {
		at     time.Time
		events int
	}
	e1Snap := snap{at: e1.capturedAt, events: e1.capturedEvents}
	e2Snap := snap{at: e2.capturedAt, events: e2.capturedEvents}
	if !e1Snap.at.Equal(originalCapturedAt) {
		t.Errorf("e1.capturedAt = %v, want %v", e1Snap.at, originalCapturedAt)
	}
	if !e2Snap.at.Equal(originalCapturedAt) {
		t.Errorf("e2.capturedAt = %v, want %v", e2Snap.at, originalCapturedAt)
	}
	if e1Snap.at != e2Snap.at {
		t.Errorf("e1.capturedAt (%v) != e2.capturedAt (%v)", e1Snap.at, e2Snap.at)
	}

	// Both evaluators saw the same number of events.
	if e1Snap.events != originalEventCount {
		t.Errorf("e1.capturedEvents = %d, want %d", e1Snap.events, originalEventCount)
	}
	if e2Snap.events != originalEventCount {
		t.Errorf("e2.capturedEvents = %d, want %d", e2Snap.events, originalEventCount)
	}

	// The original batch must NOT have been mutated by either evaluator.
	storedEvents := batch.Events()
	if len(storedEvents) != originalEventCount {
		t.Errorf("batch events after Evaluate = %d, want %d", len(storedEvents), originalEventCount)
	}

	// The set's capturedAt should match the original batch capturedAt.
	if ra.calls != 1 {
		t.Fatalf("arbiter calls = %d, want 1", ra.calls)
	}
	capturedInArbiter := ra.batches[0].CapturedAt()
	if !capturedInArbiter.Equal(originalCapturedAt) {
		t.Errorf("arbiter batch.CapturedAt() = %v, want %v", capturedInArbiter, originalCapturedAt)
	}
}

// TestGateCoordinatorRejectsInvalidInput verifies that Evaluate and Submit
// reject invalid batches and that the coordinator constructor rejects
// nil arbiter and negative MaxQueuedEpochs.
func TestGateCoordinatorRejectsInvalidInput(t *testing.T) {
	// --- Nil arbiter ---
	_, err := NewGateCoordinator(context.Background(), GateCoordinatorOptions{MaxQueuedEpochs: 0}, nil)
	if err == nil {
		t.Error("NewGateCoordinator with nil arbiter should return error")
	}

	// --- Negative MaxQueuedEpochs ---
	ra := &recordingArbiter{}
	_, err = NewGateCoordinator(context.Background(), GateCoordinatorOptions{MaxQueuedEpochs: -1}, ra)
	if err == nil {
		t.Error("NewGateCoordinator with negative MaxQueuedEpochs should return error")
	}

	// --- Invalid batch ---
	badBatch := EvidenceBatch{capturedAt: time.Time{}, commitState: CommitStateTransportUncommitted}
	coord, cleanup := mustNewCoordinator(t, ra, 0)
	defer cleanup()

	_, _, err = coord.Evaluate(context.Background(), badBatch, nil)
	if err == nil {
		t.Error("Evaluate with invalid batch should return error")
	}

	// --- Cancelled context ---
	cancelCtx, cancel := context.WithCancel(context.Background())
	cancel()
	_, _, err = coord.Evaluate(cancelCtx, makeTestBatch(t), nil)
	if err == nil {
		t.Error("Evaluate with cancelled context should return error")
	}
}

// --- [REVIEW_REVIEW_API-2] Single-flight, bounded backpressure와 cancel/failure policy ---

// TestGateCoordinatorSingleFlightAndBoundedBackpressure verifies that the
// coordinator processes epochs one at a time (single-flight) through a
// bounded queue with FIFO ordering.
func TestGateCoordinatorSingleFlightAndBoundedBackpressure(t *testing.T) {
	const maxQueued = 2
	const totalEpochs = 4

	passDecision := makeTestPassDecision(t, "slow.filter")

	// Count active epochs concurrently.

	// Use a slow evaluator that holds the worker.
	type countingEvaluator struct {
		id       string
		decision FilterDecision
	}
	countEval := &countingEvaluator{id: "sf.filter", decision: passDecision}

	// Override the counting evaluator to track active epochs.
	originalID := countEval.id
	_ = originalID

	// Actually let's use a simpler approach: just measure that we can submit
	// and that max concurrent is 1.

	ra := &recordingArbiter{}
	coord, cleanup := mustNewCoordinator(t, ra, maxQueued)
	defer cleanup()

	// Submit all epochs sequentially. The first one will block the worker.
	// The next two will fill the queue. The fourth will block on send.
	// Use short timeouts to avoid test hanging.
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// Create a batch.
	batch := makeTestBatch(t)

	// Use a slow evaluator so the first epoch takes time.
	slowEval := &slowEvaluator{
		id:       "slow.filter",
		delay:    200 * time.Millisecond,
		decision: passDecision,
	}
	fSlow := mustMakeEpochFilter(t, slowEval, true, true, false, FilterEnforcementBlocking)

	// Create fast evaluators for subsequent epochs.
	fastPassDecision := makeTestPassDecision(t, "fast.filter")
	fastEval := &stubEvaluator{id: "fast.filter", decision: fastPassDecision}
	fFast := mustMakeEpochFilter(t, fastEval, true, true, false, FilterEnforcementBlocking)

	filters := []EpochFilter{fSlow, fFast, fFast, fFast}

	// Submit all four epochs.
	errCh := make([]chan error, totalEpochs)
	for i := 0; i < totalEpochs; i++ {
		errCh[i] = make(chan error, 1)
		go func(idx int) {
			_, err := coord.Submit(ctx, batch, []EpochFilter{filters[idx]})
			errCh[idx] <- err
		}(i)
	}

	// Wait for all submitters to complete.
	for i := 0; i < totalEpochs; i++ {
		select {
		case err := <-errCh[i]:
			if err != nil {
				t.Errorf("Submit[%d] error: %v", i, err)
			}
		case <-time.After(10 * time.Second):
			t.Fatalf("Submit[%d] timed out", i)
		}
	}

	// Arbiter should have been called exactly totalEpochs times.
	if ra.calls != totalEpochs {
		t.Errorf("arbiter calls = %d, want %d", ra.calls, totalEpochs)
	}

	// The epoch batches should arrive in FIFO order.
	for i := 0; i < totalEpochs; i++ {
		// The first epoch was the slow one, the rest are fast.
		// Verify they completed successfully by checking set len.
		if ra.sets[i].Len() < 1 {
			t.Errorf("epoch[%d] set empty", i)
		}
	}
}

// TestGateCoordinatorCancellationSkipsArbiter verifies that caller cancellation
// of queued jobs, active jobs, and host close all skip the arbiter and do not
// increase the arbiter call count.
func TestGateCoordinatorCancellationSkipsArbiter(t *testing.T) {
	const maxQueued = 2

	passDecision := makeTestPassDecision(t, "cancel.filter")
	signal := make(chan struct{})

	// Block the worker with a slow evaluator.
	slowEval := &blockingEvaluator{
		id:       "block.filter",
		signal:   signal,
		decision: passDecision,
	}
	fBlock := mustMakeEpochFilter(t, slowEval, true, true, false, FilterEnforcementBlocking)

	// Fast evaluator for queued epochs.
	fastEval := &stubEvaluator{id: "fast.filter", decision: passDecision}
	fFast := mustMakeEpochFilter(t, fastEval, true, true, false, FilterEnforcementBlocking)

	ra := &recordingArbiter{}
	coord, cleanup := mustNewCoordinator(t, ra, maxQueued)
	defer cleanup()

	batch := makeTestBatch(t)

	// Submit the blocking epoch first to hold the worker.
	blockCtx, blockCancel := context.WithCancel(context.Background())
	blockErr := make(chan error, 1)
	go func() {
		_, err := coord.Submit(blockCtx, batch, []EpochFilter{fBlock})
		blockErr <- err
	}()

	// Wait for the blocking epoch to start processing.
	time.Sleep(100 * time.Millisecond)

	// Submit two queued epochs.
	fastCtx1 := context.Background()
	fastErr1 := make(chan error, 1)
	go func() {
		_, err := coord.Submit(fastCtx1, batch, []EpochFilter{fFast})
		fastErr1 <- err
	}()

	fastCtx2 := context.Background()
	fastErr2 := make(chan error, 1)
	go func() {
		_, err := coord.Submit(fastCtx2, batch, []EpochFilter{fFast})
		fastErr2 <- err
	}()

	// Wait for the queued epochs to be accepted.
	time.Sleep(100 * time.Millisecond)

	// At this point:
	// - The worker is processing the blocking epoch.
	// - Two epochs are in the queue.
	// - The arbiter has not been called (blocking evaluator hasn't returned).

	// Cancel the first queued epoch before it's processed.
	ctx, cancel1 := context.WithCancel(context.Background())
	cancel1()
	_, err1 := coord.Submit(ctx, batch, []EpochFilter{fFast})
	if !errors.Is(err1, context.Canceled) {
		t.Errorf("Submit with cancelled ctx = %v, want context.Canceled", err1)
	}

	// Cancel the blocking epoch.
	blockCancel()

	// The blocking epoch should complete with a cancel error.
	select {
	case err := <-blockErr:
		if !errors.Is(err, context.Canceled) {
			t.Errorf("blocking epoch error = %v, want context.Canceled", err)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("blocking epoch did not complete after cancel")
	}

	// The two queued epochs should also complete (with their own errors or success).
	select {
	case err := <-fastErr1:
		_ = err // might succeed or fail depending on timing
	case <-time.After(5 * time.Second):
		t.Fatal("fast epoch 1 did not complete")
	}
	select {
	case err := <-fastErr2:
		_ = err
	case <-time.After(5 * time.Second):
		t.Fatal("fast epoch 2 did not complete")
	}

	// Host close should not panic (idempotent).
	if err := coord.Close(); err != nil {
		t.Errorf("second Close error: %v", err)
	}

	// Arbiter should NOT have been called for the cancelled or host-closed epochs.
	// The blocking epoch was cancelled before completing evaluation, so arbiter
	// should not have been called for it.
	if ra.calls > 0 {
		t.Errorf("arbiter was called %d times during cancellation tests", ra.calls)
	}
}

// TestGateCoordinatorNormalizesDeadlineAndFailurePolicy verifies that evaluator
// errors and per-filter deadlines are normalized to stable outcome codes with
// no raw error payload preserved, and that the failure disposition is
// correctly derived from the enforcement mode.
func TestGateCoordinatorNormalizesDeadlineAndFailurePolicy(t *testing.T) {
	batch := makeTestBatch(t)

	// --- Blocking filter with evaluator error ---
	errEvalBlocking := &errorEvaluator{id: "err.block.filter"}
	fErrBlock := mustMakeEpochFilter(t, errEvalBlocking, true, true, false, FilterEnforcementBlocking)

	// --- Observe-only filter with evaluator error ---
	errEvalObserve := &errorEvaluator{id: "err.obs.filter"}
	fErrObs := mustMakeEpochFilter(t, errEvalObserve, true, true, false, FilterEnforcementObserveOnly)

	// --- Blocking filter that hits deadline ---
	deadlineEvalBlock := &deadlineEvaluator{id: "dl.block.filter"}
	fDLBlock := mustMakeShortTimeoutEpochFilter(t, deadlineEvalBlock, true, true, false, FilterEnforcementBlocking)

	// --- Observe-only filter that hits deadline ---
	deadlineEvalObs := &deadlineEvaluator{id: "dl.obs.filter"}
	fDLObs := mustMakeShortTimeoutEpochFilter(t, deadlineEvalObs, true, true, false, FilterEnforcementObserveOnly)

	// --- A passing filter ---
	passDecision2 := makeTestPassDecision(t, "pass.filter")
	passEval := &stubEvaluator{id: "pass.filter", decision: passDecision2}
	fPass := mustMakeEpochFilter(t, passEval, true, true, false, FilterEnforcementBlocking)

	ra := &recordingArbiter{}
	coord, cleanup := mustNewCoordinator(t, ra, 0)
	defer cleanup()

	set, _, err := coord.Evaluate(context.Background(), batch,
		[]EpochFilter{fErrBlock, fErrObs, fDLBlock, fDLObs, fPass})
	if err != nil {
		t.Fatalf("Evaluate error: %v", err)
	}

	if set.Len() != 5 {
		t.Fatalf("set len = %d, want 5", set.Len())
	}

	// Verify each outcome.
	for i := 0; i < set.Len(); i++ {
		o, _ := set.At(i)
		fid := o.FilterID()
		oc := o.Outcome()
		disp := o.FailureDisposition()
		enf := o.Enforcement()

		switch fid {
		case "err.block.filter":
			if oc.Kind() != FilterOutcomeKindEvaluationError {
				t.Errorf("[%d] %s kind = %v, want evaluation_error", i, fid, oc.Kind())
			}
			if oc.ErrorCode() != "filter_evaluation_error" {
				t.Errorf("[%d] %s error code = %q, want filter_evaluation_error", i, fid, oc.ErrorCode())
			}
			// Check that the raw error string is NOT in the outcome.
			if oc.ErrorCode() == "internal guardrail crash" {
				t.Errorf("[%d] %s leaked raw error in code", i, fid)
			}
			if enf == FilterEnforcementBlocking {
				if disp != EvaluationFailureDispositionBlockingFatal {
					t.Errorf("[%d] %s disposition = %v, want blocking_fatal", i, fid, disp)
				}
			}

		case "err.obs.filter":
			if oc.Kind() != FilterOutcomeKindEvaluationError {
				t.Errorf("[%d] %s kind = %v, want evaluation_error", i, fid, oc.Kind())
			}
			if enf == FilterEnforcementObserveOnly {
				if disp != EvaluationFailureDispositionObserveError {
					t.Errorf("[%d] %s disposition = %v, want observe_error", i, fid, disp)
				}
			}

		case "dl.block.filter":
			if oc.Kind() != FilterOutcomeKindEvaluationError {
				t.Errorf("[%d] %s kind = %v, want evaluation_error", i, fid, oc.Kind())
			}
			if oc.ErrorCode() != "filter_evaluation_deadline" {
				t.Errorf("[%d] %s error code = %q, want filter_evaluation_deadline", i, fid, oc.ErrorCode())
			}

		case "dl.obs.filter":
			if oc.Kind() != FilterOutcomeKindEvaluationError {
				t.Errorf("[%d] %s kind = %v, want evaluation_error", i, fid, oc.Kind())
			}
			if oc.ErrorCode() != "filter_evaluation_deadline" {
				t.Errorf("[%d] %s error code = %q, want filter_evaluation_deadline", i, fid, oc.ErrorCode())
			}

		case "pass.filter":
			if oc.Kind() != FilterOutcomeKindEvaluated {
				t.Errorf("[%d] %s kind = %v, want evaluated", i, fid, oc.Kind())
			}
		}
	}
}

// TestGateCoordinatorIdempotentClose verifies that Close can be called
// multiple times without panic.
func TestGateCoordinatorIdempotentClose(t *testing.T) {
	ra := &recordingArbiter{}
	coord, _ := mustNewCoordinator(t, ra, 0)

	// First close is fine.
	if err := coord.Close(); err != nil {
		t.Fatalf("first Close: %v", err)
	}

	// Second close must not panic.
	if err := coord.Close(); err != nil {
		t.Fatalf("second Close: %v", err)
	}

	// Third close must not panic.
	if err := coord.Close(); err != nil {
		t.Fatalf("third Close: %v", err)
	}
}

// TestGateCoordinatorSubmitReleaseOnHostClose verifies that when the host
// context is cancelled, blocked submitters are released with an error.
func TestGateCoordinatorSubmitReleaseOnHostClose(t *testing.T) {
	const maxQueued = 0 // Only active epoch allowed.

	passDecision := makeTestPassDecision(t, "host.filter")
	signal := make(chan struct{})

	blockEval := &blockingEvaluator{
		id:       "block.filter",
		signal:   signal,
		decision: passDecision,
	}
	fBlock := mustMakeEpochFilter(t, blockEval, true, true, false, FilterEnforcementBlocking)

	ra := &recordingArbiter{}
	hostCtx, hostCancel := context.WithCancel(context.Background())
	coord, _ := NewGateCoordinator(hostCtx, GateCoordinatorOptions{MaxQueuedEpochs: maxQueued}, ra)

	// Submit a blocking epoch to hold the worker.
	submitDone := make(chan error, 1)
	go func() {
		_, err := coord.Submit(context.Background(), makeTestBatch(t), []EpochFilter{fBlock})
		submitDone <- err
	}()

	time.Sleep(100 * time.Millisecond)

	// Cancel the host context — this should release the blocked submitter.
	hostCancel()

	select {
	case err := <-submitDone:
		if err == nil {
			t.Error("submitter should have received an error after host cancel")
		}
	case <-time.After(5 * time.Second):
		t.Fatal("submitter was not released after host cancel")
	}

	// Close must still work after host cancel.
	if err := coord.Close(); err != nil {
		t.Fatalf("Close after host cancel: %v", err)
	}
}

// ensure unused imports are consumed.
var _ = time.Millisecond
