// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/bubble/ui_bundled/bubble_presenter.h"

#import "base/apple/foundation_util.h"
#import "base/functional/bind.h"
#import "base/memory/raw_ptr.h"
#import "base/metrics/histogram_functions.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "base/strings/sys_string_conversions.h"
#import "components/content_settings/core/browser/host_content_settings_map.h"
#import "components/feature_engagement/public/event_constants.h"
#import "components/feature_engagement/public/feature_constants.h"
#import "components/feature_engagement/public/tracker.h"
#import "components/omnibox/browser/omnibox_event_global_tracker.h"
#import "components/omnibox/browser/omnibox_pref_names.h"
#import "components/prefs/pref_service.h"
#import "components/segmentation_platform/embedder/default_model/device_switcher_result_dispatcher.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/bubble/model/utils.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_constants.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_presenter_delegate.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_util.h"
#import "ios/chrome/browser/bubble/ui_bundled/bubble_view_controller_presenter.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_view.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/gesture_in_product_help_view_delegate.h"
#import "ios/chrome/browser/bubble/ui_bundled/gesture_iph/toolbar_swipe_gesture_in_product_help_view.h"
#import "ios/chrome/browser/feature_engagement/model/tracker_factory.h"
#import "ios/chrome/browser/fullscreen/ui_bundled/animated_scoped_fullscreen_disabler.h"
#import "ios/chrome/browser/fullscreen/ui_bundled/fullscreen_controller.h"
#import "ios/chrome/browser/fullscreen/ui_bundled/scoped_fullscreen_disabler.h"
#import "ios/chrome/browser/intelligence/bwg/utils/gemini_constants.h"
#import "ios/chrome/browser/intelligence/features/features.h"
#import "ios/chrome/browser/ntp/model/new_tab_page_tab_helper.h"
#import "ios/chrome/browser/ntp/shared/metrics/feed_metrics_recorder.h"
#import "ios/chrome/browser/overlays/model/public/overlay_presenter.h"
#import "ios/chrome/browser/overlays/model/public/overlay_presenter_observer_bridge.h"
#import "ios/chrome/browser/segmentation_platform/model/segmentation_platform_service_factory.h"
#import "ios/chrome/browser/shared/coordinator/scene/state/scene_layout_state.h"
#import "ios/chrome/browser/shared/model/application_context/application_context.h"
#import "ios/chrome/browser/shared/model/prefs/pref_names.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/url/chrome_url_constants.h"
#import "ios/chrome/browser/shared/model/url/url_util.h"
#import "ios/chrome/browser/shared/model/utils/first_run_util.h"
#import "ios/chrome/browser/shared/model/web_state_list/web_state_list.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
#import "ios/chrome/browser/shared/public/commands/gemini_commands.h"
#import "ios/chrome/browser/shared/public/commands/page_action_menu_entry_point_commands.h"
#import "ios/chrome/browser/shared/public/commands/popup_menu_commands.h"
#import "ios/chrome/browser/shared/public/commands/tab_strip_commands.h"
#import "ios/chrome/browser/shared/public/commands/toolbar_commands.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/public/features/system_flags.h"
#import "ios/chrome/browser/shared/ui/util/layout_guide_names.h"
#import "ios/chrome/browser/shared/ui/util/named_guide.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/shared/ui/util/util_swift.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"
#import "ios/chrome/grit/ios_branded_strings.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/web/public/navigation/navigation_manager.h"
#import "ios/web/public/ui/crw_web_view_proxy.h"
#import "ios/web/public/ui/crw_web_view_scroll_view_proxy.h"
#import "ios/web/public/web_state.h"
#import "ui/base/device_form_factor.h"
#import "ui/base/l10n/l10n_util.h"

namespace {

// Returns whether `view` could display and animate correctly within `guide`. If
// NO, elements in `view` may be hidden or overlap with each other during the
// animation.
BOOL CanGestureInProductHelpViewFitInGuide(GestureInProductHelpView* view,
                                           UILayoutGuide* guide) {
  CGSize guide_size = guide.layoutFrame.size;
  CGSize view_fitting_size =
      [view systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
  return view_fitting_size.width <= guide_size.width &&
         view_fitting_size.height <= guide_size.height;
}

// Max leeway for the bubble arrow to move on its axis.
constexpr CGFloat kMaxLeeway = 20;
// Additional margin between the bubble and the border of the screen, used to
// slightly move what the arrow is pointing to.
constexpr CGFloat kAdditionalBorderMargin = 4;

}  // namespace

@interface BubblePresenter () <GestureInProductHelpViewDelegate,
                               OverlayPresenterObserving>

// Returns whether the guide is in the bottom half of the root view.
- (BOOL)isGuideAtBottom:(GuideName*)guideName;

// Returns whether the guide is in the leading half of the root view.
- (BOOL)isGuideAtLeading:(GuideName*)guideName;

@end

@implementation BubblePresenter {
  // Required dependencies.
  LayoutGuideCenter* _layoutGuideCenter;
  raw_ptr<WebStateList> _webStateList;
  raw_ptr<feature_engagement::Tracker> _engagementTracker;
  SceneLayoutState* _layoutState;

  // Overlay observing.
  raw_ptr<OverlayPresenter> _webContentOverlayPresenter;
  raw_ptr<OverlayPresenter> _infobarBannerPresenter;
  raw_ptr<OverlayPresenter> _infobarModalPresenter;
  std::unique_ptr<OverlayPresenterObserver> _overlayPresenterObserver;

  // Whether the presenter is started.
  BOOL _started;

  // The fullscreen controller and disabler to block fullscreen momentarily for
  // some bubbles while they present.
  raw_ptr<FullscreenController> _fullscreenController;
  std::unique_ptr<ScopedFullscreenDisabler> _fullscreenDisabler;
  std::unique_ptr<AnimatedScopedFullscreenDisabler>
      _legacyAnimatedFullscreenDisabler;

  // List of existing bubble view presenters.
  BubbleViewControllerPresenter* _bottomToolbarTipBubblePresenter;
  BubbleViewControllerPresenter* _discoverFeedHeaderMenuTipBubblePresenter;
  BubbleViewControllerPresenter* _homeCustomizationMenuTipBubblePresenter;
  BubbleViewControllerPresenter*
      _homeBackgroundCustomizationMenuTipBubblePresenter;
  BubbleViewControllerPresenter* _readingListTipBubblePresenter;
  BubbleViewControllerPresenter* _defaultPageModeTipBubblePresenter;
  BubbleViewControllerPresenter* _whatsNewBubblePresenter;
  BubbleViewControllerPresenter*
      _priceNotificationsWhileBrowsingBubbleTipPresenter;
  BubbleViewControllerPresenter* _lensKeyboardPresenter;
  BubbleViewControllerPresenter* _lensOverlayEntrypointBubblePresenter;
  BubbleViewControllerPresenter* _settingsInOverflowMenuBubblePresenter;
  BubbleViewControllerPresenter*
      _switchAccountWithNTPIdentityDiscBubblePresenter;
  BubbleViewControllerPresenter* _feedSwipeBubblePresenter;
  BubbleViewControllerPresenter* _pageActionMenuBubblePresenter;
  BubbleViewControllerPresenter* _readerModeOptionsBubblePresenter;
  BubbleViewControllerPresenter* _geminiImageRemixBubblePresenter;
  BubbleViewControllerPresenter* _pinSiteToMostVisitedTilesBubblePresenter;
  BubbleViewControllerPresenter* _sendTabToSelfOmniboxBubblePresenter;

  // List of existing gestural IPH views.
  GestureInProductHelpView* _pullToRefreshGestureIPH;
  GestureInProductHelpView* _swipeBackForwardGestureIPH;
  ToolbarSwipeGestureInProductHelpView* _toolbarSwipeGestureIPH;
}

- (instancetype)
        initWithLayoutGuideCenter:(LayoutGuideCenter*)layoutGuideCenter
                engagementTracker:
                    (raw_ptr<feature_engagement::Tracker>)engagementTracker
                     webStateList:(raw_ptr<WebStateList>)webStateList
             fullscreenController:
                 (raw_ptr<FullscreenController>)fullscreenController
                      layoutState:(SceneLayoutState*)layoutState
    overlayPresenterForWebContent:
        (raw_ptr<OverlayPresenter>)webContentOverlayPresenter
                    infobarBanner:(raw_ptr<OverlayPresenter>)bannerPresenter
                     infobarModal:(raw_ptr<OverlayPresenter>)modalPresenter {
  self = [super init];
  if (self) {
    CHECK(webStateList);

    _layoutGuideCenter = layoutGuideCenter;
    _engagementTracker = engagementTracker;
    _webStateList = webStateList;
    _fullscreenController = fullscreenController;
    _layoutState = layoutState;

    _overlayPresenterObserver =
        std::make_unique<OverlayPresenterObserverBridge>(self);

    // Set and observe overlay presenters.
    if (webContentOverlayPresenter) {
      CHECK(webContentOverlayPresenter->GetModality() ==
            OverlayModality::kWebContentArea);
      _webContentOverlayPresenter = webContentOverlayPresenter;
      _webContentOverlayPresenter->AddObserver(_overlayPresenterObserver.get());
    }
    if (bannerPresenter) {
      CHECK(bannerPresenter->GetModality() == OverlayModality::kInfobarBanner);
      _infobarBannerPresenter = bannerPresenter;
      _infobarBannerPresenter->AddObserver(_overlayPresenterObserver.get());
    }
    if (modalPresenter) {
      CHECK(modalPresenter->GetModality() == OverlayModality::kInfobarModal);
      _infobarModalPresenter = modalPresenter;
      _infobarModalPresenter->AddObserver(_overlayPresenterObserver.get());
    }

    _started = YES;
  }
  return self;
}

- (void)disconnect {
  _started = NO;
  [self disconnectOverlayPresenters];
  _webStateList = nullptr;
  _engagementTracker = nullptr;
  self.geminiHandler = nil;
}

- (void)hideAllHelpBubbles {
  [_bottomToolbarTipBubblePresenter dismissAnimated:NO];
  [_discoverFeedHeaderMenuTipBubblePresenter dismissAnimated:NO];
  [_homeCustomizationMenuTipBubblePresenter dismissAnimated:NO];
  [_homeBackgroundCustomizationMenuTipBubblePresenter dismissAnimated:NO];
  [_readingListTipBubblePresenter dismissAnimated:NO];
  [_priceNotificationsWhileBrowsingBubbleTipPresenter dismissAnimated:NO];
  [_whatsNewBubblePresenter dismissAnimated:NO];
  [_lensKeyboardPresenter dismissAnimated:NO];
  [_defaultPageModeTipBubblePresenter dismissAnimated:NO];
  [_pageActionMenuBubblePresenter dismissAnimated:NO];
  [_readerModeOptionsBubblePresenter dismissAnimated:NO];
  [_geminiImageRemixBubblePresenter dismissAnimated:NO];
  [_pinSiteToMostVisitedTilesBubblePresenter dismissAnimated:NO];
  [self hideBubblesPointingToOmnibox];
  [self hideAllGestureInProductHelpViewsForReason:IPHDismissalReasonType::
                                                      kUnknown];
}

- (void)hideBubblesPointingToOmnibox {
  [_lensOverlayEntrypointBubblePresenter dismissAnimated:NO];
  [_sendTabToSelfOmniboxBubblePresenter dismissAnimated:NO];
}

- (void)handleTapOutsideOfVisibleGestureInProductHelp {
  [self hideAllGestureInProductHelpViewsForReason:
            IPHDismissalReasonType::kTappedOutsideIPHAndAnchorView];
}

- (void)handleToolbarSwipeGesture {
  [_toolbarSwipeGestureIPH
      dismissWithReason:IPHDismissalReasonType::
                            kSwipedAsInstructedByGestureIPH];
}

#pragma mark - Bubble presenter methods

- (void)presentDiscoverFeedMenuTipBubble {
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_DISCOVER_FEED_HEADER_IPH);

  UIView* menuButton =
      [_layoutGuideCenter referencedViewUnderName:kFeedIPHNamedGuide];
  // Checks "canPresentBubble" after checking that the NTP with feed is visible.
  // This ensures that the feature tracker doesn't trigger the IPH event if the
  // bubble isn't shown, which would prevent it from ever being shown again.
  if (!menuButton || ![self canPresentBubble]) {
    return;
  }
  CGPoint discoverFeedMenuAnchor =
      [menuButton.superview convertPoint:menuButton.frame.origin toView:nil];

  // Slightly move IPH to ensure that the bubble doesn't bleed out the screen.
  discoverFeedMenuAnchor.x += menuButton.frame.size.width / 2;
  discoverFeedMenuAnchor.y += menuButton.frame.size.height;

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing `discoverFeedHeaderMenuTipBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHDiscoverFeedHeaderFeature
                    direction:BubbleArrowDirectionUp
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:discoverFeedMenuAnchor];
  if (!presenter) {
    return;
  }

  _discoverFeedHeaderMenuTipBubblePresenter = presenter;
}

- (void)presentHomeCustomizationTipBubble {
  NSString* text =
      l10n_util::GetNSStringWithFixup(IDS_IOS_HOME_CUSTOMIZATION_IPH);

  UIView* menuButton =
      [_layoutGuideCenter referencedViewUnderName:kFeedIPHNamedGuide];
  // Checks "canPresentBubble" after checking that the NTP with feed is visible.
  // This ensures that the feature tracker doesn't trigger the IPH event if the
  // bubble isn't shown, which would prevent it from ever being shown again.
  if (!menuButton || ![self canPresentBubble]) {
    return;
  }
  CGPoint customizationMenuAnchor =
      [menuButton.superview convertPoint:menuButton.frame.origin toView:nil];

  // Slightly move IPH to ensure that the bubble doesn't bleed out the screen.
  customizationMenuAnchor.x += menuButton.frame.size.width / 2;
  customizationMenuAnchor.y += menuButton.frame.size.height;

  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:feature_engagement::
                                        kIPHHomeCustomizationMenuFeature
                          direction:BubbleArrowDirectionUp
                          alignment:BubbleAlignmentTopOrLeading
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:customizationMenuAnchor];
  if (!presenter) {
    return;
  }

  _homeCustomizationMenuTipBubblePresenter = presenter;
}

- (void)presentHomeBackgroundCustomizationTipBubble {
  // Scroll the NTP to top to show the IPH on the home customization button.
  [self.delegate scrollNTPToTopForBubblePresenter:self];

  NSString* text = l10n_util::GetNSStringWithFixup(
      IDS_IOS_HOME_BACKGROUND_CUSTOMIZATION_IPH);

  UIView* menuButton =
      [_layoutGuideCenter referencedViewUnderName:kFeedIPHNamedGuide];
  // Checks "canPresentBubble" after checking that the NTP with feed is visible.
  // This ensures that the feature tracker doesn't trigger the IPH event if the
  // bubble isn't shown, which would prevent it from ever being shown again.
  if (!menuButton || ![self canPresentBubble]) {
    return;
  }
  CGPoint customizationMenuAnchor =
      [menuButton.superview convertPoint:menuButton.frame.origin toView:nil];

  // Slightly move IPH to ensure that the bubble doesn't bleed out the screen.
  customizationMenuAnchor.x += menuButton.frame.size.width / 2;
  customizationMenuAnchor.y += menuButton.frame.size.height;

  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:
                feature_engagement::kIPHiOSPromoBackgroundCustomizationFeature
                          direction:BubbleArrowDirectionUp
                          alignment:BubbleAlignmentTopOrLeading
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:customizationMenuAnchor];
  if (!presenter) {
    return;
  }

  _homeBackgroundCustomizationMenuTipBubblePresenter = presenter;
}

- (void)presentDefaultSiteViewTipBubbleWithSettingsMap:
            (raw_ptr<HostContentSettingsMap>)settingsMap
                                      popupMenuHandler:(id<PopupMenuCommands>)
                                                           popupMenuHandler {
  if (![self canPresentBubble]) {
    return;
  }
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!currentWebState || ShouldLoadUrlInDesktopMode(
                              currentWebState->GetVisibleURL(), settingsMap)) {
    return;
  }

  BubbleArrowDirection arrowDirection;
  if (IsChromeNextIaEnabled()) {
    arrowDirection = [self isBottomOmnibox] ? BubbleArrowDirectionDown
                                            : BubbleArrowDirectionUp;

  } else {
    arrowDirection = IsSplitToolbarMode(self.rootViewController)
                         ? BubbleArrowDirectionDown
                         : BubbleArrowDirectionUp;
  }
  NSString* text = l10n_util::GetNSString(IDS_IOS_DEFAULT_PAGE_MODE_TIP);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing presenter to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHDefaultSiteViewFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentBottomOrTrailing
                         text:text
        voiceOverAnnouncement:l10n_util::GetNSString(
                                  IDS_IOS_DEFAULT_PAGE_MODE_TIP_VOICE_OVER)
                  anchorPoint:toolsMenuAnchor];
  if (!presenter) {
    return;
  }
  [popupMenuHandler notifyIPHBubblePresenting];
  _defaultPageModeTipBubblePresenter = presenter;
}

- (void)presentWhatsNewBottomToolbarBubbleWithPopupMenuHandler:
    (id<PopupMenuCommands>)popupMenuHandler {
  if (![self canPresentBubble]) {
    return;
  }
  BubbleArrowDirection arrowDirection;
  BubbleAlignment alignment;
  if (IsChromeNextIaEnabled()) {
    arrowDirection = [self isGuideAtBottom:kToolsMenuGuide]
                         ? BubbleArrowDirectionDown
                         : BubbleArrowDirectionUp;
    alignment = [self isGuideAtLeading:kToolsMenuGuide]
                    ? BubbleAlignmentTopOrLeading
                    : BubbleAlignmentBottomOrTrailing;
  } else {
    arrowDirection = IsSplitToolbarMode(self.rootViewController)
                         ? BubbleArrowDirectionDown
                         : BubbleArrowDirectionUp;
    alignment = BubbleAlignmentBottomOrTrailing;
  }
  NSString* text = l10n_util::GetNSString(IDS_IOS_WHATS_NEW_IPH_TEXT);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing `whatsNewBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHWhatsNewFeature
                    direction:arrowDirection
                    alignment:alignment
                         text:text
        voiceOverAnnouncement:l10n_util::GetNSString(IDS_IOS_WHATS_NEW_IPH_TEXT)
                  anchorPoint:toolsMenuAnchor];
  if (presenter) {
    [popupMenuHandler notifyIPHBubblePresenting];
    _whatsNewBubblePresenter = presenter;
  }
}

- (void)presentPriceNotificationsWhileBrowsingTipBubbleWithPopupMenuHandler:
    (id<PopupMenuCommands>)popupMenuHandler {
  if (![self canPresentBubble]) {
    return;
  }
  BubbleArrowDirection arrowDirection =
      IsSplitToolbarMode(self.rootViewController) ? BubbleArrowDirectionDown
                                                  : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(
      IDS_IOS_PRICE_NOTIFICATIONS_PRICE_TRACK_TOAST_IPH_TEXT);
  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the tip, then end early to prevent the potential reassignment of the
  // existing `whatsNewBubblePresenter` to nil.
  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:
                feature_engagement::kIPHPriceNotificationsWhileBrowsingFeature
                          direction:arrowDirection
                          alignment:BubbleAlignmentBottomOrTrailing
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:toolsMenuAnchor];
  if (presenter) {
    [popupMenuHandler notifyIPHBubblePresenting];
    _priceNotificationsWhileBrowsingBubbleTipPresenter = presenter;
  }
}

- (void)presentLensKeyboardTipBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  BubbleArrowDirection arrowDirection = BubbleArrowDirectionDown;
  NSString* text = l10n_util::GetNSString(IDS_IOS_LENS_KEYBOARD_IPH_TEXT);
  CGPoint lensButtonAnchor = [self anchorPointToGuide:kLensKeyboardButtonGuide
                                            direction:arrowDirection];

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHiOSLensKeyboardFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:lensButtonAnchor];
  if (presenter) {
    _lensKeyboardPresenter = presenter;
  }
}

- (void)presentLensOverlayTipBubble {
  BOOL checkTabScrolledToTop = YES;
  if (IsChromeNextIaEnabled() && ![self isBottomOmnibox]) {
    checkTabScrolledToTop = NO;
  }
  if (![self canPresentBubbleWithCheckTabScrolledToTop:checkTabScrolledToTop]) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  BubbleArrowDirection arrowDirection =
      [self isGuideAtBottom:kLensOverlayEntrypointGuide]
          ? BubbleArrowDirectionDown
          : BubbleArrowDirectionUp;
  NSString* text = l10n_util::GetNSString(IDS_IOS_LENS_OVERLAY_TOOLTIP_TEXT);

  CGPoint lensOverlayEntrypointAnchor =
      [self anchorPointToGuide:kLensOverlayEntrypointGuide
                     direction:arrowDirection];

  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:feature_engagement::
                                        kIPHiOSLensOverlayEntrypointTipFeature
                          direction:arrowDirection
                          alignment:BubbleAlignmentTopOrLeading
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:CGPoint(lensOverlayEntrypointAnchor.x,
                                            lensOverlayEntrypointAnchor.y)];

  if (presenter) {
    _lensOverlayEntrypointBubblePresenter = presenter;
  }
}

- (void)presentOverflowMenuSettingsBubble {
  if (![self canPresentBubble]) {
    return;
  }

  // Only show on top of the NTP:
  // If the user had opened the account menu from the NTP, chances are they were
  // looking for settings (because that's where the account particle disc used
  // to link), so this IPH will help them.
  // If they had opened the account menu from anywhere else, there's no
  // connection to settings, and so this IPH wouldn't make sense.
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  BubbleArrowDirection arrowDirection;
  BubbleAlignment alignment;
  if (IsChromeNextIaEnabled()) {
    arrowDirection = [self isGuideAtBottom:kToolsMenuGuide]
                         ? BubbleArrowDirectionDown
                         : BubbleArrowDirectionUp;
    alignment = [self isGuideAtLeading:kToolsMenuGuide]
                    ? BubbleAlignmentTopOrLeading
                    : BubbleAlignmentBottomOrTrailing;
  } else {
    arrowDirection = IsSplitToolbarMode(self.rootViewController)
                         ? BubbleArrowDirectionDown
                         : BubbleArrowDirectionUp;
    alignment = BubbleAlignmentBottomOrTrailing;
  }
  NSString* text =
      l10n_util::GetNSString(IDS_IOS_SETTINGS_IN_OVERFLOW_MENU_IPH_TEXT);

  CGPoint toolsMenuAnchor = [self anchorPointToGuide:kToolsMenuGuide
                                           direction:arrowDirection];

  // If the feature engagement tracker does not consider it valid to display
  // the IPH, then end early to prevent the potential reassignment of the
  // existing presenter to nil.
  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:
                feature_engagement::kIPHiOSSettingsInOverflowMenuBubbleFeature
                          direction:arrowDirection
                          alignment:alignment
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:toolsMenuAnchor];
  if (presenter) {
    _settingsInOverflowMenuBubblePresenter = presenter;
  }
}

- (void)presentSwitchAccountsWithNTPAccountParticleDiscBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:YES]) {
    return;
  }

  // Only show if the user has previously used a web-triggered flow to switch
  // accounts in Chrome.
  // Note: This condition can't be handled by the `feature_engagement::Tracker`
  // internally, because it's per-device while the tracker is per-profile.
  if (!GetApplicationContext()->GetLocalState()->GetBoolean(
          prefs::kHasSwitchedAccountsViaWebFlow)) {
    return;
  }

  BubbleArrowDirection arrowDirection = BubbleArrowDirectionUp;

  CGPoint identityDiscAnchor =
      [self anchorPointToGuide:kNTPIdentityDiscButtonGuide
                     direction:arrowDirection];

  // The identity disc button is slightly larger than it visually appears, so
  // move the bubble a bit closer.
  CGFloat anchorYOffset = -8;

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:
          feature_engagement::
              kIPHiOSSwitchAccountsWithNTPAccountParticleDiscFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentBottomOrTrailing
                         text:l10n_util::GetNSString(
                                  IDS_IOS_SWITCH_ACCOUNTS_IPH_MESSAGE)
        voiceOverAnnouncement:
            l10n_util::GetNSString(
                IDS_IOS_SWITCH_ACCOUNTS_IPH_ACCESSIBILITY_LABEL)
                  anchorPoint:CGPoint(identityDiscAnchor.x,
                                      identityDiscAnchor.y + anchorYOffset)];
  if (presenter) {
    _switchAccountWithNTPIdentityDiscBubblePresenter = presenter;
  }
}

- (void)presentPinSiteToMostVisitedTilesBubble {
  if (![self canPresentBubble]) {
    return;
  }
  NSString* text = l10n_util::GetNSString(
      IDS_IOS_CONTENT_SUGGESTIONS_PIN_SITE_IN_PRODUCT_HELP);
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::
                                  kIPHiOSPinMostVisitedSiteFeature
                    direction:BubbleArrowDirectionUp
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:
                      [self anchorPointToGuide:kNTPFirstMostVisitedTileGuide
                                     direction:BubbleArrowDirectionUp]];
  if (presenter) {
    _pinSiteToMostVisitedTilesBubblePresenter = presenter;
  }
}

- (void)
    presentPullToRefreshGestureInProductHelpWithDeviceSwitcherResultDispatcher:
        (raw_ptr<segmentation_platform::DeviceSwitcherResultDispatcher>)
            deviceSwitcherResultDispatcher {
  if (UIAccessibilityIsVoiceOverRunning() ||
      (![self.delegate isOverscrollActionsSupportedForBubblePresenter:self]) ||
      (![self canPresentBubble])) {
    return;
  }
  const base::Feature& pullToRefreshFeature =
      feature_engagement::kIPHiOSPullToRefreshFeature;
  BOOL userEligibleForPullToRefreshIPH =
      deviceSwitcherResultDispatcher &&
      IsUserNewSafariSwitcher(deviceSwitcherResultDispatcher) &&
      _engagementTracker->WouldTriggerHelpUI(pullToRefreshFeature);
  if (!userEligibleForPullToRefreshIPH) {
    return;
  }
  NSString* text = l10n_util::GetNSString(IDS_IOS_PULL_TO_REFRESH_IPH);
  _pullToRefreshGestureIPH =
      [self presentGestureInProductHelpForFeature:pullToRefreshFeature
                                   swipeDirection:
                                       UISwipeGestureRecognizerDirectionDown
                                             text:text];
  [_pullToRefreshGestureIPH startAnimation];
}

- (void)presentBackForwardSwipeGestureInProductHelp {
  if (UIAccessibilityIsVoiceOverRunning() ||
      (![self canPresentBubbleWithCheckTabScrolledToTop:NO])) {
    return;
  }
  const base::Feature& backForwardSwipeFeature =
      feature_engagement::kIPHiOSSwipeBackForwardFeature;
  if (!_engagementTracker->WouldTriggerHelpUI(backForwardSwipeFeature)) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  // Retrieve swipe-able directions.
  const web::NavigationManager* navigationManager =
      currentWebState->GetNavigationManager();
  BOOL back = navigationManager->CanGoBack();
  BOOL forward = navigationManager->CanGoForward();
  if (!back && !forward) {
    return;
  }
  int textId = IDS_IOS_BACK_FORWARD_SWIPE_IPH_BACK_ONLY;
  if (forward) {
    textId = back ? IDS_IOS_BACK_FORWARD_SWIPE_IPH
                  : IDS_IOS_BACK_FORWARD_SWIPE_IPH_FORWARD_ONLY;
  }

  UISwipeGestureRecognizerDirection direction =
      back ^ UseRTLLayout() ? UISwipeGestureRecognizerDirectionRight
                            : UISwipeGestureRecognizerDirectionLeft;
  _swipeBackForwardGestureIPH = [self
      presentGestureInProductHelpForFeature:backForwardSwipeFeature
                             swipeDirection:direction
                                       text:l10n_util::GetNSString(textId)];
  _swipeBackForwardGestureIPH.edgeSwipe = YES;
  if (back && forward) {
    _swipeBackForwardGestureIPH.animationRepeatCount = 4;
    _swipeBackForwardGestureIPH.bidirectional = YES;
  }
  [_swipeBackForwardGestureIPH startAnimation];
}

- (void)presentToolbarSwipeGestureInProductHelp {
  // Inapplicable on iPad.
  if (ui::GetDeviceFormFactor() !=
          ui::DeviceFormFactor::DEVICE_FORM_FACTOR_PHONE ||
      UIAccessibilityIsVoiceOverRunning() ||
      (![self canPresentBubbleWithCheckTabScrolledToTop:NO])) {
    return;
  }
  const base::Feature& feature =
      feature_engagement::kIPHiOSSwipeToolbarToChangeTabFeature;
  if (!_engagementTracker->WouldTriggerHelpUI(feature)) {
    return;
  }
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  // Check index to determine which directions are supported.
  int activeIndex = _webStateList->active_index();
  BOOL canGoBack = activeIndex > 0;
  BOOL canGoForward = activeIndex < _webStateList->count() - 1;
  if (!canGoBack && !canGoForward) {
    return;
  }
  // Setup view constraints.
  NamedGuide* contentAreaGuide =
      [NamedGuide guideWithName:kContentAreaGuide
                           view:self.rootViewController.view];
  if (!contentAreaGuide) {
    return;
  }
  UILayoutGuide* guide = [[UILayoutGuide alloc] init];
  [self.rootViewController.view addLayoutGuide:guide];
  AddSameConstraintsToSides(guide, contentAreaGuide,
                            LayoutSides::kBottom | LayoutSides::kHorizontal);
  NSLayoutConstraint* topConstraintForBottomEdgeSwipe = [guide.topAnchor
      constraintEqualToAnchor:self.rootViewController.view.topAnchor];
  NSLayoutConstraint* topConstraintForTopEdgeSwipe =
      [guide.topAnchor constraintEqualToAnchor:contentAreaGuide.topAnchor];
  NSLayoutConstraint* initialTopConstraint =
      self.rootViewController.traitCollection.verticalSizeClass ==
              UIUserInterfaceSizeClassRegular
          ? topConstraintForBottomEdgeSwipe
          : topConstraintForTopEdgeSwipe;
  initialTopConstraint.active = YES;

  // Configure IPH view.
  ToolbarSwipeGestureInProductHelpView* toolbarSwipeGestureIPH =
      [[ToolbarSwipeGestureInProductHelpView alloc]
          initWithBubbleBoundingSize:guide.layoutFrame.size
                           canGoBack:canGoBack
                             forward:canGoForward];
  [toolbarSwipeGestureIPH setTranslatesAutoresizingMaskIntoConstraints:NO];
  if (!CanGestureInProductHelpViewFitInGuide(toolbarSwipeGestureIPH, guide) ||
      !_engagementTracker->ShouldTriggerHelpUI(feature)) {
    return;
  }
  toolbarSwipeGestureIPH.topConstraintForBottomEdgeSwipe =
      topConstraintForBottomEdgeSwipe;
  toolbarSwipeGestureIPH.topConstraintForTopEdgeSwipe =
      topConstraintForTopEdgeSwipe;
  toolbarSwipeGestureIPH.delegate = self;
  [self.rootViewController.view addSubview:toolbarSwipeGestureIPH];
  AddSameConstraints(toolbarSwipeGestureIPH, guide);
  if (IsPageActionMenuEnabled()) {
    [self.geminiHandler
        hideFloatyIfInvokedAnimated:NO
                         fromSource:gemini::FloatyUpdateSource::GestureIph];
  }
  [toolbarSwipeGestureIPH startAnimation];
  _toolbarSwipeGestureIPH = toolbarSwipeGestureIPH;
}

- (void)presentFeedSwipeGestureInProductHelp {
  // TODO(crbug.com/402803175): Present animated IPH.
}

- (void)presentFeedSwipeBubble {
  if (![self canPresentBubble]) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  NSString* text = l10n_util::GetNSString(IDS_IOS_FEED_SWIPE_IPH);

  CGPoint toolbarAnchor = [self anchorPointToGuide:kSecondaryToolbarGuide
                                         direction:BubbleArrowDirectionDown];

  // Tip bubble should present slightly above the toolbar/bottom of screen and
  // not on the toolbar itself.
  CGFloat anchorYOffset = -25;

  // If the feature engagement tracker does not consider it valid to display
  // the IPH, then end early to prevent the potential reassignment of the
  // existing presenter to nil.
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHiOSFeedSwipeStaticFeature
                    direction:BubbleArrowDirectionDown
                    alignment:BubbleAlignmentCenter
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:CGPoint(toolbarAnchor.x,
                                      toolbarAnchor.y + anchorYOffset)];
  if (presenter) {
    _feedSwipeBubblePresenter = presenter;
  }
}

- (void)presentPageActionMenuBubbleForFeature:(const base::Feature&)feature {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  BOOL nextIAEnabled = IsChromeNextIaEnabled();

  GuideName* anchorGuide;
  BubbleArrowDirection arrowDirection;
  BubbleAlignment alignment = BubbleAlignmentTopOrLeading;

  if (nextIAEnabled) {
    // In Next IA, the Assistant button is located in the App Bar.
    anchorGuide = kAppBarAssistantButtonGuide;
    switch (_layoutState.appBarPosition) {
      case AppBarPosition::kLeft:
        // AppBar position is actual left/right, not leading/trailing, so RTL
        // must be handled specially.
        arrowDirection = UseRTLLayout() ? BubbleArrowDirectionTrailing
                                        : BubbleArrowDirectionLeading;
        break;
      case AppBarPosition::kRight:
        // AppBar position is actual left/right, not leading/trailing, so RTL
        // must be handled specially.
        arrowDirection = UseRTLLayout() ? BubbleArrowDirectionLeading
                                        : BubbleArrowDirectionTrailing;
        break;
      case AppBarPosition::kBottom:
        arrowDirection = BubbleArrowDirectionDown;
        break;
      case AppBarPosition::kNone:
        // No App Bar position means the app is on iPad, where the Assistant
        // Button is in the toolbar.
        arrowDirection = BubbleArrowDirectionUp;
        alignment = BubbleAlignmentBottomOrTrailing;
        break;
    }
  } else {
    anchorGuide = kPageActionMenuEntrypointGuide;
    arrowDirection = [self isGuideAtBottom:anchorGuide]
                         ? BubbleArrowDirectionDown
                         : BubbleArrowDirectionUp;
  }

  UIView* anchorView = [_layoutGuideCenter referencedViewUnderName:anchorGuide];
  CGRect anchorFrameInWindow = [anchorView convertRect:anchorView.bounds
                                                toView:nil];
  CGPoint anchorPointInWindow =
      bubble_util::AnchorPoint(anchorFrameInWindow, arrowDirection);

  NSString* text = l10n_util::GetNSString(IDS_IOS_BWG_IPH_TEXT);

  __weak __typeof(self) weakSelf = self;
  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature
      direction:arrowDirection
      alignment:alignment
      text:text
      voiceOverAnnouncement:text
      anchorPoint:anchorPointInWindow
      anchorViewFrame:anchorFrameInWindow
      presentAction:^{
        [weakSelf.pageActionMenuEntryPointHandler
            toggleEntryPointHighlight:YES];
      }
      dismissAction:^(IPHDismissalReasonType reason) {
        [weakSelf.pageActionMenuEntryPointHandler toggleEntryPointHighlight:NO];
      }];

  if (presenter &&
      feature.name == feature_engagement::kIPHIOSPageActionMenu.name) {
    _pageActionMenuBubblePresenter = presenter;
  }
}

- (void)presentReaderModeOptionsBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!currentWebState || IsUrlNtp(currentWebState->GetVisibleURL())) {
    return;
  }

  BubbleArrowDirection arrowDirection =
      [self isGuideAtBottom:kReaderModeOptionsEntrypointGuide]
          ? BubbleArrowDirectionDown
          : BubbleArrowDirectionUp;
  NSString* text =
      l10n_util::GetNSString(IDS_IOS_READER_MODE_OPTIONS_IPH_DESCRIPTION);

  CGPoint readerModeOptionsAnchor =
      [self anchorPointToGuide:kReaderModeOptionsEntrypointGuide
                     direction:arrowDirection];

  // An adjusted x offset to ensure that the bubble frame is on-screen.
  CGFloat anchorXOffset = UseRTLLayout() ? -38 : 38;

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::
                                  kIPHiOSReaderModeOptionsFeature
                    direction:arrowDirection
                    alignment:BubbleAlignmentTopOrLeading
                         text:text
        voiceOverAnnouncement:text
                  anchorPoint:CGPoint(readerModeOptionsAnchor.x + anchorXOffset,
                                      readerModeOptionsAnchor.y)];

  if (presenter) {
    _readerModeOptionsBubblePresenter = presenter;
  }
}

- (void)presentSendTabToSelfOmniboxBubble {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  BOOL isBottomOmnibox = [self isBottomOmnibox];
  BubbleArrowDirection arrowDirection =
      isBottomOmnibox ? BubbleArrowDirectionDown : BubbleArrowDirectionUp;
  GuideName* guideName =
      isBottomOmnibox ? kSecondaryToolbarGuide : kTopOmniboxGuide;
  NSString* text =
      l10n_util::GetNSString(IDS_SEND_TAB_TO_SELF_OMNIBOX_IPH_TEXT);

  CGPoint omniboxAnchor = [self anchorPointToGuide:guideName
                                         direction:arrowDirection];

  BubbleViewControllerPresenter* presenter =
      [self presentBubbleForFeature:feature_engagement::kIPHSendTabToSelfOmnibox
                          direction:arrowDirection
                          alignment:BubbleAlignmentCenter
                               text:text
              voiceOverAnnouncement:text
                        anchorPoint:omniboxAnchor];
  if (presenter) {
    _sendTabToSelfOmniboxBubblePresenter = presenter;
  }
}

- (void)presentGeminiImageRemixBubbleWithGeminiHandler:
            (id<GeminiCommands>)geminiHandler
                       pageActionMenuEntryPointHandler:
                           (id<PageActionMenuEntryPointCommands>)
                               pageActionMenuEntryPointHandler {
  if (![self canPresentBubbleWithCheckTabScrolledToTop:NO]) {
    return;
  }

  BOOL nextIAEnabled = IsChromeNextIaEnabled();
  BubbleArrowDirection arrowDirection;
  BubbleAlignment alignment = BubbleAlignmentTopOrLeading;
  if (nextIAEnabled) {
    switch (_layoutState.appBarPosition) {
      case AppBarPosition::kLeft:
        arrowDirection = BubbleArrowDirectionLeading;
        break;
      case AppBarPosition::kRight:
        arrowDirection = BubbleArrowDirectionTrailing;
        break;
      case AppBarPosition::kBottom:
        arrowDirection = BubbleArrowDirectionDown;
        break;
      case AppBarPosition::kNone:
        // No App Bar position means the app is on iPad, where the Assistant
        // Button is in the toolbar.
        arrowDirection = BubbleArrowDirectionUp;
        alignment = BubbleAlignmentBottomOrTrailing;
        break;
    }
  } else {
    arrowDirection = [self isGuideAtBottom:kPageActionMenuEntrypointGuide]
                         ? BubbleArrowDirectionDown
                         : BubbleArrowDirectionUp;
  }
  NSString* text =
      l10n_util::GetNSString(IDS_IOS_GEMINI_IMAGE_REMIX_ENTRY_POINT_IPH);

  GuideName* anchorGuide = nextIAEnabled ? kAppBarAssistantButtonGuide
                                         : kPageActionMenuEntrypointGuide;

  CGPoint pageActionMenuEntrypointAnchor =
      [self anchorPointToGuide:anchorGuide direction:arrowDirection];

  BubbleViewControllerPresenter* presenter = [self
      presentBubbleForFeature:feature_engagement::kIPHiOSGeminiImageRemixFeature
      direction:arrowDirection
      alignment:alignment
      text:text
      voiceOverAnnouncement:text
      anchorPoint:CGPoint(pageActionMenuEntrypointAnchor.x,
                          pageActionMenuEntrypointAnchor.y)
      anchorViewFrame:[self anchorViewFrameForGuide:anchorGuide]
      presentAction:^{
        if (!nextIAEnabled) {
          [pageActionMenuEntryPointHandler toggleEntryPointHighlight:YES];
        }
      }
      dismissAction:^(IPHDismissalReasonType reason) {
        if (!nextIAEnabled) {
          [pageActionMenuEntryPointHandler toggleEntryPointHighlight:NO];
        }
        base::UmaHistogramEnumeration(
            "IOS.Gemini.ImageRemix.IPH.DismissalReason", reason);

        if (reason == IPHDismissalReasonType::kTappedIPH ||
            reason == IPHDismissalReasonType::kTappedAnchorView) {
          base::RecordAction(
              base::UserMetricsAction("MobileGeminiImageRemixIPHTapped"));
          [geminiHandler
              startGeminiEntryFlowWithStartupState:
                  [[GeminiStartupState alloc]
                      initWithEntryPoint:gemini::EntryPoint::ImageRemixIPH]
                                baseViewController:self.rootViewController
                          showSnackbarOnCompletion:YES
                                        completion:nil];
        }
      }];

  if (presenter) {
    _geminiImageRemixBubblePresenter = presenter;
  }
}

#pragma mark - GestureInProductHelpViewDelegate

- (void)gestureInProductHelpView:(GestureInProductHelpView*)view
            didDismissWithReason:(IPHDismissalReasonType)reason {
  std::string dismissButtonTappedEvent;
  if (view == _pullToRefreshGestureIPH) {
    dismissButtonTappedEvent =
        feature_engagement::events::kIOSPullToRefreshIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::kIPHiOSPullToRefreshFeature];
  } else if (view == _swipeBackForwardGestureIPH) {
    dismissButtonTappedEvent =
        feature_engagement::events::kIOSSwipeBackForwardIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::kIPHiOSSwipeBackForwardFeature];
  } else if (view == _toolbarSwipeGestureIPH) {
    dismissButtonTappedEvent = feature_engagement::events::
        kIOSSwipeToolbarToChangeTabIPHDismissButtonTapped;
    [self featureDismissed:feature_engagement::
                               kIPHiOSSwipeToolbarToChangeTabFeature];
  } else {
    NOTREACHED();
  }
  if (reason == IPHDismissalReasonType::kTappedClose && _engagementTracker &&
      !dismissButtonTappedEvent.empty()) {
    _engagementTracker->NotifyEvent(dismissButtonTappedEvent);
  }
  if (IsPageActionMenuEnabled()) {
    [self.geminiHandler
        updateFloatyVisibilityIfEligibleAnimated:NO
                                      fromSource:gemini::FloatyUpdateSource::
                                                     GestureIph];
  }
}

- (void)gestureInProductHelpView:(GestureInProductHelpView*)view
    shouldHandleSwipeInDirection:(UISwipeGestureRecognizerDirection)direction {
  if (view == _pullToRefreshGestureIPH) {
    [self.delegate bubblePresenterDidPerformPullToRefreshGesture:self];
  } else if (view == _swipeBackForwardGestureIPH) {
    [self.delegate bubblePresenter:self
        didPerformSwipeToNavigateInDirection:direction];
  } else if (view == _toolbarSwipeGestureIPH) {
    // Do nothing. Swipe happens outside of the view.
  } else {
    NOTREACHED();
  }
}

#pragma mark - OverlayPresenterObserving

- (void)overlayPresenter:(OverlayPresenter*)presenter
    willShowOverlayForRequest:(OverlayRequest*)request
          initialPresentation:(BOOL)initialPresentation {
  [self hideAllHelpBubbles];
}

- (void)overlayPresenterDestroyed:(OverlayPresenter*)presenter {
  switch (presenter->GetModality()) {
    case OverlayModality::kWebContentArea:
      CHECK_EQ(presenter, _webContentOverlayPresenter);
      _webContentOverlayPresenter = nullptr;
      break;
    case OverlayModality::kInfobarBanner:
      CHECK_EQ(presenter, _infobarBannerPresenter);
      _infobarBannerPresenter = nullptr;
      break;
    case OverlayModality::kInfobarModal:
      CHECK_EQ(presenter, _infobarModalPresenter);
      _infobarModalPresenter = nullptr;
      break;
    case OverlayModality::kTesting:
      NOTREACHED();
  }
}

#pragma mark - Private

// Convenience method that calls -presentBubbleForFeature with default param
// values for `alignment`, `presentAction`, and `dismissAction`.
- (BubbleViewControllerPresenter*)
    presentBubbleForFeature:(const base::Feature&)feature
                  direction:(BubbleArrowDirection)direction
                  alignment:(BubbleAlignment)alignment
                       text:(NSString*)text
      voiceOverAnnouncement:(NSString*)voiceOverAnnouncement
                anchorPoint:(CGPoint)anchorPoint {
  return [self presentBubbleForFeature:feature
                             direction:direction
                             alignment:alignment
                                  text:text
                 voiceOverAnnouncement:voiceOverAnnouncement
                           anchorPoint:anchorPoint
                         presentAction:nil
                         dismissAction:nil];
}

// Convenience method that calls -presentBubbleForFeature with a CGRectZero
// `anchorViewFrame`.
- (BubbleViewControllerPresenter*)
    presentBubbleForFeature:(const base::Feature&)feature
                  direction:(BubbleArrowDirection)direction
                  alignment:(BubbleAlignment)alignment
                       text:(NSString*)text
      voiceOverAnnouncement:(NSString*)voiceOverAnnouncement
                anchorPoint:(CGPoint)anchorPoint
              presentAction:(ProceduralBlock)presentAction
              dismissAction:(void (^)(IPHDismissalReasonType))dismissAction {
  return [self presentBubbleForFeature:feature
                             direction:direction
                             alignment:alignment
                                  text:text
                 voiceOverAnnouncement:voiceOverAnnouncement
                           anchorPoint:anchorPoint
                       anchorViewFrame:CGRectZero
                         presentAction:presentAction
                         dismissAction:dismissAction];
}

// Presents and returns a bubble view controller for the `feature` with an arrow
// `direction`, an arrow `alignment` and a `text` on an `anchorPoint`.
- (BubbleViewControllerPresenter*)
    presentBubbleForFeature:(const base::Feature&)feature
                  direction:(BubbleArrowDirection)direction
                  alignment:(BubbleAlignment)alignment
                       text:(NSString*)text
      voiceOverAnnouncement:(NSString*)voiceOverAnnouncement
                anchorPoint:(CGPoint)anchorPoint
            anchorViewFrame:(CGRect)anchorViewFrame
              presentAction:(ProceduralBlock)presentAction
              dismissAction:(void (^)(IPHDismissalReasonType))dismissAction {
  DCHECK(_engagementTracker);
  BubbleViewControllerPresenter* presenter =
      [self bubblePresenterForFeature:feature
                            direction:direction
                            alignment:alignment
                                 text:text
                        dismissAction:dismissAction];
  if (!presenter) {
    return nil;
  }
  presenter.voiceOverAnnouncement = voiceOverAnnouncement;
  if ([presenter canPresentInView:self.rootViewController.view
                      anchorPoint:anchorPoint] &&
      ([self shouldForcePresentBubbleForFeature:feature] ||
       _engagementTracker->ShouldTriggerHelpUI(feature))) {
    if ([self shouldDisableFullscreenForFeature:feature]) {
      [self startAnimatedFullscreenDisabler];
    }
    [presenter presentInViewController:self.rootViewController
                           anchorPoint:anchorPoint
                       anchorViewFrame:anchorViewFrame];
    if (presentAction) {
      presentAction();
    }
  }
  return presenter;
}

// If any gesture IPH visible, remove it and log the `reason` why it should be
// removed on UMA. Otherwise, do nothing. The presenter of any gesture IPH
// should make sure it's called when the user leaves the refreshed website,
// especially while the IPH is still visible.
- (void)hideAllGestureInProductHelpViewsForReason:
    (IPHDismissalReasonType)reason {
  [_pullToRefreshGestureIPH dismissWithReason:reason];
  [_swipeBackForwardGestureIPH dismissWithReason:reason];
  [_toolbarSwipeGestureIPH dismissWithReason:reason];
}

#pragma mark - Private Utils

// Returns whether the guide is in the bottom half of the root view.
- (BOOL)isGuideAtBottom:(GuideName*)guideName {
  CGRect frameInWindow = [self anchorViewFrameForGuide:guideName];
  if (CGRectIsEmpty(frameInWindow)) {
    return NO;
  }
  CGRect frameInView = [self.rootViewController.view convertRect:frameInWindow
                                                        fromView:nil];
  CGFloat viewHeight = self.rootViewController.view.bounds.size.height;
  return CGRectGetMidY(frameInView) > viewHeight / 2;
}

// Returns whether the guide is in the leading half of the root view.
- (BOOL)isGuideAtLeading:(GuideName*)guideName {
  CGRect frameInWindow = [self anchorViewFrameForGuide:guideName];
  if (CGRectIsEmpty(frameInWindow)) {
    return NO;
  }
  CGRect frameInView = [self.rootViewController.view convertRect:frameInWindow
                                                        fromView:nil];
  CGFloat viewWidth = self.rootViewController.view.bounds.size.width;
  bool isRTL = base::i18n::IsRTL();
  return isRTL ? (CGRectGetMidX(frameInView) > viewWidth / 2)
               : (CGRectGetMidX(frameInView) < viewWidth / 2);
}

// Returns the anchor point for a bubble with an `arrowDirection` pointing to a
// `guideName`. The point is in the window coordinates.
- (CGPoint)anchorPointToGuide:(GuideName*)guideName
                    direction:(BubbleArrowDirection)arrowDirection {
  UILayoutGuide* guide = [_layoutGuideCenter makeLayoutGuideNamed:guideName];
  DCHECK(guide);
  [self.rootViewController.view addLayoutGuide:guide];

  CGPoint anchorPoint =
      bubble_util::AnchorPoint(guide.layoutFrame, arrowDirection);

  UIView* rootView = self.rootViewController.view;
  CGPoint anchorPointInRootView = [guide.owningView convertPoint:anchorPoint
                                                          toView:rootView];

  const CGFloat kEdgeThreshold =
      bubble_util::BubbleDefaultAlignmentOffset() + kAdditionalBorderMargin;

  if (arrowDirection == BubbleArrowDirectionUp ||
      arrowDirection == BubbleArrowDirectionDown) {
    CGFloat leeway = MIN(kMaxLeeway, guide.layoutFrame.size.width / 2.0f);

    if (anchorPointInRootView.x < kEdgeThreshold) {
      // Close to left edge.
      CGFloat shift = kEdgeThreshold - anchorPointInRootView.x;
      anchorPointInRootView.x += MIN(leeway, shift);
    } else if (anchorPointInRootView.x >
               CGRectGetMaxX(rootView.bounds) - kEdgeThreshold) {
      // Close to right edge.
      CGFloat shift = anchorPointInRootView.x -
                      (CGRectGetMaxX(rootView.bounds) - kEdgeThreshold);
      anchorPointInRootView.x -= MIN(leeway, shift);
    }
  } else {
    CGFloat leeway = MIN(kMaxLeeway, guide.layoutFrame.size.height / 2.0f);

    if (anchorPointInRootView.y < kEdgeThreshold) {
      // Close to top edge.
      CGFloat shift = kEdgeThreshold - anchorPointInRootView.y;
      anchorPointInRootView.y += MIN(leeway, shift);
    } else if (anchorPointInRootView.y >
               CGRectGetMaxY(rootView.bounds) - kEdgeThreshold) {
      // Close to bottom edge.
      CGFloat shift = anchorPointInRootView.y -
                      (CGRectGetMaxY(rootView.bounds) - kEdgeThreshold);
      anchorPointInRootView.y -= MIN(leeway, shift);
    }
  }

  CGPoint anchorPointInWindow = [rootView convertPoint:anchorPointInRootView
                                                toView:rootView.window];

  [self.rootViewController.view removeLayoutGuide:guide];
  return anchorPointInWindow;
}

// Returns the frame of a layout guide used as anchor view for a bubble. The
// frame is in the window coordinates.
- (CGRect)anchorViewFrameForGuide:(GuideName*)guideName {
  UILayoutGuide* guide = [_layoutGuideCenter makeLayoutGuideNamed:guideName];
  CHECK(guide);
  [self.rootViewController.view addLayoutGuide:guide];
  CGRect frame = guide.layoutFrame;
  CGRect frameInWindow = [guide.owningView convertRect:frame
                                                toView:guide.owningView.window];
  [self.rootViewController.view removeLayoutGuide:guide];
  return frameInWindow;
}

// Returns whether the tab can present a bubble tip.
// TODO(crbug.com/40914423): make most callsites pass NO for
// `CheckTabScrolledToTop` as it's error-prone.
- (BOOL)canPresentBubble {
  return [self canPresentBubbleWithCheckTabScrolledToTop:YES];
}

// Returns whether the tab can present a bubble tip. Whether tab being scrolled
// to top is required for presenting the bubble tip is determined by
// `checkTabScrolledToTop`.
- (BOOL)canPresentBubbleWithCheckTabScrolledToTop:(BOOL)checkTabScrolledToTop {
  // If BubblePresenter has been stopped, do not present the bubble.
  if (!_started) {
    return NO;
  }
  // If the BVC is not visible, do not present the bubble.
  if (![self.delegate rootViewVisibleForBubblePresenter:self]) {
    return NO;
  }
  // Do not present the bubble if there is no current tab.
  if (!_webStateList->GetActiveWebState()) {
    return NO;
  }
  // Do not present bubble if an overlay is showing.
  if ((_webContentOverlayPresenter &&
       _webContentOverlayPresenter->IsShowingOverlayUI()) ||
      (_infobarBannerPresenter &&
       _infobarBannerPresenter->IsShowingOverlayUI()) ||
      (_infobarModalPresenter &&
       _infobarModalPresenter->IsShowingOverlayUI())) {
    return NO;
  }
  // Do not present the bubble if the tab is not scrolled to the top.
  if (checkTabScrolledToTop && ![self isTabScrolledToTop]) {
    return NO;
  }
  return YES;
}

- (BOOL)isTabScrolledToTop {
  web::WebState* currentWebState = _webStateList->GetActiveWebState();
  if (!currentWebState) {
    return NO;
  }

  // If NTP exists, check if it is scrolled to top.
  if ([self.delegate isNTPActiveForBubblePresenter:self]) {
    return NewTabPageTabHelper::FromWebState(currentWebState)
        ->IsScrolledToTop();
  }
  CRWWebViewScrollViewProxy* scrollProxy =
      currentWebState->GetWebViewProxy().scrollViewProxy;
  CGPoint scrollOffset = scrollProxy.contentOffset;
  UIEdgeInsets contentInset = scrollProxy.contentInset;
  return AreCGFloatsEqual(scrollOffset.y, -contentInset.top);
}

// Returns a bubble associated with an in-product help promotion if
// it is valid to show the promotion and `nil` otherwise. `feature` is the
// base::Feature object associated with the given promotion. `direction` is the
// direction the bubble's arrow is pointing. `alignment` is the alignment of the
// arrow on the button. `text` is the text displayed by the bubble.
- (BubbleViewControllerPresenter*)
    bubblePresenterForFeature:(const base::Feature&)feature
                    direction:(BubbleArrowDirection)direction
                    alignment:(BubbleAlignment)alignment
                         text:(NSString*)text
                dismissAction:(void (^)(IPHDismissalReasonType))dismissAction {
  DCHECK(_engagementTracker);
  // Capture `weakSelf` instead of the feature engagement tracker object
  // because `weakSelf` will safely become `nil` if it is deallocated, whereas
  // the feature engagement tracker will remain pointing to invalid memory if
  // its owner (the ProfileIOS) is deallocated.
  __weak BubblePresenter* weakSelf = self;
  CallbackWithIPHDismissalReasonType dismissalCallback =
      ^(IPHDismissalReasonType IPHDismissalReasonType) {
        if (dismissAction) {
          dismissAction(IPHDismissalReasonType);
        }
        [weakSelf stopAnimatedFullscreenDisabler];
        [weakSelf featureDismissed:feature];
      };

  BubbleViewControllerPresenter* bubbleViewControllerPresenter =
      [[BubbleViewControllerPresenter alloc]
          initDefaultBubbleWithText:text
                     arrowDirection:direction
                          alignment:alignment
                  dismissalCallback:dismissalCallback];

  bubbleViewControllerPresenter.customBubbleVisibilityDuration =
      [self bubbleVisibilityDurationForFeature:feature];
  bubbleViewControllerPresenter.ignoreWebContentAreaInteractions =
      [self shouldIgnoreWebContentAreaInteractionsForFeature:feature];

  BOOL isLensOverlayIPH =
      (feature.name ==
           feature_engagement::kIPHiOSLensOverlayEscapeHatchTipFeature.name ||
       feature.name ==
           feature_engagement::kIPHiOSLensOverlayEntrypointTipFeature.name);
  BOOL isPageActionMenuIPH =
      feature.name == feature_engagement::kIPHIOSPageActionMenu.name;
  BOOL isGeminiImageRemixIPH =
      feature.name == feature_engagement::kIPHiOSGeminiImageRemixFeature.name;
  bubbleViewControllerPresenter.forceDisablePanGestureRecognizer =
      isLensOverlayIPH || isPageActionMenuIPH || isGeminiImageRemixIPH;

  return bubbleViewControllerPresenter;
}

// If an in-product help message should be shown for `feature`, presents an IPH
// view covering the content area and return the view, otherwise return `nil`
// and do nothing. `direction` is the direction the bubble's arrow is pointing.
// `text` is the text displayed by the bubble.
//
// Note that this method does NOT start the animation. The caller should start
// the animation of the returned `GestureInProductHelpView` accordingly. This
// allows the caller to make modifications to the view before animating.
- (GestureInProductHelpView*)
    presentGestureInProductHelpForFeature:(const base::Feature&)feature
                           swipeDirection:
                               (UISwipeGestureRecognizerDirection)direction
                                     text:(NSString*)text {
  DCHECK(_engagementTracker);
  NamedGuide* contentAreaGuide =
      [NamedGuide guideWithName:kContentAreaGuide
                           view:self.rootViewController.view];
  if (!contentAreaGuide) {
    return nil;
  }
  UILayoutGuide* boundingSizeGuide = [[UILayoutGuide alloc] init];
  UILayoutGuide* safeAreaGuide =
      self.rootViewController.view.safeAreaLayoutGuide;
  [self.rootViewController.view addLayoutGuide:boundingSizeGuide];

  BOOL isDirectionLeading = direction == UseRTLLayout()
                                ? UISwipeGestureRecognizerDirectionRight
                                : UISwipeGestureRecognizerDirectionLeft;
  switch (direction) {
    case UISwipeGestureRecognizerDirectionUp:
      AddSameConstraintsToSides(boundingSizeGuide, contentAreaGuide,
                                LayoutSides::kTop | LayoutSides::kHorizontal);
      AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                LayoutSides::kBottom);
      break;
    case UISwipeGestureRecognizerDirectionDown:
      AddSameConstraintsToSides(
          boundingSizeGuide, contentAreaGuide,
          LayoutSides::kBottom | LayoutSides::kHorizontal);
      AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                LayoutSides::kTop);
      break;
    case UISwipeGestureRecognizerDirectionLeft:
    case UISwipeGestureRecognizerDirectionRight:
      if (isDirectionLeading) {
        AddSameConstraintsToSides(
            boundingSizeGuide, contentAreaGuide,
            LayoutSides::kLeading | LayoutSides::kVertical);
        AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                  LayoutSides::kTrailing);
      } else {
        AddSameConstraintsToSides(
            boundingSizeGuide, contentAreaGuide,
            LayoutSides::kTrailing | LayoutSides::kVertical);
        AddSameConstraintsToSides(boundingSizeGuide, safeAreaGuide,
                                  LayoutSides::kLeading);
      }
      break;
  }
  GestureInProductHelpView* gestureIPHView = [[GestureInProductHelpView alloc]
            initWithText:text
      bubbleBoundingSize:boundingSizeGuide.layoutFrame.size
          swipeDirection:direction];
  [gestureIPHView setTranslatesAutoresizingMaskIntoConstraints:NO];
  if (CanGestureInProductHelpViewFitInGuide(gestureIPHView,
                                            boundingSizeGuide) &&
      _engagementTracker->ShouldTriggerHelpUI(feature)) {
    [self.rootViewController.view addSubview:gestureIPHView];
    gestureIPHView.delegate = self;
    AddSameConstraints(gestureIPHView, contentAreaGuide);
    if (IsPageActionMenuEnabled()) {
      [self.geminiHandler
          hideFloatyIfInvokedAnimated:NO
                           fromSource:gemini::FloatyUpdateSource::GestureIph];
    }
    return gestureIPHView;
  }
  return nil;
}

// Stops the animated fullscreen disabler.
- (void)stopAnimatedFullscreenDisabler {
  _fullscreenDisabler = nullptr;
  _legacyAnimatedFullscreenDisabler = nullptr;
}

// Creates and starts the animated fullscreen disabler.
- (void)startAnimatedFullscreenDisabler {
  if (IsFullscreenRefactoringEnabled()) {
    _fullscreenDisabler =
        std::make_unique<ScopedFullscreenDisabler>(self.fullscreenHandler);
  } else {
    _legacyAnimatedFullscreenDisabler =
        std::make_unique<AnimatedScopedFullscreenDisabler>(
            _fullscreenController);
    _legacyAnimatedFullscreenDisabler->StartAnimation();
  }
}

- (void)featureDismissed:(const base::Feature&)feature {
  if (!_engagementTracker) {
    return;
  }
  // Don't alert FET if bubble was force presented, because in that case, the
  // FET was never informed of the initial presentation.
  if ([self shouldForcePresentBubbleForFeature:feature]) {
    return;
  }
  _engagementTracker->Dismissed(feature);
}

// Returns the custom duration of the bubble for `feature`, or 0 if there is
// none.
- (NSTimeInterval)bubbleVisibilityDurationForFeature:
    (const base::Feature&)feature {
  // Display FollowWhileBrowsing in-product help bubble with custom duration.
  if (feature.name == feature_engagement::kIPHFollowWhileBrowsingFeature.name ||
      feature.name == feature_engagement::kIPHIOSPageActionMenu.name ||
      feature.name == feature_engagement::kIPHiOSGeminiImageRemixFeature.name) {
    return kDefaultLongDurationBubbleVisibility;
  }

  return 0;
}

// Returns whether the web content area interactions should be ignored for the
// given feature.
- (BOOL)shouldIgnoreWebContentAreaInteractionsForFeature:
    (const base::Feature&)feature {
  if (feature.name == feature_engagement::kIPHIOSPageActionMenu.name ||
      feature.name == feature_engagement::kIPHiOSGeminiImageRemixFeature.name) {
    return YES;
  }

  return NO;
}

// Returns whether fullscreen should be disabled before presenting the bubble
// for a given feature.
- (BOOL)shouldDisableFullscreenForFeature:(const base::Feature&)feature {
  if (feature.name == feature_engagement::kIPHIOSPageActionMenu.name ||
      feature.name == feature_engagement::kIPHiOSGeminiImageRemixFeature.name) {
    return YES;
  }

  return NO;
}

// Return YES if the bubble should always be presented. Ex. if force present
// bubble set by system experimental settings. The bubble presenter will not
// inform the FET of dismissal in this case. The client is responsible for
// informing the FET.
- (BOOL)shouldForcePresentBubbleForFeature:(const base::Feature&)feature {
  // When Level Up is enabled, the Page Action Menu bubble is triggered
  // on-demand when the user selects the Gemini task, bypassing standard FET
  // engagement limits.
  if (IsLevelUpEnabled() &&
      feature.name == feature_engagement::kIPHIOSPageActionMenu.name) {
    return YES;
  }
  // The background customization feature bubble is tied in with other IPH, so
  // the feature engagement tracker is checked externally to this class.
  if (feature.name ==
      feature_engagement::kIPHiOSPromoBackgroundCustomizationFeature.name) {
    return YES;
  }
  return NO;
}

// Stop observing overlay events and disconnect related properties.
- (void)disconnectOverlayPresenters {
  if (_webContentOverlayPresenter) {
    _webContentOverlayPresenter->RemoveObserver(
        _overlayPresenterObserver.get());
    _webContentOverlayPresenter = nullptr;
  }
  if (_infobarBannerPresenter) {
    _infobarBannerPresenter->RemoveObserver(_overlayPresenterObserver.get());
    _infobarBannerPresenter = nullptr;
  }
  if (_infobarModalPresenter) {
    _infobarModalPresenter->RemoveObserver(_overlayPresenterObserver.get());
    _infobarModalPresenter = nullptr;
  }
  _overlayPresenterObserver = nullptr;
}

// Returns whether the omnibox is in the bottom position.
- (BOOL)isBottomOmnibox {
  return IsBottomOmniboxAvailable() &&
         GetApplicationContext()->GetLocalState()->GetBoolean(
             omnibox::kIsOmniboxInBottomPosition);
}

#pragma mark - Testing

- (BubbleViewControllerPresenter*)
    sendTabToSelfOmniboxBubblePresenterForTesting {
  return _sendTabToSelfOmniboxBubblePresenter;
}

@end
