package streamgate_test

import (
	"context"
	"encoding/json"
	"errors"
	"sort"
	"strings"
	"testing"
	"time"

	"iop/packages/go/streamgate"
)

// fakeOpenAIRawKind is an OpenAI-family-only raw event kind enum. It is a
// distinct Go type from streamgate.EventKind and from fakeExternalRawKind, so the
// OpenAI codec cannot accidentally consume a production kind or an agent-kind
// constant. Each constant is owned by the OpenAI converter alone.
type fakeOpenAIRawKind string

const (
	rawOpenAIResponseStart    fakeOpenAIRawKind = "openai_response_start"
	rawOpenAITextDelta        fakeOpenAIRawKind = "openai_text_delta"
	rawOpenAIReasoningDelta   fakeOpenAIRawKind = "openai_reasoning_delta"
	rawOpenAIToolCallFragment fakeOpenAIRawKind = "openai_tool_call_fragment"
	rawOpenAITerminal         fakeOpenAIRawKind = "openai_terminal"
	rawOpenAIProviderError    fakeOpenAIRawKind = "openai_provider_error"
)

// fakeExternalRawKind is an agent-family-only raw event kind enum. It is a
// distinct Go type from streamgate.EventKind and from fakeOpenAIRawKind, so
// the agent codec cannot accidentally consume a production kind or an
// OpenAI-kind constant.
type fakeExternalRawKind string

const (
	rawAgentResponseStart    fakeExternalRawKind = "agent_response_start"
	rawAgentTextDelta        fakeExternalRawKind = "agent_text_delta"
	rawAgentReasoningDelta   fakeExternalRawKind = "agent_reasoning_delta"
	rawAgentToolCallFragment fakeExternalRawKind = "agent_tool_call_fragment"
	rawAgentTerminal         fakeExternalRawKind = "agent_terminal"
	rawExternalProviderError fakeExternalRawKind = "external_provider_error"
)

// fakeOpenAIProviderFrame simulates an OpenAI provider tunnel codec by
// translating raw provider-frame inputs (status, text, reasoning, tool call,
// terminal, error) into normalized events and terminals. It is keyed by its
// own distinct fakeOpenAIRawKind enum, not by streamgate.EventKind. It does
// not import any apps/internal or proto types.
type fakeOpenAIProviderFrame struct {
	kind         fakeOpenAIRawKind
	status       int
	headers      map[string]string
	text         string
	reasoning    string
	toolCallID   string
	toolCallName string
	toolCallArgs string
	errorType    string
	errorCode    string
	errorMessage string
	errorParam   string
}

// fakeExternalRuntimeEvent simulates an agent-family runtime event codec with
// its own distinct fakeExternalRawKind enum, but with runtime-event fields. It
// does not delegate to fakeOpenAIProviderFrame and does not import any
// apps/internal or proto types.
type fakeExternalRuntimeEvent struct {
	kind         fakeExternalRawKind
	text         string
	reasoning    string
	toolCallID   string
	toolCallName string
	toolCallArgs string
	errorType    string
	errorCode    string
	errorMessage string
	errorParam   string
}

// normalizeOpenAIFrame converts a fakeOpenAIProviderFrame's raw inputs into a
// normalized event using production constructors. Each fakeOpenAIRawKind
// constant maps directly to its production constructor without calling any
// other converter.
func normalizeOpenAIFrame(f fakeOpenAIProviderFrame) (streamgate.NormalizedEvent, error) {
	ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
	switch f.kind {
	case rawOpenAIResponseStart:
		status := f.status
		if status == 0 {
			status = 200
		}
		headers := f.headers
		if headers == nil {
			headers = map[string]string{}
		}
		return streamgate.NewResponseStartEvent("ch-1", status, headers, ts)
	case rawOpenAITextDelta:
		return streamgate.NewTextDeltaEvent("ch-1", f.text, ts)
	case rawOpenAIReasoningDelta:
		return streamgate.NewReasoningDeltaEvent("ch-1", f.reasoning, ts)
	case rawOpenAIToolCallFragment:
		id := f.toolCallID
		if id == "" {
			id = "tc-1"
		}
		name := f.toolCallName
		if name == "" {
			name = "test"
		}
		args := f.toolCallArgs
		if args == "" {
			args = "{}"
		}
		return streamgate.NewToolCallFragmentEvent("ch-1", id, name, args, ts)
	case rawOpenAITerminal:
		return streamgate.NewTerminalEvent("ch-1", ts)
	case rawOpenAIProviderError:
		errorType := f.errorType
		if errorType == "" {
			errorType = "provider_error"
		}
		errorCode := f.errorCode
		if errorCode == "" {
			errorCode = "provider_error"
		}
		errorMessage := f.errorMessage
		if errorMessage == "" {
			errorMessage = "provider_error_message"
		}
		desc, err := streamgate.NewExternalDescriptor(errorType, errorCode, errorMessage, f.errorParam)
		if err != nil {
			return streamgate.NormalizedEvent{}, err
		}
		return streamgate.NewProviderErrorEvent("ch-1", desc, streamgate.FailureCauseChain{}, ts)
	}
	return streamgate.NormalizedEvent{}, errors.New("unknown openai raw kind")
}

// normalizeExternalEvent converts a fakeExternalRuntimeEvent's raw inputs into a
// normalized event using production constructors. Each fakeExternalRawKind
// constant maps directly to its production constructor without calling any
// other converter, ensuring the agent family codec is independently validated
// against the OpenAI provider frame.
func normalizeExternalEvent(f fakeExternalRuntimeEvent) (streamgate.NormalizedEvent, error) {
	ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
	switch f.kind {
	case rawAgentResponseStart:
		// Agent runtime events do not carry HTTP status/headers in their
		// raw shape; this branch exists for matrix completeness.
		return streamgate.NewResponseStartEvent("ch-1", 200, map[string]string{}, ts)
	case rawAgentTextDelta:
		return streamgate.NewTextDeltaEvent("ch-1", f.text, ts)
	case rawAgentReasoningDelta:
		return streamgate.NewReasoningDeltaEvent("ch-1", f.reasoning, ts)
	case rawAgentToolCallFragment:
		id := f.toolCallID
		if id == "" {
			id = "tc-1"
		}
		name := f.toolCallName
		if name == "" {
			name = "test"
		}
		args := f.toolCallArgs
		if args == "" {
			args = "{}"
		}
		return streamgate.NewToolCallFragmentEvent("ch-1", id, name, args, ts)
	case rawAgentTerminal:
		return streamgate.NewTerminalEvent("ch-1", ts)
	case rawExternalProviderError:
		errorType := f.errorType
		if errorType == "" {
			errorType = "agent_runtime_error"
		}
		errorCode := f.errorCode
		if errorCode == "" {
			errorCode = "agent_runtime_error"
		}
		errorMessage := f.errorMessage
		if errorMessage == "" {
			errorMessage = "agent_runtime_error_message"
		}
		desc, err := streamgate.NewExternalDescriptor(errorType, errorCode, errorMessage, f.errorParam)
		if err != nil {
			return streamgate.NormalizedEvent{}, err
		}
		return streamgate.NewProviderErrorEvent("ch-1", desc, streamgate.FailureCauseChain{}, ts)
	}
	return streamgate.NormalizedEvent{}, errors.New("unknown agent raw kind")
}

// fakeHostReleaseSink implements streamgate.ReleaseSink and captures exactly
// one terminal commit while rejecting subsequent attempts, enforcing single
// external terminal semantics in the test fixture.
type fakeHostReleaseSink struct {
	committedTerminal streamgate.TerminalResult
	committedCount    int
	outputBody        string
	state             streamgate.CommitState
}

// Compile-time assertion that fakeHostReleaseSink implements ReleaseSink.
var _ streamgate.ReleaseSink = (*fakeHostReleaseSink)(nil)

func (s *fakeHostReleaseSink) CommitResponseStart(ctx context.Context, rs streamgate.ResponseStart) (streamgate.CommitState, error) {
	s.state = streamgate.CommitStateStreamOpen
	return s.state, nil
}

func (s *fakeHostReleaseSink) Release(ctx context.Context, ev streamgate.ReleaseEvent) (streamgate.CommitState, error) {
	return s.state, nil
}

func (s *fakeHostReleaseSink) CommitTerminal(ctx context.Context, tr streamgate.TerminalResult) (streamgate.CommitState, error) {
	if s.committedCount >= 1 {
		return s.state, errors.New("streamgate test: second terminal rejected")
	}
	s.committedCount++
	s.committedTerminal = tr
	s.state = streamgate.CommitStateTerminalCommitted
	// Record external JSON body via descriptor accessors only.
	s.outputBody = formatErrorEnvelope(tr)
	return s.state, nil
}

// formatErrorEnvelope produces a JSON error envelope from a TerminalResult
// using only the ExternalDescriptor accessor methods. It returns an empty
// string for success results.
func formatErrorEnvelope(tr streamgate.TerminalResult) string {
	if !tr.Error() {
		return ""
	}
	ed := tr.ExternalDesc()
	if ed == nil {
		return ""
	}
	env := struct {
		Error struct {
			Type    string `json:"type"`
			Message string `json:"message"`
		} `json:"error"`
	}{
		Error: struct {
			Type    string `json:"type"`
			Message string `json:"message"`
		}{
			Type:    ed.Type(),
			Message: ed.Message(),
		},
	}
	b, _ := json.Marshal(env)
	return string(b)
}

// encodeChatSSEError frames a TerminalResult as a Chat Completions SSE error
// response: one SSE data line with the JSON envelope followed by [DONE]. It
// uses only the ExternalDescriptor accessor methods for payload construction.
func encodeChatSSEError(tr streamgate.TerminalResult) (string, error) {
	envelope := formatErrorEnvelope(tr)
	if envelope == "" {
		return "", nil
	}
	return "data: " + envelope + "\n\n" + "[DONE]\n\n", nil
}

// encodeResponsesJSONError frames a TerminalResult as a Responses API JSON
// error response: a single JSON envelope body. It uses only the
// ExternalDescriptor accessor methods for payload construction.
func encodeResponsesJSONError(tr streamgate.TerminalResult) (string, error) {
	return formatErrorEnvelope(tr), nil
}

// expectedMatrix is a test-internal table that pairs each codec family's raw
// kind with the production EventKind, expected BaseEventDisposition, and
// expected typed accessor payload. The matrix is the single source of truth
// for the independent lifecycle assertion.
type expectedMatrix struct {
	name          string
	openaiRaw     fakeOpenAIRawKind
	externalRaw   fakeExternalRawKind
	expectedKind  streamgate.EventKind
	expectedDisp  streamgate.BaseEventDisposition
	openaiStatus  int
	openaiHeaders map[string]string
	openaiText    string
	openaiReason  string
	openaiTcID    string
	openaiTcName  string
	openaiTcArgs  string
	openaiErrType string
	openaiErrCode string
	openaiErrMsg  string
	agentText     string
	agentReason   string
	agentTcID     string
	agentTcName   string
	agentTcArgs   string
	agentErrType  string
	agentErrCode  string
	agentErrMsg   string
}

// TestOpenAIAndAgentCodecContractMatrix verifies that the two codec families,
// each keyed by its own distinct raw kind enum and each with its own
// independent converter, produce NormalizedEvents whose actual production
// kind, Disposition(), Validate() result, and typed accessor payload all
// independently match the expectedMatrix. The OpenAI family uses
// fakeOpenAIRawKind; the agent family uses fakeExternalRawKind. Neither raw
// enum is streamgate.EventKind.
func TestOpenAIAndAgentCodecContractMatrix(t *testing.T) {
	matrix := []expectedMatrix{
		{
			name:          "response_start",
			openaiRaw:     rawOpenAIResponseStart,
			externalRaw:   rawAgentResponseStart,
			expectedKind:  streamgate.EventKindResponseStart,
			expectedDisp:  streamgate.BaseDispositionHold,
			openaiStatus:  201,
			openaiHeaders: map[string]string{"x-custom": "val"},
		},
		{
			name:         "text_delta",
			openaiRaw:    rawOpenAITextDelta,
			externalRaw:  rawAgentTextDelta,
			expectedKind: streamgate.EventKindTextDelta,
			expectedDisp: streamgate.BaseDispositionReleaseCandidate,
			openaiText:   "hello-from-openai",
			agentText:    "hello-from-agent",
		},
		{
			name:         "reasoning_delta",
			openaiRaw:    rawOpenAIReasoningDelta,
			externalRaw:  rawAgentReasoningDelta,
			expectedKind: streamgate.EventKindReasoningDelta,
			expectedDisp: streamgate.BaseDispositionReleaseCandidate,
			openaiReason: "thinking-openai",
			agentReason:  "thinking-agent",
		},
		{
			name:         "tool_call",
			openaiRaw:    rawOpenAIToolCallFragment,
			externalRaw:  rawAgentToolCallFragment,
			expectedKind: streamgate.EventKindToolCallFragment,
			expectedDisp: streamgate.BaseDispositionReleaseCandidate,
			openaiTcID:   "tc-1",
			openaiTcName: "get_weather",
			openaiTcArgs: `{"q":"hi"}`,
			agentTcID:    "tc-2",
			agentTcName:  "search",
			agentTcArgs:  `{"q":"bye"}`,
		},
		{
			name:         "terminal",
			openaiRaw:    rawOpenAITerminal,
			externalRaw:  rawAgentTerminal,
			expectedKind: streamgate.EventKindTerminal,
			expectedDisp: streamgate.BaseDispositionTerminalSuccessCandidate,
		},
		{
			name:          "provider_error",
			openaiRaw:     rawOpenAIProviderError,
			externalRaw:   rawExternalProviderError,
			expectedKind:  streamgate.EventKindProviderError,
			expectedDisp:  streamgate.BaseDispositionTerminalErrorCandidate,
			openaiErrType: "provider_timeout",
			openaiErrCode: "provider_timeout",
			openaiErrMsg:  "provider_timed_out",
			agentErrType:  "agent_runtime_timeout",
			agentErrCode:  "agent_runtime_timeout",
			agentErrMsg:   "agent_timed_out",
		},
	}

	for _, tc := range matrix {
		t.Run(tc.name, func(t *testing.T) {
			// --- OpenAI path ---
			openaiFrame := fakeOpenAIProviderFrame{
				kind:         tc.openaiRaw,
				text:         tc.openaiText,
				reasoning:    tc.openaiReason,
				toolCallID:   tc.openaiTcID,
				toolCallName: tc.openaiTcName,
				toolCallArgs: tc.openaiTcArgs,
				errorType:    tc.openaiErrType,
				errorCode:    tc.openaiErrCode,
				errorMessage: tc.openaiErrMsg,
			}
			if tc.name == "response_start" {
				openaiFrame.status = tc.openaiStatus
				openaiFrame.headers = tc.openaiHeaders
			}

			openaiEvent, err := normalizeOpenAIFrame(openaiFrame)
			if err != nil {
				t.Fatalf("openai normalize failed: %v", err)
			}

			// 1. Kind independently matches expected production kind.
			if openaiEvent.Kind() != tc.expectedKind {
				t.Errorf("openai Kind(): want %q, got %q", tc.expectedKind, openaiEvent.Kind())
			}

			// 2. Disposition() independently matches expected base disposition.
			actualDisp, err := streamgate.BaseDispositionOf(openaiEvent.Kind())
			if err != nil {
				t.Fatalf("openai BaseDispositionOf(%q): %v", openaiEvent.Kind(), err)
			}
			if openaiEvent.Disposition() != tc.expectedDisp {
				t.Errorf("openai Disposition(): want %q, got %q", tc.expectedDisp, openaiEvent.Disposition())
			}
			if actualDisp != tc.expectedDisp {
				t.Errorf("openai BaseDispositionOf(kind) does not match matrix: want %q, got %q", tc.expectedDisp, actualDisp)
			}

			// 3. Validate() passes.
			if err := openaiEvent.Validate(); err != nil {
				t.Errorf("openai event Validate() = %v", err)
			}

			// 4. Kind-specific typed accessor payload independently verified.
			verifyOpenAIPayload(t, openaiEvent, tc)

			// --- Agent path ---
			externalFrame := fakeExternalRuntimeEvent{
				kind:         tc.externalRaw,
				text:         tc.agentText,
				reasoning:    tc.agentReason,
				toolCallID:   tc.agentTcID,
				toolCallName: tc.agentTcName,
				toolCallArgs: tc.agentTcArgs,
				errorType:    tc.agentErrType,
				errorCode:    tc.agentErrCode,
				errorMessage: tc.agentErrMsg,
			}

			externalEvent, err := normalizeExternalEvent(externalFrame)
			if err != nil {
				t.Fatalf("agent normalize failed: %v", err)
			}

			// 1. Kind independently matches expected production kind.
			if externalEvent.Kind() != tc.expectedKind {
				t.Errorf("agent Kind(): want %q, got %q", tc.expectedKind, externalEvent.Kind())
			}

			// 2. Disposition() independently matches expected base disposition.
			agentActualDisp, err := streamgate.BaseDispositionOf(externalEvent.Kind())
			if err != nil {
				t.Fatalf("agent BaseDispositionOf(%q): %v", externalEvent.Kind(), err)
			}
			if externalEvent.Disposition() != tc.expectedDisp {
				t.Errorf("agent Disposition(): want %q, got %q", tc.expectedDisp, externalEvent.Disposition())
			}
			if agentActualDisp != tc.expectedDisp {
				t.Errorf("agent BaseDispositionOf(kind) does not match matrix: want %q, got %q", tc.expectedDisp, agentActualDisp)
			}

			// 3. Validate() passes.
			if err := externalEvent.Validate(); err != nil {
				t.Errorf("agent event Validate() = %v", err)
			}

			// 4. Kind-specific typed accessor payload independently verified.
			verifyAgentPayload(t, externalEvent, tc)
		})
	}

	// --- Cross-family independence: raw kinds are distinct types and cannot
	//     be swapped. Compile-time guarantee, not runtime. ---
	var _ fakeOpenAIRawKind = rawOpenAIResponseStart
	var _ fakeExternalRawKind = rawAgentResponseStart
	// The following would not compile (and must not):
	// var _ fakeOpenAIRawKind = rawAgentResponseStart
	// var _ fakeExternalRawKind = rawOpenAIResponseStart
	// var _ streamgate.EventKind = rawOpenAIResponseStart
	// var _ streamgate.EventKind = rawAgentResponseStart
}

// verifyOpenAIPayload checks the OpenAI event's typed accessor payload against
// the expected matrix values, independently of the agent path.
func verifyOpenAIPayload(t *testing.T, ev streamgate.NormalizedEvent, tc expectedMatrix) {
	t.Helper()
	switch tc.expectedKind {
	case streamgate.EventKindResponseStart:
		rs, err := ev.AsResponseStart()
		if err != nil {
			t.Fatalf("openai AsResponseStart: %v", err)
		}
		if rs.Status() != tc.openaiStatus {
			t.Errorf("openai ResponseStart.Status(): want %d, got %d", tc.openaiStatus, rs.Status())
		}
		for k, v := range tc.openaiHeaders {
			if rs.Headers()[k] != v {
				t.Errorf("openai ResponseStart.Headers()[%q] = %q, want %q", k, rs.Headers()[k], v)
			}
		}
	case streamgate.EventKindTextDelta:
		text, err := ev.AsTextDelta()
		if err != nil {
			t.Fatalf("openai AsTextDelta: %v", err)
		}
		if text != tc.openaiText {
			t.Errorf("openai text delta: want %q, got %q", tc.openaiText, text)
		}
	case streamgate.EventKindReasoningDelta:
		reason, err := ev.AsReasoningDelta()
		if err != nil {
			t.Fatalf("openai AsReasoningDelta: %v", err)
		}
		if reason != tc.openaiReason {
			t.Errorf("openai reasoning delta: want %q, got %q", tc.openaiReason, reason)
		}
	case streamgate.EventKindToolCallFragment:
		tcData, err := ev.AsToolCallFragment()
		if err != nil {
			t.Fatalf("openai AsToolCallFragment: %v", err)
		}
		if tcData.ID != tc.openaiTcID {
			t.Errorf("openai tc ID: want %q, got %q", tc.openaiTcID, tcData.ID)
		}
		if tcData.Name != tc.openaiTcName {
			t.Errorf("openai tc name: want %q, got %q", tc.openaiTcName, tcData.Name)
		}
		if tcData.Arguments != tc.openaiTcArgs {
			t.Errorf("openai tc args: want %q, got %q", tc.openaiTcArgs, tcData.Arguments)
		}
	case streamgate.EventKindTerminal:
		tr, err := ev.AsTerminal()
		if err != nil {
			t.Fatalf("openai AsTerminal: %v", err)
		}
		if !tr.Success() {
			t.Error("openai terminal: expected success")
		}
		if tr.Error() {
			t.Error("openai terminal: must not be error")
		}
	case streamgate.EventKindProviderError:
		tr, err := ev.AsProviderError()
		if err != nil {
			t.Fatalf("openai AsProviderError: %v", err)
		}
		if !tr.Error() {
			t.Error("openai provider error: expected error terminal")
		}
		if tr.Success() {
			t.Error("openai provider error: must not be success")
		}
		ed := tr.ExternalDesc()
		if ed == nil {
			t.Fatal("openai provider error: descriptor is nil")
		}
		if ed.Type() != tc.openaiErrType {
			t.Errorf("openai descriptor Type(): want %q, got %q", tc.openaiErrType, ed.Type())
		}
		if ed.Code() != tc.openaiErrCode {
			t.Errorf("openai descriptor Code(): want %q, got %q", tc.openaiErrCode, ed.Code())
		}
		if ed.Message() != tc.openaiErrMsg {
			t.Errorf("openai descriptor Message(): want %q, got %q", tc.openaiErrMsg, ed.Message())
		}
	}
}

// verifyAgentPayload checks the agent event's typed accessor payload against
// the expected matrix values, independently of the OpenAI path.
func verifyAgentPayload(t *testing.T, ev streamgate.NormalizedEvent, tc expectedMatrix) {
	t.Helper()
	switch tc.expectedKind {
	case streamgate.EventKindResponseStart:
		rs, err := ev.AsResponseStart()
		if err != nil {
			t.Fatalf("agent AsResponseStart: %v", err)
		}
		if rs.Status() != 200 {
			t.Errorf("agent ResponseStart.Status(): want 200, got %d", rs.Status())
		}
	case streamgate.EventKindTextDelta:
		text, err := ev.AsTextDelta()
		if err != nil {
			t.Fatalf("agent AsTextDelta: %v", err)
		}
		if text != tc.agentText {
			t.Errorf("agent text delta: want %q, got %q", tc.agentText, text)
		}
	case streamgate.EventKindReasoningDelta:
		reason, err := ev.AsReasoningDelta()
		if err != nil {
			t.Fatalf("agent AsReasoningDelta: %v", err)
		}
		if reason != tc.agentReason {
			t.Errorf("agent reasoning delta: want %q, got %q", tc.agentReason, reason)
		}
	case streamgate.EventKindToolCallFragment:
		tcData, err := ev.AsToolCallFragment()
		if err != nil {
			t.Fatalf("agent AsToolCallFragment: %v", err)
		}
		if tcData.ID != tc.agentTcID {
			t.Errorf("agent tc ID: want %q, got %q", tc.agentTcID, tcData.ID)
		}
		if tcData.Name != tc.agentTcName {
			t.Errorf("agent tc name: want %q, got %q", tc.agentTcName, tcData.Name)
		}
		if tcData.Arguments != tc.agentTcArgs {
			t.Errorf("agent tc args: want %q, got %q", tc.agentTcArgs, tcData.Arguments)
		}
	case streamgate.EventKindTerminal:
		tr, err := ev.AsTerminal()
		if err != nil {
			t.Fatalf("agent AsTerminal: %v", err)
		}
		if !tr.Success() {
			t.Error("agent terminal: expected success")
		}
		if tr.Error() {
			t.Error("agent terminal: must not be error")
		}
	case streamgate.EventKindProviderError:
		tr, err := ev.AsProviderError()
		if err != nil {
			t.Fatalf("agent AsProviderError: %v", err)
		}
		if !tr.Error() {
			t.Error("agent provider error: expected error terminal")
		}
		if tr.Success() {
			t.Error("agent provider error: must not be success")
		}
		ed := tr.ExternalDesc()
		if ed == nil {
			t.Fatal("agent provider error: descriptor is nil")
		}
		if ed.Type() != tc.agentErrType {
			t.Errorf("agent descriptor Type(): want %q, got %q", tc.agentErrType, ed.Type())
		}
		if ed.Code() != tc.agentErrCode {
			t.Errorf("agent descriptor Code(): want %q, got %q", tc.agentErrCode, ed.Code())
		}
		if ed.Message() != tc.agentErrMsg {
			t.Errorf("agent descriptor Message(): want %q, got %q", tc.agentErrMsg, ed.Message())
		}
	}
}

// TestProviderErrorRetainsTerminalBaseDisposition verifies that provider error
// events retain terminal-error candidate disposition even when the codec
// families have distinct error fields. Both the OpenAI provider frame and
// agent runtime event produce a terminal-error candidate via their
// independent converters with their own distinct raw kind enums.
func TestProviderErrorRetainsTerminalBaseDisposition(t *testing.T) {
	expectedDisp := streamgate.BaseDispositionTerminalErrorCandidate

	openaiFrame := fakeOpenAIProviderFrame{
		kind:         rawOpenAIProviderError,
		errorType:    "provider_timeout",
		errorCode:    "provider_timeout",
		errorMessage: "provider_timed_out",
	}
	externalEvent := fakeExternalRuntimeEvent{
		kind:         rawExternalProviderError,
		errorType:    "agent_runtime_timeout",
		errorCode:    "agent_runtime_timeout",
		errorMessage: "agent_timed_out",
	}

	openaiEvent, err := normalizeOpenAIFrame(openaiFrame)
	if err != nil {
		t.Fatalf("openai normalize failed: %v", err)
	}

	externalEventResult, err := normalizeExternalEvent(externalEvent)
	if err != nil {
		t.Fatalf("agent normalize failed: %v", err)
	}

	// Kind check: each converter maps its own raw kind to the correct
	// production kind.
	kind := streamgate.EventKindProviderError
	if openaiEvent.Kind() != kind {
		t.Errorf("openai kind: want %v, got %v", kind, openaiEvent.Kind())
	}
	if externalEventResult.Kind() != kind {
		t.Errorf("agent kind: want %v, got %v", kind, externalEventResult.Kind())
	}

	// Disposition check: each produced event's actual Disposition() matches
	// the expected terminal-error candidate.
	openaiDisp := openaiEvent.Disposition()
	if openaiDisp != expectedDisp {
		t.Errorf("openai Disposition(): want %v, got %v", expectedDisp, openaiDisp)
	}

	agentDisp := externalEventResult.Disposition()
	if agentDisp != expectedDisp {
		t.Errorf("agent Disposition(): want %v, got %v", expectedDisp, agentDisp)
	}

	// Validate() passes for each.
	if err := openaiEvent.Validate(); err != nil {
		t.Errorf("openai Validate(): %v", err)
	}
	if err := externalEventResult.Validate(); err != nil {
		t.Errorf("agent Validate(): %v", err)
	}

	// AsProviderError yields an error TerminalResult for each.
	openaiTr, err := openaiEvent.AsProviderError()
	if err != nil {
		t.Fatalf("openai AsProviderError: %v", err)
	}
	if !openaiTr.Error() {
		t.Error("openai provider error terminal should be error type")
	}
	if openaiTr.Success() {
		t.Error("openai provider error terminal must not be success")
	}

	agentTr, err := externalEventResult.AsProviderError()
	if err != nil {
		t.Fatalf("agent AsProviderError: %v", err)
	}
	if !agentTr.Error() {
		t.Error("agent provider error terminal should be error type")
	}
	if agentTr.Success() {
		t.Error("agent provider error terminal must not be success")
	}

	// Descriptor accessors return family-specific values independently.
	openaiED := openaiTr.ExternalDesc()
	agentED := agentTr.ExternalDesc()
	if openaiED.Type() == agentED.Type() {
		t.Errorf("openai and agent descriptor Type() should be distinct: both %q", openaiED.Type())
	}
	if openaiED.Message() == agentED.Message() {
		t.Errorf("openai and agent descriptor Message() should be distinct: both %q", openaiED.Message())
	}
}

// TestReleaseSinkCommitsSingleExternalTerminal verifies that the fake sink
// accepts exactly one terminal, rejects subsequent commits, and produces
// correct per-endpoint public framing through descriptor accessors only. The
// failure cause chain retains the latest four codes including both required
// codes ("repetition_loop_detected" and "recovery_notice_translation_failed"),
// proving the bound is functional, not suppressive.
func TestReleaseSinkCommitsSingleExternalTerminal(t *testing.T) {
	ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
	ctx := context.Background()

	// --- Build a terminal with a bounded cause chain (5 inputs, latest 4) ---
	desc, err := streamgate.NewExternalDescriptor("rate_limit_error", "rate_limit_exceeded", "rate_limit_exceeded", "")
	if err != nil {
		t.Fatalf("NewExternalDescriptor: %v", err)
	}

	chain := streamgate.FailureCauseChain{}
	// Append 5 causes; only the latest 4 should be retained. Using 5 ensures
	// "discarded_prelude" is dropped while both "repetition_loop_detected"
	// and "recovery_notice_translation_failed" survive in the retained set,
	// proving the bound is functional, not suppressive.
	allCodes := []string{
		"discarded_prelude",
		"repetition_loop_detected",
		"filter_blocked",
		"validation_failed",
		"recovery_notice_translation_failed",
	}
	for _, code := range allCodes {
		cause, err := streamgate.NewFailureCause("stage", code, "consumer-1", "filter-1", "")
		if err != nil {
			t.Fatalf("NewFailureCause(%s): %v", code, err)
		}
		chain, err = chain.Append(cause)
		if err != nil {
			t.Fatalf("chain.Append(%s): %v", code, err)
		}
	}

	// Chain must retain exactly 4 causes (latest 4).
	if chain.Len() != 4 {
		t.Fatalf("chain Len() = %d, want 4", chain.Len())
	}

	// Both required codes must be present in the retained chain, in exact
	// retained order, proving the bound is functional (latest 4 retained),
	// not suppressive. The dropped prelude must not be present.
	expectedCodes := []string{
		"repetition_loop_detected",
		"filter_blocked",
		"validation_failed",
		"recovery_notice_translation_failed",
	}
	retained := chain.All()
	if len(retained) != len(expectedCodes) {
		t.Fatalf("retained chain length = %d, want %d", len(retained), len(expectedCodes))
	}
	for i, want := range expectedCodes {
		if got := retained[i].Code(); got != want {
			t.Errorf("retained[%d].Code() = %q, want %q", i, got, want)
		}
	}
	// The dropped code must not be present in the retained chain.
	hasPrelude := false
	for _, c := range retained {
		if c.Code() == "discarded_prelude" {
			hasPrelude = true
			break
		}
	}
	if hasPrelude {
		t.Error("retained chain must not include 'discarded_prelude' (should be dropped)")
	}

	tr, err := streamgate.NewErrorTerminalResult("ch-1", desc, chain, ts)
	if err != nil {
		t.Fatalf("NewErrorTerminalResult: %v", err)
	}

	// --- Chat SSE endpoint subtest ---
	t.Run("chat_sse_endpoint", func(t *testing.T) {
		sink := &fakeHostReleaseSink{}

		rs, err := streamgate.NewResponseStart("ch-1", 200, map[string]string{}, ts)
		if err != nil {
			t.Fatalf("NewResponseStart: %v", err)
		}
		_, err = sink.CommitResponseStart(ctx, rs)
		if err != nil {
			t.Fatalf("CommitResponseStart: %v", err)
		}

		// First commit should succeed.
		state, err := sink.CommitTerminal(ctx, tr)
		if err != nil {
			t.Fatalf("first CommitTerminal failed: %v", err)
		}
		if state != streamgate.CommitStateTerminalCommitted {
			t.Errorf("first commit state: want terminal_committed, got %v", state)
		}
		if sink.committedCount != 1 {
			t.Errorf("committedCount = %d, want 1", sink.committedCount)
		}

		// Verify Chat SSE framing: data: {envelope}\n\n[DONE]\n\n
		sseOutput, err := encodeChatSSEError(sink.committedTerminal)
		if err != nil {
			t.Fatalf("encodeChatSSEError: %v", err)
		}

		expectedEnvelope := formatErrorEnvelope(sink.committedTerminal)
		expectedSSE := "data: " + expectedEnvelope + "\n\n" + "[DONE]\n\n"
		if sseOutput != expectedSSE {
			t.Errorf("Chat SSE output:\n got: %q\nwant: %q", sseOutput, expectedSSE)
		}

		// SSE output must start with "data: ".
		if !strings.HasPrefix(sseOutput, "data: ") {
			t.Errorf("Chat SSE must start with 'data: ', got: %q", sseOutput)
		}

		// SSE output must contain "[DONE]" terminator.
		if !strings.Contains(sseOutput, "[DONE]") {
			t.Errorf("Chat SSE must contain '[DONE]', got: %q", sseOutput)
		}

		// Decode the envelope and verify exact key set.
		var decoded map[string]interface{}
		if err := json.Unmarshal([]byte(expectedEnvelope), &decoded); err != nil {
			t.Fatalf("envelope JSON decode failed: %v; body=%q", err, expectedEnvelope)
		}

		if _, ok := decoded["error"]; !ok {
			t.Errorf("decoded top-level keys: missing 'error'; got %+v", decoded)
		}
		topKeys := make([]string, 0, len(decoded))
		for k := range decoded {
			topKeys = append(topKeys, k)
		}
		if len(topKeys) != 1 || topKeys[0] != "error" {
			t.Errorf("decoded top-level keys: want ['error'], got %v", topKeys)
		}

		errObj, ok := decoded["error"].(map[string]interface{})
		if !ok {
			t.Fatalf("decoded['error'] is not object; got %T", decoded["error"])
		}
		errKeys := make([]string, 0, len(errObj))
		for k := range errObj {
			errKeys = append(errKeys, k)
		}
		if len(errKeys) != 2 {
			t.Errorf("decoded['error'] keys: want 2, got %d (%v)", len(errKeys), errKeys)
		}
		if _, ok := errObj["type"]; !ok {
			t.Error("decoded['error'] missing 'type'")
		}
		if _, ok := errObj["message"]; !ok {
			t.Error("decoded['error'] missing 'message'")
		}
		if errObj["type"] != "rate_limit_error" {
			t.Errorf("decoded['error'].type = %v, want rate_limit_error", errObj["type"])
		}
		if errObj["message"] != "rate_limit_exceeded" {
			t.Errorf("decoded['error'].message = %v, want 'rate_limit_exceeded'", errObj["message"])
		}

		// Forbidden internal fields must not appear anywhere in the SSE output.
		forbiddenFields := []string{"causes", "stack", "trace", "provider", "internal", "raw", "repetition_loop", "filter_blocked", "validation_failed", "cache_miss", "stage", "consumer"}
		for _, f := range forbiddenFields {
			if strings.Contains(sseOutput, f) {
				t.Errorf("forbidden field '%s' found in Chat SSE output: %s", f, sseOutput)
			}
		}

		// Verify the committed terminal reflects the bounded cause chain.
		committed := sink.committedTerminal
		if !committed.Error() {
			t.Error("committed terminal should be error type")
		}
		committedCauses := committed.FailureCauses()
		if committedCauses.Len() != 4 {
			t.Errorf("committed causes Len() = %d, want 4", committedCauses.Len())
		}
	})

	// --- Responses JSON endpoint subtest ---
	t.Run("responses_json_endpoint", func(t *testing.T) {
		sink := &fakeHostReleaseSink{}

		rs, err := streamgate.NewResponseStart("ch-1", 200, map[string]string{}, ts)
		if err != nil {
			t.Fatalf("NewResponseStart: %v", err)
		}
		_, err = sink.CommitResponseStart(ctx, rs)
		if err != nil {
			t.Fatalf("CommitResponseStart: %v", err)
		}

		// First commit should succeed.
		state, err := sink.CommitTerminal(ctx, tr)
		if err != nil {
			t.Fatalf("first CommitTerminal failed: %v", err)
		}
		if state != streamgate.CommitStateTerminalCommitted {
			t.Errorf("first commit state: want terminal_committed, got %v", state)
		}
		if sink.committedCount != 1 {
			t.Errorf("committedCount = %d, want 1", sink.committedCount)
		}

		// Verify Responses JSON framing: just the envelope.
		jsonOutput, err := encodeResponsesJSONError(sink.committedTerminal)
		if err != nil {
			t.Fatalf("encodeResponsesJSONError: %v", err)
		}

		expectedEnvelope := formatErrorEnvelope(sink.committedTerminal)
		if jsonOutput != expectedEnvelope {
			t.Errorf("Responses JSON output:\n got: %q\nwant: %q", jsonOutput, expectedEnvelope)
		}

		// Responses JSON must NOT contain SSE framing markers.
		if strings.Contains(jsonOutput, "data:") {
			t.Errorf("Responses JSON must not contain SSE 'data:' marker: %q", jsonOutput)
		}
		if strings.Contains(jsonOutput, "[DONE]") {
			t.Errorf("Responses JSON must not contain SSE '[DONE]' marker: %q", jsonOutput)
		}

		// Decode the envelope and verify exact key set.
		var decoded map[string]interface{}
		if err := json.Unmarshal([]byte(jsonOutput), &decoded); err != nil {
			t.Fatalf("outputBody JSON decode failed: %v; body=%q", err, jsonOutput)
		}

		if _, ok := decoded["error"]; !ok {
			t.Errorf("decoded top-level keys: missing 'error'; got %+v", decoded)
		}
		topKeys := make([]string, 0, len(decoded))
		for k := range decoded {
			topKeys = append(topKeys, k)
		}
		if len(topKeys) != 1 || topKeys[0] != "error" {
			t.Errorf("decoded top-level keys: want ['error'], got %v", topKeys)
		}

		errObj, ok := decoded["error"].(map[string]interface{})
		if !ok {
			t.Fatalf("decoded['error'] is not object; got %T", decoded["error"])
		}
		errKeys := make([]string, 0, len(errObj))
		for k := range errObj {
			errKeys = append(errKeys, k)
		}
		if len(errKeys) != 2 {
			t.Errorf("decoded['error'] keys: want 2, got %d (%v)", len(errKeys), errKeys)
		}
		if _, ok := errObj["type"]; !ok {
			t.Error("decoded['error'] missing 'type'")
		}
		if _, ok := errObj["message"]; !ok {
			t.Error("decoded['error'] missing 'message'")
		}
		if errObj["type"] != "rate_limit_error" {
			t.Errorf("decoded['error'].type = %v, want rate_limit_error", errObj["type"])
		}
		if errObj["message"] != "rate_limit_exceeded" {
			t.Errorf("decoded['error'].message = %v, want 'rate_limit_exceeded'", errObj["message"])
		}

		// Forbidden internal fields must not appear anywhere in the JSON output.
		forbiddenFields := []string{"causes", "stack", "trace", "provider", "internal", "raw", "repetition_loop", "filter_blocked", "validation_failed", "cache_miss", "stage", "consumer"}
		for _, f := range forbiddenFields {
			if strings.Contains(jsonOutput, f) {
				t.Errorf("forbidden field '%s' found in Responses JSON output: %s", f, jsonOutput)
			}
		}

		// Verify the committed terminal reflects the bounded cause chain.
		committed := sink.committedTerminal
		if !committed.Error() {
			t.Error("committed terminal should be error type")
		}
		committedCauses := committed.FailureCauses()
		if committedCauses.Len() != 4 {
			t.Errorf("committed causes Len() = %d, want 4", committedCauses.Len())
		}
	})

	// --- Second commit must be rejected without changing output ---
	t.Run("second_commit_rejected", func(t *testing.T) {
		secondSink := &fakeHostReleaseSink{}
		secondSink.state = streamgate.CommitStateStreamOpen

		// First commit should succeed.
		_, err := secondSink.CommitTerminal(ctx, tr)
		if err != nil {
			t.Fatalf("first CommitTerminal in second-commit check should succeed: %v", err)
		}
		secondSinkCommittedCount := secondSink.committedCount
		secondSinkOutputBody := secondSink.outputBody
		secondSinkState := secondSink.state

		// Second commit must be rejected.
		_, err = secondSink.CommitTerminal(ctx, tr)
		if err == nil {
			t.Error("second CommitTerminal should have been rejected")
		}

		if secondSink.committedCount != secondSinkCommittedCount {
			t.Errorf("committedCount after second: %d, want %d", secondSink.committedCount, secondSinkCommittedCount)
		}

		if secondSink.outputBody != secondSinkOutputBody {
			t.Errorf("outputBody changed after second commit: %q != %q", secondSink.outputBody, secondSinkOutputBody)
		}

		if secondSink.state != secondSinkState {
			t.Errorf("state changed after second commit: %q != %q", secondSink.state, secondSinkState)
		}
	})
}

// TEST-1: consumer semantic/intent parity
//
// semanticFilter is a test-only Filter implementation that produces a
// deterministic FilterDecision based on the event kind and a configurable
// rule. It owns only the semantic decision/optional-intent responsibility —
// it performs no mutation, retry, rebuild, dispatch, or submit callback.
// It is registered as a Filter via the public Filter interface only.
type semanticFilter struct {
	base     streamgate.FilterBase
	ruleKind streamgate.FilterDecisionKind
	intent   *streamgate.RecoveryIntent
	repl     *streamgate.ReplacementProposal
}

func newSemanticFilter(id string, ruleKind streamgate.FilterDecisionKind, intent *streamgate.RecoveryIntent, repl *streamgate.ReplacementProposal) (*semanticFilter, error) {
	base, err := streamgate.NewFilterBase(id)
	if err != nil {
		return nil, err
	}
	return &semanticFilter{base: base, ruleKind: ruleKind, intent: intent, repl: repl}, nil
}

func (f *semanticFilter) ID() string { return f.base.ID() }

func (f *semanticFilter) Applies(ctx streamgate.FilterContext) bool {
	return ctx.Family() == "openai" || ctx.Family() == "agent"
}

func (f *semanticFilter) HoldRequirement(ctx streamgate.FilterContext) streamgate.FilterHoldRequirement {
	kinds := []streamgate.EventKind{
		streamgate.EventKindTextDelta,
		streamgate.EventKindReasoningDelta,
		streamgate.EventKindToolCallFragment,
		streamgate.EventKindTerminal,
		streamgate.EventKindProviderError,
	}
	// Determine hold mode based on filter ID suffix.
	id := f.base.ID()
	if strings.Contains(id, "rolling") {
		return buildHoldRequirement(streamgate.FilterHoldModeRolling, ctx.StreamID(), kinds)
	}
	if strings.Contains(id, "terminal") {
		return buildHoldRequirement(streamgate.FilterHoldModeTerminalGate, ctx.StreamID(), kinds)
	}
	if strings.Contains(id, "fragment") {
		return buildHoldRequirement(streamgate.FilterHoldModeFragmentGate, ctx.StreamID(), kinds)
	}
	return buildHoldRequirement(streamgate.FilterHoldModeNone, ctx.StreamID(), kinds)
}

func (f *semanticFilter) Evaluate(_ context.Context, _ streamgate.FilterContext, batch streamgate.EvidenceBatch) (streamgate.FilterDecision, error) {
	events := batch.Events()
	if len(events) == 0 {
		return streamgate.FilterDecision{}, errors.New("empty batch")
	}
	ev := events[len(events)-1]
	ts := time.Now().UTC()
	evidence, err := streamgate.NewSanitizedEvidence(
		ev.Kind(), ev.Channel(), f.base.ID(), "rule.semantic", [32]byte{1, 2, 3}, 1, 0,
		streamgate.FilterOutcomeKindEvaluated, ts,
	)
	if err != nil {
		return streamgate.FilterDecision{}, err
	}
	if f.repl != nil {
		return streamgate.NewFilterDecisionWithReplacement(f.ruleKind, "consumer.semantic", f.base.ID(), "rule.semantic", evidence, f.intent, f.repl)
	}
	return streamgate.NewFilterDecision(f.ruleKind, "consumer.semantic", f.base.ID(), "rule.semantic", evidence, f.intent)
}

// buildTestFilterContext creates a minimal FilterContext for test use.
func buildTestFilterContext() streamgate.FilterContext {
	bc := streamgate.NewFilterContextBuilder("gen-test", "attempt-test")
	bc = bc.SetEnvironment("staging")
	bc = bc.SetEndpoint("chat-sse")
	bc = bc.SetFamily("openai")
	bc = bc.SetStream("ch-1")
	ctx, _ := bc.Build()
	return ctx
}

// buildHoldRequirement creates a hold requirement with the given mode and channel.
func buildHoldRequirement(mode streamgate.FilterHoldMode, channel string, kinds []streamgate.EventKind) streamgate.FilterHoldRequirement {
	switch mode {
	case streamgate.FilterHoldModeNone:
		req, _ := streamgate.NewFilterHoldRequirementNone(channel, kinds)
		return req
	case streamgate.FilterHoldModeRolling:
		req, _ := streamgate.NewFilterHoldRequirementRolling(channel, kinds, 10)
		return req
	case streamgate.FilterHoldModeTerminalGate:
		req, _ := streamgate.NewFilterHoldRequirementTerminalGate(channel, kinds, streamgate.EventKindTerminal)
		return req
	case streamgate.FilterHoldModeFragmentGate:
		req, _ := streamgate.NewFilterHoldRequirementFragmentGate(channel, kinds, streamgate.EventKindToolCallFragment)
		return req
	}
	req, _ := streamgate.NewFilterHoldRequirementNone(channel, kinds)
	return req
}

// TestConsumerSemanticIntentParity verifies that the same semantic Filter
// decision matrix produces identical ArbitrationResult regardless of whether
// the input events come from the OpenAI-family or the agent-family normalizer.
//
// The matrix exercises: pass, violation-without-intent, violation-with-valid-
// recovery-intent, fatal, replacement, unmatched-provider-error, matched-
// provider-error. Each family's raw fixture and converter are independent —
// fakeOpenAIRawKind is never shared with normalizeExternalEvent and vice versa.
func TestConsumerSemanticIntentParity(t *testing.T) {
	ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)

	// --- Build a valid recovery intent for the "violation with intent" case. ---
	directive, err := streamgate.NewRecoveryDirectiveExact("req-1")
	if err != nil {
		t.Fatalf("NewRecoveryDirectiveExact: %v", err)
	}
	intent, err := streamgate.NewRecoveryIntent(
		streamgate.RecoveryStrategyExactReplay, directive,
		"consumer.recovery.attempt", 100,
	)
	if err != nil {
		t.Fatalf("NewRecoveryIntent: %v", err)
	}

	// --- Build a replacement proposal for the "replacement" case. ---
	replEv, err := streamgate.NewTextDeltaEvent("ch-1", "safe-replacement", ts)
	if err != nil {
		t.Fatalf("NewTextDeltaEvent for replacement: %v", err)
	}
	replProposal, err := streamgate.NewReplacementProposal([]streamgate.NormalizedEvent{replEv})
	if err != nil {
		t.Fatalf("NewReplacementProposal: %v", err)
	}

	// --- Build a matched provider-error descriptor for the "matched intent" case. ---
	matchedDesc, err := streamgate.NewExternalDescriptor("rate_limit_error", "rate_limit_exceeded", "rate_limit_exceeded", "")
	if err != nil {
		t.Fatalf("NewExternalDescriptor (matched): %v", err)
	}
	_ = matchedDesc // reserved for future expanded matrix

	// --- Semantic filter instances for each decision kind. ---
	fPass, err := newSemanticFilter("f.pass", streamgate.FilterDecisionKindPass, nil, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter pass: %v", err)
	}
	fViolation, err := newSemanticFilter("f.violation", streamgate.FilterDecisionKindViolation, nil, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter violation: %v", err)
	}
	fViolationIntent, err := newSemanticFilter("f.violation_intent", streamgate.FilterDecisionKindViolation, &intent, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter violation_intent: %v", err)
	}
	fFatal, err := newSemanticFilter("f.fatal", streamgate.FilterDecisionKindFatal, nil, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter fatal: %v", err)
	}
	fReplacement, err := newSemanticFilter("f.replacement", streamgate.FilterDecisionKindReplacement, nil, &replProposal)
	if err != nil {
		t.Fatalf("newSemanticFilter replacement: %v", err)
	}

	// --- Matrix of test cases. Each case runs identically for both families. ---
	type matrixCase struct {
		name          string
		filter        *semanticFilter
		wantAction    streamgate.ArbitrationAction
		wantFilterID  string
		wantRuleID    string
		wantIntentNil bool
		wantReplNil   bool
		wantBaseDisp  streamgate.BaseEventDisposition
	}

	cases := []matrixCase{
		{
			name:          "pass",
			filter:        fPass,
			wantAction:    streamgate.ArbitrationActionRelease,
			wantFilterID:  "",
			wantRuleID:    "",
			wantIntentNil: true,
			wantReplNil:   true,
			wantBaseDisp:  streamgate.BaseDispositionReleaseCandidate,
		},
		{
			name:          "violation_without_intent",
			filter:        fViolation,
			wantAction:    streamgate.ArbitrationActionTerminal,
			wantFilterID:  "f.violation",
			wantRuleID:    "rule.semantic",
			wantIntentNil: true,
			wantReplNil:   true,
			wantBaseDisp:  streamgate.BaseDispositionReleaseCandidate,
		},
		{
			name:          "violation_with_valid_intent",
			filter:        fViolationIntent,
			wantAction:    streamgate.ArbitrationActionRecover,
			wantFilterID:  "f.violation_intent",
			wantRuleID:    "rule.semantic",
			wantIntentNil: false,
			wantReplNil:   true,
			wantBaseDisp:  streamgate.BaseDispositionReleaseCandidate,
		},
		{
			name:          "fatal",
			filter:        fFatal,
			wantAction:    streamgate.ArbitrationActionTerminal,
			wantFilterID:  "f.fatal",
			wantRuleID:    "rule.semantic",
			wantIntentNil: true,
			wantReplNil:   true,
			wantBaseDisp:  streamgate.BaseDispositionReleaseCandidate,
		},
		{
			name:          "replacement",
			filter:        fReplacement,
			wantAction:    streamgate.ArbitrationActionReplacement,
			wantFilterID:  "f.replacement",
			wantRuleID:    "rule.semantic",
			wantIntentNil: true,
			wantReplNil:   false,
			wantBaseDisp:  streamgate.BaseDispositionReleaseCandidate,
		},
	}

	// --- Helper to run a single case through both families. ---
	runCase := func(tc matrixCase, family string, normalizeFn func() (streamgate.NormalizedEvent, error)) {
		t.Run(tc.name+"_"+family, func(t *testing.T) {
			ev, err := normalizeFn()
			if err != nil {
				t.Fatalf("normalize %s: %v", family, err)
			}

			// Build batch.
			batch, err := streamgate.NewEvidenceBatch(
				[]streamgate.NormalizedEvent{ev},
				nil, nil, nil, false,
				streamgate.CommitStateTransportUncommitted, ts,
			)
			if err != nil {
				t.Fatalf("NewEvidenceBatch %s: %v", family, err)
			}

			// Build evaluation set: one outcome for the semantic filter.
			decision, err := tc.filter.Evaluate(context.Background(), buildTestFilterContext(), batch)
			if err != nil {
				t.Fatalf("Evaluate %s: %v", family, err)
			}
			oc, err := streamgate.NewFilterOutcomeEvaluated(decision)
			if err != nil {
				t.Fatalf("NewFilterOutcomeEvaluated %s: %v", family, err)
			}
			o, err := streamgate.NewEpochFilterOutcome(tc.filter.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
			if err != nil {
				t.Fatalf("NewEpochFilterOutcome %s: %v", family, err)
			}
			set, err := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
			if err != nil {
				t.Fatalf("NewEvaluationSet %s: %v", family, err)
			}

			// Arbitrate.
			res, err := streamgate.Arbitrate(context.Background(), batch, set)
			if err != nil {
				t.Fatalf("Arbitrate %s: %v", family, err)
			}

			// --- Verify result parity. ---
			if res.Action() != tc.wantAction {
				t.Errorf("action = %v, want %v", res.Action(), tc.wantAction)
			}
			if res.FilterID() != tc.wantFilterID {
				t.Errorf("filterID = %q, want %q", res.FilterID(), tc.wantFilterID)
			}
			if res.RuleID() != tc.wantRuleID {
				t.Errorf("ruleID = %q, want %q", res.RuleID(), tc.wantRuleID)
			}
			if tc.wantIntentNil && res.RecoveryIntent() != nil {
				t.Error("intent should be nil")
			}
			if !tc.wantIntentNil && res.RecoveryIntent() == nil {
				t.Error("intent should not be nil")
			}
			if tc.wantReplNil && res.ReplacementProposal() != nil {
				t.Error("replacement should be nil")
			}
			if !tc.wantReplNil && res.ReplacementProposal() == nil {
				t.Error("replacement should not be nil")
			}
			if res.BaseDisposition() != tc.wantBaseDisp {
				t.Errorf("baseDisposition = %v, want %v", res.BaseDisposition(), tc.wantBaseDisp)
			}
		})
	}

	// --- Run each case for OpenAI family. ---
	for _, tc := range cases {
		tc := tc
		switch tc.name {
		case "pass":
			runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
				return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "hello"})
			})
		case "violation_without_intent":
			runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
				return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "bad-input"})
			})
		case "violation_with_valid_intent":
			runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
				return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "recoverable"})
			})
		case "fatal":
			runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
				return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "fatal-input"})
			})
		case "replacement":
			runCase(tc, "openai", func() (streamgate.NormalizedEvent, error) {
				return normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "replace-me"})
			})
		}
	}

	// --- Run each case for agent family. ---
	for _, tc := range cases {
		tc := tc
		switch tc.name {
		case "pass":
			runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
				return normalizeExternalEvent(fakeExternalRuntimeEvent{kind: rawAgentTextDelta, text: "hello"})
			})
		case "violation_without_intent":
			runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
				return normalizeExternalEvent(fakeExternalRuntimeEvent{kind: rawAgentTextDelta, text: "bad-input"})
			})
		case "violation_with_valid_intent":
			runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
				return normalizeExternalEvent(fakeExternalRuntimeEvent{kind: rawAgentTextDelta, text: "recoverable"})
			})
		case "fatal":
			runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
				return normalizeExternalEvent(fakeExternalRuntimeEvent{kind: rawAgentTextDelta, text: "fatal-input"})
			})
		case "replacement":
			runCase(tc, "agent", func() (streamgate.NormalizedEvent, error) {
				return normalizeExternalEvent(fakeExternalRuntimeEvent{kind: rawAgentTextDelta, text: "replace-me"})
			})
		}
	}

	// --- Cross-family parity assertion: same filter, same batch content, same result. ---
	// The OpenAI and agent families produce different event payloads (different text
	// content, different descriptor type/message) but the semantic filter ignores
	// payload and only inspects Kind(). Therefore the ArbitrationResult must be
	// identical across families for each matrix case.
	for _, tc := range cases {
		tc := tc
		t.Run(tc.name+"_cross_family_parity", func(t *testing.T) {
			openaiEv, err := normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "parity-test"})
			if err != nil {
				t.Fatalf("openai normalize: %v", err)
			}
			externalEv, err := normalizeExternalEvent(fakeExternalRuntimeEvent{kind: rawAgentTextDelta, text: "parity-test"})
			if err != nil {
				t.Fatalf("agent normalize: %v", err)
			}

			// Both events must have the same Kind.
			if openaiEv.Kind() != externalEv.Kind() {
				t.Fatalf("kind mismatch: openai=%v, agent=%v", openaiEv.Kind(), externalEv.Kind())
			}

			batchOpenAI, err := streamgate.NewEvidenceBatch(
				[]streamgate.NormalizedEvent{openaiEv},
				nil, nil, nil, false,
				streamgate.CommitStateTransportUncommitted, ts,
			)
			if err != nil {
				t.Fatalf("NewEvidenceBatch openai: %v", err)
			}
			batchAgent, err := streamgate.NewEvidenceBatch(
				[]streamgate.NormalizedEvent{externalEv},
				nil, nil, nil, false,
				streamgate.CommitStateTransportUncommitted, ts,
			)
			if err != nil {
				t.Fatalf("NewEvidenceBatch agent: %v", err)
			}

			// Build outcomes identically.
			decision, err := tc.filter.Evaluate(context.Background(), buildTestFilterContext(), batchOpenAI)
			if err != nil {
				t.Fatalf("Evaluate: %v", err)
			}
			oc, _ := streamgate.NewFilterOutcomeEvaluated(decision)
			o, _ := streamgate.NewEpochFilterOutcome(tc.filter.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
			set, _ := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})

			resOpenAI, err := streamgate.Arbitrate(context.Background(), batchOpenAI, set)
			if err != nil {
				t.Fatalf("Arbitrate openai: %v", err)
			}
			resAgent, err := streamgate.Arbitrate(context.Background(), batchAgent, set)
			if err != nil {
				t.Fatalf("Arbitrate agent: %v", err)
			}

			// Core assertion: the ArbitrationResult is identical.
			if resOpenAI.Action() != resAgent.Action() {
				t.Errorf("action mismatch: openai=%v, agent=%v", resOpenAI.Action(), resAgent.Action())
			}
			if resOpenAI.FilterID() != resAgent.FilterID() {
				t.Errorf("filterID mismatch: openai=%q, agent=%q", resOpenAI.FilterID(), resAgent.FilterID())
			}
			if resOpenAI.RuleID() != resAgent.RuleID() {
				t.Errorf("ruleID mismatch: openai=%q, agent=%q", resOpenAI.RuleID(), resAgent.RuleID())
			}
			if resOpenAI.BaseDisposition() != resAgent.BaseDisposition() {
				t.Errorf("baseDisposition mismatch: openai=%v, agent=%v", resOpenAI.BaseDisposition(), resAgent.BaseDisposition())
			}
			if (resOpenAI.RecoveryIntent() == nil) != (resAgent.RecoveryIntent() == nil) {
				t.Error("RecoveryIntent nil-ness mismatch between families")
			}
			if (resOpenAI.ReplacementProposal() == nil) != (resAgent.ReplacementProposal() == nil) {
				t.Error("ReplacementProposal nil-ness mismatch between families")
			}
		})
	}

	// --- Provider error matrix: unmatched vs matched intent. ---
	t.Run("provider_error_matrix", func(t *testing.T) {
		// Build provider error events for both families.
		openaiPE, err := normalizeOpenAIFrame(fakeOpenAIProviderFrame{
			kind: rawOpenAIProviderError, errorType: "rate_limit_error",
			errorCode: "rate_limit_exceeded", errorMessage: "rate_limit_exceeded",
		})
		if err != nil {
			t.Fatalf("openai provider error normalize: %v", err)
		}
		externalPE, err := normalizeExternalEvent(fakeExternalRuntimeEvent{
			kind: rawExternalProviderError, errorType: "external_rate_limit",
			errorCode: "external_rate_limit_exceeded", errorMessage: "external_rate_limit_exceeded",
		})
		if err != nil {
			t.Fatalf("agent provider error normalize: %v", err)
		}

		// Both must be provider_error kind.
		if openaiPE.Kind() != streamgate.EventKindProviderError {
			t.Errorf("openai PE kind = %v, want provider_error", openaiPE.Kind())
		}
		if externalPE.Kind() != streamgate.EventKindProviderError {
			t.Errorf("agent PE kind = %v, want provider_error", externalPE.Kind())
		}

		// --- Unmatched provider error: must be terminal (no recovery intent). ---
		t.Run("unmatched_provider_error_is_terminal", func(t *testing.T) {
			batch, err := streamgate.NewEvidenceBatch(
				[]streamgate.NormalizedEvent{openaiPE},
				nil, nil, nil, true,
				streamgate.CommitStateTransportUncommitted, ts,
			)
			if err != nil {
				t.Fatalf("NewEvidenceBatch terminal: %v", err)
			}
			fUnmatched, err := newSemanticFilter("f.unmatched", streamgate.FilterDecisionKindViolation, nil, nil)
			if err != nil {
				t.Fatalf("newSemanticFilter unmatched: %v", err)
			}
			decision, _ := fUnmatched.Evaluate(context.Background(), buildTestFilterContext(), batch)
			oc, _ := streamgate.NewFilterOutcomeEvaluated(decision)
			o, _ := streamgate.NewEpochFilterOutcome(fUnmatched.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
			set, _ := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
			res, err := streamgate.Arbitrate(context.Background(), batch, set)
			if err != nil {
				t.Fatalf("Arbitrate: %v", err)
			}
			if res.Action() != streamgate.ArbitrationActionTerminal {
				t.Errorf("unmatched PE action = %v, want terminal", res.Action())
			}
			if res.RecoveryIntent() != nil {
				t.Error("unmatched PE must not have recovery intent")
			}
		})

		// --- Matched provider error with valid intent: must recover. ---
		t.Run("matched_provider_error_recover", func(t *testing.T) {
			batch, err := streamgate.NewEvidenceBatch(
				[]streamgate.NormalizedEvent{externalPE},
				nil, nil, nil, true,
				streamgate.CommitStateTransportUncommitted, ts,
			)
			if err != nil {
				t.Fatalf("NewEvidenceBatch terminal: %v", err)
			}
			fMatched, err := newSemanticFilter("f.matched", streamgate.FilterDecisionKindViolation, &intent, nil)
			if err != nil {
				t.Fatalf("newSemanticFilter matched: %v", err)
			}
			decision, _ := fMatched.Evaluate(context.Background(), buildTestFilterContext(), batch)
			oc, _ := streamgate.NewFilterOutcomeEvaluated(decision)
			o, _ := streamgate.NewEpochFilterOutcome(fMatched.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
			set, _ := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
			res, err := streamgate.Arbitrate(context.Background(), batch, set)
			if err != nil {
				t.Fatalf("Arbitrate: %v", err)
			}
			if res.Action() != streamgate.ArbitrationActionRecover {
				t.Errorf("matched PE action = %v, want recover", res.Action())
			}
			if res.RecoveryIntent() == nil {
				t.Error("matched PE must have recovery intent")
			}
		})
	})

	// --- Priority mismatch rejection: intent priority must match filter effective priority. ---
	t.Run("intent_priority_mismatch_rejected", func(t *testing.T) {
		wrongDirective, err := streamgate.NewRecoveryDirectiveExact("req-wrong")
		if err != nil {
			t.Fatalf("NewRecoveryDirectiveExact: %v", err)
		}
		wrongIntent, err := streamgate.NewRecoveryIntent(
			streamgate.RecoveryStrategyExactReplay, wrongDirective, "reason", 999,
		)
		if err != nil {
			t.Fatalf("NewRecoveryIntent: %v", err)
		}
		fWrong, err := newSemanticFilter("f.wrong", streamgate.FilterDecisionKindViolation, &wrongIntent, nil)
		if err != nil {
			t.Fatalf("newSemanticFilter wrong: %v", err)
		}

		ev, err := normalizeOpenAIFrame(fakeOpenAIProviderFrame{kind: rawOpenAITextDelta, text: "test"})
		if err != nil {
			t.Fatalf("normalize: %v", err)
		}
		batch, err := streamgate.NewEvidenceBatch(
			[]streamgate.NormalizedEvent{ev},
			nil, nil, nil, false,
			streamgate.CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}

		decision, err := fWrong.Evaluate(context.Background(), buildTestFilterContext(), batch)
		if err != nil {
			t.Fatalf("Evaluate: %v", err)
		}
		oc, _ := streamgate.NewFilterOutcomeEvaluated(decision)
		// The outcome wrapper has priority 100; the intent has priority 999.
		// Arbitrate must reject this mismatch.
		o, _ := streamgate.NewEpochFilterOutcome(fWrong.ID(), 100, oc, streamgate.FilterEnforcementBlocking)
		set, _ := streamgate.NewEvaluationSet([]streamgate.EpochFilterOutcome{o})
		_, err = streamgate.Arbitrate(context.Background(), batch, set)
		if err == nil {
			t.Error("Arbitrate must reject intent priority mismatch")
		}
	})

	// --- Violation intent on non-violation kind is rejected by FilterDecision.Validate. ---
	t.Run("intent_on_non_violation_rejected", func(t *testing.T) {
		evidence, err := streamgate.NewSanitizedEvidence(
			streamgate.EventKindTextDelta, "ch-1", "f.id", "rule.id",
			[32]byte{1, 2, 3}, 1, 0,
			streamgate.FilterOutcomeKindEvaluated, ts,
		)
		if err != nil {
			t.Fatalf("NewSanitizedEvidence: %v", err)
		}
		_, err = streamgate.NewFilterDecision(
			streamgate.FilterDecisionKindPass, "consumer.id", "f.id", "rule.id",
			evidence, &intent,
		)
		if err == nil {
			t.Error("NewFilterDecision(pass, intent) should be rejected")
		}
		_, err = streamgate.NewFilterDecision(
			streamgate.FilterDecisionKindFatal, "consumer.id", "f.id", "rule.id",
			evidence, &intent,
		)
		if err == nil {
			t.Error("NewFilterDecision(fatal, intent) should be rejected")
		}
		_, err = streamgate.NewFilterDecision(
			streamgate.FilterDecisionKindReplacement, "consumer.id", "f.id", "rule.id",
			evidence, &intent,
		)
		if err == nil {
			t.Error("NewFilterDecision(replacement, intent) should be rejected")
		}
	})
}

// TEST-2: mixed policy public integration
//
// mixedPolicyFixture registers rolling, terminal-gate, fragment-gate, and
// provider-error filters with mixed enforcement (blocking + observe-only) on
// the same resolved request, then drives them through the public API path:
// registry → resolve → plan → bind epoch → coordinator.Evaluate → arbiter.
//
// It verifies: applicability, hold/release, single-buffer/commit boundary —
// all through external-package public API only. No internal field access.
func TestConsumerPolicyMixedPublicIntegration(t *testing.T) {
	ts := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)

	// --- Semantic filter that always passes. Used as a neutral observer. ---
	fNeutral, err := newSemanticFilter("f.neutral", streamgate.FilterDecisionKindPass, nil, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter neutral: %v", err)
	}

	// --- Semantic filter that blocks on terminal (terminal-gate style). ---
	fTerminal, err := newSemanticFilter("f.terminal", streamgate.FilterDecisionKindPass, nil, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter terminal: %v", err)
	}

	// --- Semantic filter that blocks on tool call fragments (fragment-gate style). ---
	fFragment, err := newSemanticFilter("f.fragment", streamgate.FilterDecisionKindPass, nil, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter fragment: %v", err)
	}

	// --- Semantic filter that blocks on text delta (rolling style). ---
	fRolling, err := newSemanticFilter("f.rolling", streamgate.FilterDecisionKindPass, nil, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter rolling: %v", err)
	}

	// --- Semantic filter for provider error (observe-only). ---
	fPEObserve, err := newSemanticFilter("f.pe_observe", streamgate.FilterDecisionKindPass, nil, nil)
	if err != nil {
		t.Fatalf("newSemanticFilter pe_observe: %v", err)
	}

	// --- Build registry snapshot. ---
	regNeutral, err := streamgate.NewFilterRegistration(fNeutral, "cap-neutral", true, streamgate.FilterEnforcementBlocking, 5*time.Second, 10)
	if err != nil {
		t.Fatalf("NewFilterRegistration neutral: %v", err)
	}
	regTerminal, err := streamgate.NewFilterRegistration(fTerminal, "cap-terminal", true, streamgate.FilterEnforcementBlocking, 5*time.Second, 20)
	if err != nil {
		t.Fatalf("NewFilterRegistration terminal: %v", err)
	}
	regFragment, err := streamgate.NewFilterRegistration(fFragment, "cap-fragment", true, streamgate.FilterEnforcementBlocking, 5*time.Second, 30)
	if err != nil {
		t.Fatalf("NewFilterRegistration fragment: %v", err)
	}
	regRolling, err := streamgate.NewFilterRegistration(fRolling, "cap-rolling", true, streamgate.FilterEnforcementBlocking, 5*time.Second, 40)
	if err != nil {
		t.Fatalf("NewFilterRegistration rolling: %v", err)
	}
	regPE, err := streamgate.NewFilterRegistration(fPEObserve, "cap-pe", true, streamgate.FilterEnforcementObserveOnly, 5*time.Second, 50)
	if err != nil {
		t.Fatalf("NewFilterRegistration pe: %v", err)
	}

	snap, err := streamgate.NewFilterRegistrySnapshot("gen-mixed", []streamgate.FilterRegistration{
		regNeutral, regTerminal, regFragment, regRolling, regPE,
	}, nil)
	if err != nil {
		t.Fatalf("NewFilterRegistrySnapshot: %v", err)
	}

	// --- Begin request. ---
	rc, err := streamgate.NewRequestFilterContext(
		"gen-mixed", "attempt-mixed", "staging", "chat-sse", "openai",
		"ch-1", streamgate.CommitStateTransportUncommitted,
		false, false, "corr-1",
	)
	if err != nil {
		t.Fatalf("NewRequestFilterContext: %v", err)
	}
	rsnap, err := snap.BeginRequest(rc)
	if err != nil {
		t.Fatalf("BeginRequest: %v", err)
	}

	// --- Resolve attempt. ---
	target, err := streamgate.NewAttemptTarget("mg-1", "model-gpt-4o", "prov-openai", "primary", []string{
		"cap-neutral", "cap-terminal", "cap-fragment", "cap-rolling", "cap-pe",
	})
	if err != nil {
		t.Fatalf("NewAttemptTarget: %v", err)
	}
	resolved, err := rsnap.ResolveAttempt(target)
	if err != nil {
		t.Fatalf("ResolveAttempt: %v", err)
	}
	if len(resolved) != 5 {
		t.Fatalf("ResolveAttempt returned %d filters, want 5", len(resolved))
	}

	// --- Build EvidencePlan from resolved filters. ---
	plan, err := streamgate.NewEvidencePlanFromResolvedFilters(resolved)
	if err != nil {
		t.Fatalf("NewEvidencePlanFromResolvedFilters: %v", err)
	}

	// --- Verify plan has blocking requirement for channel. ---
	if !plan.HasBlockingRequirement("ch-1") {
		t.Error("plan must have blocking requirement for ch-1")
	}

	// --- Helper: build epoch with all-ready applicabilities for all resolved filters. ---
	buildEpoch := func(epochID uint64, channel string, mode streamgate.FilterHoldMode, triggered bool, reason string, allReady bool) (streamgate.EvidenceEpoch, error) {
		var apps []streamgate.FilterApplicability
		for _, rf := range resolved {
			app, err := streamgate.NewFilterApplicability(rf.FilterID(), allReady, allReady)
			if err != nil {
				return streamgate.EvidenceEpoch{}, err
			}
			apps = append(apps, app)
		}
		return streamgate.NewEvidenceEpochWithApplicabilities(epochID, channel, mode, triggered, reason, apps)
	}

	// --- Epoch 1: text delta events (rolling epoch, all ready). ---
	t.Run("rolling_epoch_text_delta", func(t *testing.T) {
		ev1, err := streamgate.NewTextDeltaEvent("ch-1", "hello-world-text-delta-evaluation-trigger", ts)
		if err != nil {
			t.Fatalf("NewTextDeltaEvent 1: %v", err)
		}
		ev2, err := streamgate.NewTextDeltaEvent("ch-1", "second-text-delta-for-evaluation", ts)
		if err != nil {
			t.Fatalf("NewTextDeltaEvent 2: %v", err)
		}

		batch, err := streamgate.NewEvidenceBatch(
			[]streamgate.NormalizedEvent{ev1, ev2},
			nil, nil, nil, false,
			streamgate.CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch rolling: %v", err)
		}

		epoch, err := buildEpoch(1, "ch-1", streamgate.FilterHoldModeRolling, true, "threshold", true)
		if err != nil {
			t.Fatalf("buildEpoch: %v", err)
		}

		efList, err := plan.BindEpochFilters(epoch, resolved)
		if err != nil {
			t.Fatalf("BindEpochFilters: %v", err)
		}
		if len(efList) != 5 {
			t.Fatalf("BindEpochFilters returned %d filters, want 5", len(efList))
		}

		// Drive through coordinator.
		hostCtx := context.Background()
		arb := streamgate.NewDecisionArbiter()
		coord, err := streamgate.NewGateCoordinator(hostCtx, streamgate.GateCoordinatorOptions{MaxQueuedEpochs: 0}, arb)
		if err != nil {
			t.Fatalf("NewGateCoordinator: %v", err)
		}
		defer coord.Close()

		set, res, err := coord.Evaluate(hostCtx, batch, efList)
		if err != nil {
			t.Fatalf("Evaluate: %v", err)
		}

		// All filters pass → release (hold/release is managed by EvidenceTail,
		// not by the arbiter when all filter decisions are pass).
		if res.Action() != streamgate.ArbitrationActionRelease {
			t.Errorf("rolling epoch action = %v, want release", res.Action())
		}

		// Set must have 5 outcomes.
		if set.Len() != 5 {
			t.Errorf("set.Len() = %d, want 5", set.Len())
		}

		// All outcomes must be evaluated.
		for i := 0; i < set.Len(); i++ {
			o, _ := set.At(i)
			if o.Outcome().Kind() != streamgate.FilterOutcomeKindEvaluated {
				t.Errorf("outcome[%d].Kind() = %v, want evaluated", i, o.Outcome().Kind())
			}
		}
	})

	// --- Epoch 2: terminal event (terminal-gate epoch). ---
	t.Run("terminal_gate_epoch", func(t *testing.T) {
		ev1, err := streamgate.NewTextDeltaEvent("ch-1", "text-before-terminal", ts)
		if err != nil {
			t.Fatalf("NewTextDeltaEvent: %v", err)
		}
		ev2, err := streamgate.NewTerminalEvent("ch-1", ts)
		if err != nil {
			t.Fatalf("NewTerminalEvent: %v", err)
		}

		batch, err := streamgate.NewEvidenceBatch(
			[]streamgate.NormalizedEvent{ev1, ev2},
			nil, nil, nil, true,
			streamgate.CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch terminal: %v", err)
		}

		epoch, err := buildEpoch(2, "ch-1", streamgate.FilterHoldModeTerminalGate, true, "trigger", true)
		if err != nil {
			t.Fatalf("buildEpoch: %v", err)
		}

		efList, err := plan.BindEpochFilters(epoch, resolved)
		if err != nil {
			t.Fatalf("BindEpochFilters: %v", err)
		}

		hostCtx := context.Background()
		arb := streamgate.NewDecisionArbiter()
		coord, err := streamgate.NewGateCoordinator(hostCtx, streamgate.GateCoordinatorOptions{MaxQueuedEpochs: 0}, arb)
		if err != nil {
			t.Fatalf("NewGateCoordinator: %v", err)
		}
		defer coord.Close()

		set, res, err := coord.Evaluate(hostCtx, batch, efList)
		if err != nil {
			t.Fatalf("Evaluate: %v", err)
		}

		// Terminal batch with all-pass → terminal action.
		if res.Action() != streamgate.ArbitrationActionTerminal {
			t.Errorf("terminal gate action = %v, want terminal", res.Action())
		}
		if res.BaseDisposition() != streamgate.BaseDispositionTerminalSuccessCandidate {
			t.Errorf("terminal gate baseDisposition = %v, want terminal_success_candidate", res.BaseDisposition())
		}
		if set.Len() != 5 {
			t.Errorf("set.Len() = %d, want 5", set.Len())
		}
	})

	// --- Epoch 3: provider error only (event-only filter). ---
	t.Run("provider_error_epoch", func(t *testing.T) {
		desc, err := streamgate.NewExternalDescriptor("rate_limit_error", "rate_limit_exceeded", "rate_limit_exceeded", "")
		if err != nil {
			t.Fatalf("NewExternalDescriptor: %v", err)
		}
		ev, err := streamgate.NewProviderErrorEvent("ch-1", desc, streamgate.FailureCauseChain{}, ts)
		if err != nil {
			t.Fatalf("NewProviderErrorEvent: %v", err)
		}

		batch, err := streamgate.NewEvidenceBatch(
			[]streamgate.NormalizedEvent{ev},
			nil, nil, nil, true,
			streamgate.CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch PE: %v", err)
		}

		epoch, err := buildEpoch(3, "ch-1", streamgate.FilterHoldModeTerminalGate, true, "trigger", true)
		if err != nil {
			t.Fatalf("buildEpoch: %v", err)
		}

		efList, err := plan.BindEpochFilters(epoch, resolved)
		if err != nil {
			t.Fatalf("BindEpochFilters: %v", err)
		}

		hostCtx := context.Background()
		arb := streamgate.NewDecisionArbiter()
		coord, err := streamgate.NewGateCoordinator(hostCtx, streamgate.GateCoordinatorOptions{MaxQueuedEpochs: 0}, arb)
		if err != nil {
			t.Fatalf("NewGateCoordinator: %v", err)
		}
		defer coord.Close()

		set, res, err := coord.Evaluate(hostCtx, batch, efList)
		if err != nil {
			t.Fatalf("Evaluate: %v", err)
		}

		// PE with observe-only filter → no blocking violation → terminal.
		if res.Action() != streamgate.ArbitrationActionTerminal {
			t.Errorf("PE epoch action = %v, want terminal", res.Action())
		}
		if res.BaseDisposition() != streamgate.BaseDispositionTerminalErrorCandidate {
			t.Errorf("PE epoch baseDisposition = %v, want terminal_error_candidate", res.BaseDisposition())
		}

		// Verify the observe-only filter outcome has none disposition.
		foundObserve := false
		for i := 0; i < set.Len(); i++ {
			o, _ := set.At(i)
			if o.FilterID() == "f.pe_observe" {
				foundObserve = true
				if o.FailureDisposition() != streamgate.EvaluationFailureDispositionNone {
					t.Errorf("PE observe outcome disposition = %v, want none", o.FailureDisposition())
				}
			}
		}
		if !foundObserve {
			t.Error("observe-only PE filter outcome not found in set")
		}
	})

	// --- Single-buffer/commit boundary: Submit returns same result as Evaluate. ---
	t.Run("single_buffer_commit_boundary", func(t *testing.T) {
		ev, err := streamgate.NewTextDeltaEvent("ch-1", "commit-boundary-test", ts)
		if err != nil {
			t.Fatalf("NewTextDeltaEvent: %v", err)
		}
		batch, err := streamgate.NewEvidenceBatch(
			[]streamgate.NormalizedEvent{ev},
			nil, nil, nil, false,
			streamgate.CommitStateTransportUncommitted, ts,
		)
		if err != nil {
			t.Fatalf("NewEvidenceBatch: %v", err)
		}

		epoch, err := buildEpoch(4, "ch-1", streamgate.FilterHoldModeRolling, true, "threshold", true)
		if err != nil {
			t.Fatalf("buildEpoch: %v", err)
		}

		efList, err := plan.BindEpochFilters(epoch, resolved)
		if err != nil {
			t.Fatalf("BindEpochFilters: %v", err)
		}

		hostCtx := context.Background()
		arb := streamgate.NewDecisionArbiter()
		coord, err := streamgate.NewGateCoordinator(hostCtx, streamgate.GateCoordinatorOptions{MaxQueuedEpochs: 0}, arb)
		if err != nil {
			t.Fatalf("NewGateCoordinator: %v", err)
		}
		defer coord.Close()

		// Evaluate path.
		setEval, resEval, err := coord.Evaluate(hostCtx, batch, efList)
		if err != nil {
			t.Fatalf("Evaluate: %v", err)
		}

		// Submit path (single buffer).
		resSubmit, err := coord.Submit(hostCtx, batch, efList)
		if err != nil {
			t.Fatalf("Submit: %v", err)
		}

		// Both paths must produce the same action.
		if resEval.Action() != resSubmit.Action() {
			t.Errorf("action mismatch: Evaluate=%v, Submit=%v", resEval.Action(), resSubmit.Action())
		}
		if resEval.FilterID() != resSubmit.FilterID() {
			t.Errorf("filterID mismatch: Evaluate=%q, Submit=%q", resEval.FilterID(), resSubmit.FilterID())
		}
		if resEval.RuleID() != resSubmit.RuleID() {
			t.Errorf("ruleID mismatch: Evaluate=%q, Submit=%q", resEval.RuleID(), resSubmit.RuleID())
		}
		if resEval.BaseDisposition() != resSubmit.BaseDisposition() {
			t.Errorf("baseDisposition mismatch: Evaluate=%v, Submit=%v", resEval.BaseDisposition(), resSubmit.BaseDisposition())
		}

		// Set from Evaluate must be valid.
		if err := setEval.Validate(); err != nil {
			t.Errorf("Evaluate set Validate: %v", err)
		}
	})

	// --- Plan accessor: verify channel bindings and mode. ---
	t.Run("plan_accessor_bindings", func(t *testing.T) {
		bindings := plan.BindingsForChannel("ch-1")
		if len(bindings) != 5 {
			t.Errorf("BindingsForChannel('ch-1') = %d, want 5", len(bindings))
		}

		// Verify distinct filter IDs.
		seen := make(map[string]bool)
		for _, b := range bindings {
			if seen[b.FilterID()] {
				t.Errorf("duplicate binding: %s", b.FilterID())
			}
			seen[b.FilterID()] = true
		}

		// Verify that observe-only binding does not block release.
		for _, b := range bindings {
			if b.FilterID() == "f.pe_observe" {
				if b.BlocksRelease() {
					t.Error("observe-only binding must not block release")
				}
				if b.Enforcement() != streamgate.FilterEnforcementObserveOnly {
					t.Errorf("observe binding enforcement = %v, want observe_only", b.Enforcement())
				}
			}
		}
	})
}

// ensure unused imports are consumed.
var _ = sort.Strings
