/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* 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 "nsMenuItemX.h"
#include "nsMenuBarX.h"
#include "nsMenuX.h"
#include "nsMenuItemIconX.h"
#include "nsMenuUtilsX.h"
#include "nsCocoaUtils.h"

#include "nsObjCExceptions.h"

#include "nsCOMPtr.h"
#include "nsGkAtoms.h"

#include "mozilla/dom/Element.h"
#include "mozilla/dom/Event.h"
#include "mozilla/ErrorResult.h"
#include "nsIWidget.h"
#include "mozilla/dom/Document.h"

using namespace mozilla;

using mozilla::dom::CallerType;
using mozilla::dom::Event;

nsMenuItemX::nsMenuItemX(nsMenuX* aParent, const nsString& aLabel,
                         EMenuItemType aItemType,
                         nsMenuGroupOwnerX* aMenuGroupOwner, nsIContent* aNode)
    : mContent(aNode),
      mType(aItemType),
      mMenuParent(aParent),
      mMenuGroupOwner(aMenuGroupOwner) {
  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;

  MOZ_COUNT_CTOR(nsMenuItemX);

  MOZ_RELEASE_ASSERT(mContent->IsElement(),
                     "nsMenuItemX should only be created for elements");
  NS_ASSERTION(mMenuGroupOwner, "No menu owner given, must have one!");

  mMenuGroupOwner->RegisterForContentChanges(mContent, this);

  dom::Document* doc = mContent->GetUncomposedDoc();

  // if we have a command associated with this menu item, register for changes
  // to the command DOM node
  if (doc) {
    nsAutoString ourCommand;
    mContent->AsElement()->GetAttr(nsGkAtoms::command, ourCommand);

    if (!ourCommand.IsEmpty()) {
      dom::Element* commandElement = doc->GetElementById(ourCommand);

      if (commandElement) {
        mCommandElement = commandElement;
        // register to observe the command DOM element
        mMenuGroupOwner->RegisterForContentChanges(mCommandElement, this);
      }
    }
  }

  // decide enabled state based on command content if it exists, otherwise do it
  // based on our own content
  bool isEnabled;
  if (mCommandElement) {
    isEnabled = !mCommandElement->AttrValueIs(
        kNameSpaceID_None, nsGkAtoms::disabled, nsGkAtoms::_true, eCaseMatters);
  } else {
    isEnabled = !mContent->AsElement()->AttrValueIs(
        kNameSpaceID_None, nsGkAtoms::disabled, nsGkAtoms::_true, eCaseMatters);
  }

  // set up the native menu item
  if (mType == eSeparatorMenuItemType) {
    mNativeMenuItem = [[NSMenuItem separatorItem] retain];
  } else {
    NSString* newCocoaLabelString =
        nsMenuUtilsX::GetTruncatedCocoaLabel(aLabel);
    mNativeMenuItem = [[NSMenuItem alloc] initWithTitle:newCocoaLabelString
                                                 action:nil
                                          keyEquivalent:@""];

    mIsChecked = mContent->AsElement()->AttrValueIs(
        kNameSpaceID_None, nsGkAtoms::checked, nsGkAtoms::_true, eCaseMatters);

    mNativeMenuItem.enabled = isEnabled;
    mNativeMenuItem.state =
        mIsChecked ? NSControlStateValueOn : NSControlStateValueOff;

    SetKeyEquiv();
  }

  mIcon = MakeUnique<nsMenuItemIconX>(this);

  mIsVisible = !nsMenuUtilsX::NodeIsHiddenOrCollapsed(mContent);

  // All menu items other than the "Copy" menu item share the same target and
  // action, and are differentiated be a unique (representedObject, tag) pair.
  // The "Copy" menu item is a special case that requires a macOS-default
  // action of `copy:` and a default target in order for the "Edit" menu to be
  // populated with OS-provided menu items such as the Emoji picker,
  // especially in multi-language environments (see bug 1478347). Our
  // application delegate implements `copy:` by simply forwarding it to
  // [nsMenuBarX::sNativeEventTarget menuItemHit:].
  if (mContent->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::id,
                                         u"menu_copy"_ns, eCaseMatters)) {
    mNativeMenuItem.action = @selector(copy:);
  } else {
    mNativeMenuItem.action = @selector(menuItemHit:);
    mNativeMenuItem.target = nsMenuBarX::sNativeEventTarget;
  }

  mNativeMenuItem.representedObject = mMenuGroupOwner->GetRepresentedObject();
  mNativeMenuItem.tag = mMenuGroupOwner->RegisterForCommand(this);

  if (mIsVisible) {
    SetupIcon();
  }

  NS_OBJC_END_TRY_ABORT_BLOCK;
}

nsMenuItemX::~nsMenuItemX() {
  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;

  // autorelease the native menu item so that anything else happening to this
  // object happens before the native menu item actually dies
  [mNativeMenuItem autorelease];

  DetachFromGroupOwner();

  MOZ_COUNT_DTOR(nsMenuItemX);

  NS_OBJC_END_TRY_ABORT_BLOCK;
}

void nsMenuItemX::DetachFromGroupOwner() {
  if (mMenuGroupOwner) {
    mMenuGroupOwner->UnregisterCommand(mNativeMenuItem.tag);

    if (mContent) {
      mMenuGroupOwner->UnregisterForContentChanges(mContent);
    }
    if (mCommandElement) {
      mMenuGroupOwner->UnregisterForContentChanges(mCommandElement);
    }
  }

  mMenuGroupOwner = nullptr;
}

nsresult nsMenuItemX::SetChecked(bool aIsChecked) {
  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;

  mIsChecked = aIsChecked;

  // update the content model. This will also handle unchecking our siblings
  // if we are a radiomenu
  if (mIsChecked) {
    mContent->AsElement()->SetAttr(kNameSpaceID_None, nsGkAtoms::checked,
                                   u"true"_ns, true);
  } else {
    mContent->AsElement()->UnsetAttr(kNameSpaceID_None, nsGkAtoms::checked,
                                     true);
  }

  // update native menu item
  mNativeMenuItem.state =
      mIsChecked ? NSControlStateValueOn : NSControlStateValueOff;

  return NS_OK;

  NS_OBJC_END_TRY_ABORT_BLOCK;
}

EMenuItemType nsMenuItemX::GetMenuItemType() { return mType; }

// Executes the "cached" javaScript command.
// Returns NS_OK if the command was executed properly, otherwise an error code.
void nsMenuItemX::DoCommand(NSEventModifierFlags aModifierFlags,
                            int16_t aButton) {
  // flip "checked" state if we're a checkbox menu, or an un-checked radio menu
  if (mType == eCheckboxMenuItemType ||
      (mType == eRadioMenuItemType && !mIsChecked)) {
    if (!mContent->AsElement()->AttrValueIs(kNameSpaceID_None,
                                            nsGkAtoms::autocheck,
                                            nsGkAtoms::_false, eCaseMatters)) {
      SetChecked(!mIsChecked);
    }
    /* the AttributeChanged code will update all the internal state */
  }

  nsMenuUtilsX::DispatchCommandTo(mContent, aModifierFlags, aButton);
}

nsresult nsMenuItemX::DispatchDOMEvent(const nsString& eventName,
                                       bool* preventDefaultCalled) {
  if (!mContent) {
    return NS_ERROR_FAILURE;
  }

  // get owner document for content
  nsCOMPtr<dom::Document> parentDoc = mContent->OwnerDoc();

  // create DOM event
  ErrorResult rv;
  RefPtr<Event> event =
      parentDoc->CreateEvent(u"Events"_ns, CallerType::System, rv);
  if (rv.Failed()) {
    NS_WARNING("Failed to create Event");
    return rv.StealNSResult();
  }
  event->InitEvent(eventName, true, true);

  // mark DOM event as trusted
  event->SetTrusted(true);

  // send DOM event
  *preventDefaultCalled =
      mContent->DispatchEvent(*event, CallerType::System, rv);
  if (rv.Failed()) {
    NS_WARNING("Failed to send DOM event via EventTarget");
    return rv.StealNSResult();
  }

  return NS_OK;
}

// Walk the sibling list looking for nodes with the same name and
// uncheck them all.
void nsMenuItemX::UncheckRadioSiblings(nsIContent* aCheckedContent) {
  nsAutoString myGroupName;
  aCheckedContent->AsElement()->GetAttr(nsGkAtoms::name, myGroupName);
  if (!myGroupName.Length()) {  // no groupname, nothing to do
    return;
  }

  nsCOMPtr<nsIContent> parent = aCheckedContent->GetParent();
  if (!parent) {
    return;
  }

  // loop over siblings
  for (nsIContent* sibling = parent->GetFirstChild(); sibling;
       sibling = sibling->GetNextSibling()) {
    if (sibling != aCheckedContent && sibling->IsElement()) {  // skip this node
      // if the current sibling is in the same group, clear it
      if (sibling->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::name,
                                            myGroupName, eCaseMatters)) {
        sibling->AsElement()->SetAttr(kNameSpaceID_None, nsGkAtoms::checked,
                                      u"false"_ns, true);
      }
    }
  }
}

void nsMenuItemX::SetKeyEquiv() {
  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;

  // Set key shortcut and modifiers
  nsAutoString keyValue;
  mContent->AsElement()->GetAttr(nsGkAtoms::key, keyValue);

  if (!keyValue.IsEmpty() && mContent->GetUncomposedDoc()) {
    dom::Element* keyContent =
        mContent->GetUncomposedDoc()->GetElementById(keyValue);
    if (keyContent) {
      nsAutoString keyChar;
      bool hasKey = keyContent->GetAttr(nsGkAtoms::key, keyChar);

      if (!hasKey || keyChar.IsEmpty()) {
        nsAutoString keyCodeName;
        keyContent->GetAttr(nsGkAtoms::keycode, keyCodeName);
        uint32_t charCode =
            nsCocoaUtils::ConvertGeckoNameToMacCharCode(keyCodeName);
        if (charCode) {
          keyChar.Assign(charCode);
        } else {
          keyChar.AssignLiteral(u" ");
        }
      }

      nsAutoString modifiersStr;
      keyContent->GetAttr(nsGkAtoms::modifiers, modifiersStr);
      uint8_t modifiers =
          nsMenuUtilsX::GeckoModifiersForNodeAttribute(modifiersStr);

      unsigned int macModifiers =
          nsMenuUtilsX::MacModifiersForGeckoModifiers(modifiers);
      mNativeMenuItem.keyEquivalentModifierMask = macModifiers;

      NSString* keyEquivalent =
          [[NSString stringWithCharacters:(unichar*)keyChar.get()
                                   length:keyChar.Length()] lowercaseString];
      if ([keyEquivalent isEqualToString:@" "]) {
        mNativeMenuItem.keyEquivalent = @"";
      } else {
        mNativeMenuItem.keyEquivalent = keyEquivalent;
      }

      return;
    }
  }

  // if the key was removed, clear the key
  mNativeMenuItem.keyEquivalent = @"";

  NS_OBJC_END_TRY_ABORT_BLOCK;
}

void nsMenuItemX::Dump(uint32_t aIndent) const {
  printf("%*s - item [%p] %-16s <%s>\n", aIndent * 2, "", this,
         mType == eSeparatorMenuItemType ? "----"
                                         : [mNativeMenuItem.title UTF8String],
         NS_ConvertUTF16toUTF8(mContent->NodeName()).get());
}

//
// nsChangeObserver
//

void nsMenuItemX::ObserveAttributeChanged(dom::Document* aDocument,
                                          nsIContent* aContent,
                                          nsAtom* aAttribute) {
  NS_OBJC_BEGIN_TRY_ABORT_BLOCK;

  if (!aContent) {
    return;
  }

  if (aContent == mContent) {  // our own content node changed
    if (aAttribute == nsGkAtoms::checked) {
      // if we're a radio menu, uncheck our sibling radio items. No need to
      // do any of this if we're just a normal check menu.
      if (mType == eRadioMenuItemType &&
          mContent->AsElement()->AttrValueIs(kNameSpaceID_None,
                                             nsGkAtoms::checked,
                                             nsGkAtoms::_true, eCaseMatters)) {
        UncheckRadioSiblings(mContent);
      }
      mMenuParent->SetRebuild(true);
    } else if (aAttribute == nsGkAtoms::hidden ||
               aAttribute == nsGkAtoms::collapsed) {
      bool isVisible = !nsMenuUtilsX::NodeIsHiddenOrCollapsed(mContent);
      if (isVisible != mIsVisible) {
        mIsVisible = isVisible;
        RefPtr<nsMenuItemX> self = this;
        mMenuParent->MenuChildChangedVisibility(nsMenuParentX::MenuChild(self),
                                                isVisible);
        if (mIsVisible) {
          SetupIcon();
        }
      }
      mMenuParent->SetRebuild(true);
    } else if (aAttribute == nsGkAtoms::label) {
      if (mType != eSeparatorMenuItemType) {
        nsAutoString newLabel;
        mContent->AsElement()->GetAttr(nsGkAtoms::label, newLabel);
        mNativeMenuItem.title = nsMenuUtilsX::GetTruncatedCocoaLabel(newLabel);
      }
    } else if (aAttribute == nsGkAtoms::key) {
      SetKeyEquiv();
    } else if (aAttribute == nsGkAtoms::image) {
      SetupIcon();
    } else if (aAttribute == nsGkAtoms::disabled) {
      mNativeMenuItem.enabled = !aContent->AsElement()->AttrValueIs(
          kNameSpaceID_None, nsGkAtoms::disabled, nsGkAtoms::_true,
          eCaseMatters);
    }
  } else if (aContent == mCommandElement) {
    // the only thing that really matters when the menu isn't showing is the
    // enabled state since it enables/disables keyboard commands
    if (aAttribute == nsGkAtoms::disabled) {
      // first we sync our menu item DOM node with the command DOM node
      nsAutoString commandDisabled;
      nsAutoString menuDisabled;
      aContent->AsElement()->GetAttr(nsGkAtoms::disabled, commandDisabled);
      mContent->AsElement()->GetAttr(nsGkAtoms::disabled, menuDisabled);
      if (!commandDisabled.Equals(menuDisabled)) {
        // The menu's disabled state needs to be updated to match the command.
        if (commandDisabled.IsEmpty()) {
          mContent->AsElement()->UnsetAttr(kNameSpaceID_None,
                                           nsGkAtoms::disabled, true);
        } else {
          mContent->AsElement()->SetAttr(kNameSpaceID_None, nsGkAtoms::disabled,
                                         commandDisabled, true);
        }
      }
      // now we sync our native menu item with the command DOM node
      mNativeMenuItem.enabled = !aContent->AsElement()->AttrValueIs(
          kNameSpaceID_None, nsGkAtoms::disabled, nsGkAtoms::_true,
          eCaseMatters);
    }
  }

  NS_OBJC_END_TRY_ABORT_BLOCK;
}

bool IsMenuStructureElement(nsIContent* aContent) {
  return aContent->IsAnyOfXULElements(nsGkAtoms::menu, nsGkAtoms::menuitem,
                                      nsGkAtoms::menuseparator);
}

void nsMenuItemX::ObserveContentRemoved(dom::Document* aDocument,
                                        nsIContent* aContainer,
                                        nsIContent* aChild,
                                        nsIContent* aPreviousSibling) {
  MOZ_RELEASE_ASSERT(mMenuGroupOwner);
  MOZ_RELEASE_ASSERT(mMenuParent);

  if (aChild == mCommandElement) {
    mMenuGroupOwner->UnregisterForContentChanges(mCommandElement);
    mCommandElement = nullptr;
  }
  if (IsMenuStructureElement(aChild)) {
    mMenuParent->SetRebuild(true);
  }
}

void nsMenuItemX::ObserveContentInserted(dom::Document* aDocument,
                                         nsIContent* aContainer,
                                         nsIContent* aChild) {
  MOZ_RELEASE_ASSERT(mMenuParent);

  // The child node could come from the custom element that is for display, so
  // only rebuild the menu if the child is related to the structure of the
  // menu.
  if (IsMenuStructureElement(aChild)) {
    mMenuParent->SetRebuild(true);
  }
}

void nsMenuItemX::SetupIcon() {
  if (mType != eRegularMenuItemType) {
    // Don't support icons on checkbox and radio menuitems, for consistency with
    // Windows & Linux.
    return;
  }

  mIcon->SetupIcon(mContent);
  mNativeMenuItem.image = mIcon->GetIconImage();
}

void nsMenuItemX::IconUpdated() {
  mNativeMenuItem.image = mIcon->GetIconImage();
}