package streamgate_test

import (
	"context"
	"encoding/json"
	"errors"
	"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 fakeAgentRawKind, 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"
)

// fakeAgentRawKind 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 fakeAgentRawKind string

const (
	rawAgentResponseStart    fakeAgentRawKind = "agent_response_start"
	rawAgentTextDelta        fakeAgentRawKind = "agent_text_delta"
	rawAgentReasoningDelta   fakeAgentRawKind = "agent_reasoning_delta"
	rawAgentToolCallFragment fakeAgentRawKind = "agent_tool_call_fragment"
	rawAgentTerminal         fakeAgentRawKind = "agent_terminal"
	rawAgentProviderError    fakeAgentRawKind = "agent_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
}

// fakeAgentRuntimeEvent simulates an agent-family runtime event codec with
// its own distinct fakeAgentRawKind enum, but with runtime-event fields. It
// does not delegate to fakeOpenAIProviderFrame and does not import any
// apps/internal or proto types.
type fakeAgentRuntimeEvent struct {
	kind         fakeAgentRawKind
	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")
}

// normalizeAgentEvent converts a fakeAgentRuntimeEvent's raw inputs into a
// normalized event using production constructors. Each fakeAgentRawKind
// 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 normalizeAgentEvent(f fakeAgentRuntimeEvent) (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 rawAgentProviderError:
		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
	agentRaw      fakeAgentRawKind
	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 fakeAgentRawKind. Neither raw
// enum is streamgate.EventKind.
func TestOpenAIAndAgentCodecContractMatrix(t *testing.T) {
	matrix := []expectedMatrix{
		{
			name:          "response_start",
			openaiRaw:     rawOpenAIResponseStart,
			agentRaw:      rawAgentResponseStart,
			expectedKind:  streamgate.EventKindResponseStart,
			expectedDisp:  streamgate.BaseDispositionHold,
			openaiStatus:  201,
			openaiHeaders: map[string]string{"x-custom": "val"},
		},
		{
			name:         "text_delta",
			openaiRaw:    rawOpenAITextDelta,
			agentRaw:     rawAgentTextDelta,
			expectedKind: streamgate.EventKindTextDelta,
			expectedDisp: streamgate.BaseDispositionReleaseCandidate,
			openaiText:   "hello-from-openai",
			agentText:    "hello-from-agent",
		},
		{
			name:         "reasoning_delta",
			openaiRaw:    rawOpenAIReasoningDelta,
			agentRaw:     rawAgentReasoningDelta,
			expectedKind: streamgate.EventKindReasoningDelta,
			expectedDisp: streamgate.BaseDispositionReleaseCandidate,
			openaiReason: "thinking-openai",
			agentReason:  "thinking-agent",
		},
		{
			name:         "tool_call",
			openaiRaw:    rawOpenAIToolCallFragment,
			agentRaw:     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,
			agentRaw:     rawAgentTerminal,
			expectedKind: streamgate.EventKindTerminal,
			expectedDisp: streamgate.BaseDispositionTerminalSuccessCandidate,
		},
		{
			name:          "provider_error",
			openaiRaw:     rawOpenAIProviderError,
			agentRaw:      rawAgentProviderError,
			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 ---
			agentFrame := fakeAgentRuntimeEvent{
				kind:         tc.agentRaw,
				text:         tc.agentText,
				reasoning:    tc.agentReason,
				toolCallID:   tc.agentTcID,
				toolCallName: tc.agentTcName,
				toolCallArgs: tc.agentTcArgs,
				errorType:    tc.agentErrType,
				errorCode:    tc.agentErrCode,
				errorMessage: tc.agentErrMsg,
			}

			agentEvent, err := normalizeAgentEvent(agentFrame)
			if err != nil {
				t.Fatalf("agent normalize failed: %v", err)
			}

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

			// 2. Disposition() independently matches expected base disposition.
			agentActualDisp, err := streamgate.BaseDispositionOf(agentEvent.Kind())
			if err != nil {
				t.Fatalf("agent BaseDispositionOf(%q): %v", agentEvent.Kind(), err)
			}
			if agentEvent.Disposition() != tc.expectedDisp {
				t.Errorf("agent Disposition(): want %q, got %q", tc.expectedDisp, agentEvent.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 := agentEvent.Validate(); err != nil {
				t.Errorf("agent event Validate() = %v", err)
			}

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

	// --- Cross-family independence: raw kinds are distinct types and cannot
	//     be swapped. Compile-time guarantee, not runtime. ---
	var _ fakeOpenAIRawKind = rawOpenAIResponseStart
	var _ fakeAgentRawKind = rawAgentResponseStart
	// The following would not compile (and must not):
	// var _ fakeOpenAIRawKind = rawAgentResponseStart
	// var _ fakeAgentRawKind = 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",
	}
	agentEvent := fakeAgentRuntimeEvent{
		kind:         rawAgentProviderError,
		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)
	}

	agentEventResult, err := normalizeAgentEvent(agentEvent)
	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 agentEventResult.Kind() != kind {
		t.Errorf("agent kind: want %v, got %v", kind, agentEventResult.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 := agentEventResult.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 := agentEventResult.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 := agentEventResult.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)
		}
	})
}
