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

#import "chrome/browser/ui/cocoa/confirm_quit_panel_controller.h"

#import <Cocoa/Cocoa.h>
#import <QuartzCore/QuartzCore.h>

#include "base/check_op.h"
#include "base/strings/sys_string_conversions.h"
#include "chrome/app/chrome_command_ids.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser_window/public/global_browser_collection.h"
#include "chrome/browser/ui/cocoa/confirm_quit.h"
#include "chrome/common/pref_names.h"
#include "chrome/grit/generated_resources.h"
#include "components/prefs/pref_registry_simple.h"
#import "components/remote_cocoa/app_shim/native_widget_mac_nswindow.h"
#include "ui/base/accelerators/accelerator.h"
#import "ui/base/accelerators/platform_accelerator_cocoa.h"
#include "ui/base/l10n/l10n_util_mac.h"
#include "ui/events/cocoa/cocoa_event_utils.h"
#include "ui/events/keycodes/keyboard_code_conversion_mac.h"
#include "ui/gfx/native_ui_types.h"

// Constants ///////////////////////////////////////////////////////////////////

// Leeway between the |targetDate| and the current time that will confirm a
// quit.
const NSTimeInterval kTimeDeltaFuzzFactor = 1.0;

// Time to wait between KeyUp events before checking the keyboard state again.
constexpr NSTimeInterval kEventQueueWaitTime = 0.1;

namespace {

CGFloat ConfirmQuitFrameCornerRadius() {
  if (@available(macOS 26, *)) {
    return 20.0;
  }
  return 9.0;
}

}  // namespace

// Custom Content View /////////////////////////////////////////////////////////

// The content view of the window that draws a custom frame.
@interface ConfirmQuitFrameView : NSView {
 @private
  NSTextField* __weak _message;
}
- (void)setMessageText:(NSString*)text;
@end

@implementation ConfirmQuitFrameView

- (instancetype)initWithFrame:(NSRect)frameRect {
  if ((self = [super initWithFrame:frameRect])) {
    // The frame will be fixed up when |-setMessageText:| is called.
    NSTextField* message = [[NSTextField alloc] initWithFrame:NSZeroRect];
    message.editable = NO;
    message.selectable = NO;
    message.bezeled = NO;
    message.drawsBackground = NO;
    message.font = [NSFont boldSystemFontOfSize:24];
    message.textColor = NSColor.whiteColor;
    [self addSubview:message];
    _message = message;
  }
  return self;
}

- (void)drawRect:(NSRect)dirtyRect {
  const CGFloat cornerRadius = ConfirmQuitFrameCornerRadius();
  NSBezierPath* path = [NSBezierPath bezierPathWithRoundedRect:self.bounds
                                                       xRadius:cornerRadius
                                                       yRadius:cornerRadius];

  NSColor* fillColor = [NSColor colorWithCalibratedWhite:0.2 alpha:0.75];
  [fillColor set];
  [path fill];
}

- (void)setMessageText:(NSString*)text {
  const CGFloat kHorizontalPadding = 30;  // In view coordinates.

  // Style the string.
  NSMutableAttributedString* attrString =
      [[NSMutableAttributedString alloc] initWithString:text];
  NSShadow* textShadow = [[NSShadow alloc] init];
  textShadow.shadowColor = [NSColor colorWithCalibratedWhite:0 alpha:0.6];
  textShadow.shadowOffset = NSMakeSize(0, -1);
  textShadow.shadowBlurRadius = 1.0;
  [attrString addAttribute:NSShadowAttributeName
                     value:textShadow
                     range:NSMakeRange(0, text.length)];
  _message.attributedStringValue = attrString;

  // Fixup the frame of the string.
  [_message sizeToFit];
  NSRect messageFrame = _message.frame;
  NSRect frameInViewSpace = [_message convertRect:self.window.frame
                                         fromView:nil];

  if (NSWidth(messageFrame) > NSWidth(frameInViewSpace)) {
    frameInViewSpace.size.width = NSWidth(messageFrame) + kHorizontalPadding;
  }

  messageFrame.origin.x = NSWidth(frameInViewSpace) / 2 - NSMidX(messageFrame);
  messageFrame.origin.y = NSHeight(frameInViewSpace) / 2 - NSMidY(messageFrame);

  [self.window setFrame:[_message convertRect:frameInViewSpace toView:nil]
                display:YES];
  _message.frame = messageFrame;
}

@end

typedef NS_ENUM(NSInteger, FadeWindowsOperation) { kHide, kShow };

// Animation ///////////////////////////////////////////////////////////////////

// This animation will run through all the windows of NSApp and will fade their
// alpha value to 0.0 if `op` is `kHide` and 1.0 otherwise.
@interface FadeAllWindowsAnimation : NSAnimation <NSAnimationDelegate>
- (instancetype)initWithOperation:(FadeWindowsOperation)op
                animationDuration:(NSTimeInterval)duration;
@end

@implementation FadeAllWindowsAnimation {
  FadeWindowsOperation _op;
}

- (instancetype)initWithOperation:(FadeWindowsOperation)op
                animationDuration:(NSTimeInterval)duration {
  if ((self = [super initWithDuration:duration
                       animationCurve:NSAnimationLinear])) {
    _op = op;
    self.delegate = self;
  }
  return self;
}

- (void)setCurrentProgress:(NSAnimationProgress)progress {
  CGFloat value = _op == kShow ? progress : 1.0 - progress;
  NSMutableSet* windowsToFade = [NSMutableSet set];
  for (NSWindow* window in NSApp.windows) {
    if ([window isKindOfClass:[NativeWidgetMacNSWindow class]]) {
      [windowsToFade addObject:window];
      // Include child windows to ensure the animation is also applied to
      // popovers, dialogs, etc.
      [windowsToFade addObjectsFromArray:window.childWindows];
    }
  }

  for (NSWindow* window in windowsToFade) {
    window.alphaValue = value;
  }
}

@end

// Private Interface ///////////////////////////////////////////////////////////

@interface ConfirmQuitPanelController (Private) <CAAnimationDelegate>
// The menu item for the Quit menu item, or a thrown-together default one if no
// Quit menu item exists.
@property(class, readonly) NSMenuItem* quitMenuItem;

- (void)animateFadeOut;
- (NSEvent*)waitForKeyEventUpWithTimeout:(NSTimeInterval)timeout;
- (void)hideAllWindowsWithDuration:(NSTimeInterval)duration;
- (void)sendAccessibilityAnnouncement;
@end

static BOOL __strong (^g_isKeyDownForKeyCodeMock)(unsigned short) = nil;

BOOL isKeyDownForKeyCode(unsigned short keyCode) {
  if (g_isKeyDownForKeyCodeMock) {
    return g_isKeyDownForKeyCodeMock(keyCode);
  }
  return CGEventSourceKeyState(kCGEventSourceStateCombinedSessionState,
                               keyCode);
}

////////////////////////////////////////////////////////////////////////////////

@implementation ConfirmQuitPanelController {
 @private
  // The content view of the window that this controller manages.
  ConfirmQuitFrameView* __weak _contentView;
  // Whether we've hidden all windows and initiated the quitting process.
  BOOL _didHideWindows;
  // Invoked when the panel has finished dismissing.
  void (^_dismissedCallback)();
}

+ (BOOL (^)(unsigned short))isKeyDownForKeyCodeMock {
  return [g_isKeyDownForKeyCodeMock copy];
}

+ (void)setIsKeyDownForKeyCodeMock:(BOOL (^)(unsigned short))mock {
  g_isKeyDownForKeyCodeMock = [mock copy];
}

- (instancetype)init {
  const NSRect kWindowFrame = NSMakeRect(0, 0, 350, 70);
  NSWindow* window =
      [[NSWindow alloc] initWithContentRect:kWindowFrame
                                  styleMask:NSWindowStyleMaskBorderless
                                    backing:NSBackingStoreBuffered
                                      defer:NO];
  if ((self = [super initWithWindow:window])) {
    window.delegate = self;
    window.backgroundColor = NSColor.clearColor;
    window.opaque = NO;
    window.hasShadow = NO;
    window.releasedWhenClosed = NO;

    // Create the content view. Take the frame from the existing content view.
    NSRect frame = window.contentView.frame;
    ConfirmQuitFrameView* frameView =
        [[ConfirmQuitFrameView alloc] initWithFrame:frame];
    window.contentView = frameView;

    // Set the proper string.
    NSString* message = l10n_util::GetNSStringF(
        IDS_CONFIRM_TO_QUIT_DESCRIPTION,
        base::SysNSStringToUTF16(ConfirmQuitPanelController.keyCommandString));
    frameView.messageText = message;
  }
  return self;
}

- (BOOL)runConfirmQuitLoopWithEvent:(NSEvent*)event
                  dismissedCallback:(void (^)())dismissedCallback {
  _dismissedCallback = [dismissedCallback copy];
  [[maybe_unused]] NS_VALID_UNTIL_END_OF_SCOPE ConfirmQuitPanelController*
      keepAlive = self;

  // If this is the second of two such attempts to quit within a certain time
  // interval, then just quit.
  // Time of last quit attempt, if any.
  static NSDate* lastQuitAttempt;  // Initially nil, as it's static.
  NSDate* timeNow = NSDate.date;
  if (lastQuitAttempt &&
      [timeNow timeIntervalSinceDate:lastQuitAttempt] < kTimeDeltaFuzzFactor) {
    // The panel tells users to Hold Cmd+Q. However, we also want to have a
    // double-tap shortcut that allows for a quick quit path. For the users who
    // tap Cmd+Q and then hold it with the window still open, this double-tap
    // logic will run and cause the quit to get committed. If the key
    // combination held down, the system will start sending the Cmd+Q event to
    // the next key application, and so on. This is bad, so instead we hide all
    // the windows (without animation) to look like we've "quit" and then wait
    // for the KeyUp event to commit the quit.
    [self hideAllWindowsWithDuration:0];
    NSEvent* nextEvent = nil;
    do {
      nextEvent = [self waitForKeyEventUpWithTimeout:kEventQueueWaitTime];
    } while (isKeyDownForKeyCode(event.keyCode));
    [NSApp discardEventsMatchingMask:NSEventMaskAny beforeEvent:nextEvent];

    // Based on how long the user held the keys, record the metric.
    if ([NSDate.date timeIntervalSinceDate:timeNow] <
        confirm_quit::kDoubleTapTimeDelta.InSecondsF()) {
      confirm_quit::RecordHistogram(confirm_quit::kDoubleTap);
    } else {
      confirm_quit::RecordHistogram(confirm_quit::kTapHold);
    }
    return YES;
  } else {
    lastQuitAttempt = timeNow;  // Record this attempt for next time.
  }

  // Show the info panel that explains what the user must to do confirm quit.
  [self showWindow:self];

  // Explicitly announce the hold-to-quit message. For an ordinary modal dialog
  // VoiceOver would announce it and read its message, but VoiceOver does not do
  // this for windows whose styleMask is NSWindowStyleMaskBorderless, so do it
  // manually here. Without this screen reader users have no way to know why
  // their quit hotkey seems not to work.
  [self sendAccessibilityAnnouncement];

  // Spin a nested run loop until the |targetDate| is reached or a KeyUp event
  // is sent.
  NSDate* targetDate = [NSDate
      dateWithTimeIntervalSinceNow:confirm_quit::kShowDuration.InSecondsF()];
  BOOL willQuit = NO;
  NSEvent* nextEvent = nil;
  do {
    nextEvent = [self waitForKeyEventUpWithTimeout:kEventQueueWaitTime];

    // Wait for the time expiry to happen. Once past the hold threshold,
    // commit to quitting and hide all the open windows.
    if (!willQuit) {
      NSDate* now = NSDate.date;
      NSTimeInterval difference = [targetDate timeIntervalSinceDate:now];
      if (difference < kTimeDeltaFuzzFactor) {
        willQuit = YES;

        // At this point, the quit has been confirmed and windows should all
        // fade out to convince the user to release the key combo to finalize
        // the quit.
        [self hideAllWindowsWithDuration:confirm_quit::kWindowFadeDuration
                                             .InSecondsF()];
      }
    }
  } while (isKeyDownForKeyCode(event.keyCode));

  // The user has released the key combo. Discard any events (i.e. the
  // repeated KeyDown Cmd+Q).
  [NSApp discardEventsMatchingMask:NSEventMaskAny beforeEvent:nextEvent];

  if (willQuit) {
    // The user held down the combination long enough that quitting should
    // happen.
    confirm_quit::RecordHistogram(confirm_quit::kHoldDuration);
    return YES;
  }
  // Slowly fade the confirm window out in case the user doesn't
  // understand what they have to do to quit.
  [self dismissPanel];
  return NO;
}

- (void)windowWillClose:(NSNotification*)notif {
  // Release all animations because CAAnimation retains its delegate (self),
  // which will cause a retain cycle. Break it!
  self.window.animations = @{};
  if (_dismissedCallback) {
    _dismissedCallback();
    _dismissedCallback = nil;
  }
}

- (void)showWindow:(id)sender {
  // If a panel that is fading out is going to be reused here, make sure it
  // does not get released when the animation finishes.
  [[maybe_unused]] NS_VALID_UNTIL_END_OF_SCOPE ConfirmQuitPanelController*
      keepAlive = self;

  self.window.animations = @{};
  [self.window center];
  self.window.alphaValue = 1.0;
  [super showWindow:sender];
}

- (void)dismissPanel {
  [self performSelector:@selector(animateFadeOut)
             withObject:nil
             afterDelay:1.0];
}

- (void)cancel {
  if (!_didHideWindows) {
    return;
  }
  [self dismissPanel];
  FadeAllWindowsAnimation* animation = [[FadeAllWindowsAnimation alloc]
      initWithOperation:kShow
      animationDuration:confirm_quit::kWindowFadeDuration.InSecondsF()];
  [animation startAnimation];
  _didHideWindows = NO;
}

- (void)animateFadeOut {
  if (!self.window.visible) {
    return;
  }

  NSWindow* window = self.window;
  CAAnimation* animation = [[window animationForKey:@"alphaValue"] copy];
  animation.delegate = self;
  animation.duration = 0.2;
  NSMutableDictionary* dictionary = [[window animations] mutableCopy];
  dictionary[@"alphaValue"] = animation;
  window.animations = dictionary;
  window.animator.alphaValue = 0.0;
}

- (void)animationDidStop:(CAAnimation*)theAnimation finished:(BOOL)finished {
  [self close];
}

+ (NSString*)keyCommandString {
  NSMenuItem* quitItem = self.quitMenuItem;
  ui::Accelerator accelerator(
      ui::KeyboardCodeFromCharCode([quitItem.keyEquivalent characterAtIndex:0]),
      ui::EventFlagsFromModifiers(quitItem.keyEquivalentModifierMask));
  return base::SysUTF16ToNSString(accelerator.GetShortcutText());
}

// Runs a nested loop that pumps the event queue until a keyup event is
// dequeued or |timeout| has passed.
- (NSEvent*)waitForKeyEventUpWithTimeout:(NSTimeInterval)timeout {
  NSDate* untilDate = [NSDate dateWithTimeIntervalSinceNow:timeout];
  return [NSApp nextEventMatchingMask:NSEventMaskKeyUp
                            untilDate:untilDate
                               inMode:NSEventTrackingRunLoopMode
                              dequeue:YES];
}

// Iterates through the list of open windows and hides them all.
- (void)hideAllWindowsWithDuration:(NSTimeInterval)duration {
  _didHideWindows = YES;
  FadeAllWindowsAnimation* animation =
      [[FadeAllWindowsAnimation alloc] initWithOperation:kHide
                                       animationDuration:duration];

  // -startAnimation holds a strong reference to the animation until it is
  // complete.
  [animation startAnimation];
}

// This returns the NSMenuItem that quits the application.
+ (NSMenuItem*)quitMenuItem {
  // Get the application menu (i.e. Chromium).
  NSMenu* appMenu = [[NSApp.mainMenu itemAtIndex:0] submenu];
  for (NSMenuItem* item in appMenu.itemArray) {
    // Find the Quit item.
    if (item.tag == IDC_EXIT) {
      return item;
    }
  }

  // Default to Cmd+Q.
  NSMenuItem* item =
      [[NSMenuItem alloc] initWithTitle:@""
                                 action:@selector(commandDispatch:)
                          keyEquivalent:@"q"];
  item.tag = IDC_EXIT;
  item.keyEquivalentModifierMask = NSEventModifierFlagCommand;
  return item;
}

- (void)sendAccessibilityAnnouncement {
  NSString* message = l10n_util::GetNSStringF(
      IDS_CONFIRM_TO_QUIT_DESCRIPTION,
      base::SysNSStringToUTF16(ConfirmQuitPanelController.keyCommandString));

  NSAccessibilityPostNotificationWithUserInfo(
      NSApp.mainWindow, NSAccessibilityAnnouncementRequestedNotification, @{
        NSAccessibilityAnnouncementKey : message,
        NSAccessibilityPriorityKey : @(NSAccessibilityPriorityHigh),
      });
}

@end
