/**
 * Copyright (c) Facebook, Inc. and its 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
 */

'use strict';

const View = require('../Components/View/View');
const {AnimatedEvent} = require('./AnimatedEvent');
const AnimatedProps = require('./nodes/AnimatedProps');
const React = require('react');
const NativeAnimatedHelper = require('./NativeAnimatedHelper');

const invariant = require('invariant');
const setAndForwardRef = require('../Utilities/setAndForwardRef');

let animatedComponentNextId = 1;

export type AnimatedComponentType<
  Props: {+[string]: mixed, ...},
  Instance,
> = React.AbstractComponent<
  $ObjMap<
    Props &
      $ReadOnly<{
        passthroughAnimatedPropExplicitValues?: React.ElementConfig<
          typeof View,
        >,
      }>,
    () => any,
  >,
  Instance,
>;

type AnimatedComponentOptions = {
  collapsable?: boolean,
};

function createAnimatedComponent<Props: {+[string]: mixed, ...}, Instance>(
  Component: React.AbstractComponent<Props, Instance>,
  options?: AnimatedComponentOptions,
): AnimatedComponentType<Props, Instance> {
  invariant(
    typeof Component !== 'function' ||
      (Component.prototype && Component.prototype.isReactComponent),
    '`createAnimatedComponent` does not support stateless functional components; ' +
      'use a class component instead.',
  );

  class AnimatedComponent extends React.Component<Object> {
    _component: any; // TODO T53738161: flow type this, and the whole file
    _invokeAnimatedPropsCallbackOnMount: boolean = false;
    _prevComponent: any;
    _propsAnimated: AnimatedProps;
    _eventDetachers: Array<Function> = [];

    // Only to be used in this file, and only in Fabric.
    _animatedComponentId: string = `${animatedComponentNextId++}:animatedComponent`;

    _attachNativeEvents() {
      // Make sure to get the scrollable node for components that implement
      // `ScrollResponder.Mixin`.
      const scrollableNode = this._component?.getScrollableNode
        ? this._component.getScrollableNode()
        : this._component;

      for (const key in this.props) {
        const prop = this.props[key];
        if (prop instanceof AnimatedEvent && prop.__isNative) {
          prop.__attach(scrollableNode, key);
          this._eventDetachers.push(() => prop.__detach(scrollableNode, key));
        }
      }
    }

    _detachNativeEvents() {
      this._eventDetachers.forEach(remove => remove());
      this._eventDetachers = [];
    }

    _isFabric = (): boolean => {
      // When called during the first render, `_component` is always null.
      // Therefore, even if a component is rendered in Fabric, we can't detect
      // that until ref is set, which happens sometime after the first render.
      // In cases where this value switching between "false" and "true" on Fabric
      // causes issues, add an additional check for _component nullity.
      if (this._component == null) {
        return false;
      }
      return (
        // eslint-disable-next-line dot-notation
        this._component['_internalInstanceHandle']?.stateNode?.canonical !=
          null ||
        // Some components have a setNativeProps function but aren't a host component
        // such as lists like FlatList and SectionList. These should also use
        // forceUpdate in Fabric since setNativeProps doesn't exist on the underlying
        // host component. This crazy hack is essentially special casing those lists and
        // ScrollView itself to use forceUpdate in Fabric.
        // If these components end up using forwardRef then these hacks can go away
        // as this._component would actually be the underlying host component and the above check
        // would be sufficient.
        (this._component.getNativeScrollRef != null &&
          this._component.getNativeScrollRef() != null &&
          // eslint-disable-next-line dot-notation
          this._component.getNativeScrollRef()['_internalInstanceHandle']
            ?.stateNode?.canonical != null) ||
        (this._component.getScrollResponder != null &&
          this._component.getScrollResponder() != null &&
          this._component.getScrollResponder().getNativeScrollRef != null &&
          this._component.getScrollResponder().getNativeScrollRef() != null &&
          this._component.getScrollResponder().getNativeScrollRef()[
            // eslint-disable-next-line dot-notation
            '_internalInstanceHandle'
          ]?.stateNode?.canonical != null)
      );
    };

    _waitForUpdate = (): void => {
      if (this._isFabric()) {
        NativeAnimatedHelper.API.setWaitingForIdentifier(
          this._animatedComponentId,
        );
      }
    };

    _markUpdateComplete = (): void => {
      if (this._isFabric()) {
        NativeAnimatedHelper.API.unsetWaitingForIdentifier(
          this._animatedComponentId,
        );
      }
    };

    // The system is best designed when setNativeProps is implemented. It is
    // able to avoid re-rendering and directly set the attributes that changed.
    // However, setNativeProps can only be implemented on leaf native
    // components. If you want to animate a composite component, you need to
    // re-render it. In this case, we have a fallback that uses forceUpdate.
    // This fallback is also called in Fabric.
    _animatedPropsCallback = () => {
      if (this._component == null) {
        // AnimatedProps is created in will-mount because it's used in render.
        // But this callback may be invoked before mount in async mode,
        // In which case we should defer the setNativeProps() call.
        // React may throw away uncommitted work in async mode,
        // So a deferred call won't always be invoked.
        this._invokeAnimatedPropsCallbackOnMount = true;
      } else if (
        process.env.NODE_ENV === 'test' ||
        // For animating properties of non-leaf/non-native components
        typeof this._component.setNativeProps !== 'function' ||
        // In Fabric, force animations to go through forceUpdate and skip setNativeProps
        this._isFabric()
      ) {
        this.forceUpdate();
      } else if (!this._propsAnimated.__isNative) {
        this._component.setNativeProps(
          this._propsAnimated.__getAnimatedValue(),
        );
      } else {
        throw new Error(
          'Attempting to run JS driven animation on animated ' +
            'node that has been moved to "native" earlier by starting an ' +
            'animation with `useNativeDriver: true`',
        );
      }
    };

    _attachProps(nextProps) {
      const oldPropsAnimated = this._propsAnimated;

      if (nextProps === oldPropsAnimated) {
        return;
      }

      this._propsAnimated = new AnimatedProps(
        nextProps,
        this._animatedPropsCallback,
      );

      // When you call detach, it removes the element from the parent list
      // of children. If it goes to 0, then the parent also detaches itself
      // and so on.
      // An optimization is to attach the new elements and THEN detach the old
      // ones instead of detaching and THEN attaching.
      // This way the intermediate state isn't to go to 0 and trigger
      // this expensive recursive detaching to then re-attach everything on
      // the very next operation.
      if (oldPropsAnimated) {
        oldPropsAnimated.__restoreDefaultValues();
        oldPropsAnimated.__detach();
      }
    }

    _setComponentRef = setAndForwardRef({
      getForwardedRef: () => this.props.forwardedRef,
      setLocalRef: ref => {
        this._prevComponent = this._component;
        this._component = ref;

        // TODO: Delete this in a future release.
        if (ref != null && ref.getNode == null) {
          ref.getNode = () => {
            console.warn(
              '%s: Calling `getNode()` on the ref of an Animated component ' +
                'is no longer necessary. You can now directly use the ref ' +
                'instead. This method will be removed in a future release.',
              ref.constructor.name ?? '<<anonymous>>',
            );
            return ref;
          };
        }
      },
    });

    render() {
      const {style = {}, ...props} = this._propsAnimated.__getValue() || {};
      const {style: passthruStyle = {}, ...passthruProps} =
        this.props.passthroughAnimatedPropExplicitValues || {};
      const mergedStyle = {...style, ...passthruStyle};

      // On Fabric, we always want to ensure the container Animated View is *not*
      // flattened.
      // Because we do not get a host component ref immediately and thus cannot
      // do a proper Fabric vs non-Fabric detection immediately, we default to assuming
      // that Fabric *is* enabled until we know otherwise.
      // Thus, in Fabric, this view will never be flattened. In non-Fabric, the view will
      // not be flattened during the initial render but may be flattened in the second render
      // and onwards.
      const forceNativeIdFabric =
        (this._component == null &&
          (options?.collapsable === false || props.collapsable !== true)) ||
        this._isFabric();

      const forceNativeId =
        props.collapsable ??
        (this._propsAnimated.__isNative ||
          forceNativeIdFabric ||
          options?.collapsable === false);
      // The native driver updates views directly through the UI thread so we
      // have to make sure the view doesn't get optimized away because it cannot
      // go through the NativeViewHierarchyManager since it operates on the shadow
      // thread. TODO: T68258846
      const collapsableProps = forceNativeId
        ? {
            nativeID: props.nativeID ?? 'animatedComponent',
            collapsable: false,
          }
        : {};
      return (
        <Component
          {...props}
          {...passthruProps}
          {...collapsableProps}
          style={mergedStyle}
          ref={this._setComponentRef}
        />
      );
    }

    UNSAFE_componentWillMount() {
      this._waitForUpdate();
      this._attachProps(this.props);
    }

    componentDidMount() {
      if (this._invokeAnimatedPropsCallbackOnMount) {
        this._invokeAnimatedPropsCallbackOnMount = false;
        this._animatedPropsCallback();
      }

      this._propsAnimated.setNativeView(this._component);
      this._attachNativeEvents();
      this._markUpdateComplete();
    }

    UNSAFE_componentWillReceiveProps(newProps) {
      this._waitForUpdate();
      this._attachProps(newProps);
    }

    componentDidUpdate(prevProps) {
      if (this._component !== this._prevComponent) {
        this._propsAnimated.setNativeView(this._component);
      }
      if (this._component !== this._prevComponent || prevProps !== this.props) {
        this._detachNativeEvents();
        this._attachNativeEvents();
      }
      this._markUpdateComplete();
    }

    componentWillUnmount() {
      this._propsAnimated && this._propsAnimated.__detach();
      this._detachNativeEvents();
      this._markUpdateComplete();
      this._component = null;
      this._prevComponent = null;
    }
  }

  return React.forwardRef(function AnimatedComponentWrapper(props, ref) {
    return (
      <AnimatedComponent
        {...props}
        {...(ref == null ? null : {forwardedRef: ref})}
      />
    );
  });
}

module.exports = createAnimatedComponent;
