import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useFetchData, _resetInFlightRequests } from "../useFetchData";
import type { FetchError } from "../../types/api";

// Clear the module-level inFlightRequests map between tests to prevent state leakage
beforeEach(() => {
  _resetInFlightRequests();
});

describe("useFetchData", () => {
  it("fetches and selects data on mount", async () => {
    const fetcher = vi.fn().mockResolvedValue({ value: "test" });
    const selector = vi.fn((response: { value: string }) => response.value);

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
      })
    );

    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBeUndefined();

    await act(async () => {
      await Promise.resolve();
    });

    expect(fetcher).toHaveBeenCalledTimes(1);
    expect(selector).toHaveBeenCalledWith({ value: "test" });
    expect(result.current.data).toBe("test");
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBeNull();
  });

  it("uses initial data if provided", () => {
    const fetcher = vi.fn().mockResolvedValue({ value: "updated" });
    const selector = vi.fn();

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        initialData: "initial",
      })
    );

    expect(result.current.data).toBe("initial");
    expect(result.current.loading).toBe(true);
  });

  it("sets typed error when fetcher rejects", async () => {
    const fetcher = vi.fn().mockRejectedValue(new Error("network error"));
    const selector = vi.fn();

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    const error = result.current.error as FetchError | null;
    expect(error).not.toBeNull();
    expect(error?.type).toBe("network_error");
    expect(result.current.loading).toBe(false);
    expect(result.current.data).toBeUndefined();
  });

  it("calls onSuccess callback after successful fetch", async () => {
    const response = { value: "success" };
    const fetcher = vi.fn().mockResolvedValue(response);
    const selector = vi.fn((r: typeof response) => r.value);
    const onSuccess = vi.fn();

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed",
        onSuccess,
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    expect(onSuccess).toHaveBeenCalledWith(response);
    expect(result.current.data).toBe("success");
  });

  it("refresh triggers a new fetch", async () => {
    const fetcher = vi
      .fn()
      .mockResolvedValueOnce({ value: "first" })
      .mockResolvedValueOnce({ value: "second" });
    const selector = vi.fn((response: { value: string }) => response.value);

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    expect(result.current.data).toBe("first");

    await act(async () => {
      result.current.refresh();
      await Promise.resolve();
    });

    expect(fetcher).toHaveBeenCalledTimes(2);
    expect(result.current.data).toBe("second");
  });

  it("aborts previous request when refresh is called", async () => {
    const abortedSignals: AbortSignal[] = [];
    const fetcher = vi.fn().mockImplementation(async (signal: AbortSignal) => {
      abortedSignals.push(signal);
      await new Promise((resolve) => setTimeout(resolve, 50));
      return { value: "done" };
    });
    const selector = vi.fn();

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    // Trigger refresh
    await act(async () => {
      result.current.refresh();
      await Promise.resolve();
    });

    // First signal should have been aborted
    expect(abortedSignals[0]?.aborted).toBe(true);
  });

  it("does not update state when signal is aborted", async () => {
    const fetcher = vi.fn().mockImplementation(async () => {
      await new Promise((resolve) => setTimeout(resolve, 50));
      return { value: "late" };
    });
    const selector = vi.fn((response: { value: string }) => response.value);

    const { result, unmount } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
      })
    );

    // Unmount immediately
    await act(async () => {
      await Promise.resolve();
    });
    unmount();

    // Data should not be updated
    expect(result.current.data).toBeUndefined();
    expect(result.current.loading).toBe(true); // frozen at true due to abort
  });

  it("silently handles abort errors", async () => {
    const fetcher = vi
      .fn()
      .mockRejectedValue(new DOMException("Aborted", "AbortError"));
    const selector = vi.fn();

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    // Abort errors should not set error state
    expect(result.current.error).toBeNull();
  });

  it("exposes typed API error information", async () => {
    const fetcher = vi.fn().mockImplementation(() => {
      return Promise.reject((() => {
        const error = new Error("API error 500: Server error");
        (error as any).name = "ApiError";
        (error as any).status = 500;
        (error as any).body = "Server error";
        return error;
      })());
    });
    const selector = vi.fn();

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    const error = result.current.error as FetchError | null;
    expect(error).not.toBeNull();
    expect(error?.type).toBe("api_error");
    if (error?.type === "api_error") {
      expect(error.status).toBe(500);
      expect(error.body).toBe("Server error");
    }
  });

  it("deduplicates in-flight requests with same requestKey", async () => {
    let resolveFirst: ((value: { value: string }) => void) | null = null;
    const fetcher = vi.fn().mockImplementation(
      () =>
        new Promise((resolve) => {
          resolveFirst = resolve;
        })
    );
    const selector = vi.fn((response: { value: string }) => response.value);

    const { result: result1 } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "shared-request",
      })
    );

    // Wait for first hook to start loading
    await act(async () => {
      await Promise.resolve();
    });

    expect(result1.current.loading).toBe(true);
    expect(fetcher).toHaveBeenCalledTimes(1);

    // Mount second hook with same requestKey
    const { result: result2 } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "shared-request",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    // Should still be only 1 fetch call (deduplication worked)
    expect(fetcher).toHaveBeenCalledTimes(1);

    // Complete the request
    await act(async () => {
      resolveFirst?.({ value: "shared-data" });
      await Promise.resolve();
    });

    // Both hooks should have the same data
    expect(result1.current.data).toBe("shared-data");
    expect(result2.current.data).toBe("shared-data");
    expect(result1.current.loading).toBe(false);
    expect(result2.current.loading).toBe(false);
  });

  it("deduplicates rapid consecutive refresh calls with requestKey", async () => {
    const fetcher = vi
      .fn()
      .mockImplementation(
        () =>
          new Promise((resolve) => {
            setTimeout(() => resolve({ value: "data" }), 10);
          })
      );
    const selector = vi.fn((response: { value: string }) => response.value);

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "dedup-key",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    expect(fetcher).toHaveBeenCalledTimes(1);

    // Call refresh multiple times rapidly while first request is still in-flight
    await act(async () => {
      result.current.refresh();
      result.current.refresh();
      result.current.refresh();
      // Wait for the request to complete
      await new Promise((resolve) => setTimeout(resolve, 20));
    });

    // Should still be only 1 fetch call because all refreshes are deduplicated
    // against the initial in-flight request
    expect(fetcher).toHaveBeenCalledTimes(1);
    expect(result.current.data).toBe("data");
  });

  it("clears cache entry when request completes", async () => {
    const fetcher = vi.fn().mockResolvedValue({ value: "data" });
    const selector = vi.fn((response: { value: string }) => response.value);

    const { result, unmount } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "cache-clear-test",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    expect(result.current.data).toBe("data");
    expect(result.current.loading).toBe(false);

    unmount();

    // Mount again with same requestKey - should make a new request
    // (cache was cleared after previous request completed)
    const { result: result2 } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "cache-clear-test",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    // Should be 2 fetcher calls: initial + remount
    expect(fetcher).toHaveBeenCalledTimes(2);
    expect(result2.current.data).toBe("data");
  });

  it("works without requestKey (backward compatibility)", async () => {
    const fetcher = vi
      .fn()
      .mockResolvedValueOnce({ value: "first" })
      .mockResolvedValueOnce({ value: "second" });
    const selector = vi.fn((response: { value: string }) => response.value);

    const { result } = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        // No requestKey - should behave like before
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    expect(result.current.data).toBe("first");

    await act(async () => {
      result.current.refresh();
      await Promise.resolve();
    });

    // Each refresh launches a new request without deduplication
    expect(fetcher).toHaveBeenCalledTimes(2);
    expect(result.current.data).toBe("second");
  });

  it("aborted subscriber in deduplication does not receive setData/onSuccess", async () => {
    let resolveFirst: ((value: { value: string }) => void) | null = null;
    const fetcher = vi.fn().mockImplementation(
      () =>
        new Promise((resolve) => {
          resolveFirst = resolve;
        })
    );
    const selector = vi.fn((response: { value: string }) => response.value);
    const onSuccess1 = vi.fn();
    const onSuccess2 = vi.fn();

    const hook1 = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "abort-subscriber-test",
        onSuccess: onSuccess1,
      })
    );

    const hook2 = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "abort-subscriber-test",
        onSuccess: onSuccess2,
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    expect(fetcher).toHaveBeenCalledTimes(1);

    // Unmount hook2 (simulates component unmounting and aborting its signal)
    hook2.unmount();

    // Allow the request to resolve
    await act(async () => {
      resolveFirst?.({ value: "shared-data" });
      await Promise.resolve();
    });

    // hook2 should not have received the data (it aborted)
    expect(hook2.result.current.data).toBeUndefined();
    expect(onSuccess2).not.toHaveBeenCalled();
    // hook1 should still have the data
    expect(hook1.result.current.data).toBe("shared-data");
    expect(onSuccess1).toHaveBeenCalledWith({ value: "shared-data" });
  });

  it("last subscriber abort cancels underlying request", async () => {
    let resolveFirst: ((value: { value: string }) => void) | null = null;
    const abortSignals: AbortSignal[] = [];
    const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
      abortSignals.push(signal);
      return new Promise((resolve) => {
        resolveFirst = resolve;
      });
    });
    const selector = vi.fn((response: { value: string }) => response.value);

    const hook1 = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "cancel-underlying-test",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    expect(fetcher).toHaveBeenCalledTimes(1);

    // Mount second subscriber
    const hook2 = renderHook(() =>
      useFetchData({
        fetcher,
        selector,
        errorMessage: "Failed to load",
        requestKey: "cancel-underlying-test",
      })
    );

    await act(async () => {
      await Promise.resolve();
    });

    // Both subscribers now sharing one request
    expect(fetcher).toHaveBeenCalledTimes(1);

    // Unmount first subscriber (doesn't cancel underlying request yet)
    hook1.unmount();

    await act(async () => {
      await Promise.resolve();
    });

    // Underlying request should NOT be cancelled yet (hook2 still waiting)
    expect(abortSignals[0]?.aborted).toBe(false);

    // Unmount second (last) subscriber — should cancel the underlying request
    hook2.unmount();

    await act(async () => {
      await Promise.resolve();
    });

    // Underlying request should now be cancelled
    expect(abortSignals[0]?.aborted).toBe(true);
  });
});
