export default function makeRequestBatcher<ResultType>(
  fetchBatch: (keys: string[]) => Promise<ResultType[]>,
  timeoutMS: number = 100,
  maxBatchSize: number = 20
) {
  const pendingResolvers: {
    [key: string]: Array<(value: ResultType) => void>;
  } = {};
  const pendingRejecters: { [key: string]: Array<(error: any) => void> } = {};
  let timer: ReturnType<typeof setTimeout> | null = null;
  let isProcessing = false;
  let lastFetchTime = 0;

  async function processNextBatch() {
    if (isProcessing) {
      return;
    }

    const now = Date.now();
    const timeSinceLastFetch = now - lastFetchTime;

    // If we haven't waited long enough since the last fetch, schedule for later
    if (timeSinceLastFetch < timeoutMS) {
      const remainingTime = timeoutMS - timeSinceLastFetch;
      if (timer) {
        clearTimeout(timer);
      }
      timer = setTimeout(() => {
        processNextBatch();
      }, remainingTime);
      return;
    }

    const keys = Object.keys(pendingResolvers);
    if (keys.length === 0) {
      return;
    }

    isProcessing = true;

    if (timer) {
      clearTimeout(timer);
      timer = null;
    }

    // Take up to maxBatchSize unique keys
    const keysToProcess = keys.slice(0, maxBatchSize);
    const resolversToProcess: {
      [key: string]: Array<(value: ResultType) => void>;
    } = {};
    const rejectersToProcess: { [key: string]: Array<(error: any) => void> } =
      {};

    // Extract resolvers and rejecters for the keys being processed
    keysToProcess.forEach((key) => {
      resolversToProcess[key] = pendingResolvers[key];
      rejectersToProcess[key] = pendingRejecters[key];
      delete pendingResolvers[key];
      delete pendingRejecters[key];
    });

    // Update lastFetchTime when we start the fetch, not when it completes
    lastFetchTime = Date.now();

    try {
      const results = await fetchBatch(keysToProcess);

      // Ensure we have the same number of results as keys requested
      const expectedResults = keysToProcess.length;
      const actualResults = results?.length || 0;

      if (actualResults !== expectedResults) {
        throw new Error(
          `Expected ${expectedResults} results but got ${actualResults}`
        );
      }

      results.forEach((result, index) => {
        const key = keysToProcess[index];
        if (resolversToProcess[key]) {
          resolversToProcess[key].forEach((resolve) => resolve(result));
        }
      });
    } catch (error) {
      keysToProcess.forEach((key) => {
        if (rejectersToProcess[key]) {
          rejectersToProcess[key].forEach((reject) => reject(error));
        }
      });
    } finally {
      isProcessing = false;

      // If there are more pending requests, schedule the next batch
      if (Object.keys(pendingResolvers).length > 0) {
        timer = setTimeout(() => {
          processNextBatch();
        }, timeoutMS);
      }
    }
  }

  return (key: string) => {
    return new Promise<ResultType>((resolve, reject) => {
      // Add to existing key's resolvers/rejecters or create new arrays
      if (!pendingResolvers[key]) {
        pendingResolvers[key] = [];
      }
      if (!pendingRejecters[key]) {
        pendingRejecters[key] = [];
      }

      pendingResolvers[key].push(resolve);
      pendingRejecters[key].push(reject);

      // If we're not already processing and no timer is set, start processing
      if (!isProcessing && !timer) {
        timer = setTimeout(() => {
          processNextBatch();
        }, timeoutMS);
      }

      // If we've reached maxBatchSize, process immediately
      if (
        Object.keys(pendingResolvers).length >= maxBatchSize &&
        !isProcessing
      ) {
        if (timer) {
          clearTimeout(timer);
          timer = null;
        }
        processNextBatch();
      }
    });
  };
}
