/**
 * L5 — the first-run wizard wired to the stateful {@link MockProfileGateway} via
 * the real {@link DIProvider}. Covers: pre-filled editable rows (only the
 * selectable Claude/Codex profiles, §17.3/D7), detection ✓/✗, the **absence** of
 * any custom-profile block, and finishing (configure ⇒ first run closed ⇒ onDone).
 */
import { describe, it, expect, vi } from "vitest";
import {
  act,
  render,
  screen,
  waitFor,
  fireEvent,
  within,
} from "@testing-library/react";

import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
import type {
  AgentProfile,
  LocalModelServerConfig,
  ProfileAvailability,
} from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { FirstRunWizard } from "./FirstRunWizard";
import { DETECT_TIMEOUT_MS } from "./useFirstRun";

function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
  fireEvent.click(screen.getByLabelText(label));
  fireEvent.click(screen.getByRole("option", { name: optionName }));
}

function renderWizard(
  profile: MockProfileGateway = new MockProfileGateway(),
  onDone = vi.fn(),
  modelServer: MockModelServerGateway = new MockModelServerGateway(),
) {
  const gateways = { profile, modelServer } as unknown as Gateways;
  return {
    profile,
    modelServer,
    onDone,
    ...render(
      <DIProvider gateways={gateways}>
        <FirstRunWizard onDone={onDone} />
      </DIProvider>,
    ),
  };
}

async function waitForLoaded() {
  await screen.findByLabelText("first run setup");
}

describe("FirstRunWizard (with MockProfileGateway)", () => {
  it("renders only the selectable (Claude/Codex) pre-filled, editable profiles", async () => {
    renderWizard();
    await waitForLoaded();

    // Only structured-drivable profiles are offered (§17.3/D7).
    for (const name of ["Claude Code", "OpenAI Codex CLI"]) {
      expect(screen.getByText(name)).toBeTruthy();
    }
    // Gemini/Aider are no longer proposed for selection.
    expect(screen.queryByText("Gemini CLI")).toBeNull();
    expect(screen.queryByText("Aider")).toBeNull();
    expect(screen.queryByLabelText("Aider command")).toBeNull();

    // Commands are editable inputs, pre-filled.
    const claudeCmd = screen.getByLabelText(
      "Claude Code command",
    ) as HTMLInputElement;
    expect(claudeCmd.value).toBe("claude");
  });

  it("editing a command updates the input value", async () => {
    renderWizard();
    await waitForLoaded();
    const cmd = screen.getByLabelText(
      "OpenAI Codex CLI command",
    ) as HTMLInputElement;
    fireEvent.change(cmd, { target: { value: "codex-2" } });
    expect(cmd.value).toBe("codex-2");
  });

  it("keeps args spaces while typing and parses them on blur", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();
    await waitFor(() =>
      expect(
        (screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
      ).toBe(true),
    );

    const args = screen.getByLabelText("Claude Code args") as HTMLInputElement;
    fireEvent.change(args, {
      target: { value: "--model   claude-sonnet-4-5  --verbose " },
    });
    expect(args.value).toBe("--model   claude-sonnet-4-5  --verbose ");

    fireEvent.blur(args);
    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const [saved] = await profile.listProfiles();
      expect(saved.args).toEqual(["--model", "claude-sonnet-4-5", "--verbose"]);
    });
  });

  it("detection shows ✓ for claude and ✗ for the rest", async () => {
    renderWizard();
    await waitForLoaded();

    fireEvent.click(screen.getByRole("button", { name: "Detect installed CLIs" }));

    await waitFor(() => {
      expect(
        screen.getByLabelText("Claude Code availability").textContent,
      ).toMatch(/installed/);
    });
    expect(
      screen.getByLabelText("OpenAI Codex CLI availability").textContent,
    ).toMatch(/not found/);
  });

  // §17.3/D7 — anti-regression guard. The custom-profile block was removed
  // because we cannot drive an arbitrary command in structured mode. This test
  // asserts the *absence* of that block: it must fail the instant any
  // `AddCustomProfile` UI (its label/role/inputs) reappears.
  it("offers no custom-profile block (removed in D7)", async () => {
    renderWizard();
    await waitForLoaded();

    expect(screen.queryByLabelText("add custom profile")).toBeNull();
    expect(
      screen.queryByRole("button", { name: "Add custom profile" }),
    ).toBeNull();
    expect(screen.queryByLabelText("custom name")).toBeNull();
    expect(screen.queryByLabelText("custom command")).toBeNull();
  });

  it("auto-detects on open and pre-checks only installed CLIs", async () => {
    renderWizard();
    await waitForLoaded();

    // The mock reports only `claude` as installed → only it is pre-checked.
    await waitFor(() =>
      expect(
        (screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
      ).toBe(true),
    );
    expect(
      (screen.getByLabelText("use OpenAI Codex CLI") as HTMLInputElement).checked,
    ).toBe(false);
    // Availability was filled automatically, without clicking the button.
    expect(
      screen.getByLabelText("Claude Code availability").textContent,
    ).toMatch(/installed/);
  });

  it("finishing persists the auto-selected (installed) profiles and closes the first run", async () => {
    const { profile, onDone } = renderWizard();
    await waitForLoaded();
    await waitFor(() =>
      expect(
        (screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
      ).toBe(true),
    );

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(() => expect(onDone).toHaveBeenCalled());
    await waitFor(() =>
      expect(screen.queryByLabelText("first run setup")).toBeNull(),
    );

    // Only the installed CLI was pre-selected, so only it is persisted.
    const saved = await profile.listProfiles();
    expect(saved.map((p) => p.command)).toEqual(["claude"]);
    expect((await profile.firstRunState()).isFirstRun).toBe(false);
  });

  it("ticking an extra profile persists it alongside the installed ones", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();
    await waitFor(() =>
      expect(
        (screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
      ).toBe(true),
    );

    // Keep Codex too (not installed, unchecked by default).
    fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      expect(saved.map((p) => p.command)).toEqual(["claude", "codex"]);
    });
  });

  it("renders nothing when it is not the first run", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([]); // closes first run
    const { container } = renderWizard(profile);

    // No wizard section ever appears.
    await waitFor(() =>
      expect(screen.queryByLabelText("first run setup")).toBeNull(),
    );
    expect(container.querySelector("section")).toBeNull();
  });
});

describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", () => {
  it("shows only a free-form model field for Codex and persists no provider/API key", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();
    await waitFor(() =>
      expect(
        (screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
      ).toBe(true),
    );

    fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));

    const row = within(
      screen.getByLabelText("use OpenAI Codex CLI").closest("li")!,
    );
    expect(row.queryByLabelText("OpenAI Codex CLI provider")).toBeNull();
    expect(row.queryByLabelText("OpenAI Codex CLI provider search")).toBeNull();
    expect(row.queryByLabelText("OpenAI Codex CLI api key")).toBeNull();

    fireEvent.change(row.getByLabelText("OpenAI Codex CLI model"), {
      target: { value: "gpt-5-codex" },
    });

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const codex = saved.find((p) => p.command === "codex");
      expect(codex?.model).toBe("gpt-5-codex");
      expect(JSON.stringify(codex)).not.toContain("provider");
      expect(JSON.stringify(codex)).not.toContain("apiKey");
    });
  });

  it("shows only a free-form model field for Claude and persists no provider/API key", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();

    const claudeToggle = screen.getByLabelText(
      "use Claude Code",
    ) as HTMLInputElement;
    if (!claudeToggle.checked) fireEvent.click(claudeToggle);

    const row = within(claudeToggle.closest("li")!);
    expect(row.queryByLabelText("Claude Code provider")).toBeNull();
    expect(row.queryByLabelText("Claude Code provider search")).toBeNull();
    expect(row.queryByLabelText("Claude Code api key")).toBeNull();

    fireEvent.change(row.getByLabelText("Claude Code model"), {
      target: { value: "claude-sonnet-4-5" },
    });

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const claude = saved.find((p) => p.command === "claude");
      expect(claude?.model).toBe("claude-sonnet-4-5");
      expect(JSON.stringify(claude)).not.toContain("provider");
      expect(JSON.stringify(claude)).not.toContain("apiKey");
    });
  });

  it("edits an existing Codex model without rendering provider/API key fields", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      {
        id: "codex-existing",
        name: "Codex configured",
        command: "codex",
        args: [],
        contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
        detect: "codex --version",
        cwdTemplate: "{projectRoot}",
        structuredAdapter: "codex",
        model: "gpt-5-mini",
      },
    ]);
    const gateways = {
      profile,
      modelServer: new MockModelServerGateway(),
    } as unknown as Gateways;
    render(
      <DIProvider gateways={gateways}>
        <FirstRunWizard forceOpen />
      </DIProvider>,
    );
    await waitForLoaded();

    const row = within(
      screen.getByLabelText("use Codex configured").closest("li")!,
    );
    expect(row.queryByLabelText("Codex configured provider")).toBeNull();
    expect(row.queryByLabelText("Codex configured api key")).toBeNull();
    expect(
      (row.getByLabelText("Codex configured model") as HTMLInputElement).value,
    ).toBe("gpt-5-mini");

    fireEvent.change(row.getByLabelText("Codex configured model"), {
      target: { value: "gpt-5" },
    });
    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const [saved] = await profile.listProfiles();
      expect(saved.model).toBe("gpt-5");
      expect(JSON.stringify(saved)).not.toContain("provider");
      expect(JSON.stringify(saved)).not.toContain("apiKey");
    });
  });
});

describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
  const OPENCODE = "OpenCode + llama.cpp";

  it("offers the local model as a selectable, pre-filled OpenCode config row", async () => {
    renderWizard();
    await waitForLoaded();

    expect(screen.getByText(OPENCODE)).toBeTruthy();
    // The OpenCode fields are rendered, pre-filled from the reference.
    expect(
      (screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value,
    ).toBe("http://localhost:8080/v1");
    expect(
      (screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value,
    ).toBe("qwen3-coder-30b");
    // Claude/Codex rows must NOT grow endpoint fields (additive, no regression).
    expect(screen.queryByLabelText("Claude Code base url")).toBeNull();
  });

  it("exposes the API key as a free-form optional field (not an env var name)", async () => {
    renderWizard();
    await waitForLoaded();

    const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement;
    expect(apiKey.value).toBe("sk-no-key");

    // A raw secret is accepted verbatim (OpenCode carries the key itself) — no
    // env-var-name error appears.
    fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
    expect(apiKey.value).toBe("sk-secret-123");
    expect(
      screen.queryByText(/valid env var name/i),
    ).toBeNull();
  });

  it("flags an invalid base URL scheme inline", async () => {
    renderWizard();
    await waitForLoaded();

    const baseUrl = screen.getByLabelText(`${OPENCODE} base url`);
    fireEvent.change(baseUrl, { target: { value: "ftp://oops" } });
    expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
  });

  it("flags an empty model inline", async () => {
    renderWizard();
    await waitForLoaded();

    fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
      target: { value: "" },
    });
    expect(screen.getByText(/model is required/i)).toBeTruthy();
  });

  it("persists the edited OpenCode config round-trip when selected and saved", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();

    // Select the local model and edit its base URL, model and API key.
    fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
    fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), {
      target: { value: "http://localhost:9090/v1" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
      target: { value: "qwen3-coder-14b" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
      target: { value: "sk-local" },
    });

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const opencode = saved.find((p) => p.command === "opencode");
      expect(opencode?.structuredAdapter).toBe("openCode");
      expect(opencode?.opencode?.baseURL).toBe("http://localhost:9090/v1");
      expect(opencode?.opencode?.model).toBe("qwen3-coder-14b");
      expect(opencode?.opencode?.apiKey).toBe("sk-local");
    });
  });

  it("clearing the API key drops it (optional, omitted when blank)", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();

    fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
    fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
      target: { value: "" },
    });

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const opencode = saved.find((p) => p.command === "opencode");
      expect(opencode?.opencode?.apiKey).toBeUndefined();
    });
  });
});

describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
  const OPENCODE = "OpenCode + llama.cpp";

  it("defaults to Local, and switching to Cloud swaps the sub-form", async () => {
    renderWizard();
    await waitForLoaded();

    expect(
      screen.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute(
        "aria-checked",
      ),
    ).toBe("true");
    expect(screen.getByLabelText(`${OPENCODE} base url`)).toBeTruthy();

    fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));

    expect(screen.queryByLabelText(`${OPENCODE} base url`)).toBeNull();
    await screen.findByLabelText(`${OPENCODE} provider`);
  });

  it("editing an already-cloud profile preselects the Cloud segment with an empty key", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      {
        id: "cfg-oc-cloud-1",
        name: "Claude via OpenCode",
        command: "opencode",
        args: [],
        contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
        detect: "opencode --version",
        cwdTemplate: "{projectRoot}",
        structuredAdapter: "openCode",
        opencodeProvider: {
          providerId: "anthropic",
          model: "claude-sonnet-5",
          apiKeyRef: "secret-ref-1",
        },
      },
    ]);

    const gateways = {
      profile,
      modelServer: new MockModelServerGateway(),
    } as unknown as Gateways;
    render(
      <DIProvider gateways={gateways}>
        <FirstRunWizard forceOpen />
      </DIProvider>,
    );
    await waitForLoaded();

    // Scope to this row: the still-unconfigured "OpenCode + llama.cpp"
    // reference also renders (edit mode dedups by id only), with its own
    // Local/Cloud segmented control.
    const row = within(
      screen.getByLabelText("use Claude via OpenCode").closest("li")!,
    );
    expect(
      row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
        "aria-checked",
      ),
    ).toBe("true");
    expect(
      row.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute(
        "aria-checked",
      ),
    ).toBe("false");
    expect(
      (row.getByLabelText("Claude via OpenCode api key") as HTMLInputElement)
        .value,
    ).toBe("");
  });

  it("cascades provider ➜ model and disables Save until the API key is filled", async () => {
    renderWizard();
    await waitForLoaded();
    fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));

    await screen.findByLabelText(`${OPENCODE} provider`);
    const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLButtonElement;
    expect(modelSelect.disabled).toBe(true);

    chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
    expect(modelSelect.disabled).toBe(false);

    const saveButton = screen.getByRole("button", {
      name: "Enregistrer",
    }) as HTMLButtonElement;
    expect(saveButton.disabled).toBe(true);

    chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
    expect(saveButton.disabled).toBe(true);

    fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
      target: { value: "sk-ant-secret" },
    });
    expect(saveButton.disabled).toBe(false);
  });

  it("saves a cloud profile via saveOpenCodeProviderProfile and clears the key afterwards", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();
    fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));

    await screen.findByLabelText(`${OPENCODE} provider`);
    chooseDropdownOption(`${OPENCODE} provider`, "Anthropic");
    chooseDropdownOption(`${OPENCODE} model`, "claude-sonnet-5");
    const apiKeyInput = screen.getByLabelText(
      `${OPENCODE} api key`,
    ) as HTMLInputElement;
    fireEvent.change(apiKeyInput, { target: { value: "sk-ant-secret" } });

    fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const opencode = saved.find((p) => p.command === "opencode");
      expect(opencode?.opencodeProvider?.providerId).toBe("anthropic");
      expect(opencode?.opencodeProvider?.model).toBe("claude-sonnet-5");
      expect(opencode?.opencode).toBeUndefined();
    });
    // The key is never kept around client-side once saved.
    expect(apiKeyInput.value).toBe("");
  });

  it("shows submit-time validation messages when provider/model/key are missing", async () => {
    renderWizard();
    await waitForLoaded();
    fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
    await screen.findByLabelText(`${OPENCODE} provider`);

    // Save stays disabled with no key, so drive validation via a filled key but
    // no provider/model to see the field-level messages fire on submit.
    fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
      target: { value: "sk-ant-secret" },
    });
    fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));

    expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
    expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
  });
});

describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic catalogue)", () => {
  const OPENCODE = "OpenCode + llama.cpp";

  async function goToCloudCustomMode() {
    fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
    await screen.findByLabelText(`${OPENCODE} provider`);
    chooseDropdownOption(`${OPENCODE} provider`, "Autre / personnalisé…");
  }

  it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
    renderWizard();
    await waitForLoaded();
    await goToCloudCustomMode();

    expect(screen.queryByLabelText(`${OPENCODE} provider search`)).toBeNull();
    expect(
      screen.getByLabelText(`${OPENCODE} custom provider id`),
    ).toBeTruthy();
    expect(
      (screen.getByLabelText(`${OPENCODE} custom npm package`) as HTMLInputElement)
        .value,
    ).toBe("@ai-sdk/openai-compatible");
    expect(screen.getByLabelText(`${OPENCODE} custom base url`)).toBeTruthy();
    expect(screen.getByLabelText(`${OPENCODE} model`)).toBeTruthy();
    expect(
      screen.getByLabelText(`${OPENCODE} custom display name`),
    ).toBeTruthy();
  });

  it("validates the required custom fields on submit", async () => {
    renderWizard();
    await waitForLoaded();
    await goToCloudCustomMode();

    fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
      target: { value: "sk-custom-secret" },
    });
    fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));

    expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
    expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
    expect(screen.getByText("L'URL de base est obligatoire.")).toBeTruthy();
    // npm is pre-filled by default, so it doesn't fail validation here.
    expect(screen.queryByText("Le paquet npm est obligatoire.")).toBeNull();
  });

  it("saves a custom provider profile with the custom config mapped through", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();
    await goToCloudCustomMode();

    fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
      target: { value: "mon-provider" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} custom npm package`), {
      target: { value: "@ai-sdk/openai-compatible" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
      target: { value: "https://api.mon-provider.example/v1" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
      target: { value: "mon-modele-1" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} custom display name`), {
      target: { value: "Mon Modèle" },
    });
    const apiKeyInput = screen.getByLabelText(
      `${OPENCODE} api key`,
    ) as HTMLInputElement;
    fireEvent.change(apiKeyInput, { target: { value: "sk-custom-secret" } });

    fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const opencode = saved.find((p) => p.command === "opencode");
      expect(opencode?.opencodeProvider?.providerId).toBe("mon-provider");
      expect(opencode?.opencodeProvider?.model).toBe("mon-modele-1");
      expect(opencode?.opencodeProvider?.custom).toEqual({
        npm: "@ai-sdk/openai-compatible",
        baseUrl: "https://api.mon-provider.example/v1",
        displayName: "Mon Modèle",
      });
    });
    expect(apiKeyInput.value).toBe("");
  });

  it("omits displayName and custom when left blank / not in custom mode", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();
    await goToCloudCustomMode();

    fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
      target: { value: "mon-provider" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
      target: { value: "https://api.mon-provider.example/v1" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
      target: { value: "mon-modele-1" },
    });
    fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
      target: { value: "sk-custom-secret" },
    });
    fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const opencode = saved.find((p) => p.command === "opencode");
      expect(opencode?.opencodeProvider?.custom?.displayName).toBeUndefined();
    });
  });

  it('"← Choisir un provider du catalogue" switches back to the cascade', async () => {
    renderWizard();
    await waitForLoaded();
    await goToCloudCustomMode();

    fireEvent.click(
      screen.getByRole("button", { name: "← Choisir un provider du catalogue" }),
    );

    expect(screen.getByLabelText(`${OPENCODE} provider`)).toBeTruthy();
    expect(screen.queryByLabelText(`${OPENCODE} custom provider id`)).toBeNull();
  });

  it("editing an existing custom-provider profile preselects custom mode and prefills fields (except the key)", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      {
        id: "cfg-oc-custom-1",
        name: "Mon provider via OpenCode",
        command: "opencode",
        args: [],
        contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
        detect: "opencode --version",
        cwdTemplate: "{projectRoot}",
        structuredAdapter: "openCode",
        opencodeProvider: {
          providerId: "mon-provider",
          model: "mon-modele-1",
          apiKeyRef: "secret-ref-2",
          custom: {
            npm: "@ai-sdk/openai-compatible",
            baseUrl: "https://api.mon-provider.example/v1",
            displayName: "Mon Modèle",
          },
        },
      },
    ]);

    const gateways = {
      profile,
      modelServer: new MockModelServerGateway(),
    } as unknown as Gateways;
    render(
      <DIProvider gateways={gateways}>
        <FirstRunWizard forceOpen />
      </DIProvider>,
    );
    await waitForLoaded();

    const row = within(
      screen.getByLabelText("use Mon provider via OpenCode").closest("li")!,
    );
    expect(
      row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
        "aria-checked",
      ),
    ).toBe("true");
    expect(
      (row.getByLabelText("Mon provider via OpenCode custom provider id") as HTMLInputElement)
        .value,
    ).toBe("mon-provider");
    expect(
      (row.getByLabelText("Mon provider via OpenCode custom npm package") as HTMLInputElement)
        .value,
    ).toBe("@ai-sdk/openai-compatible");
    expect(
      (row.getByLabelText("Mon provider via OpenCode custom base url") as HTMLInputElement)
        .value,
    ).toBe("https://api.mon-provider.example/v1");
    expect(
      (row.getByLabelText("Mon provider via OpenCode model") as HTMLInputElement).value,
    ).toBe("mon-modele-1");
    expect(
      (row.getByLabelText("Mon provider via OpenCode custom display name") as HTMLInputElement)
        .value,
    ).toBe("Mon Modèle");
    expect(
      (row.getByLabelText("Mon provider via OpenCode api key") as HTMLInputElement)
        .value,
    ).toBe("");
  });
});

describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
  const OPENCODE = "OpenCode + llama.cpp";
  const CLONE1 = `${OPENCODE} (copy 1)`;

  it('"Add OpenCode profile" clones the seed into a new, editable, pre-selected row', async () => {
    renderWizard();
    await waitForLoaded();

    // Only one OpenCode row to begin with (the seed reference).
    expect(screen.getAllByText(OPENCODE).length).toBe(1);

    fireEvent.click(
      screen.getByRole("button", { name: "Add OpenCode profile" }),
    );

    // A second OpenCode row appears, pre-filled from the seed and pre-selected.
    const added = await screen.findByLabelText(`use ${CLONE1}`);
    expect((added as HTMLInputElement).checked).toBe(true);
    expect(
      (screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value,
    ).toBe("http://localhost:8080/v1");
    // Its name is editable per profile (identity is the id, not the name).
    expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy();
  });

  it("persists two distinct OpenCode profiles when both are selected", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();

    // Select the seed row and edit it.
    fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
    fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
      target: { value: "qwen3-coder-14b" },
    });

    // Add a second one and give it a different endpoint.
    fireEvent.click(
      screen.getByRole("button", { name: "Add OpenCode profile" }),
    );
    await screen.findByLabelText(`use ${CLONE1}`);
    fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), {
      target: { value: "http://localhost:9191/v1" },
    });

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const opencode = saved.filter((p) => p.structuredAdapter === "openCode");
      expect(opencode.length).toBe(2);
      // Distinct ids (identity) and distinct endpoints.
      expect(new Set(opencode.map((p) => p.id)).size).toBe(2);
      expect(opencode.map((p) => p.opencode?.baseURL).sort()).toEqual([
        "http://localhost:8080/v1",
        "http://localhost:9191/v1",
      ]);
    });
  });

  it("Duplicate clones a row carrying its current config over", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();

    // Edit the seed row, then duplicate it.
    fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
    fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
      target: { value: "qwen3-coder-7b" },
    });
    fireEvent.click(
      screen.getByRole("button", { name: `duplicate ${OPENCODE}` }),
    );

    const dupName = `${OPENCODE} (copy)`;
    await screen.findByLabelText(`use ${dupName}`);
    // The duplicate inherits the edited model.
    expect(
      (screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value,
    ).toBe("qwen3-coder-7b");

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const models = saved
        .filter((p) => p.structuredAdapter === "openCode")
        .map((p) => p.opencode?.model);
      expect(models).toEqual(["qwen3-coder-7b", "qwen3-coder-7b"]);
    });
  });

  it("editing the name round-trips on save", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();

    fireEvent.click(
      screen.getByRole("button", { name: "Add OpenCode profile" }),
    );
    await screen.findByLabelText(`use ${CLONE1}`);
    fireEvent.change(screen.getByLabelText(`${CLONE1} name`), {
      target: { value: "Fast local model" },
    });

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const oc = saved.find((p) => p.structuredAdapter === "openCode");
      expect(oc?.name).toBe("Fast local model");
    });
  });

  it("reasoning/attachment round-trip on save (F36)", async () => {
    const { profile } = renderWizard();
    await waitForLoaded();

    fireEvent.click(
      screen.getByRole("button", { name: "Add OpenCode profile" }),
    );
    await screen.findByLabelText(`use ${CLONE1}`);

    // Reasoning defaults on (backend effective default true); turn it off.
    const reasoning = screen.getByLabelText(
      `${CLONE1} reasoning`,
    ) as HTMLInputElement;
    expect(reasoning.checked).toBe(true);
    fireEvent.click(reasoning);
    // Attachments default off; turn them on.
    fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`));

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const oc = saved.find((p) => p.structuredAdapter === "openCode");
      expect(oc?.opencode?.reasoning).toBe(false);
      expect(oc?.opencode?.attachment).toBe(true);
    });
  });

  it("binds localModelServerId via the server dropdown (F35.2), replacing free text", async () => {
    // Seed a declared server so the dropdown offers it.
    const modelServer = new MockModelServerGateway();
    const server: LocalModelServerConfig = {
      id: "550e8400-e29b-41d4-a716-446655440000",
      kind: "llamaCpp",
      name: "Local A",
      baseURL: "http://localhost:8080/v1",
      port: 8080,
      servedModelName: "qwen3-coder-30b",
      host: "127.0.0.1",
      jinja: false,
      args: [],
      autoStart: false,
      stopPolicy: "stopOnAppExit",
    };
    await modelServer.saveModelServer(server);

    const { profile } = renderWizard(new MockProfileGateway(), vi.fn(), modelServer);
    await waitForLoaded();

    fireEvent.click(
      screen.getByRole("button", { name: "Add OpenCode profile" }),
    );
    await screen.findByLabelText(`use ${CLONE1}`);

    // The old free-text field is gone; a dropdown replaces it.
    expect(
      screen.queryByLabelText(`${CLONE1} local model server id`),
    ).toBeNull();
    await screen.findByLabelText(`${CLONE1} local model server`);
    chooseDropdownOption(`${CLONE1} local model server`, /Local A/);

    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
    await waitFor(async () => {
      const saved = await profile.listProfiles();
      const oc = saved.find((p) => p.structuredAdapter === "openCode");
      expect(oc?.opencode?.localModelServerId).toBe(server.id);
    });
  });
});

// Ticket #28 — the wizard must survive a detection that never answers. When the
// backend `detect_profiles` command panics, the `invoke` promise is neither
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.
// `busy` must therefore never be tied to detection, or both buttons stay greyed
// out for good and the first run cannot be completed.
describe("FirstRunWizard — detection that never answers (ticket #28)", () => {
  /** A gateway whose `detectProfiles` promise stays pending forever. */
  class HangingDetectGateway extends MockProfileGateway {
    override detectProfiles(): Promise<ProfileAvailability[]> {
      return new Promise<ProfileAvailability[]>(() => {});
    }
  }

  it("keeps Save and Detect enabled once firstRunState answered", async () => {
    renderWizard(new HangingDetectGateway());
    await waitForLoaded();

    // The rows are rendered, so the blocking load is over: both actions are live.
    const save = screen.getByRole("button", { name: "Save and continue" });
    const detect = screen.getByRole("button", { name: "Detect installed CLIs" });
    expect((save as HTMLButtonElement).disabled).toBe(false);
    expect((detect as HTMLButtonElement).disabled).toBe(false);

    // Detection is merely reported as in flight, never as a blocking state.
    expect(screen.getByRole("status").textContent).toMatch(/detecting/i);

    // Re-probing by hand must not freeze them either.
    fireEvent.click(detect);
    expect((save as HTMLButtonElement).disabled).toBe(false);
    expect((detect as HTMLButtonElement).disabled).toBe(false);
  });

  it("still finishes the first run while detection hangs", async () => {
    const profile = new HangingDetectGateway();
    const { onDone } = renderWizard(profile, vi.fn());
    await waitForLoaded();

    // Nothing got pre-checked (detection never answered): tick a profile by hand.
    fireEvent.click(screen.getByLabelText("use Claude Code"));
    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(() => expect(onDone).toHaveBeenCalled());
    const saved = await profile.listProfiles();
    expect(saved.map((p) => p.command)).toEqual(["claude"]);
  });

  it("drops the detecting flag on timeout even if the promise never settles", async () => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
    try {
      renderWizard(new HangingDetectGateway());
      await waitForLoaded();
      expect(screen.getByRole("status").textContent).toMatch(/detecting/i);

      await act(async () => {
        vi.advanceTimersByTime(DETECT_TIMEOUT_MS + 1);
      });
      expect(screen.queryByRole("status")).toBeNull();
    } finally {
      vi.useRealTimers();
    }
  });
});

describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
  /** A gateway whose first run is already done (profiles configured). */
  async function configuredGateway() {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([]); // marks first run as done
    return profile;
  }

  it("stays hidden once the first run is done (default)", async () => {
    const profile = await configuredGateway();
    const gateways = { profile } as unknown as Gateways;
    render(
      <DIProvider gateways={gateways}>
        <FirstRunWizard />
      </DIProvider>,
    );
    // Give the async first-run state time to resolve to `false`.
    await waitFor(() =>
      expect(screen.queryByLabelText("first run setup")).toBeNull(),
    );
  });

  it("renders when forced open (Settings ▸ Configure profiles)", async () => {
    const profile = await configuredGateway();
    const gateways = { profile } as unknown as Gateways;
    render(
      <DIProvider gateways={gateways}>
        <FirstRunWizard forceOpen />
      </DIProvider>,
    );
    expect(await screen.findByLabelText("first run setup")).toBeTruthy();
  });
});

// Ticket #44 — reopening the wizard from Settings (`forceOpen` ⇒ edit mode) must
// pre-fill the already-configured profiles, pre-selected with their real config,
// so a save doesn't silently drop them and generic reference candidates don't
// take over. Dedup is by id only; several local OpenCode profiles are preserved.
describe("FirstRunWizard edit mode reopening (#44)", () => {
  const injection = {
    strategy: "conventionFile" as const,
    target: "AGENTS.md",
  };
  function openCodeProfile(
    id: string,
    name: string,
    baseURL: string,
    model = "qwen3-coder-30b",
  ): AgentProfile {
    return {
      id,
      name,
      command: "opencode",
      args: [],
      contextInjection: injection,
      detect: "opencode --version",
      cwdTemplate: "{projectRoot}",
      structuredAdapter: "openCode",
      opencode: { baseURL, apiKey: "sk-x", model },
    };
  }

  function renderEdit(profile: MockProfileGateway) {
    const gateways = {
      profile,
      modelServer: new MockModelServerGateway(),
    } as unknown as Gateways;
    return render(
      <DIProvider gateways={gateways}>
        <FirstRunWizard forceOpen />
      </DIProvider>,
    );
  }

  it("pre-fills the configured profiles, pre-selected, with config intact", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      openCodeProfile("cfg-oc-1", "Local A", "http://localhost:9090/v1", "qwen3-coder-14b"),
    ]);

    renderEdit(profile);
    await waitForLoaded();

    // The configured OpenCode comes back checked, with its real endpoint/model.
    expect(
      (screen.getByLabelText("use Local A") as HTMLInputElement).checked,
    ).toBe(true);
    expect(
      (screen.getByLabelText("Local A base url") as HTMLInputElement).value,
    ).toBe("http://localhost:9090/v1");
    expect(
      (screen.getByLabelText("Local A model") as HTMLInputElement).value,
    ).toBe("qwen3-coder-14b");
  });

  it("does not duplicate a configured reference (matched by id, configured wins)", async () => {
    // The reference OpenCode (id `mock-opencode`) was edited and is now
    // configured under the same id — it must appear exactly once, pre-selected.
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      openCodeProfile("mock-opencode", "OpenCode (edited)", "http://localhost:7777/v1"),
    ]);

    renderEdit(profile);
    await waitForLoaded();

    // Exactly one row for the edited OpenCode; the generic reference name is gone.
    expect(screen.getAllByText("OpenCode (edited)")).toHaveLength(1);
    expect(screen.queryByText("OpenCode + llama.cpp")).toBeNull();
    expect(
      (screen.getByLabelText("use OpenCode (edited)") as HTMLInputElement).checked,
    ).toBe(true);
  });

  it("keeps several distinct OpenCode profiles (same command, different ids)", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
      openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
    ]);

    renderEdit(profile);
    await waitForLoaded();

    // Both distinct profiles are present and pre-selected, none collapsed.
    expect(
      (screen.getByLabelText("use Local A") as HTMLInputElement).checked,
    ).toBe(true);
    expect(
      (screen.getByLabelText("use Local B") as HTMLInputElement).checked,
    ).toBe(true);
    expect(
      (screen.getByLabelText("Local A base url") as HTMLInputElement).value,
    ).toBe("http://localhost:8080/v1");
    expect(
      (screen.getByLabelText("Local B base url") as HTMLInputElement).value,
    ).toBe("http://localhost:9191/v1");
  });

  it("saving in edit mode does not drop configured profiles by omission", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
      openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
    ]);

    renderEdit(profile);
    await waitForLoaded();
    // Save straight away without touching anything.
    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      expect(saved.map((p) => p.id).sort()).toEqual(["oc-1", "oc-2"]);
      // Real config preserved through the round-trip.
      expect(
        saved.find((p) => p.id === "oc-1")?.opencode?.baseURL,
      ).toBe("http://localhost:8080/v1");
    });
  });

  it("unchecking a configured row removes it on save (deliberate deletion)", async () => {
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      openCodeProfile("oc-1", "Local A", "http://localhost:8080/v1"),
      openCodeProfile("oc-2", "Local B", "http://localhost:9191/v1"),
    ]);

    renderEdit(profile);
    await waitForLoaded();
    // Deselect Local B, then save — an explicit removal, not an omission.
    fireEvent.click(screen.getByLabelText("use Local B"));
    fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));

    await waitFor(async () => {
      const saved = await profile.listProfiles();
      expect(saved.map((p) => p.id)).toEqual(["oc-1"]);
    });
  });

  it("auto-detection in edit mode does not re-check or uncheck the initial selection", async () => {
    // A configured Codex (not installed per the mock) reuses the reference id, so
    // it dedups to one pre-selected row. Claude is installed but only a reference.
    const profile = new MockProfileGateway();
    await profile.configureProfiles([
      {
        id: "mock-codex",
        name: "Codex Configured",
        command: "codex",
        args: [],
        contextInjection: injection,
        detect: "codex --version",
        cwdTemplate: "{projectRoot}",
      },
    ]);

    renderEdit(profile);
    await waitForLoaded();

    // Wait until detection has filled availability (Claude reads as installed).
    await waitFor(() =>
      expect(
        screen.getByLabelText("Claude Code availability").textContent,
      ).toMatch(/installed/),
    );

    // Selection is untouched by detection: the configured (uninstalled) Codex
    // stays checked; the installed Claude reference stays UNchecked.
    expect(
      (screen.getByLabelText("use Codex Configured") as HTMLInputElement).checked,
    ).toBe(true);
    expect(
      (screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
    ).toBe(false);
  });
});
