import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

/** Recorded delays passed to the mocked sleep function. */
const recordedDelays: number[] = [];

// Mock the sleep module to resolve instantly and record delays
vi.mock("./sleep", () => ({
  sleep: vi.fn((ms: number): Promise<void> => {
    recordedDelays.push(ms);
    return Promise.resolve();
  }),
}));

import { fetchWithRetry } from "./fetch-with-retry";
import { sleep } from "./sleep";

/**
 * Helper: create a minimal Response object for mocking fetch.
 */
function mockResponse(status: number, ok?: boolean): Response {
  return {
    ok: ok ?? (status >= 200 && status < 300),
    status,
    statusText: status === 200 ? "OK" : "Error",
    headers: new Headers(),
    redirected: false,
    type: "basic" as ResponseType,
    url: "",
    body: null,
    bodyUsed: false,
    clone: vi.fn() as unknown as () => Response,
    arrayBuffer: vi.fn() as unknown as () => Promise<ArrayBuffer>,
    blob: vi.fn() as unknown as () => Promise<Blob>,
    formData: vi.fn() as unknown as () => Promise<FormData>,
    json: vi.fn() as unknown as () => Promise<unknown>,
    text: vi.fn() as unknown as () => Promise<string>,
    bytes: vi.fn() as unknown as () => Promise<Uint8Array>,
  } as Response;
}

describe("fetchWithRetry", (): void => {
  const originalFetch = global.fetch;
  const sleepMock = vi.mocked(sleep);

  beforeEach((): void => {
    global.fetch = vi.fn();
    recordedDelays.length = 0;
    sleepMock.mockClear();
  });

  afterEach((): void => {
    vi.restoreAllMocks();
    global.fetch = originalFetch;
  });

  it("should succeed on first attempt without retrying", async (): Promise<void> => {
    const okResponse = mockResponse(200);
    vi.mocked(global.fetch).mockResolvedValueOnce(okResponse);

    const result = await fetchWithRetry("https://api.example.com/auth/config");

    expect(result).toBe(okResponse);
    expect(global.fetch).toHaveBeenCalledTimes(1);
    expect(sleepMock).not.toHaveBeenCalled();
  });

  it("should retry on network error and succeed on 2nd attempt", async (): Promise<void> => {
    const okResponse = mockResponse(200);
    vi.mocked(global.fetch)
      .mockRejectedValueOnce(new TypeError("Failed to fetch"))
      .mockResolvedValueOnce(okResponse);

    const result = await fetchWithRetry("https://api.example.com/auth/config");

    expect(result).toBe(okResponse);
    expect(global.fetch).toHaveBeenCalledTimes(2);
    expect(sleepMock).toHaveBeenCalledTimes(1);
  });

  it("should retry on server error (500) and succeed on 3rd attempt", async (): Promise<void> => {
    const serverError = mockResponse(500);
    const okResponse = mockResponse(200);

    vi.mocked(global.fetch)
      .mockResolvedValueOnce(serverError)
      .mockResolvedValueOnce(serverError)
      .mockResolvedValueOnce(okResponse);

    const result = await fetchWithRetry("https://api.example.com/auth/config");

    expect(result).toBe(okResponse);
    expect(global.fetch).toHaveBeenCalledTimes(3);
    expect(sleepMock).toHaveBeenCalledTimes(2);
  });

  it("should give up after maxRetries and throw the last error", async (): Promise<void> => {
    const networkError = new TypeError("Failed to fetch");
    vi.mocked(global.fetch)
      .mockRejectedValueOnce(networkError)
      .mockRejectedValueOnce(networkError)
      .mockRejectedValueOnce(networkError)
      .mockRejectedValueOnce(networkError);

    await expect(
      fetchWithRetry("https://api.example.com/auth/config", undefined, {
        maxRetries: 3,
        baseDelayMs: 1000,
      })
    ).rejects.toThrow("Failed to fetch");

    // 1 initial + 3 retries = 4 total attempts
    expect(global.fetch).toHaveBeenCalledTimes(4);
    // Sleep called for the 3 retries (not after the final failure)
    expect(sleepMock).toHaveBeenCalledTimes(3);
  });

  it("should NOT retry on non-retryable errors (401)", async (): Promise<void> => {
    const unauthorizedResponse = mockResponse(401);
    vi.mocked(global.fetch).mockResolvedValueOnce(unauthorizedResponse);

    const result = await fetchWithRetry("https://api.example.com/auth/config");

    expect(result).toBe(unauthorizedResponse);
    expect(global.fetch).toHaveBeenCalledTimes(1);
    expect(sleepMock).not.toHaveBeenCalled();
  });

  it("should NOT retry on non-retryable errors (403)", async (): Promise<void> => {
    const forbiddenResponse = mockResponse(403);
    vi.mocked(global.fetch).mockResolvedValueOnce(forbiddenResponse);

    const result = await fetchWithRetry("https://api.example.com/auth/config");

    expect(result).toBe(forbiddenResponse);
    expect(global.fetch).toHaveBeenCalledTimes(1);
    expect(sleepMock).not.toHaveBeenCalled();
  });

  it("should NOT retry on non-retryable errors (429)", async (): Promise<void> => {
    const rateLimitedResponse = mockResponse(429);
    vi.mocked(global.fetch).mockResolvedValueOnce(rateLimitedResponse);

    const result = await fetchWithRetry("https://api.example.com/auth/config");

    expect(result).toBe(rateLimitedResponse);
    expect(global.fetch).toHaveBeenCalledTimes(1);
    expect(sleepMock).not.toHaveBeenCalled();
  });

  it("should respect custom maxRetries option", async (): Promise<void> => {
    const networkError = new TypeError("Failed to fetch");
    vi.mocked(global.fetch).mockRejectedValueOnce(networkError).mockRejectedValueOnce(networkError);

    await expect(
      fetchWithRetry("https://api.example.com/auth/config", undefined, {
        maxRetries: 1,
        baseDelayMs: 50,
      })
    ).rejects.toThrow("Failed to fetch");

    // 1 initial + 1 retry = 2 total attempts
    expect(global.fetch).toHaveBeenCalledTimes(2);
    expect(sleepMock).toHaveBeenCalledTimes(1);
  });

  it("should respect custom baseDelayMs option", async (): Promise<void> => {
    const okResponse = mockResponse(200);
    vi.mocked(global.fetch)
      .mockRejectedValueOnce(new TypeError("Failed to fetch"))
      .mockResolvedValueOnce(okResponse);

    await fetchWithRetry("https://api.example.com/auth/config", undefined, {
      baseDelayMs: 500,
    });

    // First retry delay should be 500ms (baseDelayMs * 2^0)
    expect(recordedDelays[0]).toBe(500);
  });

  it("should use exponential backoff with doubling delays (1s, 2s, 4s)", async (): Promise<void> => {
    const networkError = new TypeError("Failed to fetch");
    const okResponse = mockResponse(200);

    vi.mocked(global.fetch)
      .mockRejectedValueOnce(networkError)
      .mockRejectedValueOnce(networkError)
      .mockRejectedValueOnce(networkError)
      .mockResolvedValueOnce(okResponse);

    const result = await fetchWithRetry("https://api.example.com/auth/config", undefined, {
      baseDelayMs: 1000,
      backoffFactor: 2,
    });

    expect(result).toBe(okResponse);
    expect(global.fetch).toHaveBeenCalledTimes(4);

    // Verify exponential backoff: 1000 * 2^0, 1000 * 2^1, 1000 * 2^2
    expect(recordedDelays).toEqual([1000, 2000, 4000]);
  });

  it("should log retry attempts in all environments", async (): Promise<void> => {
    const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);

    const okResponse = mockResponse(200);
    vi.mocked(global.fetch)
      .mockRejectedValueOnce(new TypeError("Failed to fetch"))
      .mockResolvedValueOnce(okResponse);

    await fetchWithRetry("https://api.example.com/auth/config");

    expect(warnSpy).toHaveBeenCalledTimes(1);
    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("[Auth] Retry 1/3"));

    warnSpy.mockRestore();
  });

  it("should log retry attempts for HTTP errors", async (): Promise<void> => {
    const warnSpy = vi.spyOn(console, "warn").mockReturnValue(undefined);

    const serverError = mockResponse(500);
    const okResponse = mockResponse(200);

    vi.mocked(global.fetch).mockResolvedValueOnce(serverError).mockResolvedValueOnce(okResponse);

    await fetchWithRetry("https://api.example.com/auth/config");

    expect(warnSpy).toHaveBeenCalledTimes(1);
    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining("[Auth] Retry 1/3 after HTTP 500")
    );

    warnSpy.mockRestore();
  });

  it("should forward RequestInit options to fetch", async (): Promise<void> => {
    const okResponse = mockResponse(200);
    vi.mocked(global.fetch).mockResolvedValueOnce(okResponse);

    const requestOptions: RequestInit = {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token: "abc" }),
    };

    await fetchWithRetry("https://api.example.com/auth/config", requestOptions);

    expect(global.fetch).toHaveBeenCalledWith(
      "https://api.example.com/auth/config",
      requestOptions
    );
  });

  it("should not retry on non-retryable thrown errors", async (): Promise<void> => {
    // An Error that parseAuthError classifies as non-retryable (e.g., "Unauthorized")
    const nonRetryableError = new Error("Unauthorized");
    vi.mocked(global.fetch).mockRejectedValueOnce(nonRetryableError);

    await expect(fetchWithRetry("https://api.example.com/auth/config")).rejects.toThrow(
      "Unauthorized"
    );

    expect(global.fetch).toHaveBeenCalledTimes(1);
    expect(sleepMock).not.toHaveBeenCalled();
  });

  it("should return last non-ok response when server errors exhaust retries", async (): Promise<void> => {
    const serverError = mockResponse(500);
    vi.mocked(global.fetch)
      .mockResolvedValueOnce(serverError)
      .mockResolvedValueOnce(serverError)
      .mockResolvedValueOnce(serverError);

    const result = await fetchWithRetry("https://api.example.com/auth/config", undefined, {
      maxRetries: 2,
    });

    expect(result.status).toBe(500);
    // 1 initial + 2 retries = 3 total attempts
    expect(global.fetch).toHaveBeenCalledTimes(3);
    expect(sleepMock).toHaveBeenCalledTimes(2);
  });

  describe("RetryOptions value clamping", (): void => {
    it("should clamp negative maxRetries to 0 (no retries)", async (): Promise<void> => {
      const serverError = mockResponse(500);
      vi.mocked(global.fetch).mockResolvedValueOnce(serverError);

      const result = await fetchWithRetry("https://api.example.com/auth/config", undefined, {
        maxRetries: -5,
      });

      // maxRetries clamped to 0 means only the initial attempt, no retries
      expect(result.status).toBe(500);
      expect(global.fetch).toHaveBeenCalledTimes(1);
      expect(sleepMock).not.toHaveBeenCalled();
    });

    it("should clamp fractional maxRetries by flooring", async (): Promise<void> => {
      const networkError = new TypeError("Failed to fetch");
      const okResponse = mockResponse(200);
      vi.mocked(global.fetch).mockRejectedValueOnce(networkError).mockResolvedValueOnce(okResponse);

      const result = await fetchWithRetry("https://api.example.com/auth/config", undefined, {
        maxRetries: 1.9,
        baseDelayMs: 100,
      });

      // 1.9 floors to 1, so 1 initial + 1 retry = 2 attempts
      expect(result).toBe(okResponse);
      expect(global.fetch).toHaveBeenCalledTimes(2);
      expect(sleepMock).toHaveBeenCalledTimes(1);
    });

    it("should clamp baseDelayMs below 100 up to 100", async (): Promise<void> => {
      const okResponse = mockResponse(200);
      vi.mocked(global.fetch)
        .mockRejectedValueOnce(new TypeError("Failed to fetch"))
        .mockResolvedValueOnce(okResponse);

      await fetchWithRetry("https://api.example.com/auth/config", undefined, {
        baseDelayMs: 0,
      });

      // baseDelayMs clamped to 100, so first retry delay = 100 * 2^0 = 100
      expect(recordedDelays[0]).toBe(100);
    });

    it("should clamp backoffFactor below 1 up to 1 (linear delay)", async (): Promise<void> => {
      const networkError = new TypeError("Failed to fetch");
      const okResponse = mockResponse(200);
      vi.mocked(global.fetch)
        .mockRejectedValueOnce(networkError)
        .mockRejectedValueOnce(networkError)
        .mockResolvedValueOnce(okResponse);

      await fetchWithRetry("https://api.example.com/auth/config", undefined, {
        maxRetries: 3,
        baseDelayMs: 200,
        backoffFactor: 0,
      });

      // backoffFactor clamped to 1, so delays are 200*1^0=200, 200*1^1=200 (constant)
      expect(recordedDelays).toEqual([200, 200]);
    });
  });
});
