import {
  isReadonly,
  onMounted,
  onUnmounted,
  reactive,
  ref,
  toRefs,
  watch,
} from "vue";
import webPreset from "./lib/web-preset";
import SWRVCache from "./cache";
import type {
  fetcherFn,
  IConfig,
  IKey,
  IResponse,
  revalidateOptions,
} from "./types";

type StateRef<Data, Error> = {
  data: Data;
  error: Error;
  isValidating: boolean;
  revalidate: Function;
  key: any;
};

const DATA_CACHE = new SWRVCache<Omit<IResponse, "mutate">>();
const REF_CACHE = new SWRVCache<StateRef<any, any>[]>();
const PROMISES_CACHE = new SWRVCache<Omit<IResponse, "mutate">>();

const defaultConfig: IConfig = {
  cache: DATA_CACHE,
  refreshInterval: 0,
  ttl: 0,
  serverTTL: 1000,
  dedupingInterval: 2000,
  revalidateOnFocus: false,
  revalidateDebounce: 0,
  shouldRetryOnError: true,
  errorRetryInterval: 5000,
  errorRetryCount: 5,
  fetcher: webPreset.fetcher,
  isOnline: webPreset.isOnline,
  isDocumentVisible: webPreset.isDocumentVisible,
};

/**
 * Cache the refs for later revalidation
 */
function setRefCache(key: string, theRef: StateRef<any, any>, ttl: number) {
  const refCacheItem = REF_CACHE.get(key);
  if (refCacheItem) {
    refCacheItem.data.push(theRef);
  } else {
    // #51 ensures ref cache does not evict too soon
    const gracePeriod = 5000;
    REF_CACHE.set(key, [theRef], ttl > 0 ? ttl + gracePeriod : ttl);
  }
}

function onErrorRetry(
  revalidate: (any, opts: revalidateOptions) => void,
  errorRetryCount: number,
  config: IConfig
): void {
  if (!config.isDocumentVisible()) {
    return;
  }

  if (
    config.errorRetryCount !== undefined &&
    errorRetryCount > config.errorRetryCount
  ) {
    return;
  }

  const count = Math.min(errorRetryCount || 0, config.errorRetryCount);
  const timeout = count * config.errorRetryInterval;
  setTimeout(() => {
    revalidate(null, { errorRetryCount: count + 1, shouldRetryOnError: true });
  }, timeout);
}

/**
 * Main mutation function for receiving data from promises to change state and
 * set data cache
 */
const mutate = async <Data>(
  key: string,
  res: Promise<Data> | Data,
  cache = DATA_CACHE,
  ttl = defaultConfig.ttl
) => {
  let data, error, isValidating;

  if (isPromise(res)) {
    try {
      data = await res;
    } catch (err) {
      error = err;
    }
  } else {
    data = res;
  }

  // eslint-disable-next-line prefer-const
  isValidating = false;

  const newData = { data, error, isValidating };
  if (typeof data !== "undefined") {
    cache.set(key, newData, ttl);
  }

  /**
   * Revalidate all swrv instances with new data
   */
  const stateRef = REF_CACHE.get(key);
  if (stateRef && stateRef.data.length) {
    // This filter fixes #24 race conditions to only update ref data of current
    // key, while data cache will continue to be updated if revalidation is
    // fired
    let refs = stateRef.data.filter((r) => r.key === key);

    refs.forEach((r, idx) => {
      if (typeof newData.data !== "undefined") {
        r.data = newData.data;
      }
      r.error = newData.error;
      r.isValidating = newData.isValidating;

      const isLast = idx === refs.length - 1;
      if (!isLast) {
        // Clean up refs that belonged to old keys
        delete refs[idx];
      }
    });

    refs = refs.filter(Boolean);
  }

  return newData;
};

/* Stale-While-Revalidate hook to handle fetching, caching, validation, and more... */
function useSWRV<Data = any, Error = any>(key: IKey): IResponse<Data, Error>;
function useSWRV<Data = any, Error = any>(
  key: IKey,
  fn: fetcherFn<Data> | undefined | null,
  config?: IConfig
): IResponse<Data, Error>;
function useSWRV<Data = any, Error = any>(...args): IResponse<Data, Error> {
  let key: IKey;
  let fn: fetcherFn<Data> | undefined | null;
  let config: IConfig = { ...defaultConfig };
  let unmounted = false;

  if (args.length >= 1) {
    key = args[0];
  }
  if (args.length >= 2) {
    fn = args[1];
  }
  if (args.length > 2) {
    config = {
      ...config,
      ...args[2],
    };
  }

  const ttl = config.ttl;
  const keyRef = typeof key === "function" ? (key as any) : ref(key);

  if (typeof fn === "undefined") {
    // use the global fetcher
    fn = config.fetcher;
  }

  let stateRef = null as StateRef<Data, Error>;

  if (!stateRef) {
    stateRef = reactive({
      data: undefined,
      error: undefined,
      isValidating: true,
      key: null,
    }) as StateRef<Data, Error>;
  }

  /**
   * Revalidate the cache, mutate data
   */
  const revalidate = async (
    data?: fetcherFn<Data>,
    opts?: revalidateOptions
  ) => {
    const isFirstFetch = stateRef.data === undefined;
    const keyVal = keyRef.value;
    if (!keyVal) {
      return;
    }

    const cacheItem = config.cache.get(keyVal);
    const newData = cacheItem && cacheItem.data;

    stateRef.isValidating = true;
    if (newData) {
      stateRef.data = newData.data;
      stateRef.error = newData.error;
    }

    const fetcher = data || fn;
    if (
      !fetcher ||
      (!config.isDocumentVisible() && !isFirstFetch) ||
      (opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
    ) {
      stateRef.isValidating = false;
      return;
    }

    // Dedupe items that were created in the last interval #76
    if (cacheItem) {
      const shouldRevalidate = Boolean(
        Date.now() - cacheItem.createdAt >= config.dedupingInterval ||
          opts?.forceRevalidate
      );

      if (!shouldRevalidate) {
        stateRef.isValidating = false;
        return;
      }
    }

    const trigger = async () => {
      const promiseFromCache = PROMISES_CACHE.get(keyVal);
      if (!promiseFromCache) {
        const fetcherArgs = Array.isArray(keyVal) ? keyVal : [keyVal];
        // debugger;
        const newPromise = fetcher(...fetcherArgs);
        PROMISES_CACHE.set(keyVal, newPromise, config.dedupingInterval);
        await mutate(keyVal, newPromise, config.cache, ttl);
      } else {
        await mutate(keyVal, promiseFromCache.data, config.cache, ttl);
      }
      stateRef.isValidating = false;
      PROMISES_CACHE.delete(keyVal);
      if (stateRef.error !== undefined) {
        const shouldRetryOnError =
          !unmounted &&
          config.shouldRetryOnError &&
          (opts ? opts.shouldRetryOnError : true);
        if (shouldRetryOnError) {
          onErrorRetry(revalidate, opts ? opts.errorRetryCount : 1, config);
        }
      }
    };

    if (newData && config.revalidateDebounce) {
      setTimeout(async () => {
        if (!unmounted) {
          await trigger();
        }
      }, config.revalidateDebounce);
    } else {
      await trigger();
    }
  };

  const revalidateCall = async () =>
    revalidate(null, { shouldRetryOnError: false });
  let timer = null;
  /**
   * Setup polling
   */
  onMounted(() => {
    const tick = async () => {
      // component might un-mount during revalidate, so do not set a new timeout
      // if this is the case, but continue to revalidate since promises can't
      // be cancelled and new hook instances might rely on promise/data cache or
      // from pre-fetch
      if (!stateRef.error && config.isOnline()) {
        // if API request errored, we stop polling in this round
        // and let the error retry function handle it
        await revalidate();
      } else {
        if (timer) {
          clearTimeout(timer);
        }
      }

      if (config.refreshInterval && !unmounted) {
        timer = setTimeout(tick, config.refreshInterval);
      }
    };

    if (config.refreshInterval) {
      timer = setTimeout(tick, config.refreshInterval);
    }
    if (config.revalidateOnFocus) {
      document.addEventListener("visibilitychange", revalidateCall, false);
      window.addEventListener("focus", revalidateCall, false);
    }
  });

  /**
   * Teardown
   */
  onUnmounted(() => {
    unmounted = true;
    if (timer) {
      clearTimeout(timer);
    }
    if (config.revalidateOnFocus) {
      document.removeEventListener("visibilitychange", revalidateCall, false);
      window.removeEventListener("focus", revalidateCall, false);
    }
    const refCacheItem = REF_CACHE.get(keyRef.value);
    if (refCacheItem) {
      refCacheItem.data = refCacheItem.data.filter((ref) => ref === stateRef);
      // refCacheItem.data = refCacheItem.data.filter(
      //   (ref) => JSON.stringify(ref) === JSON.stringify(stateRef)
      // );
    }
  });

  /**
   * Revalidate when key dependencies change
   */
  try {
    watch(
      keyRef,
      (val) => {
        if (!isReadonly(keyRef)) {
          keyRef.value = val;
        }
        stateRef.key = val;
        stateRef.isValidating = Boolean(val);
        setRefCache(keyRef.value, stateRef, ttl);

        if (keyRef.value) {
          revalidate();
        }
      },
      {
        immediate: true,
      }
    );
  } catch {
    // do nothing
  }

  const res: IResponse = {
    ...toRefs(stateRef),
    mutate: (data, opts: revalidateOptions) =>
      revalidate(data, {
        ...opts,
        forceRevalidate: true,
      }),
  };

  return res;
}

function isPromise<T>(p: any): p is Promise<T> {
  return p !== null && typeof p === "object" && typeof p.then === "function";
}

export { mutate };
export default useSWRV;
