/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

#include "XULMenuParentElement.h"

#include "XULButtonElement.h"
#include "XULMenuBarElement.h"
#include "XULPopupElement.h"
#include "mozilla/EventDispatcher.h"
#include "mozilla/LookAndFeel.h"
#include "mozilla/StaticAnalysisFunctions.h"
#include "mozilla/TextEvents.h"
#include "mozilla/Utf16.h"
#include "mozilla/dom/DocumentInlines.h"
#include "mozilla/dom/KeyboardEvent.h"
#include "nsDebug.h"
#include "nsMenuPopupFrame.h"
#include "nsString.h"
#include "nsStringFwd.h"
#include "nsXULElement.h"
#include "nsXULPopupManager.h"

namespace mozilla::dom {

NS_IMPL_ISUPPORTS_CYCLE_COLLECTION_INHERITED_0(XULMenuParentElement,
                                               nsXULElement)
NS_IMPL_CYCLE_COLLECTION_INHERITED(XULMenuParentElement, nsXULElement,
                                   mActiveItem)

XULMenuParentElement::XULMenuParentElement(
    already_AddRefed<mozilla::dom::NodeInfo> aNodeInfo)
    : nsXULElement(std::move(aNodeInfo)) {}

XULMenuParentElement::~XULMenuParentElement() = default;

class MenuActivateEvent final : public Runnable {
 public:
  MenuActivateEvent(Element* aMenu, bool aIsActivate)
      : Runnable("MenuActivateEvent"), mMenu(aMenu), mIsActivate(aIsActivate) {}

  // TODO: Convert this to MOZ_CAN_RUN_SCRIPT (bug 1415230, bug 1535398)
  MOZ_CAN_RUN_SCRIPT_BOUNDARY NS_IMETHOD Run() override {
    nsAutoString domEventToFire;
    if (mIsActivate) {
      // Highlight the menu.
      mMenu->SetAttr(kNameSpaceID_None, nsGkAtoms::menuactive, u"true"_ns,
                     true);
      // The menuactivated event is used by accessibility to track the user's
      // movements through menus
      domEventToFire.AssignLiteral("DOMMenuItemActive");
    } else {
      // Unhighlight the menu.
      mMenu->UnsetAttr(kNameSpaceID_None, nsGkAtoms::menuactive, true);
      domEventToFire.AssignLiteral("DOMMenuItemInactive");
    }

    RefPtr<nsPresContext> pc = mMenu->OwnerDoc()->GetPresContext();
    RefPtr<dom::Event> event = NS_NewDOMEvent(mMenu, pc, nullptr);
    event->InitEvent(domEventToFire, true, true);

    event->SetTrusted(true);

    EventDispatcher::DispatchDOMEvent(mMenu, nullptr, event, pc, nullptr);
    return NS_OK;
  }

 private:
  const RefPtr<Element> mMenu;
  bool mIsActivate;
};

static void ActivateOrDeactivate(XULButtonElement& aButton, bool aActivate) {
  if (!aButton.IsMenu()) {
    return;
  }

  if (nsXULPopupManager* pm = nsXULPopupManager::GetInstance()) {
    if (aActivate) {
      // Cancel the close timer if selecting a menu within the popup to be
      // closed.
      pm->CancelMenuTimer(aButton.GetContainingPopupWithoutFlushing());
    } else if (auto* popup = aButton.GetMenuPopupWithoutFlushing()) {
      if (popup->IsOpen()) {
        // Set up the close timer if deselecting an open sub-menu.
        pm->HidePopupAfterDelay(popup, aButton.MenuOpenCloseDelay());
      }
    }
  }

  nsCOMPtr<nsIRunnable> event = new MenuActivateEvent(&aButton, aActivate);
  aButton.OwnerDoc()->Dispatch(event.forget());
}

XULButtonElement* XULMenuParentElement::GetContainingMenu() const {
  if (IsMenuBar()) {
    return nullptr;
  }
  auto* button = XULButtonElement::FromNodeOrNull(GetParent());
  if (!button || !button->IsMenu()) {
    return nullptr;
  }
  return button;
}

void XULMenuParentElement::LockMenuUntilClosed(bool aLock) {
  if (IsMenuBar()) {
    return;
  }
  mLocked = aLock;
  // Lock/Unlock the parent menu too.
  if (XULButtonElement* menu = GetContainingMenu()) {
    if (XULMenuParentElement* parent = menu->GetMenuParent()) {
      parent->LockMenuUntilClosed(aLock);
    }
  }
}

void XULMenuParentElement::SetActiveMenuChild(XULButtonElement* aChild,
                                              ByKey aByKey) {
  if (aChild == mActiveItem) {
    return;
  }

  if (mActiveItem) {
    ActivateOrDeactivate(*mActiveItem, false);
  }
  mActiveItem = nullptr;

  if (auto* menuBar = XULMenuBarElement::FromNode(*this)) {
    // KnownLive because `this` is known-live by definition.
    MOZ_KnownLive(menuBar)->SetActive(!!aChild);
  }

  if (!aChild) {
    return;
  }

  // When a menu opens a submenu, the mouse will often be moved onto a sibling
  // before moving onto an item within the submenu, causing the parent to become
  // deselected. We need to ensure that the parent menu is reselected when an
  // item in the submenu is selected.
  if (RefPtr menu = GetContainingMenu()) {
    if (RefPtr parent = menu->GetMenuParent()) {
      parent->SetActiveMenuChild(menu, aByKey);
    }
  }

  mActiveItem = aChild;
  ActivateOrDeactivate(*mActiveItem, true);

  if (IsInMenuList()) {
    if (nsMenuPopupFrame* f = do_QueryFrame(GetPrimaryFrame())) {
      f->EnsureActiveMenuListItemIsVisible();
#ifdef XP_WIN
      // On Windows, a menulist should update its value whenever navigation was
      // done by the keyboard.
      //
      // NOTE(emilio): This is a rather odd per-platform behavior difference,
      // but other browsers also do this.
      if (aByKey == ByKey::Yes && f->IsOpen()) {
        // Fire a command event as the new item, but we don't want to close the
        // menu, blink it, or update any other state of the menuitem. The
        // command event will cause the item to be selected.
        RefPtr<mozilla::PresShell> presShell = OwnerDoc()->GetPresShell();
        nsContentUtils::DispatchXULCommand(aChild, /* aTrusted = */ true,
                                           nullptr, presShell, false, false,
                                           false, false);
      }
#endif
    }
  }
}

static bool IsValidMenuItem(const XULMenuParentElement& aMenuParent,
                            const nsIContent& aContent) {
  const auto* button = XULButtonElement::FromNode(aContent);
  if (!button || !button->IsMenu()) {
    return false;
  }
  if (!button->GetPrimaryFrame()) {
    // Hidden buttons are not focusable/activatable.
    return false;
  }
  if (!button->IsDisabled()) {
    return true;
  }
  // In the menubar or a menulist disabled items are always skipped.
  const bool skipDisabled =
      LookAndFeel::GetInt(LookAndFeel::IntID::SkipNavigatingDisabledMenuItem) ||
      aMenuParent.IsMenuBar() || aMenuParent.IsInMenuList();
  return !skipDisabled;
}

enum class StartKind { Parent, Item };

template <bool aForward>
static XULButtonElement* DoGetNextMenuItem(
    const XULMenuParentElement& aMenuParent, const nsIContent& aStart,
    StartKind aStartKind) {
  nsIContent* start =
      aStartKind == StartKind::Item
          ? (aForward ? aStart.GetNextSibling() : aStart.GetPreviousSibling())
          : (aForward ? aStart.GetFirstChild() : aStart.GetLastChild());
  for (nsIContent* node = start; node;
       node = aForward ? node->GetNextSibling() : node->GetPreviousSibling()) {
    if (IsValidMenuItem(aMenuParent, *node)) {
      return static_cast<XULButtonElement*>(node);
    }
    if (node->IsXULElement(nsGkAtoms::menugroup)) {
      if (XULButtonElement* child = DoGetNextMenuItem<aForward>(
              aMenuParent, *node, StartKind::Parent)) {
        return child;
      }
    }
  }
  if (aStartKind == StartKind::Item && aStart.GetParent() &&
      aStart.GetParent()->IsXULElement(nsGkAtoms::menugroup)) {
    // We haven't found anything in aStart's sibling list, but if we're in a
    // group we need to keep looking.
    return DoGetNextMenuItem<aForward>(aMenuParent, *aStart.GetParent(),
                                       StartKind::Item);
  }
  return nullptr;
}

XULButtonElement* XULMenuParentElement::GetFirstMenuItem() const {
  return DoGetNextMenuItem<true>(*this, *this, StartKind::Parent);
}

XULButtonElement* XULMenuParentElement::GetLastMenuItem() const {
  return DoGetNextMenuItem<false>(*this, *this, StartKind::Parent);
}

XULButtonElement* XULMenuParentElement::GetNextMenuItemFrom(
    const XULButtonElement& aStartingItem) const {
  return DoGetNextMenuItem<true>(*this, aStartingItem, StartKind::Item);
}

XULButtonElement* XULMenuParentElement::GetPrevMenuItemFrom(
    const XULButtonElement& aStartingItem) const {
  return DoGetNextMenuItem<false>(*this, aStartingItem, StartKind::Item);
}

XULButtonElement* XULMenuParentElement::GetNextMenuItem(Wrap aWrap) const {
  if (mActiveItem) {
    if (auto* next = GetNextMenuItemFrom(*mActiveItem)) {
      return next;
    }
    if (aWrap == Wrap::No) {
      return nullptr;
    }
  }
  return GetFirstMenuItem();
}

XULButtonElement* XULMenuParentElement::GetPrevMenuItem(Wrap aWrap) const {
  if (mActiveItem) {
    if (auto* prev = GetPrevMenuItemFrom(*mActiveItem)) {
      return prev;
    }
    if (aWrap == Wrap::No) {
      return nullptr;
    }
  }
  return GetLastMenuItem();
}

void XULMenuParentElement::SelectFirstItem() {
  if (RefPtr firstItem = GetFirstMenuItem()) {
    SetActiveMenuChild(firstItem);
  }
}

XULButtonElement* XULMenuParentElement::FindMenuWithShortcut(
    KeyboardEvent& aKeyEvent) const {
  using AccessKeyArray = AutoTArray<uint32_t, 10>;
  AccessKeyArray accessKeys;
  WidgetKeyboardEvent* nativeKeyEvent =
      aKeyEvent.WidgetEventPtr()->AsKeyboardEvent();
  if (nativeKeyEvent) {
    nativeKeyEvent->GetAccessKeyCandidates(accessKeys);
  }
  const uint32_t charCode = aKeyEvent.CharCode();
  if (accessKeys.IsEmpty() && charCode) {
    accessKeys.AppendElement(charCode);
  }
  if (accessKeys.IsEmpty()) {
    return nullptr;  // no character was pressed so just return
  }
  XULButtonElement* foundMenu = nullptr;
  size_t foundIndex = AccessKeyArray::NoIndex;
  for (auto* item = GetFirstMenuItem(); item;
       item = GetNextMenuItemFrom(*item)) {
    nsAutoString shortcutKey;
    item->GetAttr(nsGkAtoms::accesskey, shortcutKey);
    if (shortcutKey.IsEmpty()) {
      continue;
    }

    ToLowerCase(shortcutKey);
    const char16_t* start = shortcutKey.BeginReading();
    const char16_t* end = shortcutKey.EndReading();
    uint32_t ch = DecodeOneUtf16CodePoint(&start, end);
    size_t index = accessKeys.IndexOf(ch);
    if (index == AccessKeyArray::NoIndex) {
      continue;
    }
    if (foundIndex == AccessKeyArray::NoIndex || index < foundIndex) {
      foundMenu = item;
      foundIndex = index;
    }
  }
  return foundMenu;
}

XULButtonElement* XULMenuParentElement::FindMenuWithShortcut(
    const nsAString& aString, bool& aDoAction) const {
  aDoAction = false;
  uint32_t accessKeyMatchCount = 0;
  uint32_t matchCount = 0;

  XULButtonElement* foundAccessKeyMenu = nullptr;
  XULButtonElement* foundMenuBeforeCurrent = nullptr;
  XULButtonElement* foundMenuAfterCurrent = nullptr;

  bool foundActive = false;
  for (auto* item = GetFirstMenuItem(); item;
       item = GetNextMenuItemFrom(*item)) {
    nsAutoString textKey;
    // Get the shortcut attribute.
    item->GetAttr(nsGkAtoms::accesskey, textKey);
    const bool isAccessKey = !textKey.IsEmpty();
    if (textKey.IsEmpty()) {  // No shortcut, try first letter
      item->GetAttr(nsGkAtoms::label, textKey);
      if (textKey.IsEmpty()) {  // No label, try another attribute (value)
        item->GetAttr(nsGkAtoms::value, textKey);
      }
    }

    const bool isActive = item == GetActiveMenuChild();
    foundActive |= isActive;

    if (!StringBeginsWith(
            nsContentUtils::TrimWhitespace<
                nsContentUtils::IsHTMLWhitespaceOrNBSP>(textKey, false),
            aString, nsCaseInsensitiveStringComparator)) {
      continue;
    }
    // There is one match
    matchCount++;
    if (isAccessKey) {
      // There is one shortcut-key match
      accessKeyMatchCount++;
      // Record the matched item. If there is only one matched shortcut
      // item, do it
      foundAccessKeyMenu = item;
    }
    // Get the active status
    if (isActive && aString.Length() > 1 && !foundMenuBeforeCurrent) {
      // If there is more than one char typed and the current item matches, the
      // current item has highest priority, otherwise the item next to current
      // has highest priority.
      return item;
    }
    if (!foundActive || isActive) {
      // It's a first candidate item located before/on the current item
      if (!foundMenuBeforeCurrent) {
        foundMenuBeforeCurrent = item;
      }
    } else {
      if (!foundMenuAfterCurrent) {
        foundMenuAfterCurrent = item;
      }
    }
  }

  aDoAction = !IsInMenuList() && (matchCount == 1 || accessKeyMatchCount == 1);

  if (accessKeyMatchCount == 1) {
    // We have one matched accesskey item
    return foundAccessKeyMenu;
  }
  // If we have matched an item after the current, use it.
  if (foundMenuAfterCurrent) {
    return foundMenuAfterCurrent;
  }
  // If we haven't, use the item before the current, if any.
  return foundMenuBeforeCurrent;
}

void XULMenuParentElement::HandleEnterKeyPress(WidgetEvent& aEvent) {
  if (RefPtr child = GetActiveMenuChild()) {
    child->HandleEnterKeyPress(aEvent);
  }
}

}  // namespace mozilla::dom
