/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 * @format
 */

import type {AttributeConfiguration} from '../../Renderer/shims/ReactNativeTypes';

import flattenStyle from '../../StyleSheet/flattenStyle';
import deepDiffer from '../../Utilities/differ/deepDiffer';

const emptyObject = {};

/**
 * Create a payload that contains all the updates between two sets of props.
 *
 * These helpers are all encapsulated into a single module, because they use
 * mutation as a performance optimization which leads to subtle shared
 * dependencies between the code paths. To avoid this mutable state leaking
 * across modules, I've kept them isolated to this module.
 */

type NestedNode = Array<NestedNode> | Object;

// Tracks removed keys
let removedKeys: {[string]: boolean} | null = null;
let removedKeyCount = 0;

const deepDifferOptions = {
  unsafelyIgnoreFunctions: true,
};

function defaultDiffer(prevProp: mixed, nextProp: mixed): boolean {
  if (typeof nextProp !== 'object' || nextProp === null) {
    // Scalars have already been checked for equality
    return true;
  } else {
    // For objects and arrays, the default diffing algorithm is a deep compare
    return deepDiffer(prevProp, nextProp, deepDifferOptions);
  }
}

function restoreDeletedValuesInNestedArray(
  updatePayload: Object,
  node: NestedNode,
  validAttributes: AttributeConfiguration,
) {
  if (Array.isArray(node)) {
    let i = node.length;
    while (i-- && removedKeyCount > 0) {
      restoreDeletedValuesInNestedArray(
        updatePayload,
        node[i],
        validAttributes,
      );
    }
  } else if (node && removedKeyCount > 0) {
    const obj = node;
    for (const propKey in removedKeys) {
      // $FlowFixMe[incompatible-use] removedKeys is always non-null
      if (!removedKeys[propKey]) {
        continue;
      }
      let nextProp = obj[propKey];
      if (nextProp === undefined) {
        continue;
      }

      const attributeConfig = validAttributes[propKey];
      if (!attributeConfig) {
        continue; // not a valid native prop
      }

      if (typeof nextProp === 'function') {
        // $FlowFixMe[incompatible-type] found when upgrading Flow
        nextProp = true;
      }
      if (typeof nextProp === 'undefined') {
        // $FlowFixMe[incompatible-type] found when upgrading Flow
        nextProp = null;
      }

      if (typeof attributeConfig !== 'object') {
        // case: !Object is the default case
        updatePayload[propKey] = nextProp;
      } else if (
        typeof attributeConfig.diff === 'function' ||
        typeof attributeConfig.process === 'function'
      ) {
        // case: CustomAttributeConfiguration
        const nextValue =
          typeof attributeConfig.process === 'function'
            ? attributeConfig.process(nextProp)
            : nextProp;
        updatePayload[propKey] = nextValue;
      }
      // $FlowFixMe[incompatible-use] found when upgrading Flow
      removedKeys[propKey] = false;
      removedKeyCount--;
    }
  }
}

function diffNestedArrayProperty(
  updatePayload: null | Object,
  prevArray: Array<NestedNode>,
  nextArray: Array<NestedNode>,
  validAttributes: AttributeConfiguration,
): null | Object {
  const minLength =
    prevArray.length < nextArray.length ? prevArray.length : nextArray.length;
  let i;
  for (i = 0; i < minLength; i++) {
    // Diff any items in the array in the forward direction. Repeated keys
    // will be overwritten by later values.
    updatePayload = diffNestedProperty(
      updatePayload,
      prevArray[i],
      nextArray[i],
      validAttributes,
    );
  }
  for (; i < prevArray.length; i++) {
    // Clear out all remaining properties.
    updatePayload = clearNestedProperty(
      updatePayload,
      prevArray[i],
      validAttributes,
    );
  }
  for (; i < nextArray.length; i++) {
    // Add all remaining properties
    const nextProp = nextArray[i];
    if (!nextProp) {
      continue;
    }
    updatePayload = addNestedProperty(updatePayload, nextProp, validAttributes);
  }
  return updatePayload;
}

function diffNestedProperty(
  updatePayload: null | Object,
  prevProp: NestedNode,
  nextProp: NestedNode,
  validAttributes: AttributeConfiguration,
): null | Object {
  if (!updatePayload && prevProp === nextProp) {
    // If no properties have been added, then we can bail out quickly on object
    // equality.
    return updatePayload;
  }

  if (!prevProp || !nextProp) {
    if (nextProp) {
      return addNestedProperty(updatePayload, nextProp, validAttributes);
    }
    if (prevProp) {
      return clearNestedProperty(updatePayload, prevProp, validAttributes);
    }
    return updatePayload;
  }

  if (!Array.isArray(prevProp) && !Array.isArray(nextProp)) {
    // Both are leaves, we can diff the leaves.
    return diffProperties(updatePayload, prevProp, nextProp, validAttributes);
  }

  if (Array.isArray(prevProp) && Array.isArray(nextProp)) {
    // Both are arrays, we can diff the arrays.
    return diffNestedArrayProperty(
      updatePayload,
      prevProp,
      nextProp,
      validAttributes,
    );
  }

  if (Array.isArray(prevProp)) {
    return diffProperties(
      updatePayload,
      flattenStyle(prevProp),
      nextProp,
      validAttributes,
    );
  }

  return diffProperties(
    updatePayload,
    prevProp,
    flattenStyle(nextProp),
    validAttributes,
  );
}

/**
 * clearNestedProperty takes a single set of props and valid attributes. It
 * adds a null sentinel to the updatePayload, for each prop key.
 */
function clearNestedProperty(
  updatePayload: null | Object,
  prevProp: NestedNode,
  validAttributes: AttributeConfiguration,
): null | Object {
  if (!prevProp) {
    return updatePayload;
  }

  if (!Array.isArray(prevProp)) {
    // Add each property of the leaf.
    return clearProperties(updatePayload, prevProp, validAttributes);
  }

  for (let i = 0; i < prevProp.length; i++) {
    // Add all the properties of the array.
    updatePayload = clearNestedProperty(
      updatePayload,
      prevProp[i],
      validAttributes,
    );
  }
  return updatePayload;
}

/**
 * diffProperties takes two sets of props and a set of valid attributes
 * and write to updatePayload the values that changed or were deleted.
 * If no updatePayload is provided, a new one is created and returned if
 * anything changed.
 */
function diffProperties(
  updatePayload: null | Object,
  prevProps: Object,
  nextProps: Object,
  validAttributes: AttributeConfiguration,
): null | Object {
  let attributeConfig;
  let nextProp;
  let prevProp;

  for (const propKey in nextProps) {
    attributeConfig = validAttributes[propKey];
    if (!attributeConfig) {
      continue; // not a valid native prop
    }

    prevProp = prevProps[propKey];
    nextProp = nextProps[propKey];

    if (typeof nextProp === 'function') {
      const attributeConfigHasProcess =
        typeof attributeConfig === 'object' &&
        typeof attributeConfig.process === 'function';
      if (!attributeConfigHasProcess) {
        // functions are converted to booleans as markers that the associated
        // events should be sent from native.
        nextProp = (true: any);
        // If nextProp is not a function, then don't bother changing prevProp
        // since nextProp will win and go into the updatePayload regardless.
        if (typeof prevProp === 'function') {
          prevProp = (true: any);
        }
      }
    }

    // An explicit value of undefined is treated as a null because it overrides
    // any other preceding value.
    if (typeof nextProp === 'undefined') {
      nextProp = (null: any);
      if (typeof prevProp === 'undefined') {
        prevProp = (null: any);
      }
    }

    if (removedKeys) {
      removedKeys[propKey] = false;
    }

    if (updatePayload && updatePayload[propKey] !== undefined) {
      // Something else already triggered an update to this key because another
      // value diffed. Since we're now later in the nested arrays our value is
      // more important so we need to calculate it and override the existing
      // value. It doesn't matter if nothing changed, we'll set it anyway.

      // Pattern match on: attributeConfig
      if (typeof attributeConfig !== 'object') {
        // case: !Object is the default case
        updatePayload[propKey] = nextProp;
      } else if (
        typeof attributeConfig.diff === 'function' ||
        typeof attributeConfig.process === 'function'
      ) {
        // case: CustomAttributeConfiguration
        const nextValue =
          typeof attributeConfig.process === 'function'
            ? attributeConfig.process(nextProp)
            : nextProp;
        updatePayload[propKey] = nextValue;
      }
      continue;
    }

    if (prevProp === nextProp) {
      continue; // nothing changed
    }

    // Pattern match on: attributeConfig
    if (typeof attributeConfig !== 'object') {
      // case: !Object is the default case
      if (defaultDiffer(prevProp, nextProp)) {
        // a normal leaf has changed
        (updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[
          propKey
        ] = nextProp;
      }
    } else if (
      typeof attributeConfig.diff === 'function' ||
      typeof attributeConfig.process === 'function'
    ) {
      // case: CustomAttributeConfiguration
      const shouldUpdate =
        prevProp === undefined ||
        (typeof attributeConfig.diff === 'function'
          ? attributeConfig.diff(prevProp, nextProp)
          : defaultDiffer(prevProp, nextProp));
      if (shouldUpdate) {
        const nextValue =
          typeof attributeConfig.process === 'function'
            ? // $FlowFixMe[incompatible-use] found when upgrading Flow
              attributeConfig.process(nextProp)
            : nextProp;
        (updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[
          propKey
        ] = nextValue;
      }
    } else {
      // default: fallthrough case when nested properties are defined
      removedKeys = null;
      removedKeyCount = 0;
      // We think that attributeConfig is not CustomAttributeConfiguration at
      // this point so we assume it must be AttributeConfiguration.
      updatePayload = diffNestedProperty(
        updatePayload,
        prevProp,
        nextProp,
        ((attributeConfig: any): AttributeConfiguration),
      );
      if (removedKeyCount > 0 && updatePayload) {
        restoreDeletedValuesInNestedArray(
          updatePayload,
          nextProp,
          ((attributeConfig: any): AttributeConfiguration),
        );
        removedKeys = null;
      }
    }
  }

  // Also iterate through all the previous props to catch any that have been
  // removed and make sure native gets the signal so it can reset them to the
  // default.
  for (const propKey in prevProps) {
    if (nextProps[propKey] !== undefined) {
      continue; // we've already covered this key in the previous pass
    }
    attributeConfig = validAttributes[propKey];
    if (!attributeConfig) {
      continue; // not a valid native prop
    }

    if (updatePayload && updatePayload[propKey] !== undefined) {
      // This was already updated to a diff result earlier.
      continue;
    }

    prevProp = prevProps[propKey];
    if (prevProp === undefined) {
      continue; // was already empty anyway
    }
    // Pattern match on: attributeConfig
    if (
      typeof attributeConfig !== 'object' ||
      typeof attributeConfig.diff === 'function' ||
      typeof attributeConfig.process === 'function'
    ) {
      // case: CustomAttributeConfiguration | !Object
      // Flag the leaf property for removal by sending a sentinel.
      (updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[
        propKey
      ] = null;
      if (!removedKeys) {
        removedKeys = ({}: {[string]: boolean});
      }
      if (!removedKeys[propKey]) {
        removedKeys[propKey] = true;
        removedKeyCount++;
      }
    } else {
      // default:
      // This is a nested attribute configuration where all the properties
      // were removed so we need to go through and clear out all of them.
      updatePayload = clearNestedProperty(
        updatePayload,
        prevProp,
        ((attributeConfig: any): AttributeConfiguration),
      );
    }
  }
  return updatePayload;
}

function addNestedProperty(
  payload: null | Object,
  props: Object,
  validAttributes: AttributeConfiguration,
): null | Object {
  // Flatten nested style props.
  if (Array.isArray(props)) {
    for (let i = 0; i < props.length; i++) {
      payload = addNestedProperty(payload, props[i], validAttributes);
    }
    return payload;
  }

  for (const propKey in props) {
    const prop = props[propKey];

    const attributeConfig = ((validAttributes[
      propKey
    ]: any): AttributeConfiguration);

    if (attributeConfig == null) {
      continue;
    }

    let newValue;

    if (prop === undefined) {
      // Discard the prop if it was previously defined.
      if (payload && payload[propKey] !== undefined) {
        newValue = null;
      } else {
        continue;
      }
    } else if (typeof attributeConfig === 'object') {
      if (typeof attributeConfig.process === 'function') {
        // An atomic prop with custom processing.
        newValue = attributeConfig.process(prop);
      } else if (typeof attributeConfig.diff === 'function') {
        // An atomic prop with custom diffing. We don't need to do diffing when adding props.
        newValue = prop;
      }
    } else {
      if (typeof prop === 'function') {
        // A function prop. It represents an event handler. Pass it to native as 'true'.
        newValue = true;
      } else {
        // An atomic prop. Doesn't need to be flattened.
        newValue = prop;
      }
    }

    if (newValue !== undefined) {
      if (!payload) {
        payload = ({}: {[string]: $FlowFixMe});
      }
      payload[propKey] = newValue;
      continue;
    }

    payload = addNestedProperty(payload, prop, attributeConfig);
  }

  return payload;
}

/**
 * clearProperties clears all the previous props by adding a null sentinel
 * to the payload for each valid key.
 */
function clearProperties(
  updatePayload: null | Object,
  prevProps: Object,
  validAttributes: AttributeConfiguration,
): null | Object {
  return diffProperties(updatePayload, prevProps, emptyObject, validAttributes);
}

export function create(
  props: Object,
  validAttributes: AttributeConfiguration,
): null | Object {
  return addNestedProperty(null, props, validAttributes);
}

export function diff(
  prevProps: Object,
  nextProps: Object,
  validAttributes: AttributeConfiguration,
): null | Object {
  return diffProperties(
    null, // updatePayload
    prevProps,
    nextProps,
    validAttributes,
  );
}
