package openai

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

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

type fakeRunService struct {
	req        edgeservice.SubmitRunRequest
	reqs       []edgeservice.SubmitRunRequest
	ollamaReq  edgeservice.OllamaAPIRequest
	ollamaResp edgeservice.OllamaAPIView
	events     chan *iop.RunEvent
	eventRuns  []chan *iop.RunEvent
	runIDs     []string

	submitMu    sync.Mutex
	cancelMu    sync.Mutex
	cancelCalls []edgeservice.CancelRunRequest
	cancelErr   error
}

func (s *fakeRunService) CancelRun(_ context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) {
	s.cancelMu.Lock()
	s.cancelCalls = append(s.cancelCalls, req)
	s.cancelMu.Unlock()
	if s.cancelErr != nil {
		return edgeservice.CommandResult{}, s.cancelErr
	}
	return edgeservice.CommandResult{NodeID: req.NodeRef, SessionID: req.SessionID}, nil
}

func (s *fakeRunService) cancelCallsSnapshot() []edgeservice.CancelRunRequest {
	s.cancelMu.Lock()
	defer s.cancelMu.Unlock()
	return append([]edgeservice.CancelRunRequest(nil), s.cancelCalls...)
}

func (s *fakeRunService) reqsSnapshot() []edgeservice.SubmitRunRequest {
	s.submitMu.Lock()
	defer s.submitMu.Unlock()
	return append([]edgeservice.SubmitRunRequest(nil), s.reqs...)
}

func TestRoutesRequireBearerTokenWhenConfigured(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{
		BearerToken: "secret-token",
		Models:      []string{"model-a"},
	}, &fakeRunService{}, nil)

	for _, tc := range []struct {
		name       string
		auth       string
		wantStatus int
	}{
		{name: "missing", wantStatus: http.StatusUnauthorized},
		{name: "wrong", auth: "Bearer wrong", wantStatus: http.StatusUnauthorized},
		{name: "valid", auth: "Bearer secret-token", wantStatus: http.StatusOK},
	} {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
			if tc.auth != "" {
				req.Header.Set("Authorization", tc.auth)
			}
			w := httptest.NewRecorder()

			srv.routes().ServeHTTP(w, req)

			if w.Code != tc.wantStatus {
				t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
			}
		})
	}
}

func TestHealthzDoesNotRequireBearerToken(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{BearerToken: "secret-token"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
	w := httptest.NewRecorder()

	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
}

func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) {
	s.submitMu.Lock()
	s.req = req
	s.reqs = append(s.reqs, req)
	attempt := len(s.reqs)
	events := s.events
	if attempt-1 < len(s.eventRuns) {
		events = s.eventRuns[attempt-1]
	}
	runID := "run-test"
	if attempt-1 < len(s.runIDs) {
		runID = s.runIDs[attempt-1]
	} else if attempt > 1 {
		runID = fmt.Sprintf("run-test-%d", attempt)
	}
	s.submitMu.Unlock()
	return &edgeservice.RunHandle{
		RunDispatch: edgeservice.RunDispatch{
			RunID:         runID,
			ModelGroupKey: req.ModelGroupKey,
			Adapter:       req.Adapter,
			Target:        req.Target,
			SessionID:     req.SessionID,
			TimeoutSec:    5,
		},
		RunStream: edgeservice.RunStream{
			Events:     events,
			NodeEvents: make(chan *iop.EdgeNodeEvent),
		},
	}, nil
}

func (s *fakeRunService) OllamaAPI(_ context.Context, req edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error) {
	s.ollamaReq = req
	if s.ollamaResp.StatusCode == 0 {
		s.ollamaResp.StatusCode = http.StatusOK
	}
	return s.ollamaResp, nil
}

func bufferedRunEvents(events ...*iop.RunEvent) chan *iop.RunEvent {
	ch := make(chan *iop.RunEvent, len(events))
	for _, event := range events {
		ch <- event
	}
	return ch
}

func TestChatCompletionsDispatchesConfiguredOllamaTarget(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: " world"}
	fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 2, OutputTokens: 2}}

	srv := NewServer(config.EdgeOpenAIConf{
		Adapter:    "ollama",
		Target:     "llama-fixed",
		SessionID:  "cline",
		TimeoutSec: 15,
	}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"client-model",
		"messages":[{"role":"system","content":"brief"},{"role":"user","content":"say hello"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Adapter != "ollama" || fake.req.Target != "llama-fixed" {
		t.Fatalf("dispatch target mismatch: %+v", fake.req)
	}
	if fake.req.SessionID != "cline" || fake.req.TimeoutSec != 15 {
		t.Fatalf("execution config mismatch: %+v", fake.req)
	}
	if fake.req.ModelGroupKey != "client-model" {
		t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
	}
	if !strings.Contains(fake.req.Prompt, "system: brief") || !strings.Contains(fake.req.Prompt, "user: say hello") {
		t.Fatalf("prompt did not include messages: %q", fake.req.Prompt)
	}

	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if resp.Choices[0].Message.Content != "hello world" {
		t.Fatalf("content: got %q", resp.Choices[0].Message.Content)
	}
	if resp.Usage == nil || resp.Usage.TotalTokens != 4 {
		t.Fatalf("usage: %+v", resp.Usage)
	}
}

func TestChatCompletionsUsesRequestModelWhenNoConfiguredTarget(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"from-request",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Target != "from-request" {
		t.Fatalf("target: got %q", fake.req.Target)
	}
}

func TestChatCompletionsPreservesProviderFinishReason(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "truncated"}
	fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if resp.Choices[0].FinishReason != "length" {
		t.Fatalf("finish_reason: got %q, want length", resp.Choices[0].FinishReason)
	}
}

func TestChatCompletionsPassesStandardOptions(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"from-request",
		"messages":[{"role":"user","content":"hi"}],
		"max_tokens":12,
		"temperature":0.1,
		"top_p":0.9,
		"stop":["END"],
		"response_format":{"type":"json_object"},
		"tools":[{"type":"function","function":{"name":"lookup"}}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	options, ok := fake.req.Input["options"].(map[string]any)
	if !ok {
		t.Fatalf("options not passed: %+v", fake.req.Input)
	}
	if options["temperature"].(float64) != 0.1 || options["top_p"].(float64) != 0.9 {
		t.Fatalf("unexpected options: %+v", options)
	}
	if options["max_tokens"].(int) != 12 {
		t.Fatalf("max_tokens not passed: %+v", options)
	}
	if _, ok := options["stop"]; !ok {
		t.Fatalf("stop not passed: %+v", options)
	}
	if _, ok := options["response_format"]; !ok {
		t.Fatalf("response_format not passed: %+v", options)
	}
	if tools, ok := fake.req.Input["tools"].([]any); !ok || len(tools) != 1 {
		t.Fatalf("tools not passed: %+v", fake.req.Input["tools"])
	}
	if _, ok := fake.req.Input["tool_choice"]; ok {
		t.Fatalf("tool_choice must be omitted when the client omits it: %+v", fake.req.Input["tool_choice"])
	}
}

func TestChatCompletionsAcceptsStoreNoOp(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"from-request",
		"messages":[{"role":"user","content":"hi"}],
		"store":false
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if _, ok := fake.req.Input["store"]; ok {
		t.Fatalf("store must be accepted as a client compatibility no-op: %+v", fake.req.Input)
	}
}

func TestChatCompletionsOmitsProviderToolChoiceAuto(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"hi"}],
		"tools":[{"type":"function","function":{"name":"lookup"}}],
		"tool_choice":"auto",
		"parallel_tool_calls":true,
		"stream_options":{"include_usage":true}
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if _, ok := fake.req.Input["tool_choice"]; ok {
		t.Fatalf("tool_choice auto should be omitted for provider defaults: %+v", fake.req.Input["tool_choice"])
	}
	if _, ok := fake.req.Input["parallel_tool_calls"]; ok {
		t.Fatalf("parallel_tool_calls must not be forwarded: %+v", fake.req.Input)
	}
	if _, ok := fake.req.Input["stream_options"]; ok {
		t.Fatalf("stream_options must not be forwarded: %+v", fake.req.Input)
	}
}

func TestChatCompletionsPassesProviderForcedToolChoice(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"hi"}],
		"tools":[{"type":"function","function":{"name":"lookup"}}],
		"tool_choice":{"type":"function","function":{"name":"lookup"}}
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	choice, ok := fake.req.Input["tool_choice"].(map[string]any)
	if !ok {
		t.Fatalf("tool_choice not forwarded as object: %+v", fake.req.Input["tool_choice"])
	}
	if choice["type"] != "function" {
		t.Fatalf("tool_choice type: %+v", choice)
	}
}

func TestChatCompletionsDowngradesCLIToolChoiceAutoToNone(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"codex-cli",
		"messages":[{"role":"user","content":"hi"}],
		"tools":[{"type":"function","function":{"name":"lookup"}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Input["tool_choice"] != "none" {
		t.Fatalf("tool_choice: got %+v, want none", fake.req.Input["tool_choice"])
	}
}

func TestChatCompletionsSynthesizesToolCallsFromClineTextBlock(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"cd /config/workspace/iop && git status\"]\n</parameter>\n</function>\n</tool_call>"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	choice := resp.Choices[0]
	if choice.FinishReason != "tool_calls" {
		t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
	}
	if strings.Contains(choice.Message.Content, "<tool_call>") {
		t.Fatalf("content still contains raw tool_call block: %q", choice.Message.Content)
	}
	if choice.Message.Content != "먼저 현재 git 상태를 확인하겠습니다." {
		t.Fatalf("content: got %q", choice.Message.Content)
	}
	if len(choice.Message.ToolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
	}
	call, ok := choice.Message.ToolCalls[0].(map[string]any)
	if !ok {
		t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0])
	}
	if call["id"] != "call_iop_run-test_1" {
		t.Fatalf("tool call id: got %+v", call["id"])
	}
	fn, ok := call["function"].(map[string]any)
	if !ok {
		t.Fatalf("function shape: %+v", call["function"])
	}
	if fn["name"] != "run_commands" {
		t.Fatalf("function name: got %+v", fn["name"])
	}
	var args map[string][]string
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" {
		t.Fatalf("commands args: %+v", args["commands"])
	}
}

func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "사용자가 변경 내용을 푸시해달라고 요청하셨습니다. 먼저 현재 git 상태를 확인하여 변경된 파일과 커밋 가능한 내용을 파악하겠습니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"push changes"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	choice := resp.Choices[0]
	if choice.FinishReason != "tool_calls" {
		t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
	}
	if strings.Contains(choice.Message.Content, "{{run_commands") {
		t.Fatalf("content still contains raw template tool call: %q", choice.Message.Content)
	}
	if len(choice.Message.ToolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
	}
	call, ok := choice.Message.ToolCalls[0].(map[string]any)
	if !ok {
		t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0])
	}
	fn, ok := call["function"].(map[string]any)
	if !ok {
		t.Fatalf("function shape: %+v", call["function"])
	}
	if fn["name"] != "run_commands" {
		t.Fatalf("function name: got %+v", fn["name"])
	}
	var args map[string][]map[string]any
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	if len(args["commands"]) != 1 || args["commands"][0]["command"] != "git" {
		t.Fatalf("commands args: %+v", args["commands"])
	}
	commandArgs := args["commands"][0]["args"].([]any)
	if len(commandArgs) != 1 || commandArgs[0] != "status" {
		t.Fatalf("command args: %+v", commandArgs)
	}
}

func TestChatCompletionsDoesNotSynthesizeTextToolCallsForProviderRoute(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"git status\"]\n</parameter>\n</function>\n</tool_call>"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	choice := resp.Choices[0]
	if choice.FinishReason == "tool_calls" || len(choice.Message.ToolCalls) != 0 {
		t.Fatalf("provider text must not be synthesized: %+v", choice)
	}
	if !strings.Contains(choice.Message.Content, "<tool_call>") {
		t.Fatalf("provider raw content should be preserved: %q", choice.Message.Content)
	}
}

func TestChatCompletionsSynthesizesProviderTextToolCallsWhenFallbackMarked(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"cd /config/workspace/iop && git status\"]\n</parameter>\n</function>\n</tool_call>"}
	fake.events <- &iop.RunEvent{
		Type: "complete",
		Metadata: map[string]string{
			runtimeMetadataOpenAITextToolFallback: "true",
		},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	choice := resp.Choices[0]
	if choice.FinishReason != "tool_calls" {
		t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
	}
	if strings.Contains(choice.Message.Content, "<tool_call>") {
		t.Fatalf("content still contains raw tool_call block: %q", choice.Message.Content)
	}
	if len(choice.Message.ToolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
	}
	call := choice.Message.ToolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string][]string
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" {
		t.Fatalf("commands args: %+v", args["commands"])
	}
}

func TestChatCompletionsPassesThroughNativeToolCallsFromProviderRoute(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
	fake.events <- &iop.RunEvent{
		Type: "complete",
		Metadata: map[string]string{
			"finish_reason":                "tool_calls",
			runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
		},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands"}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	choice := resp.Choices[0]
	if choice.FinishReason != "tool_calls" {
		t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
	}
	if len(choice.Message.ToolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
	}
	call := choice.Message.ToolCalls[0].(map[string]any)
	if call["id"] != "call_1" {
		t.Fatalf("tool call id: got %+v", call["id"])
	}
}

func TestChatCompletionsNormalizesNativeToolCallArgumentsForProviderRoute(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
	fake.events <- &iop.RunEvent{
		Type: "complete",
		Metadata: map[string]string{
			"finish_reason":                "tool_calls",
			runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"[{\\\"command\\\":\\\"cd /config/workspace/iop && git status\\\"}]\"}"}}]`,
		},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	call := resp.Choices[0].Message.ToolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string][]string
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" {
		t.Fatalf("commands args: %+v", args["commands"])
	}
}

func TestChatCompletionsRetriesMalformedToolCallBeforeResponse(t *testing.T) {
	fake := &fakeRunService{
		eventRuns: []chan *iop.RunEvent{
			bufferedRunEvents(&iop.RunEvent{
				Type: "complete",
				Metadata: map[string]string{
					"finish_reason":                "tool_calls",
					runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`,
				},
			}),
			bufferedRunEvents(&iop.RunEvent{
				Type: "complete",
				Metadata: map[string]string{
					"finish_reason":                "tool_calls",
					runtimeMetadataOpenAIToolCalls: `[{"id":"call_good","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
				},
			}),
		},
		runIDs: []string{"run-bad", "run-good"},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	reqs := fake.reqsSnapshot()
	if len(reqs) != 2 {
		t.Fatalf("submit attempts: got %d want 2", len(reqs))
	}
	if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" {
		t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata)
	}
	if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" {
		t.Fatalf("retry metadata: %+v", reqs[1].Metadata)
	}
	if !strings.Contains(reqs[1].Metadata[runtimeMetadataToolValidationFailure], "$.arguments.commands type string") {
		t.Fatalf("retry failure reason: %+v", reqs[1].Metadata)
	}

	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if resp.ID != "chatcmpl-run-good" {
		t.Fatalf("response id: got %q", resp.ID)
	}
	call := resp.Choices[0].Message.ToolCalls[0].(map[string]any)
	if call["id"] != "call_good" {
		t.Fatalf("tool call id: got %+v", call["id"])
	}
}

func TestChatCompletionsFailsMalformedToolCallAfterRetryLimit(t *testing.T) {
	invalidComplete := func(id string) *iop.RunEvent {
		return &iop.RunEvent{
			Type: "complete",
			Metadata: map[string]string{
				"finish_reason":                "tool_calls",
				runtimeMetadataOpenAIToolCalls: fmt.Sprintf(`[{"id":%q,"type":"function","function":{"name":"run_commands","arguments":"{\"unexpected\":true}"}}]`, id),
			},
		}
	}
	fake := &fakeRunService{
		eventRuns: []chan *iop.RunEvent{
			bufferedRunEvents(invalidComplete("call_bad_1")),
			bufferedRunEvents(invalidComplete("call_bad_2")),
		},
		runIDs: []string{"run-bad-1", "run-bad-2"},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
		"tool_choice":{"type":"function","function":{"name":"run_commands"}}
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusBadGateway {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), `"type":"tool_validation_error"`) || !strings.Contains(w.Body.String(), "$.arguments.commands is required") {
		t.Fatalf("expected tool validation error body, got %s", w.Body.String())
	}
	if reqs := fake.reqsSnapshot(); len(reqs) != 2 {
		t.Fatalf("submit attempts: got %d want 2", len(reqs))
	}
}

func TestChatCompletionsToolChoiceNoneSkipsRuntimeToolValidation(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
	fake.events <- &iop.RunEvent{
		Type: "complete",
		Metadata: map[string]string{
			"finish_reason":                "tool_calls",
			runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"unknown_tool","arguments":"not-json"}}]`,
		},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}],
		"tool_choice":"none"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	reqs := fake.reqsSnapshot()
	if len(reqs) != 1 {
		t.Fatalf("submit attempts: got %d want 1", len(reqs))
	}
	if _, ok := reqs[0].Metadata[runtimeMetadataToolValidationAttempt]; ok {
		t.Fatalf("tool_choice none should not enable validation metadata: %+v", reqs[0].Metadata)
	}
}

func TestChatCompletionsLiveStreamSkipsRuntimeToolValidation(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
	fake.events <- &iop.RunEvent{
		Type: "complete",
		Metadata: map[string]string{
			"finish_reason":                "tool_calls",
			runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`,
		},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"stream":true,
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	reqs := fake.reqsSnapshot()
	if len(reqs) != 1 {
		t.Fatalf("submit attempts: got %d want 1 (live stream must not retry)", len(reqs))
	}
	if _, ok := reqs[0].Metadata[runtimeMetadataToolValidationAttempt]; ok {
		t.Fatalf("live stream should not attach validation attempt metadata: %+v", reqs[0].Metadata)
	}
	body := w.Body.String()
	if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") {
		t.Fatalf("live stream did not preserve existing tool_calls semantics:\n%s", body)
	}
}

func TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk(t *testing.T) {
	fake := &fakeRunService{
		eventRuns: []chan *iop.RunEvent{
			bufferedRunEvents(&iop.RunEvent{
				Type: "complete",
				Metadata: map[string]string{
					"finish_reason":                "tool_calls",
					runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`,
				},
			}),
			bufferedRunEvents(&iop.RunEvent{
				Type: "complete",
				Metadata: map[string]string{
					"finish_reason":                "tool_calls",
					runtimeMetadataOpenAIToolCalls: `[{"id":"call_good","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
				},
			}),
		},
		runIDs: []string{"run-bad", "run-good"},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"stream":true,
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	reqs := fake.reqsSnapshot()
	if len(reqs) != 2 {
		t.Fatalf("submit attempts: got %d want 2", len(reqs))
	}
	if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" {
		t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata)
	}
	if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" {
		t.Fatalf("retry metadata: %+v", reqs[1].Metadata)
	}
	body := w.Body.String()
	if strings.Contains(body, "call_bad") {
		t.Fatalf("buffered stream leaked invalid tool call before retry:\n%s", body)
	}
	if !strings.Contains(body, "call_good") || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") {
		t.Fatalf("buffered stream did not emit validated tool call:\n%s", body)
	}
}

func TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimit(t *testing.T) {
	invalidComplete := func(id string) *iop.RunEvent {
		return &iop.RunEvent{
			Type: "complete",
			Metadata: map[string]string{
				"finish_reason":                "tool_calls",
				runtimeMetadataOpenAIToolCalls: fmt.Sprintf(`[{"id":%q,"type":"function","function":{"name":"run_commands","arguments":"{\"unexpected\":true}"}}]`, id),
			},
		}
	}
	fake := &fakeRunService{
		eventRuns: []chan *iop.RunEvent{
			bufferedRunEvents(invalidComplete("call_bad_1")),
			bufferedRunEvents(invalidComplete("call_bad_2")),
		},
		runIDs: []string{"run-bad-1", "run-bad-2"},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"stream":true,
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
		"tool_choice":{"type":"function","function":{"name":"run_commands"}}
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, `"type":"tool_validation_error"`) || !strings.Contains(body, "$.arguments.commands is required") {
		t.Fatalf("expected tool_validation_error SSE body, got:\n%s", body)
	}
	if strings.Contains(body, "call_bad") {
		t.Fatalf("buffered stream leaked invalid tool call chunk after retry limit:\n%s", body)
	}
	if !strings.Contains(body, "data: [DONE]") {
		t.Fatalf("buffered stream error should terminate with [DONE]:\n%s", body)
	}
	if reqs := fake.reqsSnapshot(); len(reqs) != 2 {
		t.Fatalf("submit attempts: got %d want 2", len(reqs))
	}
}

func TestValidateJSONSchemaSubsetCoversObjectArrayEnumAdditionalProperties(t *testing.T) {
	schema := map[string]any{
		"type": "object",
		"properties": map[string]any{
			"mode": map[string]any{
				"type": "string",
				"enum": []any{"read", "write"},
			},
			"commands": map[string]any{
				"type":  "array",
				"items": map[string]any{"type": "string"},
			},
			"options": map[string]any{
				"type": "object",
				"properties": map[string]any{
					"dry_run": map[string]any{"type": "boolean"},
				},
				"additionalProperties": false,
			},
		},
		"required":             []any{"mode", "commands"},
		"additionalProperties": false,
	}

	valid := map[string]any{
		"mode":     "read",
		"commands": []any{"git status"},
		"options":  map[string]any{"dry_run": true},
	}
	if err := validateJSONSchemaSubset(valid, schema, "$.arguments"); err != nil {
		t.Fatalf("valid schema rejected: %v", err)
	}

	cases := []struct {
		name string
		args map[string]any
		want string
	}{
		{
			name: "enum",
			args: map[string]any{"mode": "delete", "commands": []any{"git status"}},
			want: "$.arguments.mode value",
		},
		{
			name: "array item type",
			args: map[string]any{"mode": "read", "commands": []any{1}},
			want: "$.arguments.commands[0] type integer does not match schema type string",
		},
		{
			name: "additional properties",
			args: map[string]any{"mode": "read", "commands": []any{"git status"}, "extra": true},
			want: "$.arguments.extra is not allowed",
		},
		{
			name: "nested additional properties",
			args: map[string]any{"mode": "read", "commands": []any{"git status"}, "options": map[string]any{"dry_run": true, "trace": true}},
			want: "$.arguments.options.trace is not allowed",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := validateJSONSchemaSubset(tc.args, schema, "$.arguments")
			if err == nil || !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("error got %v, want substring %q", err, tc.want)
			}
		})
	}
}

func TestSynthesizesTemplateToolCallConvertsCommandObjectsToShellStrings(t *testing.T) {
	content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'command -v git && echo \"found\" || echo \"not found\"', 'runInTerminal': True}])}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "run_commands",
		},
	}}

	cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if cleaned != "확인합니다." {
		t.Fatalf("content: got %q", cleaned)
	}
	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call := toolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string]any
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	commands := args["commands"].([]any)
	if commands[0] != `command -v git && echo "found" || echo "not found"` {
		t.Fatalf("commands args: %+v", commands)
	}
	if _, ok := args["runInTerminal"]; ok {
		t.Fatalf("runInTerminal should not be emitted: %+v", args)
	}
}

func TestSynthesizesTemplateToolCallStripsTopLevelExecutionHints(t *testing.T) {
	content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git status'}], runInTerminal=True, description='status')}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "run_commands",
		},
	}}

	_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call := toolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string]any
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	commands := args["commands"].([]any)
	command := commands[0].(map[string]any)
	if command["command"] != "git" {
		t.Fatalf("commands args: %+v", commands)
	}
	commandArgs := command["args"].([]any)
	if len(commandArgs) != 1 || commandArgs[0] != "status" {
		t.Fatalf("command args: %+v", commandArgs)
	}
	if _, ok := args["runInTerminal"]; ok {
		t.Fatalf("runInTerminal should not be emitted: %+v", args)
	}
	if _, ok := args["description"]; ok {
		t.Fatalf("description should not be emitted: %+v", args)
	}
}

func TestSynthesizesTemplateToolCallKeepsStructuredCommandArgs(t *testing.T) {
	content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git', 'args': ['status', '--short'], 'description': 'status'}])}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "run_commands",
		},
	}}

	_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call := toolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string]any
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	commands := args["commands"].([]any)
	command := commands[0].(map[string]any)
	if command["command"] != "git" {
		t.Fatalf("command: %+v", command)
	}
	commandArgs := command["args"].([]any)
	if len(commandArgs) != 2 || commandArgs[0] != "status" || commandArgs[1] != "--short" {
		t.Fatalf("command args: %+v", commandArgs)
	}
	if _, ok := command["description"]; ok {
		t.Fatalf("description should not be emitted: %+v", command)
	}
}

func TestSynthesizesTemplateToolCallFollowsClineUnionSchemaWithShellStrings(t *testing.T) {
	content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git', 'args': ['-C', '/config/workspace/iop', 'status']}, {'command': 'pwd && ls -la /config/workspace/'}])}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "run_commands",
			"parameters": map[string]any{
				"type": "object",
				"properties": map[string]any{
					"commands": map[string]any{
						"type": "array",
						"items": map[string]any{
							"anyOf": []any{
								map[string]any{
									"type": "object",
									"properties": map[string]any{
										"command": map[string]any{"type": "string"},
										"args": map[string]any{
											"type":  "array",
											"items": map[string]any{"type": "string"},
										},
									},
								},
								map[string]any{"type": "string"},
							},
						},
					},
				},
			},
		},
	}}

	_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call := toolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string][]string
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	if len(args["commands"]) != 2 {
		t.Fatalf("commands len: %+v", args["commands"])
	}
	if args["commands"][0] != "git -C /config/workspace/iop status" {
		t.Fatalf("first command: %+v", args["commands"][0])
	}
	if args["commands"][1] != "pwd && ls -la /config/workspace/" {
		t.Fatalf("second command: %+v", args["commands"][1])
	}
}

func TestSynthesizesTemplateToolCallConvertsStructuredShellOperatorsToString(t *testing.T) {
	content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'cd', 'args': ['/config/workspace/iop', '&&', 'git', 'status', '&&', 'git', 'diff', '--stat']}, {'command': 'ls', 'args': ['-la', '/config/workspace/iop', '&&', 'pwd']}])}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "run_commands",
		},
	}}

	_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call := toolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string]any
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	commands := args["commands"].([]any)
	if commands[0] != "cd /config/workspace/iop && git status && git diff --stat" {
		t.Fatalf("first command: %+v", commands[0])
	}
	if commands[1] != "ls -la /config/workspace/iop && pwd" {
		t.Fatalf("second command: %+v", commands[1])
	}
}

func TestSynthesizesTemplateToolCallFollowsStringArrayCommandSchema(t *testing.T) {
	content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': 'status'}])}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "run_commands",
			"parameters": map[string]any{
				"type": "object",
				"properties": map[string]any{
					"commands": map[string]any{
						"type": "array",
						"items": map[string]any{
							"type": "string",
						},
					},
				},
			},
		},
	}}

	_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call := toolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string][]string
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	if len(args["commands"]) != 1 || args["commands"][0] != "git status" {
		t.Fatalf("commands args: %+v", args["commands"])
	}
}

func TestSynthesizesTemplateToolCallFollowsSingleCommandSchema(t *testing.T) {
	content := "확인합니다.\n\n{{bash(commands=[{'command': 'cd', 'args': ['/config/workspace/iop', '&&', 'git', 'status']}], runInTerminal=True)}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "bash",
			"parameters": map[string]any{
				"type": "object",
				"properties": map[string]any{
					"command": map[string]any{
						"type": "string",
					},
				},
			},
		},
	}}

	_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call := toolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string]string
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	if args["command"] != "cd /config/workspace/iop && git status" {
		t.Fatalf("command arg: %+v", args)
	}
	if _, ok := args["commands"]; ok {
		t.Fatalf("commands should not be emitted: %+v", args)
	}
	if _, ok := args["runInTerminal"]; ok {
		t.Fatalf("runInTerminal should not be emitted: %+v", args)
	}
}

func TestSynthesizesShellCommandStringWithoutExtraExecutionHints(t *testing.T) {
	content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "run_commands",
		},
	}}

	cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if cleaned != "확인합니다." {
		t.Fatalf("content: got %q", cleaned)
	}
	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call := toolCalls[0].(map[string]any)
	fn := call["function"].(map[string]any)
	var args map[string]any
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	commands := args["commands"].([]any)
	if commands[0] != `command -v git && echo "found" || echo "not found"` {
		t.Fatalf("commands args: %+v", commands)
	}
	if _, ok := args["runInTerminal"]; ok {
		t.Fatalf("runInTerminal should not be emitted: %+v", args)
	}
}

func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) {
	content := "확인합니다.\n\n{{run_commands(commands=[{'command': \"printf ')}}'\", 'description': 'marker test', 'runInTerminal': False}])}}"
	tools := []any{map[string]any{
		"type": "function",
		"function": map[string]any{
			"name": "run_commands",
		},
	}}

	cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")

	if cleaned != "확인합니다." {
		t.Fatalf("content: got %q", cleaned)
	}
	if len(toolCalls) != 1 {
		t.Fatalf("tool_calls len: got %d", len(toolCalls))
	}
	call, ok := toolCalls[0].(map[string]any)
	if !ok {
		t.Fatalf("tool call shape: %+v", toolCalls[0])
	}
	fn, ok := call["function"].(map[string]any)
	if !ok {
		t.Fatalf("function shape: %+v", call["function"])
	}
	var args map[string][]string
	if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
		t.Fatalf("arguments JSON: %v", err)
	}
	if len(args["commands"]) != 1 || args["commands"][0] != "printf ')}}'" {
		t.Fatalf("commands args: %+v", args["commands"])
	}
}

func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call><function=delete_everything><parameter=confirm>true</parameter></function></tool_call>"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands"}}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 {
		t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0])
	}
	if !strings.Contains(resp.Choices[0].Message.Content, "<tool_call>") {
		t.Fatalf("raw unknown tool call should remain content: %q", resp.Choices[0].Message.Content)
	}
}

func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands"}}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 {
		t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0])
	}
	if !strings.Contains(resp.Choices[0].Message.Content, "{{delete_everything") {
		t.Fatalf("raw unknown template tool call should remain content: %q", resp.Choices[0].Message.Content)
	}
}

func TestChatCompletionsMapsMaxCompletionTokens(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"from-request",
		"messages":[{"role":"user","content":"hi"}],
		"max_completion_tokens":7
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	options, ok := fake.req.Input["options"].(map[string]any)
	if !ok {
		t.Fatalf("options not passed: %+v", fake.req.Input)
	}
	if options["max_tokens"].(int) != 7 {
		t.Fatalf("max_completion_tokens not mapped to provider max_tokens: %+v", options)
	}
}

func TestChatCompletionsStreamsSSE(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"}
	fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"stream-model",
		"stream":true,
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") {
		t.Fatalf("unexpected SSE body:\n%s", body)
	}
	if !strings.Contains(body, `"finish_reason":"length"`) {
		t.Fatalf("streaming finish_reason did not preserve provider value:\n%s", body)
	}
}

func TestChatCompletionsStreamSynthesizesToolCallsFromClineTextBlock(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"cd /config/workspace/iop && git status\"]\n</parameter>\n</function>\n</tool_call>"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"stream-model",
		"stream":true,
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands"}}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) {
		t.Fatalf("stream did not synthesize tool_calls:\n%s", body)
	}
	if strings.Contains(body, "<tool_call>") || strings.Contains(body, `\u003ctool_call`) {
		t.Fatalf("stream leaked raw tool_call block:\n%s", body)
	}
	if !strings.Contains(body, `"index":0`) {
		t.Fatalf("stream tool_call delta should include index:\n%s", body)
	}
	if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) {
		t.Fatalf("stream should preserve text before tool call:\n%s", body)
	}
}

func TestChatCompletionsStreamWithToolsFlushesTextBeforeComplete(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	httpSrv := httptest.NewServer(srv.routes())
	defer httpSrv.Close()

	resp, err := http.Post(httpSrv.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{
		"model":"stream-model",
		"stream":true,
		"messages":[{"role":"user","content":"explain sudo"}],
		"tools":[{"type":"function","function":{"name":"run_commands"}}],
		"tool_choice":"auto"
	}`))
	if err != nil {
		t.Fatalf("post: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status: got %d", resp.StatusCode)
	}

	lines := make(chan string, 16)
	scanDone := make(chan struct{})
	go func() {
		defer close(scanDone)
		scanner := bufio.NewScanner(resp.Body)
		for scanner.Scan() {
			lines <- scanner.Text()
		}
	}()

	fake.events <- &iop.RunEvent{Type: "delta", Delta: "streaming before complete"}

	deadline := time.After(2 * time.Second)
	for {
		select {
		case line := <-lines:
			if strings.Contains(line, `"finish_reason"`) {
				t.Fatalf("saw finish before streamed content: %s", line)
			}
			if strings.Contains(line, `"content":"streaming before complete"`) {
				fake.events <- &iop.RunEvent{Type: "complete"}
				select {
				case <-scanDone:
				case <-time.After(2 * time.Second):
					t.Fatal("stream did not close after complete")
				}
				return
			}
		case <-deadline:
			t.Fatal("tools request did not stream content before complete")
		}
	}
}

func TestChatCompletionsStreamSynthesizesToolCallsFromClineTemplateCall(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"stream-model",
		"stream":true,
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands"}}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) {
		t.Fatalf("stream did not synthesize tool_calls:\n%s", body)
	}
	if strings.Contains(body, "{{run_commands") {
		t.Fatalf("stream leaked raw template tool call:\n%s", body)
	}
	if !strings.Contains(body, `"index":0`) {
		t.Fatalf("stream tool_call delta should include index:\n%s", body)
	}
	if !strings.Contains(body, `\"commands\":[{\"args\":[\"status\"],\"command\":\"git\"}]`) &&
		!strings.Contains(body, `\"commands\":[{\"command\":\"git\",\"args\":[\"status\"]}]`) {
		t.Fatalf("stream tool_call arguments should include command:\n%s", body)
	}
	if strings.Contains(body, "runInTerminal") || strings.Contains(body, "description") {
		t.Fatalf("stream tool_call arguments should strip execution metadata:\n%s", body)
	}
	if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) {
		t.Fatalf("stream should preserve text before tool call:\n%s", body)
	}
}

func TestChatCompletionsStreamPassesThroughNativeToolCallsFromProviderRoute(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "checking"}
	fake.events <- &iop.RunEvent{
		Type: "complete",
		Metadata: map[string]string{
			"finish_reason":                "tool_calls",
			runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
		},
	}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"stream-model",
		"stream":true,
		"messages":[{"role":"user","content":"status"}],
		"tools":[{"type":"function","function":{"name":"run_commands"}}],
		"tool_choice":"auto"
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"call_1"`) {
		t.Fatalf("stream did not pass through native tool_calls:\n%s", body)
	}
	if !strings.Contains(body, `"finish_reason":"tool_calls"`) {
		t.Fatalf("stream finish_reason:\n%s", body)
	}
}

func TestChatCompletionsStreamingReportsClosedRunStream(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	close(fake.events)

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"stream-model",
		"stream":true,
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, `"message":"run stream closed"`) || !strings.Contains(body, "data: [DONE]") {
		t.Fatalf("unexpected SSE error body:\n%s", body)
	}
}

func TestChatCompletionsReturnsReasoningContentSeparately(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)}
	fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "}
	fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "more"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"}
	fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 2, OutputTokens: 2}}

	srv := NewServer(config.EdgeOpenAIConf{
		Adapter: "ollama",
		Target:  "llama-fixed",
	}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"client-model",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}

	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if resp.Choices[0].Message.Content != "answer" {
		t.Fatalf("content: got %q, expected \"answer\"", resp.Choices[0].Message.Content)
	}
	if resp.Choices[0].Message.ReasoningContent != "thinking more" {
		t.Fatalf("reasoning_content: got %q, expected \"thinking more\"", resp.Choices[0].Message.ReasoningContent)
	}
}

func TestChatCompletionsStreamsReasoningSSE(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
	fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"stream-model",
		"stream":true,
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, `"reasoning_content":"think"`) || !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") {
		t.Fatalf("unexpected SSE body:\n%s", body)
	}
}

func TestChatCompletionsStrictOutputNormalizesXMLStyleAgentResponse(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\n<thinking>hidden</thinking>\n\n<attempt_completion>\n<result>ok</result>\n</attempt completion>\n```\ntrailing noise"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"agent-model",
		"messages":[{"role":"user","content":"finish"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Input["think"] != false {
		t.Fatalf("strict output should disable thinking by default: %+v", fake.req.Input)
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	want := "<attempt_completion>\n<result>ok</result>\n</attempt_completion>"
	if resp.Choices[0].Message.Content != want {
		t.Fatalf("content:\ngot  %q\nwant %q", resp.Choices[0].Message.Content, want)
	}
	if resp.Choices[0].Message.ReasoningContent != "" {
		t.Fatalf("strict output should drop reasoning content: %q", resp.Choices[0].Message.ReasoningContent)
	}
}

func TestChatCompletionsStrictOutputWrapsPlainTextWhenPromptDeclaresCompletionTool(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "plain answer"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"agent-model",
		"messages":[
			{"role":"system","content":"Once you've completed the user's task, you must use the attempt_completion tool to present the result.\n\n<attempt_completion>\n<result>done</result>\n</attempt_completion>"},
			{"role":"user","content":"finish"}
		]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(fake.req.Prompt, "output exactly one <attempt_completion> block") || !strings.Contains(fake.req.Prompt, "actual user-facing response") {
		t.Fatalf("strict contract instruction not injected:\n%s", fake.req.Prompt)
	}
	var resp chatCompletionResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	want := "<attempt_completion>\n<result>plain answer</result>\n</attempt_completion>"
	if resp.Choices[0].Message.Content != want {
		t.Fatalf("content:\ngot  %q\nwant %q", resp.Choices[0].Message.Content, want)
	}
}

func TestChatCompletionsStrictOutputBuffersStreamingResponse(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
	fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\n<think>noise</think><read_file><path>a.go</path></read file>"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"agent-model",
		"stream":true,
		"messages":[{"role":"user","content":"read"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if strings.Contains(body, "reasoning_content") || strings.Contains(body, "<think>") || strings.Contains(body, "</read file>") {
		t.Fatalf("strict stream leaked unstable content:\n%s", body)
	}
	if !strings.Contains(body, "read_file") || !strings.Contains(body, "a.go") || !strings.Contains(body, "data: [DONE]") {
		t.Fatalf("unexpected strict SSE body:\n%s", body)
	}
}

func TestChatCompletionsStrictOutputStreamsLiveContent(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)}
	fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "<attempt_completion>\n"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "<result>ok</result>\n</attempt_completion>"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"agent-model",
		"stream":true,
		"messages":[{"role":"user","content":"finish"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if strings.Contains(body, "reasoning_content") || strings.Contains(body, "hidden") {
		t.Fatalf("strict stream leaked reasoning content:\n%s", body)
	}
	if strings.Count(body, `"content":`) != 2 || !strings.Contains(body, "attempt_completion") || !strings.Contains(body, "data: [DONE]") {
		t.Fatalf("strict stream should pass live content deltas:\n%s", body)
	}
}

func TestModelsUsesConfiguredModelsOrTarget(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Target: "fallback-model"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
	w := httptest.NewRecorder()
	srv.handleModels(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d", w.Code)
	}
	if !strings.Contains(w.Body.String(), "fallback-model") {
		t.Fatalf("expected target model, got %s", w.Body.String())
	}

	srv = NewServer(config.EdgeOpenAIConf{Models: []string{"a", "b"}}, &fakeRunService{}, nil)
	w = httptest.NewRecorder()
	srv.handleModels(w, req)
	if !strings.Contains(w.Body.String(), `"id":"a"`) || !strings.Contains(w.Body.String(), `"id":"b"`) {
		t.Fatalf("expected configured models, got %s", w.Body.String())
	}
}

// TestOpenAIModelsUsesRefreshedCatalog verifies that a provider-pool catalog
// applied via SetModelCatalog (config refresh apply) is reflected in /v1/models
// on the next request, replacing the previous catalog snapshot.
func TestOpenAIModelsUsesRefreshedCatalog(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{}, &fakeRunService{}, nil)
	srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "model-old"}})

	req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
	w := httptest.NewRecorder()
	srv.handleModels(w, req)
	if body := w.Body.String(); !strings.Contains(body, `"id":"model-old"`) {
		t.Fatalf("expected initial catalog model-old, got %s", body)
	}

	// Refresh the catalog: the next request must show the new model and not the old.
	srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "model-new"}})
	w = httptest.NewRecorder()
	srv.handleModels(w, req)
	body := w.Body.String()
	if !strings.Contains(body, `"id":"model-new"`) {
		t.Fatalf("expected refreshed catalog model-new, got %s", body)
	}
	if strings.Contains(body, `"id":"model-old"`) {
		t.Fatalf("stale catalog model-old still present after refresh, got %s", body)
	}
}

// TestOpenAIProviderPoolRouteUsesRefreshedCatalog verifies that provider-pool
// route resolution reads the refreshed catalog snapshot, so a model added by a
// config refresh is routed via the provider pool.
func TestOpenAIProviderPoolRouteUsesRefreshedCatalog(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{}, &fakeRunService{}, nil)
	if srv.findProviderPoolEntry("model-new") != nil {
		t.Fatal("expected no provider-pool entry before catalog is set")
	}
	srv.SetModelCatalog([]config.ModelCatalogEntry{{
		ID:        "model-new",
		Providers: map[string]string{"prov-a": "served-a"},
	}})
	dispatch, ok := srv.resolveRouteDispatch("model-new")
	if !ok || !dispatch.ProviderPool {
		t.Fatalf("expected provider-pool dispatch for refreshed model, got ok=%v dispatch=%+v", ok, dispatch)
	}
}

func TestOllamaAPIPassthrough(t *testing.T) {
	fake := &fakeRunService{
		ollamaResp: edgeservice.OllamaAPIView{
			StatusCode:  http.StatusAccepted,
			ContentType: "application/json",
			Body:        `{"ok":true}`,
		},
	}
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", TimeoutSec: 15}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(`{"model":"gemma4:26b"}`))
	w := httptest.NewRecorder()

	srv.handleOllamaAPI(w, req)

	if w.Code != http.StatusAccepted {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.ollamaReq.Adapter != "ollama" || fake.ollamaReq.Method != http.MethodPost || fake.ollamaReq.Path != "/api/show" {
		t.Fatalf("passthrough req mismatch: %+v", fake.ollamaReq)
	}
	if fake.ollamaReq.Body != `{"model":"gemma4:26b"}` || fake.ollamaReq.TimeoutSec != 15 {
		t.Fatalf("passthrough body/timeout mismatch: %+v", fake.ollamaReq)
	}
	if w.Body.String() != `{"ok":true}` {
		t.Fatalf("body: got %s", w.Body.String())
	}
}

func TestOllamaAPIPassthroughPreservesConfiguredTarget(t *testing.T) {
	fake := &fakeRunService{
		ollamaResp: edgeservice.OllamaAPIView{
			StatusCode:  http.StatusOK,
			ContentType: "application/json",
			Body:        `{"models":[]}`,
		},
	}
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "gemma4:26b", TimeoutSec: 15}, fake, nil)
	req := httptest.NewRequest(http.MethodGet, "/api/tags", nil)
	w := httptest.NewRecorder()

	srv.handleOllamaAPI(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.ollamaReq.Target != "gemma4:26b" {
		t.Fatalf("passthrough target: got %q, want gemma4:26b", fake.ollamaReq.Target)
	}
}

func TestResponsesDispatchesNonStreamingRequest(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: " world"}
	fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}}

	srv := NewServer(config.EdgeOpenAIConf{
		Adapter:    "ollama",
		Target:     "llama-fixed",
		SessionID:  "configured-session",
		TimeoutSec: 42,
	}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"client-model",
		"input":"say hello",
		"instructions":"Use short answers.",
		"max_output_tokens":16,
		"temperature":0.2,
		"top_p":0.9,
		"background":false
	}`))
	w := httptest.NewRecorder()

	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Target != "llama-fixed" {
		t.Fatalf("target: got %q, want llama-fixed", fake.req.Target)
	}
	if fake.req.Prompt != "Use short answers.\n\nsay hello" {
		t.Fatalf("prompt: got %q", fake.req.Prompt)
	}
	options, ok := fake.req.Input["options"].(map[string]any)
	if !ok {
		t.Fatalf("options not passed: %+v", fake.req.Input)
	}
	if options["max_tokens"].(int) != 16 || options["temperature"].(float64) != 0.2 || options["top_p"].(float64) != 0.9 {
		t.Fatalf("unexpected response options: %+v", options)
	}
	if fake.req.TimeoutSec != 42 {
		t.Fatalf("timeout: got %d, want 42", fake.req.TimeoutSec)
	}
	if fake.req.SessionID != "configured-session" {
		t.Fatalf("session_id: got %q, want configured-session", fake.req.SessionID)
	}
	if fake.req.Background {
		t.Fatal("background should remain false for responses")
	}

	var resp responsesResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if resp.OutputText != "hello world" {
		t.Fatalf("output_text: got %q", resp.OutputText)
	}
	if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != "hello world" {
		t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output)
	}
}

func TestResponsesRejectsUnsupportedRequests(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)

	cases := []struct {
		name   string
		method string
		body   string
		want   int
	}{
		{"wrong method", http.MethodGet, "", http.StatusMethodNotAllowed},
		{"stream=true", http.MethodPost, `{"model":"m","input":"hi","stream":true}`, http.StatusBadRequest},
		{"empty input", http.MethodPost, `{"model":"m","input":""}`, http.StatusBadRequest},
		{"non-string input", http.MethodPost, `{"model":"m","input":["a","b"]}`, http.StatusBadRequest},
		{"options wrapper unsupported", http.MethodPost, `{"model":"m","input":"hi","options":{"max_output_tokens":16}}`, http.StatusBadRequest},
		{"background unsupported", http.MethodPost, `{"model":"m","input":"hi","background":true}`, http.StatusBadRequest},
		{"bad max_output_tokens", http.MethodPost, `{"model":"m","input":"hi","max_output_tokens":0}`, http.StatusBadRequest},
		{"bad top_p", http.MethodPost, `{"model":"m","input":"hi","top_p":1.5}`, http.StatusBadRequest},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(tc.method, "/v1/responses", strings.NewReader(tc.body))
			w := httptest.NewRecorder()
			srv.routes().ServeHTTP(w, req)
			if w.Code != tc.want {
				t.Fatalf("got %d want %d body=%s", w.Code, tc.want, w.Body.String())
			}
		})
	}
}

func TestResponsesGenericMetadataContract(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"client-model",
		"input":"test",
		"metadata":{
			"request_id":"req-001",
			"workspace":"/config/workspace/iop",
			"task_id":"task-123",
			"custom":"value"
		}
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Target != "client-model" {
		t.Fatalf("target: got %q, want client-model", fake.req.Target)
	}
	if fake.req.Workspace != "/config/workspace/iop" {
		t.Fatalf("workspace: got %q", fake.req.Workspace)
	}
	if fake.req.Metadata["request_id"] != "req-001" {
		t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"])
	}
	if _, ok := fake.req.Metadata["workspace"]; ok {
		t.Fatal("workspace should not be copied into run metadata")
	}
	if fake.req.Metadata["task_id"] != "task-123" {
		t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
	}
	if fake.req.Metadata["custom"] != "value" {
		t.Fatalf("custom: got %q", fake.req.Metadata["custom"])
	}
	if fake.req.Metadata["openai_model"] != "client-model" {
		t.Fatalf("openai_model: got %q", fake.req.Metadata["openai_model"])
	}
	if fake.req.ModelGroupKey != "client-model" {
		t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
	}
	if fake.req.Metadata["openai_stream"] != "false" {
		t.Fatalf("openai_stream: got %q", fake.req.Metadata["openai_stream"])
	}
	if fake.req.Metadata["strict_output"] != "false" {
		t.Fatalf("strict_output: got %q", fake.req.Metadata["strict_output"])
	}
}

func TestChatCompletionsMetadataContractAndWorkspace(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"client-model",
		"messages":[{"role":"user","content":"hi"}],
		"metadata":{
			"request_id":"req-chat-001",
			"workspace":"/config/workspace/iop",
			"task_id":"task-123"
		}
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Target != "client-model" {
		t.Fatalf("target: got %q, want client-model", fake.req.Target)
	}
	if fake.req.Workspace != "/config/workspace/iop" {
		t.Fatalf("workspace: got %q", fake.req.Workspace)
	}
	if fake.req.Metadata["request_id"] != "req-chat-001" {
		t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"])
	}
	if fake.req.Metadata["task_id"] != "task-123" {
		t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
	}
	if fake.req.ModelGroupKey != "client-model" {
		t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
	}
	if _, ok := fake.req.Metadata["workspace"]; ok {
		t.Fatal("workspace should not be copied into run metadata")
	}
}

func TestChatCompletionsRejectsObjectMetadata(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"m",
		"messages":[{"role":"user","content":"hi"}],
		"metadata":{"cli":{"flag":"x"}}
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
	}
}

func TestChatCompletionsRejectsUnsupportedFields(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
	cases := []struct {
		name string
		body string
	}{
		{"options wrapper", `{"model":"m","messages":[{"role":"user","content":"hi"}],"options":{"num_ctx":8192}}`},
		{"think", `{"model":"m","messages":[{"role":"user","content":"hi"}],"think":false}`},
		{"format", `{"model":"m","messages":[{"role":"user","content":"hi"}],"format":"json"}`},
		{"keep_alive", `{"model":"m","messages":[{"role":"user","content":"hi"}],"keep_alive":"10m"}`},
		{"bad max_tokens", `{"model":"m","messages":[{"role":"user","content":"hi"}],"max_tokens":0}`},
		{"bad top_p", `{"model":"m","messages":[{"role":"user","content":"hi"}],"top_p":2}`},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(tc.body))
			w := httptest.NewRecorder()
			srv.routes().ServeHTTP(w, req)
			if w.Code != http.StatusBadRequest {
				t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
			}
		})
	}
}

func TestChatCompletionsRejectsSourceMetadata(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"m",
		"messages":[{"role":"user","content":"hi"}],
		"metadata":{"source":"manual"}
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "metadata.source is not supported") {
		t.Fatalf("expected source unsupported error, got %s", w.Body.String())
	}
}

func TestChatCompletionsRejectsNonStringMetadataValue(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"m",
		"messages":[{"role":"user","content":"hi"}],
		"metadata":{"attempt":2}
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "metadata.attempt must be a string") {
		t.Fatalf("expected string metadata error, got %s", w.Body.String())
	}
}

func TestChatCompletionsRejectsObjectWorkspaceMetadata(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"m",
		"messages":[{"role":"user","content":"hi"}],
		"metadata":{
			"workspace": {
				"path": "/home/user/workspace",
				"source_branch": "develop"
			}
		}
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "metadata.workspace must be a string") {
		t.Fatalf("expected workspace string error, got %s", w.Body.String())
	}
}

func TestResponsesPreservesGenericTaskMetadata(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"client-model",
		"input":"test",
		"metadata":{
			"request_id":"req-flat-001",
			"task_id":"task-flat"
		}
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Metadata["task_id"] != "task-flat" {
		t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
	}
}

func TestResponsesConfiguredTargetWinsOverRequestModel(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "config-target"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"client-model",
		"input":"test"
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Target != "config-target" {
		t.Fatalf("target: got %q, want config-target", fake.req.Target)
	}

	var resp responsesResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if resp.Model != "client-model" {
		t.Fatalf("model: got %q, want client-model", resp.Model)
	}
}

func TestResponsesRejectsNonStringMetadata(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)

	cases := []struct {
		name string
		body string
	}{
		{"cli only", `{"model":"m","input":"hi","metadata":{"cli":{"flag":"x"}}}`},
		{"inference target", `{"model":"m","input":"hi","metadata":{"inference":{"target":"t"}}}`},
		{"nomadcode metadata", `{"model":"m","input":"hi","metadata":{"nomadcode":{"task_id":"t"}}}`},
		{"source metadata", `{"model":"m","input":"hi","metadata":{"source":"manual"}}`},
		{"number metadata", `{"model":"m","input":"hi","metadata":{"attempt":2}}`},
		{"boolean metadata", `{"model":"m","input":"hi","metadata":{"urgent":true}}`},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(tc.body))
			w := httptest.NewRecorder()
			srv.routes().ServeHTTP(w, req)
			if w.Code != http.StatusBadRequest {
				t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
			}
		})
	}
}

func TestResponsesRejectsObjectWorkspaceMetadata(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"m",
		"input":"hi",
		"metadata":{
			"workspace": {
				"path": "/home/user/workspace",
				"source_branch": "develop"
			}
		}
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "metadata.workspace must be a string") {
		t.Fatalf("expected workspace string error, got %s", w.Body.String())
	}
}

func TestResponsesReturnsOpenAICompatibleShape(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"}
	fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"client-model",
		"input":"say hello"
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}

	var resp responsesResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if !strings.HasPrefix(resp.ID, "resp-") {
		t.Fatalf("id: got %q, want resp- prefix", resp.ID)
	}
	if resp.Model != "client-model" {
		t.Fatalf("model: got %q", resp.Model)
	}
	if resp.OutputText != "answer" {
		t.Fatalf("output_text: got %q", resp.OutputText)
	}
	if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != "answer" {
		t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output)
	}
	if resp.Usage.TotalTokens != 2 {
		t.Fatalf("usage.total_tokens: got %d", resp.Usage.TotalTokens)
	}
}

func TestResponsesUsageDefaultsToZeroObject(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"m",
		"input":"hi"
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}

	var body map[string]json.RawMessage
	if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if _, ok := body["usage"]; !ok {
		t.Fatal("usage field missing from response")
	}
	var usage openAIUsage
	if err := json.Unmarshal(body["usage"], &usage); err != nil {
		t.Fatalf("usage is not an object: %s", body["usage"])
	}
}

func TestResponsesStrictOutputNormalizesAgentResponse(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
	fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "plain answer"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama", StrictOutput: true}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"client-model",
		"input":"Once you've completed the user's task, you must use the attempt_completion tool to present the result.\n\n<attempt_completion>\n<result>done</result>\n</attempt_completion>\n\nfinish"
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Metadata["strict_output"] != "true" {
		t.Fatalf("strict_output metadata: got %q", fake.req.Metadata["strict_output"])
	}
	if fake.req.Input["think"] != false {
		t.Fatalf("strict output should disable thinking by default: %+v", fake.req.Input)
	}
	if !strings.Contains(fake.req.Prompt, "output exactly one <attempt_completion> block") {
		t.Fatalf("strict contract instruction not injected:\n%s", fake.req.Prompt)
	}

	var resp responsesResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	want := "<attempt_completion>\n<result>plain answer</result>\n</attempt_completion>"
	if resp.OutputText != want {
		t.Fatalf("output_text:\ngot  %q\nwant %q", resp.OutputText, want)
	}
	if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != want {
		t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output)
	}
}

func TestCollectRunResultTimesOut(t *testing.T) {
	handle := &edgeservice.RunHandle{
		RunDispatch: edgeservice.RunDispatch{TimeoutSec: 1},
		RunStream: edgeservice.RunStream{
			Events:     make(chan *iop.RunEvent),
			NodeEvents: make(chan *iop.EdgeNodeEvent),
		},
	}
	ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
	defer cancel()
	_, _, _, _, _, _, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout())
	if err == nil {
		t.Fatal("expected timeout error")
	}
}

// fakeRunResultWithTimeout implements edgeservice.RunResult with a directly
// configurable WaitTimeout, so tests can exercise the run-timeout cancel path
// without waiting out the real DefaultTimeoutSec-derived duration.
type fakeRunResultWithTimeout struct {
	dispatch    edgeservice.RunDispatch
	stream      edgeservice.RunStream
	waitTimeout time.Duration
}

func (f *fakeRunResultWithTimeout) Dispatch() edgeservice.RunDispatch { return f.dispatch }
func (f *fakeRunResultWithTimeout) Stream() edgeservice.RunStream     { return f.stream }
func (f *fakeRunResultWithTimeout) Close()                            {}
func (f *fakeRunResultWithTimeout) WaitTimeout() time.Duration        { return f.waitTimeout }

func TestChatCompletionContextCancelSendsCancelRun(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	srv := NewServer(config.EdgeOpenAIConf{
		Adapter:    "ollama",
		Target:     "llama-fixed",
		SessionID:  "cline",
		TimeoutSec: 15,
	}, fake, nil)

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"client-model",
		"messages":[{"role":"user","content":"hi"}]
	}`)).WithContext(ctx)
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusRequestTimeout {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	calls := fake.cancelCallsSnapshot()
	if len(calls) != 1 {
		t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
	}
	if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" {
		t.Fatalf("unexpected cancel request: %+v", calls[0])
	}
}

func TestResponsesContextCancelSendsCancelRun(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	srv := NewServer(config.EdgeOpenAIConf{
		Adapter:    "ollama",
		Target:     "llama-fixed",
		SessionID:  "cline",
		TimeoutSec: 15,
	}, fake, nil)

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"client-model",
		"input":"say hello"
	}`)).WithContext(ctx)
	w := httptest.NewRecorder()

	srv.handleResponses(w, req)

	if w.Code != http.StatusRequestTimeout {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	calls := fake.cancelCallsSnapshot()
	if len(calls) != 1 {
		t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
	}
	if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" {
		t.Fatalf("unexpected cancel request: %+v", calls[0])
	}
}

func TestStreamChatCompletionContextCancelSendsCancelRun(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	srv := NewServer(config.EdgeOpenAIConf{
		Adapter:    "ollama",
		Target:     "llama-fixed",
		SessionID:  "cline",
		TimeoutSec: 15,
	}, fake, nil)

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"client-model",
		"stream":true,
		"messages":[{"role":"user","content":"hi"}]
	}`)).WithContext(ctx)
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	calls := fake.cancelCallsSnapshot()
	if len(calls) != 1 {
		t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
	}
	if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" {
		t.Fatalf("unexpected cancel request: %+v", calls[0])
	}
}

func TestStreamChatCompletionTimeoutSendsCancelRun(t *testing.T) {
	fake := &fakeRunService{}
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)

	handle := &fakeRunResultWithTimeout{
		dispatch: edgeservice.RunDispatch{
			RunID:     "run-timeout",
			NodeID:    "node-1",
			Adapter:   "ollama",
			Target:    "llama3",
			SessionID: "cline",
		},
		stream: edgeservice.RunStream{
			Events:     make(chan *iop.RunEvent),
			NodeEvents: make(chan *iop.EdgeNodeEvent),
		},
		waitTimeout: 50 * time.Millisecond,
	}

	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
	w := httptest.NewRecorder()

	srv.streamChatCompletion(w, req, chatCompletionRequest{Model: "llama3"}, edgeservice.SubmitRunRequest{}, handle, strictOutputPolicy{}, toolValidationContract{})

	calls := fake.cancelCallsSnapshot()
	if len(calls) != 1 {
		t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
	}
	if calls[0].RunID != "run-timeout" || calls[0].NodeRef != "node-1" {
		t.Fatalf("unexpected cancel request: %+v", calls[0])
	}
	if !strings.Contains(w.Body.String(), "run timed out") {
		t.Fatalf("expected timeout SSE error, got %s", w.Body.String())
	}
}

func TestChatCompletionCompleteDoesNotSendCancelRun(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama-fixed"}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"client-model",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()

	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if calls := fake.cancelCallsSnapshot(); len(calls) != 0 {
		t.Fatalf("expected no CancelRun calls for a normal completion, got %d: %+v", len(calls), calls)
	}
}

func TestModelsExposesCatalogRouteModels(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "model-a", Adapter: "ollama", Target: "llama3"},
			{Model: "model-b", Adapter: "vllm", Target: "qwen"},
		},
	}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
	w := httptest.NewRecorder()
	srv.handleModels(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d", w.Code)
	}
	body := w.Body.String()
	if !strings.Contains(body, `"id":"model-a"`) || !strings.Contains(body, `"id":"model-b"`) {
		t.Fatalf("expected catalog model IDs, got %s", body)
	}
}

func TestModelsSkipsRoutesWithEmptyTarget(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "model-with-target", Adapter: "ollama", Target: "llama3"},
			{Model: "model-no-target", Adapter: "ollama", Target: ""},
		},
	}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
	w := httptest.NewRecorder()
	srv.handleModels(w, req)
	body := w.Body.String()
	if !strings.Contains(body, `"id":"model-with-target"`) {
		t.Fatalf("expected model-with-target in response, got %s", body)
	}
	if strings.Contains(body, "model-no-target") {
		t.Fatalf("model-no-target should be skipped (no target), got %s", body)
	}
}

func TestModelsRouteCatalogWinsOverModelsField(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{
		Models: []string{"legacy-model"},
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "catalog-model", Adapter: "ollama", Target: "llama3"},
		},
	}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
	w := httptest.NewRecorder()
	srv.handleModels(w, req)
	body := w.Body.String()
	if !strings.Contains(body, `"id":"catalog-model"`) {
		t.Fatalf("expected catalog model in response, got %s", body)
	}
	if strings.Contains(body, "legacy-model") {
		t.Fatalf("legacy model should be hidden when catalog is set, got %s", body)
	}
}

func TestChatCompletionsRouteCatalogDispatches(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "model-a", Adapter: "ollama", Target: "llama3"},
			{Model: "model-b", Adapter: "vllm", Target: "qwen"},
		},
	}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"model-a",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Adapter != "ollama" || fake.req.Target != "llama3" {
		t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target)
	}
}

func TestChatCompletionsRouteCatalogDispatchesModelB(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "model-a", Adapter: "ollama", Target: "llama3"},
			{Model: "model-b", Adapter: "vllm", Target: "qwen", MaxQueue: 5, QueueTimeoutMS: 2000},
		},
	}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"model-b",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" {
		t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target)
	}
	if fake.req.ModelGroupKey != "model-b" {
		t.Fatalf("model group key: got %q, want model-b", fake.req.ModelGroupKey)
	}
	if fake.req.MaxQueue != 5 || fake.req.QueueTimeoutMS != 2000 {
		t.Fatalf("queue policy mismatch: MaxQueue=%d, QueueTimeoutMS=%d", fake.req.MaxQueue, fake.req.QueueTimeoutMS)
	}
}

func TestChatCompletionsCatalogMissFallsToLegacyTarget(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{
		Adapter: "ollama",
		Target:  "legacy-target",
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "model-a", Adapter: "ollama", Target: "llama3"},
		},
	}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"unknown-model",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Target != "legacy-target" {
		t.Fatalf("expected legacy-target fallback, got %q", fake.req.Target)
	}
}

func TestChatCompletionsEmptyModelReturns400(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400 for empty model, got %d body=%s", w.Code, w.Body.String())
	}
}

func TestResponsesRouteCatalogDispatchesRoute(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "model-a", Adapter: "vllm", Target: "qwen"},
		},
	}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"model-a",
		"input":"say hello"
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" {
		t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target)
	}
	if fake.req.ModelGroupKey != "model-a" {
		t.Fatalf("model group key: got %q, want model-a", fake.req.ModelGroupKey)
	}
}

func TestResponsesRouteCatalogDispatchesQueuePolicy(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "model-a", Adapter: "ollama", Target: "route-target", MaxQueue: 3, QueueTimeoutMS: 1500},
		},
	}, fake, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"model-a",
		"input":"test"
	}`))
	w := httptest.NewRecorder()
	srv.routes().ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Target != "route-target" {
		t.Fatalf("expected route-target, got %q", fake.req.Target)
	}
	if fake.req.ModelGroupKey != "model-a" {
		t.Fatalf("model group key: got %q, want model-a", fake.req.ModelGroupKey)
	}
	if fake.req.MaxQueue != 3 || fake.req.QueueTimeoutMS != 1500 {
		t.Fatalf("queue policy mismatch: MaxQueue=%d, QueueTimeoutMS=%d", fake.req.MaxQueue, fake.req.QueueTimeoutMS)
	}
}

func TestChatCompletionsRejectsNumCtxOptionsWrapper(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"from-request",
		"messages":[{"role":"user","content":"hi"}],
		"options":{"num_ctx":8192}
	}`))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusBadRequest {
		t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
	}
}

func TestResolveRouteDispatchPreservesWorkspaceRequired(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true},
			{Model: "llama3", Adapter: "ollama", Target: "llama3:8b"},
		},
	}, &fakeRunService{}, nil)

	dispatch, ok := srv.resolveRouteDispatch("codex")
	if !ok {
		t.Fatal("expected dispatch to succeed for codex route")
	}
	if !dispatch.WorkspaceRequired {
		t.Fatalf("expected workspace_required=true for codex route, got false")
	}

	dispatch, ok = srv.resolveRouteDispatch("llama3")
	if !ok {
		t.Fatal("expected dispatch to succeed for llama3 route")
	}
	if dispatch.WorkspaceRequired {
		t.Fatalf("expected workspace_required=false for llama3 route, got true")
	}
}

func TestResolveRouteDispatchFallbackWorkspaceRequiredFalse(t *testing.T) {
	srv := NewServer(config.EdgeOpenAIConf{
		Adapter: "ollama",
		Target:  "llama3",
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true},
		},
	}, &fakeRunService{}, nil)

	dispatch, ok := srv.resolveRouteDispatch("unknown-model")
	if !ok {
		t.Fatal("expected fallback dispatch to succeed")
	}
	if dispatch.WorkspaceRequired {
		t.Fatalf("expected workspace_required=false for fallback route, got true")
	}
}

func TestCollectRunResultFailsWhenEventStreamCloses(t *testing.T) {
	events := make(chan *iop.RunEvent)
	close(events)
	handle := &edgeservice.RunHandle{
		RunDispatch: edgeservice.RunDispatch{TimeoutSec: 60},
		RunStream: edgeservice.RunStream{
			Events:     events,
			NodeEvents: make(chan *iop.EdgeNodeEvent),
		},
	}

	_, _, _, _, _, _, err := collectRunResult(context.Background(), handle.Stream(), handle.WaitTimeout())
	if err == nil {
		t.Fatal("expected closed stream error")
	}
	if !strings.Contains(err.Error(), "run stream closed") {
		t.Fatalf("expected run stream closed error, got %v", err)
	}
}

// workspaceBoundCfg returns a server config with a workspace-required codex route and a
// non-required llama3 route for workspace validation tests.
func workspaceBoundCfg() config.EdgeOpenAIConf {
	return config.EdgeOpenAIConf{
		Adapter: "ollama",
		Target:  "llama3",
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true},
			{Model: "llama3", Adapter: "ollama", Target: "llama3"},
		},
	}
}

func TestResponsesWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	srv := NewServer(workspaceBoundCfg(), fake, nil)
	body := `{"model":"codex","input":"hello"}`
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
	w := httptest.NewRecorder()
	srv.handleResponses(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "workspace is required") {
		t.Fatalf("expected workspace error, got %s", w.Body.String())
	}
}

func TestResponsesWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	srv := NewServer(workspaceBoundCfg(), fake, nil)
	body := `{"model":"codex","input":"hello","metadata":{"workspace":"relative/path"}}`
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
	w := httptest.NewRecorder()
	srv.handleResponses(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "absolute path") {
		t.Fatalf("expected absolute path error, got %s", w.Body.String())
	}
}

func TestResponsesWorkspaceRequiredRouteAbsoluteWorkspaceOK(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}
	srv := NewServer(workspaceBoundCfg(), fake, nil)
	body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/path"}}`
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
	w := httptest.NewRecorder()
	srv.handleResponses(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Workspace != "/abs/path" {
		t.Fatalf("workspace not preserved: got %q", fake.req.Workspace)
	}
}

func TestResponsesNonRequiredRouteNoWorkspaceOK(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}
	srv := NewServer(workspaceBoundCfg(), fake, nil)
	body := `{"model":"llama3","input":"hello"}`
	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
	w := httptest.NewRecorder()
	srv.handleResponses(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String())
	}
}

func TestResponsesSurfacesDistinctRunFailures(t *testing.T) {
	cases := []struct {
		name      string
		runError  string
		want      []string
		forbidAny []string
	}{
		{
			name:     "missing workspace path from node",
			runError: "cli adapter: workspace not found: /abs/missing",
			want:     []string{`"type":"run_error"`, "cli adapter: workspace not found: /abs/missing"},
		},
		{
			name:     "inaccessible workspace path from node",
			runError: "cli adapter: workspace inaccessible: /abs/private: permission denied",
			want:     []string{`"type":"run_error"`, "cli adapter: workspace inaccessible: /abs/private"},
		},
		{
			name:      "agent process exit failure",
			runError:  "command failed: exit status 7",
			want:      []string{`"type":"run_error"`, "command failed: exit status 7"},
			forbidAny: []string{"workspace not found", "workspace inaccessible", "workspace is not a directory"},
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
			fake.events <- &iop.RunEvent{Type: "error", Error: tc.runError}
			srv := NewServer(workspaceBoundCfg(), fake, nil)
			body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/workspace"}}`
			req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
			w := httptest.NewRecorder()
			srv.handleResponses(w, req)

			if w.Code != http.StatusBadGateway {
				t.Fatalf("run failure: want 502, got %d body=%s", w.Code, w.Body.String())
			}
			for _, want := range tc.want {
				if !strings.Contains(w.Body.String(), want) {
					t.Fatalf("expected body to contain %q, got %s", want, w.Body.String())
				}
			}
			for _, forbidden := range tc.forbidAny {
				if strings.Contains(w.Body.String(), forbidden) {
					t.Fatalf("body should not contain %q, got %s", forbidden, w.Body.String())
				}
			}
		})
	}
}

func TestChatCompletionsWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	srv := NewServer(workspaceBoundCfg(), fake, nil)
	body := `{"model":"codex","messages":[{"role":"user","content":"hi"}]}`
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "workspace is required") {
		t.Fatalf("expected workspace error, got %s", w.Body.String())
	}
}

func TestChatCompletionsWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent)}
	srv := NewServer(workspaceBoundCfg(), fake, nil)
	body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"some/relative"}}`
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String())
	}
	if !strings.Contains(w.Body.String(), "absolute path") {
		t.Fatalf("expected absolute path error, got %s", w.Body.String())
	}
}

func TestChatCompletionsWorkspaceRequiredRouteAbsoluteWorkspaceOK(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}
	srv := NewServer(workspaceBoundCfg(), fake, nil)
	body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"/abs/workspace"}}`
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.Workspace != "/abs/workspace" {
		t.Fatalf("workspace not preserved: got %q", fake.req.Workspace)
	}
}

func TestChatCompletionsNonRequiredRouteNoWorkspaceOK(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}
	srv := NewServer(workspaceBoundCfg(), fake, nil)
	body := `{"model":"llama3","messages":[{"role":"user","content":"hi"}]}`
	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String())
	}
}

// TestHandleModelsProviderPoolCatalog verifies that /v1/models returns the
// provider-pool catalog IDs when a catalog is set, ignoring legacy model_routes.
func TestHandleModelsProviderPoolCatalog(t *testing.T) {
	catalog := []config.ModelCatalogEntry{
		{ID: "qwen3.6:35b", Providers: map[string]string{"prov-1": "Qwen3-35B-A22B"}},
		{ID: "llama3.3:70b", Providers: map[string]string{"prov-2": "llama-3.3-70b"}},
	}
	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "legacy-model", Target: "legacy-target"},
		},
	}, &fakeRunService{}, nil)
	srv.SetModelCatalog(catalog)

	req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
	w := httptest.NewRecorder()
	srv.handleModels(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	if !strings.Contains(body, "qwen3.6:35b") || !strings.Contains(body, "llama3.3:70b") {
		t.Fatalf("catalog models not listed: %s", body)
	}
	if strings.Contains(body, "legacy-model") {
		t.Fatalf("legacy model_routes should be suppressed when catalog is set: %s", body)
	}
}

// TestChatCompletionsProviderPoolDispatch verifies that when a request model
// matches the provider-pool catalog, ProviderPool=true is set on the service
// request and Adapter/Target are left empty for service-layer resolution.
func TestChatCompletionsProviderPoolDispatch(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	catalog := []config.ModelCatalogEntry{
		{ID: "qwen3.6:35b", Providers: map[string]string{"prov-vllm": "Qwen3-35B-A22B"}},
	}
	srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
	srv.SetModelCatalog(catalog)

	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"qwen3.6:35b",
		"messages":[{"role":"user","content":"hello"}]
	}`))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if !fake.req.ProviderPool {
		t.Error("ProviderPool should be true for catalog-matched model")
	}
	if fake.req.ModelGroupKey != "qwen3.6:35b" {
		t.Errorf("ModelGroupKey: got %q, want qwen3.6:35b", fake.req.ModelGroupKey)
	}
	if fake.req.Adapter != "" || fake.req.Target != "" {
		t.Errorf("Adapter/Target should be empty for provider-pool dispatch, got %q/%q", fake.req.Adapter, fake.req.Target)
	}
}

// TestChatCompletionsLegacyRouteSetsProviderPoolFalse verifies that when the
// server is configured with Adapter + Target (no catalog), the dispatched
// SubmitRunRequest has ProviderPool=false so the service uses direct dispatch
// (not the queue admission gate that requires ProviderPool=true).
func TestChatCompletionsLegacyRouteSetsProviderPoolFalse(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	// Server with adapter+target only, no catalog — classic legacy route.
	srv := NewServer(config.EdgeOpenAIConf{
		Adapter:    "cli",
		Target:     "codex",
		TimeoutSec: 10,
	}, fake, nil)

	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"any-model",
		"messages":[{"role":"user","content":"hello"}]
	}`))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.ProviderPool {
		t.Error("ProviderPool must be false for a legacy adapter+target route with no catalog")
	}
	if fake.req.Adapter != "cli" || fake.req.Target != "codex" {
		t.Errorf("Adapter/Target: got %q/%q, want cli/codex", fake.req.Adapter, fake.req.Target)
	}
}

// TestResponsesProviderPoolDispatch verifies that the /v1/responses endpoint
// sets ProviderPool=true and leaves Adapter/Target empty when the request model
// matches a catalog entry, mirroring chat/completions provider-pool semantics.
func TestResponsesProviderPoolDispatch(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}}

	catalog := []config.ModelCatalogEntry{
		{ID: "prov-vllm:model-a", Providers: map[string]string{"prov-vllm": "model-a"}},
	}
	srv := NewServer(config.EdgeOpenAIConf{TimeoutSec: 5}, fake, nil)
	srv.SetModelCatalog(catalog)

	req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
		"model":"prov-vllm:model-a",
		"input":"hello"
	}`))
	w := httptest.NewRecorder()
	srv.handleResponses(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if !fake.req.ProviderPool {
		t.Error("ProviderPool should be true for catalog-matched model via /v1/responses")
	}
	if fake.req.ModelGroupKey != "prov-vllm:model-a" {
		t.Errorf("ModelGroupKey: got %q, want prov-vllm:model-a", fake.req.ModelGroupKey)
	}
	if fake.req.Adapter != "" || fake.req.Target != "" {
		t.Errorf("Adapter/Target should be empty for provider-pool dispatch, got %q/%q", fake.req.Adapter, fake.req.Target)
	}
}

// TestChatCompletionsProviderPoolFallsBackToLegacyRoute verifies that when the
// request model does not match the catalog, the legacy model_routes path is used.
func TestChatCompletionsProviderPoolFallsBackToLegacyRoute(t *testing.T) {
	fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
	fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
	fake.events <- &iop.RunEvent{Type: "complete"}

	catalog := []config.ModelCatalogEntry{
		{ID: "qwen3.6:35b", Providers: map[string]string{"prov-vllm": "Qwen3-35B-A22B"}},
	}
	srv := NewServer(config.EdgeOpenAIConf{
		ModelRoutes: []config.OpenAIRouteEntry{
			{Model: "ollama-model", Adapter: "ollama", Target: "llama3"},
		},
	}, fake, nil)
	srv.SetModelCatalog(catalog)

	req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
		"model":"ollama-model",
		"messages":[{"role":"user","content":"hi"}]
	}`))
	w := httptest.NewRecorder()
	srv.handleChatCompletions(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
	}
	if fake.req.ProviderPool {
		t.Error("ProviderPool should be false for non-catalog model")
	}
	if fake.req.Target != "llama3" {
		t.Errorf("Target: got %q, want llama3", fake.req.Target)
	}
}
