import { afterEach, describe, expect, test, vi } from 'vitest';
import { api, apiFetch } from './client';

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

function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
  const res = new Response(
    body === null ? null : JSON.stringify(body),
    { status, headers: { 'Content-Type': 'application/json' }, ...init }
  );
  const spy = vi.fn().mockResolvedValue(res);
  vi.stubGlobal('fetch', spy);
  return spy;
}

describe('apiFetch', () => {
  test('resolves with parsed JSON on 200', async () => {
    stubFetch(200, { hello: 'world' });
    const result = await apiFetch('/api/ping');
    expect(result).toEqual({ hello: 'world' });
  });

  test('sends Content-Type application/json by default', async () => {
    const spy = stubFetch(200, {});
    await apiFetch('/api/ping');
    const init = spy.mock.calls[0][1] as RequestInit;
    expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
  });

  test('resolves to null on 204', async () => {
    stubFetch(204, null);
    const result = await apiFetch('/api/ping', { method: 'DELETE' });
    expect(result).toBeNull();
  });

  test('throws ApiError from error envelope on 4xx', async () => {
    stubFetch(404, { error: { code: 'not_found', message: 'track not found' } });
    await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({
      code: 'not_found',
      message: 'track not found',
      status: 404
    });
  });

  test('degrades to {code:unknown, message:statusText} on non-JSON error body', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
      new Response('<html>500</html>', { status: 500, statusText: 'Internal Server Error' })
    ));
    await expect(apiFetch('/api/ping')).rejects.toMatchObject({
      code: 'unknown',
      message: 'Internal Server Error',
      status: 500
    });
  });
});

describe('api.get/post/del', () => {
  test('api.get returns typed JSON on 200', async () => {
    stubFetch(200, { id: 'abc', name: 'A' });
    type T = { id: string; name: string };
    const result = await api.get<T>('/api/artists/abc');
    expect(result).toEqual({ id: 'abc', name: 'A' });
  });

  test('api.post serializes body and sets method', async () => {
    const spy = stubFetch(200, { ok: true });
    await api.post('/api/auth/login', { username: 'u', password: 'p' });
    const init = spy.mock.calls[0][1] as RequestInit;
    expect(init.method).toBe('POST');
    expect(init.body).toBe(JSON.stringify({ username: 'u', password: 'p' }));
  });

  test('api.del sends DELETE and resolves to null on 204', async () => {
    const spy = stubFetch(204, null);
    const result = await api.del('/api/sessions/xyz');
    expect(spy.mock.calls[0][1]).toMatchObject({ method: 'DELETE' });
    expect(result).toBeNull();
  });
});

describe('apiFetch 401 interceptor', () => {
  test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
    const logoutSpy = vi.fn();
    vi.doMock('$lib/auth/store.svelte', () => ({
      logout: logoutSpy,
      login: vi.fn(),
      bootstrap: vi.fn(),
      user: { value: null }
    }));
    // Re-import to pick up the mock.
    const { apiFetch: apiFetchFresh } = await import('./client');
    stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } });
    await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({
      code: 'unauthorized',
      status: 401
    });
    expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
    vi.doUnmock('$lib/auth/store.svelte');
  });
});
