// Copyright 2016 Workiva Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

library abstract_transition;

import 'dart:async';
import 'dart:html';

import 'package:over_react/over_react.dart';

@AbstractProps()
abstract class AbstractTransitionProps extends UiProps {    /* GENERATED CONSTANTS */ static const ConsumedProps $consumedProps = const ConsumedProps($props, $propKeys); static const PropDescriptor _$prop__transitionCount = const PropDescriptor(_$key__transitionCount), _$prop__onWillHide = const PropDescriptor(_$key__onWillHide), _$prop__onDidHide = const PropDescriptor(_$key__onDidHide), _$prop__onWillShow = const PropDescriptor(_$key__onWillShow), _$prop__onDidShow = const PropDescriptor(_$key__onDidShow); static const List<PropDescriptor> $props = const [_$prop__transitionCount, _$prop__onWillHide, _$prop__onDidHide, _$prop__onWillShow, _$prop__onDidShow]; static const String _$key__transitionCount = 'AbstractTransitionProps.transitionCount', _$key__onWillHide = 'AbstractTransitionProps.onWillHide', _$key__onDidHide = 'AbstractTransitionProps.onDidHide', _$key__onWillShow = 'AbstractTransitionProps.onWillShow', _$key__onDidShow = 'AbstractTransitionProps.onDidShow'; static const List<String> $propKeys = const [_$key__transitionCount, _$key__onWillHide, _$key__onDidHide, _$key__onWillShow, _$key__onDidShow]; 
  /// Number of transitions to occur within the [AbstractTransitionComponent].
  ///
  /// _If the [AbstractTransitionComponent] does not transition set [AbstractTransitionProps.transition] to [Transition.NONE] rather than setting this to 0._
  ///
  /// Default: 1
  int transitionCountint get transitionCount => props[_$key__transitionCount];  set transitionCount(int value) => props[_$key__transitionCount] = value;;

  /// Optional callback that fires before the [AbstractTransitionComponent] is hidden.
  ///
  /// Returning `false` will cancel default behavior, and the [AbstractTransitionComponent] will remain visible.
  Callback onWillHideCallback get onWillHide => props[_$key__onWillHide];  set onWillHide(Callback value) => props[_$key__onWillHide] = value;;

  /// Optional callback that fires after the [AbstractTransitionComponent] is hidden.
  Callback onDidHideCallback get onDidHide => props[_$key__onDidHide];  set onDidHide(Callback value) => props[_$key__onDidHide] = value;;

  /// Optional callback that fires before the [AbstractTransitionComponent] appears.
  ///
  /// Returning `false` will cancel default behavior, and the [AbstractTransitionComponent] will not appear.
  Callback onWillShowCallback get onWillShow => props[_$key__onWillShow];  set onWillShow(Callback value) => props[_$key__onWillShow] = value;;

  /// Optional callback that fires after the [AbstractTransitionComponent] appears.
  Callback onDidShowCallback get onDidShow => props[_$key__onDidShow];  set onDidShow(Callback value) => props[_$key__onDidShow] = value;;
}

@AbstractState()
abstract class AbstractTransitionState extends UiState {    /* GENERATED CONSTANTS */ static const StateDescriptor _$prop__transitionPhase = const StateDescriptor(_$key__transitionPhase); static const List<StateDescriptor> $state = const [_$prop__transitionPhase]; static const String _$key__transitionPhase = 'AbstractTransitionState.transitionPhase'; static const List<String> $stateKeys = const [_$key__transitionPhase]; 
  /// The current phase of transition the [AbstractTransitionComponent] is in.
  ///
  /// Default:  [AbstractTransitionComponent.initiallyShown] ? [TransitionState.SHOWN] : [TransitionState.HIDDEN]
  TransitionPhase transitionPhaseTransitionPhase get transitionPhase => state[_$key__transitionPhase];  set transitionPhase(TransitionPhase value) => state[_$key__transitionPhase] = value;;
}

/// How to use [AbstractTransitionComponent]:
///
/// * Create props and state the extend [AbstractTransitionProps] and [AbstractTransitionState].
///
///         @Props()
///         class CustomComponentProps extends AbstractTransitionProps {}
///
///         @State()
///         class CustomComponentState extends AbstractTransitionProps {}
///
/// * Have your component extend [AbstractTransitionComponent].
///
///         @Component()
///         class CustomComponent extends AbstractTransitionComponent<CustomComponentProps, CustomComponentState> {}
///
/// * Override [initiallyShown], [getTransitionDomNode] and optionally [hasTransition].
///
/// * Use helper getters to render your component.
///
///         @override
///         render() {
///           if (!shouldRender) {
///             return false;
///           }
///
///           var classes = forwardingClassNameBuilder()
///             ..add('class-to-start-transition', isShown);
///
///           return (Dom.div()
///             ..className = classes.toClassName()
///           )()
///         }
///
/// * Granular lifecycle methods available:
///   * [prepareShow]
///   * [handlePreShowing]
///   * [handleShowing]
///   * [handleShown]
///   * [prepareHide]
///   * [handleHiding]
///   * [handleHidden]
///
/// * API methods that you get for free:
///   * [show]
///   * [hide]
///   * [toggle]
@AbstractComponent()
abstract class AbstractTransitionComponent<T extends AbstractTransitionProps, S extends AbstractTransitionState> extends UiStatefulComponent<T, S> {
  @override
  Map getDefaultProps() => (newProps()
    ..transitionCount = 1
  );

  @override
  Map getInitialState() => (newState()
    ..transitionPhase = this.initiallyShown ? TransitionPhase.SHOWN : TransitionPhase.HIDDEN
  );

  /// Stream for listening to `transitionend` events on the [AbstractTransitionComponent].
  StreamSubscription _endTransitionSubscription;

  /// Whether the [AbstractTransitionComponent] should be visible initially when mounted.
  bool get initiallyShown;

  /// Returns the DOM node that will transition.
  Element getTransitionDomNode();

  /// Whether the Element returned by [getTransitionDomNode] will have a transition event.
  bool get hasTransition => true;

  /// The duration that can elapse before a transition timeout occurs.
  Duration get transitionTimeout => const Duration(seconds: 1);

  // --------------------------------------------------------------------------
  // Private Utility Methods
  // --------------------------------------------------------------------------

  /// Begin showing the [AbstractTransitionComponent], unless:
  ///   * The [AbstractTransitionComponent] is already shown or is in the process of showing.
  ///   * The [AbstractTransitionProps.onWillShow] callback returns `false`.
  void _handleShow() {
    if (isOrWillBeShown) {
      return;
    }

    if (props.onWillShow != null && props.onWillShow() == false) {
      // Short-circuit default behavior if the callback cancelled this action by returning 'false'.
      return;
    }

    prepareShow();

    setState(newState()
      ..transitionPhase = hasTransition ? TransitionPhase.PRE_SHOWING : TransitionPhase.SHOWN
    );
  }

  /// Begin hiding the [AbstractTransitionComponent], unless:
  ///   * The [AbstractTransitionComponent] is already hidden or is in the process of being hidden.
  ///   * The [AbstractTransitionProps.onWillHide] callback returns `false`.
  void _handleHide() {
    if (isOrWillBeHidden) {
      return;
    }

    if (props.onWillHide != null && props.onWillHide() == false) {
      // Short-circuit default behavior if the callback cancelled this action by returning 'false'.
      return;
    }

    prepareHide();

    setState(newState()
      ..transitionPhase = hasTransition ? TransitionPhase.HIDING : TransitionPhase.HIDDEN
    );
  }

  /// Listens for the next `transitionend` event and invokes a callback after
  /// the event is dispatched.
  void onNextTransitionEnd(complete()) {
    var skipCount = props.transitionCount - 1;

    if (props.transitionCount <= 0) {
      var warningMessage = 'You have set `props.transitionCount` to an invalid option: ${props.transitionCount}.';

      if (props.transitionCount == 0) {
        warningMessage += ' Instead of setting this prop to 0, override the `hasTransition` getter to return false.';
      }

      assert(ValidationUtil.warn(warningMessage));

      skipCount = 0;
    }

    var timer = new Timer(transitionTimeout, () {
      assert(ValidationUtil.warn(
        'The number of transitions expected to complete have not completed. Something is most likely wrong.'
      ));

      complete();
    });

    _endTransitionSubscription = getTransitionDomNode()?.onTransitionEnd?.skip(skipCount)?.take(1)?.listen((_) {
      timer.cancel();
      complete();
    });
  }

  void _cancelTransitionEventListener() {
    _endTransitionSubscription?.cancel();
    _endTransitionSubscription = null;
  }

  /// Whether the [AbstractTransitionComponent] should render.
  ///
  /// If this is false your [render] should return false.
  bool get shouldRender =>
      state.transitionPhase != TransitionPhase.HIDDEN;


  /// Whether the [AbstractTransitionComponent] is in a "visible" state.
  ///
  /// You should add your CSS class that starts a transition based on this value.
  bool get isShown =>
      state.transitionPhase == TransitionPhase.SHOWN ||
      state.transitionPhase == TransitionPhase.SHOWING;

  /// Whether the [AbstractTransitionComponent] is hidden or in the process of hiding.
  bool get isOrWillBeHidden =>
      state.transitionPhase == TransitionPhase.HIDING ||
      state.transitionPhase == TransitionPhase.HIDDEN;

  /// Whether the [AbstractTransitionComponent] is shown or in the process of showing.
  bool get isOrWillBeShown =>
      state.transitionPhase == TransitionPhase.PRE_SHOWING ||
      state.transitionPhase == TransitionPhase.SHOWING ||
      state.transitionPhase == TransitionPhase.SHOWN;

  // --------------------------------------------------------------------------
  // Lifecycle Methods
  // --------------------------------------------------------------------------

  /// Whether the overlay is not guaranteed to transition in response to the current
  /// state change.
  ///
  /// _Stored as variable as workaround for not adding breaking change to [handleHiding] API._
  ///
  /// A transition may not always occur when the state moves from SHOWING to HIDING
  /// if the PRE_SHOWING-->SHOWING-->HIDING transition happens back-to-back.
  ///
  /// Better to not always transition when the user is ninja-toggling a transitionable
  /// component than to break state changes waiting for a transition that will never happen.
  bool _transitionNotGuaranteed = false;

  @override
  void componentDidUpdate(Map prevProps, Map prevState) {
    _transitionNotGuaranteed = false;

    var tPrevState = typedStateFactory(prevState);

    if (tPrevState.transitionPhase != state.transitionPhase) {
      if (state.transitionPhase != TransitionPhase.SHOWING) {
        // Allows the AbstractTransitionComponent to handle state changes that interrupt state
        // changes waiting on transitionend events.
        _cancelTransitionEventListener();
      }

      switch (state.transitionPhase) {
        case TransitionPhase.PRE_SHOWING:
          handlePreShowing();
          break;
        case TransitionPhase.SHOWING:
          handleShowing();
          break;
        case TransitionPhase.HIDING:
          _transitionNotGuaranteed = tPrevState.transitionPhase == TransitionPhase.SHOWING;
          handleHiding();
          break;
        case TransitionPhase.HIDDEN:
          handleHidden();
          break;
        case TransitionPhase.SHOWN:
          handleShown();
          break;
      }
    }
  }

  @override
  void componentWillUnmount() {
    _cancelTransitionEventListener();
  }

  // --------------------------------------------------------------------------
  // State Transition Methods
  // --------------------------------------------------------------------------

  /// Method that will be called right before the [AbstractTransitionComponent] begins to show.
  void prepareShow() {}

  /// Method that will be called right before the [AbstractTransitionComponent] begins to hide.
  void prepareHide() {}

  /// Method that will be called when [AbstractTransitionComponent]  first enters the `preShowing` state.
  void handlePreShowing() {
    onNextTransitionEnd(() {
      if (state.transitionPhase == TransitionPhase.SHOWING) {
        setState(newState()
          ..transitionPhase = TransitionPhase.SHOWN
        );
      }
    });

    // Force a repaint by accessing `offsetHeight` so that the changes to CSS classes are guaranteed to trigger a transition when it is applied
    getTransitionDomNode()?.offsetHeight;

    setState(newState()
      ..transitionPhase = TransitionPhase.SHOWING
    );
  }

  /// Method that will be called when [AbstractTransitionComponent]  first enters the `showing` state.
  void handleShowing() {}

  /// Method that will be called when [AbstractTransitionComponent]  first enters the `hiding` state.
  void handleHiding() {
    if (_transitionNotGuaranteed) {
      // No transition will occur, so kick off the state change manually.
      //
      // Do this in a microtask since this state change causes invariant exceptions
      // when OverlayTrigger API methods are called at the same time.
      //
      // TODO: possibly remove microtask once using react-dart 1.0.0
      scheduleMicrotask(() {
        setState(newState()
          ..transitionPhase = TransitionPhase.HIDDEN
        );
      });
    } else {
      onNextTransitionEnd(() {
        if (state.transitionPhase == TransitionPhase.HIDING) {
          setState(newState()
            ..transitionPhase = TransitionPhase.HIDDEN
          );
        }
      });
    }
  }

  /// Method that will be called when [AbstractTransitionComponent]  first enters the `hidden` state.
  void handleHidden() {
    if (props.onDidHide != null) {
      props.onDidHide();
    }
  }


  /// Method that will be called when [AbstractTransitionComponent]  first enters the `shown` state.
  void handleShown() {
    if (props.onDidShow != null) {
      props.onDidShow();
    }
  }

  // --------------------------------------------------------------------------
  // Public API Methods
  // --------------------------------------------------------------------------

  /// Shows the [AbstractTransitionComponent] by adding the CSS class that invokes a CSS transition.
  void show() {
    _handleShow();
  }

  /// Hides the [AbstractTransitionComponent] by removing the CSS class that invokes a CSS transition.
  void hide() {
    _handleHide();
  }

  /// Toggles the visibility of the [AbstractTransitionComponent] based on the value of [AbstractTransitionState.transitionPhase].
  void toggle() {
    if (isOrWillBeShown) {
      /// If the [AbstractTransitionComponent] is shown or in the process of showing, hide it.
      hide();
    } else if (isOrWillBeHidden) {
      /// If the [AbstractTransitionComponent] is hidden or in the process of hiding, show it.
      show();
    }
  }
}

/// The transition phase of the [AbstractTransitionComponent].
enum TransitionPhase {
  /// > SHOWN: The [AbstractTransitionComponent] is done transitioning to a visible / "shown" state.
  SHOWN,
  /// > HIDDEN: The [AbstractTransitionComponent] is done transitioning to a hidden state.
  HIDDEN,
  /// > HIDING: The CSS class that triggers transitions has been removed from the [AbstractTransitionComponent], and an `onTransitionEnd` listener is active.
  HIDING,
  /// > PRE_SHOWING: The [AbstractTransitionComponent] is mounted in the DOM, ready to be shown, and an `onTransitionEnd` listener is set up.
  PRE_SHOWING,
  /// > SHOWING: The CSS class that triggers transitions is added to the [AbstractTransitionComponent], and an `onTransitionEnd` listener is active.
  SHOWING
}