package openai

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"iop/packages/go/config"

	edgeservice "iop/apps/edge/internal/service"
	eventpkg "iop/packages/go/events"
	iop "iop/proto/gen/iop"
)

// countTerminals reports how many SSE terminal markers a session wrote. Every
// terminal path — finish, fail, and the timeout notice — ends with [DONE], so a
// value other than 1 means the session emitted no terminal or emitted twice.
func countTerminals(body string) int {
	return strings.Count(body, "data: [DONE]")
}

// runStreamSession drives a live SSE session over the supplied events and
// returns the response body.
func runStreamSession(t *testing.T, req chatCompletionRequest, outputPolicy strictOutputPolicy, waitTimeout time.Duration, ctx context.Context, emit func(events chan *iop.RunEvent, nodeEvents chan *iop.EdgeNodeEvent)) string {
	t.Helper()
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, &fakeRunService{}, nil)

	events := make(chan *iop.RunEvent, 8)
	nodeEvents := make(chan *iop.EdgeNodeEvent, 4)
	handle := &fakeRunResultWithTimeout{
		dispatch: edgeservice.RunDispatch{
			RunID:   "run-session",
			NodeID:  "node-1",
			Adapter: "ollama",
			Target:  "llama3",
		},
		stream:      edgeservice.RunStream{Events: events, NodeEvents: nodeEvents},
		waitTimeout: waitTimeout,
	}

	emit(events, nodeEvents)

	httpReq := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
	w := httptest.NewRecorder()
	dc := newTestChatDispatchContext(httpReq, req)
	dc.outputPolicy = outputPolicy
	srv.streamChatCompletion(w, dc, handle)
	return w.Body.String()
}

// streamSessionCase is one terminal outcome of the live SSE session.
type streamSessionCase struct {
	name         string
	req          chatCompletionRequest
	outputPolicy strictOutputPolicy
	waitTimeout  time.Duration
	emit         func(events chan *iop.RunEvent, nodeEvents chan *iop.EdgeNodeEvent)
	wantBody     []string
	wantMissing  []string
}

// emitReasoningThenAnswer emits a reasoning delta, a content delta, and a
// complete event — the shape the reasoning visibility rules are decided on.
func emitReasoningThenAnswer(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
	events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking hard"}
	events <- &iop.RunEvent{Type: "delta", Delta: "answer"}
	events <- &iop.RunEvent{Type: "complete"}
}

// streamSessionTerminalCases covers how a session ends.
func streamSessionTerminalCases() []streamSessionCase {
	return []streamSessionCase{
		{
			name:        "complete emits content then finish",
			req:         chatCompletionRequest{Model: "llama3"},
			waitTimeout: time.Second,
			emit: func(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
				events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
				events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "stop"}}
			},
			wantBody: []string{`"role":"assistant"`, `"content":"hello"`, `"finish_reason":"stop"`},
		},
		{
			name:        "error event surfaces the run error",
			req:         chatCompletionRequest{Model: "llama3"},
			waitTimeout: time.Second,
			emit: func(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
				events <- &iop.RunEvent{Type: "error", Error: "boom"}
			},
			wantBody:    []string{"boom", "run_error"},
			wantMissing: []string{`"finish_reason":"stop"`},
		},
		{
			name:        "cancelled event surfaces its message",
			req:         chatCompletionRequest{Model: "llama3"},
			waitTimeout: time.Second,
			emit: func(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
				events <- &iop.RunEvent{Type: "cancelled", Message: "stopped by user"}
			},
			wantBody: []string{"stopped by user"},
		},
		{
			name:        "closed stream terminates once",
			req:         chatCompletionRequest{Model: "llama3"},
			waitTimeout: time.Second,
			emit: func(events chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {
				close(events)
			},
			wantBody: []string{"run stream closed"},
		},
		{
			name:        "node disconnect terminates once",
			req:         chatCompletionRequest{Model: "llama3"},
			waitTimeout: time.Second,
			emit: func(_ chan *iop.RunEvent, nodeEvents chan *iop.EdgeNodeEvent) {
				nodeEvents <- &iop.EdgeNodeEvent{Type: eventpkg.TypeNodeDisconnected, NodeId: "node-1"}
			},
			wantBody: []string{"node disconnected"},
		},
		{
			name:        "timeout terminates once",
			req:         chatCompletionRequest{Model: "llama3"},
			waitTimeout: 30 * time.Millisecond,
			emit:        func(_ chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {},
			wantBody:    []string{"run timed out"},
		},
	}
}

// streamSessionReasoningCases covers what a session exposes of the model's
// reasoning.
func streamSessionReasoningCases() []streamSessionCase {
	strictTrue := true
	return []streamSessionCase{
		{
			name:        "reasoning is exposed by default",
			req:         chatCompletionRequest{Model: "llama3"},
			waitTimeout: time.Second,
			emit:        emitReasoningThenAnswer,
			wantBody:    []string{`"reasoning_content":"thinking hard"`, `"content":"answer"`},
		},
		{
			name:         "strict output hides reasoning the caller did not ask for",
			req:          chatCompletionRequest{Model: "llama3"},
			outputPolicy: strictOutputPolicy{Strict: true},
			waitTimeout:  time.Second,
			emit:         emitReasoningThenAnswer,
			wantBody:     []string{`"content":"answer"`},
			wantMissing:  []string{"thinking hard"},
		},
		{
			name:         "strict output exposes reasoning the caller asked for",
			req:          chatCompletionRequest{Model: "llama3", IncludeReasoning: &strictTrue},
			outputPolicy: strictOutputPolicy{Strict: true},
			waitTimeout:  time.Second,
			emit:         emitReasoningThenAnswer,
			wantBody:     []string{`"reasoning_content":"thinking hard"`},
		},
	}
}

// TestChatStreamSessionTerminalOutcomes pins the terminal contract of the live
// SSE session: each outcome emits exactly one terminal and the accumulated
// content it was supposed to expose.
func TestChatStreamSessionTerminalOutcomes(t *testing.T) {
	cases := append(streamSessionTerminalCases(), streamSessionReasoningCases()...)
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			body := runStreamSession(t, tc.req, tc.outputPolicy, tc.waitTimeout, context.Background(), tc.emit)

			if got := countTerminals(body); got != 1 {
				t.Fatalf("expected exactly 1 terminal, got %d: %s", got, body)
			}
			for _, want := range tc.wantBody {
				if !strings.Contains(body, want) {
					t.Fatalf("expected body to contain %q, got %s", want, body)
				}
			}
			for _, missing := range tc.wantMissing {
				if strings.Contains(body, missing) {
					t.Fatalf("expected body to omit %q, got %s", missing, body)
				}
			}
		})
	}
}

// TestChatStreamSessionCallerCancelWritesNoTerminal pins that a caller who
// disconnects gets no SSE body: the run is cancelled node-side instead.
func TestChatStreamSessionCallerCancelWritesNoTerminal(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	body := runStreamSession(t, chatCompletionRequest{Model: "llama3"}, strictOutputPolicy{}, time.Second, ctx,
		func(_ chan *iop.RunEvent, _ chan *iop.EdgeNodeEvent) {})

	if got := countTerminals(body); got != 0 {
		t.Fatalf("expected no terminal for a cancelled caller, got %d: %s", got, body)
	}
}
