// 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 'dart:math' as math;
import 'dart:sky' as sky;

import 'package:newton/newton.dart';
import 'package:sky/animation/animated_simulation.dart';
import 'package:sky/animation/animated_value.dart';
import 'package:sky/animation/animation_performance.dart';
import 'package:sky/animation/curves.dart';
import 'package:sky/animation/scroll_behavior.dart';
import 'package:sky/theme/view_configuration.dart' as config;
import 'package:sky/widgets/basic.dart';
import 'package:sky/widgets/block_viewport.dart';
import 'package:sky/widgets/scrollable.dart';
import 'package:sky/widgets/framework.dart';

export 'package:sky/widgets/block_viewport.dart' show BlockViewportLayoutState;

const double _kMillisecondsPerSecond = 1000.0;

double _velocityForFlingGesture(double eventVelocity) {
  // eventVelocity is pixels/second, config min,max limits are pixels/ms
  return eventVelocity.clamp(-config.kMaxFlingVelocity, config.kMaxFlingVelocity) /
    _kMillisecondsPerSecond;
}

abstract class ScrollClient {
  bool ancestorScrolled(Scrollable ancestor);
}

/// A base class for scrollable widgets that reacts to user input and generates
/// a scrollOffset.
abstract class Scrollable extends StatefulComponent {

  Scrollable({
    Key key,
    this.scrollDirection: ScrollDirection.vertical
  }) : super(key: key) {
    assert(scrollDirection == ScrollDirection.vertical ||
        scrollDirection == ScrollDirection.horizontal);
  }

  ScrollDirection scrollDirection;

  AnimatedSimulation _toEndAnimation; // See _startToEndAnimation()
  AnimationPerformance _toOffsetAnimation; // Started by scrollTo(offset, duration: d)

  void initState() {
    _toEndAnimation = new AnimatedSimulation(_tickScrollOffset);
    _toOffsetAnimation = new AnimationPerformance()
      ..addListener(() {
        AnimatedValue<double> offset = _toOffsetAnimation.variable;
        scrollTo(offset.value);
      });
  }

  void syncFields(Scrollable source) {
    scrollDirection == source.scrollDirection;
  }

  double _scrollOffset = 0.0;
  double get scrollOffset => _scrollOffset;

  Offset get scrollOffsetVector {
    if (scrollDirection == ScrollDirection.horizontal)
      return new Offset(scrollOffset, 0.0);
    return new Offset(0.0, scrollOffset);
  }

  ScrollBehavior _scrollBehavior;
  ScrollBehavior createScrollBehavior();
  ScrollBehavior get scrollBehavior {
    if (_scrollBehavior == null)
      _scrollBehavior = createScrollBehavior();
    return _scrollBehavior;
  }

  Widget buildContent();

  Widget build() {
    return new Listener(
      child: buildContent(),
      onPointerDown: _handlePointerDown,
      onPointerUp: _handlePointerUpOrCancel,
      onPointerCancel: _handlePointerUpOrCancel,
      onGestureFlingStart: _handleFlingStart,
      onGestureFlingCancel: _handleFlingCancel,
      onGestureScrollUpdate: _handleScrollUpdate,
      onWheel: _handleWheel
    );
  }

  List<ScrollClient> _registeredScrollClients;

  void registerScrollClient(ScrollClient notifiee) {
    if (_registeredScrollClients == null)
      _registeredScrollClients = new List<ScrollClient>();
    setState(() {
      _registeredScrollClients.add(notifiee);
    });
  }

  void unregisterScrollClient(ScrollClient notifiee) {
    if (_registeredScrollClients == null)
      return;
    setState(() {
      _registeredScrollClients.remove(notifiee);
    });
  }

  void _startToOffsetAnimation(double newScrollOffset, Duration duration) {
      _stopToEndAnimation();
      _stopToOffsetAnimation();
      _toOffsetAnimation
        ..variable = new AnimatedValue<double>(scrollOffset,
          end: newScrollOffset,
          curve: ease
        )
        ..progress = 0.0
        ..duration = duration
        ..play();
  }

  void _stopToOffsetAnimation() {
    if (_toOffsetAnimation.isAnimating)
      _toOffsetAnimation.stop();
  }

  void _startToEndAnimation({ double velocity: 0.0 }) {
    _stopToEndAnimation();
    _stopToOffsetAnimation();
    Simulation simulation = scrollBehavior.release(scrollOffset, velocity);
    if (simulation != null)
      _toEndAnimation.start(simulation);
  }

  void _stopToEndAnimation() {
    _toEndAnimation.stop();
  }

  void didUnmount() {
    _stopToEndAnimation();
    _stopToOffsetAnimation();
    super.didUnmount();
  }

  bool scrollTo(double newScrollOffset, { Duration duration }) {
    if (newScrollOffset == _scrollOffset)
      return false;

    if (duration == null) {
      setState(() {
        _scrollOffset = newScrollOffset;
      });
    } else {
      _startToOffsetAnimation(newScrollOffset, duration);
    }

    if (_registeredScrollClients != null) {
      var newList = null;
      _registeredScrollClients.forEach((target) {
        if (target.ancestorScrolled(this)) {
          if (newList == null)
            newList = new List<ScrollClient>();
          newList.add(target);
        }
      });
      setState(() {
        _registeredScrollClients = newList;
      });
    }
    return true;
  }

  bool scrollBy(double scrollDelta) {
    double newScrollOffset = scrollBehavior.applyCurve(_scrollOffset, scrollDelta);
    return scrollTo(newScrollOffset);
  }

  void settleScrollOffset() {
    _startToEndAnimation();
  }

  void _tickScrollOffset(double value) {
    scrollTo(value);
  }

  EventDisposition _handlePointerDown(_) {
    _stopToEndAnimation();
    _stopToOffsetAnimation();
    return EventDisposition.processed;
  }

  EventDisposition _handleScrollUpdate(sky.GestureEvent event) {
    scrollBy(scrollDirection == ScrollDirection.horizontal ? event.dx : -event.dy);
    return EventDisposition.processed;
  }

  EventDisposition _handleFlingStart(sky.GestureEvent event) {
    double eventVelocity = scrollDirection == ScrollDirection.horizontal
      ? -event.velocityX
      : -event.velocityY;
    _startToEndAnimation(velocity: _velocityForFlingGesture(eventVelocity));
    return EventDisposition.processed;
  }

  void _maybeSettleScrollOffset() {
    if (!_toEndAnimation.isAnimating && !_toOffsetAnimation.isAnimating)
      settleScrollOffset();
  }

  EventDisposition _handlePointerUpOrCancel(_) {
    _maybeSettleScrollOffset();
    return EventDisposition.processed;
  }

  EventDisposition _handleFlingCancel(sky.GestureEvent event) {
    _maybeSettleScrollOffset();
    return EventDisposition.processed;
  }


  EventDisposition _handleWheel(sky.WheelEvent event) {
    scrollBy(-event.offsetY);
    return EventDisposition.processed;
  }
}

/// A simple scrollable widget that has a single child. Use this component if
/// you are not worried about offscreen widgets consuming resources.
class ScrollableViewport extends Scrollable {
  ScrollableViewport({
    Key key,
    this.child,
    ScrollDirection scrollDirection: ScrollDirection.vertical
  }) : super(key: key, scrollDirection: scrollDirection);

  Widget child;

  void syncFields(ScrollableViewport source) {
    child = source.child;
    super.syncFields(source);
  }

  ScrollBehavior createScrollBehavior() => new OverscrollWhenScrollableBehavior();
  OverscrollWhenScrollableBehavior get scrollBehavior => super.scrollBehavior;

  double _viewportSize = 0.0;
  double _childSize = 0.0;
  void _handleViewportSizeChanged(Size newSize) {
    _viewportSize = scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width;
    _updateScrollBehaviour();
  }
  void _handleChildSizeChanged(Size newSize) {
    _childSize = scrollDirection == ScrollDirection.vertical ? newSize.height : newSize.width;
    _updateScrollBehaviour();
  }
  void _updateScrollBehaviour() {
    scrollBehavior.contentsSize = _childSize;
    scrollBehavior.containerSize = _viewportSize;
    if (scrollOffset > scrollBehavior.maxScrollOffset)
      settleScrollOffset();
  }

  Widget buildContent() {
    return new SizeObserver(
      callback: _handleViewportSizeChanged,
      child: new Viewport(
        scrollOffset: scrollOffsetVector,
        scrollDirection: scrollDirection,
        child: new SizeObserver(
          callback: _handleChildSizeChanged,
          child: child
        )
      )
    );
  }
}

/// A mashup of [ScrollableViewport] and [Block]. Useful when you have a small,
/// fixed number of children that you wish to arrange in a block layout and that
/// might exceed the height of its container (and therefore need to scroll).
class ScrollableBlock extends Component {
  ScrollableBlock(this.children, {
    Key key,
    this.scrollDirection: ScrollDirection.vertical
  }) : super(key: key);

  final List<Widget> children;
  final ScrollDirection scrollDirection;

  BlockDirection get _direction {
    if (scrollDirection == ScrollDirection.vertical)
      return BlockDirection.vertical;
    return BlockDirection.horizontal;
  }

  Widget build() {
    return new ScrollableViewport(
      scrollDirection: scrollDirection,
      child: new Block(children, direction: _direction)
    );
  }
}

/// An optimized scrollable widget for a large number of children that are all
/// of the same height. Use this widget when you have a large number of children
/// or when you are concerned about offscreen widgets consuming resources.
abstract class FixedHeightScrollable extends Scrollable {

  FixedHeightScrollable({ Key key, this.itemHeight, this.padding })
      : super(key: key) {
    assert(itemHeight != null);
  }

  EdgeDims padding;
  double itemHeight;

  /// Subclasses must implement `get itemCount` to tell FixedHeightScrollable
  /// how many items there are in the list.
  int get itemCount;
  int _previousItemCount;

  void syncFields(FixedHeightScrollable source) {
    padding = source.padding;
    itemHeight = source.itemHeight;
    super.syncFields(source);
  }

  ScrollBehavior createScrollBehavior() => new OverscrollBehavior();
  OverscrollBehavior get scrollBehavior => super.scrollBehavior;

  double _height;
  void _handleSizeChanged(Size newSize) {
    setState(() {
      _height = newSize.height;
      scrollBehavior.containerSize = _height;
    });
  }

  void _updateContentsHeight() {
    double contentsHeight = itemHeight * itemCount;
    if (padding != null)
      contentsHeight += padding.top + padding.bottom;
    scrollBehavior.contentsSize = contentsHeight;
  }

  void _updateScrollOffset() {
    if (scrollOffset > scrollBehavior.maxScrollOffset)
      settleScrollOffset();
  }

  Widget buildContent() {
    if (itemCount != _previousItemCount) {
      _previousItemCount = itemCount;
      _updateContentsHeight();
      _updateScrollOffset();
    }

    int itemShowIndex = 0;
    int itemShowCount = 0;
    double offsetY = 0.0;
    if (_height != null && _height > 0.0) {
      if (scrollOffset < 0.0) {
        double visibleHeight = _height + scrollOffset;
        itemShowCount = (visibleHeight / itemHeight).round() + 1;
        offsetY = scrollOffset;
      } else {
        itemShowCount = (_height / itemHeight).ceil();
        double alignmentDelta = -scrollOffset % itemHeight;
        double drawStart;
        if (alignmentDelta != 0.0) {
          alignmentDelta -= itemHeight;
          itemShowCount += 1;
          drawStart = scrollOffset + alignmentDelta;
          offsetY = -alignmentDelta;
        } else {
          drawStart = scrollOffset;
        }
        itemShowIndex = math.max(0, (drawStart / itemHeight).floor());
      }
    }

    List<Widget> items = buildItems(itemShowIndex, itemShowCount);
    assert(items.every((item) => item.key != null));

    // TODO(ianh): Refactor this so that it does the building in the
    // same frame as the size observing, similar to BlockViewport, but
    // keeping the fixed-height optimisations.
    return new SizeObserver(
      callback: _handleSizeChanged,
      child: new Viewport(
        scrollOffset: new Offset(0.0, offsetY),
        child: new Container(
          padding: padding,
          child: new Block(items)
        )
      )
    );
  }

  List<Widget> buildItems(int start, int count);

}

typedef Widget ItemBuilder<T>(T item);

/// A wrapper around [FixedHeightScrollable] that helps you translate a list of
/// model objects into a scrollable list of widgets. Assumes all the widgets
/// have the same height.
class ScrollableList<T> extends FixedHeightScrollable {
  ScrollableList({
    Key key,
    this.items,
    this.itemBuilder,
    double itemHeight,
    EdgeDims padding
  }) : super(key: key, itemHeight: itemHeight, padding: padding);

  List<T> items;
  ItemBuilder<T> itemBuilder;

  void syncFields(ScrollableList<T> source) {
    items = source.items;
    itemBuilder = source.itemBuilder;
    super.syncFields(source);
  }

  int get itemCount => items.length;

  List<Widget> buildItems(int start, int count) {
    List<Widget> result = new List<Widget>();
    int end = math.min(start + count, items.length);
    for (int i = start; i < end; ++i)
      result.add(itemBuilder(items[i]));
    return result;
  }
}

/// A general scrollable list for a large number of children that might not all
/// have the same height. Prefer [FixedHeightScrollable] when all the children
/// have the same height because it can use that property to be more efficient.
/// Prefer [ScrollableViewport] with a single child.
class VariableHeightScrollable extends Scrollable {
  VariableHeightScrollable({
    Key key,
    this.builder,
    this.token,
    this.layoutState
  }) : super(key: key);

  IndexedBuilder builder;
  Object token;
  BlockViewportLayoutState layoutState;

  // When the token changes the scrollable's contents may have
  // changed. Remember as much so that after the new contents
  // have been laid out we can adjust the scrollOffset so that
  // the last page of content is still visible.
  bool _contentsChanged = true;

  void initState() {
    assert(layoutState != null);
    super.initState();
  }

  void didMount() {
    layoutState.addListener(_handleLayoutChanged);
    super.didMount();
  }

  void didUnmount() {
    layoutState.removeListener(_handleLayoutChanged);
    super.didUnmount();
  }

  void syncFields(VariableHeightScrollable source) {
    builder = source.builder;
    if (token != source.token)
      _contentsChanged = true;
    token = source.token;
    if (layoutState != source.layoutState) {
      // Warning: this is unlikely to be what you intended.
      assert(source.layoutState != null);
      layoutState.removeListener(_handleLayoutChanged);
      layoutState = source.layoutState;
      layoutState.addListener(_handleLayoutChanged);
    }
    super.syncFields(source);
  }

  ScrollBehavior createScrollBehavior() => new OverscrollBehavior();
  OverscrollBehavior get scrollBehavior => super.scrollBehavior;

  void _handleSizeChanged(Size newSize) {
    scrollBehavior.containerSize = newSize.height;
  }

  void _handleLayoutChanged() {
    if (layoutState.didReachLastChild) {
      scrollBehavior.contentsSize = layoutState.contentsSize;
      if (_contentsChanged && scrollOffset > scrollBehavior.maxScrollOffset) {
        _contentsChanged = false;
        settleScrollOffset();
      }
    } else {
      scrollBehavior.contentsSize = double.INFINITY;
    }
  }

  Widget buildContent() {
    return new SizeObserver(
      callback: _handleSizeChanged,
      child: new BlockViewport(
        builder: builder,
        layoutState: layoutState,
        startOffset: scrollOffset,
        token: token
      )
    );
  }
}