/**
 * @file Workspace.test.tsx
 * @description Workspace page integration tests covering the lane-based run flow.
 * Verifies: lanes API contract, ensure before start flow, lane start vs /api/run,
 * and that no /stage endpoint is called.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";

// Track all API calls made by recording the method and endpoint
const recordedCalls: Array<{ method: string; path: string }> = [];

// Mutable test state. `run_id` is widened explicitly: the fixtures start with
// no run, and one case assigns run ids to drive the console.
type LaneFixture = {
  id: number;
  cwd: string;
  title: string;
  pipeline_name: string;
  pipeline_nodes: never[];
  detected_signal: string | null;
  run_id: string | null;
  active_feature_id?: number | null;
};

let lanesToReturn: LaneFixture[] = [
  {
    id: 1,
    cwd: "/workspace/a",
    title: "Lane A",
    pipeline_name: "default",
    pipeline_nodes: [],
    detected_signal: null,
    run_id: null,
  },
  {
    id: 2,
    cwd: "/workspace/b",
    title: "Lane B",
    pipeline_name: "default",
    pipeline_nodes: [],
    detected_signal: null,
    run_id: null,
  },
];
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
let nextRunId = 1;

vi.mock("../../lib/api", async (importOriginal) => {
  const actual = await importOriginal<Record<string, unknown>>();
  const recordCall = (method: string, path: string) => {
    recordedCalls.push({ method, path });
  };
  return {
    ...actual,
    api: {
      lanes: {
        list: vi.fn().mockImplementation(async () => {
          recordCall("GET", "/api/lanes");
          return { lanes: lanesToReturn, counts: countsToReturn };
        }),
        ensure: vi.fn().mockImplementation(async ({ cwd }: { cwd: string }) => {
          recordCall("POST", "/api/lanes/ensure");
          const newLane = {
            id: Math.max(...lanesToReturn.map((l) => l.id), 0) + 1,
            cwd,
            title: `Lane for ${cwd}`,
            pipeline_name: "default",
            pipeline_nodes: [],
            detected_signal: null,
            run_id: null,
          };
          lanesToReturn = [...lanesToReturn, newLane];
          return { lane: newLane, created: true };
        }),
        action: vi.fn().mockImplementation(async (id: number, action: string) => {
          recordCall("POST", `/api/lanes/${id}/${action}`);
          if (action === "start") {
            const runId = `run-${nextRunId++}`;
            const lane = lanesToReturn.find((l) => l.id === id);
            if (lane) {
              lane.run_id = runId;
            }
            return { lane: { ...lane, run_id: runId } };
          }
          return { ok: true };
        }),
        // LaneCard reads its own working-copy facts per card. Nothing here
        // asserts on them, so report the "not a git repo" shape.
        git: vi.fn().mockImplementation(async (id: number) => {
          recordCall("GET", `/api/lanes/${id}/git`);
          return { available: false };
        }),
        // Same for the lane's own application stack: nothing here asserts on it,
        // so report the "no .ccam/profile" shape, which is the common case.
        runtime: vi.fn().mockImplementation(async (id: number) => {
          recordCall("GET", `/api/lanes/${id}/runtime`);
          return { available: false };
        }),
        stage: vi.fn().mockImplementation(async () => {
          recordCall("POST", `/api/lanes/stage`);
          return { ok: true };
        }),
        pipelines: vi.fn().mockImplementation(async () => {
          recordCall("GET", "/api/lanes/pipelines");
          return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
        }),
        update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
          recordCall("PATCH", `/api/lanes/${id}`);
          const lane = lanesToReturn.find((l) => l.id === id);
          return { lane: { ...lane, ...patch } };
        }),
        features: {
          list: vi.fn().mockImplementation(async (id: number) => {
            recordCall("GET", `/api/lanes/${id}/features`);
            return { features: [] };
          }),
          show: vi.fn().mockImplementation(async (id: number, slug: string) => {
            recordCall("GET", `/api/lanes/${id}/features/${slug}`);
            return { feature: null };
          }),
        },
        proof: {
          list: vi.fn().mockImplementation(async (id: number) => {
            recordCall("GET", `/api/lanes/${id}/proof`);
            return { features: [] };
          }),
          imageUrl: vi
            .fn()
            .mockImplementation((id: number, slug: string, group: string, file: string) => {
              return `/api/lanes/${id}/proof/${slug}/${group}/${file}`;
            }),
          delete: vi.fn().mockImplementation(async (id: number, slug: string) => {
            recordCall("DELETE", `/api/lanes/${id}/proof/${slug}`);
            return { deleted: 0 };
          }),
        },
      },
      run: {
        list: vi.fn().mockImplementation(async () => {
          recordCall("GET", "/api/run/list");
          return { runs: [], items: [] };
        }),
        history: vi.fn().mockImplementation(async (limit?: number, opts?: { laneId?: number }) => {
          recordCall(
            "GET",
            `/api/run/history?limit=${limit}${opts?.laneId ? `&laneId=${opts.laneId}` : ""}`
          );
          return { items: [] };
        }),
        binary: vi.fn().mockImplementation(async () => {
          recordCall("GET", "/api/run/binary");
          return { found: true, path: "/usr/bin/claude" };
        }),
        cwds: vi.fn().mockImplementation(async () => {
          recordCall("GET", "/api/run/cwds");
          return { items: [{ kind: "home", path: "/home/user", label: "Home" }] };
        }),
        files: vi.fn().mockImplementation(async () => {
          recordCall("GET", "/api/run/files");
          return { items: [] };
        }),
        start: vi.fn().mockImplementation(async () => {
          recordCall("POST", "/api/run/start");
          const runId = `run-${nextRunId++}`;
          return { id: runId, status: "running" };
        }),
        get: vi.fn().mockImplementation(async (id: string) => {
          recordCall("GET", `/api/run/${id}`);
          return {
            id,
            pid: 12345,
            mode: "conversation",
            cwd: "/workspace",
            model: "claude-opus-5",
            permissionMode: "acceptEdits",
            effort: "medium",
            prompt: "test prompt",
            argv: [],
            resumeSessionId: null,
            status: "running",
            startedAt: Date.now(),
            endedAt: null,
            exitCode: null,
            signal: null,
            error: null,
            sessionId: "sess-1",
            envelopeCount: 0,
            stdoutTail: "",
            stderrTail: "",
            messages: [],
            envelopes: [],
          };
        }),
        send: vi.fn().mockImplementation(async (id: string) => {
          recordCall("POST", `/api/run/${id}/send`);
          return { messageId: "msg-1" };
        }),
        kill: vi.fn().mockImplementation(async (id: string) => {
          recordCall("POST", `/api/run/${id}/kill`);
          return { ok: true };
        }),
      },
      ccConfig: {
        commands: vi.fn().mockImplementation(async () => {
          recordCall("GET", "/api/cc-config/commands");
          return { items: [] };
        }),
        plugins: vi.fn().mockImplementation(async () => {
          recordCall("GET", "/api/cc-config/plugins");
          return { plugins: [] };
        }),
        file: vi.fn().mockImplementation(async (path: string) => {
          recordCall("GET", `/api/cc-config/file?path=${path}`);
          return { text: "", content: "" };
        }),
      },
      sessions: {
        list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
        transcript: vi.fn().mockResolvedValue({ messages: [] }),
      },
    },
  };
});

vi.mock("../../lib/eventBus", () => ({
  eventBus: {
    subscribe: () => () => {},
    publish: () => {},
    onConnection: () => () => {},
    connected: true,
    setConnected: () => {},
  },
}));

vi.mock("../../components/run/TerminalView", () => ({
  TerminalView: ({ runId }: { runId: string }) => (
    <div data-testid="terminal-view" data-run-id={runId} />
  ),
}));

import { Workspace } from "../Workspace";
import { api } from "../../lib/api";

class ObserverStub {
  observe() {}
  unobserve() {}
  disconnect() {}
  takeRecords() {
    return [];
  }
}

// Set up DOM polyfills for tests
if (typeof globalThis !== "undefined") {
  globalThis.ResizeObserver =
    globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
  if (typeof Element !== "undefined") {
    for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
      if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
        (Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
      }
    }
  }
}

async function settle() {
  await act(async () => {
    await new Promise((r) => setTimeout(r, 0));
    await new Promise((r) => setTimeout(r, 0));
  });
}

async function renderWorkspace() {
  const utils = render(
    <MemoryRouter initialEntries={["/run"]}>
      <Workspace />
    </MemoryRouter>
  );
  await settle();
  await settle();
  await settle();
  return utils;
}

beforeEach(() => {
  recordedCalls.length = 0;
  lanesToReturn = [
    {
      id: 1,
      cwd: "/workspace/a",
      title: "Lane A",
      pipeline_name: "default",
      pipeline_nodes: [],
      detected_signal: null,
      run_id: null,
    },
    {
      id: 2,
      cwd: "/workspace/b",
      title: "Lane B",
      pipeline_name: "default",
      pipeline_nodes: [],
      detected_signal: null,
      run_id: null,
    },
  ];
  countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
  nextRunId = 1;
  vi.clearAllMocks();
});

afterEach(() => {
  recordedCalls.length = 0;
  vi.clearAllMocks();
});

describe("Workspace page — lane integration", () => {
  it("lane strip lists lanes and counters match the API response", async () => {
    await renderWorkspace();

    // Verify lanes are rendered
    expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
    expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");

    // Verify counters are rendered with correct values
    const counterTexts = screen.getAllByText((_, element) => {
      if (!element) return false;
      return element.textContent?.includes("2 lanes") || false;
    });
    expect(counterTexts.length).toBeGreaterThan(0);
  });

  it("selecting a lane switches the pipeline and run id", async () => {
    lanesToReturn = lanesToReturn.map((l, i) => ({
      ...l,
      run_id: i === 0 ? "run-100" : "run-200",
    }));

    await renderWorkspace();
    const user = userEvent.setup();

    // Selection is announced on the tile itself, not inferred from styling.
    const laneATile = screen.getByTestId("lane-tile-1");
    const laneBTile = screen.getByTestId("lane-tile-2");
    expect(laneATile.getAttribute("aria-pressed")).toBe("true");
    expect(laneBTile.getAttribute("aria-pressed")).toBe("false");

    await user.click(laneBTile);
    await settle();

    expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
    expect(laneATile.getAttribute("aria-pressed")).toBe("false");
    // And the detail panel follows: only the selected lane gets a full card.
    expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
    expect(screen.queryByTestId("lane-card-1")).toBeNull();
  });

  it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
    await renderWorkspace();
    recordedCalls.length = 0; // Clear initial setup calls
    const user = userEvent.setup();

    // Get all textboxes and find the ones we need
    const textboxes = screen.getAllByRole("textbox");
    const cwdInput = textboxes.find((el) =>
      (el as HTMLInputElement).placeholder?.includes("absolute path")
    );
    const promptInput = textboxes.find((el) =>
      (el as HTMLInputElement).placeholder?.includes("Ask Claude")
    );

    if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");

    // Set cwd to an existing lane's cwd
    await user.clear(cwdInput as HTMLInputElement);
    await user.type(cwdInput as HTMLInputElement, "/workspace/a");
    await settle();

    // Set prompt
    await user.type(promptInput as HTMLInputElement, "test prompt");
    await settle();

    // Find the Run button in the RunSetup form
    const runButton = screen.getByRole("button", { name: /^Run$/i });
    const startButton = runButton;

    await user.click(startButton);
    await waitFor(() => {
      expect(
        recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
      ).toBe(true);
    });

    // Verify /api/run/start was NOT called
    const runStartCalled = recordedCalls.some((c) => c.path === "/api/run/start");
    expect(runStartCalled).toBe(false);
  });

  it("picking a cwd that no lane owns requests /api/lanes/ensure BEFORE start", async () => {
    await renderWorkspace();
    recordedCalls.length = 0; // Clear initial setup calls
    const user = userEvent.setup();

    // Get all textboxes and find the ones we need
    const textboxes = screen.getAllByRole("textbox");
    const cwdInput = textboxes.find((el) =>
      (el as HTMLInputElement).placeholder?.includes("absolute path")
    );
    const promptInput = textboxes.find((el) =>
      (el as HTMLInputElement).placeholder?.includes("Ask Claude")
    );

    if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");

    // Set cwd to a path no lane owns
    await user.clear(cwdInput as HTMLInputElement);
    await user.type(cwdInput as HTMLInputElement, "/new/project/path");
    await settle();

    // Set prompt
    await user.type(promptInput as HTMLInputElement, "test prompt");
    await settle();

    // Find the Run button in the RunSetup form
    const runButton = screen.getByRole("button", { name: /^Run$/i });

    await user.click(runButton);

    await waitFor(() => {
      expect(recordedCalls.some((c) => c.path === "/api/lanes/ensure")).toBe(true);
      expect(
        recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
      ).toBe(true);
    });

    // Verify ensure was called BEFORE start
    const ensureIndex = recordedCalls.findIndex((c) => c.path === "/api/lanes/ensure");
    const startIndex = recordedCalls.findIndex(
      (c) => c.path.includes("/api/lanes/") && c.path.includes("/start")
    );
    expect(ensureIndex).toBeLessThan(startIndex);
  });

  it("after a full start-then-message cycle, no recorded request URL matches /stage", async () => {
    await renderWorkspace();
    recordedCalls.length = 0; // Clear initial setup calls
    const user = userEvent.setup();

    // Get all textboxes and find the ones we need
    const textboxes = screen.getAllByRole("textbox");
    const cwdInput = textboxes.find((el) =>
      (el as HTMLInputElement).placeholder?.includes("absolute path")
    );
    const promptInput = textboxes.find((el) =>
      (el as HTMLInputElement).placeholder?.includes("Ask Claude")
    );

    if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");

    // Set cwd to an existing lane's cwd
    await user.clear(cwdInput as HTMLInputElement);
    await user.type(cwdInput as HTMLInputElement, "/workspace/a");
    await settle();

    // Set prompt
    await user.type(promptInput as HTMLInputElement, "test prompt");
    await settle();

    // Find the Run button in the RunSetup form
    const runButton = screen.getByRole("button", { name: /^Run$/i });

    await user.click(runButton);

    await waitFor(() => {
      expect(
        recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
      ).toBe(true);
    });

    // After start, verify no /stage call was made
    const stageCalls = recordedCalls.filter((c) => c.path.includes("/stage"));
    expect(stageCalls).toHaveLength(0);
  });
});

describe("Workspace layout", () => {
  it("shows the four counters with the values the API reported", async () => {
    countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
    await renderWorkspace();

    expect(screen.getByTestId("count-total").textContent).toContain("2");
    expect(screen.getByTestId("count-running").textContent).toContain("1");
    expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
    expect(screen.getByTestId("count-dead").textContent).toContain("4");
  });

  it("hides the needs-you and dead counters when they are zero", async () => {
    countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
    await renderWorkspace();

    expect(screen.queryByTestId("count-needs-you")).toBeNull();
    expect(screen.queryByTestId("count-dead")).toBeNull();
  });

  it("gives every lane a tile in the carousel", async () => {
    await renderWorkspace();
    expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
    expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
    expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
  });

  it("shows the full card only for the selected lane, in the detail panel", async () => {
    await renderWorkspace();
    // Lane 1 is selected by default. Its full card — controls, git facts — is
    // the detail panel; the other lane stays a tile.
    expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
    expect(screen.queryByTestId("lane-card-2")).toBeNull();
  });

  it("renders the selected lane's pipeline detail panel", async () => {
    await renderWorkspace();
    expect(screen.getByTestId("lane-detail")).toBeInTheDocument();
    // The colour legend was removed: read once, noise thereafter.
    expect(screen.queryByTestId("pipeline-legend")).toBeNull();
  });

  it("shows a feature picker when features are available", async () => {
    await renderWorkspace();

    // The feature picker should only render when there are features available
    // With the current mock setup, features return empty list, so picker won't render
    let picker = screen.queryByTestId("feature-picker");
    expect(picker).toBeNull();

    // This test verifies the feature picker UI was added and the i18n keys exist
    // Full feature testing requires server-side mocking of feature lists
  });
  it("renders the console body without any collapse toggle", async () => {
    await renderWorkspace();
    // The disclosure was removed - the console is always attached and visible.
    expect(screen.queryByTestId("console-toggle")).toBeNull();
    const body = screen.getByTestId("console-body");
    expect(body).toBeInTheDocument();
    expect(body.className).not.toContain("hidden");
  });
});

describe("Workspace — the console is its own section", () => {
  it("does not nest the console inside any lane card", async () => {
    await renderWorkspace();

    const body = screen.getByTestId("console-body");
    // The console is a window onto a process, not a property of a card. Nesting
    // it in one made the owning card span the row and left a hole when closed.
    expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
    expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
  });

  it("has no header line of its own - the pipeline panel already names the lane", async () => {
    lanesToReturn = lanesToReturn.map((l, i) => ({ ...l, run_id: i === 0 ? "run-100" : null }));
    await renderWorkspace();

    expect(screen.queryByTestId("console-owner")).toBeNull();
    expect(screen.queryByText("Claude console")).toBeNull();
  });

  it("keeps the console reachable when no lane exists at all", async () => {
    lanesToReturn = [];
    countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
    await renderWorkspace();

    expect(screen.getByTestId("console-body")).toBeInTheDocument();
  });

  it("attaches under the selected lane's pipeline, not as a separate section", async () => {
    await renderWorkspace();

    const detail = screen.getByTestId("lane-detail");
    const body = screen.getByTestId("console-body");
    expect(detail.contains(body)).toBe(true);
  });
});

describe("Workspace — proof gallery", () => {
  it("shows the proof gallery panel for the selected feature", async () => {
    vi.mocked(api.lanes.proof.list).mockResolvedValue({
      features: [
        {
          slug: "feat-one",
          groups: { "qc-local": ["a.png", "b.png"] },
          ticket_report: "",
          mtime: 0,
        },
      ],
    });
    // Set active_feature_id and ensure features list is populated
    // (indexed access is `| undefined` under noUncheckedIndexedAccess)
    lanesToReturn[0]!.active_feature_id = 1;
    vi.mocked(api.lanes.features.list).mockResolvedValue({
      features: [
        {
          id: 1,
          lane_id: 1,
          slug: "feat-one",
          title: "Feature One",
          stage: "ship",
          status: "running",
          archived_at: null,
          pipeline_nodes: [],
          progress: 100,
        },
      ],
    });

    await renderWorkspace();
    await waitFor(() => {
      expect(screen.getByTestId("proof-gallery")).toBeInTheDocument();
    });
  });

  it("shows no gallery panel when the selected feature has no proof", async () => {
    vi.mocked(api.lanes.proof.list).mockResolvedValue({ features: [] });

    await renderWorkspace();
    await settle();

    const gallery = screen.queryByTestId("proof-gallery");
    expect(gallery).toBeNull();
  });
});

describe("split terminal view", () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => {
    await renderWorkspace();
    expect(screen.getAllByTestId("console-body")).toHaveLength(1);
    expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
  });

  it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
    await renderWorkspace();
    fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
    await settle();
    expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
    expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
  });

  it("switching to 4-pane layout renders four panes", async () => {
    await renderWorkspace();
    fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
    await settle();
    expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
  });

  it("persists the layout and pane selections to localStorage across remounts", async () => {
    const { unmount } = await renderWorkspace();
    fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
    await settle();
    const selects = screen.getAllByTestId("pane-lane-select");
    const select = selects[1];
    expect(select).toBeDefined();
    fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
    await settle();
    unmount();

    await renderWorkspace();
    const persistedSelects = screen.getAllByTestId("pane-lane-select");
    expect(persistedSelects).toHaveLength(2);
    expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
  });

  it("falls back to unselected when a persisted lane id no longer exists", async () => {
    localStorage.setItem(
      "ccam.workspace.splitView",
      JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
    );
    await renderWorkspace();
    // Lane 9999 doesn't exist, so it falls back to null (unselected).
    // The second pane is already null. Both render as pane-empty.
    expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
  });
});
