import { afterEach, describe, expect, test, vi } from 'vitest';
import { fetchRemoteData, RemoteFetchError } from './remote-data';

/** A minimal Response stand-in for the bits the adapter reads. */
function fakeResponse(opts: {
  ok: boolean;
  status?: number;
  statusText?: string;
  body?: string;
  contentType?: string | null;
  contentLength?: number;
}): Response {
  const headers: Record<string, string | null> = {
    'content-type': opts.contentType ?? null,
    'content-length': opts.contentLength !== undefined ? String(opts.contentLength) : null,
  };
  return {
    ok: opts.ok,
    status: opts.status ?? (opts.ok ? 200 : 500),
    statusText: opts.statusText ?? '',
    text: () => Promise.resolve(opts.body ?? ''),
    headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
  } as unknown as Response;
}

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

describe('fetchRemoteData', () => {
  test('returns the body and content-type on success', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() =>
        Promise.resolve(fakeResponse({ ok: true, body: 'a,b\n1,2', contentType: 'text/csv' })),
      ),
    );
    const result = await fetchRemoteData('https://example.com/data.csv');
    expect(result.text).toBe('a,b\n1,2');
    expect(result.contentType).toBe('text/csv');
  });

  test('classifies a non-2xx response as http with its status', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() =>
        Promise.resolve(fakeResponse({ ok: false, status: 404, statusText: 'Not Found' })),
      ),
    );
    await expect(fetchRemoteData('https://example.com/missing.csv')).rejects.toMatchObject({
      reason: 'http',
      status: 404,
    });
  });

  test('classifies a rejected fetch (CORS / offline) as network', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))),
    );
    await expect(fetchRemoteData('https://blocked.example/data.csv')).rejects.toMatchObject({
      reason: 'network',
    });
  });

  test('classifies an empty body as empty', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() => Promise.resolve(fakeResponse({ ok: true, body: '   \n' }))),
    );
    await expect(fetchRemoteData('https://example.com/blank.csv')).rejects.toMatchObject({
      reason: 'empty',
    });
  });

  test('classifies an aborted fetch as timeout', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() => {
        const e = new Error('aborted');
        e.name = 'AbortError';
        return Promise.reject(e);
      }),
    );
    await expect(
      fetchRemoteData('https://slow.example/data.csv', { timeoutMs: 1 }),
    ).rejects.toMatchObject({
      reason: 'timeout',
    });
  });

  test('rejects an oversized body declared by Content-Length, before reading it', async () => {
    const text = vi.fn(() => Promise.resolve('x'));
    vi.stubGlobal(
      'fetch',
      vi.fn(() =>
        Promise.resolve({
          ok: true,
          status: 200,
          statusText: '',
          text,
          headers: { get: (k: string) => (k.toLowerCase() === 'content-length' ? '999' : null) },
        } as unknown as Response),
      ),
    );
    await expect(
      fetchRemoteData('https://example.com/huge.csv', { maxBytes: 100 }),
    ).rejects.toMatchObject({ reason: 'too-large' });
    expect(text).not.toHaveBeenCalled(); // never read the body
  });

  test('rejects an oversized body as a backstop when no length is declared', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() => Promise.resolve(fakeResponse({ ok: true, body: 'abcdefghij' }))),
    );
    await expect(
      fetchRemoteData('https://example.com/chunked.csv', { maxBytes: 5 }),
    ).rejects.toMatchObject({ reason: 'too-large' });
  });

  test('the thrown error is a RemoteFetchError', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))),
    );
    await expect(fetchRemoteData('https://x.example')).rejects.toBeInstanceOf(RemoteFetchError);
  });
});
