import { DeepPartial, ObjectValue, Value } from "../shared/types";

/**
 * merge recursively merges sources into value, returning a value.
 */
export default function merge<T extends Value>(
  value: T,
  ...sources: DeepPartial<T>[]
): T {
  return sources.reduce((currentValue, source) => {
    return mergeSource(currentValue, source);
  }, value);
}

function mergeSource<T extends Value>(value: T, source: DeepPartial<T>): T {
  if (source === null || source === undefined) {
    return value; // No override specified so we just return the value.
  }
  if (Array.isArray(value) && Array.isArray(source)) {
    return mergeArraySource(value, source) as T;
  }
  if (
    !Array.isArray(value) &&
    !Array.isArray(source) &&
    typeof value === "object" &&
    typeof source === "object"
  ) {
    return mergeObjectSource(value, source) as T;
  }
  return source as T;
}

function mergeArraySource<T extends Value>(
  value: T[],
  source: DeepPartial<T>[]
): T[] {
  // No null or undefined values indicate that we should override the list length.
  const overrideLength = !source.some(
    (item) => item === undefined || item === null
  );
  if (overrideLength) {
    return source.map((itemOverride, index) =>
      mergeSource(value[index], itemOverride)
    );
  }
  // Add extra items to the end in case override array is longer than value array.
  return (
    [...value, ...source.slice(value.length)]
      .map((itemValue, index) => mergeSource(itemValue as T, source[index]))
      // Remove any null or undefined values that are beyond the original list length.
      .filter((item) => item !== null && item !== undefined)
  );
}

function mergeObjectSource<T extends ObjectValue>(
  value: T,
  override: DeepPartial<T>
): T {
  return Object.fromEntries(
    Object.entries(value)
      .map(([fieldName, field]) => [
        fieldName,
        mergeSource(field, (override as T)[fieldName]),
      ])
      .concat(
        // Add any additional fields from the override to the object.
        Object.entries(override as ObjectValue).filter(
          ([fieldName]) => !(fieldName in value)
        )
      )
  ) as T;
}
