/**
 * @file Regression test for the lane card's status badge. Every lane status
 * the server can set must have a real translated word behind
 * `t("status." + lane.status)`; before this test existed, no locale defined
 * any `status.*` key, and i18next's default missing-key behavior (return the
 * key itself) hid that from the `||` fallback, so the badge showed literal
 * text like `status.active`.
 * @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 LaneCard from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types";
import { api } from "../../../lib/api";

vi.mock("../../../lib/api", () => ({
  api: {
    lanes: {
      git: vi.fn(),
      runtime: vi.fn(),
      up: vi.fn(),
      down: vi.fn(),
      preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
      integration: vi.fn(),
      agentsInstall: vi.fn(),
      mcpSync: vi.fn(),
      syncBaseCheck: vi.fn(),
    },
    locks: {
      list: vi.fn(),
    },
  },
}));

beforeEach(() => {
  vi.mocked(api.lanes.git).mockReset();
  vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
  // A lane with no profile is the default fixture: most lanes never run a
  // stack, so the runtime strip and its button stay absent unless a test opts in.
  vi.mocked(api.lanes.runtime).mockReset();
  vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
  vi.mocked(api.lanes.integration).mockReset();
  vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
});
vi.mocked(api.locks.list).mockReset();
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });

function makeLane(overrides: 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: 1,
    title: "demo",
    cwd: "/work/demo",
    branch: "lane/demo",
    kind: "adopted",
    source_repo: null,
    pipeline: "default",
    session_id: null,
    run_id: null,
    stage: "plan",
    stage_since: null,
    status: "idle",
    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: {},
    ...overrides,
  } as Lane;
}

describe("LaneCard status badge", () => {
  for (const status of ["idle", "running", "provisioning", "failed"] as const) {
    it(`renders a real word for status "${status}", not the raw key`, () => {
      render(<LaneCard lane={makeLane({ status })} onAction={vi.fn()} />);
      expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument();
      expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument();
    });
  }
});

describe("LaneCard child worktrees", () => {
  it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
    const onSelectLane = vi.fn();
    const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
    render(
      <LaneCard
        lane={makeLane({ id: 1 })}
        onAction={vi.fn()}
        childWorktrees={[worktree]}
        onSelectLane={onSelectLane}
      />
    );

    expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
    const user = userEvent.setup();
    await user.click(screen.getByRole("button", { name: /Worktree A/ }));
    expect(onSelectLane).toHaveBeenCalledWith(8);
  });

  it("renders nothing when there are no child worktrees", () => {
    render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
    expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
  });
});

const pipelineNodes: Lane["pipeline_nodes"] = [
  { id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
  { id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
  { id: "implement", label: "implement", icon: "🛠", gate: false, state: "current" },
  { id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
];

describe("LaneCard rebuilt layout", () => {
  const full = (over: Partial<Lane> = {}) =>
    makeLane({
      id: 7,
      title: "Rename Metric to Rule",
      kind: "managed",
      status: "running",
      liveness: "active",
      stage: "plan",
      stage_seconds: 152,
      progress: 48,
      ci_status: "green",
      pipeline_nodes: pipelineNodes,
      ...over,
    });

  it("labels the card with the lane id", () => {
    render(<LaneCard lane={full()} onAction={vi.fn()} />);
    expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
  });

  it("shows the progress percentage and the time on stage", () => {
    render(<LaneCard lane={full()} onAction={vi.fn()} />);
    expect(screen.getByText("48%")).toBeInTheDocument();
    expect(screen.getByText("2m 32s")).toBeInTheDocument();
  });

  it("gives the progress bar a width matching the lane's progress", () => {
    render(<LaneCard lane={full()} onAction={vi.fn()} />);
    expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
  });

  it("surfaces a needs-you message", () => {
    render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
    expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
  });

  it("fires a plain action with its own name", async () => {
    const onAction = vi.fn();
    render(<LaneCard lane={full()} onAction={onAction} />);
    await userEvent.setup().click(screen.getByTestId("lane-action-stop"));
    expect(onAction).toHaveBeenCalledWith("stop");
  });

  it("shows both deletions as buttons, and keeps reset behind the menu", async () => {
    render(<LaneCard lane={full()} onAction={vi.fn()} />);
    // Deleting the lane and deleting its history are the two the user goes
    // looking for, so they are visible without opening anything. Reset is not.
    expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
    expect(screen.getByTestId("lane-action-purge")).toBeInTheDocument();
    expect(screen.queryByTestId("lane-action-reset")).toBeNull();

    await userEvent.setup().click(screen.getByTestId("lane-more"));
    expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
  });

  it("routes both deletions through the modal rather than firing them", async () => {
    const onAction = vi.fn();
    render(<LaneCard lane={full()} onAction={onAction} />);
    const user = userEvent.setup();
    await user.click(screen.getByTestId("lane-action-remove"));
    expect(onAction).not.toHaveBeenCalled();
    await user.click(screen.getByTestId("lane-action-purge"));
    expect(onAction).not.toHaveBeenCalled();
  });

  it("routes reset through the confirmation modal rather than firing it", async () => {
    const onAction = vi.fn();
    render(<LaneCard lane={full()} onAction={onAction} />);
    const user = userEvent.setup();
    await user.click(screen.getByTestId("lane-more"));
    await user.click(screen.getByTestId("lane-action-reset"));
    expect(onAction).not.toHaveBeenCalled();
  });

  it("offers reset only for a managed lane, and drops the menu entirely without it", async () => {
    const user = userEvent.setup();
    const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
    await user.click(screen.getByTestId("lane-more"));
    expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
    unmount();

    // An adopted lane has nothing left in the menu, so there is no ⋯ to open.
    render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
    expect(screen.queryByTestId("lane-more")).toBeNull();
    expect(screen.queryByTestId("lane-action-reset")).toBeNull();
    expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
  });
});

describe("LaneCard git block", () => {
  const facts = {
    available: true as const,
    branch: "feat/rename-metric",
    head: "9b3e74a",
    subject: "free-text rule mode in the form",
    dirty: 2,
    untracked: 1,
  };

  it("renders the live branch, head, subject and uncommitted counts", async () => {
    vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
    render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);

    const block = await screen.findByTestId("lane-git");
    expect(block.textContent).toContain("feat/rename-metric");
    expect(block.textContent).toContain("9b3e74a");
    expect(block.textContent).toContain("free-text rule mode in the form");
    expect(block.textContent).toContain("2");
    // The live branch replaces the lane's recorded one rather than doubling it.
    expect(screen.queryByText(/stale\/recorded/)).toBeNull();
  });

  it("omits the uncommitted line when the tree is clean", async () => {
    vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
    const block = await screen.findByTestId("lane-git");
    expect(block.textContent).not.toContain("modified");
  });

  it("renders the card with no git block and no error when git is unavailable", async () => {
    vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
    render(<LaneCard lane={makeLane({ title: "plain dir lane" })} onAction={vi.fn()} />);

    expect(await screen.findByText("plain dir lane")).toBeInTheDocument();
    expect(screen.queryByTestId("lane-git")).toBeNull();
  });

  it("swallows a rejected request instead of surfacing an error", async () => {
    vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
    render(<LaneCard lane={makeLane({ title: "offline lane" })} onAction={vi.fn()} />);

    expect(await screen.findByText("offline lane")).toBeInTheDocument();
    expect(screen.queryByTestId("lane-git")).toBeNull();
    expect(screen.queryByText(/network down/)).toBeNull();
  });

  it("stops polling once the card unmounts", async () => {
    vi.useFakeTimers();
    try {
      vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
      const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
      expect(api.lanes.git).toHaveBeenCalledTimes(1);
      unmount();
      vi.advanceTimersByTime(120_000);
      expect(api.lanes.git).toHaveBeenCalledTimes(1);
    } finally {
      vi.useRealTimers();
    }
  });
});

describe("LaneCard — the lane's own application stack", () => {
  const upRuntime = {
    available: true as const,
    provisioned: true as const,
    slot: 3,
    kind: "managed" as const,
    hooks: ["boot", "health"],
    profileDir: "/work/demo/.ccam/profile",
    services: [{ name: "web", pid: 4242, alive: true }],
    ports: { api: { port: 8003, expected: 8003, listening: true } },
    steppedAside: false,
    up: true,
    healthy: true,
    logs: ["boot.log"],
    logDir: "/lanes/.state/lane3/logs",
    lastError: null,
  };

  it("shows nothing at all for a lane whose repo declares no profile", async () => {
    vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
    render(<LaneCard lane={makeLane({ title: "no profile" })} onAction={vi.fn()} />);

    expect(await screen.findByText("no profile")).toBeInTheDocument();
    expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
    expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull();
  });

  it("lists each declared port with its listening state", async () => {
    vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);

    expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument();
    expect(screen.getByText(":8003")).toBeInTheDocument();
    expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i);
  });

  it("flags a port that stepped aside from its base, showing the expected number", async () => {
    vi.mocked(api.lanes.runtime).mockResolvedValue({
      ...upRuntime,
      steppedAside: true,
      ports: { api: { port: 8103, expected: 8003, listening: true } },
    });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);

    expect(await screen.findByText(":8103")).toBeInTheDocument();
    expect(screen.getByText(/8003/)).toBeInTheDocument();
  });

  it("stops the stack through /down, never through the run's onAction prop", async () => {
    vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
    vi.mocked(api.lanes.down).mockResolvedValue({
      ok: true,
      killed: [4242],
      runtime: { available: false },
    });
    const onAction = vi.fn();
    render(<LaneCard lane={makeLane()} onAction={onAction} />);

    await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));

    await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1));
    expect(api.lanes.up).not.toHaveBeenCalled();
    expect(onAction).not.toHaveBeenCalled();
  });

  it("boots a provisioned-but-down lane and stays busy past the 202", async () => {
    vi.mocked(api.lanes.runtime).mockResolvedValue({
      ...upRuntime,
      services: [{ name: "web", pid: 4242, alive: false }],
      ports: { api: { port: 8003, expected: 8003, listening: false } },
      up: false,
      healthy: false,
    });
    vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);

    await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));

    await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1));
    // The request resolving is not the stack being up: the server answered 202
    // and is still booting, so the button must not go idle yet.
    await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled());
  });

  it("surfaces the last boot error when nothing is streaming", async () => {
    vi.mocked(api.lanes.runtime).mockResolvedValue({
      ...upRuntime,
      up: false,
      healthy: false,
      lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" },
    });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);

    expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
    expect(screen.getByText(/health check failed/)).toBeInTheDocument();
  });
});

describe("LaneCard — named locks", () => {
  it("shows a lock badge when this lane holds a named lock", async () => {
    vi.mocked(api.locks.list).mockResolvedValue({
      locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
    });
    render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);

    expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
    expect(screen.getByText(/lock/i)).toBeInTheDocument();
  });

  it("shows no lock badge when locks belong to a different lane", async () => {
    vi.mocked(api.locks.list).mockResolvedValue({
      locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
    });
    render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);

    expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
  });
});

describe("agents install / mcp sync / integration badges / sync check", () => {
  beforeEach(() => {
    // Only the fields this describe block's assertions read; `as LaneRuntime`
    // keeps the double from having to restate a shape the component never
    // touches here.
    vi.mocked(api.lanes.runtime).mockResolvedValue({
      available: true as const,
      provisioned: true as const,
      up: false,
      slot: 1,
      profileDir: "/work/demo/.ccam/profile",
      ports: {},
    } as unknown as LaneRuntime);
    vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
      Promise.resolve({ enabled: name === "tracker" })
    );
  });

  it("shows integration badges reflecting each toggle's state", async () => {
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
    const tracker = await screen.findByTestId("lane-integration-tracker");
    const devQc = await screen.findByTestId("lane-integration-dev_qc");
    expect(tracker.className).toContain("status-success");
    expect(devQc.className).not.toContain("status-success");
  });

  it("clicking Install agents calls the API and shows the result", async () => {
    vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
      installed: ["qc-local.md", "senior-gate-reviewer.md"],
    });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
    const button = await screen.findByTestId("lane-agents-install");
    await userEvent.click(button);
    await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
    expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
  });

  it("clicking Sync MCP calls the API and shows the result", async () => {
    vi.mocked(api.lanes.mcpSync).mockResolvedValue({
      servers: ["playwright"],
      profilesSeeded: [],
    });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
    const button = await screen.findByTestId("lane-mcp-sync");
    await userEvent.click(button);
    await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
    expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
  });

  it("clicking Check dev sync reports a clean result", async () => {
    vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
      code: 0,
      devDelta: ["a.txt", "b.txt"],
      overlap: [],
    });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
    const button = await screen.findByTestId("lane-sync-check");
    await userEvent.click(button);
    await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
    expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
  });

  it("clicking Check dev sync reports a migration collision", async () => {
    vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
      code: 5,
      collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
    });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
    const button = await screen.findByTestId("lane-sync-check");
    await userEvent.click(button);
    expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
  });

  it("hides all four additions when the lane has no profile", async () => {
    vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
    render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
    await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
    expect(screen.queryByTestId("lane-agents-install")).toBeNull();
    expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
    expect(screen.queryByTestId("lane-sync-check")).toBeNull();
    expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
  });
});
