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

import * as React from 'react';
import invariant from 'shared/invariant';

type Unsubscribe = () => void;

export function createSubscription<Property, Value>(
  config: $ReadOnly<{|
    // Synchronously gets the value for the subscribed property.
    // Return undefined if the subscribable value is undefined,
    // Or does not support synchronous reading (e.g. native Promise).
    getCurrentValue: (source: Property) => Value | void,

    // Setup a subscription for the subscribable value in props, and return an unsubscribe function.
    // Return empty function if the property cannot be unsubscribed from (e.g. native Promises).
    // Due to the variety of change event types, subscribers should provide their own handlers.
    // Those handlers should not attempt to update state though;
    // They should call the callback() instead when a subscription changes.
    subscribe: (
      source: Property,
      callback: (value: Value | void) => void,
    ) => Unsubscribe,
  |}>,
): React$ComponentType<{|
  children: (value: Value | void) => React$Node,
  source: Property,
|}> {
  const {getCurrentValue, subscribe} = config;

  if (__DEV__) {
    if (typeof getCurrentValue !== 'function') {
      console.error('Subscription must specify a getCurrentValue function');
    }
    if (typeof subscribe !== 'function') {
      console.error('Subscription must specify a subscribe function');
    }
  }

  type Props = {|
    children: (value: Value) => React$Element<any>,
    source: Property,
  |};
  type State = {|
    source: Property,
    value: Value | void,
  |};

  // Reference: https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3
  class Subscription extends React.Component<Props, State> {
    state: State = {
      source: this.props.source,
      value:
        this.props.source != null
          ? getCurrentValue(this.props.source)
          : undefined,
    };

    _hasUnmounted: boolean = false;
    _unsubscribe: Unsubscribe | null = null;

    static getDerivedStateFromProps(nextProps, prevState) {
      if (nextProps.source !== prevState.source) {
        return {
          source: nextProps.source,
          value:
            nextProps.source != null
              ? getCurrentValue(nextProps.source)
              : undefined,
        };
      }

      return null;
    }

    componentDidMount() {
      this.subscribe();
    }

    componentDidUpdate(prevProps, prevState) {
      if (this.state.source !== prevState.source) {
        this.unsubscribe();
        this.subscribe();
      }
    }

    componentWillUnmount() {
      this.unsubscribe();

      // Track mounted to avoid calling setState after unmounting
      // For source like Promises that can't be unsubscribed from.
      this._hasUnmounted = true;
    }

    render() {
      return this.props.children(this.state.value);
    }

    subscribe() {
      const {source} = this.state;
      if (source != null) {
        const callback = (value: Value | void) => {
          if (this._hasUnmounted) {
            return;
          }

          this.setState(state => {
            // If the value is the same, skip the unnecessary state update.
            if (value === state.value) {
              return null;
            }

            // If this event belongs to an old or uncommitted data source, ignore it.
            if (source !== state.source) {
              return null;
            }

            return {value};
          });
        };

        // Store the unsubscribe method for later (in case the subscribable prop changes).
        const unsubscribe = subscribe(source, callback);
        invariant(
          typeof unsubscribe === 'function',
          'A subscription must return an unsubscribe function.',
        );

        // It's safe to store unsubscribe on the instance because
        // We only read or write that property during the "commit" phase.
        this._unsubscribe = unsubscribe;

        // External values could change between render and mount,
        // In some cases it may be important to handle this case.
        const value = getCurrentValue(this.props.source);
        if (value !== this.state.value) {
          this.setState({value});
        }
      }
    }

    unsubscribe() {
      if (typeof this._unsubscribe === 'function') {
        this._unsubscribe();
      }
      this._unsubscribe = null;
    }
  }

  return Subscription;
}
