// Copyright 2014 The Flutter 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/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/gestures.dart';

import 'basic.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'shortcuts.dart';

/// Creates actions for use in defining shortcuts.
///
/// Used by clients of [ShortcutMap] to define shortcut maps.
typedef ActionFactory = Action Function();

/// A class representing a particular configuration of an action.
///
/// This class is what a key map in a [ShortcutMap] has as values, and is used
/// by an [ActionDispatcher] to look up an action and invoke it, giving it this
/// object to extract configuration information from.
///
/// If this intent returns false from [isEnabled], then its associated action will
/// not be invoked if requested.
class Intent with Diagnosticable {
  /// A const constructor for an [Intent].
  ///
  /// The [key] argument must not be null.
  const Intent(this.key) : assert(key != null);

  /// An intent that can't be mapped to an action.
  ///
  /// This Intent is mapped to an action in the [WidgetsApp] that does nothing,
  /// so that it can be bound to a key in a [Shortcuts] widget in order to
  /// disable a key binding made above it in the hierarchy.
  static const Intent doNothing = Intent(DoNothingAction.key);

  /// The key for the action this intent is associated with.
  final LocalKey key;

  /// Returns true if the associated action is able to be executed in the
  /// given `context`.
  ///
  /// Returns true by default.
  bool isEnabled(BuildContext context) => true;

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<LocalKey>('key', key));
  }
}

/// Base class for actions.
///
/// As the name implies, an [Action] is an action or command to be performed.
/// They are typically invoked as a result of a user action, such as a keyboard
/// shortcut in a [Shortcuts] widget, which is used to look up an [Intent],
/// which is given to an [ActionDispatcher] to map the [Intent] to an [Action]
/// and invoke it.
///
/// The [ActionDispatcher] can invoke an [Action] on the primary focus, or
/// without regard for focus.
///
/// See also:
///
///  * [Shortcuts], which is a widget that contains a key map, in which it looks
///    up key combinations in order to invoke actions.
///  * [Actions], which is a widget that defines a map of [Intent] to [Action]
///    and allows redefining of actions for its descendants.
///  * [ActionDispatcher], a class that takes an [Action] and invokes it using a
///    [FocusNode] for context.
abstract class Action with Diagnosticable {
  /// A const constructor for an [Action].
  ///
  /// The [intentKey] parameter must not be null.
  const Action(this.intentKey) : assert(intentKey != null);

  /// The unique key for this action.
  ///
  /// This key will be used to map to this action in an [ActionDispatcher].
  final LocalKey intentKey;

  /// Called when the action is to be performed.
  ///
  /// This is called by the [ActionDispatcher] when an action is accepted by a
  /// [FocusNode] by returning true from its `onAction` callback, or when an
  /// action is invoked using [ActionDispatcher.invokeAction].
  ///
  /// This method is only meant to be invoked by an [ActionDispatcher], or by
  /// subclasses.
  ///
  /// Actions invoked directly with [ActionDispatcher.invokeAction] may receive a
  /// null `node`. If the information available from a focus node is
  /// needed in the action, use [ActionDispatcher.invokeFocusedAction] instead.
  @protected
  void invoke(FocusNode node, covariant Intent intent);

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<LocalKey>('intentKey', intentKey));
  }
}

/// The signature of a callback accepted by [CallbackAction].
typedef OnInvokeCallback = void Function(FocusNode node, Intent tag);

/// An [Action] that takes a callback in order to configure it without having to
/// subclass it.
///
/// See also:
///
///  * [Shortcuts], which is a widget that contains a key map, in which it looks
///    up key combinations in order to invoke actions.
///  * [Actions], which is a widget that defines a map of [Intent] to [Action]
///    and allows redefining of actions for its descendants.
///  * [ActionDispatcher], a class that takes an [Action] and invokes it using a
///    [FocusNode] for context.
class CallbackAction extends Action {
  /// A const constructor for an [Action].
  ///
  /// The `intentKey` and [onInvoke] parameters must not be null.
  /// The [onInvoke] parameter is required.
  const CallbackAction(LocalKey intentKey, {@required this.onInvoke})
      : assert(onInvoke != null),
        super(intentKey);

  /// The callback to be called when invoked.
  ///
  /// Must not be null.
  @protected
  final OnInvokeCallback onInvoke;

  @override
  void invoke(FocusNode node, Intent intent) => onInvoke.call(node, intent);
}

/// An action manager that simply invokes the actions given to it.
class ActionDispatcher with Diagnosticable {
  /// Const constructor so that subclasses can be const.
  const ActionDispatcher();

  /// Invokes the given action, optionally without regard for the currently
  /// focused node in the focus tree.
  ///
  /// Actions invoked will receive the given `focusNode`, or the
  /// [FocusManager.primaryFocus] if the given `focusNode` is null.
  ///
  /// The `action` and `intent` arguments must not be null.
  ///
  /// Returns true if the action was successfully invoked.
  bool invokeAction(Action action, Intent intent, {FocusNode focusNode}) {
    assert(action != null);
    assert(intent != null);
    focusNode ??= primaryFocus;
    if (action != null && intent.isEnabled(focusNode.context)) {
      action.invoke(focusNode, intent);
      return true;
    }
    return false;
  }
}

/// A widget that establishes an [ActionDispatcher] and a map of [Intent] to
/// [Action] to be used by its descendants when invoking an [Action].
///
/// Actions are typically invoked using [Actions.invoke] with the context
/// containing the ambient [Actions] widget.
///
/// See also:
///
///  * [ActionDispatcher], the object that this widget uses to manage actions.
///  * [Action], a class for containing and defining an invocation of a user
///    action.
///  * [Intent], a class that holds a unique [LocalKey] identifying an action,
///    as well as configuration information for running the [Action].
///  * [Shortcuts], a widget used to bind key combinations to [Intent]s.
class Actions extends InheritedWidget {
  /// Creates an [Actions] widget.
  ///
  /// The [child], [actions], and [dispatcher] arguments must not be null.
  const Actions({
    Key key,
    this.dispatcher,
    @required this.actions,
    @required Widget child,
  })  : assert(actions != null),
        super(key: key, child: child);

  /// The [ActionDispatcher] object that invokes actions.
  ///
  /// This is what is returned from [Actions.of], and used by [Actions.invoke].
  ///
  /// If this [dispatcher] is null, then [Actions.of] and [Actions.invoke] will
  /// look up the tree until they find an Actions widget that has a dispatcher
  /// set. If not such widget is found, then they will return/use a
  /// default-constructed [ActionDispatcher].
  final ActionDispatcher dispatcher;

  /// {@template flutter.widgets.actions.actions}
  /// A map of [Intent] keys to [ActionFactory] factory methods that defines
  /// which actions this widget knows about.
  ///
  /// For performance reasons, it is recommended that a pre-built map is
  /// passed in here (e.g. a final variable from your widget class) instead of
  /// defining it inline in the build function.
  /// {@endtemplate}
  final Map<LocalKey, ActionFactory> actions;

  // Finds the nearest valid ActionDispatcher, or creates a new one if it
  // doesn't find one.
  static ActionDispatcher _findDispatcher(Element element) {
    assert(element.widget is Actions);
    final Actions actions = element.widget as Actions;
    ActionDispatcher dispatcher = actions.dispatcher;
    if (dispatcher == null) {
      bool visitAncestorElement(Element visitedElement) {
        if (visitedElement.widget is! Actions) {
          // Continue visiting.
          return true;
        }
        final Actions actions = visitedElement.widget as Actions;
        if (actions.dispatcher == null) {
          // Continue visiting.
          return true;
        }
        dispatcher = actions.dispatcher;
        // Stop visiting.
        return false;
      }

      element.visitAncestorElements(visitAncestorElement);
    }
    return dispatcher ?? const ActionDispatcher();
  }

  /// Returns the [ActionDispatcher] associated with the [Actions] widget that
  /// most tightly encloses the given [BuildContext].
  ///
  /// Will throw if no ambient [Actions] widget is found.
  ///
  /// If `nullOk` is set to true, then if no ambient [Actions] widget is found,
  /// this will return null.
  ///
  /// The `context` argument must not be null.
  static ActionDispatcher of(BuildContext context, {bool nullOk = false}) {
    assert(context != null);
    final InheritedElement inheritedElement = context.getElementForInheritedWidgetOfExactType<Actions>();
    final Actions inherited = context.dependOnInheritedElement(inheritedElement) as Actions;
    assert(() {
      if (nullOk) {
        return true;
      }
      if (inherited == null) {
        throw FlutterError('Unable to find an $Actions widget in the context.\n'
            '$Actions.of() was called with a context that does not contain an '
            '$Actions widget.\n'
            'No $Actions ancestor could be found starting from the context that '
            'was passed to $Actions.of(). This can happen if the context comes '
            'from a widget above those widgets.\n'
            'The context used was:\n'
            '  $context');
      }
      return true;
    }());
    return inherited?.dispatcher ?? _findDispatcher(inheritedElement);
  }

  /// Invokes the action associated with the given [Intent] using the
  /// [Actions] widget that most tightly encloses the given [BuildContext].
  ///
  /// The `context`, `intent` and `nullOk` arguments must not be null.
  ///
  /// If the given `intent` isn't found in the first [Actions.actions] map, then
  /// it will move up to the next [Actions] widget in the hierarchy until it
  /// reaches the root.
  ///
  /// Will throw if no ambient [Actions] widget is found, or if the given
  /// `intent` doesn't map to an action in any of the [Actions.actions] maps
  /// that are found.
  ///
  /// Returns true if an action was successfully invoked.
  ///
  /// Setting `nullOk` to true means that if no ambient [Actions] widget is
  /// found, then this method will return false instead of throwing.
  static bool invoke(
    BuildContext context,
    Intent intent, {
    FocusNode focusNode,
    bool nullOk = false,
  }) {
    assert(context != null);
    assert(intent != null);
    Element actionsElement;
    Action action;

    bool visitAncestorElement(Element element) {
      if (element.widget is! Actions) {
        // Continue visiting.
        return true;
      }
      // Below when we invoke the action, we need to use the dispatcher from the
      // Actions widget where we found the action, in case they need to match.
      actionsElement = element;
      final Actions actions = element.widget as Actions;
      action = actions.actions[intent.key]?.call();
      // Keep looking if we failed to find and create an action.
      return action == null;
    }

    context.visitAncestorElements(visitAncestorElement);
    assert(() {
      if (nullOk) {
        return true;
      }
      if (actionsElement == null) {
        throw FlutterError('Unable to find a $Actions widget in the context.\n'
            '$Actions.invoke() was called with a context that does not contain an '
            '$Actions widget.\n'
            'No $Actions ancestor could be found starting from the context that '
            'was passed to $Actions.invoke(). This can happen if the context comes '
            'from a widget above those widgets.\n'
            'The context used was:\n'
            '  $context');
      }
      if (action == null) {
        throw FlutterError('Unable to find an action for an intent in the $Actions widget in the context.\n'
            "$Actions.invoke() was called on an $Actions widget that doesn't "
            'contain a mapping for the given intent.\n'
            'The context used was:\n'
            '  $context\n'
            'The intent requested was:\n'
            '  $intent');
      }
      return true;
    }());
    if (action == null) {
      // Will only get here if nullOk is true.
      return false;
    }

    // Invoke the action we found using the dispatcher from the Actions Element
    // we found, using the given focus node.
    return _findDispatcher(actionsElement).invokeAction(action, intent, focusNode: focusNode);
  }

  @override
  bool updateShouldNotify(Actions oldWidget) {
    return oldWidget.dispatcher != dispatcher || !mapEquals<LocalKey, ActionFactory>(oldWidget.actions, actions);
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<ActionDispatcher>('dispatcher', dispatcher));
    properties.add(DiagnosticsProperty<Map<LocalKey, ActionFactory>>('actions', actions));
  }
}

/// A widget that combines the functionality of [Actions], [Shortcuts],
/// [MouseRegion] and a [Focus] widget to create a detector that defines actions
/// and key bindings, and provides callbacks for handling focus and hover
/// highlights.
///
/// This widget can be used to give a control the required detection modes for
/// focus and hover handling. It is most often used when authoring a new control
/// widget, and the new control should be enabled for keyboard traversal and
/// activation.
///
/// {@tool dartpad --template=stateful_widget_material}
/// This example shows how keyboard interaction can be added to a custom control
/// that changes color when hovered and focused, and can toggle a light when
/// activated, either by touch or by hitting the `X` key on the keyboard when
/// the "And Me" button has the keyboard focus (be sure to use TAB to move the
/// focus to the "And Me" button before trying it out).
///
/// This example defines its own key binding for the `X` key, but in this case,
/// there is also a default key binding for [ActivateAction] in the default key
/// bindings created by [WidgetsApp] (the parent for [MaterialApp], and
/// [CupertinoApp]), so the `ENTER` key will also activate the buttons.
///
/// ```dart imports
/// import 'package:flutter/services.dart';
/// ```
///
/// ```dart preamble
/// class FadButton extends StatefulWidget {
///   const FadButton({Key key, this.onPressed, this.child}) : super(key: key);
///
///   final VoidCallback onPressed;
///   final Widget child;
///
///   @override
///   _FadButtonState createState() => _FadButtonState();
/// }
///
/// class _FadButtonState extends State<FadButton> {
///   bool _focused = false;
///   bool _hovering = false;
///   bool _on = false;
///   Map<LocalKey, ActionFactory> _actionMap;
///   Map<LogicalKeySet, Intent> _shortcutMap;
///
///   @override
///   void initState() {
///     super.initState();
///     _actionMap = <LocalKey, ActionFactory>{
///       ActivateAction.key: () {
///         return CallbackAction(
///           ActivateAction.key,
///           onInvoke: (FocusNode node, Intent intent) => _toggleState(),
///         );
///       },
///     };
///     _shortcutMap = <LogicalKeySet, Intent>{
///       LogicalKeySet(LogicalKeyboardKey.keyX): Intent(ActivateAction.key),
///     };
///   }
///
///   Color get color {
///     Color baseColor = Colors.lightBlue;
///     if (_focused) {
///       baseColor = Color.alphaBlend(Colors.black.withOpacity(0.25), baseColor);
///     }
///     if (_hovering) {
///       baseColor = Color.alphaBlend(Colors.black.withOpacity(0.1), baseColor);
///     }
///     return baseColor;
///   }
///
///   void _toggleState() {
///     setState(() {
///       _on = !_on;
///     });
///   }
///
///   void _handleFocusHighlight(bool value) {
///     setState(() {
///       _focused = value;
///     });
///   }
///
///   void _handleHoveHighlight(bool value) {
///     setState(() {
///       _hovering = value;
///     });
///   }
///
///   @override
///   Widget build(BuildContext context) {
///     return GestureDetector(
///       onTap: _toggleState,
///       child: FocusableActionDetector(
///         actions: _actionMap,
///         shortcuts: _shortcutMap,
///         onShowFocusHighlight: _handleFocusHighlight,
///         onShowHoverHighlight: _handleHoveHighlight,
///         child: Row(
///           children: <Widget>[
///             Container(
///               padding: EdgeInsets.all(10.0),
///               color: color,
///               child: widget.child,
///             ),
///             Container(
///               width: 30,
///               height: 30,
///               margin: EdgeInsets.all(10.0),
///               color: _on ? Colors.red : Colors.transparent,
///             ),
///           ],
///         ),
///       ),
///     );
///   }
/// }
/// ```
///
/// ```dart
/// Widget build(BuildContext context) {
///   return Scaffold(
///     appBar: AppBar(
///       title: Text('FocusableActionDetector Example'),
///     ),
///     body: Center(
///       child: Row(
///         mainAxisAlignment: MainAxisAlignment.center,
///         children: <Widget>[
///           Padding(
///             padding: const EdgeInsets.all(8.0),
///             child: FlatButton(onPressed: () {}, child: Text('Press Me')),
///           ),
///           Padding(
///             padding: const EdgeInsets.all(8.0),
///             child: FadButton(onPressed: () {}, child: Text('And Me')),
///           ),
///         ],
///       ),
///     ),
///   );
/// }
/// ```
/// {@end-tool}
///
/// This widget doesn't have any visual representation, it is just a detector that
/// provides focus and hover capabilities.
///
/// It hosts its own [FocusNode] or uses [focusNode], if given.
class FocusableActionDetector extends StatefulWidget {
  /// Create a const [FocusableActionDetector].
  ///
  /// The [enabled], [autofocus], and [child] arguments must not be null.
  const FocusableActionDetector({
    Key key,
    this.enabled = true,
    this.focusNode,
    this.autofocus = false,
    this.shortcuts,
    this.actions,
    this.onShowFocusHighlight,
    this.onShowHoverHighlight,
    this.onFocusChange,
    @required this.child,
  })  : assert(enabled != null),
        assert(autofocus != null),
        assert(child != null),
        super(key: key);

  /// Is this widget enabled or not.
  ///
  /// If disabled, will not send any notifications needed to update highlight or
  /// focus state, and will not define or respond to any actions or shortcuts.
  ///
  /// When disabled, adds [Focus] to the widget tree, but sets
  /// [Focus.canRequestFocus] to false.
  final bool enabled;

  /// {@macro flutter.widgets.Focus.focusNode}
  final FocusNode focusNode;

  /// {@macro flutter.widgets.Focus.autofocus}
  final bool autofocus;

  /// {@macro flutter.widgets.actions.actions}
  final Map<LocalKey, ActionFactory> actions;

  /// {@macro flutter.widgets.shortcuts.shortcuts}
  final Map<LogicalKeySet, Intent> shortcuts;

  /// A function that will be called when the focus highlight should be shown or
  /// hidden.
  ///
  /// This method is not triggered at the unmount of the widget.
  final ValueChanged<bool> onShowFocusHighlight;

  /// A function that will be called when the hover highlight should be shown or hidden.
  ///
  /// This method is not triggered at the unmount of the widget.
  final ValueChanged<bool> onShowHoverHighlight;

  /// A function that will be called when the focus changes.
  ///
  /// Called with true if the [focusNode] has primary focus.
  final ValueChanged<bool> onFocusChange;

  /// The child widget for this [FocusableActionDetector] widget.
  ///
  /// {@macro flutter.widgets.child}
  final Widget child;

  @override
  _FocusableActionDetectorState createState() => _FocusableActionDetectorState();
}

class _FocusableActionDetectorState extends State<FocusableActionDetector> {
  @override
  void initState() {
    super.initState();
    SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
      _updateHighlightMode(FocusManager.instance.highlightMode);
    });
    FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
  }

  @override
  void dispose() {
    FocusManager.instance.removeHighlightModeListener(_handleFocusHighlightModeChange);
    super.dispose();
  }

  bool _canShowHighlight = false;
  void _updateHighlightMode(FocusHighlightMode mode) {
    _mayTriggerCallback(task: () {
      switch (FocusManager.instance.highlightMode) {
        case FocusHighlightMode.touch:
          _canShowHighlight = false;
          break;
        case FocusHighlightMode.traditional:
          _canShowHighlight = true;
          break;
      }
    });
  }

  // Have to have this separate from the _updateHighlightMode because it gets
  // called in initState, where things aren't mounted yet.
  // Since this method is a highlight mode listener, it is only called
  // immediately following pointer events.
  void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
    if (!mounted) {
      return;
    }
    _updateHighlightMode(mode);
  }

  bool _hovering = false;
  void _handleMouseEnter(PointerEnterEvent event) {
    assert(widget.onShowHoverHighlight != null);
    if (!_hovering) {
      _mayTriggerCallback(task: () {
        _hovering = true;
      });
    }
  }

  void _handleMouseExit(PointerExitEvent event) {
    assert(widget.onShowHoverHighlight != null);
    if (_hovering) {
      _mayTriggerCallback(task: () {
        _hovering = false;
      });
    }
  }

  bool _focused = false;
  void _handleFocusChange(bool focused) {
    if (_focused != focused) {
      _mayTriggerCallback(task: () {
        _focused = focused;
      });
      widget.onFocusChange?.call(_focused);
    }
  }

  // Record old states, do `task` if not null, then compare old states with the
  // new states, and trigger callbacks if necessary.
  //
  // The old states are collected from `oldWidget` if it is provided, or the
  // current widget (before doing `task`) otherwise. The new states are always
  // collected from the current widget.
  void _mayTriggerCallback({VoidCallback task, FocusableActionDetector oldWidget}) {
    bool shouldShowHoverHighlight(FocusableActionDetector target) {
      return _hovering && target.enabled && _canShowHighlight;
    }
    bool shouldShowFocusHighlight(FocusableActionDetector target) {
      return _focused && target.enabled && _canShowHighlight;
    }

    assert(SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks);
    final FocusableActionDetector oldTarget = oldWidget ?? widget;
    final bool didShowHoverHighlight = shouldShowHoverHighlight(oldTarget);
    final bool didShowFocusHighlight = shouldShowFocusHighlight(oldTarget);
    if (task != null)
      task();
    final bool doShowHoverHighlight = shouldShowHoverHighlight(widget);
    final bool doShowFocusHighlight = shouldShowFocusHighlight(widget);
    if (didShowFocusHighlight != doShowFocusHighlight)
      widget.onShowFocusHighlight?.call(doShowFocusHighlight);
    if (didShowHoverHighlight != doShowHoverHighlight)
      widget.onShowHoverHighlight?.call(doShowHoverHighlight);
  }

  @override
  void didUpdateWidget(FocusableActionDetector oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.enabled != oldWidget.enabled) {
      SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
        _mayTriggerCallback(oldWidget: oldWidget);
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    Widget child = MouseRegion(
      onEnter: _handleMouseEnter,
      onExit: _handleMouseExit,
      child: Focus(
        focusNode: widget.focusNode,
        autofocus: widget.autofocus,
        canRequestFocus: widget.enabled,
        onFocusChange: _handleFocusChange,
        child: widget.child,
      ),
    );
    if (widget.enabled && widget.actions != null && widget.actions.isNotEmpty) {
      child = Actions(actions: widget.actions, child: child);
    }
    if (widget.enabled && widget.shortcuts != null && widget.shortcuts.isNotEmpty) {
      child = Shortcuts(shortcuts: widget.shortcuts, child: child);
    }
    return child;
  }
}

/// An [Action], that, as the name implies, does nothing.
///
/// This action is bound to the [Intent.doNothing] intent inside of
/// [WidgetsApp.build] so that a [Shortcuts] widget can bind a key to it to
/// override another shortcut binding defined above it in the hierarchy.
class DoNothingAction extends Action {
  /// Const constructor for [DoNothingAction].
  const DoNothingAction() : super(key);

  /// The Key used for the [DoNothingIntent] intent, and registered at the top
  /// level actions in [WidgetsApp.build].
  static const LocalKey key = ValueKey<Type>(DoNothingAction);

  @override
  void invoke(FocusNode node, Intent intent) { }
}

/// An action that invokes the currently focused control.
///
/// This is an abstract class that serves as a base class for actions that
/// activate a control. By default, is bound to [LogicalKeyboardKey.enter] in
/// the default keyboard map in [WidgetsApp].
abstract class ActivateAction extends Action {
  /// Creates a [ActivateAction] with a fixed [key];
  const ActivateAction() : super(key);

  /// The [LocalKey] that uniquely identifies this action.
  static const LocalKey key = ValueKey<Type>(ActivateAction);
}

/// An action that selects the currently focused control.
///
/// This is an abstract class that serves as a base class for actions that
/// select something. It is not bound to any key by default.
abstract class SelectAction extends Action {
  /// Creates a [SelectAction] with a fixed [key];
  const SelectAction() : super(key);

  /// The [LocalKey] that uniquely identifies this action.
  static const LocalKey key = ValueKey<Type>(SelectAction);
}
