// @vitest-environment jsdom
/**
 * Component tests for LlmWorkersForm (settings batch A).
 *
 * Focus: the worker-array editing serialization that has real logic —
 *  - addWorker pushes a default-shaped worker onto llm.workers
 *  - removeWorker / moveWorker rewrite the array correctly
 *  - the connectionType <select> keeps the legacy `proxy` flag in sync
 *    (aao_gateway -> proxy:true, direct -> proxy:undefined)
 *  - retry.backoffMs serialization (StringArrayEditor strings -> numbers, NaN dropped)
 *  - the self-loop endpoint warning heuristic (localhost) for gateway rows
 *
 * ModelSelect uses raw fetch for model discovery; we stub global fetch so no
 * real network happens.
 */
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import { LlmWorkersForm } from './LlmWorkersForm';

function render(config: any) {
  const onChange = vi.fn();
  renderWithProviders(<LlmWorkersForm config={config} onChange={onChange} overriddenByEnv={{}} />);
  return { onChange };
}

beforeEach(() => {
  // Never let ModelSelect hit the network.
  vi.stubGlobal(
    'fetch',
    vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })) as any,
  );
});
afterEach(() => {
  vi.unstubAllGlobals();
});

describe('LlmWorkersForm', () => {
  it('shows the empty state when there are no workers', () => {
    render({ llm: { workers: [] } });
    // empty -> i18n key 'llmWorkers.empty' (not initialized) appears as text.
    expect(screen.getByText('llmWorkers.empty')).toBeInTheDocument();
  });

  it('addWorker appends a default-shaped worker to llm.workers', async () => {
    const { onChange } = render({ llm: { workers: [] } });
    await userEvent.click(screen.getByRole('button', { name: 'llmWorkers.addWorker' }));
    const [path, value] = onChange.mock.calls.at(-1)!;
    expect(path).toBe('llm.workers');
    expect(value).toHaveLength(1);
    expect(value[0]).toMatchObject({
      connectionType: 'direct',
      enabled: true,
      maxConcurrency: 1,
      roles: [],
    });
  });

  it('removeWorker drops the row from the array', async () => {
    const { onChange } = render({
      llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }, { id: 'w2', endpoint: 'http://b/v1' }] },
    });
    await userEvent.click(screen.getAllByTitle('llmWorkers.removeWorker')[0]);
    const [path, value] = onChange.mock.calls.at(-1)!;
    expect(path).toBe('llm.workers');
    expect(value).toHaveLength(1);
    expect(value[0].id).toBe('w2');
  });

  it('moveWorker reorders rows', async () => {
    const { onChange } = render({
      llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }, { id: 'w2', endpoint: 'http://b/v1' }] },
    });
    // Second row's move-up button.
    await userEvent.click(screen.getAllByTitle('llmWorkers.moveUp')[1]);
    const [, value] = onChange.mock.calls.at(-1)!;
    expect(value.map((w: any) => w.id)).toEqual(['w2', 'w1']);
  });

  it('keeps the legacy proxy flag in sync with connectionType', async () => {
    const { onChange } = render({
      llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://a/v1' }] },
    });
    // The connection-type <select> is the only combobox in the row.
    const select = screen.getByRole('combobox');
    await userEvent.selectOptions(select, 'aao_gateway');
    let [path, value] = onChange.mock.calls.at(-1)!;
    expect(path).toBe('llm.workers');
    expect(value[0]).toMatchObject({ connectionType: 'aao_gateway', proxy: true });

    await userEvent.selectOptions(select, 'direct');
    [path, value] = onChange.mock.calls.at(-1)!;
    expect(value[0].connectionType).toBe('direct');
    expect(value[0].proxy).toBeUndefined();
  });

  it('serializes retry.backoffMs from string chips to numbers, dropping NaN', async () => {
    const { onChange } = render({ llm: { workers: [], retry: { backoffMs: [] } } });
    // The backoff chip editor input has placeholder "2000".
    const input = screen.getByPlaceholderText('2000');
    await userEvent.type(input, 'abc{enter}');
    // 'abc' -> NaN -> dropped, so the array stays empty.
    let call = onChange.mock.calls.find(([p]) => p === 'llm.retry.backoffMs');
    expect(call?.[1]).toEqual([]);

    await userEvent.type(input, '1500{enter}');
    call = onChange.mock.calls.filter(([p]) => p === 'llm.retry.backoffMs').at(-1);
    expect(call?.[1]).toEqual([1500]);
  });

  it('shows the self-loop warning for a gateway worker pointing at localhost', () => {
    render({
      llm: { workers: [{ id: 'w1', connectionType: 'aao_gateway', endpoint: 'http://localhost:9876/v1' }] },
    });
    expect(screen.getByText('llmWorkers.selfLoopWarn')).toBeInTheDocument();
  });

  it('does NOT show the self-loop warning for a direct remote endpoint', () => {
    render({
      llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://remote-box:11434/v1' }] },
    });
    expect(screen.queryByText('llmWorkers.selfLoopWarn')).toBeNull();
  });
});
