// 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/gestures.dart';
import 'package:flutter/rendering.dart';

import 'editable_text.dart';
import 'framework.dart';

// Enable if you want verbose logging about tap region changes.
const bool _kDebugTapRegion = false;

bool _tapRegionDebug(String message, [Iterable<String>? details]) {
  if (_kDebugTapRegion) {
    debugPrint('TAP REGION: $message');
    if (details != null && details.isNotEmpty) {
      for (final String detail in details) {
        debugPrint('    $detail');
      }
    }
  }
  // Return true so that it can be easily used inside of an assert.
  return true;
}

/// The type of callback that [TapRegion.onTapOutside] and
/// [TapRegion.onTapInside] take.
///
/// The event is the pointer event that caused the callback to be called.
typedef TapRegionCallback = void Function(PointerDownEvent event);

/// An interface for registering and unregistering a [RenderTapRegion]
/// (typically created with a [TapRegion] widget) with a
/// [RenderTapRegionSurface] (typically created with a [TapRegionSurface]
/// widget).
abstract class TapRegionRegistry {
  /// Register the given [RenderTapRegion] with the registry.
  void registerTapRegion(RenderTapRegion region);

  /// Unregister the given [RenderTapRegion] with the registry.
  void unregisterTapRegion(RenderTapRegion region);

  /// Allows finding of the nearest [TapRegionRegistry], such as a
  /// [RenderTapRegionSurface].
  ///
  /// Will throw if a [TapRegionRegistry] isn't found.
  static TapRegionRegistry of(BuildContext context) {
    final TapRegionRegistry? registry = maybeOf(context);
    assert(() {
      if (registry == null) {
        throw FlutterError(
          'TapRegionRegistry.of() was called with a context that does not contain a TapRegionSurface widget.\n'
          'No TapRegionSurface widget ancestor could be found starting from the context that was passed to '
          'TapRegionRegistry.of().\n'
          'The context used was:\n'
          '  $context',
        );
      }
      return true;
    }());
    return registry!;
  }

  /// Allows finding of the nearest [TapRegionRegistry], such as a
  /// [RenderTapRegionSurface].
  static TapRegionRegistry? maybeOf(BuildContext context) {
    return context.findAncestorRenderObjectOfType<RenderTapRegionSurface>();
  }
}

/// A widget that provides notification of a tap inside or outside of a set of
/// registered regions, without participating in the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system.
///
/// The regions are defined by adding [TapRegion] widgets to the widget tree
/// around the regions of interest, and they will register with this
/// [TapRegionSurface]. Each of the tap regions can optionally belong to a group
/// by assigning a [TapRegion.groupId], where all the regions with the same
/// groupId act as if they were all one region.
///
/// When a tap outside of a registered region or region group is detected, its
/// [TapRegion.onTapOutside] callback is called. If the tap is outside one
/// member of a group, but inside another, no notification is made.
///
/// When a tap inside of a registered region or region group is detected, its
/// [TapRegion.onTapInside] callback is called. If the tap is inside one member
/// of a group, all members are notified.
///
/// The [TapRegionSurface] should be defined at the highest level needed to
/// encompass the entire area where taps should be monitored. This is typically
/// around the entire app. If the entire app isn't covered, then taps outside of
/// the [TapRegionSurface] will be ignored and no [TapRegion.onTapOutside] calls
/// will be made for those events. The [WidgetsApp], [MaterialApp] and
/// [CupertinoApp] automatically include a [TapRegionSurface] around their
/// entire app.
///
/// [TapRegionSurface] does not participate in the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system, so if multiple [TapRegionSurface]s are active at the same time, they
/// will all fire, and so will any other gestures recognized by a
/// [GestureDetector] or other pointer event handlers.
///
/// [TapRegion]s register only with the nearest ancestor [TapRegionSurface].
///
/// See also:
///
///  * [RenderTapRegionSurface], the render object that is inserted into the
///    render tree by this widget.
///  * <https://flutter.dev/gestures/#gesture-disambiguation> for more
///    information about the gesture system and how it disambiguates inputs.
class TapRegionSurface extends SingleChildRenderObjectWidget {
  /// Creates a const [RenderTapRegionSurface].
  ///
  /// The [child] attribute is required.
  const TapRegionSurface({
    super.key,
    required Widget super.child,
  });

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderTapRegionSurface();
  }

  @override
  void updateRenderObject(
    BuildContext context,
    RenderProxyBoxWithHitTestBehavior renderObject,
  ) {}
}

/// A render object that provides notification of a tap inside or outside of a
/// set of registered regions, without participating in the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system.
///
/// The regions are defined by adding [RenderTapRegion] render objects in the
/// render tree around the regions of interest, and they will register with this
/// [RenderTapRegionSurface]. Each of the tap regions can optionally belong to a
/// group by assigning a [RenderTapRegion.groupId], where all the regions with
/// the same groupId act as if they were all one region.
///
/// When a tap outside of a registered region or region group is detected, its
/// [TapRegion.onTapOutside] callback is called. If the tap is outside one
/// member of a group, but inside another, no notification is made.
///
/// When a tap inside of a registered region or region group is detected, its
/// [TapRegion.onTapInside] callback is called. If the tap is inside one member
/// of a group, all members are notified.
///
/// The [RenderTapRegionSurface] should be defined at the highest level needed
/// to encompass the entire area where taps should be monitored. This is
/// typically around the entire app. If the entire app isn't covered, then taps
/// outside of the [RenderTapRegionSurface] will be ignored and no
/// [RenderTapRegion.onTapOutside] calls will be made for those events. The
/// [WidgetsApp], [MaterialApp] and [CupertinoApp] automatically include a
/// [RenderTapRegionSurface] around the entire app.
///
/// [RenderTapRegionSurface] does not participate in the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system, so if multiple [RenderTapRegionSurface]s are active at the same
/// time, they will all fire, and so will any other gestures recognized by a
/// [GestureDetector] or other pointer event handlers.
///
/// [RenderTapRegion]s register only with the nearest ancestor
/// [RenderTapRegionSurface].
///
/// See also:
///
///  * [TapRegionSurface], a widget that inserts a [RenderTapRegionSurface] into
///    the render tree.
///  * [TapRegionRegistry.of], which can find the nearest ancestor
///    [RenderTapRegionSurface], which is a [TapRegionRegistry].
class RenderTapRegionSurface extends RenderProxyBoxWithHitTestBehavior implements TapRegionRegistry {
  final Expando<BoxHitTestResult> _cachedResults = Expando<BoxHitTestResult>();
  final Set<RenderTapRegion> _registeredRegions = <RenderTapRegion>{};
  final Map<Object?, Set<RenderTapRegion>> _groupIdToRegions = <Object?, Set<RenderTapRegion>>{};

  @override
  void registerTapRegion(RenderTapRegion region) {
    assert(_tapRegionDebug('Region $region registered.'));
    assert(!_registeredRegions.contains(region));
    _registeredRegions.add(region);
    if (region.groupId != null) {
      _groupIdToRegions[region.groupId] ??= <RenderTapRegion>{};
      _groupIdToRegions[region.groupId]!.add(region);
    }
  }

  @override
  void unregisterTapRegion(RenderTapRegion region) {
    assert(_tapRegionDebug('Region $region unregistered.'));
    assert(_registeredRegions.contains(region));
    _registeredRegions.remove(region);
    if (region.groupId != null) {
      assert(_groupIdToRegions.containsKey(region.groupId));
      _groupIdToRegions[region.groupId]!.remove(region);
      if (_groupIdToRegions[region.groupId]!.isEmpty) {
        _groupIdToRegions.remove(region.groupId);
      }
    }
  }

  @override
  bool hitTest(BoxHitTestResult result, {required Offset position}) {
    if (!size.contains(position)) {
      return false;
    }

    final bool hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);

    if (hitTarget) {
      final BoxHitTestEntry entry = BoxHitTestEntry(this, position);
      _cachedResults[entry] = result;
      result.add(entry);
    }

    return hitTarget;
  }

  @override
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    assert(() {
      for (final RenderTapRegion region in _registeredRegions) {
        if (!region.enabled) {
          return false;
        }
      }
      return true;
    }(), 'A RenderTapRegion was registered when it was disabled.');

    if (event is! PointerDownEvent || event.buttons != kPrimaryButton) {
      return;
    }

    if (_registeredRegions.isEmpty) {
      assert(_tapRegionDebug('Ignored tap event because no regions are registered.'));
      return;
    }

    final BoxHitTestResult? result = _cachedResults[entry];

    if (result == null) {
      assert(_tapRegionDebug('Ignored tap event because no surface descendants were hit.'));
      return;
    }

    // A child was hit, so we need to call onTapOutside for those regions or
    // groups of regions that were not hit.
    final Set<RenderTapRegion> hitRegions =
        _getRegionsHit(_registeredRegions, result.path).cast<RenderTapRegion>().toSet();
    final Set<RenderTapRegion> insideRegions = <RenderTapRegion>{};
    assert(_tapRegionDebug('Tap event hit ${hitRegions.length} descendants.'));

    for (final RenderTapRegion region in hitRegions) {
      if (region.groupId == null) {
        insideRegions.add(region);
        continue;
      }
      // Add all grouped regions to the insideRegions so that groups act as a
      // single region.
      insideRegions.addAll(_groupIdToRegions[region.groupId]!);
    }
    // If they're not inside, then they're outside.
    final Set<RenderTapRegion> outsideRegions = _registeredRegions.difference(insideRegions);

    for (final RenderTapRegion region in outsideRegions) {
      assert(_tapRegionDebug('Calling onTapOutside for $region'));
      region.onTapOutside?.call(event);
    }
    for (final RenderTapRegion region in insideRegions) {
      assert(_tapRegionDebug('Calling onTapInside for $region'));
      region.onTapInside?.call(event);
    }
  }

  // Returns the registered regions that are in the hit path.
  Iterable<HitTestTarget> _getRegionsHit(Set<RenderTapRegion> detectors, Iterable<HitTestEntry> hitTestPath) {
    final Set<HitTestTarget> hitRegions = <HitTestTarget>{};
    for (final HitTestEntry<HitTestTarget> entry in hitTestPath) {
      final HitTestTarget target = entry.target;
      if (_registeredRegions.contains(target)) {
        hitRegions.add(target);
      }
    }
    return hitRegions;
  }
}

/// A widget that defines a region that can detect taps inside or outside of
/// itself and any group of regions it belongs to, without participating in the
/// [gesture disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system.
///
/// This widget indicates to the nearest ancestor [TapRegionSurface] that the
/// region occupied by its child will participate in the tap detection for that
/// surface.
///
/// If this region belongs to a group (by virtue of its [groupId]), all the
/// regions in the group will act as one.
///
/// If there is no [TapRegionSurface] ancestor, [TapRegion] will do nothing.
class TapRegion extends SingleChildRenderObjectWidget {
  /// Creates a const [TapRegion].
  ///
  /// The [child] argument is required.
  const TapRegion({
    super.key,
    required super.child,
    this.enabled = true,
    this.behavior = HitTestBehavior.deferToChild,
    this.onTapOutside,
    this.onTapInside,
    this.groupId,
    String? debugLabel,
  }) : debugLabel = kReleaseMode ? null : debugLabel;

  /// Whether or not this [TapRegion] is enabled as part of the composite region.
  final bool enabled;

  /// How to behave during hit testing when deciding how the hit test propagates
  /// to children and whether to consider targets behind this [TapRegion].
  ///
  /// Defaults to [HitTestBehavior.deferToChild].
  ///
  /// See [HitTestBehavior] for the allowed values and their meanings.
  final HitTestBehavior behavior;

  /// A callback to be invoked when a tap is detected outside of this
  /// [TapRegion] and any other region with the same [groupId], if any.
  ///
  /// The [PointerDownEvent] passed to the function is the event that caused the
  /// notification. If this region is part of a group (i.e. [groupId] is set),
  /// then it's possible that the event may be outside of this immediate region,
  /// although it will be within the region of one of the group members.
  final TapRegionCallback? onTapOutside;

  /// A callback to be invoked when a tap is detected inside of this
  /// [TapRegion], or any other tap region with the same [groupId], if any.
  ///
  /// The [PointerDownEvent] passed to the function is the event that caused the
  /// notification. If this region is part of a group (i.e. [groupId] is set),
  /// then it's possible that the event may be outside of this immediate region,
  /// although it will be within the region of one of the group members.
  final TapRegionCallback? onTapInside;

  /// An optional group ID that groups [TapRegion]s together so that they
  /// operate as one region. If any member of a group is hit by a particular
  /// tap, then the [onTapOutside] will not be called for any members of the
  /// group. If any member of the group is hit, then all members will have their
  /// [onTapInside] called.
  ///
  /// If the group id is null, then only this region is hit tested.
  final Object? groupId;

  /// An optional debug label to help with debugging in debug mode.
  ///
  /// Will be null in release mode.
  final String? debugLabel;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderTapRegion(
      registry: TapRegionRegistry.maybeOf(context),
      enabled: enabled,
      behavior: behavior,
      onTapOutside: onTapOutside,
      onTapInside: onTapInside,
      groupId: groupId,
      debugLabel: debugLabel,
    );
  }

  @override
  void updateRenderObject(BuildContext context, covariant RenderTapRegion renderObject) {
    renderObject
      ..registry = TapRegionRegistry.maybeOf(context)
      ..enabled = enabled
      ..behavior = behavior
      ..groupId = groupId
      ..onTapOutside = onTapOutside
      ..onTapInside = onTapInside;
    if (!kReleaseMode) {
      renderObject.debugLabel = debugLabel;
    }
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true));
    properties.add(DiagnosticsProperty<HitTestBehavior>('behavior', behavior, defaultValue: HitTestBehavior.deferToChild));
    properties.add(DiagnosticsProperty<Object?>('debugLabel', debugLabel, defaultValue: null));
    properties.add(DiagnosticsProperty<Object?>('groupId', groupId, defaultValue: null));
  }
}

/// A render object that defines a region that can detect taps inside or outside
/// of itself and any group of regions it belongs to, without participating in
/// the [gesture
/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation)
/// system.
///
/// This render object indicates to the nearest ancestor [TapRegionSurface] that
/// the region occupied by its child (or itself if [behavior] is
/// [HitTestBehavior.opaque]) will participate in the tap detection for that
/// surface.
///
/// If this region belongs to a group (by virtue of its [groupId]), all the
/// regions in the group will act as one.
///
/// If there is no [RenderTapRegionSurface] ancestor in the render tree,
/// [RenderTapRegion] will do nothing.
///
/// The [behavior] attribute describes how to behave during hit testing when
/// deciding how the hit test propagates to children and whether to consider
/// targets behind the tap region. Defaults to [HitTestBehavior.deferToChild].
/// See [HitTestBehavior] for the allowed values and their meanings.
///
/// See also:
///
///  * [TapRegion], a widget that inserts a [RenderTapRegion] into the render
///    tree.
class RenderTapRegion extends RenderProxyBoxWithHitTestBehavior {
  /// Creates a [RenderTapRegion].
  RenderTapRegion({
    TapRegionRegistry? registry,
    bool enabled = true,
    this.onTapOutside,
    this.onTapInside,
    super.behavior = HitTestBehavior.deferToChild,
    Object? groupId,
    String? debugLabel,
  })  : _registry = registry,
        _enabled = enabled,
        _groupId = groupId,
        debugLabel = kReleaseMode ? null : debugLabel;

  bool _isRegistered = false;

  /// A callback to be invoked when a tap is detected outside of this
  /// [RenderTapRegion] and any other region with the same [groupId], if any.
  ///
  /// The [PointerDownEvent] passed to the function is the event that caused the
  /// notification. If this region is part of a group (i.e. [groupId] is set),
  /// then it's possible that the event may be outside of this immediate region,
  /// although it will be within the region of one of the group members.
  TapRegionCallback? onTapOutside;

  /// A callback to be invoked when a tap is detected inside of this
  /// [RenderTapRegion], or any other tap region with the same [groupId], if any.
  ///
  /// The [PointerDownEvent] passed to the function is the event that caused the
  /// notification. If this region is part of a group (i.e. [groupId] is set),
  /// then it's possible that the event may be outside of this immediate region,
  /// although it will be within the region of one of the group members.
  TapRegionCallback? onTapInside;

  /// A label used in debug builds. Will be null in release builds.
  String? debugLabel;

  /// Whether or not this region should participate in the composite region.
  bool get enabled => _enabled;
  bool _enabled;
  set enabled(bool value) {
    if (_enabled != value) {
      _enabled = value;
      markNeedsLayout();
    }
  }

  /// An optional group ID that groups [RenderTapRegion]s together so that they
  /// operate as one region. If any member of a group is hit by a particular
  /// tap, then the [onTapOutside] will not be called for any members of the
  /// group. If any member of the group is hit, then all members will have their
  /// [onTapInside] called.
  ///
  /// If the group id is null, then only this region is hit tested.
  Object? get groupId => _groupId;
  Object? _groupId;
  set groupId(Object? value) {
    if (_groupId != value) {
      // If the group changes, we need to unregister and re-register under the
      // new group. The re-registration happens automatically in layout().
      if (_isRegistered) {
        _registry!.unregisterTapRegion(this);
        _isRegistered = false;
      }
      _groupId = value;
      markNeedsLayout();
    }
  }

  /// The registry that this [RenderTapRegion] should register with.
  ///
  /// If the [registry] is null, then this region will not be registered
  /// anywhere, and will not do any tap detection.
  ///
  /// A [RenderTapRegionSurface] is a [TapRegionRegistry].
  TapRegionRegistry? get registry => _registry;
  TapRegionRegistry? _registry;
  set registry(TapRegionRegistry? value) {
    if (_registry != value) {
      if (_isRegistered) {
        _registry!.unregisterTapRegion(this);
        _isRegistered = false;
      }
      _registry = value;
      markNeedsLayout();
    }
  }

  @override
  void layout(Constraints constraints, {bool parentUsesSize = false}) {
    super.layout(constraints, parentUsesSize: parentUsesSize);
    if (_registry == null) {
      return;
    }
    if (_isRegistered) {
      _registry!.unregisterTapRegion(this);
    }
    final bool shouldBeRegistered = _enabled && _registry != null;
    if (shouldBeRegistered) {
      _registry!.registerTapRegion(this);
    }
    _isRegistered = shouldBeRegistered;
  }

  @override
  void dispose() {
    if (_isRegistered) {
      _registry!.unregisterTapRegion(this);
    }
    super.dispose();
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<String?>('debugLabel', debugLabel, defaultValue: null));
    properties.add(DiagnosticsProperty<Object?>('groupId', groupId, defaultValue: null));
    properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true));
  }
}

/// A [TapRegion] that adds its children to the tap region group for widgets
/// based on the [EditableText] text editing widget, such as [TextField] and
/// [CupertinoTextField].
///
/// Widgets that are wrapped with a [TextFieldTapRegion] are considered to be
/// part of a text field for purposes of unfocus behavior. So, when the user
/// taps on them, the currently focused text field won't be unfocused by
/// default. This allows controls like spinners, copy buttons, and formatting
/// buttons to be associated with a text field without causing the text field to
/// lose focus when they are interacted with.
///
/// {@tool dartpad}
/// This example shows how to use a [TextFieldTapRegion] to wrap a set of
/// "spinner" buttons that increment and decrement a value in the text field
/// without causing the text field to lose keyboard focus.
///
/// This example includes a generic `SpinnerField<T>` class that you can copy/paste
/// into your own project and customize.
///
/// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart **
/// {@end-tool}
///
/// See also:
///
///  * [TapRegion], the widget that this widget uses to add widgets to the group
///    of text fields.
class TextFieldTapRegion extends TapRegion {
  /// Creates a const [TextFieldTapRegion].
  ///
  /// The [child] field is required.
  const TextFieldTapRegion({
    super.key,
    required super.child,
    super.enabled,
    super.onTapOutside,
    super.onTapInside,
    super.debugLabel,
  }) : super(groupId: EditableText);
}
