/**
 * Composable base hook for fetch lifecycle, cancellation, and error handling.
 *
 * Provides common primitives for data-fetching hooks: abort controller management,
 * loading/error state, and a refresh callback with cancellation safety.
 *
 * This is an internal hook meant to be composed by higher-level hooks like
 * useListData and usePolledData. Direct usage is discouraged — instead,
 * create a domain-specific hook that wraps this base and adds your specific
 * requirements (e.g., polling, windowed effects, derived state).
 *
 * When a `requestKey` is provided, multiple hook instances with the same key
 * will deduplicate in-flight requests, ensuring only one fetch happens at a time
 * for that data. This prevents wasted bandwidth and race conditions.
 */
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
import type { FetchError } from "../types/api";

/**
 * Module-level cache for in-flight requests.
 * Maps requestKey to { promise, controller, subscribers } to enable deduplication
 * across multiple hook instances.
 */
interface Subscriber<TResponse> {
  /** This instance's abort signal. */
  signal: AbortSignal;
  /** Callbacks registered by this subscriber. */
  onSetData?: (response: TResponse) => void;
  onSuccess?: (response: TResponse) => void;
}

interface InFlightRequest<TResponse> {
  promise: Promise<TResponse>;
  controller: AbortController;
  /** Map of subscriberId -> Subscriber.  Cleared when subscriber aborts. */
  subscribers: Map<string, Subscriber<TResponse>>;
  /** True when initiator has cleaned up but subscribers remain. */
  initiatorDone: boolean;
}

const inFlightRequests = new Map<string, InFlightRequest<unknown>>();

/** Visible for testing only.  Clears all in-flight request state. */
export const _resetInFlightRequests = (): void => {
  inFlightRequests.clear();
};

export interface UseFetchDataOptions<TResponse, TData> {
  /** Async function that accepts an AbortSignal for cancellation. */
  fetcher: (signal: AbortSignal) => Promise<TResponse>;
  /** Synchronous selector to extract domain data from the response. */
  selector: (response: TResponse) => TData;
  /** Human-readable error message used as fallback if fetch fails. */
  errorMessage: string;
  /** Optional callback invoked after successful fetch with full response. */
  onSuccess?: (response: TResponse) => void;
  /** Initial data value. If undefined, data starts as undefined until first fetch. */
  initialData?: TData;
  /**
   * Optional unique key for request deduplication.
   * When provided, multiple hook instances with the same key will share
   * in-flight requests, preventing duplicate API calls.
   */
  requestKey?: string;
}

export interface UseFetchDataResult<TData> {
  /** The extracted data from the most recent successful fetch. */
  data: TData | undefined;
  /** True while a fetch is in-flight, false when complete. */
  loading: boolean;
  /** Typed error or null. Check `error?.type` to handle specific failure modes. */
  error: FetchError | null;
  /** Trigger a fresh fetch. Cancels any in-flight request first. Returns null if aborted, otherwise the selected data. */
  refresh: () => Promise<TData | null>;
}

/**
 * Generic base hook that manages the fetch lifecycle for a single resource.
 *
 * Handles abort controller management, error handling, and refresh semantics.
 * Automatically cancels in-flight requests on component unmount.
 *
 * When `requestKey` is provided, deduplicates in-flight requests across
 * multiple hook instances. If a request is already in-flight for that key,
 * the hook reuses the existing promise instead of making a duplicate call.
 * This prevents wasted bandwidth and race conditions when multiple components
 * fetch the same data simultaneously or when the same component calls refresh rapidly.
 *
 * Prefer composing this hook via higher-level hooks (useListData, usePolledData)
 * rather than using directly.
 *
 * @param options - Configuration: fetcher, selector, error message, and optional callbacks
 * @returns Data, loading state, typed error, and refresh callback
 */
export function useFetchData<TResponse, TData>(
  options: UseFetchDataOptions<TResponse, TData>,
): UseFetchDataResult<TData> {
  const { fetcher, selector, errorMessage, onSuccess, initialData, requestKey } = options;
  const [data, setData] = useState<TData | undefined>(initialData);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<FetchError | null>(null);
  const abortRef = useRef<AbortController | null>(null);
  const localControllerRef = useRef<AbortController | null>(null);
  const subscriberAbortRef = useRef<AbortController | null>(null);
  /** Unique ID for this instance, used to track its subscription to deduplicated requests. */
  const subscriberIdRef = useRef<string | null>(null);

  const refresh = useCallback(async (): Promise<TData | null> => {
    // Abort any previous request from this hook instance
    abortRef.current?.abort();
    localControllerRef.current = new AbortController();
    abortRef.current = localControllerRef.current;

    // If using request deduplication via requestKey and a request is already in-flight,
    // subscribe to it instead of launching a duplicate
    if (requestKey && inFlightRequests.has(requestKey)) {
      const inFlight = inFlightRequests.get(requestKey)! as InFlightRequest<TResponse>;

      // Create per-instance abort controller for this subscription.
      // Do NOT abort previous subscription signals here - each subscriber's signal
      // only aborts when THAT subscriber unmounts. Aborting old signals would
      // incorrectly remove other active subscribers from the list.
      subscriberAbortRef.current = new AbortController();

      // Register this instance as a subscriber.
      // Always generate a new ID - each hook instance must have its own subscriber ID
      // so that unmounting one subscriber does not remove another subscriber's entry.
      subscriberIdRef.current = crypto.randomUUID();
      const sid = subscriberIdRef.current;
      const subscription: Subscriber<TResponse> = {
        signal: subscriberAbortRef.current.signal,
        onSetData: (response: TResponse) => {
          setData(selector(response));
          if (onSuccess) {
            onSuccess(response);
          }
        },
        onSuccess,
      };
      inFlight.subscribers.set(sid, subscription);

      // When this instance aborts, remove it from the subscriber list.
      // Note: we do NOT abort the underlying controller here - that belongs to
      // the initiator and should only be aborted by the initiator itself.
      const cleanup = (): void => {
        inFlight.subscribers.delete(sid);
      };
      subscription.signal.addEventListener("abort", cleanup, { once: true });

      inFlight.promise
        .then((response: TResponse) => {
          // Check whether this subscriber has already aborted before propagating result
          if (subscription.signal.aborted) return;
          subscription.onSetData?.(response);
        })
        .catch((err: unknown) => {
          // Only handle non-abort errors; abort errors are silently ignored
          if (err instanceof DOMException && err.name === "AbortError") {
            return;
          }
          handleFetchError(err, setError, errorMessage);
        })
        .finally(() => {
          setLoading(false);
        });
      return null;
    }

    setLoading(true);
    setError(null);

    const controller = localControllerRef.current;

    // Raw promise for deduplication storage - stores BEFORE transformation
    const rawPromise = fetcher(controller.signal).catch((err: unknown) => {
      if (controller.signal.aborted) throw err;
      handleFetchError(err, setError, errorMessage);
      throw err;
    });

    // Store in-flight BEFORE transformation for correct subscriber type
    if (requestKey) {
      inFlightRequests.set(requestKey, {
        promise: rawPromise as Promise<TResponse>,
        controller,
        subscribers: new Map(),
        initiatorDone: false,
      });
    }

    // Transformed promise for return value - applies selector and returns TData | null
    const responsePromise = rawPromise
      .then((response) => {
        if (controller.signal.aborted) return null;
        const data = selector(response);
        setData(data);
        if (onSuccess) {
          onSuccess(response);
        }
        return data;
      })
      .finally(() => {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
        if (requestKey) {
          inFlightRequests.delete(requestKey);
        }
      });

    return responsePromise;
  }, [fetcher, selector, errorMessage, onSuccess, requestKey]);

  useEffect(() => {
    refresh();

    return (): void => {
      if (subscriberIdRef.current !== null) {
        // Subscriber cleanup
        const inFlight = requestKey ? inFlightRequests.get(requestKey) : undefined;
        subscriberAbortRef.current?.abort();
        // If initiator already done and no subscribers left, cancel the request
        if (inFlight && inFlight.initiatorDone && inFlight.subscribers.size === 0) {
          inFlight.controller.abort();
        }
      } else {
        // Initiator cleanup
        const inFlight = requestKey ? inFlightRequests.get(requestKey) : undefined;
        // Mark initiator as done so last subscriber knows to cancel
        if (inFlight) {
          inFlight.initiatorDone = true;
        }
        if (!inFlight || inFlight.subscribers.size === 0) {
          abortRef.current?.abort();
        }
      }
    };
  }, [refresh]);

  return { data, loading, error, refresh };
}
