// @vitest-environment jsdom
/**
 * Component tests for ConnectionPicker (exported from ConsoleTab.tsx) — the
 * "add a session" panel. Verifies: the connection list loads and renders,
 * selecting a connection and submitting POSTs { connection_id } (no
 * force_replace — that flow is dead now that the session endpoint adds
 * sessions instead of replacing them) and calls onStarted with that same
 * connection_id, a 429 task_session_cap/user_session_cap response surfaces a
 * distinct cap message via the new i18n keys, and the old
 * "replace the current session" control from the pre-add-session design is
 * gone entirely.
 */
import '../../../test/dom-setup';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../test/render-helpers';
import '../../../i18n';
import type { SshConnection } from '../../../lib/ssh-types';
import type { ConsoleSessionSummary } from '../../../lib/ssh-console-types';

// ConsoleTab (the outer tab-strip + selection component, tested below in
// "ConsoleTab — new session selection") pulls in the real WS hook and
// xterm.js terminal. Neither is exercised by these tests — they only need
// the tab strip / selection logic — and both require browser APIs (real
// WebSocket, ResizeObserver, canvas) that jsdom doesn't provide, so they're
// replaced with inert stubs.
vi.mock('../../../hooks/useConsoleSession', () => ({
  useConsoleSession: () => ({
    state: { kind: 'no_session' },
    onOutput: () => () => {},
    onNotice: () => () => {},
    send: () => {},
    sendResize: () => {},
    close: () => {},
    reconnectNow: vi.fn(),
  }),
}));
vi.mock('./console/TerminalView', () => ({
  TerminalView: () => null,
}));

import { ConnectionPicker, ConsoleTab } from './ConsoleTab';

function makeConnection(overrides: Partial<SshConnection> = {}): SshConnection {
  return {
    id: 'conn-a',
    ownerId: null,
    label: 'Prod DB',
    host: 'db.example.com',
    port: 22,
    username: 'root',
    keyVersion: 1,
    keyFingerprint: null,
    hostKeyType: null,
    hostKeyFingerprint: null,
    hostKeyRecordedAt: null,
    hostKeyVerifiedAt: null,
    hostKeyPending: false,
    hostKeyPendingFingerprint: null,
    hostKeyPendingSource: null,
    commandDenyPatterns: null,
    commandAllowPatterns: null,
    remotePathPrefix: '',
    allowRemoteUnrestricted: false,
    allowPrivateAddresses: false,
    enabled: true,
    disabledByAdmin: false,
    disabledByAdminReason: null,
    disabledByAdminAt: null,
    disabledByAdminUserId: null,
    createdAt: '2026-07-01T00:00:00Z',
    updatedAt: '2026-07-01T00:00:00Z',
    ...overrides,
  } as SshConnection;
}

let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
  fetchMock = vi.fn();
  vi.stubGlobal('fetch', fetchMock);
});

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

/** Wires fetchMock: GET /api/ssh/connections returns `connections`, POST
 * .../console/session is handled by `postSession`. */
function stubFetch(
  connections: SshConnection[],
  postSession: (body: any) => { status: number; json: () => Promise<any> },
) {
  fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
    if (url === '/api/ssh/connections') {
      return { ok: true, status: 200, json: async () => ({ connections }) };
    }
    if (typeof url === 'string' && url.includes('/console/session') && init?.method === 'POST') {
      const body = JSON.parse(String(init.body));
      const { status, json } = postSession(body);
      return { ok: status >= 200 && status < 300, status, json };
    }
    throw new Error(`unexpected fetch: ${url}`);
  });
}

describe('ConnectionPicker', () => {
  it('lists connections and, on submit, POSTs { connection_id } (no force_replace) and calls onStarted with that id', async () => {
    const user = userEvent.setup();
    const onStarted = vi.fn();
    const connA = makeConnection({ id: 'conn-a', label: 'Prod DB' });
    const connB = makeConnection({ id: 'conn-b', label: 'Staging' });
    stubFetch([connA, connB], () => ({ status: 200, json: async () => ({}) }));

    renderWithProviders(<ConnectionPicker taskId={1} onStarted={onStarted} />);

    await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2));
    expect(screen.getByRole('option', { name: /Prod DB/ })).toBeInTheDocument();
    expect(screen.getByRole('option', { name: /Staging/ })).toBeInTheDocument();

    await user.selectOptions(screen.getByRole('combobox'), 'conn-b');
    await user.click(screen.getByRole('button', { name: /start session/i }));

    await waitFor(() => expect(onStarted).toHaveBeenCalledWith('conn-b'));

    const postCall = fetchMock.mock.calls.find(
      ([url]: [string]) => typeof url === 'string' && url.includes('/console/session'),
    );
    expect(postCall).toBeTruthy();
    const [url, init] = postCall!;
    expect(url).toBe('/api/local/tasks/1/console/session');
    const body = JSON.parse(init.body);
    expect(body).toEqual({ connection_id: 'conn-b' });
    expect(body.force_replace).toBeUndefined();
  });

  it('surfaces a distinct message for a 429 task_session_cap response', async () => {
    const user = userEvent.setup();
    const onStarted = vi.fn();
    stubFetch([makeConnection({ id: 'conn-a' })], () => ({
      status: 429,
      json: async () => ({ error: 'task_session_cap' }),
    }));

    renderWithProviders(<ConnectionPicker taskId={1} onStarted={onStarted} />);
    await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0));
    await user.click(screen.getByRole('button', { name: /start session/i }));

    await waitFor(() => expect(screen.getByText(/console session limit/i)).toBeInTheDocument());
    // Distinguish from the generic "startFailed: {{code}}" fallback message.
    expect(screen.queryByText(/Could not start the session/i)).not.toBeInTheDocument();
    expect(onStarted).not.toHaveBeenCalled();
  });

  it('surfaces a distinct message for a 429 user_session_cap response', async () => {
    const user = userEvent.setup();
    stubFetch([makeConnection({ id: 'conn-a' })], () => ({
      status: 429,
      json: async () => ({ error: 'user_session_cap' }),
    }));

    renderWithProviders(<ConnectionPicker taskId={1} onStarted={vi.fn()} />);
    await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0));
    await user.click(screen.getByRole('button', { name: /start session/i }));

    await waitFor(() => expect(screen.getByText(/account-wide console session limit/i)).toBeInTheDocument());
  });

  it('has no "replace the current session" control (dead now that sessions add rather than replace)', async () => {
    const user = userEvent.setup();
    stubFetch([makeConnection({ id: 'conn-a' }), makeConnection({ id: 'conn-b', label: 'Staging' })], () => ({
      status: 409,
      json: async () => ({ error: 'connection_change_requires_force' }),
    }));

    renderWithProviders(<ConnectionPicker taskId={1} onStarted={vi.fn()} />);
    await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0));
    await user.click(screen.getByRole('button', { name: /start session/i }));

    // Even a server response using the old conflict code must not resurrect
    // the old replace-and-start button — that branch is deleted.
    await waitFor(() => expect(screen.queryByText(/^Loading/)).not.toBeInTheDocument());
    expect(screen.queryByRole('button', { name: /replace/i })).not.toBeInTheDocument();
    expect(screen.queryByText(/replace the current session/i)).not.toBeInTheDocument();
  });
});

function makeSummary(overrides: Partial<ConsoleSessionSummary> = {}): ConsoleSessionSummary {
  return {
    connection_id: 'conn-a',
    connection_label: 'Prod DB',
    started_at: '2026-07-01T00:00:00Z',
    last_activity_at: '2026-07-01T00:00:00Z',
    status: 'connected',
    can_write: true,
    can_close: true,
    agent_active: false,
    cols: 80,
    rows: 24,
    ...overrides,
  };
}

/**
 * Regression test for Finding 3 (final-review, feat/ssh-console-multi-session):
 * after the add-connection flow calls `onStarted(newConnectionId)`, ConsoleTab
 * immediately selected the new id AND invalidated the sessions query — but the
 * selection-reconciliation effect ran against the still-stale `sessions` list
 * (the new session isn't in it until the refetch resolves), decided the new id
 * "doesn't exist", and reverted the selection to the previous tab. The fix
 * gates that effect on the sessions query's `isFetching` so a just-set
 * selection survives the in-flight refetch instead of being clobbered by
 * stale data.
 */
describe('ConsoleTab — new session selection (Finding 3 regression)', () => {
  it('selects the newly-added connection once the sessions refetch resolves, and does not revert to the previous tab', async () => {
    const user = userEvent.setup();
    const sessionA = makeSummary({ connection_id: 'conn-a', connection_label: 'Prod DB' });
    const sessionB = makeSummary({
      connection_id: 'conn-b',
      connection_label: 'Staging',
      started_at: '2026-07-02T00:00:00Z',
      last_activity_at: '2026-07-02T00:00:00Z',
    });
    const connA = makeConnection({ id: 'conn-a', label: 'Prod DB' });
    const connB = makeConnection({ id: 'conn-b', label: 'Staging' });

    let sessionAdded = false;
    fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
      if (url === '/api/local/tasks/1/console/sessions') {
        return {
          ok: true,
          json: async () => ({ sessions: sessionAdded ? [sessionA, sessionB] : [sessionA] }),
        };
      }
      if (url === '/api/ssh/connections') {
        return { ok: true, status: 200, json: async () => ({ connections: [connA, connB] }) };
      }
      if (typeof url === 'string' && url.includes('/console/session') && init?.method === 'POST') {
        sessionAdded = true;
        return { ok: true, status: 200, json: async () => ({}) };
      }
      throw new Error(`unexpected fetch: ${url}`);
    });

    renderWithProviders(<ConsoleTab taskId={1} />);

    // Initial state: only the conn-a tab exists.
    await waitFor(() => expect(screen.getByText('Prod DB')).toBeInTheDocument());
    expect(screen.queryByText('Staging')).not.toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: /add connection/i }));
    await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2));
    await user.selectOptions(screen.getByRole('combobox'), 'conn-b');
    await user.click(screen.getByRole('button', { name: /start session/i }));

    // Once the invalidated sessions query refetches (now including conn-b),
    // its tab appears — and it must be the SELECTED tab, not reverted to
    // conn-a.
    await waitFor(() => expect(screen.getByText('Staging')).toBeInTheDocument());
    const stagingTab = screen.getByText('Staging').closest('[role="button"]');
    const prodTab = screen.getByText('Prod DB').closest('[role="button"]');
    expect(stagingTab).toHaveAttribute('aria-selected', 'true');
    expect(prodTab).toHaveAttribute('aria-selected', 'false');
  });
});
