/**
 * F35 (wizard V2) — local model server registry: the pure validation/derivation
 * helpers, the {@link useModelServers} hook behind the real DIProvider + mock
 * gateway, the {@link ModelServersPanel} wizard (source toggle, reserved-flag
 * mirror, V2 save payload, served-name prefill, command preview), and the
 * {@link ModelServerSelect} binding dropdown.
 */

import { describe, it, expect } from "vitest";
import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react";

import type { Gateways } from "@/ports";
import type { LocalModelServerConfig } from "@/domain";
import { MockModelServerGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { useModelServers } from "./useModelServers";
import { ModelServersPanel } from "./ModelServersPanel";
import { ModelServerSelect } from "./ModelServerSelect";
import {
  deriveServedModelName,
  emptyModelServer,
  isAbsolutePath,
  isValidHfRef,
  reservedFlagsIn,
  validateModelServer,
} from "./modelServer";

const SERVER: LocalModelServerConfig = {
  id: "550e8400-e29b-41d4-a716-446655440000",
  kind: "llamaCpp",
  name: "Local A",
  baseURL: "http://localhost:8080/v1",
  port: 8080,
  modelSource: { type: "huggingFace", repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
  servedModelName: "qwen3-coder-30b",
  host: "127.0.0.1",
  jinja: false,
  args: [],
  autoStart: false,
  stopPolicy: "stopOnAppExit",
};

function setup(modelServer = new MockModelServerGateway()) {
  const gateways = { modelServer } as unknown as Gateways;
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <DIProvider gateways={gateways}>{children}</DIProvider>
  );
  const view = renderHook(() => useModelServers(), { wrapper });
  return { modelServer, view };
}

// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------

describe("modelServer helpers (F35 V2)", () => {
  it("emptyModelServer needs a name and a served model name, and starts jinja-off on 127.0.0.1", () => {
    const draft = emptyModelServer();
    expect(draft.host).toBe("127.0.0.1");
    expect(draft.jinja).toBe(false);
    expect(draft.modelSource).toBeUndefined();
    expect(validateModelServer(draft)).toEqual({
      name: "Name is required.",
      servedModelName: "Le nom du modèle est requis.",
    });
  });

  it("flags a non-http base URL and an out-of-range port", () => {
    const errors = validateModelServer({
      ...SERVER,
      baseURL: "ftp://oops",
      port: 70_000,
    });
    expect(errors.baseURL).toBeTruthy();
    expect(errors.port).toBeTruthy();
  });

  it("validates the local path source (absolute) and requires a source for auto-start", () => {
    expect(
      validateModelServer({
        ...SERVER,
        modelSource: { type: "localPath", path: "relative/x.gguf" },
      }).modelSource,
    ).toBeTruthy();
    expect(
      validateModelServer({
        ...SERVER,
        modelSource: { type: "localPath", path: "/m/x.gguf" },
      }).modelSource,
    ).toBeUndefined();
    expect(
      validateModelServer({ ...SERVER, modelSource: undefined, autoStart: true })
        .modelSource,
    ).toMatch(/démarrage automatique/i);
  });

  it("validates the Hugging Face source format", () => {
    expect(isValidHfRef("unsloth/Qwen3.5-9B-GGUF")).toBe(true);
    expect(isValidHfRef("unsloth/Qwen3.5-9B-GGUF:Q4_K_M")).toBe(true);
    expect(isValidHfRef("https://huggingface.co/x/y")).toBe(false);
    expect(isValidHfRef("just-a-name")).toBe(false);
    expect(isValidHfRef("a/b:c:d")).toBe(false);
    expect(
      validateModelServer({
        ...SERVER,
        modelSource: { type: "huggingFace", repo: "not valid ref" },
      }).modelSource,
    ).toBeTruthy();
  });

  it("recognises absolute POSIX and Windows paths", () => {
    expect(isAbsolutePath("/models/x.gguf")).toBe(true);
    expect(isAbsolutePath("C:/models/x.gguf")).toBe(true);
    expect(isAbsolutePath("models/x.gguf")).toBe(false);
  });

  it("derives a served model name from either source", () => {
    expect(
      deriveServedModelName({ type: "localPath", path: "/models/qwen3-coder.gguf" }),
    ).toBe("qwen3-coder");
    expect(
      deriveServedModelName({ type: "huggingFace", repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" }),
    ).toBe("Qwen3.5-9B-GGUF");
    expect(deriveServedModelName(undefined)).toBe("");
  });

  it("spots reserved llama.cpp flags typed in the free args, incl. --flag=value", () => {
    expect(reservedFlagsIn(["--flash-attn", "--parallel", "2"])).toEqual([]);
    expect(reservedFlagsIn(["--model", "/x.gguf"])).toEqual(["--model"]);
    expect(reservedFlagsIn(["--host=0.0.0.0"])).toEqual(["--host"]);
    expect(reservedFlagsIn(["-ngl", "99"])).toEqual(["-ngl"]);
  });
});

// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------

describe("useModelServers (F35 V2)", () => {
  it("saves and lists servers", async () => {
    const { view } = setup();
    await act(async () => {
      await view.result.current.save(SERVER);
    });
    expect(view.result.current.servers).toHaveLength(1);
    expect(view.result.current.servers[0].name).toBe("Local A");
  });

  it("removes a server", async () => {
    const { view } = setup();
    await act(async () => {
      await view.result.current.save(SERVER);
    });
    await act(async () => {
      const ok = await view.result.current.remove(SERVER.id);
      expect(ok).toBe(true);
    });
    expect(view.result.current.servers).toHaveLength(0);
  });

  it("surfaces model_server_in_use as an actionable error and keeps the server", async () => {
    const modelServer = new MockModelServerGateway();
    const { view } = setup(modelServer);
    await act(async () => {
      await view.result.current.save(SERVER);
    });
    modelServer.markInUse(SERVER.id);
    await act(async () => {
      const ok = await view.result.current.remove(SERVER.id);
      expect(ok).toBe(false);
    });
    expect(view.result.current.error).toMatch(/encore utilisé par un profil/i);
    expect(view.result.current.servers).toHaveLength(1);
  });

  it("previews a command for a valid draft and returns null for a source-less one", async () => {
    const { view } = setup();
    const withSource = await view.result.current.preview(SERVER);
    expect(withSource?.args).toContain("-hf");
    expect(withSource?.display).toMatch(/unsloth\/Qwen3\.5-9B-GGUF:Q4_K_M/);
    const withoutSource = await view.result.current.preview({
      ...SERVER,
      modelSource: undefined,
    });
    expect(withoutSource).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------

function renderPanel(modelServer = new MockModelServerGateway()) {
  const gateways = { modelServer } as unknown as Gateways;
  function Harness() {
    const vm = useModelServers();
    return <ModelServersPanel vm={vm} />;
  }
  return {
    modelServer,
    ...render(
      <DIProvider gateways={gateways}>
        <Harness />
      </DIProvider>,
    ),
  };
}

describe("ModelServersPanel wizard (F35 V2)", () => {
  it("declares a Hugging Face server end-to-end with a V2 payload", async () => {
    const { modelServer } = renderPanel();
    fireEvent.click(screen.getByLabelText("add model server"));

    fireEvent.change(screen.getByLabelText("server name"), {
      target: { value: "Local A" },
    });
    fireEvent.change(screen.getByLabelText("hugging face model"), {
      target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
    });

    const save = screen.getByLabelText("save model server") as HTMLButtonElement;
    await waitFor(() => expect(save.disabled).toBe(false));
    fireEvent.click(save);

    await waitFor(async () => {
      const saved = await modelServer.listModelServers();
      expect(saved).toHaveLength(1);
    });
    const [saved] = await modelServer.listModelServers();
    // Payload conforms to the V2 DTO: modelSource (not modelPath), host, jinja.
    expect(saved.modelSource).toEqual({
      type: "huggingFace",
      repo: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M",
    });
    expect(saved.host).toBe("127.0.0.1");
    expect(saved.jinja).toBe(false);
    expect("modelPath" in saved).toBe(false);
    expect(screen.getByLabelText("edit Local A")).toBeTruthy();
  });

  it("toggles between the Hugging Face and local .gguf source fields", () => {
    renderPanel();
    fireEvent.click(screen.getByLabelText("add model server"));

    // HF tab is the default.
    expect(screen.getByLabelText("hugging face model")).toBeTruthy();
    expect(screen.queryByLabelText("local gguf file")).toBeNull();

    fireEvent.click(screen.getByRole("button", { name: /fichier \.gguf local/i }));
    expect(screen.getByLabelText("local gguf file")).toBeTruthy();
    expect(screen.queryByLabelText("hugging face model")).toBeNull();
  });

  it("prefills the served model name from the source when left blank", () => {
    renderPanel();
    fireEvent.click(screen.getByLabelText("add model server"));
    fireEvent.change(screen.getByLabelText("hugging face model"), {
      target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
    });
    expect(
      (screen.getByLabelText("server served model name") as HTMLInputElement).value,
    ).toBe("Qwen3.5-9B-GGUF");
  });

  it("mirrors a soft warning when a reserved flag is typed in the extra args", () => {
    renderPanel();
    fireEvent.click(screen.getByLabelText("add model server"));
    fireEvent.change(screen.getByLabelText("extra arguments"), {
      target: { value: "--host 0.0.0.0" },
    });
    expect(screen.getByText(/déjà pilotée par un champ/i)).toBeTruthy();
  });

  it("blocks auto-start until a model source is set", () => {
    renderPanel();
    fireEvent.click(screen.getByLabelText("add model server"));
    const autoStart = screen.getByLabelText("server auto start") as HTMLInputElement;
    expect(autoStart.disabled).toBe(true);
    fireEvent.change(screen.getByLabelText("hugging face model"), {
      target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
    });
    expect(
      (screen.getByLabelText("server auto start") as HTMLInputElement).disabled,
    ).toBe(false);
  });

  it("renders the backend-built command preview after a source is entered", async () => {
    renderPanel();
    fireEvent.click(screen.getByLabelText("add model server"));
    fireEvent.change(screen.getByLabelText("hugging face model"), {
      target: { value: "unsloth/Qwen3.5-9B-GGUF:Q4_K_M" },
    });
    const line = (await screen.findByLabelText(
      "preview command line",
    )) as HTMLElement;
    expect(line.textContent).toMatch(/-hf unsloth\/Qwen3\.5-9B-GGUF:Q4_K_M/);
    expect(line.textContent).toMatch(/--port 8080/);
  });

  it("shows an actionable banner when deleting a referenced server", async () => {
    const modelServer = new MockModelServerGateway();
    await modelServer.saveModelServer(SERVER);
    modelServer.markInUse(SERVER.id);
    renderPanel(modelServer);

    await screen.findByLabelText("edit Local A");
    fireEvent.click(screen.getByLabelText("delete Local A"));

    expect((await screen.findByRole("alert")).textContent).toMatch(
      /encore utilisé par un profil/i,
    );
    expect(screen.getByLabelText("edit Local A")).toBeTruthy();
  });

  it("edits an existing server's served model name", async () => {
    const modelServer = new MockModelServerGateway();
    await modelServer.saveModelServer(SERVER);
    renderPanel(modelServer);

    fireEvent.click(await screen.findByLabelText("edit Local A"));
    fireEvent.change(screen.getByLabelText("server served model name"), {
      target: { value: "qwen3-coder-7b" },
    });
    fireEvent.click(screen.getByLabelText("save model server"));

    await waitFor(async () => {
      const saved = await modelServer.listModelServers();
      expect(saved[0].servedModelName).toBe("qwen3-coder-7b");
    });
  });
});

// ---------------------------------------------------------------------------
// Select
// ---------------------------------------------------------------------------

describe("ModelServerSelect (F35)", () => {
  it("offers a 'none' option plus each declared server, and emits the id", () => {
    let picked: string | undefined = "sentinel";
    render(
      <ModelServerSelect
        servers={[SERVER]}
        value={undefined}
        onChange={(id) => (picked = id)}
      />,
    );
    const select = screen.getByLabelText("local model server") as HTMLSelectElement;
    expect(select.value).toBe("");
    fireEvent.change(select, { target: { value: SERVER.id } });
    expect(picked).toBe(SERVER.id);
  });

  it("emits undefined when 'none' is chosen", () => {
    let picked: string | undefined = SERVER.id;
    render(
      <ModelServerSelect
        servers={[SERVER]}
        value={SERVER.id}
        onChange={(id) => (picked = id)}
      />,
    );
    fireEvent.change(screen.getByLabelText("local model server"), {
      target: { value: "" },
    });
    expect(picked).toBeUndefined();
  });

  it("keeps a removed/unknown bound id visible rather than dropping it", () => {
    render(
      <ModelServerSelect servers={[]} value="ghost-id" onChange={() => {}} />,
    );
    expect(screen.getByText(/ghost-id \(unknown \/ removed\)/)).toBeTruthy();
  });
});
