import {
  DEFAULT_LIST_STREAM_OPTIONS,
  ListStreamOptions,
  ReferenceProperty,
  StreamListsDiff,
  StreamReferences,
} from "@models/stream";
import { LIST_STATUS } from "@models/list";
import { isEqual } from "@lib/utils";
import { EventEmitter, StreamEvent } from "./emitter";

function outputDiffChunk<T extends Record<string, unknown>>(
  emitter: EventEmitter,
) {
  let chunks: StreamListsDiff<T>[] = [];

  return function handleDiffChunk(
    chunk: StreamListsDiff<T>,
    isLastChunk: boolean,
    options: ListStreamOptions,
  ): void {
    const showChunk = options?.showOnly
      ? options?.showOnly.includes(chunk.status)
      : true;
    if (!showChunk) {
      return;
    }
    if ((options.chunksSize as number) > 0) {
      chunks.push(chunk);
      if (chunks.length >= (options.chunksSize as number) || isLastChunk) {
        const output = chunks;
        chunks = [];
        return emitter.emit(StreamEvent.Data, output);
      }
    }
    return emitter.emit(StreamEvent.Data, [chunk]);
  };
}

function formatSingleListStreamDiff<T extends Record<string, unknown>>(
  list: T[],
  isPrevious: boolean,
  status: LIST_STATUS,
  options: ListStreamOptions,
): StreamListsDiff<T>[] {
  const diff: StreamListsDiff<T>[] = list.map((data, i) => ({
    previousValue: isPrevious ? data : null,
    currentValue: isPrevious ? null : data,
    prevIndex: status === LIST_STATUS.ADDED ? null : i,
    newIndex: status === LIST_STATUS.ADDED ? i : null,
    indexDiff: null,
    status,
  }));
  if (options.showOnly && options.showOnly.length > 0) {
    return diff.filter((value) => options.showOnly?.includes(value.status));
  }
  return diff;
}

function isValidChunkSize(
  chunksSize: ListStreamOptions["chunksSize"],
): boolean {
  if (!chunksSize) return true;
  const x = String(Math.sign(chunksSize));
  return x !== "-1" && x !== "NaN";
}

function getDiffChunks<T extends Record<string, unknown>>(
  prevList: T[],
  nextList: T[],
  referenceProperty: ReferenceProperty<T>,
  emitter: EventEmitter,
  options: ListStreamOptions = DEFAULT_LIST_STREAM_OPTIONS,
) {
  if (!isValidChunkSize(options?.chunksSize)) {
    return emitter.emit(
      StreamEvent.Error,
      `The chunk size can't be negative. You entered the value ${options.chunksSize}`,
    );
  }
  if (!prevList && !nextList) {
    return [];
  }
  if (!prevList) {
    const nextDiff = formatSingleListStreamDiff(
      nextList as T[],
      false,
      LIST_STATUS.ADDED,
      options,
    );
    return nextDiff.forEach((data, i) =>
      handleDiffChunk(data, i === nextDiff.length - 1, options),
    );
  }
  if (!nextList) {
    const prevDiff = formatSingleListStreamDiff(
      prevList as T[],
      true,
      LIST_STATUS.DELETED,
      options,
    );
    return prevDiff.forEach((data, i) =>
      handleDiffChunk(data, i === prevDiff.length - 1, options),
    );
  }
  const listsReferences: StreamReferences<T> = new Map();
  const handleDiffChunk = outputDiffChunk<T>(emitter);
  prevList.forEach((data, i) => {
    if (data) {
      listsReferences.set(String(data[referenceProperty]), {
        prevIndex: i,
        nextIndex: undefined,
      });
    }
  });

  nextList.forEach((data, i) => {
    if (data) {
      const listReference = listsReferences.get(
        String(data[referenceProperty]),
      );
      if (listReference) {
        listReference.nextIndex = i;
      } else {
        handleDiffChunk(
          {
            previousValue: null,
            currentValue: data,
            prevIndex: null,
            newIndex: i,
            indexDiff: null,
            status: LIST_STATUS.ADDED,
          },
          i === nextList.length - 1,
          options,
        );
      }
    }
  });
  let streamedChunks = 0;
  const totalChunks = listsReferences.size;
  for (const data of listsReferences.values()) {
    streamedChunks++;
    const isLastChunk = totalChunks === streamedChunks;
    if (!data.nextIndex) {
      handleDiffChunk(
        {
          previousValue: prevList[data.prevIndex],
          currentValue: null,
          prevIndex: data.prevIndex,
          newIndex: null,
          indexDiff: null,
          status: LIST_STATUS.DELETED,
        },
        isLastChunk,
        options,
      );
    } else {
      const prevData = prevList[data.prevIndex];
      const nextData = nextList[data.nextIndex];
      const isDataEqual = isEqual(prevData, nextData);
      const indexDiff = data.prevIndex - data.nextIndex;
      if (isDataEqual) {
        if (indexDiff === 0) {
          handleDiffChunk(
            {
              previousValue: prevList[data.prevIndex],
              currentValue: nextList[data.nextIndex],
              prevIndex: null,
              newIndex: data.nextIndex,
              indexDiff: null,
              status: LIST_STATUS.EQUAL,
            },
            isLastChunk,
            options,
          );
        } else {
          handleDiffChunk(
            {
              previousValue: prevList[data.prevIndex],
              currentValue: nextList[data.nextIndex],
              prevIndex: data.prevIndex,
              newIndex: data.nextIndex,
              indexDiff,
              status: options.considerMoveAsUpdate
                ? LIST_STATUS.UPDATED
                : LIST_STATUS.MOVED,
            },
            isLastChunk,
            options,
          );
        }
      } else {
        handleDiffChunk(
          {
            previousValue: prevList[data.prevIndex],
            currentValue: nextList[data.nextIndex],
            prevIndex: data.prevIndex,
            newIndex: data.nextIndex,
            indexDiff,
            status: LIST_STATUS.UPDATED,
          },
          isLastChunk,
          options,
        );
      }
    }
  }
  return emitter.emit(StreamEvent.Finish);
}

/**
 * Streams the diff of two object lists
 * @param {Record<string, unknown>[]} prevList - The original object list.
 * @param {Record<string, unknown>[]} nextList - The new object list.
 * @param {ReferenceProperty<T>} referenceProperty - A common property in all the objects of your lists (e.g. `id`)
 * @param {ListStreamOptions} options - Options to refine your output.
    - `chunksSize`: the number of object diffs returned by each stream chunk. If set to `0`, each stream will return a single object diff. If set to `10` each stream will return 10 object diffs. (default is `0`)
    - `showOnly`: returns only the values whose status you are interested in. (e.g. `["added", "equal"]`)
    - `considerMoveAsUpdate`: if set to `true` a `moved` object will be considered as `updated`
 * @returns EventEmitter
 */
export function streamListsDiff<T extends Record<string, unknown>>(
  prevList: T[],
  nextList: T[],
  referenceProperty: ReferenceProperty<T>,
  options: ListStreamOptions = DEFAULT_LIST_STREAM_OPTIONS,
): EventEmitter {
  const emitter = new EventEmitter();
  setTimeout(() => {
    try {
      getDiffChunks(prevList, nextList, referenceProperty, emitter, options);
    } catch (err) {
      return emitter.emit(StreamEvent.Error, err);
    }
  }, 0);
  return emitter;
}