import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Fetch } from './types';
import { FetchError } from './error';
import { createFetch } from './fetch';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeFetchMock(
  body: unknown = { ok: true },
  init: ResponseInit = { status: 200 },
  contentType = 'application/json',
) {
  return vi.fn<Fetch>().mockResolvedValue(
    new Response(typeof body === 'string' ? body : JSON.stringify(body), {
      ...init,
      headers: { 'content-type': contentType, ...init.headers },
    }),
  );
}

// ---------------------------------------------------------------------------
// Basic fetch
// ---------------------------------------------------------------------------

describe('createFetch — basic', () => {
  it('returns parsed JSON body', async () => {
    const fetchMock = makeFetchMock({ id: 1 });
    const $fetch = createFetch({ fetch: fetchMock });

    const data = await $fetch<{ id: number }>('https://api.example.com/user');

    expect(data).toEqual({ id: 1 });
    expect(fetchMock).toHaveBeenCalledOnce();
  });

  it('passes options through to the underlying fetch', async () => {
    const fetchMock = makeFetchMock({ done: true });
    const $fetch = createFetch({ fetch: fetchMock });

    await $fetch('https://api.example.com/task', {
      method: 'POST',
      headers: { 'x-token': 'abc' },
    });

    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect((init.headers as Headers).get('x-token')).toBe('abc');
    expect(init.method).toBe('POST');
  });

  it('uppercases the HTTP method', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });

    await $fetch('https://api.example.com', { method: 'post' });

    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.method).toBe('POST');
  });
});

// ---------------------------------------------------------------------------
// raw
// ---------------------------------------------------------------------------

describe('$fetch.raw', () => {
  it('returns a Response with _data', async () => {
    const fetchMock = makeFetchMock({ value: 42 });
    const $fetch = createFetch({ fetch: fetchMock });

    const response = await $fetch.raw<{ value: number }>('https://api.example.com');

    expect(response).toBeInstanceOf(Response);
    expect(response._data).toEqual({ value: 42 });
    expect(response.status).toBe(200);
  });
});

// ---------------------------------------------------------------------------
// Method shortcuts
// ---------------------------------------------------------------------------

describe('method shortcuts', () => {
  it('$fetch.get sends a GET request', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    await $fetch.get('https://api.example.com/items');
    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.method).toBe('GET');
  });

  it('$fetch.post sends a POST request', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    await $fetch.post('https://api.example.com/items', { body: { name: 'x' } });
    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.method).toBe('POST');
  });

  it('$fetch.put sends a PUT request', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    await $fetch.put('https://api.example.com/items/1', { body: { name: 'y' } });
    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.method).toBe('PUT');
  });

  it('$fetch.patch sends a PATCH request', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    await $fetch.patch('https://api.example.com/items/1', { body: { name: 'z' } });
    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.method).toBe('PATCH');
  });

  it('$fetch.delete sends a DELETE request', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    await $fetch.delete('https://api.example.com/items/1');
    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.method).toBe('DELETE');
  });
});

// ---------------------------------------------------------------------------
// baseURL
// ---------------------------------------------------------------------------

describe('baseURL', () => {
  it('prepends baseURL to a relative path', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });

    await $fetch('/users', { baseURL: 'https://api.example.com/v1' });

    const [url] = fetchMock.mock.calls[0] as [string];
    expect(url).toBe('https://api.example.com/v1/users');
  });

  it('inherits baseURL from create() defaults', async () => {
    const fetchMock = makeFetchMock({});
    const api = createFetch({ fetch: fetchMock }).create({ baseURL: 'https://api.example.com' });

    await api('/health');

    const [url] = fetchMock.mock.calls[0] as [string];
    expect(url).toBe('https://api.example.com/health');
  });
});

// ---------------------------------------------------------------------------
// Query params
// ---------------------------------------------------------------------------

describe('query params', () => {
  it('appends query to the request URL', async () => {
    const fetchMock = makeFetchMock([]);
    const $fetch = createFetch({ fetch: fetchMock });

    await $fetch('https://api.example.com/items', { query: { page: 2, limit: 10 } });

    const [url] = fetchMock.mock.calls[0] as [string];
    expect(url).toContain('page=2');
    expect(url).toContain('limit=10');
  });

  it('merges default query with per-request query', async () => {
    const fetchMock = makeFetchMock([]);
    const $fetch = createFetch({ fetch: fetchMock }).create({
      baseURL: 'https://api.example.com',
      query: { version: 2 },
    });

    await $fetch('/items', { query: { page: 1 } });

    const [url] = fetchMock.mock.calls[0] as [string];
    expect(url).toContain('version=2');
    expect(url).toContain('page=1');
  });
});

// ---------------------------------------------------------------------------
// JSON body serialisation
// ---------------------------------------------------------------------------

describe('JSON body serialisation', () => {
  it('serialises plain objects and sets content-type to application/json', async () => {
    const fetchMock = makeFetchMock({ ok: true });
    const $fetch = createFetch({ fetch: fetchMock });

    await $fetch('https://api.example.com/users', {
      method: 'POST',
      body: { name: 'Alice' },
    });

    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.body).toBe('{"name":"Alice"}');
    expect((init.headers as Headers).get('content-type')).toBe('application/json');
  });

  it('respects a pre-set content-type header', async () => {
    const fetchMock = makeFetchMock({ ok: true });
    const $fetch = createFetch({ fetch: fetchMock });

    await $fetch('https://api.example.com/form', {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: { key: 'value' },
    });

    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.body).toBe('key=value');
  });

  it('passes a raw string body through without forcing a JSON content-type', async () => {
    const fetchMock = makeFetchMock({ ok: true });
    const $fetch = createFetch({ fetch: fetchMock });

    await $fetch('https://api.example.com/raw', {
      method: 'POST',
      body: 'plain text payload',
    });

    const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
    expect(init.body).toBe('plain text payload');
    expect((init.headers as Headers).get('content-type')).toBeNull();
    expect((init.headers as Headers).get('accept')).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------

describe('error handling', () => {
  it('throws FetchError on 4xx response', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      new Response('{"error":"not found"}', {
        status: 404,
        statusText: 'Not Found',
        headers: { 'content-type': 'application/json' },
      }),
    );
    const $fetch = createFetch({ fetch: fetchMock });

    await expect($fetch('https://api.example.com/missing')).rejects.toBeInstanceOf(FetchError);
  });

  it('throws FetchError on 5xx response', async () => {
    // Use mockImplementation so each retry attempt gets a fresh Response (body not yet read)
    const fetchMock = vi
      .fn()
      .mockImplementation(async () => new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }));
    const $fetch = createFetch({ fetch: fetchMock });

    await expect($fetch('https://api.example.com/crash')).rejects.toThrow(FetchError);
  });

  it('does not throw when ignoreResponseError is true', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      new Response('{"error":"bad request"}', {
        status: 400,
        headers: { 'content-type': 'application/json' },
      }),
    );
    const $fetch = createFetch({ fetch: fetchMock });

    await expect(
      $fetch('https://api.example.com/bad', { ignoreResponseError: true }),
    ).resolves.toEqual({ error: 'bad request' });
  });

  it('throws FetchError on network error', async () => {
    const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
    const $fetch = createFetch({ fetch: fetchMock });

    await expect($fetch('https://api.example.com/offline')).rejects.toBeInstanceOf(FetchError);
  });
});

// ---------------------------------------------------------------------------
// Retry
// ---------------------------------------------------------------------------

describe('retry', () => {
  it('retries once on 500 by default for GET', async () => {
    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce(new Response('error', { status: 500 }))
      .mockResolvedValueOnce(
        new Response('{"ok":true}', {
          status: 200,
          headers: { 'content-type': 'application/json' },
        }),
      );
    const $fetch = createFetch({ fetch: fetchMock });

    const data = await $fetch('https://api.example.com/flaky');

    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(data).toEqual({ ok: true });
  });

  it('does not retry POST by default', async () => {
    const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 }));
    const $fetch = createFetch({ fetch: fetchMock });

    await expect(
      $fetch('https://api.example.com/task', { method: 'POST' }),
    ).rejects.toBeInstanceOf(FetchError);

    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('respects retry: false', async () => {
    const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 503 }));
    const $fetch = createFetch({ fetch: fetchMock });

    await expect(
      $fetch('https://api.example.com/flaky', { retry: false }),
    ).rejects.toBeInstanceOf(FetchError);

    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('respects custom retryStatusCodes', async () => {
    const fetchMock = vi
      .fn()
      .mockResolvedValueOnce(new Response('', { status: 418 }))
      .mockResolvedValueOnce(
        new Response('{"ok":true}', {
          status: 200,
          headers: { 'content-type': 'application/json' },
        }),
      );
    const $fetch = createFetch({ fetch: fetchMock });

    const data = await $fetch('https://api.example.com/teapot', {
      retryStatusCodes: [418],
    });

    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(data).toEqual({ ok: true });
  });

  it('does not retry a user-initiated abort', async () => {
    const controller = new AbortController();
    const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) =>
      new Promise((_resolve, reject) => {
        const signal = init.signal as AbortSignal;
        signal.addEventListener('abort', () => reject(signal.reason));
      }),
    );
    const $fetch = createFetch({ fetch: fetchMock });

    const promise = $fetch('https://api.example.com/cancel', {
      signal: controller.signal,
      retry: 3,
    });
    controller.abort();

    await expect(promise).rejects.toBeInstanceOf(FetchError);
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('clears a stale error on a successful retry before onResponse runs', async () => {
    const fetchMock = vi
      .fn()
      .mockRejectedValueOnce(new TypeError('network down'))
      .mockResolvedValueOnce(
        new Response('{"ok":true}', {
          status: 200,
          headers: { 'content-type': 'application/json' },
        }),
      );
    const $fetch = createFetch({ fetch: fetchMock });

    let errorInResponseHook: unknown = 'unset';
    const data = await $fetch('https://api.example.com/flaky', {
      onResponse: (ctx) => {
        errorInResponseHook = ctx.error;
      },
    });

    expect(data).toEqual({ ok: true });
    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(errorInResponseHook).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// Lifecycle hooks
// ---------------------------------------------------------------------------

describe('lifecycle hooks', () => {
  it('calls onRequest before sending', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    const calls: string[] = [];

    await $fetch('https://api.example.com', {
      onRequest: () => {
        calls.push('request');
      },
    });

    expect(calls).toContain('request');
    expect(calls.indexOf('request')).toBeLessThan(1);
  });

  it('calls onResponse after a successful response', async () => {
    const fetchMock = makeFetchMock({ data: 1 });
    const $fetch = createFetch({ fetch: fetchMock });
    const calls: string[] = [];

    await $fetch('https://api.example.com', {
      onResponse: () => {
        calls.push('response');
      },
    });

    expect(calls).toContain('response');
  });

  it('calls onResponseError for 4xx responses', async () => {
    const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 401 }));
    const $fetch = createFetch({ fetch: fetchMock });
    const calls: string[] = [];

    await expect(
      $fetch('https://api.example.com/protected', {
        retry: false,
        onResponseError: () => {
          calls.push('responseError');
        },
      }),
    ).rejects.toBeInstanceOf(FetchError);

    expect(calls).toContain('responseError');
  });

  it('calls onRequestError on network failure', async () => {
    const fetchMock = vi.fn().mockRejectedValue(new TypeError('Network error'));
    const $fetch = createFetch({ fetch: fetchMock });
    const calls: string[] = [];

    await expect(
      $fetch('https://api.example.com/offline', {
        retry: false,
        onRequestError: () => {
          calls.push('requestError');
        },
      }),
    ).rejects.toBeInstanceOf(FetchError);

    expect(calls).toContain('requestError');
  });

  it('supports multiple hooks as an array', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    const calls: number[] = [];

    await $fetch('https://api.example.com', {
      onRequest: [
        () => {
          calls.push(1);
        },
        () => {
          calls.push(2);
        },
      ],
    });

    expect(calls).toEqual([1, 2]);
  });
});

// ---------------------------------------------------------------------------
// create / extend
// ---------------------------------------------------------------------------

describe('create and extend', () => {
  it('creates a new instance with merged defaults', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    const api = $fetch.create({ baseURL: 'https://api.example.com' });

    await api('/ping');

    const [url] = fetchMock.mock.calls[0] as [string];
    expect(url).toBe('https://api.example.com/ping');
  });

  it('extend is an alias for create', async () => {
    const fetchMock = makeFetchMock({});
    const $fetch = createFetch({ fetch: fetchMock });
    const api = $fetch.extend({ baseURL: 'https://api.example.com' });

    await api('/ping');

    const [url] = fetchMock.mock.calls[0] as [string];
    expect(url).toBe('https://api.example.com/ping');
  });
});

// ---------------------------------------------------------------------------
// Response type variants
// ---------------------------------------------------------------------------

describe('response types', () => {
  it('returns text when responseType is "text"', async () => {
    const fetchMock = vi
      .fn()
      .mockResolvedValue(new Response('hello world', { headers: { 'content-type': 'text/plain' } }));
    const $fetch = createFetch({ fetch: fetchMock });

    const text = await $fetch<string, 'text'>('https://api.example.com/text', {
      responseType: 'text',
    });

    expect(text).toBe('hello world');
  });

  it('returns a Blob when responseType is "blob"', async () => {
    const fetchMock = vi
      .fn()
      .mockResolvedValue(new Response('binary', { headers: { 'content-type': 'image/png' } }));
    const $fetch = createFetch({ fetch: fetchMock });

    const blob = await $fetch<Blob, 'blob'>('https://api.example.com/img', {
      responseType: 'blob',
    });

    expect(blob).toBeInstanceOf(Blob);
  });

  it('uses a custom parseResponse function', async () => {
    const fetchMock = vi
      .fn<Fetch>()
      .mockResolvedValue(
        new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }),
      );
    const $fetch = createFetch({ fetch: fetchMock });

    const data = await $fetch<{ value: number; custom: boolean }>('https://api.example.com/custom', {
      parseResponse: text => ({ ...(JSON.parse(text) as { value: number }), custom: true }),
    });

    expect(data).toEqual({ value: 10, custom: true });
  });
});

// ---------------------------------------------------------------------------
// Timeout
// ---------------------------------------------------------------------------

describe('timeout', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

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

  it('aborts a request that exceeds the timeout', async () => {
    // fetchMock that never resolves until the signal fires
    const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
      return new Promise((_resolve, reject) => {
        (init.signal as AbortSignal).addEventListener('abort', () => {
          reject(new DOMException('The operation was aborted.', 'AbortError'));
        });
      });
    });

    const $fetch = createFetch({ fetch: fetchMock });

    const promise = $fetch('https://api.example.com/slow', { timeout: 100, retry: false });

    vi.advanceTimersByTime(200);

    await expect(promise).rejects.toBeInstanceOf(FetchError);
  });

  it('uses a fresh, un-aborted timeout signal on each retry attempt', async () => {
    let attempt = 0;
    const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
      attempt += 1;
      const signal = init.signal as AbortSignal;

      // First attempt hangs until its own timeout fires.
      if (attempt === 1) {
        return new Promise((_resolve, reject) => {
          signal.addEventListener('abort', () => reject(signal.reason));
        });
      }

      // Retry must receive a brand-new signal, not the already-aborted one.
      expect(signal.aborted).toBe(false);
      return Promise.resolve(
        new Response('{"ok":true}', {
          status: 200,
          headers: { 'content-type': 'application/json' },
        }),
      );
    });

    const $fetch = createFetch({ fetch: fetchMock });
    const promise = $fetch('https://api.example.com/slow', { timeout: 100 });

    // Fire attempt-1 timeout and let the retry proceed to attempt 2.
    await vi.advanceTimersByTimeAsync(100);

    await expect(promise).resolves.toEqual({ ok: true });
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
});
