/**
 * @vitest-environment happy-dom
 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api } from './client';
import { clearStoredAuth, setStoredAuth } from './auth';
import { mockAgent, mockServerConfig, mockServerInfo } from '../test/fixtures';

function jsonResponse(data: unknown, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { 'Content-Type': 'application/json' },
  });
}

function textResponse(body: string, status: number) {
  return new Response(body, { status });
}

describe('api client', () => {
  let fetchMock: ReturnType<typeof vi.fn>;

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

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

  function lastFetch(): { url: string; init: RequestInit } {
    const [url, init] = fetchMock.mock.calls.at(-1)!;
    return { url: url as string, init: init as RequestInit };
  }

  function expectAuthHeaders(init: RequestInit) {
    const headers = init.headers as Record<string, string>;
    expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
  }

  it('sends JSON Content-Type and auth on listAgents', async () => {
    setStoredAuth('user', 'pass');
    fetchMock.mockResolvedValueOnce(jsonResponse([mockAgent()]));

    const agents = await api.listAgents();

    expect(agents).toHaveLength(1);
    const { url, init } = lastFetch();
    expect(url).toBe('/api/v1/agents');
    expect(init.method).toBeUndefined();
    expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
    expectAuthHeaders(init);
  });

  it('throws with status and body on API errors', async () => {
    fetchMock.mockResolvedValueOnce(textResponse('not found', 404));

    await expect(api.getAgent('missing')).rejects.toThrow('API error 404: not found');
    expect(lastFetch().url).toBe('/api/v1/agents/missing');
  });

  it('omits Authorization when logged out', async () => {
    clearStoredAuth();
    fetchMock.mockResolvedValueOnce(jsonResponse({ status: 'ok' }));

    await api.healthCheck();

    const headers = lastFetch().init.headers as Record<string, string>;
    expect(headers.Authorization).toBeUndefined();
  });

  it('getAgentStats appends limit query param', async () => {
    fetchMock.mockResolvedValueOnce(jsonResponse([]));
    await api.getAgentStats('agent-1', 25);
    expect(lastFetch().url).toBe('/api/v1/agents/agent-1/stats?limit=25');
  });

  it('getAgentStats omits limit when undefined', async () => {
    fetchMock.mockResolvedValueOnce(jsonResponse([]));
    await api.getAgentStats('agent-1');
    expect(lastFetch().url).toBe('/api/v1/agents/agent-1/stats');
  });

  it('getRecentShares appends limit query param', async () => {
    fetchMock.mockResolvedValueOnce(jsonResponse([]));
    await api.getRecentShares(10);
    expect(lastFetch().url).toBe('/api/v1/shares?limit=10');
  });

  it('updateConfig PUTs JSON body', async () => {
    const partial = { server: { dashboard_subtitle: 'test' } };
    fetchMock.mockResolvedValueOnce(jsonResponse(mockServerConfig(partial)));

    await api.updateConfig(partial);

    const { url, init } = lastFetch();
    expect(url).toBe('/api/v1/config');
    expect(init.method).toBe('PUT');
    expect(init.body).toBe(JSON.stringify(partial));
  });

  it('buildAgent POSTs JSON when fusion disabled', async () => {
    const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
    fetchMock.mockResolvedValueOnce(jsonResponse({ build_id: 'b1', success: true }));

    await api.buildAgent(req);

    const { url, init } = lastFetch();
    expect(url).toBe('/api/v1/builder/build');
    expect(init.method).toBe('POST');
    expect(init.body).toBe(JSON.stringify(req));
  });

  it('buildAgent rejects fusion without prep file', async () => {
    const req = { fusion_enabled: true } as Parameters<typeof api.buildAgent>[0];
    await expect(api.buildAgent(req)).rejects.toThrow('Fusion requires prep.exe upload');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('buildAgent POSTs multipart when fusion enabled', async () => {
    setStoredAuth('user', 'pass');
    const req = { fusion_enabled: true, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
    const prep = new File(['prep'], 'custom.exe', { type: 'application/octet-stream' });
    fetchMock.mockResolvedValueOnce(jsonResponse({ build_id: 'b2', success: true }));

    await api.buildAgent(req, prep);

    const { url, init } = lastFetch();
    expect(url).toBe('/api/v1/builder/build');
    expect(init.method).toBe('POST');
    expect(init.body).toBeInstanceOf(FormData);
    const headers = init.headers as Record<string, string>;
    expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
    expect(headers['Content-Type']).toBeUndefined();
    const form = init.body as FormData;
    expect(form.get('config')).toBe(JSON.stringify(req));
    expect(form.get('prep_exe')).toBeInstanceOf(File);
  });

  it('estimateFusion POSTs multipart with auth', async () => {
    setStoredAuth('user', 'pass');
    const req = { fusion_enabled: true } as Parameters<typeof api.estimateFusion>[0];
    const prep = new File(['prep'], 'prep.exe');
    fetchMock.mockResolvedValueOnce(jsonResponse({ estimated_size_mb: 12 }));

    await api.estimateFusion(req, prep);

    const { url, init } = lastFetch();
    expect(url).toBe('/api/v1/builder/estimate');
    expect(init.method).toBe('POST');
    expectAuthHeaders(init);
  });

  it('estimateFusion rejects without prep file', async () => {
    const req = { fusion_enabled: true } as Parameters<typeof api.estimateFusion>[0];
    await expect(api.estimateFusion(req, null)).rejects.toThrow('Fusion requires prep.exe upload');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('pinBuild, unpinAll, deleteBuild use correct methods and paths', async () => {
    fetchMock
      .mockResolvedValueOnce(jsonResponse({ ok: true, pinned_id: 'b1' }))
      .mockResolvedValueOnce(jsonResponse({ ok: true }))
      .mockResolvedValueOnce(jsonResponse({ ok: true, deleted_id: 'b1' }));

    await api.pinBuild('b1');
    expect(lastFetch().url).toBe('/api/v1/builds/b1/pin');
    expect(lastFetch().init.method).toBe('PUT');

    await api.unpinAll();
    expect(lastFetch().url).toBe('/api/v1/builds/pin');
    expect(lastFetch().init.method).toBe('DELETE');

    await api.deleteBuild('b1');
    expect(lastFetch().url).toBe('/api/v1/builds/b1');
    expect(lastFetch().init.method).toBe('DELETE');
  });

  it('build URL helpers encode paths', () => {
    expect(api.buildDownloadUrl('id-1')).toBe('/api/v1/builds/id-1/download');
    expect(api.buildArtifactUrl('id-1', 'file with spaces.exe')).toBe(
      '/api/v1/builds/id-1/artifact/file%20with%20spaces.exe'
    );
    expect(api.buildUninstallUrl('id-1')).toBe('/api/v1/builds/id-1/uninstall');
  });

  it('blueprint CRUD uses encoded names', async () => {
    fetchMock
      .mockResolvedValueOnce(jsonResponse([]))
      .mockResolvedValueOnce(jsonResponse({ name: 'preset' }))
      .mockResolvedValueOnce(jsonResponse({ success: true, name: 'preset', file_path: '/x', created_at: '' }))
      .mockResolvedValueOnce(jsonResponse({ success: true, name: 'preset' }));

    await api.listBlueprints();
    expect(lastFetch().url).toBe('/api/v1/blueprints');

    await api.getBlueprint('my preset');
    expect(lastFetch().url).toBe('/api/v1/blueprints/my%20preset');

    await api.saveBlueprint('preset', { foo: 1 });
    expect(lastFetch().url).toBe('/api/v1/blueprints');
    expect(JSON.parse(lastFetch().init.body as string)).toEqual({ name: 'preset', data: { foo: 1 } });

    await api.deleteBlueprint('my preset');
    expect(lastFetch().url).toBe('/api/v1/blueprints?name=my%20preset');
    expect(lastFetch().init.method).toBe('DELETE');
  });

  it('fleet ops endpoints hit expected paths', async () => {
    fetchMock
      .mockResolvedValueOnce(jsonResponse([]))
      .mockResolvedValueOnce(jsonResponse([]))
      .mockResolvedValueOnce(jsonResponse([]))
      .mockResolvedValueOnce(jsonResponse({ xmr_per_day: 0.01, usd_per_day: 1, network_hashrate: 1 }))
      .mockResolvedValueOnce(jsonResponse({ success: true }))
      .mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' }))
      .mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'fresh' }))
      .mockResolvedValueOnce(jsonResponse({ success: true, agent: mockAgent() }))
      .mockResolvedValueOnce(jsonResponse({ success: true, sent: 2, failed: 0, action: 'pause' }));

    await api.getAlerts();
    expect(lastFetch().url).toBe('/api/v1/alerts');

    await api.getPoolStatus();
    expect(lastFetch().url).toBe('/api/v1/pools/status');

    await api.getAIActivity();
    expect(lastFetch().url).toBe('/api/v1/ai/activity');

    await api.getEarningsEstimate(1234.5);
    expect(lastFetch().url).toBe('/api/v1/earnings/estimate?hashrate=1234.5');

    await api.sendAgentCommand('a1', 'pause', { reason: 'test' });
    expect(lastFetch().url).toBe('/api/v1/agents/a1/command');
    expect(JSON.parse(lastFetch().init.body as string)).toEqual({ action: 'pause', reason: 'test' });

    await api.getAgentLog('a1');
    expect(lastFetch().url).toBe('/api/v1/agents/a1/log');

    await api.getAgentLog('a1', true);
    expect(lastFetch().url).toBe('/api/v1/agents/a1/log?refresh=1');

    await api.updateAgentMeta('a1', 'notes', ['tag1']);
    expect(lastFetch().url).toBe('/api/v1/agents/a1/meta');
    expect(JSON.parse(lastFetch().init.body as string)).toEqual({ notes: 'notes', tags: ['tag1'] });

    await api.sendBulkCommand(['a1', 'a2'], 'resume');
    expect(lastFetch().url).toBe('/api/v1/agents/bulk-command');
    expect(JSON.parse(lastFetch().init.body as string)).toEqual({
      agent_ids: ['a1', 'a2'],
      action: 'resume',
    });
  });

  it('createUser POSTs credentials', async () => {
    fetchMock.mockResolvedValueOnce(jsonResponse({ success: true }));

    await api.createUser('alice', 'secret');

    expect(lastFetch().url).toBe('/api/v1/users');
    expect(JSON.parse(lastFetch().init.body as string)).toEqual({ username: 'alice', password: 'secret' });
  });

  it('rotateFleetSecret POSTs rotate endpoint', async () => {
    fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, hint: 'abcd1234...' }));

    const res = await api.rotateFleetSecret();

    expect(res.ok).toBe(true);
    expect(lastFetch().url).toBe('/api/v1/server/rotate-secret');
    expect(lastFetch().init.method).toBe('POST');
  });

  it('getXmrPrice and getServerInfo', async () => {
    fetchMock
      .mockResolvedValueOnce(jsonResponse({ usd: 200, updated_at: 'now' }))
      .mockResolvedValueOnce(jsonResponse(mockServerInfo));

    await api.getXmrPrice();
    expect(lastFetch().url).toBe('/api/v1/market/xmr');

    await api.getServerInfo();
    expect(lastFetch().url).toBe('/api/v1/server/info');
  });

  it('cancelBuild DELETEs encoded token', async () => {
    fetchMock.mockResolvedValueOnce(jsonResponse({ cancelled: true }));

    await api.cancelBuild('token/with/slash');

    expect(lastFetch().url).toBe('/api/v1/builder/cancel/token%2Fwith%2Fslash');
    expect(lastFetch().init.method).toBe('DELETE');
  });

  it('getDashboardStats and listBuilds', async () => {
    fetchMock
      .mockResolvedValueOnce(
        jsonResponse({ total_agents: 1, online_agents: 1, total_hashrate: 100, total_shares: 5 })
      )
      .mockResolvedValueOnce(jsonResponse([]));

    await api.getDashboardStats();
    expect(lastFetch().url).toBe('/api/v1/dashboard/stats');

    await api.listBuilds();
    expect(lastFetch().url).toBe('/api/v1/builds');
  });
});
