/**
 * @license
 * Copyright 2011 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

// Former goog.module ID: Blockly.ContextMenu

import type {Block} from './block.js';
import type {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import {config} from './config.js';
import type {
  ContextMenuOption,
  LegacyContextMenuOption,
} from './contextmenu_registry.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
import * as serializationBlocks from './serialization/blocks.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import {Rect} from './utils/rect.js';
import * as svgMath from './utils/svg_math.js';
import * as WidgetDiv from './widgetdiv.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import * as Xml from './xml.js';

/**
 * Which block is the context menu attached to?
 */
let currentBlock: Block | null = null;

const dummyOwner = {};

/**
 * Gets the block the context menu is currently attached to.
 * It is not recommended that you use this function; instead,
 * use the scope object passed to the context menu callback.
 *
 * @returns The block the context menu is attached to.
 */
export function getCurrentBlock(): Block | null {
  return currentBlock;
}

/**
 * Sets the block the context menu is currently attached to.
 *
 * @param block The block the context menu is attached to.
 */
export function setCurrentBlock(block: Block | null) {
  currentBlock = block;
}

/**
 * Menu object.
 */
let menu_: Menu | null = null;

/**
 * Construct the menu based on the list of options and show the menu.
 *
 * @param menuOpenEvent Event that caused the menu to open.
 * @param options Array of menu options.
 * @param rtl True if RTL, false if LTR.
 * @param workspace The workspace associated with the context menu, if any.
 * @param location The screen coordinates at which to show the menu.
 */
export function show(
  menuOpenEvent: Event,
  options: (ContextMenuOption | LegacyContextMenuOption)[],
  rtl: boolean,
  workspace?: WorkspaceSvg,
  location?: Coordinate,
) {
  WidgetDiv.show(dummyOwner, rtl, dispose, workspace);
  if (!options.length) {
    hide();
    return;
  }

  if (!location) {
    if (menuOpenEvent instanceof PointerEvent) {
      location = new Coordinate(menuOpenEvent.clientX, menuOpenEvent.clientY);
    } else {
      // We got a keyboard event that didn't tell us where to open the menu, so just guess
      console.warn('Context menu opened with keyboard but no location given');
      location = new Coordinate(0, 0);
    }
  }
  const menu = populate_(options, rtl, menuOpenEvent, location);
  menu_ = menu;

  position_(menu, rtl, location);
  // 1ms delay is required for focusing on context menus because some other
  // mouse event is still waiting in the queue and clears focus.
  setTimeout(function () {
    menu.focus();
  }, 1);
  currentBlock = null; // May be set by Blockly.Block.
}

/**
 * Create the context menu object and populate it with the given options.
 *
 * @param options Array of menu options.
 * @param rtl True if RTL, false if LTR.
 * @param menuOpenEvent The event that triggered the context menu to open.
 * @param location The screen coordinates at which to show the menu.
 * @returns The menu that will be shown on right click.
 */
function populate_(
  options: (ContextMenuOption | LegacyContextMenuOption)[],
  rtl: boolean,
  menuOpenEvent: Event,
  location: Coordinate,
): Menu {
  /* Here's what one option object looks like:
      {text: 'Make It So',
       enabled: true,
       callback: Blockly.MakeItSo}
    */
  const menu = new Menu();
  menu.setRole(aria.Role.MENU);
  for (let i = 0; i < options.length; i++) {
    const option = options[i];
    if (option.separator) {
      menu.addChild(new MenuSeparator());
      continue;
    }

    const menuItem = new MenuItem(option.text);
    menuItem.setRightToLeft(rtl);
    menuItem.setRole(aria.Role.MENUITEM);
    menu.addChild(menuItem);
    menuItem.setEnabled(option.enabled);
    if (option.enabled) {
      const actionHandler = function (p1: MenuItem, menuSelectEvent: Event) {
        hide();
        requestAnimationFrame(() => {
          setTimeout(() => {
            // If .scope does not exist on the option, then the callback
            // will not be expecting a scope parameter, so there should be
            // no problems. Just assume it is a ContextMenuOption and we'll
            // pass undefined if it's not.
            option.callback(
              (option as ContextMenuOption).scope,
              menuOpenEvent,
              menuSelectEvent,
              location,
            );
          }, 0);
        });
      };
      menuItem.onAction(actionHandler, {});
    }
  }
  return menu;
}

/**
 * Add the menu to the page and position it correctly.
 *
 * @param menu The menu to add and position.
 * @param rtl True if RTL, false if LTR.
 * @param location The location at which to anchor the menu.
 */
function position_(menu: Menu, rtl: boolean, location: Coordinate) {
  // Record windowSize and scrollOffset before adding menu.
  const viewportBBox = svgMath.getViewportBBox();
  // This one is just a point, but we'll pretend that it's a rect so we can use
  // some helper functions.
  const anchorBBox = new Rect(
    location.y + viewportBBox.top,
    location.y + viewportBBox.top,
    location.x + viewportBBox.left,
    location.x + viewportBBox.left,
  );

  createWidget_(menu);
  const menuSize = menu.getSize();

  if (rtl) {
    anchorBBox.left += menuSize.width;
    anchorBBox.right += menuSize.width;
    viewportBBox.left += menuSize.width;
    viewportBBox.right += menuSize.width;
  }

  WidgetDiv.positionWithAnchor(viewportBBox, anchorBBox, menuSize, rtl);
  // Calling menuDom.focus() has to wait until after the menu has been placed
  // correctly.  Otherwise it will cause a page scroll to get the misplaced menu
  // in view.  See issue #1329.
  menu.focus();
}

/**
 * Create and render the menu widget inside Blockly's widget div.
 *
 * @param menu The menu to add to the widget div.
 */
function createWidget_(menu: Menu) {
  const div = WidgetDiv.getDiv();
  if (!div) {
    throw Error('Attempting to create a context menu when widget div is null');
  }
  const menuDom = menu.render(div);
  dom.addClass(menuDom, 'blocklyContextMenu');
  // Prevent system context menu when right-clicking a Blockly context menu.
  browserEvents.conditionalBind(
    menuDom as EventTarget,
    'contextmenu',
    null,
    haltPropagation,
  );
  // Focus only after the initial render to avoid issue #1329.
  menu.focus();
}
/**
 * Halts the propagation of the event without doing anything else.
 *
 * @param e An event.
 */
function haltPropagation(e: Event) {
  // This event has been handled.  No need to bubble up to the document.
  e.preventDefault();
  e.stopPropagation();
}

/**
 * Hide the context menu.
 */
export function hide() {
  WidgetDiv.hideIfOwner(dummyOwner);
  currentBlock = null;
  menu_?.dispose();
  menu_ = null;
}

/**
 * Dispose of the menu.
 */
export function dispose() {
  if (menu_) {
    menu_.dispose();
    menu_ = null;
  }
}

/**
 * Create a callback function that creates and configures a block,
 *   then places the new block next to the original and returns it.
 *
 * @param block Original block.
 * @param state XML or JSON object representation of the new block.
 * @returns Function that creates a block.
 */
export function callbackFactory(
  block: Block,
  state: Element | serializationBlocks.State,
): () => BlockSvg {
  return () => {
    eventUtils.disable();
    let newBlock: BlockSvg;
    try {
      if (state instanceof Element) {
        newBlock = Xml.domToBlockInternal(state, block.workspace!) as BlockSvg;
      } else {
        newBlock = serializationBlocks.appendInternal(
          state,
          block.workspace,
        ) as BlockSvg;
      }
      // Move the new block next to the old block.
      const xy = block.getRelativeToSurfaceXY();
      if (block.RTL) {
        xy.x -= config.snapRadius;
      } else {
        xy.x += config.snapRadius;
      }
      xy.y += config.snapRadius * 2;
      newBlock.moveBy(xy.x, xy.y);
    } finally {
      eventUtils.enable();
    }
    if (eventUtils.isEnabled() && !newBlock.isShadow()) {
      eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(newBlock));
    }
    getFocusManager().focusNode(newBlock);
    return newBlock;
  };
}

/**
 * Returns the contextual menu if it is currently being shown.
 */
export function getMenu(): Menu | null {
  return menu_;
}
