/**
 * @file AddLaneModal.test.tsx
 * @description Pins the "+ Add lane" flow's two modes: "Worktree" (default -
 * pick a source repo, fork a branch, type a new branch name, submit through
 * the provisioning endpoint) and "Repo" (adopt a directory as-is through
 * `ensure`, no branch fields). Also covers the branch-lookup debounce, the
 * unresolvable-path degrade, server-error handling, the auto-setup summary,
 * the provisioning-wait race, and the folder-browse modal.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { CwdSuggestion } from "../../../lib/api";
import type { Lane } from "../../../lib/types";

vi.mock("../../../lib/api", () => ({
  api: {
    lanes: {
      branches: vi.fn(),
      pipelines: vi.fn(),
      worktree: vi.fn(),
      ensure: vi.fn(),
      browse: vi.fn(),
      get: vi.fn(),
      profileInit: vi.fn(),
      agentsInstall: vi.fn(),
      mcpSync: vi.fn(),
    },
  },
}));

function laneFixture(over: Partial<Lane> = {}): Lane {
  // `as Lane`: spreading a Partial<Lane> widens every field it may carry to
  // `T | undefined`, which no longer satisfies Lane's required fields. The
  // base object below still lists all of them, so the cast asserts what the
  // literal already proves.
  return {
    id: 9,
    title: "",
    cwd: "/lanes/repo__feature",
    branch: "feat/feature",
    kind: "managed",
    source_repo: null,
    pipeline: "default",
    session_id: null,
    run_id: null,
    stage: "idle",
    stage_since: null,
    status: "provisioning",
    gate_decision: null,
    ci_status: null,
    needs_action: null,
    links: {},
    stages: {},
    notes: null,
    pipeline_name: "Default",
    pipeline_nodes: [],
    progress: 0,
    stage_seconds: null,
    last_event_seconds: null,
    liveness: "idle",
    detected_stage: null,
    detected_signal: null,
    slot: null,
    ports: {},
    ...over,
  } as Lane;
}

const SUGGESTIONS: CwdSuggestion[] = [
  { kind: "home", path: "/Users/tester", label: "Home" },
  { kind: "recent", path: "/Users/tester/projects/repo", label: "repo" },
];

function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
  return render(
    <AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={SUGGESTIONS} {...over} />
  );
}

/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
 *  races userEvent.type() and can eat the first keystroke. Let that timer fire,
 *  then click the field to reclaim focus before typing. */
async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElement) {
  await new Promise((r) => setTimeout(r, 0));
  await user.click(el);
}

/** Fills the default "Worktree" mode's form up through a resolved branch
 *  list, title, and new-branch name - everything Add lane needs to enable. */
async function fillWorktreeForm(
  user: ReturnType<typeof userEvent.setup>,
  { repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {}
) {
  const repoField = screen.getByLabelText("Source repository");
  await focusField(user, repoField);
  await user.type(repoField, repo);
  await screen.findByLabelText("Branch to fork from");
  await user.type(screen.getByLabelText("Title"), title);
  await user.type(screen.getByLabelText("New branch name"), branch);
}

beforeEach(() => {
  vi.mocked(api.lanes.branches).mockReset();
  vi.mocked(api.lanes.pipelines)
    .mockReset()
    .mockResolvedValue({
      pipelines: [
        { id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
        {
          id: "ship-feature",
          name: "Ship feature (lane pipeline)",
          nodes: new Array(16).fill({ id: "n" }),
        },
      ],
    });
  vi.mocked(api.lanes.worktree).mockReset();
  vi.mocked(api.lanes.ensure).mockReset();
  vi.mocked(api.lanes.browse).mockReset();
  // Provisioning finishes instantly by default - tests that care about the
  // provisioning-in-progress race override this per-test.
  vi.mocked(api.lanes.get)
    .mockReset()
    .mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
  vi.mocked(api.lanes.profileInit)
    .mockReset()
    .mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
  vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
  vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
});

describe("AddLaneModal — worktree mode (default)", () => {
  it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
    renderModal();
    expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
  });

  it("looks up branches once the repo path settles, and shows them as a picker", async () => {
    vi.mocked(api.lanes.branches).mockResolvedValue({
      branches: ["main", "feat/other"],
      current: "main",
    });
    renderModal();
    const user = userEvent.setup();

    const repoField = screen.getByLabelText("Source repository");
    await focusField(user, repoField);
    await user.type(repoField, "/Users/tester/projects/repo");

    await waitFor(() =>
      expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo")
    );
    const base = await screen.findByLabelText("Branch to fork from");
    expect(base).toHaveValue("main"); // the repo's current branch is preselected
    expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
  });

  it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => {
    vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
    renderModal();
    const user = userEvent.setup();

    const repoField = screen.getByLabelText("Source repository");
    await focusField(user, repoField);
    await user.type(repoField, "/not/a/repo");

    await waitFor(() => expect(api.lanes.branches).toHaveBeenCalled());
    expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument();
    expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
    expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
  });

  it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
    vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
    vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
    const onAdded = vi.fn();
    const onClose = vi.fn();
    renderModal({ onClose, onAdded });
    const user = userEvent.setup();

    await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
    await user.click(screen.getByRole("button", { name: "Add lane" }));

    await waitFor(() => {
      expect(api.lanes.worktree).toHaveBeenCalledWith({
        sourceRepo: "/Users/tester/projects/repo",
        title: "New feature",
        base: "main",
        branch: "feat/new-feature",
        pipeline: "default",
      });
    });
    expect(onAdded).toHaveBeenCalledWith(
      expect.objectContaining({ id: 9, status: "provisioning" })
    );
    // The modal stays open showing the setup summary until dismissed - it
    // does not close itself just because the lane was added.
    expect(onClose).not.toHaveBeenCalled();
  });

  it("shows a server error and leaves the modal open instead of closing silently", async () => {
    vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
    vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
    const onClose = vi.fn();
    renderModal({ onClose });
    const user = userEvent.setup();

    await fillWorktreeForm(user, { title: "New feature" });
    await user.click(screen.getByRole("button", { name: "Add lane" }));

    expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
    expect(onClose).not.toHaveBeenCalled();
  });

  it("renders nothing when closed", () => {
    renderModal({ open: false });
    expect(screen.queryByRole("dialog")).toBeNull();
  });

  it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
    vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
    vi.mocked(api.lanes.worktree).mockResolvedValue({
      lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
    });
    const onAdded = vi.fn();
    renderModal({ onAdded });
    const user = userEvent.setup();

    await fillWorktreeForm(user);
    await user.click(screen.getByRole("button", { name: "Add lane" }));

    await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
    await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
    expect(api.lanes.agentsInstall).toHaveBeenCalledWith(42);
    expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
    await waitFor(() => expect(onAdded).toHaveBeenCalled());
  });

  it("still calls onAdded even when every setup call fails", async () => {
    vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
    vi.mocked(api.lanes.worktree).mockResolvedValue({
      lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
    });
    vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
    vi.mocked(api.lanes.agentsInstall).mockRejectedValue(new Error("boom"));
    vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
    const onAdded = vi.fn();
    const onClose = vi.fn();
    renderModal({ onAdded, onClose });
    const user = userEvent.setup();

    await fillWorktreeForm(user, { title: "demo2" });
    await user.click(screen.getByRole("button", { name: "Add lane" }));

    await waitFor(() => expect(onAdded).toHaveBeenCalled());
    expect(onClose).not.toHaveBeenCalled();
  });

  it("shows the setup summary and lets the user dismiss it manually", async () => {
    vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
    vi.mocked(api.lanes.worktree).mockResolvedValue({
      lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
    });
    const onClose = vi.fn();
    renderModal({ onClose });
    const user = userEvent.setup();

    await fillWorktreeForm(user, { title: "demo3" });
    await user.click(screen.getByRole("button", { name: "Add lane" }));

    expect(await screen.findByText("Setup")).toBeInTheDocument();
    expect(onClose).not.toHaveBeenCalled();

    const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" });
    if (!dismissButton) throw new Error("Cancel button not found");
    await user.click(dismissButton);
    expect(onClose).toHaveBeenCalled();
  });

  it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
    vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
    vi.mocked(api.lanes.worktree).mockResolvedValue({
      lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
    });
    // First poll still provisioning, second poll reports the background
    // `git worktree add` failed.
    vi.mocked(api.lanes.get)
      .mockReset()
      .mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
      .mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
    const onAdded = vi.fn();
    renderModal({ onAdded });
    const user = userEvent.setup();

    await fillWorktreeForm(user, { title: "demo4" });
    await user.click(screen.getByRole("button", { name: "Add lane" }));

    await waitFor(() => expect(onAdded).toHaveBeenCalled());
    expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
    expect(api.lanes.profileInit).not.toHaveBeenCalled();
    expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
    expect(api.lanes.mcpSync).not.toHaveBeenCalled();
  });
});

describe("AddLaneModal — repo mode (adopt)", () => {
  it("hides the branch fields and enables confirm on a path alone", async () => {
    renderModal();
    const user = userEvent.setup();
    await user.click(screen.getByRole("button", { name: "Repo" }));

    expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
    const dirField = screen.getByLabelText("Directory");
    await focusField(user, dirField);
    await user.type(dirField, "/Users/tester/projects/repo");

    expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
    expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
    expect(screen.queryByLabelText("New branch name")).toBeNull();
    expect(api.lanes.branches).not.toHaveBeenCalled();
  });

  it("submits through ensure, not worktree, and closes immediately", async () => {
    vi.mocked(api.lanes.ensure).mockResolvedValue({
      lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
      created: true,
    });
    const onAdded = vi.fn();
    const onClose = vi.fn();
    renderModal({ onAdded, onClose });
    const user = userEvent.setup();

    await user.click(screen.getByRole("button", { name: "Repo" }));
    const dirField = screen.getByLabelText("Directory");
    await focusField(user, dirField);
    await user.type(dirField, "/Users/tester/projects/repo");
    await user.type(screen.getByLabelText("Title"), "main repo");
    await user.click(screen.getByRole("button", { name: "Add lane" }));

    await waitFor(() =>
      expect(api.lanes.ensure).toHaveBeenCalledWith({
        cwd: "/Users/tester/projects/repo",
        title: "main repo",
        pipeline: "default",
      })
    );
    expect(api.lanes.worktree).not.toHaveBeenCalled();
    await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 })));
    // Repo mode never runs the profile/agents/mcp setup summary - it should
    // close right away like the old adopt flow did.
    expect(onClose).toHaveBeenCalled();
  });
});

describe("AddLaneModal — folder browse", () => {
  it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
    vi.mocked(api.lanes.browse).mockResolvedValue({
      path: "/Users/tester",
      parent: "/Users",
      entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }],
    });
    renderModal();
    const user = userEvent.setup();

    await user.click(screen.getByTitle("Browse for a folder"));
    expect(await screen.findByText("projects")).toBeInTheDocument();

    await user.click(screen.getByText("projects"));
    expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
  });

  it("Escape closes only the folder browser, not the whole modal", async () => {
    vi.mocked(api.lanes.browse).mockResolvedValue({
      path: "/Users/tester",
      parent: null,
      entries: [],
    });
    const onClose = vi.fn();
    renderModal({ onClose });
    const user = userEvent.setup();

    await user.click(screen.getByTitle("Browse for a folder"));
    await screen.findByRole("dialog", { name: "Browse for a folder" });

    await user.keyboard("{Escape}");

    expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
    expect(
      screen.getByRole("dialog", { name: "Create a lane from a working directory" })
    ).toBeInTheDocument();
    expect(onClose).not.toHaveBeenCalled();
  });
});

describe("AddLaneModal — pipeline template", () => {
  it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
    // Creation is the ONLY point the UI can set a template, so a lane born on
    // `default` renders 8 nodes for a 16-node workflow with no way back from
    // any screen.
    vi.mocked(api.lanes.ensure).mockResolvedValue({
      lane: laneFixture({ status: "idle" }),
      created: true,
    });
    renderModal();
    const user = userEvent.setup();

    await user.click(screen.getByRole("button", { name: "Repo" }));
    const select = await screen.findByLabelText("Pipeline template");
    await waitFor(() =>
      expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
    );
    expect(
      screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
    ).toBeInTheDocument();

    const repoField = screen.getByLabelText("Directory");
    await focusField(user, repoField);
    await user.type(repoField, "/Users/tester/projects/repo");
    await user.selectOptions(select, "ship-feature");
    await user.click(screen.getByRole("button", { name: "Add lane" }));

    await waitFor(() =>
      expect(api.lanes.ensure).toHaveBeenCalledWith(
        expect.objectContaining({ pipeline: "ship-feature" })
      )
    );
  });

  it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
    vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
    renderModal();

    const select = await screen.findByLabelText("Pipeline template");
    await waitFor(() => expect(select).toHaveValue("default"));
    expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
  });
});
