'use client';

import {useCallback, useEffect, useState} from 'react';
import {fetchEndpoint, endpointUrl} from '@/shared/services/httpClient';
import type {Endpoint} from '@/shared/services/httpClient';
import {isFailure} from '@/shared/types/api';
import type {ApiFailure, ApiMeta} from '@/shared/types/api';

/**
 * Deliberately shaped like SWR so it can be replaced by SWR or TanStack Query
 * in exactly one file when a real backend arrives.
 *
 * 'empty' is a first-class status rather than something each caller derives
 * from `data.length === 0`. Getting that wrong is how "no results" ends up
 * rendering as an axis with no bars.
 */
export type AsyncState<T> =
  | {status: 'loading'; data: undefined; error: undefined}
  | {status: 'success'; data: T; error: undefined}
  | {status: 'empty'; data: T; error: undefined}
  | {status: 'error'; data: undefined; error: ApiFailure['error']};

export type Resource<T> = AsyncState<T> & {
  refetch: () => void;
  /**
   * True while a new request is in flight but previous data is still on
   * screen. Panels use this to dim rather than to blank.
   */
  isRefreshing: boolean;
  /**
   * Response metadata, including the SERVER's clock. Anything computing a
   * relative deadline — days-to-expiry, "2 hours ago" — must measure against
   * this rather than Date.now(): calling Date.now() during render is impure,
   * disagrees between the SSR and hydration passes, and quietly reports the
   * wrong countdown to any user whose device clock is off.
   */
  meta?: ApiMeta;
};

/** What the fetch resolved to, tagged with the request it belongs to. */
type Settled<T> =
  | {key: string; ok: true; data: T; meta?: ApiMeta}
  | {key: string; ok: false; error: ApiFailure['error']};

export function useResource<T>(
  endpoint: Endpoint<T> | null,
  opts?: {isEmpty?: (d: T) => boolean; initialData?: T},
): Resource<T> {
  const {isEmpty, initialData} = opts ?? {};

  // The URL IS the request identity. Keying on it means a store or range change
  // refetches while an unrelated re-render does not.
  const key = endpoint ? endpointUrl(endpoint) : null;

  // Seeded from initialData with no meta — a caller-supplied payload has no
  // server timestamp to report.
  const [settled, setSettled] = useState<Settled<T> | null>(() =>
    initialData !== undefined && key
      ? {key, ok: true, data: initialData}
      : null,
  );
  const [nonce, setNonce] = useState(0);

  useEffect(() => {
    if (!endpoint || !key) return;

    const controller = new AbortController();
    let cancelled = false;

    // No setState for the loading state: it is DERIVED below from whether the
    // settled result matches the current key. Setting it here would be a
    // synchronous setState in an effect body — a cascading render, and the
    // thing react-hooks/set-state-in-effect exists to catch.
    fetchEndpoint(endpoint, controller.signal)
      .then((res) => {
        if (cancelled) return;
        setSettled(
          isFailure(res)
            ? {key, ok: false, error: res.error}
            : {key, ok: true, data: res.data, meta: res.meta},
        );
      })
      .catch((err: unknown) => {
        // An abort is a superseded request, not a failure to show the user.
        if (cancelled || (err as Error)?.name === 'AbortError') return;
        setSettled({
          key,
          ok: false,
          error: {
            code: 'internal',
            message: (err as Error)?.message ?? 'Request failed',
          },
        });
      });

    return () => {
      cancelled = true;
      controller.abort();
    };
    // `endpoint` is intentionally not a dep: `key` is its serialized identity,
    // and callers construct the object inline on every render.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [key, nonce]);

  const refetch = useCallback(() => setNonce((n) => n + 1), []);

  // Stale-while-revalidate.
  //
  // When the scope changes, the honest-but-wrong thing is to drop straight to
  // 'loading': the whole dashboard blanks to skeletons and every animated
  // counter restarts from zero. Merchants switch store and period constantly,
  // so that reads as the page breaking rather than as data arriving.
  //
  // Instead the previous result stays on screen until the new one lands, with
  // `isRefreshing` set. 'loading' is now reserved for the genuine first paint,
  // when there is nothing to show. A stale ERROR is not kept — showing a dead
  // error under a fresh request would be actively misleading.
  const isStale = !!settled && settled.key !== key;
  const usable = settled && (settled.ok || !isStale) ? settled : null;

  const state: AsyncState<T> =
    !key || !usable
      ? {status: 'loading', data: undefined, error: undefined}
      : usable.ok
        ? {
            // Emptiness is derived here rather than inside the effect, so
            // `isEmpty` never has to be a dependency — no ref, no stale
            // closure, no ref mutation during render.
            status: (
              isEmpty
                ? isEmpty(usable.data)
                : Array.isArray(usable.data) && usable.data.length === 0
            )
              ? 'empty'
              : 'success',
            data: usable.data,
            error: undefined,
          }
        : {status: 'error', data: undefined, error: usable.error};

  const meta = usable && usable.ok ? usable.meta : undefined;

  return {...state, refetch, isRefreshing: isStale, meta} as Resource<T>;
}
