// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/animation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';

import 'basic.dart';
import 'framework.dart';
import 'navigator.dart';
import 'overlay.dart';
import 'pages.dart';
import 'transitions.dart';

// Heroes are the parts of an application's screen-to-screen transitions where a
// component from one screen shifts to a position on the other. For example,
// album art from a list of albums growing to become the centerpiece of the
// album's details view. In this context, a screen is a navigator ModalRoute.

// To get this effect, all you have to do is wrap each hero on each route with a
// Hero widget, and give each hero a tag. Tag must either be unique within the
// current route's widget subtree, or all the Heroes with that tag on a
// particular route must have a key. When the app transitions from one route to
// another, each tag present is animated. When there's exactly one hero with
// that tag, that hero will be animated for that tag. When there are multiple
// heroes in a route with the same tag, then whichever hero has a key that
// matches one of the keys in the "most important key" list given to the
// navigator when the route was pushed will be animated. If a hero is only
// present on one of the routes and not the other, then it will be made to
// appear or disappear as needed.

// TODO(ianh): Make the appear/disappear animations pretty. Right now they're
// pretty crude (just rotate and shrink the constraints). They should probably
// involve actually scaling and fading, at a minimum.

// Heroes and the Navigator's Stack must be axis-aligned for all this to work.
// The top left and bottom right coordinates of each animated Hero will be
// converted to global coordinates and then from there converted to the
// Navigator Stack's coordinate space, and the entire Hero subtree will, for the
// duration of the animation, be lifted out of its original place, and
// positioned on that stack. If the Hero isn't axis aligned, this is going to
// fail in a rather ugly fashion. Don't rotate your heroes!

// To make the animations look good, it's critical that the widget tree for the
// hero in both locations be essentially identical. The widget of the target is
// used to do the transition: when going from route A to route B, route B's
// hero's widget is placed over route A's hero's widget, and route A's hero is
// hidden. Then the widget is animated to route B's hero's position, and then
// the widget is inserted into route B. When going back from B to A, route A's
// hero's widget is placed over where route B's hero's widget was, and then the
// animation goes the other way.

// TODO(ianh): If the widgets use Inherited properties, they are taken from the
// Navigator's position in the widget hierarchy, not the source or target. We
// should interpolate the inherited properties from their value at the source to
// their value at the target. See: https://github.com/flutter/flutter/issues/213

class _HeroManifest {
  const _HeroManifest({
    this.key,
    this.config,
    this.sourceStates,
    this.currentRect,
    this.currentTurns
  });
  final GlobalKey key;
  final Widget config;
  final Set<HeroState> sourceStates;
  final RelativeRect currentRect;
  final double currentTurns;
}

abstract class HeroHandle {
  bool get alwaysAnimate;
  _HeroManifest _takeChild(Rect animationArea);
}

class Hero extends StatefulComponent {
  Hero({
    Key key,
    this.tag,
    this.child,
    this.turns: 1,
    this.alwaysAnimate: false
  }) : super(key: key) {
    assert(tag != null);
  }

  final Object tag;
  final Widget child;
  final int turns;

  /// If true, the hero will always animate, even if it has no matching hero to
  /// animate to or from. (This only applies if the hero is relevant; if there
  /// are multiple heroes with the same tag, only the one whose key matches the
  /// "most valuable keys" will be used.)
  final bool alwaysAnimate;

  static Map<Object, HeroHandle> of(BuildContext context, Set<Key> mostValuableKeys) {
    mostValuableKeys ??= new Set<Key>();
    assert(!mostValuableKeys.contains(null));
    // first we collect ALL the heroes, sorted by their tags
    Map<Object, Map<Key, HeroState>> heroes = <Object, Map<Key, HeroState>>{};
    void visitor(Element element) {
      if (element.widget is Hero) {
        StatefulComponentElement<Hero, HeroState> hero = element;
        Object tag = hero.widget.tag;
        assert(tag != null);
        Key key = hero.widget.key;
        final Map<Key, HeroState> tagHeroes = heroes.putIfAbsent(tag, () => <Key, HeroState>{});
        assert(() {
          if (tagHeroes.containsKey(key)) {
            debugPrint('Tag: $tag   Key: $key');
            assert(() {
              'There are multiple heroes that share the same key within the same subtree.               '
              'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), '
              'either each Hero must have a unique tag, or, all the heroes with a particular tag must   '
              'have different keys. The relevant tag and key were dumped above.                         ';
              return false;
            });
          }
          return true;
        });
        tagHeroes[key] = hero.state;
      }
      element.visitChildren(visitor);
    }
    context.visitChildElements(visitor);
    // next, for each tag, we're going to decide on the one hero we care about for that tag
    Map<Object, HeroHandle> result = <Object, HeroHandle>{};
    for (Object tag in heroes.keys) {
      assert(tag != null);
      if (heroes[tag].length == 1) {
        result[tag] = heroes[tag].values.first;
      } else {
        assert(heroes[tag].length > 1);
        assert(!heroes[tag].containsKey(null));
        assert(heroes[tag].keys.where((Key key) => mostValuableKeys.contains(key)).length <= 1);
        Key mostValuableKey = mostValuableKeys.firstWhere((Key key) => heroes[tag].containsKey(key), orElse: () => null);
        if (mostValuableKey != null)
          result[tag] = heroes[tag][mostValuableKey];
      }
    }
    assert(!result.containsKey(null));
    return result;
  }

  HeroState createState() => new HeroState();
}

enum _HeroMode { constructing, initialized, measured, taken }

class HeroState extends State<Hero> implements HeroHandle {

  void initState() {
    assert(_mode == _HeroMode.constructing);
    super.initState();
    _key = new GlobalKey();
    _mode = _HeroMode.initialized;
  }

  GlobalKey _key;

  _HeroMode _mode = _HeroMode.constructing;
  Size _size;

  bool get alwaysAnimate => config.alwaysAnimate;

  _HeroManifest _takeChild(Rect animationArea) {
    assert(_mode == _HeroMode.measured || _mode == _HeroMode.taken);
    final RenderBox renderObject = context.findRenderObject();
    final Point heroTopLeft = renderObject.localToGlobal(Point.origin);
    final Point heroBottomRight = renderObject.localToGlobal(renderObject.size.bottomRight(Point.origin));
    final Rect heroArea = new Rect.fromLTRB(heroTopLeft.x, heroTopLeft.y, heroBottomRight.x, heroBottomRight.y);
    final RelativeRect startRect = new RelativeRect.fromRect(heroArea, animationArea);
    _HeroManifest result = new _HeroManifest(
      key: _key,
      config: config,
      sourceStates: new Set<HeroState>.from(<HeroState>[this]),
      currentRect: startRect,
      currentTurns: config.turns.toDouble()
    );
    setState(() {
      _key = null;
      _mode = _HeroMode.taken;
    });
    return result;
  }

  void _setChild(GlobalKey value) {
    assert(_mode == _HeroMode.taken);
    assert(_key == null);
    assert(_size != null);
    if (mounted)
      setState(() { _key = value; });
    _size = null;
    _mode = _HeroMode.initialized;
  }

  void _resetChild() {
    assert(_mode == _HeroMode.taken);
    assert(_key == null);
    assert(_size != null);
    if (mounted)
      setState(() { _key = new GlobalKey(); });
    _size = null;
    _mode = _HeroMode.initialized;
  }

  Widget build(BuildContext context) {
    switch (_mode) {
      case _HeroMode.constructing:
        assert(false);
        return null;
      case _HeroMode.initialized:
      case _HeroMode.measured:
        return new SizeObserver(
          onSizeChanged: (Size size) {
            assert(_mode == _HeroMode.initialized || _mode == _HeroMode.measured);
            _size = size;
            _mode = _HeroMode.measured;
          },
          child: new KeyedSubtree(
            key: _key,
            child: config.child
          )
        );
      case _HeroMode.taken:
        return new SizedBox(width: _size.width, height: _size.height);
    }
  }

}


class _HeroQuestState implements HeroHandle {
  _HeroQuestState({
    this.tag,
    this.key,
    this.child,
    this.sourceStates,
    this.targetRect,
    this.targetTurns,
    this.targetState,
    this.currentRect,
    this.currentTurns
  }) {
    assert(tag != null);
  }

  final Object tag;
  final GlobalKey key;
  final Widget child;
  final Set<HeroState> sourceStates;
  final RelativeRect targetRect;
  final int targetTurns;
  final HeroState targetState;
  final AnimatedRelativeRectValue currentRect;
  final AnimatedValue<double> currentTurns;

  bool get alwaysAnimate => true;

  bool get taken => _taken;
  bool _taken = false;
  _HeroManifest _takeChild(Rect animationArea) {
    assert(!taken);
    _taken = true;
    Set<HeroState> states = sourceStates;
    if (targetState != null)
      states = states.union(new Set<HeroState>.from(<HeroState>[targetState]));
    return new _HeroManifest(
      key: key,
      config: child,
      sourceStates: states,
      currentRect: currentRect.value,
      currentTurns: currentTurns.value
    );
  }

  Widget build(BuildContext context, PerformanceView performance) {
    return new PositionedTransition(
      rect: currentRect,
      performance: performance,
      child: new RotationTransition(
        turns: currentTurns,
        performance: performance,
        child: new KeyedSubtree(
          key: key,
          child: child
        )
      )
    );
  }
}

class _HeroMatch {
  const _HeroMatch(this.from, this.to, this.tag);
  final HeroHandle from;
  final HeroHandle to;
  final Object tag;
}

class HeroParty {
  HeroParty({ this.onQuestFinished });

  final VoidCallback onQuestFinished;

  List<_HeroQuestState> _heroes = <_HeroQuestState>[];
  bool get isEmpty => _heroes.isEmpty;

  Map<Object, HeroHandle> getHeroesToAnimate() {
    Map<Object, HeroHandle> result = new Map<Object, HeroHandle>();
    for (_HeroQuestState hero in _heroes)
      result[hero.tag] = hero;
    assert(!result.containsKey(null));
    return result;
  }

  AnimatedRelativeRectValue createAnimatedRelativeRect(RelativeRect begin, RelativeRect end, Curve curve) {
    return new AnimatedRelativeRectValue(begin, end: end, curve: curve);
  }

  AnimatedValue<double> createAnimatedTurns(double begin, double end, Curve curve) {
    assert(end.floor() == end);
    return new AnimatedValue<double>(begin, end: end, curve: curve);
  }

  void animate(Map<Object, HeroHandle> heroesFrom, Map<Object, HeroHandle> heroesTo, Rect animationArea, Curve curve) {
    assert(!heroesFrom.containsKey(null));
    assert(!heroesTo.containsKey(null));

    // make a list of pairs of heroes, based on the from and to lists
    Map<Object, _HeroMatch> heroes = <Object, _HeroMatch>{};
    for (Object tag in heroesFrom.keys)
      heroes[tag] = new _HeroMatch(heroesFrom[tag], heroesTo[tag], tag);
    for (Object tag in heroesTo.keys) {
      if (!heroes.containsKey(tag))
        heroes[tag] = new _HeroMatch(heroesFrom[tag], heroesTo[tag], tag);
    }

    // create a heroating hero out of each pair
    final List<_HeroQuestState> _newHeroes = <_HeroQuestState>[];
    for (_HeroMatch heroPair in heroes.values) {
      assert(heroPair.from != null || heroPair.to != null);
      if ((heroPair.from == null && !heroPair.to.alwaysAnimate) ||
          (heroPair.to == null && !heroPair.from.alwaysAnimate))
        continue;
      _HeroManifest from = heroPair.from?._takeChild(animationArea);
      assert(heroPair.to == null || heroPair.to is HeroState);
      _HeroManifest to = heroPair.to?._takeChild(animationArea);
      assert(from != null || to != null);
      assert(to == null || to.sourceStates.length == 1);
      assert(to == null || to.currentTurns.floor() == to.currentTurns);
      HeroState targetState = to != null ? to.sourceStates.elementAt(0) : null;
      Set<HeroState> sourceStates = from != null ? from.sourceStates : new Set<HeroState>();
      sourceStates.remove(targetState);
      RelativeRect sourceRect = from != null ? from.currentRect :
        new RelativeRect.fromRect(to.currentRect.toRect(animationArea).center & Size.zero, animationArea);
      RelativeRect targetRect = to != null ? to.currentRect :
        new RelativeRect.fromRect(from.currentRect.toRect(animationArea).center & Size.zero, animationArea);
      double sourceTurns = from != null ? from.currentTurns : 0.0;
      double targetTurns = to != null ? to.currentTurns : 0.0;
      _newHeroes.add(new _HeroQuestState(
        tag: heroPair.tag,
        key: from != null ? from.key : to.key,
        child: to != null ? to.config : from.config,
        sourceStates: sourceStates,
        targetRect: targetRect,
        targetTurns: targetTurns.floor(),
        targetState: targetState,
        currentRect: createAnimatedRelativeRect(sourceRect, targetRect, curve),
        currentTurns: createAnimatedTurns(sourceTurns, targetTurns, curve)
      ));
    }

    assert(!_heroes.any((_HeroQuestState hero) => !hero.taken));
    _heroes = _newHeroes;
  }

  PerformanceView _currentPerformance;

  void _clearCurrentPerformance() {
    _currentPerformance?.removeStatusListener(_handleUpdate);
    _currentPerformance = null;
  }

  void setPerformance(PerformanceView performance) {
    assert(performance != null || _heroes.length == 0);
    if (performance != _currentPerformance) {
      _clearCurrentPerformance();
      _currentPerformance = performance;
      _currentPerformance?.addStatusListener(_handleUpdate);
    }
  }

  void _handleUpdate(PerformanceStatus status) {
    if (status == PerformanceStatus.completed ||
        status == PerformanceStatus.dismissed) {
      for (_HeroQuestState hero in _heroes) {
        if (hero.targetState != null)
          hero.targetState._setChild(hero.key);
        for (HeroState source in hero.sourceStates)
          source._resetChild();
      }
      _heroes.clear();
      _clearCurrentPerformance();
      if (onQuestFinished != null)
        onQuestFinished();
    }
  }

  String toString() => '$_heroes';
}

class HeroController extends NavigatorObserver {
  HeroController() {
    _party = new HeroParty(onQuestFinished: _handleQuestFinished);
  }

  HeroParty _party;
  PerformanceView _performance;
  PageRoute _from;
  PageRoute _to;

  final List<OverlayEntry> _overlayEntries = new List<OverlayEntry>();

  void didPush(Route route, Route previousRoute) {
    assert(navigator != null);
    assert(route != null);
    if (route is PageRoute) {
      assert(route.performance != null);
      if (previousRoute is PageRoute) // could be null
        _from = previousRoute;
      _to = route;
      _performance = route.performance;
      _checkForHeroQuest();
    }
  }

  void didPop(Route route, Route previousRoute) {
    assert(navigator != null);
    assert(route != null);
    if (route is PageRoute) {
      assert(route.performance != null);
      if (previousRoute is PageRoute) {
        _to = previousRoute;
        _from = route;
        _performance = route.performance;
        _checkForHeroQuest();
      }
    }
  }

  void _checkForHeroQuest() {
    if (_from != null && _to != null && _from != _to) {
      _to.offstage = _to.performance.status != PerformanceStatus.completed;
      Scheduler.instance.addPostFrameCallback(_updateQuest);
    }
  }

  void _handleQuestFinished() {
    _removeHeroesFromOverlay();
    _from = null;
    _to = null;
    _performance = null;
  }

  Rect _getAnimationArea(BuildContext context) {
    RenderBox box = context.findRenderObject();
    Point topLeft = box.localToGlobal(Point.origin);
    Point bottomRight = box.localToGlobal(box.size.bottomRight(Point.origin));
    return new Rect.fromLTRB(topLeft.x, topLeft.y, bottomRight.x, bottomRight.y);
  }

  void _removeHeroesFromOverlay() {
    for (OverlayEntry entry in _overlayEntries)
      entry.remove();
    _overlayEntries.clear();
  }

  void _addHeroToOverlay(Widget hero, Object tag, OverlayState overlay) {
    OverlayEntry entry = new OverlayEntry(builder: (_) => hero);
    if (_performance.direction == AnimationDirection.forward)
      _to.insertHeroOverlayEntry(entry, tag, overlay);
    else
      _from.insertHeroOverlayEntry(entry, tag, overlay);
    _overlayEntries.add(entry);
  }

  Set<Key> _getMostValuableKeys() {
    assert(_from != null);
    assert(_to != null);
    Set<Key> result = new Set<Key>();
    if (_from.settings.mostValuableKeys != null)
      result.addAll(_from.settings.mostValuableKeys);
    if (_to.settings.mostValuableKeys != null)
      result.addAll(_to.settings.mostValuableKeys);
    return result;
  }

  void _updateQuest(Duration timeStamp) {
    Set<Key> mostValuableKeys = _getMostValuableKeys();
    Map<Object, HeroHandle> heroesFrom = _party.isEmpty ?
        Hero.of(_from.subtreeContext, mostValuableKeys) : _party.getHeroesToAnimate();

    Map<Object, HeroHandle> heroesTo = Hero.of(_to.subtreeContext, mostValuableKeys);
    _to.offstage = false;

    PerformanceView performance = _performance;
    Curve curve = Curves.ease;
    if (performance.status == PerformanceStatus.reverse) {
      performance = new ReversePerformance(performance);
      curve = new Interval(performance.progress, 1.0, curve: curve);
    }

    _party.animate(heroesFrom, heroesTo, _getAnimationArea(navigator.context), curve);
    _removeHeroesFromOverlay();
    _party.setPerformance(performance);
    for (_HeroQuestState hero in _party._heroes) {
      Widget widget = hero.build(navigator.context, performance);
      _addHeroToOverlay(widget, hero.tag, navigator.overlay);
    }
  }
}