/**
 * 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
 */

/* eslint no-bitwise: 0 */

'use strict';

import type {PlatformConfig} from '../AnimatedPlatformConfig';
import type AnimatedNode from './AnimatedNode';
import type {AnimatedNodeConfig} from './AnimatedNode';

import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
import {validateInterpolation} from '../../../src/private/animated/NativeAnimatedValidation';
import normalizeColor from '../../StyleSheet/normalizeColor';
import processColor from '../../StyleSheet/processColor';
import Easing from '../Easing';
import AnimatedWithChildren from './AnimatedWithChildren';
import invariant from 'invariant';

type ExtrapolateType = 'extend' | 'identity' | 'clamp';

export type InterpolationConfigType<OutputT: number | string> = $ReadOnly<{
  ...AnimatedNodeConfig,
  inputRange: $ReadOnlyArray<number>,
  outputRange: $ReadOnlyArray<OutputT>,
  easing?: (input: number) => number,
  extrapolate?: ExtrapolateType,
  extrapolateLeft?: ExtrapolateType,
  extrapolateRight?: ExtrapolateType,
}>;

/**
 * Very handy helper to map input ranges to output ranges with an easing
 * function and custom behavior outside of the ranges.
 */
function createNumericInterpolation(
  config: InterpolationConfigType<number>,
): (input: number) => number {
  const outputRange: $ReadOnlyArray<number> = (config.outputRange: any);
  const inputRange = config.inputRange;

  const easing = config.easing || Easing.linear;

  let extrapolateLeft: ExtrapolateType = 'extend';
  if (config.extrapolateLeft !== undefined) {
    extrapolateLeft = config.extrapolateLeft;
  } else if (config.extrapolate !== undefined) {
    extrapolateLeft = config.extrapolate;
  }

  let extrapolateRight: ExtrapolateType = 'extend';
  if (config.extrapolateRight !== undefined) {
    extrapolateRight = config.extrapolateRight;
  } else if (config.extrapolate !== undefined) {
    extrapolateRight = config.extrapolate;
  }

  return input => {
    invariant(
      typeof input === 'number',
      'Cannot interpolation an input which is not a number',
    );

    const range = findRange(input, inputRange);
    return (interpolate(
      input,
      inputRange[range],
      inputRange[range + 1],
      outputRange[range],
      outputRange[range + 1],
      easing,
      extrapolateLeft,
      extrapolateRight,
    ): any);
  };
}

function interpolate(
  input: number,
  inputMin: number,
  inputMax: number,
  outputMin: number,
  outputMax: number,
  easing: (input: number) => number,
  extrapolateLeft: ExtrapolateType,
  extrapolateRight: ExtrapolateType,
) {
  let result = input;

  // Extrapolate
  if (result < inputMin) {
    if (extrapolateLeft === 'identity') {
      return result;
    } else if (extrapolateLeft === 'clamp') {
      result = inputMin;
    } else if (extrapolateLeft === 'extend') {
      // noop
    }
  }

  if (result > inputMax) {
    if (extrapolateRight === 'identity') {
      return result;
    } else if (extrapolateRight === 'clamp') {
      result = inputMax;
    } else if (extrapolateRight === 'extend') {
      // noop
    }
  }

  if (outputMin === outputMax) {
    return outputMin;
  }

  if (inputMin === inputMax) {
    if (input <= inputMin) {
      return outputMin;
    }
    return outputMax;
  }

  // Input Range
  if (inputMin === -Infinity) {
    result = -result;
  } else if (inputMax === Infinity) {
    result = result - inputMin;
  } else {
    result = (result - inputMin) / (inputMax - inputMin);
  }

  // Easing
  result = easing(result);

  // Output Range
  if (outputMin === -Infinity) {
    result = -result;
  } else if (outputMax === Infinity) {
    result = result + outputMin;
  } else {
    result = result * (outputMax - outputMin) + outputMin;
  }

  return result;
}

const numericComponentRegex = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;

// Maps string inputs an RGBA color or an array of numeric components
function mapStringToNumericComponents(
  input: string,
):
  | {isColor: true, components: [number, number, number, number]}
  | {isColor: false, components: $ReadOnlyArray<number | string>} {
  let normalizedColor = normalizeColor(input);
  invariant(
    normalizedColor == null || typeof normalizedColor !== 'object',
    'PlatformColors are not supported',
  );

  if (typeof normalizedColor === 'number') {
    normalizedColor = normalizedColor || 0;
    const r = (normalizedColor & 0xff000000) >>> 24;
    const g = (normalizedColor & 0x00ff0000) >>> 16;
    const b = (normalizedColor & 0x0000ff00) >>> 8;
    const a = (normalizedColor & 0x000000ff) / 255;
    return {isColor: true, components: [r, g, b, a]};
  } else {
    const components: Array<string | number> = [];
    let lastMatchEnd = 0;
    let match: RegExp$matchResult;
    while ((match = (numericComponentRegex.exec(input): any)) != null) {
      if (match.index > lastMatchEnd) {
        components.push(input.substring(lastMatchEnd, match.index));
      }
      components.push(parseFloat(match[0]));
      lastMatchEnd = match.index + match[0].length;
    }
    invariant(
      components.length > 0,
      'outputRange must contain color or value with numeric component',
    );
    if (lastMatchEnd < input.length) {
      components.push(input.substring(lastMatchEnd, input.length));
    }
    return {isColor: false, components};
  }
}

/**
 * Supports string shapes by extracting numbers so new values can be computed,
 * and recombines those values into new strings of the same shape.  Supports
 * things like:
 *
 *   rgba(123, 42, 99, 0.36) // colors
 *   -45deg                  // values with units
 */
function createStringInterpolation(
  config: InterpolationConfigType<string>,
): (input: number) => string {
  invariant(config.outputRange.length >= 2, 'Bad output range');
  const outputRange = config.outputRange.map(mapStringToNumericComponents);

  const isColor = outputRange[0].isColor;
  if (__DEV__) {
    invariant(
      outputRange.every(output => output.isColor === isColor),
      'All elements of output range should either be a color or a string with numeric components',
    );
    const firstOutput = outputRange[0].components;
    invariant(
      outputRange.every(
        output => output.components.length === firstOutput.length,
      ),
      'All elements of output range should have the same number of components',
    );
    invariant(
      outputRange.every(output =>
        output.components.every(
          (component, i) =>
            // $FlowIgnoreMe[invalid-compare]
            typeof component === 'number' || component === firstOutput[i],
        ),
      ),
      'All elements of output range should have the same non-numeric components',
    );
  }

  const numericComponents: $ReadOnlyArray<$ReadOnlyArray<number>> =
    outputRange.map(output =>
      isColor
        ? // $FlowIgnoreMe[incompatible-call]
          output.components
        : // $FlowIgnoreMe[incompatible-call]
          output.components.filter(c => typeof c === 'number'),
    );
  const interpolations = numericComponents[0].map((_, i) =>
    createNumericInterpolation({
      ...config,
      outputRange: numericComponents.map(components => components[i]),
    }),
  );
  if (!isColor) {
    return input => {
      const values = interpolations.map(interpolation => interpolation(input));
      let i = 0;
      return outputRange[0].components
        .map(c => (typeof c === 'number' ? values[i++] : c))
        .join('');
    };
  } else {
    return input => {
      const result = interpolations.map((interpolation, i) => {
        const value = interpolation(input);
        // rgba requires that the r,g,b are integers.... so we want to round them, but we *dont* want to
        // round the opacity (4th column).
        return i < 3 ? Math.round(value) : Math.round(value * 1000) / 1000;
      });
      return `rgba(${result[0]}, ${result[1]}, ${result[2]}, ${result[3]})`;
    };
  }
}

function findRange(input: number, inputRange: $ReadOnlyArray<number>) {
  let i;
  for (i = 1; i < inputRange.length - 1; ++i) {
    if (inputRange[i] >= input) {
      break;
    }
  }
  return i - 1;
}

function checkValidRanges<OutputT: number | string>(
  inputRange: $ReadOnlyArray<number>,
  outputRange: $ReadOnlyArray<OutputT>,
) {
  checkInfiniteRange('outputRange', outputRange);
  checkInfiniteRange('inputRange', inputRange);
  checkValidInputRange(inputRange);

  invariant(
    inputRange.length === outputRange.length,
    'inputRange (' +
      inputRange.length +
      ') and outputRange (' +
      outputRange.length +
      ') must have the same length',
  );
}

function checkValidInputRange(arr: $ReadOnlyArray<number>) {
  invariant(arr.length >= 2, 'inputRange must have at least 2 elements');
  const message =
    'inputRange must be monotonically non-decreasing ' + String(arr);
  for (let i = 1; i < arr.length; ++i) {
    invariant(arr[i] >= arr[i - 1], message);
  }
}

function checkInfiniteRange<OutputT: number | string>(
  name: string,
  arr: $ReadOnlyArray<OutputT>,
) {
  invariant(arr.length >= 2, name + ' must have at least 2 elements');
  invariant(
    arr.length !== 2 || arr[0] !== -Infinity || arr[1] !== Infinity,
    /* $FlowFixMe[incompatible-type] (>=0.13.0) - In the addition expression
     * below this comment, one or both of the operands may be something that
     * doesn't cleanly convert to a string, like undefined, null, and object,
     * etc. If you really mean this implicit string conversion, you can do
     * something like String(myThing) */
    // $FlowFixMe[unsafe-addition]
    name + 'cannot be ]-infinity;+infinity[ ' + arr,
  );
}

export default class AnimatedInterpolation<
  OutputT: number | string,
> extends AnimatedWithChildren {
  _parent: AnimatedNode;
  _config: InterpolationConfigType<OutputT>;
  _interpolation: ?(input: number) => OutputT;

  constructor(parent: AnimatedNode, config: InterpolationConfigType<OutputT>) {
    super(config);
    this._parent = parent;
    this._config = config;

    if (__DEV__) {
      checkValidRanges(config.inputRange, config.outputRange);

      // Create interpolation eagerly in dev, so we can signal errors faster
      // even when using the native driver
      this._getInterpolation();
    }
  }

  _getInterpolation(): number => OutputT {
    if (!this._interpolation) {
      const config = this._config;
      if (config.outputRange && typeof config.outputRange[0] === 'string') {
        this._interpolation = (createStringInterpolation((config: any)): any);
      } else {
        this._interpolation = (createNumericInterpolation((config: any)): any);
      }
    }
    return this._interpolation;
  }

  __makeNative(platformConfig: ?PlatformConfig) {
    this._parent.__makeNative(platformConfig);
    super.__makeNative(platformConfig);
  }

  __getValue(): OutputT {
    const parentValue: number = this._parent.__getValue();
    invariant(
      typeof parentValue === 'number',
      'Cannot interpolate an input which is not a number.',
    );
    return this._getInterpolation()(parentValue);
  }

  interpolate<NewOutputT: number | string>(
    config: InterpolationConfigType<NewOutputT>,
  ): AnimatedInterpolation<NewOutputT> {
    return new AnimatedInterpolation(this, config);
  }

  __attach(): void {
    this._parent.__addChild(this);
    super.__attach();
  }

  __detach(): void {
    this._parent.__removeChild(this);
    super.__detach();
  }

  __getNativeConfig(): any {
    if (__DEV__) {
      validateInterpolation(this._config);
    }

    // Only the `outputRange` can contain strings so we don't need to transform `inputRange` here
    let outputRange = this._config.outputRange;
    let outputType = null;
    if (typeof outputRange[0] === 'string') {
      // $FlowIgnoreMe[incompatible-cast]
      outputRange = ((outputRange: $ReadOnlyArray<string>).map(value => {
        const processedColor = processColor(value);
        if (typeof processedColor === 'number') {
          outputType = 'color';
          return processedColor;
        } else {
          return NativeAnimatedHelper.transformDataType(value);
        }
      }): any);
    }

    return {
      inputRange: this._config.inputRange,
      outputRange,
      outputType,
      extrapolateLeft:
        this._config.extrapolateLeft || this._config.extrapolate || 'extend',
      extrapolateRight:
        this._config.extrapolateRight || this._config.extrapolate || 'extend',
      type: 'interpolation',
      debugID: this.__getDebugID(),
    };
  }
}
