import { useRef, useState, useEffect, useCallback } from 'atomico';

const useIsomorphicLayoutEffect = useEffect;

// assign current value to a ref and providing a getter.
// This way we are sure to always get latest value provided to hook and
// avoid weird issues due to closures capturing stale values...
// See https://overreacted.io/making-setinterval-declarative-with-react-hooks/
const useGetter = (t) => {
  const ref = useRef(t);
  useIsomorphicLayoutEffect(() => {
    ref.current = t;
  });
  return () => ref.current;
};


















































const InitialAsyncState = {
  status: "not-requested",
  loading: false,
  result: undefined,
  error: undefined,
};

const InitialAsyncLoadingState = {
  status: "loading",
  loading: true,
  result: undefined,
  error: undefined,
};

const defaultSetLoading = (_asyncState) => InitialAsyncLoadingState;

const defaultSetResult = (result, _asyncState) => ({
  status: "success",
  loading: false,
  result,
  error: undefined,
});

const defaultSetError = (error, _asyncState) => ({
  status: "error",
  loading: false,
  result: undefined,
  error,
});

// eslint-disable-next-line @typescript-eslint/explicit-function-return-type,@typescript-eslint/no-empty-function
const noop = () => {};

const DefaultOptions = {
  initialState: (options) => (options && options.executeOnMount
    ? InitialAsyncLoadingState
    : InitialAsyncState),
  executeOnMount: true,
  executeOnUpdate: true,
  setLoading: defaultSetLoading,
  setResult: defaultSetResult,
  setError: defaultSetError,
  onSuccess: noop,
  onError: noop,
};

const normalizeOptions = (
  options,
) => ({
    ...DefaultOptions,
    ...options,
  });










const useAsyncState = (
  options,
) => {
  const [value, setValue] = useState(InitialAsyncLoadingState);

  const reset = useCallback(() => setValue(options.initialState(options)), [
    setValue,
    options,
  ]);

  const setLoading = useCallback(() => setValue(options.setLoading(value)), [
    value,
    setValue,
  ]);
  const setResult = useCallback(
    (result) => setValue(options.setResult(result, value)),
    [value, setValue],
  );

  const setError = useCallback(
    (error) => setValue(options.setError(error, value)),
    [value, setValue],
  );

  const set = setValue;

  const merge = useCallback(
    (state) => set({
      ...value,
      ...state,
    }),
    [value, set],
  );
  return {
    value,
    set,
    merge,
    reset,
    setLoading,
    setResult,
    setError,
  };
};

const useIsMounted = () => {
  const ref = useRef(false);
  useEffect(() => {
    ref.current = true;
    return () => {
      ref.current = false;
    };
  }, []);
  return () => ref.current;
};






const useCurrentPromise = () => {
  const ref = useRef(null);
  return {
    set: (promise) => (ref.current = promise),
    get: () => ref.current,
    is: (promise) => ref.current === promise,
  };
};













// Relaxed interface which accept both async and sync functions
// Accepting sync function is convenient for useAsyncCallback
const useAsyncInternal = (
  asyncFunction,
  params,
  options,
) => {
  const normalizedOptions = normalizeOptions(options);

  const [currentParams, setCurrentParams] = useState(null);

  const AsyncState = useAsyncState(normalizedOptions);

  const isMounted = useIsMounted();
  const CurrentPromise = useCurrentPromise();

  // We only want to handle the promise result/error
  // if it is the last operation and the comp is still mounted
  const shouldHandlePromise = (p) => isMounted() && CurrentPromise.is(p);

  const executeAsyncOperation = (...args) => {
    const promise = asyncFunction(...args);
    setCurrentParams(args);
    if (promise instanceof Promise) {
      CurrentPromise.set(promise);
      AsyncState.setLoading();
      promise.then(
        (result) => {
          if (shouldHandlePromise(promise)) {
            AsyncState.setResult(result);
          }
          normalizedOptions.onSuccess(result, {
            isCurrent: () => CurrentPromise.is(promise),
          });
        },
        (error) => {
          if (shouldHandlePromise(promise)) {
            AsyncState.setError(error);
          }
          normalizedOptions.onError(error, {
            isCurrent: () => CurrentPromise.is(promise),
          });
        },
      );
      return promise;
    }
    // We allow passing a non-async function (mostly for useAsyncCallback conveniency)
    const syncResult = promise;
    AsyncState.setResult(syncResult);
    return Promise.resolve(syncResult);
  };

  // Keep this outside useEffect, because inside isMounted()
  // will be true as the component is already mounted when it's run
  const isMounting = !isMounted();
  useEffect(() => {
    if (isMounting) {
      normalizedOptions.executeOnMount && executeAsyncOperation(...params);
    } else {
      normalizedOptions.executeOnUpdate && executeAsyncOperation(...params);
    }
  }, params);

  return {
    ...AsyncState.value,
    set: AsyncState.set,
    merge: AsyncState.merge,
    reset: AsyncState.reset,
    execute: executeAsyncOperation,
    currentPromise: CurrentPromise.get(),
    currentParams,
  };
};

// override to allow passing an async function with no args:
// gives more user-freedom to create an inline async function











function useAsync(
  asyncFunction,
  params,
  options,
) {
  return useAsyncInternal(asyncFunction, params, options);
}







const useAsyncAbortable = 


(
    asyncFunction,
    params,
    options,
  ) => {
  const abortControllerRef = useRef();

  // Wrap the original async function and enhance it with abortion login
  const asyncFunctionWrapper = async (
    ...p
  ) => {
    // Cancel previous async call
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    // Create/store new abort controller for next async call
    const abortController = new AbortController();
    abortControllerRef.current = abortController;

    try {
      // @ts-ignore // TODO how to type this?
      return await asyncFunction(abortController.signal, ...p);
    } finally {
      // Unset abortController ref if response is already there,
      // as it's not needed anymore to try to abort it (would it be no-op?)
      if (abortControllerRef.current === abortController) {
        abortControllerRef.current = undefined;
      }
    }
  };

  return useAsync(asyncFunctionWrapper, params, options);
};

// keep compat with TS < 3.5











const useAsyncCallback = 


(
    asyncFunction,
    options,
  ) => useAsyncInternal(
    asyncFunction,
        // Hacky but in such case we don't need the params,
        // because async function is only executed manually
        [] ,
        {
          ...options,
          executeOnMount: false,
          executeOnUpdate: false,
        },
  );

// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const useAsyncFetchMore = ({
  value,
  fetchMore,
  merge,
  isEnd: isEndFn,
}




) => {
  const getAsyncValue = useGetter(value);
  const [isEnd, setIsEnd] = useState(false);

  // TODO not really fan of this id thing, we should find a way to support cancellation!
  const fetchMoreId = useRef(0);

  const fetchMoreAsync = useAsyncCallback(async () => {
    const freshAsyncValue = getAsyncValue();
    if (freshAsyncValue.status !== "success") {
      throw new Error(
        "Can't fetch more if the original fetch is not a success",
      );
    }
    if (fetchMoreAsync.status === "loading") {
      throw new Error(
        "Can't fetch more, because we are already fetching more!",
      );
    }

    fetchMoreId.current += 1;
    const currentId = fetchMoreId.current;
    const moreResult = await fetchMore(freshAsyncValue.result);

    // TODO not satisfied with this, we should just use "freshAsyncValue === getAsyncValue()" but asyncValue is not "stable"
    const isStillSameValue = freshAsyncValue.status === getAsyncValue().status
            && freshAsyncValue.result === getAsyncValue().result;

    const isStillSameId = fetchMoreId.current === currentId;

    // Handle race conditions: we only merge the fetchMore result if the initial async value is the same
    const canMerge = isStillSameValue && isStillSameId;
    if (canMerge) {
      value.merge({
        result: merge(value.result, moreResult),
      });
      if (isEndFn(moreResult)) {
        setIsEnd(true);
      }
    }

    // return is useful for chaining, like fetchMore().then(result => {});
    return moreResult;
  });

  const reset = () => {
    fetchMoreAsync.reset();
    setIsEnd(false);
  };

  // We only allow to fetch more on a stable async value
  // TODO value is not stable, we could just reset on value change otherwise
  const shouldReset = value.status !== "success";
  useEffect(() => {
    if (shouldReset) {
      reset();
    }
  }, [shouldReset]);

  return {
    canFetchMore:
            value.status === "success" && fetchMoreAsync.status !== "loading",
    loading: fetchMoreAsync.loading,
    status: fetchMoreAsync.status,
    fetchMore: fetchMoreAsync.execute,
    isEnd,
  };
};

export { useAsync, useAsyncAbortable, useAsyncCallback, useAsyncFetchMore };
