import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { NormalizedModelCatalogRow } from "../model-catalog/index.js";
import {
  applyModelAllowlist,
  applyModelFallbacksFromSelection,
  promptDefaultModel,
  promptModelAllowlist,
} from "./model-picker.js";
import { makePrompter } from "./setup/__tests__/test-utils.js";

const loadModelCatalog = vi.hoisted(() => vi.fn());
vi.mock("../agents/model-catalog.js", () => ({
  loadModelCatalog,
}));

const loadStaticManifestCatalogRowsForList = vi.hoisted(() =>
  vi.fn<() => readonly NormalizedModelCatalogRow[]>(() => []),
);
vi.mock("./models/list.manifest-catalog.js", () => ({
  loadStaticManifestCatalogRowsForList,
}));

const ensureAuthProfileStore = vi.hoisted(() =>
  vi.fn(() => ({
    version: 1,
    profiles: {},
  })),
);
const listProfilesForProvider = vi.hoisted(() => vi.fn(() => []));
const upsertAuthProfile = vi.hoisted(() => vi.fn());
vi.mock("../agents/auth-profiles.js", () => ({
  externalCliDiscoveryForProviderAuth: () => ({
    mode: "scoped",
    allowKeychainPrompt: false,
  }),
  ensureAuthProfileStore,
  listProfilesForProvider,
  upsertAuthProfile,
}));

const resolveEnvApiKey = vi.hoisted(() =>
  vi.fn<(_provider: string, _env?: NodeJS.ProcessEnv) => { apiKey: string; source: string } | null>(
    (_provider: string) => ({
      apiKey: "test-key",
      source: "test",
    }),
  ),
);
const hasUsableCustomProviderApiKey = vi.hoisted(() =>
  vi.fn<(_cfg?: OpenClawConfig, _provider?: string, _env?: NodeJS.ProcessEnv) => boolean>(
    () => false,
  ),
);
const hasRuntimeAvailableProviderAuth = vi.hoisted(() =>
  vi.fn(
    ({
      provider,
      cfg,
      env,
    }: {
      provider: string;
      cfg?: OpenClawConfig;
      env?: NodeJS.ProcessEnv;
    }) => {
      if (provider === "amazon-bedrock") {
        const auth = cfg?.models?.providers?.["amazon-bedrock"]?.auth;
        return auth === undefined || auth === "aws-sdk";
      }
      if (resolveEnvApiKey(provider, env)?.apiKey) {
        return true;
      }
      if (hasUsableCustomProviderApiKey(cfg, provider, env)) {
        return true;
      }
      const providerConfig = cfg?.models?.providers?.[provider];
      return Boolean(
        providerConfig?.baseUrl?.startsWith("http://127.0.0.1") &&
        providerConfig.api &&
        providerConfig.models?.length &&
        !providerConfig.apiKey,
      );
    },
  ),
);
const createRuntimeProviderAuthLookup = vi.hoisted(() =>
  vi.fn(() => ({
    envApiKey: {
      aliasMap: {},
      candidateMap: {},
      authEvidenceMap: {},
    },
    syntheticAuthProviderRefs: [],
  })),
);
vi.mock("../agents/model-auth.js", () => ({
  createRuntimeProviderAuthLookup,
  resolveEnvApiKey,
  hasUsableCustomProviderApiKey,
  hasRuntimeAvailableProviderAuth,
}));

const resolveOwningPluginIdsForProvider = vi.hoisted(() =>
  vi.fn(({ provider }: { provider: string }) => {
    if (provider === "byteplus" || provider === "byteplus-plan") {
      return ["byteplus"];
    }
    if (provider === "volcengine" || provider === "volcengine-plan") {
      return ["volcengine"];
    }
    return undefined;
  }),
);
vi.mock("../plugins/providers.js", () => ({
  resolveOwningPluginIdsForProvider,
}));

const providerModelPickerContributionRuntime = vi.hoisted(() => ({
  enabled: false,
  resolve: vi.fn(() => []),
}));
const resolveProviderModelPickerEntries = vi.hoisted(() => vi.fn(() => []));
const resolveProviderPluginChoice = vi.hoisted(() => vi.fn());
const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {}));
const resolvePluginProviders = vi.hoisted(() => vi.fn(() => []));
const runProviderPluginAuthMethod = vi.hoisted(() => vi.fn());
vi.mock("./model-picker.runtime.js", () => ({
  modelPickerRuntime: {
    get resolveProviderModelPickerContributions() {
      return providerModelPickerContributionRuntime.enabled
        ? providerModelPickerContributionRuntime.resolve
        : undefined;
    },
    resolveProviderModelPickerEntries,
    resolveProviderPluginChoice,
    runProviderModelSelectedHook,
    resolvePluginProviders,
    runProviderPluginAuthMethod,
  },
}));

const OPENROUTER_CATALOG = [
  {
    provider: "openrouter",
    id: "auto",
    name: "OpenRouter Auto",
  },
  {
    provider: "openrouter",
    id: "meta-llama/llama-3.3-70b:free",
    name: "Llama 3.3 70B",
  },
] as const;

function expectRouterModelFiltering(options: Array<{ value: string }>) {
  const routerValues = options
    .map((option) => option.value)
    .filter((value) => value.startsWith("openrouter/"));
  expect(routerValues).toEqual(["openrouter/meta-llama/llama-3.3-70b:free"]);
}

function createSelectAllMultiselect() {
  return vi.fn(async (params) => params.options.map((option: { value: string }) => option.value));
}

function configuredTextModel(id: string, name: string) {
  return {
    id,
    name,
    reasoning: false,
    input: ["text" as const],
    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
    contextWindow: 128_000,
    maxTokens: 8192,
  };
}

type MockCallSource = {
  mock: {
    calls: ReadonlyArray<ReadonlyArray<unknown>>;
  };
};

type PickerOption = Record<string, unknown> & {
  value: string;
};

function requireRecord(value: unknown, label: string): Record<string, unknown> {
  if (!value || typeof value !== "object") {
    throw new Error(`expected ${label}`);
  }
  return value as Record<string, unknown>;
}

function mockArg(source: MockCallSource, callIndex: number, argIndex: number, label: string) {
  const call = source.mock.calls[callIndex];
  if (!call) {
    throw new Error(`expected mock call: ${label}`);
  }
  return call[argIndex];
}

function pickerParams(source: MockCallSource, callIndex = 0) {
  return requireRecord(mockArg(source, callIndex, 0, `picker call ${callIndex}`), "picker params");
}

function pickerOptions(source: MockCallSource, callIndex = 0) {
  const options = pickerParams(source, callIndex).options;
  expect(options, "picker options").toBeInstanceOf(Array);
  return options as PickerOption[];
}

function optionValues(options: PickerOption[]) {
  return options.map((option) => option.value);
}

function requireOption(options: PickerOption[], value: string) {
  const option = options.find((candidate) => candidate.value === value);
  if (!option) {
    throw new Error(`expected picker option: ${value}`);
  }
  return option;
}

function providerCallProviders() {
  return resolveOwningPluginIdsForProvider.mock.calls.map(
    ([params]) => requireRecord(params, "provider ownership params").provider,
  );
}

beforeEach(() => {
  delete process.env.OPENCLAW_LOCALE;
  vi.clearAllMocks();
  loadStaticManifestCatalogRowsForList.mockReturnValue([]);
  listProfilesForProvider.mockReturnValue([]);
  resolveEnvApiKey.mockImplementation((_provider: string) => ({
    apiKey: "test-key",
    source: "test",
  }));
  hasUsableCustomProviderApiKey.mockReturnValue(false);
  providerModelPickerContributionRuntime.enabled = false;
  resolveOwningPluginIdsForProvider.mockImplementation(({ provider }: { provider: string }) => {
    if (provider === "byteplus" || provider === "byteplus-plan") {
      return ["byteplus"];
    }
    if (provider === "volcengine" || provider === "volcengine-plan") {
      return ["volcengine"];
    }
    return undefined;
  });
});

describe("promptDefaultModel", () => {
  it("adds runtime-route hints for canonical and legacy OpenAI Codex models", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
      {
        provider: "openai-codex",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
    ]);

    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });

    await promptDefaultModel({
      config: { agents: { defaults: {} } } as OpenClawConfig,
      prompter,
      allowKeep: false,
      includeManual: false,
      ignoreAllowlist: true,
    });

    const options = pickerOptions(select as MockCallSource);
    const canonical = requireOption(options, "openai/gpt-5.5");
    expect(canonical.hint).toContain("Codex runtime route");
    const legacy = requireOption(options, "openai-codex/gpt-5.5");
    expect(legacy.hint).toContain("legacy Codex OAuth route");
  });

  it("hides unauthenticated catalog entries from default model choices", async () => {
    resolveEnvApiKey.mockReturnValue(null);
    loadModelCatalog.mockResolvedValue([
      { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
      { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
    ]);

    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });

    await promptDefaultModel({
      config: { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } } },
      prompter,
      allowKeep: false,
      includeManual: false,
      ignoreAllowlist: true,
    });

    const values = optionValues(pickerOptions(select as MockCallSource));
    expect(values).toEqual(["anthropic/claude-sonnet-4-6"]);
  });

  it("keeps implicit Bedrock AWS SDK models visible without API-key auth", async () => {
    resolveEnvApiKey.mockReturnValue(null);
    loadModelCatalog.mockResolvedValue([
      { provider: "amazon-bedrock", id: "us.anthropic.claude-sonnet-4-5", name: "Claude Sonnet" },
      { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
    ]);

    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });

    await promptDefaultModel({
      config: { agents: { defaults: {} } } as OpenClawConfig,
      prompter,
      allowKeep: false,
      includeManual: false,
      ignoreAllowlist: true,
    });

    const values = optionValues(pickerOptions(select as MockCallSource));
    expect(values).toEqual(["amazon-bedrock/us.anthropic.claude-sonnet-4-5"]);
  });

  it("hides legacy runtime providers from default model choices", async () => {
    loadModelCatalog.mockResolvedValue([
      { provider: "codex", id: "gpt-5.5", name: "GPT-5.5" },
      { provider: "codex-cli", id: "gpt-5.5", name: "GPT-5.5" },
      { provider: "claude-cli", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
      { provider: "google-gemini-cli", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
      { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
      { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
      { provider: "google", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
      { provider: "openai-codex", id: "gpt-5.5", name: "GPT-5.5" },
    ]);

    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });

    await promptDefaultModel({
      config: { agents: { defaults: {} } } as OpenClawConfig,
      prompter,
      allowKeep: false,
      includeManual: false,
      ignoreAllowlist: true,
    });

    const values = optionValues(pickerOptions(select as MockCallSource));
    expect(values).toEqual([
      "openai/gpt-5.5",
      "anthropic/claude-sonnet-4-6",
      "google/gemini-3.1-pro-preview",
      "openai-codex/gpt-5.5",
    ]);
  });

  it("normalizes retired Google Gemini catalog rows before saving config", async () => {
    loadModelCatalog.mockResolvedValue([
      { provider: "google", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
    ]);

    const select = vi.fn(async (params) => params.options[0]?.value as never);
    const prompter = makePrompter({ select });

    const result = await promptDefaultModel({
      config: { agents: { defaults: {} } } as OpenClawConfig,
      prompter,
      allowKeep: false,
      includeManual: false,
      ignoreAllowlist: true,
    });

    expect(result.model).toBe("google/gemini-3.1-pro-preview");
    expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
      "google/gemini-3.1-pro-preview",
    ]);
    expect(
      requireRecord(
        mockArg(runProviderModelSelectedHook as MockCallSource, 0, 0, "provider selected hook"),
        "provider selected hook params",
      ).model,
    ).toBe("google/gemini-3.1-pro-preview");
  });

  it("uses configured provider models for default picker without loading the full catalog in replace mode", async () => {
    loadModelCatalog.mockResolvedValue([
      { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
      { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
    ]);

    const select = vi.fn(async (params) => params.options[0]?.value as never);
    const prompter = makePrompter({ select });
    const config = {
      models: {
        mode: "replace",
        providers: {
          minimax: {
            baseUrl: "https://api.minimax.test/v1",
            models: [configuredTextModel("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed")],
          },
        },
      },
      agents: { defaults: {} },
    } as OpenClawConfig;

    const result = await promptDefaultModel({
      config,
      prompter,
      allowKeep: false,
      includeManual: false,
      ignoreAllowlist: true,
    });

    expect(loadModelCatalog).not.toHaveBeenCalled();
    const minimaxOption = requireOption(
      pickerOptions(select as MockCallSource),
      "minimax/MiniMax-M2.7-highspeed",
    );
    expect(minimaxOption.hint).toContain("MiniMax M2.7 Highspeed");
    expect(result.model).toBe("minimax/MiniMax-M2.7-highspeed");
  });

  it("treats byteplus plan models as preferred-provider matches", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
      {
        provider: "byteplus-plan",
        id: "ark-code-latest",
        name: "Ark Coding Plan",
      },
    ]);

    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });
    const config = {
      agents: {
        defaults: {
          model: "openai/gpt-5.5",
        },
      },
    } as OpenClawConfig;

    const result = await promptDefaultModel({
      config,
      prompter,
      allowKeep: true,
      includeManual: false,
      ignoreAllowlist: true,
      preferredProvider: "byteplus",
    });

    const options = pickerOptions(select as MockCallSource);
    const values = optionValues(options);
    expect(values).toContain("byteplus-plan/ark-code-latest");
    expect(values[1]).toBe("byteplus-plan/ark-code-latest");
    expect(pickerParams(select as MockCallSource).initialValue).toBe(
      "byteplus-plan/ark-code-latest",
    );
    expect(result.model).toBe("byteplus-plan/ark-code-latest");
    expect(providerCallProviders()).toContain("byteplus");
    expect(providerCallProviders()).toContain("byteplus-plan");
  });

  it("shows literal double-prefix labels for providers that preserve literal prefixes", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "nvidia",
        id: "nvidia/nemotron-3-super-120b-a12b",
        name: "Nemotron",
      },
    ]);
    resolvePluginProviders.mockReturnValue([
      {
        id: "nvidia",
        preserveLiteralProviderPrefix: true,
      },
    ] as never);

    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });
    const config = {
      agents: {
        defaults: {
          model: "nvidia/nemotron-3-super-120b-a12b",
        },
      },
    } as OpenClawConfig;

    await promptDefaultModel({
      config,
      prompter,
      allowKeep: true,
      includeManual: false,
      ignoreAllowlist: true,
    });

    const options = pickerOptions(select as MockCallSource);
    expect(requireOption(options, "__keep__").label).toBe(
      "Keep current (nvidia/nvidia/nemotron-3-super-120b-a12b)",
    );
    expect(requireOption(options, "nvidia/nemotron-3-super-120b-a12b").label).toBe(
      "nvidia/nvidia/nemotron-3-super-120b-a12b",
    );
  });

  it("shows literal double-prefix keep label before browsing provider catalogs", async () => {
    resolvePluginProviders.mockReturnValue([
      {
        id: "nvidia",
        preserveLiteralProviderPrefix: true,
      },
    ] as never);

    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });
    const config = {
      agents: {
        defaults: {
          model: "nvidia/nemotron-3-super-120b-a12b",
        },
      },
    } as OpenClawConfig;

    const result = await promptDefaultModel({
      config,
      prompter,
      allowKeep: true,
      includeManual: true,
      ignoreAllowlist: true,
      preferredProvider: "nvidia",
      browseCatalogOnDemand: true,
    });

    expect(result).toStrictEqual({});
    expect(loadModelCatalog).not.toHaveBeenCalled();
    const params = pickerParams(select as MockCallSource);
    expect(params.searchable).toBe(false);
    expect(params.initialValue).toBe("__keep__");
    const options = pickerOptions(select as MockCallSource);
    expect(optionValues(options)).toEqual(["__keep__", "__manual__", "__browse__"]);
    expect(requireOption(options, "__keep__").label).toBe(
      "Keep current (nvidia/nvidia/nemotron-3-super-120b-a12b)",
    );
  });

  it("keeps current preferred-provider models cold until browsing is requested", async () => {
    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });
    const config = {
      agents: {
        defaults: {
          model: "openai-codex/gpt-5.5",
        },
      },
    } as OpenClawConfig;

    const result = await promptDefaultModel({
      config,
      prompter,
      allowKeep: true,
      includeManual: true,
      ignoreAllowlist: true,
      preferredProvider: "openai-codex",
      browseCatalogOnDemand: true,
    });

    expect(result).toStrictEqual({});
    expect(loadModelCatalog).not.toHaveBeenCalled();
    const params = pickerParams(select as MockCallSource);
    expect(params.searchable).toBe(false);
    expect(params.initialValue).toBe("__keep__");
    expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
      "__keep__",
      "__manual__",
      "__browse__",
    ]);
  });

  it("keeps the full catalog cold until browsing when no provider is preferred", async () => {
    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });
    const config = {
      agents: {
        defaults: {
          model: "fleet-router/qwen3.6:latest",
        },
      },
    } as OpenClawConfig;

    const result = await promptDefaultModel({
      config,
      prompter,
      allowKeep: true,
      includeManual: true,
      ignoreAllowlist: true,
      browseCatalogOnDemand: true,
      loadCatalog: true,
    });

    expect(result).toStrictEqual({});
    expect(loadModelCatalog).not.toHaveBeenCalled();
    const params = pickerParams(select as MockCallSource);
    expect(params.searchable).toBe(false);
    expect(params.initialValue).toBe("__keep__");
    expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
      "__keep__",
      "__manual__",
      "__browse__",
    ]);
  });

  it("loads the full model catalog when the user chooses to browse", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai-codex",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
      {
        provider: "openai-codex",
        id: "gpt-5.5-pro",
        name: "GPT-5.5 Pro",
      },
    ]);
    const select = vi
      .fn()
      .mockResolvedValueOnce("__browse__")
      .mockImplementationOnce(async (params) => {
        const option = params.options.find(
          (entry: { value: string }) => entry.value === "openai-codex/gpt-5.5-pro",
        );
        return option?.value ?? params.initialValue;
      });
    const prompter = makePrompter({ select });
    const config = {
      agents: {
        defaults: {
          model: "openai-codex/gpt-5.5",
        },
      },
    } as OpenClawConfig;

    const result = await promptDefaultModel({
      config,
      prompter,
      allowKeep: true,
      includeManual: true,
      ignoreAllowlist: true,
      preferredProvider: "openai-codex",
      browseCatalogOnDemand: true,
    });

    expect(result.model).toBe("openai-codex/gpt-5.5-pro");
    expect(loadModelCatalog).toHaveBeenCalledOnce();
    expect(select).toHaveBeenCalledTimes(2);
    expect(select.mock.calls[1]?.[0]?.searchable).toBe(true);
  });

  it("supports configuring vLLM during setup", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "anthropic",
        id: "claude-sonnet-4-6",
        name: "Claude Sonnet 4.5",
      },
    ]);
    resolveProviderModelPickerEntries.mockReturnValue([
      { value: "vllm", label: "vLLM (custom)", hint: "Enter vLLM URL + API key + model" },
    ] as never);
    resolvePluginProviders.mockReturnValue([{ id: "vllm" }] as never);
    resolveProviderPluginChoice.mockReturnValue({
      provider: { id: "vllm", label: "vLLM", auth: [] },
      method: { id: "custom", label: "vLLM", kind: "custom" },
    });
    runProviderPluginAuthMethod.mockResolvedValue({
      config: {
        models: {
          providers: {
            vllm: {
              baseUrl: "http://127.0.0.1:8000/v1",
              api: "openai-completions",
              apiKey: "VLLM_API_KEY",
              models: [
                {
                  id: "meta-llama/Meta-Llama-3-8B-Instruct",
                  name: "meta-llama/Meta-Llama-3-8B-Instruct",
                },
              ],
            },
          },
        },
      },
      defaultModel: "vllm/meta-llama/Meta-Llama-3-8B-Instruct",
    });

    const select = vi.fn(async (params) => {
      const vllm = params.options.find((opt: { value: string }) => opt.value === "vllm");
      return (vllm?.value ?? "") as never;
    });
    const prompter = makePrompter({ select });
    const config = { agents: { defaults: {} } } as OpenClawConfig;

    const result = await promptDefaultModel({
      config,
      prompter,
      allowKeep: false,
      includeManual: false,
      includeProviderPluginSetups: true,
      ignoreAllowlist: true,
      agentDir: "/tmp/openclaw-agent",
      runtime: {} as never,
    });

    expect(runProviderPluginAuthMethod).toHaveBeenCalledOnce();
    expect(resolvePluginProviders).toHaveBeenCalledWith({
      config,
      workspaceDir: undefined,
      env: undefined,
      mode: "setup",
    });
    expect(result.model).toBe("vllm/meta-llama/Meta-Llama-3-8B-Instruct");
    expect(result.config?.models?.providers?.vllm).toEqual({
      baseUrl: "http://127.0.0.1:8000/v1",
      api: "openai-completions",
      apiKey: "VLLM_API_KEY", // pragma: allowlist secret
      models: [
        { id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "meta-llama/Meta-Llama-3-8B-Instruct" },
      ],
    });
  });

  it("prefers provider model-picker contributions when the runtime exposes them", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
    ]);
    providerModelPickerContributionRuntime.enabled = true;
    providerModelPickerContributionRuntime.resolve.mockReturnValue([
      {
        id: "provider:model-picker:ollama",
        kind: "provider",
        surface: "model-picker",
        option: {
          value: "ollama",
          label: "Ollama",
          hint: "Local/self-hosted setup",
        },
      },
    ] as never);
    resolveProviderModelPickerEntries.mockReturnValue([
      {
        value: "legacy-entry",
        label: "Legacy entry",
        hint: "Should not be used when contributions exist",
      },
    ] as never);

    const select = vi.fn(async (params) => {
      const ollama = params.options.find((opt: { value: string }) => opt.value === "ollama");
      return (ollama?.value ?? "") as never;
    });
    const prompter = makePrompter({ select });

    await promptDefaultModel({
      config: { agents: { defaults: {} } } as OpenClawConfig,
      prompter,
      allowKeep: false,
      includeManual: false,
      includeProviderPluginSetups: true,
      ignoreAllowlist: true,
      agentDir: "/tmp/openclaw-agent",
      runtime: {} as never,
    });

    expect(providerModelPickerContributionRuntime.resolve).toHaveBeenCalledOnce();
    const options = pickerOptions(select as MockCallSource);
    expect(requireOption(options, "ollama").label).toBe("Ollama");
    expect(optionValues(options)).not.toContain("legacy-entry");
  });

  it("keeps skip-auth model selection cold when catalog loading is disabled", async () => {
    const select = vi.fn(async (params) => params.initialValue as never);
    const prompter = makePrompter({ select });
    const config = {
      agents: {
        defaults: {
          model: "openai/gpt-5.5",
        },
      },
    } as OpenClawConfig;

    const result = await promptDefaultModel({
      config,
      prompter,
      allowKeep: true,
      includeManual: true,
      ignoreAllowlist: true,
      includeProviderPluginSetups: true,
      loadCatalog: false,
      agentDir: "/tmp/openclaw-agent",
      runtime: {} as never,
    });

    expect(result).toStrictEqual({});
    expect(loadModelCatalog).not.toHaveBeenCalled();
    expect(resolveProviderModelPickerEntries).not.toHaveBeenCalled();
    expect(providerModelPickerContributionRuntime.resolve).not.toHaveBeenCalled();
    expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
      "__keep__",
      "__manual__",
      "openai/gpt-5.5",
    ]);
  });

  it("surfaces NVIDIA provider model-picker contributions", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai",
        id: "gpt-5.4",
        name: "GPT-5.4",
      },
    ]);
    providerModelPickerContributionRuntime.enabled = true;
    providerModelPickerContributionRuntime.resolve.mockReturnValue([
      {
        id: "provider:model-picker:provider-plugin:nvidia:api-key",
        kind: "provider",
        surface: "model-picker",
        option: {
          value: "provider-plugin:nvidia:api-key",
          label: "NVIDIA (custom)",
          hint: "Use NVIDIA-hosted open models",
        },
      },
    ] as never);

    const select = vi.fn(async (params) => {
      const nvidia = params.options.find(
        (opt: { value: string }) => opt.value === "provider-plugin:nvidia:api-key",
      );
      return (nvidia?.value ?? "") as never;
    });
    const prompter = makePrompter({ select });

    await promptDefaultModel({
      config: { agents: { defaults: {} } } as OpenClawConfig,
      prompter,
      allowKeep: false,
      includeManual: false,
      includeProviderPluginSetups: true,
      ignoreAllowlist: true,
      agentDir: "/tmp/openclaw-agent",
      runtime: {} as never,
    });

    expect(
      requireOption(pickerOptions(select as MockCallSource), "provider-plugin:nvidia:api-key")
        .label,
    ).toBe("NVIDIA (custom)");
  });
});

describe("promptModelAllowlist", () => {
  it("filters to allowed keys when provided", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "anthropic",
        id: "claude-opus-4-6",
        name: "Claude Opus 4.5",
      },
      {
        provider: "anthropic",
        id: "claude-sonnet-4-6",
        name: "Claude Sonnet 4.5",
      },
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
    ]);

    const multiselect = createSelectAllMultiselect();
    const prompter = makePrompter({ multiselect });
    const config = { agents: { defaults: {} } } as OpenClawConfig;

    const result = await promptModelAllowlist({
      config,
      prompter,
      allowedKeys: ["anthropic/claude-opus-4-6"],
    });

    const options = pickerOptions(multiselect as MockCallSource);
    expect(optionValues(options)).toEqual(["anthropic/claude-opus-4-6"]);
    expect(result.scopeKeys).toEqual(["anthropic/claude-opus-4-6"]);
  });

  it("localizes the model allowlist picker", async () => {
    process.env.OPENCLAW_LOCALE = "zh-CN";
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
    ]);

    const multiselect = createSelectAllMultiselect();
    const prompter = makePrompter({ multiselect });
    const config = { agents: { defaults: {} } } as OpenClawConfig;

    await promptModelAllowlist({ config, prompter });

    expect(multiselect.mock.calls[0]?.[0]?.message).toBe("/model 选择器中的模型（多选）");
  });

  it("uses static manifest catalog rows for a preferred provider without loading runtime catalog", async () => {
    loadStaticManifestCatalogRowsForList.mockReturnValue([
      {
        provider: "github-copilot",
        id: "gpt-5.4",
        name: "GPT-5.4",
        ref: "github-copilot/gpt-5.4",
        mergeKey: "github-copilot:gpt-5.4",
        source: "manifest",
        input: ["text"],
        reasoning: true,
        status: "available",
      },
    ]);

    const multiselect = createSelectAllMultiselect();
    const prompter = makePrompter({ multiselect });
    const config = { agents: { defaults: {} } } as OpenClawConfig;

    await promptModelAllowlist({
      config,
      prompter,
      preferredProvider: "github-copilot",
    });

    expect(loadStaticManifestCatalogRowsForList).toHaveBeenCalledWith({
      cfg: config,
      providerFilter: "github-copilot",
    });
    expect(loadModelCatalog).not.toHaveBeenCalled();
    expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
      "github-copilot/gpt-5.4",
    ]);
  });

  it("uses configured provider models for allowlist picker without loading the full catalog in replace mode", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
    ]);

    const multiselect = createSelectAllMultiselect();
    const prompter = makePrompter({ multiselect });
    const config = {
      models: {
        mode: "replace",
        providers: {
          minimax: {
            baseUrl: "https://api.minimax.test/v1",
            models: [configuredTextModel("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed")],
          },
          zhipu: {
            baseUrl: "https://api.zhipu.test/v1",
            models: [configuredTextModel("glm-4.5-air", "GLM 4.5 Air")],
          },
        },
      },
      agents: { defaults: {} },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({ config, prompter });

    expect(loadModelCatalog).not.toHaveBeenCalled();
    expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
      "minimax/MiniMax-M2.7-highspeed",
      "zhipu/glm-4.5-air",
    ]);
    expect(result.models).toEqual(["minimax/MiniMax-M2.7-highspeed", "zhipu/glm-4.5-air"]);
  });

  it("scopes the initial allowlist picker to the preferred provider", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "anthropic",
        id: "claude-sonnet-4-6",
        name: "Claude Sonnet 4.5",
      },
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
      {
        provider: "openai",
        id: "gpt-5.4-mini",
        name: "GPT-5.4 Mini",
      },
    ]);

    const multiselect = createSelectAllMultiselect();
    const prompter = makePrompter({ multiselect });
    const config = { agents: { defaults: {} } } as OpenClawConfig;

    await promptModelAllowlist({
      config,
      prompter,
      preferredProvider: "openai",
    });

    const options = pickerOptions(multiselect as MockCallSource);
    expect(optionValues(options)).toEqual(["openai/gpt-5.5", "openai/gpt-5.4-mini"]);
  });

  it("shows configured preferred provider models when the catalog has no entries", async () => {
    loadModelCatalog.mockResolvedValue([]);

    const multiselect = createSelectAllMultiselect();
    const text = vi.fn(async () => "");
    const prompter = makePrompter({ multiselect, text });
    const config = {
      models: {
        providers: {
          ollama: {
            api: "ollama",
            baseUrl: "https://ollama.com/v1",
            models: [
              configuredTextModel("kimi-k2.5:cloud", "Kimi K2.5"),
              configuredTextModel("gpt-oss:20b-cloud", "GPT OSS 20B"),
            ],
          },
        },
      },
      agents: { defaults: {} },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({
      config,
      prompter,
      preferredProvider: "ollama",
      loadCatalog: true,
    });

    expect(text).not.toHaveBeenCalled();
    expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
      "ollama/kimi-k2.5:cloud",
      "ollama/gpt-oss:20b-cloud",
    ]);
    expect(result).toEqual({
      models: ["ollama/kimi-k2.5:cloud", "ollama/gpt-oss:20b-cloud"],
      scopeKeys: ["ollama/kimi-k2.5:cloud", "ollama/gpt-oss:20b-cloud"],
    });
  });

  it("keeps local no-key provider models visible in allowlist choices", async () => {
    resolveEnvApiKey.mockReturnValue(null);
    loadModelCatalog.mockResolvedValue([
      {
        provider: "vllm",
        id: "meta-llama/Meta-Llama-3-8B-Instruct",
        name: "Meta Llama",
      },
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
    ]);

    const multiselect = createSelectAllMultiselect();
    const prompter = makePrompter({ multiselect });
    const config = {
      models: {
        providers: {
          vllm: {
            api: "openai-completions",
            baseUrl: "http://127.0.0.1:8000/v1",
            models: [configuredTextModel("meta-llama/Meta-Llama-3-8B-Instruct", "Meta Llama")],
          },
        },
      },
      agents: { defaults: {} },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({ config, prompter });

    expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
      "vllm/meta-llama/Meta-Llama-3-8B-Instruct",
    ]);
    expect(result.models).toEqual(["vllm/meta-llama/Meta-Llama-3-8B-Instruct"]);
  });

  it("seeds existing model fallbacks into unscoped allowlist selections", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
    ]);

    const multiselect = vi.fn(async (params) => params.initialValues ?? []);
    const prompter = makePrompter({ multiselect });
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["anthropic/claude-sonnet-4-6"],
          },
          models: {
            "openai/gpt-5.5": { alias: "gpt" },
          },
        },
      },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({ config, prompter });
    const call = pickerParams(multiselect as MockCallSource);
    expect(optionValues(call.options as PickerOption[])).toEqual([
      "openai/gpt-5.5",
      "anthropic/claude-sonnet-4-6",
    ]);
    expect(call.initialValues).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
    expect(result.models).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
  });

  it("resolves bare fallback seeds against the primary model provider", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "anthropic",
        id: "claude-opus-4-6",
        name: "Claude Opus 4.5",
      },
      {
        provider: "anthropic",
        id: "claude-sonnet-4-6",
        name: "Claude Sonnet 4.5",
      },
      {
        provider: "openai",
        id: "claude-sonnet-4-6",
        name: "Wrong provider",
      },
    ]);

    const multiselect = vi.fn(async (params) => params.initialValues ?? []);
    const prompter = makePrompter({ multiselect });
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "anthropic/claude-opus-4-6",
            fallbacks: ["claude-sonnet-4-6"],
          },
        },
      },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({ config, prompter });
    const call = pickerParams(multiselect as MockCallSource);

    expect(call.initialValues).toEqual([
      "anthropic/claude-opus-4-6",
      "anthropic/claude-sonnet-4-6",
    ]);
    expect(result.models).toEqual(["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"]);
  });

  it("keeps the no-catalog allowlist prompt blank when no allowlist exists", async () => {
    loadModelCatalog.mockResolvedValue([]);

    const text = vi.fn(async (params) => params.initialValue ?? "");
    const prompter = makePrompter({ text });
    const config = {
      agents: {
        defaults: {
          model: "openai/gpt-5.5",
        },
      },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({ config, prompter });

    expect(pickerParams(text as MockCallSource).initialValue).toBe("");
    expect(result).toStrictEqual({});
  });

  it("shows existing fallbacks in the no-catalog allowlist prompt when an allowlist exists", async () => {
    loadModelCatalog.mockResolvedValue([]);

    const text = vi.fn(async (params) => params.initialValue ?? "");
    const prompter = makePrompter({ text });
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["anthropic/claude-sonnet-4-6"],
          },
          models: {
            "openai/gpt-5.5": { alias: "gpt" },
          },
        },
      },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({ config, prompter });

    expect(pickerParams(text as MockCallSource).initialValue).toBe(
      "openai/gpt-5.5, anthropic/claude-sonnet-4-6",
    );
    expect(result.models).toEqual(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"]);
  });

  it("keeps provider-scoped fallback supplements within scope", async () => {
    loadModelCatalog.mockResolvedValue([
      {
        provider: "openai",
        id: "gpt-5.5",
        name: "GPT-5.5",
      },
      {
        provider: "openai",
        id: "gpt-5.4",
        name: "GPT-5.4",
      },
      {
        provider: "anthropic",
        id: "claude-sonnet-4-6",
        name: "Claude Sonnet 4.5",
      },
    ]);

    const multiselect = vi.fn(async (params) => params.initialValues ?? []);
    const prompter = makePrompter({ multiselect });
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["anthropic/claude-sonnet-4-6"],
          },
        },
      },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({
      config,
      prompter,
      preferredProvider: "openai",
    });

    const call = pickerParams(multiselect as MockCallSource);
    expect(optionValues(call.options as PickerOption[])).toEqual([
      "openai/gpt-5.5",
      "openai/gpt-5.4",
    ]);
    expect(call.initialValues).toEqual(["openai/gpt-5.5"]);
    expect(result).toEqual({
      models: ["openai/gpt-5.5"],
      scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
    });
  });

  it("uses configured provider-scoped seeds without loading the full catalog", async () => {
    const multiselect = vi.fn(async (params) => params.initialValues ?? []);
    const prompter = makePrompter({ multiselect });
    const config = {
      agents: {
        defaults: {
          model: "openai-codex/gpt-5.5",
        },
      },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({
      config,
      prompter,
      preferredProvider: "openai-codex",
      loadCatalog: false,
    });

    expect(loadModelCatalog).not.toHaveBeenCalled();
    expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
      "openai-codex/gpt-5.5",
    ]);
    expect(pickerParams(multiselect as MockCallSource).initialValues).toEqual([
      "openai-codex/gpt-5.5",
    ]);
    expect(result).toEqual({
      models: ["openai-codex/gpt-5.5"],
      scopeKeys: ["openai-codex/gpt-5.5"],
    });
  });

  it("uses explicit allowed model keys without loading the full catalog", async () => {
    const multiselect = createSelectAllMultiselect();
    const prompter = makePrompter({ multiselect });
    const config = {
      agents: {
        defaults: {
          model: "openai-codex/gpt-5.5",
        },
      },
    } as OpenClawConfig;

    const result = await promptModelAllowlist({
      config,
      prompter,
      allowedKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"],
      preferredProvider: "openai-codex",
    });

    expect(loadModelCatalog).not.toHaveBeenCalled();
    expect(optionValues(pickerOptions(multiselect as MockCallSource))).toEqual([
      "openai-codex/gpt-5.5",
      "openai-codex/gpt-5.4",
    ]);
    expect(pickerParams(multiselect as MockCallSource).initialValues).toEqual([
      "openai-codex/gpt-5.5",
    ]);
    expect(result).toEqual({
      models: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"],
      scopeKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"],
    });
  });
});

describe("runtime model picker visibility", () => {
  it("hides legacy runtime refs from allowlist choices and configured supplements", async () => {
    loadModelCatalog.mockResolvedValue([
      { provider: "codex", id: "gpt-5.5", name: "GPT-5.5" },
      { provider: "claude-cli", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
      { provider: "google-gemini-cli", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
      { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" },
      { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
      { provider: "google", id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
    ]);

    const multiselect = createSelectAllMultiselect();
    const prompter = makePrompter({ multiselect });
    const config = {
      agents: {
        defaults: {
          models: {
            "codex/gpt-5.5": { alias: "legacy-codex" },
            "claude-cli/claude-sonnet-4-6": { alias: "CLI Claude" },
            "google-gemini-cli/gemini-3-pro-preview": { alias: "CLI Gemini" },
            "openai/gpt-5.5": { alias: "gpt" },
          },
        },
      },
    } as OpenClawConfig;

    await promptModelAllowlist({ config, prompter });

    const call = pickerParams(multiselect as MockCallSource);
    const values = optionValues(call.options as PickerOption[]);
    expect(values).toEqual([
      "openai/gpt-5.5",
      "anthropic/claude-sonnet-4-6",
      "google/gemini-3.1-pro-preview",
    ]);
    expect(call.initialValues).toEqual(["openai/gpt-5.5"]);
  });
});

describe("router model filtering", () => {
  it("filters internal router models in both default and allowlist prompts", async () => {
    loadModelCatalog.mockResolvedValue(OPENROUTER_CATALOG);

    const select = vi.fn(async (params) => {
      const first = params.options[0];
      return first?.value ?? "";
    });
    const multiselect = createSelectAllMultiselect();
    const defaultPrompter = makePrompter({ select });
    const allowlistPrompter = makePrompter({ multiselect });
    const config = { agents: { defaults: {} } } as OpenClawConfig;

    await promptDefaultModel({
      config,
      prompter: defaultPrompter,
      allowKeep: false,
      includeManual: false,
      ignoreAllowlist: true,
    });
    await promptModelAllowlist({ config, prompter: allowlistPrompter });

    const defaultOptions = pickerOptions(select as MockCallSource);
    expectRouterModelFiltering(defaultOptions);

    const allowlistCall = pickerParams(multiselect as MockCallSource);
    expectRouterModelFiltering(allowlistCall.options as Array<{ value: string }>);
    expect(allowlistCall.searchable).toBe(true);
    expect(runProviderPluginAuthMethod).not.toHaveBeenCalled();
  });
});

describe("applyModelAllowlist", () => {
  it("preserves existing entries for selected models", () => {
    const config = {
      agents: {
        defaults: {
          models: {
            "openai/gpt-5.5": { alias: "gpt" },
            "anthropic/claude-opus-4-6": { alias: "opus" },
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelAllowlist(config, ["openai/gpt-5.5"]);
    expect(next.agents?.defaults?.models).toEqual({
      "openai/gpt-5.5": { alias: "gpt" },
    });
  });

  it("normalizes retired Google Gemini refs before writing selected models", () => {
    const config = {
      agents: {
        defaults: {
          models: {
            "google/gemini-3.1-pro-preview": { alias: "gemini" },
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelAllowlist(config, [
      "google/gemini-3-pro-preview",
      "google-gemini-cli/gemini-3-pro-preview",
      "openrouter/google/gemini-3-pro-preview",
    ]);
    expect(next.agents?.defaults?.models).toEqual({
      "google/gemini-3.1-pro-preview": { alias: "gemini" },
      "google-gemini-cli/gemini-3.1-pro-preview": {},
      "openrouter/google/gemini-3.1-pro-preview": {},
    });
  });

  it("keeps non-Google provider Gemini-looking refs unchanged while writing selected models", () => {
    const config = {} as OpenClawConfig;

    const next = applyModelAllowlist(config, ["litellm/gemini-3-flash", "litellm/gemini-3.1-pro"]);
    expect(next.agents?.defaults?.models).toEqual({
      "litellm/gemini-3-flash": {},
      "litellm/gemini-3.1-pro": {},
    });
  });

  it("preserves entries outside scoped allowlist updates", () => {
    const config = {
      agents: {
        defaults: {
          models: {
            "openai/gpt-5.5": { alias: "gpt" },
            "anthropic/claude-opus-4-6": { alias: "opus" },
            "anthropic/claude-sonnet-4-6": { alias: "sonnet" },
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelAllowlist(config, ["anthropic/claude-sonnet-4-6"], {
      scopeKeys: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"],
    });
    expect(next.agents?.defaults?.models).toEqual({
      "openai/gpt-5.5": { alias: "gpt" },
      "anthropic/claude-sonnet-4-6": { alias: "sonnet" },
    });
  });

  it("clears the allowlist when no models remain", () => {
    const config = {
      agents: {
        defaults: {
          models: {
            "openai/gpt-5.5": { alias: "gpt" },
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelAllowlist(config, []);
    expect(next.agents?.defaults?.models).toBeUndefined();
  });
});

describe("applyModelFallbacksFromSelection", () => {
  it("sets fallbacks from selection when the primary is included", () => {
    const config = {
      agents: {
        defaults: {
          model: { primary: "anthropic/claude-opus-4-6" },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, [
      "anthropic/claude-opus-4-6",
      "anthropic/claude-sonnet-4-6",
    ]);
    expect(next.agents?.defaults?.model).toEqual({
      primary: "anthropic/claude-opus-4-6",
      fallbacks: ["anthropic/claude-sonnet-4-6"],
    });
  });

  it("does not inject a phantom primary when none was configured", () => {
    const config = {
      agents: {
        defaults: {},
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, [
      "openai/gpt-5.5",
      "anthropic/claude-sonnet-4-6",
    ]);
    expect(next.agents?.defaults?.model).toEqual({
      fallbacks: ["anthropic/claude-sonnet-4-6"],
    });
    expect(next.agents?.defaults?.model).not.toHaveProperty("primary");
  });

  it("does not write an empty model object for singleton default selections", () => {
    const config = {
      agents: {
        defaults: {},
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"]);
    expect(next).toBe(config);
  });

  it("clears existing fallbacks when only the primary remains selected", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "anthropic/claude-opus-4-6",
            fallbacks: ["anthropic/claude-sonnet-4-6"],
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, ["anthropic/claude-opus-4-6"]);
    expect(next.agents?.defaults?.model).toEqual({
      primary: "anthropic/claude-opus-4-6",
    });
  });

  it("normalizes retired Google Gemini refs in selected fallbacks before writing config", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["google/gemini-3-pro-preview"],
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, [
      "openai/gpt-5.5",
      "google/gemini-3-pro-preview",
      "openrouter/google/gemini-3-pro-preview",
    ]);
    expect(next.agents?.defaults?.model).toEqual({
      primary: "openai/gpt-5.5",
      fallbacks: ["google/gemini-3.1-pro-preview", "openrouter/google/gemini-3.1-pro-preview"],
    });
  });

  it("normalizes a retired Google Gemini primary while writing selected fallbacks", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "google/gemini-3-pro-preview",
            fallbacks: ["openai/gpt-5.5"],
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, [
      "google/gemini-3.1-pro-preview",
      "openai/gpt-5.5",
    ]);
    expect(next.agents?.defaults?.model).toEqual({
      primary: "google/gemini-3.1-pro-preview",
      fallbacks: ["openai/gpt-5.5"],
    });
  });

  it("drops malformed fallback refs instead of preserving raw strings", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["openai/"],
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"]);
    expect(next.agents?.defaults?.model).toEqual({
      primary: "openai/gpt-5.5",
    });
  });

  it("preserves hidden fallbacks during unscoped selections", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["claude-cli/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"],
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"]);
    expect(next.agents?.defaults?.model).toEqual({
      primary: "openai/gpt-5.5",
      fallbacks: ["claude-cli/claude-sonnet-4-6"],
    });
  });

  it("preserves out-of-scope fallbacks during scoped selections", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"],
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"], {
      scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
    });
    expect(next.agents?.defaults?.model).toEqual({
      primary: "openai/gpt-5.5",
      fallbacks: ["anthropic/claude-sonnet-4-6"],
    });
  });

  it("removes scoped fallbacks for empty scoped selections", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "anthropic/claude-opus-4-6",
            fallbacks: ["openai/gpt-5.5", "google/gemini-3-pro-preview"],
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, [], {
      scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
    });
    expect(next.agents?.defaults?.model).toEqual({
      primary: "anthropic/claude-opus-4-6",
      fallbacks: ["google/gemini-3.1-pro-preview"],
    });
  });

  it("does not add new scoped fallbacks when the primary is outside scope", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "anthropic/claude-opus-4-6",
            fallbacks: ["openai/gpt-5.5"],
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5", "openai/gpt-5.4"], {
      scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
    });
    expect(next.agents?.defaults?.model).toEqual({
      primary: "anthropic/claude-opus-4-6",
      fallbacks: ["openai/gpt-5.5"],
    });
  });

  it("removes existing scoped fallback aliases when deselected", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["mini"],
          },
          models: {
            "openai/gpt-5.4-mini": { alias: "mini" },
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"], {
      scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4-mini"],
    });
    expect(next.agents?.defaults?.model).toEqual({
      primary: "openai/gpt-5.5",
    });
  });

  it("canonicalizes existing scoped fallback aliases when kept selected", () => {
    const config = {
      agents: {
        defaults: {
          model: {
            primary: "openai/gpt-5.5",
            fallbacks: ["mini"],
          },
          models: {
            "openai/gpt-5.4-mini": { alias: "mini" },
          },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(
      config,
      ["openai/gpt-5.5", "openai/gpt-5.4-mini"],
      {
        scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4-mini"],
      },
    );
    expect(next.agents?.defaults?.model).toEqual({
      primary: "openai/gpt-5.5",
      fallbacks: ["openai/gpt-5.4-mini"],
    });
  });

  it("keeps existing fallbacks when the primary is not selected", () => {
    const config = {
      agents: {
        defaults: {
          model: { primary: "anthropic/claude-opus-4-6", fallbacks: ["openai/gpt-5.5"] },
        },
      },
    } as OpenClawConfig;

    const next = applyModelFallbacksFromSelection(config, ["openai/gpt-5.5"]);
    expect(next.agents?.defaults?.model).toEqual({
      primary: "anthropic/claude-opus-4-6",
      fallbacks: ["openai/gpt-5.5"],
    });
  });
});
