// Copyright 2018 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:ui';

import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/scheduler.dart';

import 'events.dart';
import 'pointer_router.dart';

/// Signature for listening to [PointerEnterEvent] events.
///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
typedef PointerEnterEventListener = void Function(PointerEnterEvent event);

/// Signature for listening to [PointerExitEvent] events.
///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
typedef PointerExitEventListener = void Function(PointerExitEvent event);

/// Signature for listening to [PointerHoverEvent] events.
///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
typedef PointerHoverEventListener = void Function(PointerHoverEvent event);

/// The annotation object used to annotate layers that are interested in mouse
/// movements.
///
/// This is added to a layer and managed by the [Listener] widget.
class MouseTrackerAnnotation {
  /// Creates an annotation that can be used to find layers interested in mouse
  /// movements.
  const MouseTrackerAnnotation({this.onEnter, this.onHover, this.onExit});

  /// Triggered when a pointer has entered the bounding box of the annotated
  /// layer.
  final PointerEnterEventListener onEnter;

  /// Triggered when a pointer has moved within the bounding box of the
  /// annotated layer.
  final PointerHoverEventListener onHover;

  /// Triggered when a pointer has exited the bounding box of the annotated
  /// layer.
  final PointerExitEventListener onExit;

  @override
  String toString() {
    final String none = (onEnter == null && onExit == null && onHover == null) ? ' <none>' : '';
    return '[$runtimeType${hashCode.toRadixString(16)}$none'
        '${onEnter == null ? '' : ' onEnter'}'
        '${onHover == null ? '' : ' onHover'}'
        '${onExit == null ? '' : ' onExit'}]';
  }
}

// Used internally by the MouseTracker for accounting for which annotation is
// active on which devices inside of the MouseTracker.
class _TrackedAnnotation {
  _TrackedAnnotation(this.annotation);

  final MouseTrackerAnnotation annotation;

  /// Tracks devices that are currently active for this annotation.
  ///
  /// If the mouse pointer corresponding to the integer device ID is
  /// present in the Set, then it is currently inside of the annotated layer.
  ///
  /// This is used to detect layers that used to have the mouse pointer inside
  /// them, but now no longer do (to facilitate exit notification).
  Set<int> activeDevices = <int>{};
}

/// Describes a function that finds an annotation given an offset in logical
/// coordinates.
///
/// It is used by the [MouseTracker] to fetch annotations for the mouse
/// position.
typedef MouseDetectorAnnotationFinder = MouseTrackerAnnotation Function(Offset offset);

/// Keeps state about which objects are interested in tracking mouse positions
/// and notifies them when a mouse pointer enters, moves, or leaves an annotated
/// region that they are interested in.
///
/// Owned by the [RendererBinding] class.
class MouseTracker {
  /// Creates a mouse tracker to keep track of mouse locations.
  ///
  /// All of the parameters must not be null.
  MouseTracker(PointerRouter router, this.annotationFinder)
      : assert(router != null),
        assert(annotationFinder != null) {
    router.addGlobalRoute(_handleEvent);
  }

  /// Used to find annotations at a given logical coordinate.
  final MouseDetectorAnnotationFinder annotationFinder;

  // The collection of annotations that are currently being tracked. They may or
  // may not be active, depending on the value of _TrackedAnnotation.active.
  final Map<MouseTrackerAnnotation, _TrackedAnnotation> _trackedAnnotations = <MouseTrackerAnnotation, _TrackedAnnotation>{};

  /// Track an annotation so that if the mouse enters it, we send it events.
  ///
  /// This is typically called when the [AnnotatedRegion] containing this
  /// annotation has been added to the layer tree.
  void attachAnnotation(MouseTrackerAnnotation annotation) {
    _trackedAnnotations[annotation] = _TrackedAnnotation(annotation);
    // Schedule a check so that we test this new annotation to see if the mouse
    // is currently inside its region.
    _scheduleMousePositionCheck();
  }

  /// Stops tracking an annotation, indicating that it has been removed from the
  /// layer tree.
  ///
  /// If the associated layer is not removed, and receives a hit, then
  /// [collectMousePositions] will assert the next time it is called.
  void detachAnnotation(MouseTrackerAnnotation annotation) {
    final _TrackedAnnotation trackedAnnotation = _findAnnotation(annotation);
    assert(trackedAnnotation != null, "Tried to detach an annotation that wasn't attached: $annotation");
    for (int deviceId in trackedAnnotation.activeDevices) {
      annotation.onExit(PointerExitEvent.fromMouseEvent(_lastMouseEvent[deviceId]));
    }
    _trackedAnnotations.remove(annotation);
  }

  void _scheduleMousePositionCheck() {
    SchedulerBinding.instance.addPostFrameCallback((Duration _) => collectMousePositions());
    SchedulerBinding.instance.scheduleFrame();
  }

  // Handler for events coming from the PointerRouter.
  void _handleEvent(PointerEvent event) {
    if (event.kind != PointerDeviceKind.mouse) {
      return;
    }
    final int deviceId = event.device;
    if (_trackedAnnotations.isEmpty) {
      // If we're not tracking anything, then there is no point in registering a
      // frame callback or scheduling a frame. By definition there are no active
      // annotations that need exiting, either.
      _lastMouseEvent.remove(deviceId);
      return;
    }
    if (event is PointerRemovedEvent) {
      _lastMouseEvent.remove(deviceId);
      // If the mouse was removed, then we need to schedule one more check to
      // exit any annotations that were active.
      _scheduleMousePositionCheck();
    } else {
      if (event is PointerMoveEvent || event is PointerHoverEvent || event is PointerDownEvent) {
        if (!_lastMouseEvent.containsKey(deviceId) || _lastMouseEvent[deviceId].position != event.position) {
          // Only schedule a frame if we have our first event, or if the
          // location of the mouse has changed.
          _scheduleMousePositionCheck();
        }
        _lastMouseEvent[deviceId] = event;
      }
    }
  }

  _TrackedAnnotation _findAnnotation(MouseTrackerAnnotation annotation) {
    final _TrackedAnnotation trackedAnnotation = _trackedAnnotations[annotation];
    assert(
        trackedAnnotation != null,
        'Unable to find annotation $annotation in tracked annotations. '
        'Check that attachAnnotation has been called for all annotated layers.');
    return trackedAnnotation;
  }

  /// Checks if the given [MouseTrackerAnnotation] is attached to this
  /// [MouseTracker].
  ///
  /// This function is only public to allow for proper testing of the
  /// MouseTracker. Do not call in other contexts.
  @visibleForTesting
  bool isAnnotationAttached(MouseTrackerAnnotation annotation) {
    return _trackedAnnotations[annotation] != null;
  }

  /// Tells interested objects that a mouse has entered, exited, or moved, given
  /// a callback to fetch the [MouseTrackerAnnotation] associated with a global
  /// offset.
  ///
  /// This is called from a post-frame callback when the layer tree has been
  /// updated, right after rendering the frame.
  ///
  /// This function is only public to allow for proper testing of the
  /// MouseTracker. Do not call in other contexts.
  @visibleForTesting
  void collectMousePositions() {
    void exitAnnotation(_TrackedAnnotation trackedAnnotation, int deviceId) {
      if (trackedAnnotation.annotation?.onExit != null && trackedAnnotation.activeDevices.contains(deviceId)) {
        trackedAnnotation.annotation.onExit(PointerExitEvent.fromMouseEvent(_lastMouseEvent[deviceId]));
        trackedAnnotation.activeDevices.remove(deviceId);
      }
    }

    void exitAllDevices(_TrackedAnnotation trackedAnnotation) {
      if (trackedAnnotation.activeDevices.isNotEmpty) {
        final Set<int> deviceIds = trackedAnnotation.activeDevices.toSet();
        for (int deviceId in deviceIds) {
          exitAnnotation(trackedAnnotation, deviceId);
        }
      }
    }

    // This indicates that all mouse pointers were removed, or none have been
    // connected yet. If no mouse is connected, then we want to make sure that
    // all active annotations are exited.
    if (!mouseIsConnected) {
      _trackedAnnotations.values.forEach(exitAllDevices);
      return;
    }

    for (int deviceId in _lastMouseEvent.keys) {
      final PointerEvent lastEvent = _lastMouseEvent[deviceId];
      final MouseTrackerAnnotation hit = annotationFinder(lastEvent.position);

      // No annotation was found at this position for this deviceId, so send an
      // exit to all active tracked annotations, since none of them were hit.
      if (hit == null) {
        // Send an exit to all tracked animations tracking this deviceId.
        for (_TrackedAnnotation trackedAnnotation in _trackedAnnotations.values) {
          exitAnnotation(trackedAnnotation, deviceId);
        }
        return;
      }

      final _TrackedAnnotation hitAnnotation = _findAnnotation(hit);
      if (!hitAnnotation.activeDevices.contains(deviceId)) {
        // A tracked annotation that just became active and needs to have an enter
        // event sent to it.
        hitAnnotation.activeDevices.add(deviceId);
        if (hitAnnotation.annotation?.onEnter != null) {
          hitAnnotation.annotation.onEnter(PointerEnterEvent.fromMouseEvent(lastEvent));
        }
      }
      if (hitAnnotation.annotation?.onHover != null && lastEvent is PointerHoverEvent) {
        hitAnnotation.annotation.onHover(lastEvent);
      }

      // Tell any tracked annotations that weren't hit that they are no longer
      // active.
      for (_TrackedAnnotation trackedAnnotation in _trackedAnnotations.values) {
        if (hitAnnotation == trackedAnnotation) {
          continue;
        }
        if (trackedAnnotation.activeDevices.contains(deviceId)) {
          if (trackedAnnotation.annotation?.onExit != null) {
            trackedAnnotation.annotation.onExit(PointerExitEvent.fromMouseEvent(lastEvent));
          }
          trackedAnnotation.activeDevices.remove(deviceId);
        }
      }
    }
  }

  /// The most recent mouse event observed for each mouse device ID observed.
  ///
  /// May be null if no mouse is connected, or hasn't produced an event yet.
  /// Will not be updated unless there is at least one tracked annotation.
  final Map<int, PointerEvent> _lastMouseEvent = <int, PointerEvent>{};

  /// Whether or not a mouse is connected and has produced events.
  bool get mouseIsConnected => _lastMouseEvent.isNotEmpty;
}
