import { beforeEach, describe, expect, it, vi } from 'vitest';
import { subscribeUnauthorized } from './auth';
import { ApiError, request, requestWithMeta } from './client';

// Reads the X-Csrf header off the object handed to fetch (the client sets it post-normalization).
function csrfHeaderFrom(fetchMock: ReturnType<typeof vi.spyOn>): unknown {
  const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
  return (init?.headers as Record<string, string> | undefined)?.['X-Csrf'];
}

describe('API request client', () => {
  beforeEach(() => {
    window.localStorage.clear();
    vi.restoreAllMocks();
  });

  it.each(['POST', 'PUT', 'PATCH', 'DELETE'] as const)(
    'attaches the X-CSRF header on %s (session-auth mutation, #295)',
    async (method) => {
      const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));

      await request('/api/v1/channels/1', { method });

      expect(fetchMock).toHaveBeenCalledWith(
        '/api/v1/channels/1',
        expect.objectContaining({
          headers: expect.objectContaining({ 'X-Csrf': '1' }),
          method
        })
      );
    }
  );

  it('does not attach the X-CSRF header on a GET', async () => {
    const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
      new Response(JSON.stringify([]), {
        headers: { 'Content-Type': 'application/json' },
        status: 200
      })
    );

    await request('/api/v1/channels');

    expect(fetchMock).toHaveBeenCalledWith(
      '/api/v1/channels',
      expect.objectContaining({
        headers: expect.not.objectContaining({ 'X-Csrf': expect.anything() })
      })
    );
    expect(csrfHeaderFrom(fetchMock)).toBeUndefined();
  });

  it('never sends an X-Api-Key header, even with a legacy ctv-api-key in localStorage', async () => {
    const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
    window.localStorage.setItem('ctv-api-key', 'legacy-secret');

    await request('/api/v1/channels');
    await request('/api/v1/channels/1', { method: 'DELETE' });

    for (const call of fetchMock.mock.calls) {
      const headers = (call[1] as RequestInit | undefined)?.headers as Record<string, string> | undefined;
      expect(headers).not.toHaveProperty('X-Api-Key');
    }
  });

  it('throws an ApiError with problem details for non-OK responses', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ status: 422, title: 'Validation failed', detail: 'Number exists' }), {
        headers: { 'Content-Type': 'application/json' },
        status: 422
      })
    );

    await expect(request('/api/v1/channels', { method: 'POST', body: { name: 'News' } })).rejects.toMatchObject({
      detail: 'Number exists',
      message: 'Validation failed',
      status: 422
    } satisfies Partial<ApiError>);
  });

  it('returns undefined for successful responses without a JSON body', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));

    await expect(request('/api/v1/channels', { method: 'POST' })).resolves.toBeUndefined();
  });

  it('signals unauthorized subscribers on a 401 response', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ status: 401, title: 'Unauthorized' }), {
        headers: { 'Content-Type': 'application/problem+json' },
        status: 401
      })
    );

    const onUnauthorized = vi.fn();
    const unsubscribe = subscribeUnauthorized(onUnauthorized);

    await expect(request('/api/v1/channels')).rejects.toMatchObject({ status: 401 });

    expect(onUnauthorized).toHaveBeenCalledTimes(1);
    unsubscribe();
  });

  it('does NOT signal unauthorized subscribers on a 401 when suppressUnauthorizedSignal is set', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ status: 401, title: 'Unauthorized' }), {
        headers: { 'Content-Type': 'application/problem+json' },
        status: 401
      })
    );

    const onUnauthorized = vi.fn();
    const unsubscribe = subscribeUnauthorized(onUnauthorized);

    await expect(
      request('/api/v1/auth/login', { body: { username: 'x', password: 'y' }, method: 'POST', suppressUnauthorizedSignal: true })
    ).rejects.toMatchObject({ status: 401 });

    expect(onUnauthorized).not.toHaveBeenCalled();
    unsubscribe();
  });

  it('does not signal unauthorized subscribers on other error statuses', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ status: 403, title: 'Forbidden' }), {
        headers: { 'Content-Type': 'application/problem+json' },
        status: 403
      })
    );

    const onUnauthorized = vi.fn();
    const unsubscribe = subscribeUnauthorized(onUnauthorized);

    await expect(request('/api/v1/channels')).rejects.toMatchObject({ status: 403 });

    expect(onUnauthorized).not.toHaveBeenCalled();
    unsubscribe();
  });

  it('does not add a duplicate JSON content type when callers provide lowercase content-type', async () => {
    const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));

    await request('/api/v1/channels', {
      body: { name: 'News' },
      headers: { 'content-type': 'application/merge-patch+json' },
      method: 'PATCH'
    });

    expect(fetchMock).toHaveBeenCalledWith(
      '/api/v1/channels',
      expect.objectContaining({
        headers: expect.not.objectContaining({ 'Content-Type': 'application/json' })
      })
    );
  });

  it('requestWithMeta returns the response ETag alongside the parsed body', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(
      new Response(JSON.stringify([{ id: 1 }]), {
        headers: { ETag: '"7"', 'Content-Type': 'application/json' },
        status: 200
      })
    );

    const result = await requestWithMeta<{ id: number }[]>('/api/v1/blocks/1/items');

    expect(result.etag).toBe('"7"');
    expect(result.data).toEqual([{ id: 1 }]);
  });

  it('requestWithMeta returns a null ETag when the response has none', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ ok: true }), {
        headers: { 'Content-Type': 'application/json' },
        status: 200
      })
    );

    const result = await requestWithMeta('/api/v1/blocks/1/items');

    expect(result.etag).toBeNull();
  });

  it('requestWithMeta surfaces the ETag on a 204 (no body) response', async () => {
    vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { headers: { ETag: '"3"' }, status: 204 }));

    const result = await requestWithMeta('/api/v1/blocks/1', { method: 'PUT' });

    expect(result.data).toBeUndefined();
    expect(result.etag).toBe('"3"');
  });
});
