/*
 * Copyright (c) 2010, 2025 BSI Business Systems Integration AG
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 */
import {
  AbstractLayout, CloseKeyStroke, DesktopPopupOpenEvent, DialogLayout, Dimension, EnumObject, Event, EventHandler, FocusRule, GlassPaneRenderer, graphics, HtmlComponent, InitModelOf, Insets, KeyStroke, KeyStrokeContext, Point,
  PopupEventMap, PopupLayout, PopupModel, Rectangle, scout, scrollbars, strings, Widget, widgets
} from '../index';
import $ from 'jquery';

export type PopupAlignment = EnumObject<typeof Popup.Alignment>;

export class Popup extends Widget implements PopupModel {
  declare model: PopupModel;
  declare eventMap: PopupEventMap;
  declare self: Popup;

  anchorBounds: Rectangle;
  animateOpening: boolean;
  animateResize: boolean;
  anchor: Widget;
  windowPaddingX: number;
  windowPaddingY: number;
  withGlassPane: boolean;
  withFocusContext: boolean;
  initialFocus: () => FocusRule | HTMLElement;
  focusableContainer: boolean;
  horizontalAlignment: PopupAlignment;
  verticalAlignment: PopupAlignment;
  calculatedHorizontalAlignment: PopupAlignment; // Gives the current alignment after applying horizontal and vertical switch options
  calculatedVerticalAlignment: PopupAlignment;
  horizontalSwitch: boolean;
  verticalSwitch: boolean;
  trimWidth: boolean;
  trimHeight: boolean;
  scrollType: PopupScrollType;
  windowResizeType: PopupWindowResizeType;
  boundToAnchor: boolean;
  withArrow: boolean;
  closeOnAnchorMouseDown: boolean;
  closeOnMouseDownOutside: boolean;
  closeOnOtherPopupOpen: boolean;
  modal: boolean;
  $anchor: JQuery;
  $arrow: JQuery;
  $arrowOverlay: JQuery;
  protected _documentMouseDownHandler: (event: MouseEvent) => void;
  protected _anchorScrollHandler: (event: JQuery.ScrollEvent) => void;
  protected _anchorLocationChangeHandler: EventHandler;
  protected _popupOpenHandler: EventHandler<DesktopPopupOpenEvent>;
  protected _glassPaneRenderer: GlassPaneRenderer;
  protected _openLater: boolean;
  protected _windowResizeHandler: (event: JQuery.ResizeEvent<Window>) => void;
  protected _anchorRenderHandler: EventHandler<Event<Widget>>;
  protected _withGlassPane: boolean;
  protected _closeOnAnchorMouseDown: boolean;
  protected _closeOnMouseDownOutside: boolean;
  protected _closeOnOtherPopupOpen: boolean;

  constructor() {
    super();

    this._documentMouseDownHandler = null;
    this._anchorScrollHandler = null;
    this._anchorLocationChangeHandler = null;
    this._popupOpenHandler = null;
    this._glassPaneRenderer = null;
    this.anchorBounds = null;
    this.animateOpening = false;
    this.animateResize = false;
    this.anchor = null;
    this.$anchor = null;
    this.windowPaddingX = 10;
    this.windowPaddingY = 5;
    this.withGlassPane = false;
    this.withFocusContext = true;
    this.initialFocus = () => FocusRule.AUTO;
    this.focusableContainer = false;
    this.horizontalAlignment = Popup.Alignment.LEFTEDGE;
    this.verticalAlignment = Popup.Alignment.BOTTOM;
    this.calculatedHorizontalAlignment = this.horizontalAlignment;
    this.calculatedVerticalAlignment = this.verticalAlignment;
    this.horizontalSwitch = false;
    this.verticalSwitch = true;
    this.trimWidth = false;
    this.trimHeight = true;
    this.scrollType = 'remove';
    this.windowResizeType = null;
    this.boundToAnchor = true;
    this.withArrow = false;
    this.closeOnAnchorMouseDown = true;
    this.closeOnMouseDownOutside = true;
    this.closeOnOtherPopupOpen = true;
    this.modal = false;
    this._openLater = false;

    this.$arrow = null;
    this.$arrowOverlay = null;
    this._windowResizeHandler = this._onWindowResize.bind(this);
    this._anchorRenderHandler = this._onAnchorRender.bind(this);
    this._addWidgetProperties(['anchor']);
    this._addPreserveOnPropertyChangeProperties(['anchor']);
  }

  // Note that these strings are also used as CSS classes
  static Alignment = {
    /**
     * The entire popup is positioned horizontally left of the anchor.
     */
    LEFT: 'left',
    /**
     * With arrow: The arrow at the left edge of the popup is aligned horizontally with the center of the anchor.
     * <p>
     * Without arrow: The left edges of both the popup and the anchor are aligned horizontally.
     */
    LEFTEDGE: 'leftedge',
    /**
     * The entire popup is positioned vertically above the anchor.
     */
    TOP: 'top',
    /**
     * With arrow: The arrow at the top edge of the popup is aligned vertically with the center of the anchor.
     * <p>
     * Without arrow: The top edges of both the popup and the anchor are aligned vertically.
     */
    TOPEDGE: 'topedge',
    /**
     * The centers of both the popup and the anchor are aligned in the respective dimension.
     */
    CENTER: 'center',
    /**
     * The entire popup is positioned horizontally to the right of the anchor.
     */
    RIGHT: 'right',
    /**
     * With arrow: The arrow at the right edge of the popup is aligned horizontally with the center of the anchor.
     * <p>
     * Without arrow: The right edges of both the popup and the anchor are aligned horizontally.
     */
    RIGHTEDGE: 'rightedge',
    /**
     * The entire popup is positioned vertically below the anchor.
     */
    BOTTOM: 'bottom',
    /**
     * With arrow: The arrow at the bottom edge of the popup is aligned vertically with the center of the anchor.
     * <p>
     * Without arrow: The bottom edges of both the popup and the anchor are aligned vertically.
     */
    BOTTOMEDGE: 'bottomedge'
  } as const;

  static SwitchRule = {};

  protected override _init(options: InitModelOf<this>) {
    super._init(options);

    if (options.location) {
      this.anchorBounds = new Rectangle(options.location.x, options.location.y, 0, 0);
    }
    this._setAnchor(this.anchor);
    this._setModal(this.modal);
  }

  protected override _createKeyStrokeContext(): KeyStrokeContext {
    return new KeyStrokeContext();
  }

  protected override _initKeyStrokeContext() {
    super._initKeyStrokeContext();

    this.keyStrokeContext.registerKeyStroke(this._createCloseKeyStroke());
  }

  /**
   * Override this method to provide a key stroke which closes the popup.
   * The default impl. returns a CloseKeyStroke which handles the ESC key.
   */
  protected _createCloseKeyStroke(): KeyStroke {
    return new CloseKeyStroke(this);
  }

  protected _createLayout(): AbstractLayout {
    return new PopupLayout(this);
  }

  protected _openWithoutParent() {
    // resolve parent for entry-point (don't change the actual property)
    if (this.parent.destroyed) {
      return;
    }
    if (this.parent.rendered || this.parent.rendering) {
      this.open(this._getDefaultOpen$Parent());
      return;
    }

    // This is important for popups rendered in another (native) browser window. The DOM in the popup window
    // is rendered later, so we must wait until that window is rendered and layouted. See popup-window.html.
    this.parent.one('render', () => {
      this.session.layoutValidator.schedulePostValidateFunction(() => {
        if (this.destroyed || this.rendered) {
          return;
        }
        this.open();
      });
    });
  }

  /**
   * Only called if parent.rendered or parent.rendering
   */
  protected _getDefaultOpen$Parent(): JQuery {
    return this.parent.entryPoint();
  }

  open($parent?: JQuery) {
    if (!$parent) {
      this._openWithoutParent();
      return;
    }

    this._triggerPopupOpenEvent();

    this._open($parent);
    if (this._openLater) {
      return;
    }

    if (!this.animateOpening) {
      // It is important that focusing happens after layouting and positioning, otherwise we'd focus an element
      // that is currently not on the screen. Which would cause the whole desktop to
      // be shifted for a few pixels.
      this.validateFocus();
      return;
    }
    // Give the browser time to layout properly before starting the animation to make sure it will be smooth.
    // The before-animate-open class will make the popup invisible (cannot use the invisible class because it is already used by _validateVisibility)
    this.$container.addClass('before-animate-open');
    setTimeout(() => {
      if (!this.rendered || this.removing) {
        return;
      }
      this.$container.removeClass('before-animate-open');
      this.validateFocus(); // Need to be done after popup is visible again because focus cannot be set on invisible elements.
      if (!this.rendered) {
        // Validate again, focus change could have removed the popup
        return;
      }
      this.$container.addClassForAnimation('animate-open');
      this.$container.oneAnimationEnd(() => this.findDesktop().repositionTooltips());
    });
  }

  validateFocus() {
    if (!this.withFocusContext) {
      return;
    }
    let context = this.session.focusManager.getFocusContext(this.$container);
    context.ready();
    if (!context.lastValidFocusedElement) {
      // No widget requested focus -> try to determine the initial focus
      this._requestInitialFocus();
    }
  }

  protected _requestInitialFocus() {
    let initialFocusElement = this.session.focusManager.evaluateFocusRule(this.$container, this.initialFocus());
    if (!initialFocusElement) {
      return;
    }
    this.session.focusManager.requestFocus(initialFocusElement);
  }

  protected _open($parent: JQuery) {
    this.render($parent);
    if (this._openLater) {
      return;
    }
    this.revalidateLayout();
    this.position();
  }

  override render($parent?: JQuery) {
    let $popupParent = $parent || this.entryPoint();
    // when the parent is detached it is not possible to render the popup -> do it later
    if (!$popupParent || !$popupParent.length || !$popupParent.isAttached()) {
      this._openLater = true;
      return;
    }
    super.render($popupParent);
  }

  protected override _render() {
    this.$container = this.$parent.appendDiv('popup');
    this.htmlComp = HtmlComponent.install(this.$container, this.session);
    this.htmlComp.validateRoot = true;
    this.htmlComp.setLayout(this._createLayout());
    this.$container.window().on('resize', this._windowResizeHandler);
  }

  protected override _renderProperties() {
    super._renderProperties();
    this._renderAnchor();
    this._renderWithArrow();
    this._renderWithFocusContext();
    this._renderWithGlassPane();
    this._renderModal();
  }

  protected override _postRender() {
    super._postRender();

    this._attachCloseHandlers();
    this._attachAnchorHandlers();
    this._handleGlassPanes();
  }

  protected override _onAttach() {
    super._onAttach();
    if (this._openLater && !this.rendered) {
      this._openLater = false;
      // Don't animate the opening when parent is attached. It doesn't look right if popups "pop up" when they are not really opening but only displayed again.
      // The same applies for detaching, see _renderOnDetach
      let currentAnimateOpening = this.animateOpening;
      this.animateOpening = false;
      this.open(this.$parent);
      this.animateOpening = currentAnimateOpening;
    }
  }

  protected override _renderOnDetach() {
    this._openLater = true;

    // keep $parent so that the DOM structure stays unchanged when re-attaching
    const $parent = this.$parent;

    // If parent is detached, popup should be removed immediately, otherwise animation would still be visible even though parent has already gone.
    super.removeImmediately();

    this.$parent = $parent;
    super._renderOnDetach();
  }

  override remove() {
    let currentAnimateRemoval = this.animateRemoval;
    if ((this.boundToAnchor && this.$anchor) && !this._isAnchorInView()) {
      this.animateRemoval = false;
    }
    super.remove();
    this.animateRemoval = currentAnimateRemoval;
  }

  protected override _remove() {
    this.$container.window().off('resize', this._windowResizeHandler);
    if (this._glassPaneRenderer) {
      this._glassPaneRenderer.removeGlassPanes();
    }
    if (this.withFocusContext) {
      this.session.focusManager.uninstallFocusContext(this.$container);
    }
    if (this.$arrow) {
      this.$arrow.remove();
      this.$arrow = null;
    }

    if (this.anchor) {
      // reopen when the anchor gets rendered again
      this.anchor.one('render', this._anchorRenderHandler);
    }

    // remove all clean-up handlers
    this._detachAnchorHandlers();
    this._detachCloseHandlers();
    super._remove();
  }

  protected override _destroy() {
    if (this.anchor) {
      this.anchor.off('render', this._anchorRenderHandler);
    }
    super._destroy();
  }

  protected _renderWithFocusContext() {
    if (!this.withFocusContext) {
      return;
    }
    // Add programmatic 'tabindex' if the $container itself should be focusable (used by context menu popups with no focusable elements)
    if (this.focusableContainer) {
      this.$container.attr('tabindex', -1);
    }
    // Don't allow an element to be focused while the popup is opened.
    // The popup will focus the element as soon as the opening is finished (see open());
    // The context needs to be already installed so that child elements don't try to focus an element outside of this context
    this.session.focusManager.installFocusContext(this.$container, FocusRule.PREPARE);
  }

  setModal(modal: boolean) {
    this.setProperty('modal', modal);
  }

  protected _setModal(modal: boolean) {
    this._setProperty('modal', modal);
    if (modal) {
      widgets.preserveAndSetProperty(() => this.setProperty('withGlassPane', true), () => this.withGlassPane, this, '_withGlassPane');
      widgets.preserveAndSetProperty(() => this.setProperty('closeOnAnchorMouseDown', false), () => this.closeOnAnchorMouseDown, this, '_closeOnAnchorMouseDown');
      widgets.preserveAndSetProperty(() => this.setProperty('closeOnMouseDownOutside', false), () => this.closeOnMouseDownOutside, this, '_closeOnMouseDownOutside');
      widgets.preserveAndSetProperty(() => this.setProperty('closeOnOtherPopupOpen', false), () => this.closeOnOtherPopupOpen, this, '_closeOnOtherPopupOpen');
    } else {
      widgets.resetProperty(v => this.setWithGlassPane(v), this, '_withGlassPane');
      widgets.resetProperty(v => this.setCloseOnAnchorMouseDown(v), this, '_closeOnAnchorMouseDown');
      widgets.resetProperty(v => this.setCloseOnMouseDownOutside(v), this, '_closeOnMouseDownOutside');
      widgets.resetProperty(v => this.setCloseOnOtherPopupOpen(v), this, '_closeOnOtherPopupOpen');
    }
  }

  protected _renderModal() {
    this.$container.toggleClass('modal', this.modal);
  }

  setWithGlassPane(withGlassPane: boolean) {
    if (!this.modal) {
      this.setProperty('withGlassPane', withGlassPane);
    } else {
      this._withGlassPane = withGlassPane;
    }
  }

  protected _renderWithGlassPane() {
    if (this.withGlassPane && !this._glassPaneRenderer) {
      this._glassPaneRenderer = new GlassPaneRenderer(this);
      this._glassPaneRenderer.renderGlassPanes();
    } else if (!this.withGlassPane && this._glassPaneRenderer) {
      this._glassPaneRenderer.removeGlassPanes();
      this._glassPaneRenderer = null;
    }
  }

  setCloseOnMouseDownOutside(closeOnMouseDownOutside: boolean) {
    if (!this.modal) {
      this.setProperty('closeOnMouseDownOutside', closeOnMouseDownOutside);
    } else {
      this._closeOnMouseDownOutside = closeOnMouseDownOutside;
    }
  }

  protected _renderCloseOnMouseDownOutside() {
    // The listener needs to be executed in the capturing phase -> prevents that _onDocumentMouseDown will be executed right after the popup gets opened using mouse down, otherwise the popup would be closed immediately
    if (this.closeOnMouseDownOutside && !this._documentMouseDownHandler) {
      this._documentMouseDownHandler = this._onDocumentMouseDown.bind(this);
      this.$container.document(true).addEventListener('mousedown', this._documentMouseDownHandler, true); // true=the event handler is executed in the capturing phase
    } else if (!this.closeOnMouseDownOutside && this._documentMouseDownHandler) {
      this.$container.document(true).removeEventListener('mousedown', this._documentMouseDownHandler, true);
      this._documentMouseDownHandler = null;
    }
  }

  setCloseOnAnchorMouseDown(closeOnAnchorMouseDown: boolean) {
    if (!this.modal) {
      this.setProperty('closeOnAnchorMouseDown', closeOnAnchorMouseDown);
    } else {
      this._closeOnAnchorMouseDown = closeOnAnchorMouseDown;
    }
  }

  setCloseOnOtherPopupOpen(closeOnOtherPopupOpen: boolean) {
    if (!this.modal) {
      this.setProperty('closeOnOtherPopupOpen', closeOnOtherPopupOpen);
    } else {
      this._closeOnOtherPopupOpen = closeOnOtherPopupOpen;
    }
  }

  protected _renderCloseOnOtherPopupOpen() {
    if (this.closeOnOtherPopupOpen && !this._popupOpenHandler) {
      this._popupOpenHandler = this._onPopupOpen.bind(this);
      this.session.desktop.on('popupOpen', this._popupOpenHandler);
    } else if (!this.closeOnOtherPopupOpen && this._popupOpenHandler) {
      this.session.desktop.off('popupOpen', this._popupOpenHandler);
      this._popupOpenHandler = null;
    }
  }

  setWithArrow(withArrow: boolean) {
    this.setProperty('withArrow', withArrow);
  }

  protected _renderWithArrow() {
    if (this.$arrow) {
      this.$arrow.remove();
      this.$arrow = null;
    }
    if (this.$arrowOverlay) {
      this.$arrowOverlay.remove();
      this.$arrowOverlay = null;
    }
    if (this.withArrow) {
      this.$arrowOverlay = this.$container.prependDiv('popup-arrow-overlay');
      this.$arrow = this.$container.prependDiv('popup-arrow');
      this._updateArrowClass();
    }
    this.$container.toggleClass('with-arrow', this.withArrow);
    this.invalidateLayoutTree();
  }

  protected _updateArrowClass(verticalAlignment?: PopupAlignment, horizontalAlignment?: PopupAlignment) {
    if (this.$arrow) {
      this.$arrow.removeClass(this._alignClasses());
      this.$arrow.addClass(this._computeArrowPositionClass(verticalAlignment, horizontalAlignment));
    }
  }

  protected _computeArrowPositionClass(verticalAlignment?: PopupAlignment, horizontalAlignment?: PopupAlignment): string {
    let Alignment = Popup.Alignment;
    let cssClass = '';
    horizontalAlignment = horizontalAlignment || this.horizontalAlignment;
    verticalAlignment = verticalAlignment || this.verticalAlignment;
    switch (horizontalAlignment) {
      case Alignment.LEFT:
        cssClass = Alignment.RIGHT;
        break;
      case Alignment.RIGHT:
        cssClass = Alignment.LEFT;
        break;
      default:
        cssClass = horizontalAlignment;
        break;
    }

    switch (verticalAlignment) {
      case Alignment.BOTTOM:
        cssClass += ' ' + Alignment.TOP;
        break;
      case Alignment.TOP:
        cssClass += ' ' + Alignment.BOTTOM;
        break;
      default:
        cssClass += ' ' + verticalAlignment;
        break;
    }
    return cssClass;
  }

  protected override _animateRemovalWhileRemovingParent(): boolean {
    if (!this.$anchor) {
      // Allow remove animations for popups without an anchor
      return true;
    }
    // If parent is the anchor, prevent remove animation to ensure popup will be removed together with the anchor
    return widgets.get(this.$anchor) !== this.parent;
  }

  protected override _isRemovalPrevented(): boolean {
    // If removal of a parent is pending due to an animation then don't return true to make sure popups are closed before the parent animation starts.
    // However, if the popup itself is removed by an animation, removal should be prevented to ensure remove() won't run multiple times.
    return this.removalPending;
  }

  close() {
    if (this.destroyed || this.destroying) {
      // Already closed, do nothing
      return;
    }
    let event = this.trigger('close');
    if (!event.defaultPrevented) {
      this.destroy();
    }
  }

  /**
   * Install listeners to close the popup once clicking outside the popup,
   * or changing the anchor's scroll position, or another popup is opened.
   */
  protected _attachCloseHandlers() {
    // Install mouse close handler
    this._renderCloseOnMouseDownOutside();
    // Install popup open close handler
    this._renderCloseOnOtherPopupOpen();
  }

  protected _attachAnchorHandlers() {
    if (!this.$anchor || !this.boundToAnchor || !this.scrollType) {
      return;
    }
    // Attach a scroll handler to each scrollable parent of the anchor
    this._anchorScrollHandler = this._onAnchorScroll.bind(this);
    scrollbars.onScroll(this.$anchor, this._anchorScrollHandler);

    // Attach a location change handler as well (will only work if the anchor is a widget which triggers a locationChange event, e.g. another Popup)
    let anchor = scout.widget(this.$anchor);
    if (anchor) {
      this._anchorLocationChangeHandler = this._onAnchorLocationChange.bind(this);
      anchor.on('locationChange', this._anchorLocationChangeHandler);
    }
  }

  protected _detachAnchorHandlers() {
    if (this._anchorScrollHandler) {
      scrollbars.offScroll(this._anchorScrollHandler);
      this._anchorScrollHandler = null;
    }
    if (this._anchorLocationChangeHandler) {
      let anchor = scout.widget(this.$anchor);
      if (anchor) {
        anchor.off('locationChange', this._anchorLocationChangeHandler);
        this._anchorLocationChangeHandler = null;
      }
    }
  }

  protected _detachCloseHandlers() {
    // Uninstall popup open close handler
    if (this._popupOpenHandler) {
      this.session.desktop.off('popupOpen', this._popupOpenHandler);
      this._popupOpenHandler = null;
    }

    // Uninstall mouse close handler
    if (this._documentMouseDownHandler) {
      this.$container.document(true).removeEventListener('mousedown', this._documentMouseDownHandler, true);
      this._documentMouseDownHandler = null;
    }
  }

  protected _onDocumentMouseDown(event: MouseEvent) {
    // in some cases the mousedown handler is executed although it has been already
    // detached on the _remove() method. However, since we're in the middle of
    // processing the mousedown event, it's too late to detach the event and we must
    // deal with that situation by checking the rendered flag. Otherwise we would
    // run into an error later, since the $container is not available anymore.
    // Use the internal flag because popup should be closed even if the parent removal is pending due to a remove animation
    if (!this._rendered) {
      return;
    }
    if (this._isMouseDownOutside(event)) {
      this._onMouseDownOutside(event);
    }
  }

  protected _isMouseDownOutside(event: MouseEvent): boolean {
    let eventTarget = event.target as HTMLElement;
    let $target = $(eventTarget),
      targetWidget;

    if (!this.closeOnAnchorMouseDown && this._isMouseDownOnAnchor(event)) {
      // 1. Often times, click on the anchor opens and 2. click closes the popup
      // If we were closing the popup here, it would not be possible to achieve the described behavior anymore -> let anchor handle open and close.
      return false;
    }

    targetWidget = scout.widget($target);

    // close the popup only if the click happened outside the popup and its children
    // It is not sufficient to check the dom hierarchy using $container.has($target)
    // because the popup may open other popups which probably is not a dom child but a sibling
    // Also ignore clicks if the popup is covert by a glasspane
    return !this.isOrHas(targetWidget) && !this.session.focusManager.isElementCovertByGlassPane(this.$container[0]);
  }

  protected _isMouseDownOnAnchor(event: MouseEvent): boolean {
    let eventTarget = event.target as HTMLElement;
    return !!this.$anchor && this.$anchor.isOrHas(eventTarget);
  }

  /**
   * Method invoked once a mouse down event occurs outside the popup.
   */
  protected _onMouseDownOutside(event: MouseEvent) {
    this.close();
  }

  /**
   * Method invoked once the 'options.$anchor' is scrolled.
   */
  protected _onAnchorScroll(event: JQuery.ScrollEvent) {
    if (!this.rendered) {
      // Scroll events may be fired delayed, even if scroll listeners are already removed.
      return;
    }
    this._handleAnchorPositionChange(event);
  }

  protected _handleAnchorPositionChange(event: JQuery.ScrollEvent | Event) {
    if (scout.isOneOf(this.scrollType, 'position', 'layoutAndPosition') && this.isOpeningAnimationRunning()) {
      // If the popup is opened with an animation which transforms the popup the sizes used by prefSize and position will likely be wrong.
      // In that case it is not possible to layout and position it correctly -> do nothing.
      return;
    }

    if (this.scrollType === 'position') {
      this.position();
    } else if (this.scrollType === 'layoutAndPosition') {
      this.revalidateLayout();
      this.position();
    } else if (this.scrollType === 'remove') {
      this.close();
    }
  }

  isOpeningAnimationRunning(): boolean {
    return this.rendered && this.animateOpening && this.$container.hasClass('animate-open');
  }

  protected _onAnchorLocationChange(event: Event) {
    this._handleAnchorPositionChange(event);
  }

  /**
   * Method invoked once a popup is opened.
   */
  protected _onPopupOpen(event: DesktopPopupOpenEvent) {
    // Make sure child popups don't close the parent popup, we must check parent hierarchy in both directions
    // Use case: Opening of a context menu or cell editor in a form popup
    // Also, popups covered by a glass pane (a modal dialog is open) must never be closed
    // Use case: popup opens a modal dialog. User clicks on a smartfield on this dialog -> underlying popup must not get closed
    let closable = !this.isOrHas(event.popup) && !event.popup.isOrHas(this);
    if (this.rendered) {
      closable = closable && !this.session.focusManager.isElementCovertByGlassPane(this.$container[0]);
    }
    if (closable) {
      this.close();
    }
  }

  setHorizontalAlignment(horizontalAlignment: PopupAlignment) {
    this.setProperty('horizontalAlignment', horizontalAlignment);
  }

  protected _renderHorizontalAlignment() {
    this._updateArrowClass();
    this.invalidateLayoutTree();
  }

  setVerticalAlignment(verticalAlignment: PopupAlignment) {
    this.setProperty('verticalAlignment', verticalAlignment);
  }

  protected _renderVerticalAlignment() {
    this._updateArrowClass();
    this.invalidateLayoutTree();
  }

  setHorizontalSwitch(horizontalSwitch: boolean) {
    this.setProperty('horizontalSwitch', horizontalSwitch);
  }

  protected _renderHorizontalSwitch() {
    this.invalidateLayoutTree();
  }

  setVerticalSwitch(verticalSwitch: boolean) {
    this.setProperty('verticalSwitch', verticalSwitch);
  }

  protected _renderVerticalSwitch() {
    this.invalidateLayoutTree();
  }

  setTrimWidth(trimWidth: boolean) {
    this.setProperty('trimWidth', trimWidth);
  }

  protected _renderTrimWidth() {
    this.invalidateLayoutTree();
  }

  setTrimHeight(trimHeight: boolean) {
    this.setProperty('trimHeight', trimHeight);
  }

  protected _renderTrimHeight() {
    this.invalidateLayoutTree();
  }

  prefLocation(verticalAlignment?: PopupAlignment, horizontalAlignment?: PopupAlignment): Point {
    if (!this.boundToAnchor || (!this.anchorBounds && !this.$anchor)) {
      return this._prefLocationWithoutAnchor();
    }
    return this._prefLocationWithAnchor(verticalAlignment, horizontalAlignment);
  }

  protected _prefLocationWithoutAnchor(): Point {
    return DialogLayout.positionContainerInWindow(this.$container);
  }

  protected _prefLocationWithAnchor(verticalAlignment?: PopupAlignment, horizontalAlignment?: PopupAlignment): Point {
    let $container = this.$container;
    horizontalAlignment = horizontalAlignment || this.horizontalAlignment;
    verticalAlignment = verticalAlignment || this.verticalAlignment;
    let anchorBounds = this.getAnchorBounds();
    let size = graphics.size($container, {exact: true});
    let margins = graphics.margins($container);
    let Alignment = Popup.Alignment;

    let arrowBounds = null;
    if (this.$arrow) {
      // Ensure the arrow has the correct class
      this._updateArrowClass(verticalAlignment, horizontalAlignment);
      // Remove margin added by moving logic, otherwise the bounds would not be correct
      graphics.setMargins(this.$arrow, new Insets());
      arrowBounds = graphics.bounds(this.$arrow);
    }

    $container.removeClass(this._alignClasses());
    $container.addClass(verticalAlignment + ' ' + horizontalAlignment);

    let widthWithMargin = size.width + margins.horizontal();
    let width = size.width;
    let x = anchorBounds.x;
    if (horizontalAlignment === Alignment.LEFT) {
      x -= widthWithMargin;
    } else if (horizontalAlignment === Alignment.LEFTEDGE) {
      if (this.withArrow) {
        x += anchorBounds.width / 2 - arrowBounds.center().x - margins.left;
      } else {
        x = anchorBounds.x - margins.left;
      }
    } else if (horizontalAlignment === Alignment.CENTER) {
      x += anchorBounds.width / 2 - width / 2 - margins.left;
    } else if (horizontalAlignment === Alignment.RIGHT) {
      x += anchorBounds.width;
    } else if (horizontalAlignment === Alignment.RIGHTEDGE) {
      if (this.withArrow) {
        x += anchorBounds.width / 2 - arrowBounds.center().x - margins.left;
      } else {
        x = anchorBounds.x + anchorBounds.width - width - margins.left;
      }
    }

    let heightWithMargin = size.height + margins.vertical();
    let height = size.height;
    let y = anchorBounds.y;
    if (verticalAlignment === Alignment.TOP) {
      y -= heightWithMargin;
    } else if (verticalAlignment === Alignment.TOPEDGE) {
      if (this.withArrow) {
        y += anchorBounds.height / 2 - arrowBounds.center().y - margins.top;
      } else {
        y = anchorBounds.y - margins.top;
      }
    } else if (verticalAlignment === Alignment.CENTER) {
      y += anchorBounds.height / 2 - height / 2 - margins.top;
    } else if (verticalAlignment === Alignment.BOTTOM) {
      y += anchorBounds.height;
    } else if (verticalAlignment === Alignment.BOTTOMEDGE) {
      if (this.withArrow) {
        y += anchorBounds.height / 2 - arrowBounds.center().y - margins.top;
      } else {
        y = anchorBounds.y + anchorBounds.height - height - margins.top;
      }
    }

    // this.$parent might not be at (0,0) of the document
    let parentOffset = this.$parent.offset();
    x -= parentOffset.left;
    y -= parentOffset.top;

    return new Point(x, y);
  }

  protected _alignClasses(): string {
    let Alignment = Popup.Alignment;
    return strings.join(' ', Alignment.LEFT, Alignment.LEFTEDGE, Alignment.CENTER, Alignment.RIGHT, Alignment.RIGHTEDGE,
      Alignment.TOP, Alignment.TOPEDGE, Alignment.CENTER, Alignment.BOTTOM, Alignment.BOTTOMEDGE);
  }

  getAnchorBounds(): Rectangle {
    let anchorBounds = this.anchorBounds;
    if (!this.$anchor) {
      // Use manually set anchor bounds
      return anchorBounds;
    }
    let realAnchorBounds = graphics.offsetBounds(this.$anchor, {
      exact: true
    });
    if (!anchorBounds) {
      // Use measured anchor bounds
      anchorBounds = realAnchorBounds;
    } else {
      // Fill incomplete anchorBounds from measured anchor bounds. This allows setting one
      // coordinate to a fixed value (e.g. the current mouse cursor position) while still
      // aligning the other coordinate to the $anchor element.
      //
      // Implementation note:
      // A coordinate is considered "undefined", when it is 0. Technically, this is not 100%
      // correct, but will give the desired result in most of the cases. If would require too
      // many code changes to correctly set missing values to undefined/null.
      if (!anchorBounds.x) {
        anchorBounds.x = realAnchorBounds.x;
        anchorBounds.width = realAnchorBounds.width;
      }
      if (!anchorBounds.y) {
        anchorBounds.y = realAnchorBounds.y;
        anchorBounds.height = realAnchorBounds.height;
      }
    }
    return anchorBounds;
  }

  getWindowSize(): Dimension {
    let $window = this.$parent.window();
    return new Dimension($window.width(), $window.height());
  }

  /**
   * @returns Point the amount of overlap at the window borders.
   * A positive value indicates that it is overlapping the right / bottom border, a negative value indicates that it is overlapping the left / top border.
   * Prefers the right and bottom over the left and top border, meaning if a positive value is returned it does not mean that the left border is overlapping as well.
   */
  overlap(location: Point, includeMargin?: boolean): Point {
    let $container = this.$container;
    if (!$container || !location) {
      return null;
    }
    includeMargin = scout.nvl(includeMargin, true);
    let containerSize = graphics.size($container, {exact: true, includeMargin: includeMargin});
    let width = containerSize.width;
    let height = containerSize.height;
    let popupBounds = new Rectangle(location.x, location.y, width, height);
    let bounds = graphics.offsetBounds($container.entryPoint(), true);

    let overlapX = popupBounds.right() + this.windowPaddingX - bounds.width;
    if (overlapX < 0) {
      overlapX = Math.min(popupBounds.x - this.windowPaddingX - bounds.x, 0);
    }
    let overlapY = popupBounds.bottom() + this.windowPaddingY - bounds.height;
    if (overlapY < 0) {
      overlapY = Math.min(popupBounds.y - this.windowPaddingY - bounds.y, 0);
    }
    return new Point(overlapX, overlapY);
  }

  adjustLocation(location: Point, switchIfNecessary?: boolean): Point {
    this.calculatedVerticalAlignment = this.verticalAlignment;
    this.calculatedHorizontalAlignment = this.horizontalAlignment;
    let overlap = this.overlap(location);

    // Reset arrow style
    if (this.$arrow) {
      this._updateArrowClass(this.calculatedVerticalAlignment, this.calculatedHorizontalAlignment);
      graphics.setMargins(this.$arrow, new Insets());
    }

    location = location.clone();
    // Ignore very small overlaps (e.g. 0.3px). This could happen if anchor position is fractional and popup has a margin that is not
    // Example: anchor has top: 10px and margin-top: 10px, browser renders it at 9.984px (due to zoom) but margin stays at 10px
    // -> location.y would be -0.16px resulting in a popup switch so that the popup will be displayed outside of the window
    if (Math.abs(overlap.y) >= 1) {
      let verticalSwitch = scout.nvl(switchIfNecessary, this.verticalSwitch);
      if (verticalSwitch) {
        // Switch vertical alignment
        this.calculatedVerticalAlignment = Popup.SwitchRule[this.calculatedVerticalAlignment];
        location.y = this.prefLocation(this.calculatedVerticalAlignment).y;
      } else {
        // Move popup to the top until it gets fully visible (if switch is disabled)
        location.y -= overlap.y;
      }
    }
    // Reason for >= 1 see above
    if (Math.abs(overlap.x) >= 1) {
      let horizontalSwitch = scout.nvl(switchIfNecessary, this.horizontalSwitch);
      if (horizontalSwitch) {
        // Switch horizontal alignment
        this.calculatedHorizontalAlignment = Popup.SwitchRule[this.calculatedHorizontalAlignment];
        location.x = this.prefLocation(this.calculatedVerticalAlignment, this.calculatedHorizontalAlignment).x;
      } else {
        // Move popup to the left until it gets fully visible (if switch is disabled)
        location.x -= overlap.x;
      }
    }

    // Also move arrow so that it still points to the center of the anchor
    if (this.$arrow) {
      if (overlap.y !== 0 && (this.$arrow.hasClass(Popup.Alignment.LEFT) || this.$arrow.hasClass(Popup.Alignment.RIGHT))) {
        if (overlap.y > 0) {
          this.$arrow.cssMarginTop(overlap.y);
        } else {
          this.$arrow.cssMarginBottom(-overlap.y);
        }
      }
      if (overlap.x !== 0 && (this.$arrow.hasClass(Popup.Alignment.TOP) || this.$arrow.hasClass(Popup.Alignment.BOTTOM))) {
        if (overlap.x > 0) {
          this.$arrow.cssMarginLeft(overlap.x);
        } else {
          this.$arrow.cssMarginRight(-overlap.x);
        }
      }
    }

    return location;
  }

  position(switchIfNecessary?: boolean) {
    if (!this.rendered) {
      return;
    }
    this._validateVisibility();
    this._position(switchIfNecessary);
  }

  protected _position(switchIfNecessary?: boolean) {
    let location = this.prefLocation();
    if (!location) {
      return;
    }
    location = this.adjustLocation(location, switchIfNecessary);
    this.setLocation(location);
  }

  setLocation(location: Point) {
    if (!this.rendered) {
      return;
    }
    this.$container
      .css('left', location.x)
      .css('top', location.y);
    this._triggerLocationChange();
  }

  /**
   * Popups with an anchor must only be visible if the anchor is in view (prevents that the popup points at an invisible anchor)
   */
  protected _validateVisibility() {
    if (!this.boundToAnchor || !this.$anchor) {
      return;
    }
    let inView = this._isAnchorInView();
    let needsLayouting = this.$container.hasClass('invisible') === inView && inView;
    this.$container.toggleClass('invisible', !inView); // Use visibility: hidden to not break layouting / size measurement
    if (needsLayouting) {
      let currentAnimateResize = this.animateResize;
      this.animateResize = false;
      this.revalidateLayout();
      this.animateResize = currentAnimateResize;
      if (this.withFocusContext) {
        this.session.focusManager.validateFocus();
      }
    }
  }

  protected _isAnchorInView(): boolean {
    if (!this.boundToAnchor || !this.$anchor) {
      return;
    }
    let anchorBounds = this.getAnchorBounds();
    return scrollbars.isLocationInView(anchorBounds.center(), this.$anchor.scrollParents());
  }

  protected _triggerLocationChange() {
    this.trigger('locationChange');
  }

  /**
   * Fire event that this popup is about to open.
   */
  protected _triggerPopupOpenEvent() {
    this.session.desktop.trigger('popupOpen', {
      popup: this
    });
  }

  belongsTo($anchor: JQuery): boolean {
    return this.$anchor[0] === $anchor[0];
  }

  set$Anchor($anchor: JQuery) {
    if (this.$anchor) {
      this._detachAnchorHandlers();
    }
    this.setProperty('$anchor', $anchor);
    if (this.rendered) {
      this._attachAnchorHandlers();
      this.revalidateLayout();
      if (!this.animateResize) { // PopupLayout will move it -> don't break move animation
        this.position();
      }
    }
  }

  isOpen(): boolean {
    return this.rendered;
  }

  ensureOpen() {
    if (!this.isOpen()) {
      this.open();
    }
  }

  setAnchor(anchor: Widget) {
    this.setProperty('anchor', anchor);
  }

  protected _setAnchor(anchor: Widget) {
    if (anchor) {
      this.setParent(anchor);
    }
    this._setProperty('anchor', anchor);
  }

  protected _onAnchorRender(event: Event<Widget>) {
    this.session.layoutValidator.schedulePostValidateFunction(() => {
      if (this.rendered || this.destroyed) {
        return;
      }
      if (this.anchor && !this.anchor.rendered) {
        // Anchor was removed again while this function was scheduled -> wait again for rendering
        this.anchor.one('render', this._anchorRenderHandler);
        return;
      }
      let currentAnimateOpening = this.animateOpening;
      this.animateOpening = false;
      this.open();
      this.animateOpening = currentAnimateOpening;
    });
  }

  protected _renderAnchor() {
    if (this.anchor) {
      this.set$Anchor(this.anchor.$container);
    }
  }

  protected _onWindowResize(event: JQuery.ResizeEvent<Window>) {
    if (!this.rendered) {
      // may already be removed if a parent popup is closed during the resize event
      return;
    }
    if (this.windowResizeType === 'position') {
      this.position();
    } else if (this.windowResizeType === 'layoutAndPosition') {
      this.revalidateLayoutTree(false);
      this.position();
    } else if (this.windowResizeType === 'remove') {
      this.close();
    }
  }

  protected _handleGlassPanes() {
    let parentCoveredByGlassPane = this.session.focusManager.isElementCovertByGlassPane(this.parent.$container);
    // if a popup is covered by a glass pane the glass pane's need to be re-rendered to ensure a glass pane is also painted over the popup
    if (parentCoveredByGlassPane) {
      this.session.focusManager.rerenderGlassPanes();
    }
  }
}

((() => {
  // Initialize switch rules (wrapped in IIFE to have local function scope for the variables)
  let SwitchRule = Popup.SwitchRule;
  let Alignment = Popup.Alignment;
  SwitchRule[Alignment.LEFT] = Alignment.RIGHT;
  SwitchRule[Alignment.LEFTEDGE] = Alignment.RIGHTEDGE;
  SwitchRule[Alignment.TOP] = Alignment.BOTTOM;
  SwitchRule[Alignment.TOPEDGE] = Alignment.BOTTOMEDGE;
  SwitchRule[Alignment.CENTER] = Alignment.CENTER;
  SwitchRule[Alignment.RIGHT] = Alignment.LEFT;
  SwitchRule[Alignment.RIGHTEDGE] = Alignment.LEFTEDGE;
  SwitchRule[Alignment.BOTTOM] = Alignment.TOP;
  SwitchRule[Alignment.BOTTOMEDGE] = Alignment.TOPEDGE;
})());

export type PopupScrollType = 'position' | 'layoutAndPosition' | 'remove' | 'none';
export type PopupWindowResizeType = 'position' | 'layoutAndPosition' | 'remove';
