/**
 * @vitest-environment happy-dom
 */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useShippingRate } from "./useShippingRate";
import type { CheckoutShippingRateResult } from "./types";

const mockActionFn = vi.fn();
vi.mock("convex/react", () => ({
  useAction: () => mockActionFn,
}));

vi.mock("../../../../../convex/_generated/api", () => ({
  api: {
    checkoutActions: { getShippingRate: "mock-getShippingRate-ref" },
  },
}));

vi.mock("../../../../../convex/_generated/dataModel", () => ({}));

const sampleResult: CheckoutShippingRateResult = {
  shipmentObjectId: "shp_abc123",
  selectedRate: {
    provider: "DPD UK",
    serviceName: "Next Day",
    serviceToken: "dpd_uk_next_day",
    amount: 5.5,
    currency: "GBP",
    estimatedDays: 1,
    durationTerms: "1-2 business days",
    carrierAccount: "ca_xyz789",
  },
  alternativeRates: [
    {
      provider: "Evri UK",
      serviceName: "Standard",
      serviceToken: "evri_uk_standard",
      amount: 3.99,
      currency: "GBP",
      estimatedDays: 3,
      durationTerms: "3-5 business days",
      carrierAccount: "ca_evri456",
    },
  ],
  cartSubtotal: 49.99,
  shippingTotal: 5.5,
  orderTotal: 55.49,
};

describe("useShippingRate", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("starts idle when addressId is null — no fetch fired", async () => {
    const { result } = renderHook(() => useShippingRate(null, undefined));

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.result).toBeNull();
    expect(result.current.error).toBeNull();
    expect(mockActionFn).not.toHaveBeenCalled();
  });

  it("fetches rates automatically on mount when addressId is provided", async () => {
    mockActionFn.mockResolvedValue(sampleResult);
    const { result } = renderHook(() =>
      useShippingRate("addr_123", "session_abc"),
    );

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(mockActionFn).toHaveBeenCalledOnce();
    expect(mockActionFn).toHaveBeenCalledWith({
      addressId: "addr_123",
      sessionId: "session_abc",
    });
    expect(result.current.result).toEqual(sampleResult);
    expect(result.current.error).toBeNull();
  });

  it("passes sessionId as undefined when not provided", async () => {
    mockActionFn.mockResolvedValue(sampleResult);
    const { result } = renderHook(() =>
      useShippingRate("addr_123", undefined),
    );

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(mockActionFn).toHaveBeenCalledWith({
      addressId: "addr_123",
      sessionId: undefined,
    });
  });

  it("sets error on action failure", async () => {
    mockActionFn.mockRejectedValue(
      new Error("Shipping configuration is incomplete"),
    );
    const { result } = renderHook(() =>
      useShippingRate("addr_123", undefined),
    );

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.result).toBeNull();
    expect(result.current.error).toBe(
      "Unable to calculate shipping rates. Please try again.",
    );
  });

  it("uses fallback message for non-Error exceptions", async () => {
    mockActionFn.mockRejectedValue("unexpected string error");
    const { result } = renderHook(() =>
      useShippingRate("addr_123", undefined),
    );

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.error).toBe(
      "Unable to calculate shipping rates. Please try again.",
    );
  });

  it("retry() re-fetches rates and clears previous error", async () => {
    mockActionFn.mockRejectedValueOnce(new Error("Network timeout"));
    const { result } = renderHook(() =>
      useShippingRate("addr_123", undefined),
    );

    await waitFor(() => {
      expect(result.current.error).toBe(
        "Unable to calculate shipping rates. Please try again.",
      );
    });

    mockActionFn.mockResolvedValue(sampleResult);

    await act(async () => {
      result.current.retry();
    });

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.result).toEqual(sampleResult);
    expect(result.current.error).toBeNull();
    expect(mockActionFn).toHaveBeenCalledTimes(2);
  });

  it("re-fetches when addressId changes", async () => {
    mockActionFn.mockResolvedValue(sampleResult);
    const { result, rerender } = renderHook(
      ({ addressId, sessionId }) => useShippingRate(addressId, sessionId),
      { initialProps: { addressId: "addr_1" as string | null, sessionId: undefined as string | undefined } },
    );

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });
    expect(mockActionFn).toHaveBeenCalledTimes(1);

    const altResult: CheckoutShippingRateResult = {
      ...sampleResult,
      shipmentObjectId: "shp_def456",
      shippingTotal: 7.99,
      orderTotal: 57.98,
      selectedRate: {
        ...sampleResult.selectedRate,
        amount: 7.99,
      },
    };
    mockActionFn.mockResolvedValue(altResult);

    rerender({ addressId: "addr_2", sessionId: undefined });

    await waitFor(() => {
      expect(result.current.result).toEqual(altResult);
    });
    expect(mockActionFn).toHaveBeenCalledTimes(2);
  });

  it("does not fetch when addressId transitions to null", async () => {
    mockActionFn.mockResolvedValue(sampleResult);
    const { result, rerender } = renderHook(
      ({ addressId }) => useShippingRate(addressId, undefined),
      { initialProps: { addressId: "addr_1" as string | null } },
    );

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });
    expect(mockActionFn).toHaveBeenCalledTimes(1);

    rerender({ addressId: null });

    // Still only 1 call — no new fetch for null
    expect(mockActionFn).toHaveBeenCalledTimes(1);
  });

  it("returns all shipping rate fields correctly", async () => {
    mockActionFn.mockResolvedValue(sampleResult);
    const { result } = renderHook(() =>
      useShippingRate("addr_123", undefined),
    );

    await waitFor(() => {
      expect(result.current.result).not.toBeNull();
    });

    const rate = result.current.result!;
    expect(rate.shipmentObjectId).toBe("shp_abc123");
    expect(rate.selectedRate.provider).toBe("DPD UK");
    expect(rate.selectedRate.serviceName).toBe("Next Day");
    expect(rate.selectedRate.amount).toBe(5.5);
    expect(rate.selectedRate.currency).toBe("GBP");
    expect(rate.selectedRate.estimatedDays).toBe(1);
    expect(rate.alternativeRates).toHaveLength(1);
    expect(rate.cartSubtotal).toBe(49.99);
    expect(rate.shippingTotal).toBe(5.5);
    expect(rate.orderTotal).toBe(55.49);
  });

  it("ignores stale responses when addressId changes rapidly", async () => {
    let resolveFirst: (v: CheckoutShippingRateResult) => void;
    const firstPromise = new Promise<CheckoutShippingRateResult>((r) => {
      resolveFirst = r;
    });

    const secondResult: CheckoutShippingRateResult = {
      ...sampleResult,
      shipmentObjectId: "shp_second",
    };

    mockActionFn
      .mockReturnValueOnce(firstPromise)
      .mockResolvedValueOnce(secondResult);

    const { result, rerender } = renderHook(
      ({ addressId }) => useShippingRate(addressId, undefined),
      { initialProps: { addressId: "addr_slow" as string | null } },
    );

    rerender({ addressId: "addr_fast" });

    await waitFor(() => {
      expect(result.current.result).toEqual(secondResult);
    });

    // Now resolve the stale first request — it should be ignored
    resolveFirst!({ ...sampleResult, shipmentObjectId: "shp_stale" });

    // Give time for the stale resolve to propagate (it shouldn't)
    await new Promise((r) => setTimeout(r, 50));

    expect(result.current.result?.shipmentObjectId).toBe("shp_second");
  });
});
