// Copyright 2019 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/foundation.dart';

import 'focus_manager.dart';
import 'framework.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 extends 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 extends 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
  @mustCallSuper
  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 extends 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;

  /// 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.
  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;
    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;
        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.ancestorInheritedElementForWidgetOfExactType(Actions);
    final Actions inherited = context.inheritFromElement(inheritedElement);
    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;
      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));
  }
}

/// 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, like a checkbox or a radio button. By default, it is bound
/// to [LogicalKeyboardKey.space] in the default keyboard map in [WidgetsApp].
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);
}
