package service

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"strings"
	"testing"
	"time"

	"oci-portal/internal/aiwire"
	"oci-portal/internal/model"
	"oci-portal/internal/oci"
)

// stubServiceError 实现 common.ServiceError,用于模拟带状态码的 OCI 服务端错误。
type stubServiceError struct {
	status int
	msg    string
}

func (e stubServiceError) Error() string          { return "stub service error" }
func (e stubServiceError) GetHTTPStatusCode() int { return e.status }
func (e stubServiceError) GetMessage() string {
	if e.msg != "" {
		return e.msg
	}
	return "stub"
}
func (e stubServiceError) GetCode() string         { return "Stub" }
func (e stubServiceError) GetOpcRequestID() string { return "req-1" }

// finetuneBaseErr 模拟「微调基座模型不可按需调用」的 OCI 400。
func finetuneBaseErr() stubServiceError {
	return stubServiceError{status: 400,
		msg: "Not allowed to call finetune base model ocid1.generativeaimodel.oc1.eu-frankfurt-1.tpel5q, use Endpoint: false"}
}

// gatewayStubClient 覆写 GenAI 四方法;chatErrs 逐次弹出以模拟先失败后成功。
type gatewayStubClient struct {
	*fakeClient

	models    []oci.GenAiModel
	modelsErr error
	probeCode int
	probeErr  error
	// probeSeq 非空时逐次弹出,模拟按候选依次试调;弹尽后回落 probeCode/probeErr
	probeSeq []probeResult
	// probedNames 记录试调过的模型名,供断言候选过滤
	probedNames []string
	chatCalls   int
	regions     []string

	embedVecs  [][]float32
	embedUsage *aiwire.Usage
	embedErr   error

	passPayload []byte
	passErrs    []error
	passCalls   int
	passRegions []string

	speechAudio  []byte
	speechCT     string
	speechErr    error
	rerankRanks  []oci.RerankRank
	rerankErr    error
	guardOutcome *oci.GuardrailsOutcome
	guardErr     error
}

func (f *gatewayStubClient) GenAiCompatSpeech(ctx context.Context, cred oci.Credentials, region string, body []byte) ([]byte, string, error) {
	return f.speechAudio, f.speechCT, f.speechErr
}

func (f *gatewayStubClient) GenAiRerank(ctx context.Context, cred oci.Credentials, region, modelOcid, query string, documents []string, topN *int) ([]oci.RerankRank, error) {
	return f.rerankRanks, f.rerankErr
}

func (f *gatewayStubClient) GenAiApplyGuardrails(ctx context.Context, cred oci.Credentials, region, text string) (*oci.GuardrailsOutcome, error) {
	return f.guardOutcome, f.guardErr
}

func (f *gatewayStubClient) GenAiCompatResponses(ctx context.Context, cred oci.Credentials, region string, body []byte, wait time.Duration) ([]byte, error) {
	f.passCalls++
	f.passRegions = append(f.passRegions, region)
	if len(f.passErrs) > 0 {
		err := f.passErrs[0]
		f.passErrs = f.passErrs[1:]
		if err != nil {
			return nil, err
		}
	}
	return f.passPayload, nil
}

func (f *gatewayStubClient) GenAiCompatResponsesStream(ctx context.Context, cred oci.Credentials, region string, body []byte, wait time.Duration) (io.ReadCloser, error) {
	f.passCalls++
	f.passRegions = append(f.passRegions, region)
	if len(f.passErrs) > 0 {
		err := f.passErrs[0]
		f.passErrs = f.passErrs[1:]
		if err != nil {
			return nil, err
		}
	}
	return io.NopCloser(bytes.NewReader(f.passPayload)), nil
}

func (f *gatewayStubClient) GenAiEmbed(ctx context.Context, cred oci.Credentials, region, modelOcid string, inputs []string, dimensions *int) ([][]float32, *aiwire.Usage, error) {
	return f.embedVecs, f.embedUsage, f.embedErr
}

func (f *gatewayStubClient) ListGenAiModels(ctx context.Context, cred oci.Credentials, region string) ([]oci.GenAiModel, error) {
	return f.models, f.modelsErr
}

// probeResult 是 gatewayStubClient.probeSeq 的单次探测结果。
type probeResult struct {
	code int
	err  error
}

func (f *gatewayStubClient) GenAiProbeChat(ctx context.Context, cred oci.Credentials, region, modelOcid, modelName string) (int, error) {
	f.probedNames = append(f.probedNames, modelName)
	if len(f.probeSeq) > 0 {
		r := f.probeSeq[0]
		f.probeSeq = f.probeSeq[1:]
		return r.code, r.err
	}
	return f.probeCode, f.probeErr
}

func newTestGateway(t *testing.T, client oci.Client) (*AiGatewayService, *OciConfigService) {
	t.Helper()
	svc := newTestService(t, client)
	if err := svc.db.AutoMigrate(&model.Setting{}, &model.AiKey{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiModelBlacklist{}, &model.AiCallLog{}); err != nil {
		t.Fatalf("auto migrate ai tables: %v", err)
	}
	return NewAiGatewayService(svc.db, svc, client), svc
}

func TestAiKeyLifecycle(t *testing.T) {
	gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	ctx := context.Background()

	raw, key, err := gw.CreateKey(ctx, "免费-ai-api-key", "test-api-key", "", nil)
	if err != nil || raw != "test-api-key" || key.Tail != "-key" {
		t.Fatalf("CreateKey 自定义值 = %q %+v, %v", raw, key, err)
	}
	if _, _, err := gw.CreateKey(ctx, "short", "abc", "", nil); err == nil {
		t.Error("过短自定义密钥应被拒绝")
	}
	auto, _, err := gw.CreateKey(ctx, "auto", "", "", nil)
	if err != nil || len(auto) < 40 || auto[:3] != "sk-" {
		t.Fatalf("CreateKey 随机值 = %q, %v", auto, err)
	}
	got, err := gw.VerifyKey(ctx, "test-api-key")
	if err != nil || got.Name != "免费-ai-api-key" {
		t.Fatalf("VerifyKey = %+v, %v", got, err)
	}
	if _, err := gw.VerifyKey(ctx, "wrong"); !errors.Is(err, ErrAiKeyInvalid) {
		t.Errorf("错误密钥 err = %v", err)
	}
	off := false
	_ = gw.UpdateKey(ctx, key.ID, "", &off, nil, nil)
	if _, err := gw.VerifyKey(ctx, "test-api-key"); !errors.Is(err, ErrAiKeyInvalid) {
		t.Errorf("禁用后 VerifyKey err = %v", err)
	}
}

func TestAiChannelProbe(t *testing.T) {
	client := &gatewayStubClient{
		fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		models:     []oci.GenAiModel{{Ocid: "ocid1.generativeaimodel.oc1..m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
		probeCode:  200,
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	ctx := context.Background()

	ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
	if err != nil || ch.Name == "" || !ch.Enabled {
		t.Fatalf("CreateChannel = %+v, %v", ch, err)
	}
	if _, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"}); err == nil {
		t.Error("重复渠道应被拒绝")
	}
	probed, err := gw.ProbeChannel(ctx, ch.ID)
	if err != nil || probed.ProbeStatus != "ok" {
		t.Fatalf("ProbeChannel = %+v, %v", probed, err)
	}
	models, err := gw.channelModels(ctx, ch.ID)
	if err != nil || len(models) != 1 || models[0].Name != "meta.llama-3.3-70b-instruct" {
		t.Fatalf("模型缓存未同步: %+v, %v", models, err)
	}
	// 配额拒绝 → no_quota
	client.probeCode, client.probeErr = 403, stubServiceError{status: 403}
	probed, _ = gw.ProbeChannel(ctx, ch.ID)
	if probed.ProbeStatus != "no_quota" {
		t.Errorf("403 探测 status = %q, want no_quota", probed.ProbeStatus)
	}
	// 区域无模型 → no_service
	client.models = nil
	probed, _ = gw.ProbeChannel(ctx, ch.ID)
	if probed.ProbeStatus != "no_service" {
		t.Errorf("空模型探测 status = %q, want no_service", probed.ProbeStatus)
	}
	list, _ := gw.GatewayModels(ctx, "")
	if len(list.Data) != 0 {
		t.Errorf("no_service 后模型缓存应清空, got %+v", list.Data)
	}
}

// seedChannel 直插渠道与模型缓存,绕过探测。
func seedChannel(t *testing.T, gw *AiGatewayService, cfgID uint, region string, priority, weight int) *model.AiChannel {
	t.Helper()
	ch := &model.AiChannel{Name: region, OciConfigID: cfgID, Region: region, Enabled: true, Priority: priority, Weight: weight}
	if err := gw.db.Create(ch).Error; err != nil {
		t.Fatalf("seed channel: %v", err)
	}
	cache := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m-" + region, Name: "meta.llama-3.3-70b-instruct", Vendor: "meta", SyncedAt: time.Now()}
	if err := gw.db.Create(cache).Error; err != nil {
		t.Fatalf("seed cache: %v", err)
	}
	return ch
}

// TestFilterDeprecatedModels 断言「过滤弃用模型」开关对列表 / 路由的过滤与持久化。
func TestFilterDeprecatedModels(t *testing.T) {
	gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	cfg := importAliveConfig(t, svc)
	ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	dep := time.Now().Add(-24 * time.Hour)
	old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
		Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
	if err := gw.db.Create(old).Error; err != nil {
		t.Fatalf("seed deprecated cache: %v", err)
	}
	ctx := context.Background()

	list, _ := gw.GatewayModels(ctx, "")
	if len(list.Data) != 2 {
		t.Fatalf("开关关:应含弃用模型,got %d", len(list.Data))
	}
	if err := gw.SetFilterDeprecated(ctx, true); err != nil {
		t.Fatalf("SetFilterDeprecated: %v", err)
	}
	list, _ = gw.GatewayModels(ctx, "")
	if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" {
		t.Fatalf("开关开:弃用模型应被过滤,got %+v", list.Data)
	}
	if _, err := gw.pick(ctx, "meta.llama-old", "", "CHAT", map[uint]bool{}); !errors.Is(err, ErrAiUnknownModel) {
		t.Errorf("开关开:弃用模型路由应不可达,err = %v", err)
	}
	// 持久化:重建 service 后开关仍生效
	gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
	if !gw2.FilterDeprecated() {
		t.Error("重建后开关状态应保持开启")
	}
	if err := gw.SetFilterDeprecated(ctx, false); err != nil {
		t.Fatalf("关闭开关: %v", err)
	}
	if list, _ = gw.GatewayModels(ctx, ""); len(list.Data) != 2 {
		t.Errorf("开关关:弃用模型应恢复,got %d", len(list.Data))
	}
}

func TestPickPriorityAndBreaker(t *testing.T) {
	gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	cfg := importAliveConfig(t, svc)
	high := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	seedChannel(t, gw, cfg.ID, "us-chicago-1", 2, 1)
	ctx := context.Background()

	cand, err := gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{})
	if err != nil || cand.ch.ID != high.ID {
		t.Fatalf("应选高优先级渠道: %+v, %v", cand, err)
	}
	// 高优先级熔断 → 降级到低优先级
	until := time.Now().Add(10 * time.Minute)
	gw.db.Model(&model.AiChannel{}).Where("id = ?", high.ID).Update("disabled_until", until)
	cand, err = gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{})
	if err != nil || cand.ch.ID == high.ID {
		t.Fatalf("熔断渠道应被跳过: %+v, %v", cand, err)
	}
	// 全部排除 → ErrAiNoChannel
	_, err = gw.pick(ctx, "meta.llama-3.3-70b-instruct", "", "CHAT", map[uint]bool{cand.ch.ID: true})
	if !errors.Is(err, ErrAiNoChannel) {
		t.Errorf("全排除 err = %v", err)
	}
}

func TestMarkFailureBackoff(t *testing.T) {
	gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	cfg := importAliveConfig(t, svc)
	ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	ctx := context.Background()

	for i := 0; i < aiFailThreshold; i++ {
		gw.markFailure(ctx, ch.ID)
	}
	var got model.AiChannel
	gw.db.First(&got, ch.ID)
	if got.FailCount != aiFailThreshold || got.DisabledUntil == nil {
		t.Fatalf("达到阈值应熔断: fail=%d until=%v", got.FailCount, got.DisabledUntil)
	}
	gw.markSuccess(ctx, ch.ID)
	// 新变量重读:gorm 对 NULL 列不覆盖已有值的结构体字段
	var reset model.AiChannel
	gw.db.First(&reset, ch.ID)
	if reset.FailCount != 0 || reset.DisabledUntil != nil {
		t.Errorf("成功后应复位: %+v", reset)
	}
}

func TestAiGroupRouting(t *testing.T) {
	client := &gatewayStubClient{
		fakeClient:  &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	vip := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	other := seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
	gw.db.Model(vip).Update("channel_group", "vip")
	gw.db.Create(&model.AiModelCache{ChannelID: other.ID, ModelOcid: "ocid1..cr", Name: "cohere.command-r", Vendor: "cohere", SyncedAt: time.Now()})
	ctx := context.Background()
	body := []byte(`{"model":"meta.llama-3.3-70b-instruct","input":"你好"}`)

	// 分组密钥只落同分组渠道
	for i := 0; i < 5; i++ {
		_, meta, err := gw.RespPassthrough(ctx, body, "meta.llama-3.3-70b-instruct", "vip")
		if err != nil || meta.ChannelID != vip.ID {
			t.Fatalf("vip 组第 %d 次落点 = %d, err %v, want %d", i, meta.ChannelID, err, vip.ID)
		}
	}
	// 分组内无渠道 → 无可用渠道
	if _, _, err := gw.RespPassthrough(ctx, body, "meta.llama-3.3-70b-instruct", "nope"); !errors.Is(err, ErrAiNoChannel) {
		t.Errorf("空分组 err = %v, want ErrAiNoChannel", err)
	}
	// 模型列表按分组过滤
	list, _ := gw.GatewayModels(ctx, "vip")
	if len(list.Data) != 1 || list.Data[0].ID != "meta.llama-3.3-70b-instruct" {
		t.Errorf("vip 组模型 = %+v", list.Data)
	}
	all, _ := gw.GatewayModels(ctx, "")
	if len(all.Data) != 2 {
		t.Errorf("不限组模型数 = %d, want 2", len(all.Data))
	}
	// 密钥分组落库
	_, key, err := gw.CreateKey(ctx, "vip-key", "vip-secret-1234", "vip", nil)
	if err != nil || key.Group != "vip" {
		t.Fatalf("CreateKey group = %+v, %v", key, err)
	}
	empty := ""
	_ = gw.UpdateKey(ctx, key.ID, "", nil, &empty, nil)
	var fresh model.AiKey
	gw.db.First(&fresh, key.ID)
	if fresh.Group != "" {
		t.Errorf("清空分组后 = %q", fresh.Group)
	}
}

func TestSyncAiProbeTask(t *testing.T) {
	client := &gatewayStubClient{
		fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		models:     []oci.GenAiModel{{Ocid: "ocid1.generativeaimodel.oc1..m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
		probeCode:  200,
	}
	gw, svc := newTestGateway(t, client)
	if err := gw.db.AutoMigrate(&model.Task{}, &model.TaskLog{}, &model.Setting{}); err != nil {
		t.Fatalf("migrate task tables: %v", err)
	}
	tasks := NewTaskService(gw.db, svc, nil, nil)
	tasks.AttachAiGateway(gw)
	gw.SetOnChannelsChanged(tasks.SyncAiProbeTask)
	cfg := importAliveConfig(t, svc)
	ctx := context.Background()

	// 创建渠道 → 自动建任务并激活
	ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
	if err != nil {
		t.Fatalf("CreateChannel: %v", err)
	}
	var task model.Task
	if err := gw.db.Where("type = ?", model.TaskTypeAiProbe).First(&task).Error; err != nil {
		t.Fatalf("探测任务未自动创建: %v", err)
	}
	if task.Status != model.TaskStatusActive {
		t.Errorf("任务状态 = %q, want active", task.Status)
	}
	if task.CronExpr != "0 0 * * *" {
		t.Errorf("cron = %q, want 每天 00:00", task.CronExpr)
	}
	// 存量旧 cron 自动对齐
	gw.db.Model(&task).Update("cron_expr", "*/10 * * * *")
	tasks.SyncAiProbeTask(ctx)
	var aligned model.Task
	gw.db.First(&aligned, task.ID)
	if aligned.CronExpr != "0 0 * * *" {
		t.Errorf("存量 cron 未对齐: %q", aligned.CronExpr)
	}
	// 手动重复创建被拒
	if _, err := tasks.CreateTask(ctx, CreateTaskInput{Name: "dup", Type: model.TaskTypeAiProbe, CronExpr: "*/10 * * * *"}); err == nil {
		t.Error("重复 AI 探测任务应被拒绝")
	}
	// 立即执行一次:探测 ok
	entry, err := tasks.RunTaskNow(ctx, task.ID)
	if err != nil || entry == nil || !entry.Success {
		t.Fatalf("RunTaskNow = %+v, %v", entry, err)
	}
	if !strings.Contains(entry.Message, "1 ok") {
		t.Errorf("探测汇总 = %q", entry.Message)
	}
	// 手动删除被拒:任务由系统维护
	if err := tasks.DeleteTask(ctx, task.ID); err == nil {
		t.Error("手动删除 AI 探测任务应被拒绝")
	}
	// 渠道归零 → 任务连同日志自动删除
	if err := gw.DeleteChannel(ctx, ch.ID); err != nil {
		t.Fatalf("DeleteChannel: %v", err)
	}
	var gone int64
	gw.db.Model(&model.Task{}).Where("type = ?", model.TaskTypeAiProbe).Count(&gone)
	if gone != 0 {
		t.Errorf("渠道归零后任务应被删除,剩 %d", gone)
	}
	var logsLeft int64
	gw.db.Model(&model.TaskLog{}).Where("task_id = ?", task.ID).Count(&logsLeft)
	if logsLeft != 0 {
		t.Errorf("任务日志应随任务删除,剩 %d", logsLeft)
	}
	// 再建渠道 → 自动新建任务
	if _, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "us-chicago-1"}); err != nil {
		t.Fatalf("CreateChannel again: %v", err)
	}
	var again model.Task
	if err := gw.db.Where("type = ?", model.TaskTypeAiProbe).First(&again).Error; err != nil {
		t.Fatalf("渠道恢复后任务未重建: %v", err)
	}
	if again.Status != model.TaskStatusActive {
		t.Errorf("渠道恢复后任务状态 = %q, want active", again.Status)
	}
}

func TestProbeCandidates(t *testing.T) {
	models := []oci.GenAiModel{
		{Ocid: "o1", Name: "xai.grok-voice-agent"},
		{Ocid: "o2", Name: "cohere.command-r-plus"},
		{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
		{Ocid: "o4", Name: "google.gemini-2.5-flash"},
	}
	got := probeCandidates("", models)
	if len(got) != 3 || got[0].Name != "meta.llama-3.3-70b-instruct" || got[1].Name != "google.gemini-2.5-flash" {
		t.Errorf("候选排序 = %+v", got)
	}
	for _, m := range got {
		if m.Name == "xai.grok-voice-agent" {
			t.Error("voice 模型不应进候选前 3")
		}
	}
}

func TestProbeCandidatesVendorDiversity(t *testing.T) {
	// 部分区域单一厂商全为微调基座:候选须跨厂商分散,不能被 3 个 llama 占满前排
	models := []oci.GenAiModel{
		{Ocid: "l1", Name: "meta.llama-3-70b-instruct"},
		{Ocid: "l2", Name: "meta.llama-3.1-405b-instruct"},
		{Ocid: "l3", Name: "meta.llama-3.3-70b-instruct"},
		{Ocid: "c1", Name: "cohere.command-a-03-2025"},
		{Ocid: "g1", Name: "xai.grok-4"},
	}
	got := probeCandidates("", models)
	if len(got) != 5 || got[0].Name != "meta.llama-3-70b-instruct" {
		t.Fatalf("上限内全量返回且最高分居首: %+v", got)
	}
	vendors := map[string]bool{}
	for _, m := range got[:3] {
		vendors[modelVendor(m)] = true
	}
	if len(vendors) != 3 {
		t.Errorf("前 3 个候选应覆盖 3 个厂商: %+v", got)
	}
	// 超过上限时截断到 probeCandidateCap
	var many []oci.GenAiModel
	for i := 0; i < 12; i++ {
		many = append(many, oci.GenAiModel{Ocid: fmt.Sprintf("m%d", i), Name: fmt.Sprintf("meta.llama-%d", i)})
	}
	if capped := probeCandidates("", many); len(capped) != probeCandidateCap {
		t.Errorf("候选应截断到 %d: got %d", probeCandidateCap, len(capped))
	}
}

// TestProbeCandidatesPinsProbeModel 断言探测验证模型置首位且不占常规候选逻辑。
func TestProbeCandidatesPinsProbeModel(t *testing.T) {
	models := []oci.GenAiModel{
		{Ocid: "o2", Name: "cohere.command-r-plus"},
		{Ocid: "o3", Name: "meta.llama-3.3-70b-instruct"},
		{Ocid: "o4", Name: "xai.grok-4"},
	}
	tests := []struct {
		name       string
		probeModel string
		wantFirst  string
		wantLen    int
	}{
		{"验证模型置首位", "xai.grok-4", "xai.grok-4", 3},
		{"未设置走常规排序", "", "meta.llama-3.3-70b-instruct", 3},
		{"验证模型已不在缓存则忽略", "gone.model", "meta.llama-3.3-70b-instruct", 3},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := probeCandidates(tt.probeModel, models)
			if len(got) != tt.wantLen || got[0].Name != tt.wantFirst {
				t.Errorf("probeCandidates(%q) = %+v, want first %q", tt.probeModel, got, tt.wantFirst)
			}
		})
	}
}

// entityNotFoundErr 模拟「实体不存在」404(模型在区域内无按需供给)。
func entityNotFoundErr() stubServiceError {
	return stubServiceError{status: 404,
		msg: "Entity with key ocid1.generativeaimodel.oc1.eu-frankfurt-1.2flsfq not found"}
}

func TestProbeSkipsUnavailableModels(t *testing.T) {
	// 首选 llama 不可按需调用(基座 400 / 实体 404)→ 换候选成功 → 渠道判可用;
	// 模型全量入库不再自动标记,持久剔除交由用户手动拉黑
	tests := []struct {
		name string
		bad  probeResult
	}{
		{"微调基座 400", probeResult{400, finetuneBaseErr()}},
		{"实体不存在 404", probeResult{404, entityNotFoundErr()}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			client := &gatewayStubClient{
				fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
				models: []oci.GenAiModel{
					{Ocid: "m1", Name: "meta.llama-3-70b-instruct", Vendor: "meta"},
					{Ocid: "m2", Name: "meta.llama-3.1-70b-instruct", Vendor: "meta"},
					{Ocid: "m3", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
				},
				probeSeq: []probeResult{tt.bad, {200, nil}},
			}
			gw, svc := newTestGateway(t, client)
			cfg := importAliveConfig(t, svc)
			ctx := context.Background()

			ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
			if err != nil {
				t.Fatalf("CreateChannel: %v", err)
			}
			probed, err := gw.ProbeChannel(ctx, ch.ID)
			if err != nil || probed.ProbeStatus != "ok" {
				t.Fatalf("坏候选后应换候选并判可用: %+v, %v", probed, err)
			}
			rows, _ := gw.channelModels(ctx, ch.ID)
			if len(rows) != 3 {
				t.Errorf("模型应全量入库, got %d", len(rows))
			}
		})
	}
}

func TestProbeAuth404StillNoQuota(t *testing.T) {
	// 鉴权类 404(NotAuthorizedOrNotFound)仍属租户级,直接定论 no_quota
	client := &gatewayStubClient{
		fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		models:     []oci.GenAiModel{{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
		probeCode:  404,
		probeErr:   stubServiceError{status: 404, msg: "Authorization failed or requested resource not found."},
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	ctx := context.Background()

	ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
	probed, _ := gw.ProbeChannel(ctx, ch.ID)
	if probed.ProbeStatus != "no_quota" {
		t.Errorf("鉴权 404 status = %q, want no_quota", probed.ProbeStatus)
	}
}

// TestChannelModelsExcludeBlacklist 断言模型列表与数量统计对黑名单做查询层兜底过滤。
func TestChannelModelsExcludeBlacklist(t *testing.T) {
	gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	cfg := importAliveConfig(t, svc)
	ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	ctx := context.Background()

	// 直插黑名单行但保留缓存,模拟脏数据 / 并发窗口
	gw.db.Create(&model.AiModelBlacklist{Name: "meta.llama-3.3-70b-instruct"})
	rows, err := gw.ChannelModels(ctx, ch.ID)
	if err != nil || len(rows) != 0 {
		t.Errorf("黑名单模型应被过滤: %+v, %v", rows, err)
	}
	chs, err := gw.Channels(ctx)
	if err != nil || len(chs) != 1 || chs[0].ModelCount != 0 {
		t.Errorf("模型数量统计应剔除黑名单: %+v, %v", chs, err)
	}
}

// TestChannelModelsFilterDeprecated 断言「过滤弃用模型」开关同样作用于
// 渠道模型列表与数量统计(数据保留,展示口径过滤)。
func TestChannelModelsFilterDeprecated(t *testing.T) {
	gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	cfg := importAliveConfig(t, svc)
	ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	dep := time.Now().Add(-24 * time.Hour)
	gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "xai.grok-3",
		Vendor: "xai", SyncedAt: time.Now(), DeprecatedAt: &dep})
	ctx := context.Background()

	rows, _ := gw.ChannelModels(ctx, ch.ID)
	if len(rows) != 2 {
		t.Fatalf("开关关:弃用模型应在列,got %d", len(rows))
	}
	if err := gw.SetFilterDeprecated(ctx, true); err != nil {
		t.Fatalf("SetFilterDeprecated: %v", err)
	}
	rows, _ = gw.ChannelModels(ctx, ch.ID)
	if len(rows) != 1 || rows[0].Name != "meta.llama-3.3-70b-instruct" {
		t.Errorf("开关开:弃用模型应被过滤,got %+v", rows)
	}
	chs, _ := gw.Channels(ctx)
	if len(chs) != 1 || chs[0].ModelCount != 1 {
		t.Errorf("开关开:数量统计应同口径,got %+v", chs)
	}
}

// TestProbe403ContinuesToNextCandidate 断言模型级 403 不再武断定论渠道无配额:
// 后续候选成功 → ok;全部候选鉴权拒绝 → 仍 no_quota。
func TestProbe403ContinuesToNextCandidate(t *testing.T) {
	deny := probeResult{403, stubServiceError{status: 403, msg: "NotAuthorizedOrNotFound"}}
	tests := []struct {
		name       string
		seq        []probeResult
		wantStatus string
	}{
		{"403 后换候选成功", []probeResult{deny, {200, nil}}, "ok"},
		{"403 后换候选限流也算可用", []probeResult{deny, {429, stubServiceError{status: 429}}}, "ok"},
		{"全部候选 403", []probeResult{deny, deny}, "no_quota"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			client := &gatewayStubClient{
				fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
				models: []oci.GenAiModel{
					{Ocid: "m1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"},
					{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
				},
				probeSeq: tt.seq,
			}
			gw, svc := newTestGateway(t, client)
			cfg := importAliveConfig(t, svc)
			ctx := context.Background()

			ch, _ := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
			probed, err := gw.ProbeChannel(ctx, ch.ID)
			if err != nil || probed.ProbeStatus != tt.wantStatus {
				t.Fatalf("ProbeChannel = %+v, %v, want %q", probed, err, tt.wantStatus)
			}
		})
	}
}

// TestChannelModelTest 断言单模型试调:通过时写探测验证模型并翻转状态,失败不动。
func TestChannelModelTest(t *testing.T) {
	client := &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	gw.db.Model(ch).Updates(map[string]any{"probe_status": "no_quota", "probe_error": "旧错误", "fail_count": 6})
	ctx := context.Background()

	if _, err := gw.TestChannelModel(ctx, ch.ID, "not.exists"); err == nil {
		t.Error("缓存外模型应报错")
	}
	client.probeCode, client.probeErr = 403, stubServiceError{status: 403}
	if _, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct"); err == nil {
		t.Error("403 试调应报测试未通过")
	}
	var still model.AiChannel
	gw.db.First(&still, ch.ID)
	if still.ProbeStatus != "no_quota" || still.ProbeModel != "" {
		t.Errorf("失败不应改动渠道: %+v", still)
	}
	client.probeCode, client.probeErr = 200, nil
	fresh, err := gw.TestChannelModel(ctx, ch.ID, "meta.llama-3.3-70b-instruct")
	if err != nil {
		t.Fatalf("TestChannelModel: %v", err)
	}
	if fresh.ProbeModel != "meta.llama-3.3-70b-instruct" || fresh.ProbeStatus != "ok" ||
		fresh.ProbeError != "" || fresh.FailCount != 0 {
		t.Errorf("通过后应写验证模型并置可用: %+v", fresh)
	}
	// 已 ok 渠道再测另一模型:仅更新验证模型,不重写探测时间
	cache2 := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..m2", Name: "xai.grok-4", Vendor: "xai", SyncedAt: time.Now()}
	gw.db.Create(cache2)
	fresh2, err := gw.TestChannelModel(ctx, ch.ID, "xai.grok-4")
	if err != nil || fresh2.ProbeModel != "xai.grok-4" {
		t.Fatalf("已可用渠道更新验证模型: %+v, %v", fresh2, err)
	}
}

func TestAiChatFinetuneSwitchesChannelWithoutPenalty(t *testing.T) {
	// 微调基座 400 换渠道重试成功,且不计入熔断失败
	client := &gatewayStubClient{
		fakeClient:  &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":1}}`),
		passErrs:    []error{finetuneBaseErr()},
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)

	resp, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
	if err != nil || resp == nil {
		t.Fatalf("RespPassthrough = %v, %v", resp, err)
	}
	if meta.Retries != 1 || client.passCalls != 2 {
		t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
	}
	var chs []model.AiChannel
	gw.db.Find(&chs)
	for _, ch := range chs {
		if ch.FailCount != 0 {
			t.Errorf("微调基座 400 不应计入熔断: 渠道 %s failCount=%d", ch.Name, ch.FailCount)
		}
	}
}

func TestBlacklistLifecycle(t *testing.T) {
	// 拉黑:全渠道同名缓存删除、列表与路由立即不可见;重复/空名被拒;
	// 移出黑名单后重新同步恢复入池
	client := &gatewayStubClient{
		fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		models:     []oci.GenAiModel{{Ocid: "ocid1..m-eu-frankfurt-1", Name: "meta.llama-3.3-70b-instruct", Vendor: "meta"}},
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)
	ctx := context.Background()

	item, err := gw.AddBlacklist(ctx, " meta.llama-3.3-70b-instruct ")
	if err != nil || item.Name != "meta.llama-3.3-70b-instruct" {
		t.Fatalf("AddBlacklist = %+v, %v", item, err)
	}
	var left int64
	gw.db.Model(&model.AiModelCache{}).Count(&left)
	if left != 0 {
		t.Errorf("拉黑应删除全部渠道的同名缓存, 剩 %d", left)
	}
	if _, _, err := gw.RespPassthrough(ctx, []byte(`{}`), "meta.llama-3.3-70b-instruct", ""); !errors.Is(err, ErrAiUnknownModel) {
		t.Errorf("拉黑后路由应按未知模型拒绝: %v", err)
	}
	if _, err := gw.AddBlacklist(ctx, "meta.llama-3.3-70b-instruct"); err == nil {
		t.Error("重复拉黑应被拒绝")
	}
	if _, err := gw.AddBlacklist(ctx, "  "); err == nil {
		t.Error("空模型名应被拒绝")
	}
	rows, err := gw.Blacklist(ctx)
	if err != nil || len(rows) != 1 {
		t.Fatalf("Blacklist = %+v, %v", rows, err)
	}
	if err := gw.RemoveBlacklist(ctx, rows[0].ID); err != nil {
		t.Fatalf("RemoveBlacklist: %v", err)
	}
	if err := gw.RemoveBlacklist(ctx, rows[0].ID); err == nil {
		t.Error("移除不存在的条目应报错")
	}
	// 移出后重新同步:模型恢复入池
	if _, err := gw.SyncModels(ctx, ch.ID); err != nil {
		t.Fatalf("SyncModels: %v", err)
	}
	list, _ := gw.GatewayModels(ctx, "")
	if len(list.Data) != 1 {
		t.Errorf("移出黑名单并同步后应恢复入池: %+v", list.Data)
	}
}

func TestSyncAndProbeFilterBlacklisted(t *testing.T) {
	// 同步与探测都过滤黑名单模型:不入库、不进试调候选
	client := &gatewayStubClient{
		fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		models: []oci.GenAiModel{
			{Ocid: "m1", Name: "meta.llama-3-70b-instruct", Vendor: "meta"},
			{Ocid: "m2", Name: "cohere.command-a-03-2025", Vendor: "cohere"},
		},
		probeCode: 200,
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	ctx := context.Background()
	if _, err := gw.AddBlacklist(ctx, "meta.llama-3-70b-instruct"); err != nil {
		t.Fatalf("AddBlacklist: %v", err)
	}
	ch, err := gw.CreateChannel(ctx, ChannelInput{OciConfigID: cfg.ID, Region: "eu-frankfurt-1"})
	if err != nil {
		t.Fatalf("CreateChannel: %v", err)
	}
	probed, err := gw.ProbeChannel(ctx, ch.ID)
	if err != nil || probed.ProbeStatus != "ok" {
		t.Fatalf("ProbeChannel = %+v, %v", probed, err)
	}
	rows, _ := gw.channelModels(ctx, ch.ID)
	if len(rows) != 1 || rows[0].Name != "cohere.command-a-03-2025" {
		t.Errorf("探测同步应过滤黑名单模型: %+v", rows)
	}
	if len(client.probedNames) != 1 || client.probedNames[0] != "cohere.command-a-03-2025" {
		t.Errorf("试调候选不应包含黑名单模型: %v", client.probedNames)
	}
	models, err := gw.SyncModels(ctx, ch.ID)
	if err != nil || len(models) != 1 || models[0].Name != "cohere.command-a-03-2025" {
		t.Errorf("SyncModels 应过滤黑名单模型: %+v, %v", models, err)
	}
}

func TestDeprecatingModels(t *testing.T) {
	gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	ctx := context.Background()
	now := time.Now()
	soon := now.Add(10 * 24 * time.Hour)
	far := now.Add(90 * 24 * time.Hour)
	past := now.Add(-30 * 24 * time.Hour)
	retireSoon := now.Add(15 * 24 * time.Hour)
	rows := []model.AiModelCache{
		{ChannelID: 1, Name: "meta.llama-old", SyncedAt: now, DeprecatedAt: &soon},
		{ChannelID: 2, Name: "meta.llama-old", SyncedAt: now, DeprecatedAt: &soon},                             // 跨渠道去重
		{ChannelID: 1, Name: "xai.grok-4.3", SyncedAt: now, DeprecatedAt: &far},                                // 弃用窗口外
		{ChannelID: 1, Name: "cohere.command-latest", SyncedAt: now},                                           // 未宣布
		{ChannelID: 1, Name: "xai.grok-3", SyncedAt: now, DeprecatedAt: &past},                                 // 已过弃用日仍可用:不再告警
		{ChannelID: 1, Name: "meta.llama-3.2-11b", SyncedAt: now, DeprecatedAt: &past, RetiredAt: &retireSoon}, // 即将退役:重点告警
		{ChannelID: 1, Name: "cohere.embed-img", SyncedAt: now, DeprecatedAt: &past, RetiredAt: &far},          // 退役窗口外
	}
	if err := gw.db.Create(&rows).Error; err != nil {
		t.Fatalf("seed: %v", err)
	}
	names, err := gw.DeprecatingModels(ctx, 30*24*time.Hour)
	if err != nil || len(names) != 2 {
		t.Fatalf("DeprecatingModels = %v, %v, want 2 条", names, err)
	}
	joined := strings.Join(names, "\n")
	if !strings.Contains(joined, "meta.llama-3.2-11b(") || !strings.Contains(joined, "退役,届时无法调用") {
		t.Errorf("缺少退役告警: %v", names)
	}
	if !strings.Contains(joined, "meta.llama-old(") || !strings.Contains(joined, "退役前仍可调用") {
		t.Errorf("缺少弃用预告: %v", names)
	}
	if strings.Contains(joined, "xai.grok-3(") {
		t.Errorf("已过弃用日且未近退役的模型不应告警: %v", names)
	}
}

func TestAiEmbeddings(t *testing.T) {
	client := &gatewayStubClient{
		fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		embedVecs:  [][]float32{{0.1, 0.2}, {0.3, 0.4}},
		embedUsage: &aiwire.Usage{PromptTokens: 6, TotalTokens: 6},
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	gw.db.Create(&model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..emb", Name: "cohere.embed-v4.0", Vendor: "cohere", Capability: "EMBEDDING", SyncedAt: time.Now()})
	ctx := context.Background()

	resp, meta, err := gw.Embeddings(ctx, aiwire.EmbeddingsRequest{Model: "cohere.embed-v4.0", Input: aiwire.StringList{"a", "b"}}, "")
	if err != nil || len(resp.Data) != 2 || resp.Data[1].Index != 1 || resp.Usage.TotalTokens != 6 {
		t.Fatalf("Embeddings = %+v, meta=%+v, %v", resp, meta, err)
	}
	if resp.Object != "list" || resp.Data[0].Object != "embedding" {
		t.Errorf("响应形态 = %+v", resp)
	}
	// 对话模型名打 embeddings:能力不匹配 → 未知模型
	if _, _, err := gw.Embeddings(ctx, aiwire.EmbeddingsRequest{Model: "meta.llama-3.3-70b-instruct", Input: aiwire.StringList{"a"}}, ""); !errors.Is(err, ErrAiUnknownModel) {
		t.Errorf("chat 模型走 embeddings err = %v, want ErrAiUnknownModel", err)
	}
	// embedding 模型名打 chat:同样未知模型
	if _, _, err := gw.RespPassthrough(ctx, []byte(`{}`), "cohere.embed-v4.0", ""); !errors.Is(err, ErrAiUnknownModel) {
		t.Errorf("embedding 模型走 chat err = %v, want ErrAiUnknownModel", err)
	}
}

func TestAiContentLogSwitch(t *testing.T) {
	gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	if err := gw.db.AutoMigrate(&model.AiContentLog{}); err != nil {
		t.Fatalf("migrate content log: %v", err)
	}
	ctx := context.Background()
	_, key, err := gw.CreateKey(ctx, "k1", "content-key-1234", "", nil)
	if err != nil {
		t.Fatalf("CreateKey: %v", err)
	}
	if key.ContentLogUntil != nil {
		t.Error("新密钥内容日志应默认永关")
	}
	// 开启 24 小时
	fresh, err := gw.UpdateKeyContentLog(ctx, key.ID, 24)
	if err != nil || fresh.ContentLogUntil == nil || time.Until(*fresh.ContentLogUntil) < 23*time.Hour {
		t.Fatalf("开启失败: %+v, %v", fresh.ContentLogUntil, err)
	}
	// 超上限拒绝
	if _, err := gw.UpdateKeyContentLog(ctx, key.ID, 169); err == nil {
		t.Error("超过 7 天上限应被拒绝")
	}
	// 写入与截断(带调用日志关联)
	mustCreate(t, gw.db, &model.AiCallLog{ID: 42, KeyID: key.ID, ChannelID: 0})
	gw.LogContent(model.AiContentLog{CallLogID: 42, KeyID: key.ID, KeyName: "k1", Endpoint: "openai", Model: "m", RequestBody: strings.Repeat("x", 70*1024)})
	rows, total, err := gw.ContentLogs(ctx, key.ID, 0, 1, 20)
	if err != nil || total != 1 || len(rows[0].RequestBody) != 64*1024 {
		t.Fatalf("ContentLogs = total %d len %d, %v", total, len(rows[0].RequestBody), err)
	}
	// 按调用日志 ID 反查:命中与不命中
	if rows, total, err = gw.ContentLogs(ctx, 0, 42, 1, 20); err != nil || total != 1 || rows[0].CallLogID != 42 {
		t.Fatalf("按 callLogId 反查 = total %d, %v", total, err)
	}
	if _, total, err = gw.ContentLogs(ctx, 0, 999, 1, 20); err != nil || total != 0 {
		t.Fatalf("callLogId 不命中应为空 = total %d, %v", total, err)
	}
	// 关闭
	fresh, err = gw.UpdateKeyContentLog(ctx, key.ID, 0)
	if err != nil || fresh.ContentLogUntil != nil {
		t.Fatalf("关闭失败: %+v, %v", fresh.ContentLogUntil, err)
	}
}

func TestRespPassthroughSwitchesChannel(t *testing.T) {
	client := &gatewayStubClient{
		fakeClient:  &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		passPayload: []byte(`{"id":"resp_1","usage":{"input_tokens":9,"output_tokens":3}}`),
		passErrs:    []error{stubServiceError{status: 429}},
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)

	payload, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
	if err != nil || len(payload) == 0 {
		t.Fatalf("RespPassthrough = %v, %v", payload, err)
	}
	if meta.Retries != 1 || client.passCalls != 2 {
		t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
	}
	if len(client.passRegions) == 2 && client.passRegions[0] == client.passRegions[1] {
		t.Errorf("重试未换渠道: %v", client.passRegions)
	}
}

func TestRespPassthroughNonRetryable(t *testing.T) {
	client := &gatewayStubClient{
		fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		passErrs:   []error{stubServiceError{status: 400}},
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)

	_, meta, err := gw.RespPassthrough(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
	if err == nil || meta.Retries != 0 || client.passCalls != 1 {
		t.Errorf("400 应直接透传不重试: err=%v retries=%d calls=%d", err, meta.Retries, client.passCalls)
	}
}

func TestNormalizeKeyModels(t *testing.T) {
	tests := []struct {
		name string
		in   []string
		want []string
	}{
		{"nil 输入", nil, nil},
		{"全空串", []string{"", "  "}, nil},
		{"trim 与保序去重", []string{" a ", "b", "a", "", "b "}, []string{"a", "b"}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := normalizeKeyModels(tt.in)
			if len(got) != len(tt.want) {
				t.Fatalf("normalizeKeyModels(%v) = %v, want %v", tt.in, got, tt.want)
			}
			for i := range tt.want {
				if got[i] != tt.want[i] {
					t.Fatalf("normalizeKeyModels(%v) = %v, want %v", tt.in, got, tt.want)
				}
			}
		})
	}
}

// TestAiKeyModelsPersistence 钉住 serializer:json 字段经 Create 与 Updates(map) 两条路径的往返。
func TestAiKeyModelsPersistence(t *testing.T) {
	gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	ctx := context.Background()

	_, key, err := gw.CreateKey(ctx, "m-key", "model-key-1234", "", []string{" x-model ", "x-model", "y-model", ""})
	if err != nil {
		t.Fatalf("CreateKey: %v", err)
	}
	fresh, err := gw.VerifyKey(ctx, "model-key-1234")
	if err != nil || len(fresh.Models) != 2 || fresh.Models[0] != "x-model" || fresh.Models[1] != "y-model" {
		t.Fatalf("创建后读回 models = %v, %v", fresh.Models, err)
	}
	set := []string{"z-model"}
	if err := gw.UpdateKey(ctx, key.ID, "", nil, nil, &set); err != nil {
		t.Fatalf("UpdateKey 覆盖: %v", err)
	}
	fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
	if len(fresh.Models) != 1 || fresh.Models[0] != "z-model" {
		t.Fatalf("覆盖后 models = %v", fresh.Models)
	}
	if err := gw.UpdateKey(ctx, key.ID, "renamed", nil, nil, nil); err != nil {
		t.Fatalf("UpdateKey 不传 models: %v", err)
	}
	fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
	if len(fresh.Models) != 1 {
		t.Fatalf("未传 models 却被改动: %v", fresh.Models)
	}
	empty := []string{}
	if err := gw.UpdateKey(ctx, key.ID, "", nil, nil, &empty); err != nil {
		t.Fatalf("UpdateKey 清空: %v", err)
	}
	fresh, _ = gw.VerifyKey(ctx, "model-key-1234")
	if len(fresh.Models) != 0 {
		t.Fatalf("清空后 models = %v, want 空", fresh.Models)
	}
}

// TestRespPassthroughStreamSwitchesChannel 断言流式直通建立失败按 switchable 换渠道。
func TestRespPassthroughStreamSwitchesChannel(t *testing.T) {
	client := &gatewayStubClient{
		fakeClient:  &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}},
		passPayload: []byte("data: {\"type\":\"response.completed\"}\n\n"),
		passErrs:    []error{stubServiceError{status: 503}},
	}
	gw, svc := newTestGateway(t, client)
	cfg := importAliveConfig(t, svc)
	seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	seedChannel(t, gw, cfg.ID, "us-chicago-1", 1, 1)

	stream, meta, err := gw.RespPassthroughStream(context.Background(), []byte(`{}`), "meta.llama-3.3-70b-instruct", "")
	if err != nil || stream == nil {
		t.Fatalf("RespPassthroughStream = %v", err)
	}
	defer stream.Close()
	if meta.Retries != 1 || client.passCalls != 2 {
		t.Errorf("应换渠道重试一次: retries=%d calls=%d", meta.Retries, client.passCalls)
	}
	payload, _ := io.ReadAll(stream)
	if !bytes.Contains(payload, []byte("response.completed")) {
		t.Errorf("流内容未透传: %s", payload)
	}
}

// TestAiRuntimeSettings 断言流式保险丝与 grok 注入开关的缺省值、往返与持久化。
func TestAiRuntimeSettings(t *testing.T) {
	gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	ctx := context.Background()

	if on, kb := gw.StreamGuard(); !on || kb != 60 {
		t.Fatalf("保险丝缺省应为 开/60, got %v/%d", on, kb)
	}
	if web, x := gw.GrokSearch(); !web || !x {
		t.Fatalf("grok 注入缺省应双开, got %v/%v", web, x)
	}
	for _, bad := range []int{0, -1, 1025} {
		if err := gw.SetStreamGuard(ctx, true, bad); err == nil {
			t.Errorf("阈值 %d 应报错", bad)
		}
	}
	if err := gw.SetStreamGuard(ctx, false, 80); err != nil {
		t.Fatalf("SetStreamGuard: %v", err)
	}
	if err := gw.SetGrokSearch(ctx, false, true); err != nil {
		t.Fatalf("SetGrokSearch: %v", err)
	}
	gw2 := NewAiGatewayService(gw.db, svc, &gatewayStubClient{})
	if on, kb := gw2.StreamGuard(); on || kb != 80 {
		t.Errorf("重建后保险丝应为 关/80, got %v/%d", on, kb)
	}
	if web, x := gw2.GrokSearch(); web || !x {
		t.Errorf("重建后 grok 注入应为 关/开, got %v/%v", web, x)
	}
}

// TestAggregatedModelsFilterDeprecated 断言聚合目录与模型列表口径一致:
// 「过滤弃用」开启时弃用模型不出现,关闭时出现;空能力归一为 CHAT。
func TestAggregatedModelsFilterDeprecated(t *testing.T) {
	gw, svc := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}})
	cfg := importAliveConfig(t, svc)
	ch := seedChannel(t, gw, cfg.ID, "eu-frankfurt-1", 1, 1)
	dep := time.Now().Add(-24 * time.Hour)
	old := &model.AiModelCache{ChannelID: ch.ID, ModelOcid: "ocid1..dep", Name: "meta.llama-old",
		Vendor: "meta", SyncedAt: time.Now(), DeprecatedAt: &dep}
	if err := gw.db.Create(old).Error; err != nil {
		t.Fatalf("seed deprecated cache: %v", err)
	}
	ctx := context.Background()

	items, err := gw.AggregatedModels(ctx)
	if err != nil {
		t.Fatalf("AggregatedModels: %v", err)
	}
	if len(items) != 2 || items[0].Capability == "" {
		t.Fatalf("开关关:应含弃用模型且能力归一, got %+v", items)
	}
	if err := gw.SetFilterDeprecated(ctx, true); err != nil {
		t.Fatalf("SetFilterDeprecated: %v", err)
	}
	items, err = gw.AggregatedModels(ctx)
	if err != nil {
		t.Fatalf("AggregatedModels(过滤): %v", err)
	}
	if len(items) != 1 || items[0].Name == "meta.llama-old" {
		t.Fatalf("开关开:弃用模型应被过滤, got %+v", items)
	}
}

func TestUpstreamWaitSetting(t *testing.T) {
	gw, _ := newTestGateway(t, &gatewayStubClient{fakeClient: &fakeClient{}})
	ctx := context.Background()

	if got := gw.UpstreamWait(); got != 300*time.Second {
		t.Fatalf("缺省上游无响应预算 = %v, 期望 300s", got)
	}
	tests := []struct {
		name    string
		sec     int
		wantErr bool
	}{
		{name: "下界 30 有效", sec: 30},
		{name: "上界 900 有效", sec: 900},
		{name: "低于下界拒绝", sec: 29, wantErr: true},
		{name: "高于上界拒绝", sec: 901, wantErr: true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := gw.SetUpstreamWait(ctx, tt.sec)
			if (err != nil) != tt.wantErr {
				t.Fatalf("SetUpstreamWait(%d) = %v, wantErr=%v", tt.sec, err, tt.wantErr)
			}
			if !tt.wantErr && gw.UpstreamWait() != time.Duration(tt.sec)*time.Second {
				t.Fatalf("UpstreamWait = %v, 期望 %ds", gw.UpstreamWait(), tt.sec)
			}
		})
	}
	// 持久化后新实例应加载已存值(最后一次成功设置为 900)
	gw2 := NewAiGatewayService(gw.db, nil, nil)
	if got := gw2.UpstreamWait(); got != 900*time.Second {
		t.Fatalf("重建服务加载预算 = %v, 期望 900s", got)
	}
}
