import { VegaSlimmer } from 'vega-slimmer/core';
import { Alignments, Placements } from '../../../constants/ui';
import { isTranslocationType } from '../../../types/type-guard';
import { LogUtility } from '../../../utils/log';
import ResponsiveFormatFacade from '../../formatter/responsive-map-formatter/responsive-format-facade';
/**
 * calculate position
 */
export class BasePositionCalculationStrategy extends VegaSlimmer {
  /**
   * Whether the controller should adjust/recompute position at this time.
   *
   * This hook allows strategies to avoid unnecessary work (for example, on scroll) by deciding
   * whether a re-position is required.
   *
   * @returns {boolean} True when the controller should proceed with width/height/position adjustments.
   */
  shouldAdjustPosition() {
    return true;
  }
  /**
   * Get the position box.
   *
   * @param {PlacementType[]} placement - previous placement.
   * @param {PositionAlignmentType[]} alignment - previous placement.
   * @returns {ElementPositionBox} A function that takes in a container, element, target, placement, and translocation and
   * returns an ElementPositionBox.
   */
  getPositionBox(placement, alignment) {
    const placements = placement && placement.length > 0 ? placement : this.getPlacements();
    const alignments = alignment && alignment.length > 0 ? alignment : this.getAlignments();
    const targetBox = this.getTargetBoundBox();
    const elementBox = this.getElementBoundBox();
    const containerBox = this.getElementScrollDimension();
    const suitablePlacement = this.getSuitablePlacement(containerBox, elementBox, targetBox, placements);
    const activePlacement = suitablePlacement || placements[0];
    const suitableAlignment = this.getSuitableAlignment(containerBox, elementBox, targetBox, activePlacement, alignments);
    const activeAlignment = suitableAlignment || alignments[0];
    const positionBox = this.calculatePosition(elementBox, targetBox, activePlacement, activeAlignment);
    const translocationBox = this.getTranslocationBox(activePlacement);
    positionBox.placement = suitablePlacement;
    positionBox.alignment = suitableAlignment;
    positionBox.left += translocationBox.X;
    positionBox.top += translocationBox.Y;
    if (!this.showConfig.positionRelativeTo && this.showConfig.isScreenPosition) {
      this.adjustPositionForViewport(positionBox, elementBox, targetBox, activePlacement);
    }
    return positionBox;
  }
  /**
   * Whether the alignment space is enough
   *
   * @param {ElementScrollDimension} containerBox - The bounding box of the container element.
   * @param {ElementBoundBox} targetBox - The position of the target element.
   * @param {ElementBoundBox} elementBox - The box of the element that we're trying to position.
   * @param {PlacementType} placement - The placement of the element relative to the target.
   * @param {PlacementType} alignment - The alignment of the element relative to the target.
   * @returns {boolean} return true if it has enough space, otherwise false
   */
  hasEnoughAlignmentSpace(containerBox, targetBox, elementBox, placement, alignment) {
    if (['top', 'bottom'].includes(placement)) {
      switch (alignment) {
        case 'start': {
          return targetBox.left + elementBox.width <= containerBox.width;
        }
        case 'center': {
          return (targetBox.left >= (elementBox.width - targetBox.width) / 2 &&
            targetBox.left + targetBox.width / 2 + elementBox.width / 2 <= containerBox.width);
        }
        case 'end': {
          return targetBox.left + targetBox.width >= elementBox.width;
        }
      }
    }
    if (['right', 'left'].includes(placement)) {
      switch (alignment) {
        case 'start': {
          return targetBox.top + elementBox.height <= containerBox.height;
        }
        case 'center': {
          return (targetBox.top >= (elementBox.height - targetBox.height) / 2 &&
            targetBox.top + targetBox.height / 2 + elementBox.height / 2 <= containerBox.height);
        }
        case 'end': {
          return targetBox.top + targetBox.height >= elementBox.height;
        }
      }
    }
    /* istanbul ignore next */
    return false;
  }
  /**
   * Calculate the left position of the element
   *
   * @param {ElementBoundBox} elementBox - The position box of the element.
   * @param {ElementBoundBox} targetBox - The position box of the target element that is attached to.
   * @param {PlacementType} placement - The placement of the element.
   * @param {PositionAlignmentType} alignment - The alignment of the element
   * @returns {number} The left position of the element.
   */
  calculateLeft(elementBox, targetBox, placement, alignment) {
    switch (placement) {
      case 'top':
      case 'bottom': {
        if (alignment === 'end') {
          return targetBox.left + targetBox.width - elementBox.width;
        }
        else if (alignment === 'center') {
          return targetBox.left - (elementBox.width - targetBox.width) / 2;
        }
        else {
          return targetBox.left;
        }
      }
      case 'left': {
        return targetBox.left - elementBox.width;
      }
      case 'right': {
        return targetBox.left + targetBox.width;
      }
      /* istanbul ignore next */
      default:
        throw new Error(`Unexpected placement: ${placement}. Expected values are top, bottom, left, or right.`);
    }
  }
  /**
   * Calculate the top position of the element
   *
   * @param {ElementBoundBox} elementBox - The position box of the element.
   * @param {ElementBoundBox} targetBox - The position box of the target element that is attached to.
   * @param {PlacementType} placement - The placement of the element.
   * @param {PositionAlignmentType} alignment - The alignment of the element
   * @returns {number} the top position of the element.
   */
  calculateTop(elementBox, targetBox, placement, alignment) {
    switch (placement) {
      case 'left':
      case 'right': {
        if (alignment === 'end') {
          return targetBox.top + targetBox.height - elementBox.height;
        }
        else if (alignment === 'center') {
          return targetBox.top - (elementBox.height - targetBox.height) / 2;
        }
        else {
          return targetBox.top;
        }
      }
      case 'top': {
        return targetBox.top - elementBox.height;
      }
      case 'bottom': {
        return targetBox.top + targetBox.height;
      }
      /* istanbul ignore next */
      default:
        throw new Error(`Unexpected placement: ${placement}. Expected values are top, bottom, left, or right.`);
    }
  }
  /**
   * It returns acceptable alignments.
   *
   * @returns {PositionAlignmentType[]} acceptable alignments
   */
  getAlignments() {
    const alignment = this.showConfig.alignment;
    if (typeof alignment === 'string') {
      const partial = Alignments.filter((item) => item !== alignment);
      return [alignment, ...partial];
    }
    else if (Array.isArray(alignment)) {
      return alignment;
    }
    else {
      return [...Alignments];
    }
  }
  /**
   * It takes a list of placements and returns the first placement that has enough space to place the
   * element
   *
   * @param {ElementScrollDimension} containerBox - The box of the container element.
   * @param {ElementBoundBox} elementBox - The box of the element to be placed.
   * @param {ElementBoundBox} targetBox - The box of the target element.
   * @param {PlacementType[]} placements - The placements that we want to try.
   * @returns {Nullable<PlacementType>} A function that takes in a containerBox, elementBox, targetBox, placements, and element.
   */
  getSuitablePlacement(containerBox, elementBox, targetBox, placements) {
    const defaultPlacement = placements[0];
    const suitablePlacement = placements.find((placement) => this.hasEnoughSpace(containerBox, placement, targetBox, elementBox));
    if (suitablePlacement) {
      return suitablePlacement;
    }
    else {
      LogUtility.warn(`
                No enough space found for placing element and we will keep using the defined placement:
                ${defaultPlacement}
            `);
      return null;
    }
  }
  /**
   * "Get the width, height, and top position of an element."
   *
   * The function takes an element as an argument and returns an object with three properties: width,
   * height, and top.
   *
   * Note: If element is hide, we should temporary change it to `block` to get the bounding client rect of element,
   * After do that, this element will change to origin `hide`, if not, it will be contained in the calculate of `document.documentElement.scrollHeight`
   *
   * @returns {ElementBoundBox} An object with the width, height, and top properties of the element.
   */
  getElementBoundBox() {
    const element = this.showConfig.appendableElement;
    const isElementHide = element.classList.contains('vega-force-hidden');
    if (isElementHide)
      element.classList.remove('vega-force-hidden');
    const elementRect = element.getBoundingClientRect();
    if (isElementHide)
      element.classList.add('vega-force-hidden');
    return {
      width: elementRect.width,
      height: elementRect.height,
      left: elementRect.left,
      top: elementRect.top,
    };
  }
  /**
   * It takes a placement string or array of placements and returns an array of placements
   * if given placement is single, we should completion the placements with the default {@link Placements}
   * and make given placement as default.
   * if given placement is array, we should use it directly, even it partial of {@link Placements}.
   * if not placement give. we should use {@link Placements}.
   *
   * @returns {PlacementType[]} ordered placements.
   */
  getPlacements() {
    const placement = this.showConfig.placement;
    if (typeof placement === 'string') {
      const partial = Placements.filter((direction) => direction !== placement);
      return [placement, ...partial];
    }
    else if (Array.isArray(placement)) {
      return placement;
    }
    else {
      return [...Placements];
    }
  }
  /**
   * It takes a placement, a target box, and a element box, and returns an element box
   *
   * @param {ElementBoundBox} elementBox - The position of the element element.
   * @param {ElementBoundBox} targetBox - The position of the target element.
   * @param {PlacementType} placement - The placement of the element.
   * @param {PositionAlignmentType} alignment - The alignment of the element
   * @returns {ElementPositionBox} An object with top and left properties.
   */
  calculatePosition(elementBox, targetBox, placement, alignment) {
    return {
      top: this.calculateTop(elementBox, targetBox, placement, alignment),
      left: this.calculateLeft(elementBox, targetBox, placement, alignment),
    };
  }
  /**
   * Keeps the vertical position within the visible viewport
   *
   * @param {number} top - The calculated top position.
   * @param {number} elementHeight - The height of the element.
   * @returns {number} The top position constrained to the viewport.
   */
  keepVerticallyInView(top, elementHeight) {
    return Math.max(0, Math.min(top, window.innerHeight - elementHeight));
  }
  /**
   * Keeps the horizontal position within the visible viewport
   *
   * @param {number} left - The calculated left position.
   * @param {number} elementWidth - The width of the element.
   * @returns {number} The left position constrained to the viewport.
   */
  keepHorizontallyInView(left, elementWidth) {
    return Math.max(0, Math.min(left, window.innerWidth - elementWidth));
  }
  /**
   * Checks if the target element is visible in the viewport
   *
   * @param {ElementBoundBox} targetBox - The bounding box of the target element.
   * @returns {boolean} True if target is visible in viewport.
   */
  isTargetInView(targetBox) {
    const innerHeight = window.innerHeight;
    const innerWidth = window.innerWidth;
    const targetBottom = targetBox.top + targetBox.height;
    const targetRight = targetBox.left + targetBox.width;
    return (targetBox.top <= innerHeight &&
      targetBottom >= 0 &&
      targetBox.left <= innerWidth &&
      targetRight >= 0);
  }
  /**
   * Corrects `positionBox` so the element stays within the viewport after translocation.
   * Only runs when the target is visible; skips correction entirely if the target has
   * scrolled out of view so the element simply follows it.
   *
   * @param {ElementPositionBox} positionBox - The final position box to adjust (mutated in place).
   * @param {ElementBoundBox} elementBox - Dimensions of the positioned element.
   * @param {ElementBoundBox} targetBox - Bounding box of the target element.
   * @param {PlacementType} placement - Active placement used to select the correction axis.
   */
  adjustPositionForViewport(positionBox, elementBox, targetBox, placement) {
    if (!this.isTargetInView(targetBox)) {
      return;
    }
    if (placement === 'top' || placement === 'bottom') {
      positionBox.top = this.adjustForVerticalOverflow(positionBox.top, elementBox.height, targetBox.height);
    }
    else if (placement === 'left' || placement === 'right') {
      positionBox.left = this.adjustForHorizontalOverflow(positionBox.left, elementBox.width, targetBox.width);
    }
    positionBox.top = this.keepVerticallyInView(positionBox.top, elementBox.height);
    positionBox.left = this.keepHorizontallyInView(positionBox.left, elementBox.width);
  }
  /**
   * Nudges `top` so the element stays within the viewport vertically.
   * Only corrects when the overflow is less than `targetHeight` to keep the element
   * visually anchored to its target; larger overflows are handled by `keepVerticallyInView`.
   *
   * @param {number} top - The calculated top position.
   * @param {number} elementHeight - Height of the positioned element.
   * @param {number} targetHeight - Height of the target, used as the max correctable overflow.
   * @returns {number} The adjusted top position.
   */
  adjustForVerticalOverflow(top, elementHeight, targetHeight) {
    // Pixels the element bleeds past the bottom of the viewport (positive = overflow).
    const overflowBottom = top + elementHeight - window.innerHeight;
    // Pixels the element bleeds past the top of the viewport (positive = overflow).
    const overflowTop = -top;
    // Only nudge when overflow is smaller than targetHeight; larger overflows are left to
    // keepVerticallyInView to hard-clamp, since nudging that far would detach from the target.
    if (overflowBottom > 0 && overflowBottom < targetHeight) {
      return top - overflowBottom; // shift up until bottom edge is flush with viewport bottom
    }
    if (overflowTop > 0 && overflowTop < targetHeight) {
      return top + overflowTop; // shift down until top edge is flush with viewport top
    }
    return top;
  }
  /**
   * Nudges `left` so the element stays within the viewport horizontally.
   * Only corrects when the overflow is less than `targetWidth` to keep the element
   * visually anchored to its target; larger overflows are handled by `keepHorizontallyInView`.
   *
   * @param {number} left - The calculated left position.
   * @param {number} elementWidth - Width of the positioned element.
   * @param {number} targetWidth - Width of the target, used as the max correctable overflow.
   * @returns {number} The adjusted left position.
   */
  adjustForHorizontalOverflow(left, elementWidth, targetWidth) {
    // Pixels the element bleeds past the right edge of the viewport (positive = overflow).
    const overflowRight = left + elementWidth - window.innerWidth;
    // Pixels the element bleeds past the left edge of the viewport (positive = overflow).
    const overflowLeft = -left;
    // Only nudge when overflow is smaller than targetWidth; larger overflows are left to
    // keepHorizontallyInView to hard-clamp, since nudging that far would detach from the target.
    if (overflowRight > 0 && overflowRight < targetWidth) {
      return left - overflowRight; // shift left until right edge is flush with viewport right
    }
    if (overflowLeft > 0 && overflowLeft < targetWidth) {
      return left + overflowLeft; // shift right until left edge is flush with viewport left
    }
    return left;
  }
  /**
   * It returns the translocation of the element relative to the target element
   *
   * @param {PlacementType} placement - The suitable placement of the element.
   * @returns {TranslocationType} A translocation object with X and Y properties.
   */
  getTranslocationBox(placement) {
    const originPlacement = Array.isArray(this.showConfig.placement)
      ? this.showConfig.placement[0]
      : this.showConfig.placement;
    const translocation = this.getTranslocation();
    const result = { X: 0, Y: 0 };
    if (isTranslocationType(translocation)) {
      let xValue = translocation.X;
      let yValue = translocation.Y;
      // If originPlacement and placement is not in same axis, it should reverse axis.
      if ((originPlacement &&
        ['left', 'right'].includes(originPlacement) &&
        ['top', 'bottom'].includes(placement)) ||
        (originPlacement &&
          ['top', 'bottom'].includes(originPlacement) &&
          ['left', 'right'].includes(placement))) {
        xValue = translocation.Y;
        yValue = translocation.X;
      }
      if (['bottom', 'top'].includes(placement)) {
        result.Y = placement === 'top' ? -yValue : yValue;
        result.X = xValue;
      }
      if (['left', 'right'].includes(placement)) {
        result.X = placement === 'left' ? -xValue : xValue;
        result.Y = yValue;
      }
    }
    return result;
  }
  /**
   * It takes a list of alignments and returns the suitable one that has enough space to fit the element
   *
   * @param {ElementScrollDimension} containerBox - The bounding box of the container element.
   * @param {ElementBoundBox} elementBox - The box of the element that we want to position.
   * @param {ElementBoundBox} targetBox - The box of the element that the element to be positioned is aligned to.
   * @param {PlacementType} placement - The placement of the element relative to the target.
   * @param {PositionAlignmentType[]} alignments - An array of alignments to try.
   * @returns {Nullable<PositionAlignmentType>} the alignment that is suitable for the element.
   */
  getSuitableAlignment(containerBox, elementBox, targetBox, placement, alignments) {
    const defaultAlignment = alignments[0];
    const suitableAlignment = alignments.find((alignment) => this.hasEnoughAlignmentSpace(containerBox, targetBox, elementBox, placement, alignment));
    if (suitableAlignment) {
      return suitableAlignment;
    }
    else {
      LogUtility.warn(`
                No enough space found for placing element and we will keep using the defined alignment:
                ${defaultAlignment}
            `);
      return null;
    }
  }
  /**
   * If the placement is top or left, then the target's top or left position must be greater than or
   * equal to the element's height or width. If the placement is bottom or right, then the container's
   * height or width minus the target's top or left position minus the target's height or width must be
   * greater than or equal to the element's height or width.
   *
   * @param {ElementScrollDimension} containerBox - The bounding box of the container element.
   * @param {PlacementType} placement - The placement of the element.
   * @param {ElementBoundBox} targetBox - The position of the target element.
   * @param {ElementBoundBox} elementBox - The dimensions of the element to be positioned.
   * @returns {boolean} A boolean value.
   */
  hasEnoughSpace(containerBox, placement, targetBox, elementBox) {
    switch (placement) {
      case 'top':
        return targetBox.top >= elementBox.height;
      case 'bottom':
        return containerBox.height - targetBox.top - targetBox.height >= elementBox.height;
      case 'left':
        return targetBox.left >= elementBox.width;
      case 'right':
        return containerBox.width - targetBox.left - targetBox.width >= elementBox.width;
      /* istanbul ignore next */
      default:
        return false;
    }
  }
  /**
   * Retrieves and formats the translocation.
   *
   * @returns {Nullable<TranslocationType>} current translocation.
   */
  getTranslocation() {
    const translocation = this.showConfig.translocation;
    return translocation && ResponsiveFormatFacade.format(translocation, 'translocation');
  }
}
