// @vitest-environment jsdom
/**
 * Tests for useTemplateDeploy — the shared preflight + POST + modal
 * hook used by TemplatePalette (sidebar) and EmptyState (welcome grid).
 *
 * Behavioural coverage for the three flows the hook owns:
 *   1. Happy path: preflight ok → POST /workspaces → onDeployed fires
 *   2. Preflight errors: network throw vs not-ok-with-missing-keys
 *      (different code paths — the throw must NOT strand `deploying`,
 *      see the inline comment in the SUT for the prior bug)
 *   3. Modal lifecycle: keys-added retries POST without re-running
 *      preflight; cancel closes without POST
 *
 * Issue: #2071 (Canvas test gaps follow-up).
 */
import {
  describe,
  it,
  expect,
  vi,
  beforeEach,
  afterEach,
  type Mock,
} from "vitest";
import { act, render, cleanup, screen, fireEvent } from "@testing-library/react";
import { renderHook } from "@testing-library/react";
import type { Template } from "@/lib/deploy-preflight";

// ── Hoisted mocks ────────────────────────────────────────────────────────────
const { mockApiPost, mockApiGet, mockCheckDeploySecrets, mockResolveRuntime } =
  vi.hoisted(() => ({
    mockApiPost: vi.fn(),
    mockApiGet: vi.fn(),
    mockCheckDeploySecrets: vi.fn(),
    mockResolveRuntime: vi.fn(),
  }));

vi.mock("@/lib/api", () => ({
  api: { post: mockApiPost, get: mockApiGet },
}));

vi.mock("@/lib/deploy-preflight", async () => {
  // Re-export the real types; only swap the runtime functions.
  const actual = await vi.importActual<
    typeof import("@/lib/deploy-preflight")
  >("@/lib/deploy-preflight");
  return {
    ...actual,
    checkDeploySecrets: mockCheckDeploySecrets,
    resolveRuntime: mockResolveRuntime,
  };
});

// MissingKeysModal: render a minimal stand-in that exposes the
// callbacks the hook wires up + dumps the new template-deploy props
// (configuredKeys size, modelSuggestions, initialModel) into the
// DOM so tests can assert on them. The real modal pulls in radix +
// the secrets store, neither of which is relevant to this hook's
// behavior.
vi.mock("@/components/MissingKeysModal", () => ({
  MissingKeysModal: (props: {
    open: boolean;
    onKeysAdded: (model?: string) => void;
    onCancel: () => void;
    configuredKeys?: Set<string>;
    modelSuggestions?: string[];
    initialModel?: string;
    title?: string;
  }) =>
    props.open ? (
      <div data-testid="missing-keys-modal">
        <span data-testid="modal-configured-size">
          {props.configuredKeys?.size ?? 0}
        </span>
        <span data-testid="modal-model-suggestions">
          {(props.modelSuggestions ?? []).join(",")}
        </span>
        <span data-testid="modal-initial-model">{props.initialModel ?? ""}</span>
        <span data-testid="modal-title">{props.title ?? ""}</span>
        <button
          data-testid="modal-keys-added"
          onClick={() => props.onKeysAdded()}
        >
          keys added
        </button>
        <button
          data-testid="modal-keys-added-with-model"
          onClick={() => props.onKeysAdded("minimax/MiniMax-M2.7")}
        >
          keys added with model
        </button>
        <button data-testid="modal-cancel" onClick={props.onCancel}>
          cancel
        </button>
      </div>
    ) : null,
}));

// Import the hook AFTER the mocks are declared.
import { useTemplateDeploy } from "../useTemplateDeploy";

// ── Helpers ──────────────────────────────────────────────────────────────────

function makeTemplate(over: Partial<Template> = {}): Template {
  return {
    id: "claude-code-default",
    name: "Claude Code",
    description: "",
    tier: 1,
    model: "claude-sonnet-4-5",
    skills: [],
    skill_count: 0,
    runtime: "claude-code",
    models: [],
    required_env: [],
    ...over,
  };
}

beforeEach(() => {
  mockApiPost.mockReset();
  mockApiGet.mockReset();
  mockCheckDeploySecrets.mockReset();
  mockResolveRuntime.mockReset();
  // Default: identity-mapped runtime, preflight passes.
  mockResolveRuntime.mockImplementation((id: string) => id);
  mockCheckDeploySecrets.mockResolvedValue({
    ok: true,
    missingKeys: [],
    providers: [],
    runtime: "claude-code",
    configuredKeys: new Set(),
  });
  mockApiPost.mockResolvedValue({ id: "ws-new" });
  // Default: secrets endpoint returns nothing so the picker
  // renders every entry as input. Multi-provider tests override.
  mockApiGet.mockResolvedValue([]);
});

afterEach(() => {
  cleanup();
});

// ── Tests ────────────────────────────────────────────────────────────────────

/**
 * Drive the always-show-picker flow to completion: deploy() opens the
 * modal, then we click "keys added" to fire the actual POST. Centralised
 * here because as of the always-prompt change, every happy-path test
 * must click through the modal before asserting on POST.
 */
async function deployThroughPicker<T>(
  result: { current: ReturnType<typeof useTemplateDeploy> },
  rerender: () => void,
  template: Template,
): Promise<void> {
  await act(async () => {
    await result.current.deploy(template);
  });
  rerender();
  render(<>{result.current.modal}</>);
  await act(async () => {
    fireEvent.click(screen.getByTestId("modal-keys-added"));
    // Let the fire-and-forget executeDeploy resolve.
    await Promise.resolve();
    await Promise.resolve();
  });
}

describe("useTemplateDeploy — happy path", () => {
  it("preflight ok → modal opens → keys-added → POST /workspaces → onDeployed fires", async () => {
    const onDeployed = vi.fn();
    const { result, rerender } = renderHook(() =>
      useTemplateDeploy({ onDeployed }),
    );

    await deployThroughPicker(result, rerender, makeTemplate());

    expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);
    expect(mockApiPost).toHaveBeenCalledWith(
      "/workspaces",
      expect.objectContaining({
        name: "Claude Code",
        template: "claude-code-default",
        tier: 1,
      }),
    );
    expect(onDeployed).toHaveBeenCalledWith("ws-new");
    expect(result.current.deploying).toBeNull();
    expect(result.current.error).toBeNull();
  });

  it("uses caller-supplied canvasCoords when provided", async () => {
    const canvasCoords = vi.fn(() => ({ x: 42, y: 99 }));
    const { result, rerender } = renderHook(() =>
      useTemplateDeploy({ canvasCoords }),
    );

    await deployThroughPicker(result, rerender, makeTemplate());

    expect(canvasCoords).toHaveBeenCalledTimes(1);
    expect(mockApiPost).toHaveBeenCalledWith(
      "/workspaces",
      expect.objectContaining({ canvas: { x: 42, y: 99 } }),
    );
  });

  it("falls back to random coords inside [100,500] × [100,400] when canvasCoords omitted", async () => {
    const { result, rerender } = renderHook(() => useTemplateDeploy());

    await deployThroughPicker(result, rerender, makeTemplate());

    const body = (mockApiPost as Mock).mock.calls[0]?.[1] as {
      canvas: { x: number; y: number };
    };
    expect(body.canvas.x).toBeGreaterThanOrEqual(100);
    expect(body.canvas.x).toBeLessThan(500);
    expect(body.canvas.y).toBeGreaterThanOrEqual(100);
    expect(body.canvas.y).toBeLessThan(400);
  });

  it("prefers template.runtime over resolveRuntime fallback", async () => {
    const { result } = renderHook(() => useTemplateDeploy());

    await act(async () => {
      await result.current.deploy(
        makeTemplate({ runtime: "hermes", id: "some-id" }),
      );
    });

    expect(mockResolveRuntime).not.toHaveBeenCalled();
    expect(mockCheckDeploySecrets).toHaveBeenCalledWith(
      expect.objectContaining({ runtime: "hermes" }),
    );
  });
});

describe("useTemplateDeploy — preflight failure modes", () => {
  it("preflight throw sets error and clears deploying (no stranded button)", async () => {
    mockCheckDeploySecrets.mockRejectedValueOnce(new Error("network down"));
    const { result } = renderHook(() => useTemplateDeploy());

    await act(async () => {
      await result.current.deploy(makeTemplate());
    });

    expect(result.current.error).toBe("network down");
    expect(result.current.deploying).toBeNull();
    expect(mockApiPost).not.toHaveBeenCalled();
  });

  it("preflight not-ok opens the modal without firing POST", async () => {
    mockCheckDeploySecrets.mockResolvedValueOnce({
      ok: false,
      missingKeys: ["ANTHROPIC_API_KEY"],
      providers: [],
      runtime: "claude-code",
      configuredKeys: new Set(),
    });
    const onDeployed = vi.fn();

    const { result, rerender } = renderHook(() =>
      useTemplateDeploy({ onDeployed }),
    );

    await act(async () => {
      await result.current.deploy(makeTemplate());
    });

    rerender();
    render(<>{result.current.modal}</>);
    expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
    expect(mockApiPost).not.toHaveBeenCalled();
    expect(onDeployed).not.toHaveBeenCalled();
    expect(result.current.deploying).toBeNull();
  });
});

describe("useTemplateDeploy — modal lifecycle", () => {
  it("'keys added' retries POST without re-running preflight", async () => {
    mockCheckDeploySecrets.mockResolvedValueOnce({
      ok: false,
      missingKeys: ["ANTHROPIC_API_KEY"],
      providers: [],
      runtime: "claude-code",
      configuredKeys: new Set(),
    });
    const onDeployed = vi.fn();
    const { result, rerender } = renderHook(() =>
      useTemplateDeploy({ onDeployed }),
    );

    await act(async () => {
      await result.current.deploy(makeTemplate());
    });
    expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);

    rerender();
    render(<>{result.current.modal}</>);

    // Click "keys added" — the hook should retry via executeDeploy
    // (which does NOT call preflight again).
    await act(async () => {
      fireEvent.click(screen.getByTestId("modal-keys-added"));
      // Let the fire-and-forget executeDeploy promise resolve.
      await Promise.resolve();
      await Promise.resolve();
    });

    expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1); // still 1, not 2
    expect(mockApiPost).toHaveBeenCalledTimes(1);
    expect(onDeployed).toHaveBeenCalledWith("ws-new");
  });

  it("'cancel' closes the modal without firing POST", async () => {
    mockCheckDeploySecrets.mockResolvedValueOnce({
      ok: false,
      missingKeys: ["ANTHROPIC_API_KEY"],
      providers: [],
      runtime: "claude-code",
      configuredKeys: new Set(),
    });
    const { result, rerender } = renderHook(() => useTemplateDeploy());

    await act(async () => {
      await result.current.deploy(makeTemplate());
    });

    rerender();
    const { rerender: renderRerender } = render(<>{result.current.modal}</>);
    expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();

    await act(async () => {
      fireEvent.click(screen.getByTestId("modal-cancel"));
    });

    rerender();
    renderRerender(<>{result.current.modal}</>);
    expect(screen.queryByTestId("missing-keys-modal")).toBeNull();
    expect(mockApiPost).not.toHaveBeenCalled();
  });
});

describe("useTemplateDeploy — multi-provider always-ask flow", () => {
  // The user-reported bug: clicking a hermes template (which has
  // multiple provider options) deployed silently when global env
  // covered the API key, producing "No LLM provider configured" 500
  // because the workspace booted with no explicit model. Fix:
  // always open the picker for multi-provider templates so the
  // user picks provider + model per workspace, even when keys are
  // already saved.
  function multiProviderTemplate(): Template {
    return makeTemplate({
      id: "hermes-template",
      name: "Hermes",
      runtime: "hermes",
      model: "anthropic/claude-sonnet-4-5",
      models: [
        { id: "minimax/MiniMax-M2.7", required_env: ["MINIMAX_API_KEY"] },
        { id: "anthropic/claude-sonnet-4-5", required_env: ["ANTHROPIC_API_KEY"] },
      ],
    });
  }

  it("opens picker even when preflight.ok=true (≥2 providers)", async () => {
    mockCheckDeploySecrets.mockResolvedValueOnce({
      ok: true, // every key is in global env
      missingKeys: [],
      providers: [
        { id: "MINIMAX_API_KEY", label: "MiniMax", envVars: ["MINIMAX_API_KEY"] },
        { id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
      ],
      runtime: "hermes",
      configuredKeys: new Set(["MINIMAX_API_KEY", "ANTHROPIC_API_KEY"]),
    });
    const { result, rerender } = renderHook(() => useTemplateDeploy());

    await act(async () => {
      await result.current.deploy(multiProviderTemplate());
    });

    rerender();
    render(<>{result.current.modal}</>);

    expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
    // Both global keys flowed into the modal as `configuredKeys` so
    // entries can render as Saved without re-prompting.
    expect(screen.getByTestId("modal-configured-size").textContent).toBe("2");
    // Confirm POST has NOT fired yet — the user must explicitly
    // confirm in the picker even though preflight passed.
    expect(mockApiPost).not.toHaveBeenCalled();
    // Title shifts to "Configure Workspace" since keys aren't missing.
    expect(screen.getByTestId("modal-title").textContent).toBe(
      "Configure Workspace",
    );
  });

  it("threads template.models[].id as model suggestions + template.model as initial value", async () => {
    mockCheckDeploySecrets.mockResolvedValueOnce({
      ok: true,
      missingKeys: [],
      providers: [
        { id: "MINIMAX_API_KEY", label: "MiniMax", envVars: ["MINIMAX_API_KEY"] },
        { id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
      ],
      runtime: "hermes",
      configuredKeys: new Set(),
    });
    const { result, rerender } = renderHook(() => useTemplateDeploy());

    await act(async () => {
      await result.current.deploy(multiProviderTemplate());
    });

    rerender();
    render(<>{result.current.modal}</>);

    expect(screen.getByTestId("modal-model-suggestions").textContent).toBe(
      "minimax/MiniMax-M2.7,anthropic/claude-sonnet-4-5",
    );
    expect(screen.getByTestId("modal-initial-model").textContent).toBe(
      "anthropic/claude-sonnet-4-5",
    );
  });

  it("POST /workspaces includes model when picker confirms with one", async () => {
    mockCheckDeploySecrets.mockResolvedValueOnce({
      ok: true,
      missingKeys: [],
      providers: [
        { id: "MINIMAX_API_KEY", label: "MiniMax", envVars: ["MINIMAX_API_KEY"] },
        { id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
      ],
      runtime: "hermes",
      configuredKeys: new Set(),
    });
    const { result, rerender } = renderHook(() => useTemplateDeploy());

    await act(async () => {
      await result.current.deploy(multiProviderTemplate());
    });

    rerender();
    render(<>{result.current.modal}</>);

    await act(async () => {
      fireEvent.click(screen.getByTestId("modal-keys-added-with-model"));
      await Promise.resolve();
      await Promise.resolve();
    });

    expect(mockApiPost).toHaveBeenCalledWith(
      "/workspaces",
      expect.objectContaining({
        template: "hermes-template",
        model: "minimax/MiniMax-M2.7",
      }),
    );
  });

  it("single-provider template ALSO opens picker when preflight.ok (always-prompt rule)", async () => {
    // Default preflight mock: ok=true, providers=[]. claude-code is
    // single-provider, but the always-prompt rule means the user must
    // still click through the picker to confirm provider+model — even
    // when keys are saved and the runtime has only one provider option.
    // Reason: the user needs an explicit chance to override the
    // template's default model (e.g. opus vs sonnet vs haiku) before
    // an EC2 boots and burns billing on the wrong tier.
    const onDeployed = vi.fn();
    const { result, rerender } = renderHook(() =>
      useTemplateDeploy({ onDeployed }),
    );

    await act(async () => {
      await result.current.deploy(makeTemplate());
    });

    rerender();
    render(<>{result.current.modal}</>);

    expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
    // POST does NOT fire until the user confirms in the picker.
    expect(mockApiPost).not.toHaveBeenCalled();
    expect(onDeployed).not.toHaveBeenCalled();
    expect(result.current.deploying).toBeNull();
  });

  it("empty configuredKeys (preflight defensive fallback) still opens picker", async () => {
    // checkDeploySecrets falls back to an empty Set when the
    // /settings/secrets endpoint errors — the modal must still
    // open so the user isn't blocked, just with every entry
    // rendered as input rather than Saved.
    mockCheckDeploySecrets.mockResolvedValueOnce({
      ok: true,
      missingKeys: [],
      providers: [
        { id: "MINIMAX_API_KEY", label: "MiniMax", envVars: ["MINIMAX_API_KEY"] },
        { id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
      ],
      runtime: "hermes",
      configuredKeys: new Set(),
    });
    const { result, rerender } = renderHook(() => useTemplateDeploy());

    await act(async () => {
      await result.current.deploy(multiProviderTemplate());
    });

    rerender();
    render(<>{result.current.modal}</>);

    expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
    expect(screen.getByTestId("modal-configured-size").textContent).toBe("0");
    expect(mockApiPost).not.toHaveBeenCalled();
  });
});

describe("useTemplateDeploy — POST failure", () => {
  it("POST rejection sets error and clears deploying", async () => {
    mockApiPost.mockRejectedValueOnce(new Error("server 500"));
    const onDeployed = vi.fn();
    const { result, rerender } = renderHook(() =>
      useTemplateDeploy({ onDeployed }),
    );

    await deployThroughPicker(result, rerender, makeTemplate());

    expect(result.current.error).toBe("server 500");
    expect(result.current.deploying).toBeNull();
    expect(onDeployed).not.toHaveBeenCalled();
  });

  it("non-Error rejection still surfaces a message (defensive)", async () => {
    mockApiPost.mockRejectedValueOnce("plain string");
    const { result, rerender } = renderHook(() => useTemplateDeploy());

    await deployThroughPicker(result, rerender, makeTemplate());

    expect(result.current.error).toBe("Deploy failed");
    expect(result.current.deploying).toBeNull();
  });
});
