package runner

import (
	"fmt"
	"testing"

	"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)

func TestVideoModelTypeInferenceReadsContentArray(t *testing.T) {
	imageToVideo := modelTypeFromKind("videos.generations", map[string]any{
		"model": "demo-video",
		"content": []any{
			map[string]any{"type": "text", "text": "animate it"},
			map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/frame.png"}},
		},
	})
	if imageToVideo != "image_to_video" {
		t.Fatalf("image content should infer image_to_video, got %s", imageToVideo)
	}

	omniVideo := modelTypeFromKind("videos.generations", map[string]any{
		"model": "demo-video",
		"content": []any{
			map[string]any{"type": "text", "text": "edit it"},
			map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": "https://example.com/ref.mp4"}},
		},
	})
	if omniVideo != "omni_video" {
		t.Fatalf("video content should infer omni_video, got %s", omniVideo)
	}

	textToVideo := modelTypeFromKind("videos.generations", map[string]any{
		"model":   "demo-video",
		"content": []any{map[string]any{"type": "text", "text": "make a clip"}},
	})
	if textToVideo != "video_generate" {
		t.Fatalf("text-only content should infer video_generate, got %s", textToVideo)
	}
}

func TestAdvancedMediaModelTypeInference(t *testing.T) {
	if got := modelTypeFromKind("images.vectorize", nil); got != "image_vectorize" {
		t.Fatalf("images.vectorize model type = %s", got)
	}
	if got := modelTypeFromKind("videos.upscales", nil); got != "video_enhance" {
		t.Fatalf("videos.upscales model type = %s", got)
	}
	for _, alias := range []string{"vectorize", "image-vectorizer", "image_vectorize"} {
		if got := canonicalModelType(alias); got != "image_vectorize" {
			t.Fatalf("canonical image vectorize alias %q = %q", alias, got)
		}
	}
}

func TestVideoContentTextContributesToTokenEstimate(t *testing.T) {
	tokens := estimateRequestTokens(map[string]any{
		"model": "demo-video",
		"content": []any{
			map[string]any{"type": "text", "text": "a cinematic product reveal"},
		},
	})
	if tokens <= 1 {
		t.Fatalf("content text should contribute to token estimate, got %d", tokens)
	}
}

func TestParamProcessorOmniFiltersUnsupportedVideoAndAudioContent(t *testing.T) {
	body := map[string]any{
		"model":  "可灵O1",
		"prompt": "edit the source video",
		"content": []any{
			map[string]any{"type": "text", "text": "edit the source video"},
			map[string]any{"type": "video_url", "role": "video_base", "video_url": map[string]any{"url": "https://example.com/base.mp4", "refer_type": "base"}},
			map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": "https://example.com/ref.mp4", "refer_type": "feature"}},
			map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": "https://example.com/ref.mp3"}},
		},
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "omni_video",
		Capabilities: map[string]any{
			"omni_video": map[string]any{
				"supported_modes": []any{"video_edit"},
				"max_videos":      1,
				"input_audio":     false,
				"max_audios":      0,
			},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	processed := result.Body
	content := contentItems(processed["content"])
	if len(content) != 2 {
		t.Fatalf("expected text plus one video item, got %+v", content)
	}
	if stringFromAny(content[1]["role"]) != "video_base" || isAudioContent(content[1]) {
		t.Fatalf("unexpected retained content: %+v", content)
	}
	for _, item := range content {
		if isAudioContent(item) || stringFromAny(item["role"]) == "reference_video" {
			t.Fatalf("unsupported content was not filtered: %+v", content)
		}
	}
	if !result.Log.Changed || len(result.Log.Changes) < 2 {
		t.Fatalf("expected preprocessing log with filtered video and audio changes, got %+v", result.Log)
	}
	if result.Log.Input["content"] == nil || result.Log.Output["content"] == nil {
		t.Fatalf("preprocessing log should keep actual input and converted output: %+v", result.Log)
	}
	foundAudioReason := false
	for _, change := range result.Log.Changes {
		if change.Path == "content[3]" && change.CapabilityPath == "capabilities.omni_video.input_audio" {
			foundAudioReason = true
			break
		}
	}
	if !foundAudioReason {
		t.Fatalf("expected audio filtering reason to reference omni_video.input_audio, got %+v", result.Log.Changes)
	}
}

func TestParamProcessorOmniFiltersConvenienceReferenceFields(t *testing.T) {
	body := map[string]any{
		"model":           "可灵V3多模态",
		"prompt":          "text only",
		"reference_video": "https://example.com/ref.mp4",
		"reference_audio": "https://example.com/ref.mp3",
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "omni_video",
		Capabilities: map[string]any{
			"omni_video": map[string]any{
				"supported_modes": []any{"text_to_video"},
				"max_videos":      0,
				"input_audio":     false,
				"max_audios":      0,
			},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	processed := result.Body
	content := contentItems(processed["content"])
	if len(content) != 1 || stringFromAny(content[0]["type"]) != "text" {
		t.Fatalf("expected only text content, got %+v", content)
	}
	for _, key := range []string{"reference_video", "reference_audio"} {
		if processed[key] != nil {
			t.Fatalf("%s should be removed when capability rejects it: %+v", key, processed)
		}
	}
	if len(result.Log.Changes) == 0 {
		t.Fatalf("expected convenience-field filtering to be logged")
	}
}

func TestParamProcessorOmniCapabilityLogUsesActualCapabilityKey(t *testing.T) {
	body := map[string]any{
		"model": "Omni",
		"content": []any{
			map[string]any{"type": "text", "text": "animate"},
			map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": "https://example.com/ref.mp3"}},
		},
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "omni",
		Capabilities: map[string]any{
			"omni": map[string]any{
				"input_audio": false,
				"max_audios":  0,
			},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	for _, change := range result.Log.Changes {
		if change.Path == "content[1]" && change.CapabilityPath == "capabilities.omni.input_audio" {
			return
		}
	}
	t.Fatalf("expected log to reference capabilities.omni.input_audio, got %+v", result.Log.Changes)
}

func TestParamProcessorChatConvertsUnsupportedMediaMessageContentToText(t *testing.T) {
	body := map[string]any{
		"model": "text-only",
		"messages": []any{
			map[string]any{
				"role": "user",
				"content": []any{
					map[string]any{"type": "text", "text": "describe these"},
					map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/image.png"}},
					map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/video.mp4"}},
					map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": "https://example.com/audio.mp3"}},
					map[string]any{"type": "input_audio", "input_audio": map[string]any{"data": "https://example.com/input.wav"}},
				},
			},
		},
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "text_generate",
		Capabilities: map[string]any{
			"text_generate": map[string]any{},
			"originalTypes": []any{"text_generate"},
		},
	}

	result := preprocessRequestWithLog("chat.completions", body, candidate)
	messages, _ := result.Body["messages"].([]any)
	if len(messages) != 1 {
		t.Fatalf("expected one message, got %+v", result.Body["messages"])
	}
	message, _ := messages[0].(map[string]any)
	content, _ := message["content"].([]any)
	if len(content) != 5 {
		t.Fatalf("expected five content parts, got %+v", message["content"])
	}
	expectedText := []string{
		"describe these",
		"Image link: https://example.com/image.png",
		"video URL: https://example.com/video.mp4",
		"audio URL: https://example.com/audio.mp3",
		"audio URL: https://example.com/input.wav",
	}
	for index, expected := range expectedText {
		part, _ := content[index].(map[string]any)
		if stringFromAny(part["text"]) != expected {
			t.Fatalf("content[%d] text = %q, want %q; all=%+v", index, stringFromAny(part["text"]), expected, content)
		}
	}
	if len(result.Log.Changes) != 4 {
		t.Fatalf("expected four media conversion changes, got %+v", result.Log.Changes)
	}
	expectedCapabilityPaths := map[string]bool{
		"capabilities.image_analysis":      false,
		"capabilities.video_understanding": false,
		"capabilities.audio_understanding": false,
	}
	for _, change := range result.Log.Changes {
		if _, ok := expectedCapabilityPaths[change.CapabilityPath]; ok {
			expectedCapabilityPaths[change.CapabilityPath] = true
		}
	}
	for path, found := range expectedCapabilityPaths {
		if !found {
			t.Fatalf("expected conversion log for %s, got %+v", path, result.Log.Changes)
		}
	}
}

func TestParamProcessorChatKeepsOmniMessageContent(t *testing.T) {
	body := map[string]any{
		"model": "omni",
		"messages": []any{
			map[string]any{
				"role": "user",
				"content": []any{
					map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/image.png"}},
					map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/video.mp4"}},
					map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": "https://example.com/audio.mp3"}},
				},
			},
		},
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "text_generate",
		Capabilities: map[string]any{
			"text_generate": map[string]any{},
			"omni":          map[string]any{},
			"originalTypes": []any{"text_generate", "omni"},
		},
	}

	result := preprocessRequestWithLog("chat.completions", body, candidate)
	if result.Log.Changed {
		t.Fatalf("omni model should keep message media content unchanged, got %+v", result.Log.Changes)
	}
	messages, _ := result.Body["messages"].([]any)
	message, _ := messages[0].(map[string]any)
	content, _ := message["content"].([]any)
	for _, item := range content {
		part, _ := item.(map[string]any)
		if stringFromAny(part["type"]) == "text" {
			t.Fatalf("media content should not be converted for omni model: %+v", content)
		}
	}
}

func TestParamProcessorChatConvertsOnlyUnsupportedModalities(t *testing.T) {
	body := map[string]any{
		"model": "vision-only",
		"messages": []any{
			map[string]any{
				"role": "user",
				"content": []any{
					map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/image.png"}},
					map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://example.com/video.mp4"}},
				},
			},
		},
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "text_generate",
		Capabilities: map[string]any{
			"text_generate":  map[string]any{},
			"image_analysis": map[string]any{},
			"originalTypes":  []any{"text_generate", "image_analysis"},
		},
	}

	result := preprocessRequestWithLog("chat.completions", body, candidate)
	messages, _ := result.Body["messages"].([]any)
	message, _ := messages[0].(map[string]any)
	content, _ := message["content"].([]any)
	first, _ := content[0].(map[string]any)
	second, _ := content[1].(map[string]any)
	if stringFromAny(first["type"]) != "image_url" {
		t.Fatalf("image content should be kept when image_analysis is supported: %+v", content)
	}
	if stringFromAny(second["text"]) != "video URL: https://example.com/video.mp4" {
		t.Fatalf("video content should be converted, got %+v", second)
	}
	if len(result.Log.Changes) != 1 || result.Log.Changes[0].CapabilityPath != "capabilities.video_understanding" {
		t.Fatalf("expected only video conversion to be logged, got %+v", result.Log.Changes)
	}
}

func TestSkipTaskParameterPreprocessingLogForTextModelTypes(t *testing.T) {
	for _, modelType := range []string{"text_generate", "chat", "responses", "text"} {
		if !skipTaskParameterPreprocessingLog(modelType) {
			t.Fatalf("%s should skip task parameter preprocessing log", modelType)
		}
	}
	for _, modelType := range []string{"image_generate", "image_edit", "video_generate", "omni_video"} {
		if skipTaskParameterPreprocessingLog(modelType) {
			t.Fatalf("%s should keep task parameter preprocessing log", modelType)
		}
	}
}

func TestParamProcessorVideoCapabilitiesNormalizeAndFilter(t *testing.T) {
	body := map[string]any{
		"model":        "Seedance",
		"duration":     13,
		"aspect_ratio": "4:3",
		"resolution":   "1080p",
		"audio":        true,
		"output_audio": true,
		"content": []any{
			map[string]any{"type": "text", "text": "animate it"},
			map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}},
			map[string]any{"type": "image_url", "role": "last_frame", "image_url": map[string]any{"url": "https://example.com/last.png"}},
			map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": "https://example.com/ref.mp3"}},
		},
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_to_video",
		Capabilities: map[string]any{
			"image_to_video": map[string]any{
				"aspect_ratio_allowed":      []any{"16:9", "1:1"},
				"duration_options":          []any{4, 8, 12},
				"input_first_last_frame":    false,
				"input_audio":               false,
				"output_audio":              false,
				"max_images_for_last_frame": 0,
			},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	processed := result.Body
	if processed["duration"] != float64(12) && processed["duration"] != 12 {
		t.Fatalf("duration should be snapped to 12, got %+v", processed["duration"])
	}
	if processed["aspect_ratio"] != "16:9" {
		t.Fatalf("aspect_ratio should fall back to first allowed value, got %+v", processed["aspect_ratio"])
	}
	if processed["audio"] != nil || processed["output_audio"] != nil {
		t.Fatalf("output audio flags should be removed: %+v", processed)
	}
	for _, item := range contentItems(processed["content"]) {
		if stringFromAny(item["role"]) == "last_frame" || isAudioContent(item) {
			t.Fatalf("unsupported content remained: %+v", processed["content"])
		}
	}
	foundDuration := false
	for _, change := range result.Log.Changes {
		if change.Path == "duration" && change.CapabilityPath == "capabilities.image_to_video.duration_options" {
			foundDuration = true
			break
		}
	}
	if !foundDuration {
		t.Fatalf("expected duration adjustment to reference duration_options, got %+v", result.Log.Changes)
	}
}

func TestParamProcessorDowngradesReferenceImagesToFrames(t *testing.T) {
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_to_video",
		Capabilities: map[string]any{
			"image_to_video": map[string]any{
				"input_first_frame":                 true,
				"input_first_last_frame":            true,
				"input_reference_generate_single":   false,
				"input_reference_generate_multiple": false,
			},
		},
	}
	body := map[string]any{
		"model":  "Seedance",
		"prompt": "animate it",
		"content": []any{
			map[string]any{"type": "text", "text": "animate it"},
			map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/first.png"}},
			map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/last.png"}},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Err != nil {
		t.Fatalf("two image references should downgrade to first/last frames: %v", result.Err)
	}
	content := contentItems(result.Body["content"])
	if stringFromAny(content[1]["role"]) != "first_frame" || stringFromAny(content[2]["role"]) != "last_frame" {
		t.Fatalf("expected first/last frame downgrade, got %+v", content)
	}
}

func TestParamProcessorDowngradesSingleReferenceImageToFirstFrame(t *testing.T) {
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_to_video",
		Capabilities: map[string]any{
			"image_to_video": map[string]any{
				"input_first_frame":                 true,
				"input_first_last_frame":            true,
				"input_reference_generate_single":   false,
				"input_reference_generate_multiple": false,
			},
		},
	}
	body := map[string]any{
		"model":  "Seedance",
		"prompt": "animate it",
		"content": []any{
			map[string]any{"type": "text", "text": "animate it"},
			map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/first.png"}},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Err != nil {
		t.Fatalf("single image reference should downgrade to first frame: %v", result.Err)
	}
	content := contentItems(result.Body["content"])
	if stringFromAny(content[1]["role"]) != "first_frame" {
		t.Fatalf("expected first frame downgrade, got %+v", content)
	}
}

func TestParamProcessorRejectsUnsafeReferenceImageDowngrade(t *testing.T) {
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_to_video",
		Capabilities: map[string]any{
			"image_to_video": map[string]any{
				"input_first_frame":                 true,
				"input_first_last_frame":            false,
				"input_reference_generate_single":   false,
				"input_reference_generate_multiple": false,
			},
		},
	}
	body := map[string]any{
		"model":  "Seedance",
		"prompt": "animate it",
		"content": []any{
			map[string]any{"type": "text", "text": "animate it"},
			map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/first.png"}},
			map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/last.png"}},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Err == nil {
		t.Fatalf("two image references should be rejected when first/last frame is unsupported")
	}
	if len(result.Log.Changes) == 0 || result.Log.Changes[len(result.Log.Changes)-1].Action != "reject" {
		t.Fatalf("expected reject preprocessing log, got %+v", result.Log.Changes)
	}
}

func TestParamProcessorRejectsVideoOrAudioReferenceDowngrade(t *testing.T) {
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_to_video",
		Capabilities: map[string]any{
			"image_to_video": map[string]any{
				"input_first_frame":                 true,
				"input_first_last_frame":            true,
				"input_reference_generate_single":   false,
				"input_reference_generate_multiple": false,
			},
		},
	}
	body := map[string]any{
		"model":  "Seedance",
		"prompt": "animate it",
		"content": []any{
			map[string]any{"type": "text", "text": "animate it"},
			map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/first.png"}},
			map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": "https://example.com/ref.mp4"}},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Err == nil {
		t.Fatalf("video reference should be rejected instead of downgraded")
	}
}

func TestParamProcessorDurationRangeRoundsFractionalSecondsUp(t *testing.T) {
	body := map[string]any{
		"model":    "Seedance",
		"prompt":   "animate it",
		"duration": 5.5,
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "video_generate",
		Capabilities: map[string]any{
			"video_generate": map[string]any{
				"duration_range": []any{3, 12},
			},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Body["duration"] != float64(6) && result.Body["duration"] != 6 {
		t.Fatalf("fractional duration should be rounded up to default 1s step, got %+v", result.Body["duration"])
	}
}

func TestParamProcessorDurationWithoutRangeStillRoundsUp(t *testing.T) {
	body := map[string]any{
		"model":    "Seedance",
		"prompt":   "animate it",
		"duration": 5.2,
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "video_generate",
		Capabilities: map[string]any{
			"video_generate": map[string]any{},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Body["duration"] != float64(6) && result.Body["duration"] != 6 {
		t.Fatalf("duration should default to a 1s upward step without range, got %+v", result.Body["duration"])
	}
}

func TestParamProcessorDurationRangeUsesStepCeilingAndRange(t *testing.T) {
	body := map[string]any{
		"model":            "Seedance",
		"prompt":           "animate it",
		"duration":         6.1,
		"duration_seconds": 6.1,
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_to_video",
		Capabilities: map[string]any{
			"image_to_video": map[string]any{
				"duration_range": []any{5, 10},
				"duration_step":  2,
			},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Body["duration"] != float64(7) && result.Body["duration"] != 7 {
		t.Fatalf("duration should be rounded up by configured step, got %+v", result.Body["duration"])
	}
	if result.Body["duration_seconds"] != result.Body["duration"] {
		t.Fatalf("duration_seconds should sync with normalized duration, got %+v", result.Body)
	}

	body["duration"] = 10.1
	body["duration_seconds"] = 10.1
	result = preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Body["duration"] != float64(10) && result.Body["duration"] != 10 {
		t.Fatalf("duration should be capped by range max, got %+v", result.Body["duration"])
	}
}

func TestParamProcessorDurationOptionsChooseNextAllowedValue(t *testing.T) {
	body := map[string]any{
		"model":    "Seedance",
		"prompt":   "animate it",
		"duration": 8.1,
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_to_video",
		Capabilities: map[string]any{
			"image_to_video": map[string]any{
				"duration_options": []any{4, 8, 12},
			},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	if result.Body["duration"] != float64(12) && result.Body["duration"] != 12 {
		t.Fatalf("duration should use next allowed option, got %+v", result.Body["duration"])
	}
}

func TestParamProcessorVideoGenerateLogsFirstFrameRemoval(t *testing.T) {
	body := map[string]any{
		"model":  "Seedance T2V",
		"prompt": "animate it",
		"content": []any{
			map[string]any{"type": "text", "text": "animate it"},
			map[string]any{"type": "image_url", "role": "first_frame", "image_url": "https://example.com/first.png"},
		},
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "video_generate",
		Capabilities: map[string]any{
			"video_generate": map[string]any{
				"duration_range": []any{3, 12},
			},
		},
	}

	result := preprocessRequestWithLog("videos.generations", body, candidate)
	for _, item := range contentItems(result.Body["content"]) {
		if isImageContent(item) {
			t.Fatalf("first frame image should be removed for video_generate: %+v", result.Body["content"])
		}
	}
	for _, change := range result.Log.Changes {
		if change.Path == "content[1]" {
			if change.Reason != "模型能力未开启首帧输入，已移除 first_frame。" {
				t.Fatalf("unexpected first frame removal reason: %+v", change)
			}
			if change.CapabilityPath != "capabilities.video_generate.input_first_frame" {
				t.Fatalf("unexpected first frame capability path: %+v", change)
			}
			return
		}
	}
	t.Fatalf("expected first frame removal log, got %+v", result.Log.Changes)
}

func TestParamProcessorImageResolutionAndOutputCount(t *testing.T) {
	body := map[string]any{
		"model":  "即梦V4.0",
		"prompt": "draw",
		"size":   "2K",
		"n":      8,
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_generate",
		Capabilities: map[string]any{
			"image_generate": map[string]any{
				"output_multiple_images":  true,
				"output_max_images_count": 4,
			},
		},
	}

	processed := preprocessRequest("images.generations", body, candidate)
	if processed["resolution"] != "2K" {
		t.Fatalf("size resolution should be copied to resolution, got %+v", processed)
	}
	if processed["n"] != 4 {
		t.Fatalf("image count should be capped to 4, got %+v", processed["n"])
	}
}

func TestParamProcessorForcesSingleImageWhenMultipleOutputsAreDisabled(t *testing.T) {
	body := map[string]any{
		"model":  "Seedream-5.0-Pro",
		"prompt": "draw",
		"n":      4,
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_generate",
		Capabilities: map[string]any{
			"image_generate": map[string]any{
				"output_multiple_images":  false,
				"output_max_images_count": 1,
			},
		},
	}

	result := preprocessRequestWithLog("images.generations", body, candidate)
	if result.Body["n"] != 1 {
		t.Fatalf("single-output image model should force n=1, got %+v", result.Body)
	}
	if len(result.Log.Changes) == 0 || result.Log.Changes[len(result.Log.Changes)-1].CapabilityPath != "capabilities.image_generate.output_max_images_count" {
		t.Fatalf("single-output adjustment should be logged against output_max_images_count, got %+v", result.Log.Changes)
	}
}

func TestParamProcessorImageSizeConstraintsNormalizeExplicitDimensions(t *testing.T) {
	body := map[string]any{
		"model":      "doubao-5.0图像编辑",
		"prompt":     "draw",
		"resolution": "2K",
		"width":      1024,
		"height":     1024,
		"size":       "1024x1024",
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_generate",
		Capabilities: map[string]any{
			"image_generate": map[string]any{
				"output_resolutions": []any{"2K", "3K"},
				"output_size_range":  []any{3686400, 10404496},
				"aspect_ratio_range": []any{0.0625, 16},
			},
		},
	}

	result := preprocessRequestWithLog("images.generations", body, candidate)
	if result.Body["width"] != 1920 || result.Body["height"] != 1920 {
		t.Fatalf("explicit dimensions below model minimum should be scaled to 1920x1920, got %+v", result.Body)
	}
	if result.Body["size"] != "1920x1920" {
		t.Fatalf("size should be synchronized with normalized width/height, got %+v", result.Body)
	}
	if result.Body["resolution"] != "2K" {
		t.Fatalf("resolution should stay on allowed 2K, got %+v", result.Body)
	}
	for _, change := range result.Log.Changes {
		if change.Processor == "ImageSizeProcessor" && change.CapabilityPath == "capabilities.image_generate.output_size_range" {
			return
		}
	}
	t.Fatalf("expected image size preprocessing log against output_size_range, got %+v", result.Log.Changes)
}

func TestParamProcessorImageSizeConstraintsAcceptPixelResolutionStrings(t *testing.T) {
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_generate",
		Capabilities: map[string]any{
			"image_generate": map[string]any{
				"output_resolutions": []any{"1K", "2K", "4K"},
				"aspect_ratio_allowed": []any{
					"1:1",
					"16:9",
					"9:16",
				},
				"output_size_range":  []any{655360, 8294400},
				"width_height_range": []any{1, 3840},
			},
		},
	}

	for name, body := range map[string]map[string]any{
		"resolution": {
			"model":      "gpt-image-2",
			"prompt":     "draw",
			"resolution": "3840x2160",
		},
		"size": {
			"model":  "gpt-image-2",
			"prompt": "draw",
			"size":   "3840x2160",
		},
		"non-standard-resolution": {
			"model":      "gpt-image-2",
			"prompt":     "draw",
			"resolution": "3600x1900",
		},
	} {
		t.Run(name, func(t *testing.T) {
			processed := preprocessRequest("images.generations", body, candidate)
			expectedWidth := 3840
			expectedHeight := 2160
			if name == "non-standard-resolution" {
				expectedWidth = 3600
				expectedHeight = 1900
			}
			if processed["width"] != expectedWidth || processed["height"] != expectedHeight {
				t.Fatalf("pixel resolution string should populate width/height, got %+v", processed)
			}
			if processed["aspect_ratio"] != "16:9" {
				t.Fatalf("pixel resolution string should infer aspect_ratio, got %+v", processed)
			}
			if processed["resolution"] != "4K" {
				t.Fatalf("pixel resolution string should normalize resolution to allowed bucket, got %+v", processed)
			}
			expectedSize := fmt.Sprintf("%dx%d", expectedWidth, expectedHeight)
			if processed["size"] != expectedSize {
				t.Fatalf("size should stay synchronized with normalized width/height, got %+v", processed)
			}
		})
	}
}

func TestParamProcessorImageSizeConstraintsPreferPixelSizeOverExplicitDimensions(t *testing.T) {
	for name, testcase := range map[string]struct {
		kind      string
		modelType string
		body      map[string]any
	}{
		"generate": {
			kind:      "images.generations",
			modelType: "image_generate",
			body: map[string]any{
				"model":        "gpt-image-2",
				"prompt":       "draw",
				"size":         "2160x1566",
				"width":        1024,
				"height":       1024,
				"aspect_ratio": "1:1",
			},
		},
		"edit": {
			kind:      "images.edits",
			modelType: "image_edit",
			body: map[string]any{
				"model":        "gpt-image-2",
				"prompt":       "edit",
				"image":        "https://example.com/input.png",
				"size":         "2160x1566",
				"width":        1024,
				"height":       1024,
				"aspect_ratio": "1:1",
			},
		},
	} {
		t.Run(name, func(t *testing.T) {
			candidate := store.RuntimeModelCandidate{
				ModelType: testcase.modelType,
				Capabilities: map[string]any{
					testcase.modelType: map[string]any{
						"output_resolutions": []any{"1K", "2K", "4K"},
						"aspect_ratio_allowed": []any{
							"1:1",
							"4:3",
							"16:9",
							"9:16",
						},
						"output_size_range":     []any{655360, 8294400},
						"width_height_range":    []any{1, 3840},
						"width_height_multiple": 16,
						"input_multiple_images": true,
					},
				},
			}

			processed := preprocessRequest(testcase.kind, testcase.body, candidate)
			if processed["width"] != 2160 || processed["height"] != 1568 {
				t.Fatalf("size dimensions should win before constraints, got %+v", processed)
			}
			if processed["size"] != "2160x1568" {
				t.Fatalf("size should sync with constrained dimensions, got %+v", processed)
			}
			if processed["aspect_ratio"] != "4:3" {
				t.Fatalf("aspect_ratio should be inferred from size dimensions, got %+v", processed)
			}
			if processed["resolution"] != "2K" {
				t.Fatalf("resolution should be inferred from size dimensions, got %+v", processed)
			}
		})
	}
}

func TestParamProcessorImageSizeConstraintsNormalizeEditDimensions(t *testing.T) {
	body := map[string]any{
		"model":  "gpt-image-2",
		"prompt": "edit",
		"image":  "https://example.com/input.png",
		"width":  "513",
		"height": "513",
	}
	candidate := store.RuntimeModelCandidate{
		ModelType: "image_edit",
		Capabilities: map[string]any{
			"image_edit": map[string]any{
				"aspect_ratio_allowed":  []any{"1:1", "16:9"},
				"aspect_ratio_range":    []any{1.0 / 3.0, 3.0},
				"output_size_range":     []any{655360, 8294400},
				"width_height_range":    []any{1, 3840},
				"width_height_multiple": 16,
				"input_multiple_images": true,
			},
		},
	}

	processed := preprocessRequest("images.edits", body, candidate)
	width := int(floatFromAny(processed["width"]))
	height := int(floatFromAny(processed["height"]))
	if width != 816 || height != 816 {
		t.Fatalf("edit dimensions should scale up and align to 16px multiples, got %+v", processed)
	}
	if processed["size"] != "816x816" {
		t.Fatalf("edit size should be synchronized with normalized dimensions, got %+v", processed)
	}
	if width*height < 655360 || width%16 != 0 || height%16 != 0 {
		t.Fatalf("edit dimensions should satisfy model constraints, got %+v", processed)
	}
}

func TestParamProcessorImageQualityControl(t *testing.T) {
	body := map[string]any{
		"model":   "mock-image",
		"prompt":  "draw",
		"quality": "high",
	}

	unsupported := preprocessRequestWithLog("images.generations", body, store.RuntimeModelCandidate{
		ModelType: "image_generate",
		Capabilities: map[string]any{
			"image_generate": map[string]any{
				"output_resolutions": []any{"1K"},
			},
		},
	})
	if _, ok := unsupported.Body["quality"]; ok {
		t.Fatalf("quality should be removed when capability does not support it: %+v", unsupported.Body)
	}
	if len(unsupported.Log.Changes) == 0 || unsupported.Log.Changes[len(unsupported.Log.Changes)-1].CapabilityPath != "capabilities.image_generate.support_quality_control" {
		t.Fatalf("expected quality removal to be logged against support_quality_control, got %+v", unsupported.Log.Changes)
	}

	supported := preprocessRequest("images.generations", body, store.RuntimeModelCandidate{
		ModelType: "image_generate",
		Capabilities: map[string]any{
			"image_generate": map[string]any{
				"support_quality_control": true,
				"output_resolutions":      []any{"1K"},
			},
		},
	})
	if supported["quality"] != "high" {
		t.Fatalf("quality should be retained when capability supports it: %+v", supported)
	}

	incompatible := preprocessRequest("images.generations", map[string]any{
		"model":   "mock-image",
		"prompt":  "draw",
		"quality": "standard",
	}, store.RuntimeModelCandidate{
		ModelType: "image_generate",
		Capabilities: map[string]any{
			"image_generate": map[string]any{
				"support_quality_control": true,
				"output_resolutions":      []any{"1K"},
			},
		},
	})
	if _, ok := incompatible["quality"]; ok {
		t.Fatalf("OpenAI-compatible GPT image quality should reject standard: %+v", incompatible)
	}
}
