import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { XtermSession } from '../../src/components/agent-workspace/xterm-session';

const {
  openSpy,
  wsInstances,
  fetchSpy,
  fitSpy,
  selectionHandlers,
  getSelectionSpy,
  writeTextSpy,
} = vi.hoisted(() => ({
  openSpy: vi.fn(),
  wsInstances: [] as {
    url: string;
    send: ReturnType<typeof vi.fn>;
    close: ReturnType<typeof vi.fn>;
  }[],
  fetchSpy: vi.fn(),
  fitSpy: vi.fn(),
  selectionHandlers: [] as (() => void)[],
  getSelectionSpy: vi.fn<() => string>(() => ''),
  writeTextSpy: vi.fn<(text: string) => Promise<void>>(() =>
    Promise.resolve(),
  ),
}));

class FakeTerminal {
  cols = 80;
  rows = 24;
  options: Record<string, unknown> = {};
  open = openSpy;
  loadAddon = vi.fn();
  onData = vi.fn();
  onResize = vi.fn();
  onSelectionChange = vi.fn((cb: () => void) => {
    selectionHandlers.push(cb);
  });
  getSelection = getSelectionSpy;
  dispose = vi.fn();
  refresh = vi.fn();
  write = vi.fn();
  focus = vi.fn();
  constructor(opts: Record<string, unknown>) {
    this.options = opts ?? {};
  }
}

vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }));
vi.mock('@xterm/addon-fit', () => ({
  FitAddon: class {
    fit = fitSpy;
  },
}));
vi.mock('@xterm/addon-web-links', () => ({
  WebLinksAddon: class {},
}));
vi.mock('next-themes', () => ({
  useTheme: () => ({ resolvedTheme: 'dark' }),
}));

class FakeWebSocket {
  static OPEN = 1;
  url: string;
  readyState = 1;
  binaryType = '';
  onopen: (() => void) | null = null;
  onmessage: ((event: unknown) => void) | null = null;
  onclose: (() => void) | null = null;
  onerror: (() => void) | null = null;
  send = vi.fn();
  close = vi.fn();
  constructor(url: string) {
    this.url = url;
    wsInstances.push(this);
  }
}

beforeEach(() => {
  wsInstances.length = 0;
  selectionHandlers.length = 0;
  openSpy.mockClear();
  fitSpy.mockClear();
  getSelectionSpy.mockReset();
  getSelectionSpy.mockReturnValue('');
  writeTextSpy.mockReset();
  writeTextSpy.mockResolvedValue(undefined);
  const store = new Map<string, string>();
  const localStorageMock = {
    getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
    setItem: (key: string, value: string) => store.set(key, String(value)),
    removeItem: (key: string) => store.delete(key),
    clear: () => store.clear(),
    key: (index: number) => Array.from(store.keys())[index] ?? null,
    get length() {
      return store.size;
    },
  };
  Object.defineProperty(window, 'localStorage', {
    configurable: true,
    value: localStorageMock,
  });
  Object.defineProperty(navigator, 'clipboard', {
    configurable: true,
    value: { writeText: writeTextSpy },
  });
  fetchSpy.mockReset();
  fetchSpy.mockResolvedValue({
    ok: true,
    status: 200,
    json: () => Promise.resolve({ url: 'ws://x' }),
    text: () => Promise.resolve(''),
  });
  vi.stubGlobal('WebSocket', FakeWebSocket);
  vi.stubGlobal('fetch', fetchSpy);
  vi.stubGlobal(
    'ResizeObserver',
    class {
      observe() {
        /* noop */
      }
      disconnect() {
        /* noop */
      }
      unobserve() {
        /* noop */
      }
    },
  );
  Object.defineProperty(document, 'fonts', {
    configurable: true,
    value: {
      ready: Promise.resolve(),
      load: () => Promise.resolve([]),
    },
  });
});

afterEach(() => {
  cleanup();
  vi.unstubAllGlobals();
});

describe('XtermSession', () => {
  it('shows the waiting label and does not open a WebSocket while inactive', async () => {
    render(
      <XtermSession
        active={false}
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
        waitingLabel='Workspace is asleep.'
      />,
    );

    expect(screen.getByText('Workspace is asleep.')).toBeInTheDocument();
    // Give any (unexpected) async connect a chance to run.
    await Promise.resolve();
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(wsInstances).toHaveLength(0);
  });

  it('fetches the token URL and opens a WebSocket to the returned url when active', async () => {
    render(
      <XtermSession
        active
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
      />,
    );

    await waitFor(() =>
      expect(fetchSpy).toHaveBeenCalledWith(
        '/api/box/terminal-token',
        expect.objectContaining({ signal: expect.anything() }),
      ),
    );
    await waitFor(() => expect(wsInstances).toHaveLength(1));
    expect(wsInstances[0]?.url).toBe('ws://x');
    expect(openSpy).toHaveBeenCalled();
  });

  it('refits and sends a resize frame when it becomes visible', async () => {
    const { rerender } = render(
      <XtermSession
        active
        visible={false}
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
      />,
    );

    await waitFor(() => expect(wsInstances).toHaveLength(1));
    const ws = wsInstances[0]!;
    // Ignore fits/sends from the initial connect; only the visible toggle matters.
    fitSpy.mockClear();
    ws.send.mockClear();

    rerender(
      <XtermSession
        active
        visible
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
      />,
    );

    await waitFor(() => expect(fitSpy).toHaveBeenCalled());
    await waitFor(() =>
      expect(ws.send).toHaveBeenCalledWith(
        expect.stringContaining('"type":"resize"'),
      ),
    );
  });

  it('keeps the WebSocket open when hidden while still active', async () => {
    const { rerender } = render(
      <XtermSession
        active
        visible
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
      />,
    );

    await waitFor(() => expect(wsInstances).toHaveLength(1));
    const ws = wsInstances[0]!;

    rerender(
      <XtermSession
        active
        visible={false}
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
      />,
    );
    await Promise.resolve();

    expect(ws.close).not.toHaveBeenCalled();
    expect(wsInstances).toHaveLength(1);
  });

  it('copies the selection to the clipboard when copy-on-select is enabled', async () => {
    render(
      <XtermSession
        active
        copyOnSelect
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
      />,
    );

    await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
    getSelectionSpy.mockReturnValue('hello world');
    selectionHandlers.forEach((cb) => cb());

    await waitFor(() =>
      expect(writeTextSpy).toHaveBeenCalledWith('hello world'),
    );
  });

  it('does not copy when copy-on-select is disabled', async () => {
    render(
      <XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
    );

    await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
    getSelectionSpy.mockReturnValue('should not copy');
    selectionHandlers.forEach((cb) => cb());
    await Promise.resolve();

    expect(writeTextSpy).not.toHaveBeenCalled();
  });

  it('skips empty selections even when enabled', async () => {
    render(
      <XtermSession
        active
        copyOnSelect
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
      />,
    );

    await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
    getSelectionSpy.mockReturnValue('');
    selectionHandlers.forEach((cb) => cb());
    await Promise.resolve();

    expect(writeTextSpy).not.toHaveBeenCalled();
  });

  it('swallows clipboard errors without crashing', async () => {
    writeTextSpy.mockRejectedValue(new Error('denied'));
    render(
      <XtermSession
        active
        copyOnSelect
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-1'
      />,
    );

    await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
    getSelectionSpy.mockReturnValue('boom');
    selectionHandlers.forEach((cb) => cb());

    await waitFor(() => expect(writeTextSpy).toHaveBeenCalledWith('boom'));
    // Rejection is caught; the component stays mounted.
    expect(screen.getByRole('checkbox')).toBeInTheDocument();
  });

  it('renders the header toggle and persists flips to localStorage', async () => {
    render(
      <XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
    );

    await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
    const checkbox = screen.getByRole('checkbox');
    expect(checkbox).not.toBeChecked();

    // Off: a selection is not copied.
    getSelectionSpy.mockReturnValue('nope');
    selectionHandlers.forEach((cb) => cb());
    await Promise.resolve();
    expect(writeTextSpy).not.toHaveBeenCalled();

    // Flip on: persists and now copies.
    fireEvent.click(checkbox);
    expect(checkbox).toBeChecked();
    expect(
      window.localStorage.getItem('spoon.terminal.copyOnSelect'),
    ).toBe('true');

    getSelectionSpy.mockReturnValue('now copied');
    selectionHandlers.forEach((cb) => cb());
    await waitFor(() =>
      expect(writeTextSpy).toHaveBeenCalledWith('now copied'),
    );

    // Flip off again: persists false.
    fireEvent.click(checkbox);
    expect(checkbox).not.toBeChecked();
    expect(
      window.localStorage.getItem('spoon.terminal.copyOnSelect'),
    ).toBe('false');
  });

  it('lets the persisted localStorage value override the prop default', () => {
    window.localStorage.setItem('spoon.terminal.copyOnSelect', 'true');
    render(
      <XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
    );

    const checkbox = screen.getByRole('checkbox');
    expect(checkbox).toBeChecked();

    window.localStorage.setItem('spoon.terminal.copyOnSelect', 'false');
    render(
      <XtermSession
        active
        copyOnSelect
        tokenUrl='/api/box/terminal-token'
        sessionKey='box-2'
      />,
    );
    // The second instance's checkbox reads the persisted false over prop=true.
    const checkboxes = screen.getAllByRole('checkbox');
    expect(checkboxes[checkboxes.length - 1]).not.toBeChecked();
  });
});
