/**
 * @file TerminalView.test.tsx
 * @description Tests for the xterm.js-backed terminal view: verifies it opens
 * a WS connection to the right URL, writes incoming binary frames to the
 * mocked terminal, forwards typed input as outgoing binary frames, and
 * re-attaches after the socket drops (server restart) but not after `exit`.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { TerminalView } from "../TerminalView";

const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = [];
const openMock = vi.fn();
const disposeMock = vi.fn();
const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
const termState = {
  modes: { mouseTrackingMode: "none" as "none" | "x10" | "vt200" | "drag" | "any" },
  buffer: { active: { type: "alternate" as "normal" | "alternate" } },
};

vi.mock("@xterm/xterm", () => ({
  Terminal: vi.fn().mockImplementation(() => ({
    get modes() {
      return termState.modes;
    },
    get buffer() {
      return termState.buffer;
    },
    open: openMock,
    write: writeMock,
    onData: (fn: (d: string) => void) => {
      onDataHandlers.push(fn);
      return { dispose: vi.fn() };
    },
    dispose: disposeMock,
    loadAddon: vi.fn(),
    attachCustomWheelEventHandler: wheelHandlerMock,
    attachCustomKeyEventHandler: keyHandlerMock,
  })),
}));
vi.mock("@xterm/addon-fit", () => ({
  FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));

class MockWebSocket {
  static instances: MockWebSocket[] = [];
  url: string;
  sent: unknown[] = [];
  onopen: (() => void) | null = null;
  onmessage: ((e: { data: unknown }) => void) | null = null;
  onclose: (() => void) | null = null;
  constructor(url: string) {
    this.url = url;
    MockWebSocket.instances.push(this);
  }
  send(data: unknown) {
    this.sent.push(data);
  }
  close() {
    this.onclose?.();
  }
}
// @ts-expect-error test override
global.WebSocket = MockWebSocket;

describe("TerminalView", () => {
  beforeEach(() => {
    termState.modes.mouseTrackingMode = "none";
    termState.buffer.active.type = "alternate";
    MockWebSocket.instances = [];
    onDataHandlers.length = 0;
    writeMock.mockClear();
    wheelHandlerMock.mockClear();
    keyHandlerMock.mockClear();
    openMock.mockClear();
  });

  afterEach(cleanup);

  it("opens a WS connection to the run's ws-pty path", () => {
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    expect(MockWebSocket.instances).toHaveLength(1);
    expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/ccam-lane-1");
  });

  it("writes incoming WS data to the terminal", () => {
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    const ws = MockWebSocket.instances[0]!;
    ws.onopen?.();
    ws.onmessage?.({ data: "hello" });
    expect(writeMock).toHaveBeenCalledWith("hello");
  });

  it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => {
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    const ws = MockWebSocket.instances[0]!;
    ws.onopen?.();
    const bytes = new TextEncoder().encode("hello-binary").buffer;
    ws.onmessage?.({ data: bytes });
    expect(writeMock).toHaveBeenCalledWith("hello-binary");
  });

  it("forwards terminal keystrokes as outgoing WS sends", () => {
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    const ws = MockWebSocket.instances[0]!;
    onDataHandlers[0]!("ls -la\r");
    expect(ws.sent).toEqual(["ls -la\r"]);
  });

  it("swallows wheel events xterm would otherwise turn into arrow keys", () => {
    termState.modes.mouseTrackingMode = "none";
    termState.buffer.active.type = "alternate";
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    expect(wheelHandlerMock).toHaveBeenCalledTimes(1);
    // false = xterm skips its alt-screen wheel→cursor-key emulation, which in a
    // claude pane would walk the prompt history instead of scrolling.
    expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
  });

  it("lets the wheel through once the pane's program tracks the mouse", () => {
    // tmux with `mouse on` sets this; the wheel must reach it as a real SGR
    // report, so returning false here would kill scrolling entirely.
    termState.modes.mouseTrackingMode = "any";
    termState.buffer.active.type = "alternate";
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
  });

  it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
    termState.modes.mouseTrackingMode = "none";
    termState.buffer.active.type = "normal";
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
  });

  it("treats x10 tracking as no wheel tracking (x10 never reports the wheel)", () => {
    termState.modes.mouseTrackingMode = "x10";
    termState.buffer.active.type = "alternate";
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
  });

  it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;

    const shiftTab = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true });
    const preventShiftTab = vi.spyOn(shiftTab, "preventDefault");
    // true = xterm still processes it and sends ESC[Z; only the browser's
    // focus-moving default is suppressed.
    expect(handler(shiftTab)).toBe(true);
    expect(preventShiftTab).toHaveBeenCalled();

    const plainTab = new KeyboardEvent("keydown", { key: "Tab" });
    const preventPlainTab = vi.spyOn(plainTab, "preventDefault");
    expect(handler(plainTab)).toBe(true);
    expect(preventPlainTab).not.toHaveBeenCalled();
  });

  it("re-attaches after the socket drops (a server restart must not freeze the pane)", () => {
    vi.useFakeTimers();
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    MockWebSocket.instances[0]!.close();
    vi.advanceTimersByTime(1600);
    expect(MockWebSocket.instances).toHaveLength(2);
    vi.useRealTimers();
  });

  it("does not re-attach once the server reported the pane exited", () => {
    vi.useFakeTimers();
    render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
    const ws = MockWebSocket.instances[0]!;
    ws.onmessage?.({ data: '{"type":"exit","code":0}' });
    ws.close();
    vi.advanceTimersByTime(5000);
    expect(MockWebSocket.instances).toHaveLength(1);
    vi.useRealTimers();
  });
});
