import {ActionTrust_Enum} from '#core/constants/action-constants';
import {
  assertLength,
  getLengthNumeral,
  getLengthUnits,
  parseLength,
} from '#core/dom/layout';
import {
  RelativePositions_Enum,
  layoutRectLtwh,
  layoutRectsRelativePos,
} from '#core/dom/layout/rect';

import {Services} from '#service';
import {installPositionObserverServiceForDoc} from '#service/position-observer/position-observer-impl';
import {PositionObserverFidelity_Enum} from '#service/position-observer/position-observer-worker';

import {createCustomEvent} from '#utils/event-helper';
import {dev, devAssert, user, userAssert} from '#utils/log';

const TAG = 'amp-position-observer';

/**
 * Minimum number of pixels in height that need to change before we consider
 * a resize has happened.
 * We have this threshold  because we do not want viewport height changes
 * caused by hide/show of addressbar on mobile browsers cause jumps in
 * scrollbound animations.
 * 150 pixels accounts for most addressbar sizes on mobile browsers.
 */
const RESIZE_THRESHOLD = 150;

/**
 * @typedef {!../../../src/service/position-observer/position-observer-worker.PositionInViewportEntryDef}
 */
let PositionInViewportEntryDef;

export class AmpVisibilityObserver extends AMP.BaseElement {
  /** @param {!AmpElement} element */
  constructor(element) {
    super(element);

    /** @private {?../../../src/service/action-impl.ActionService} */
    this.action_ = null;

    /** @private {?../../../src/service/position-observer/position-observer-impl.PositionObserver} */
    this.positionObserver_ = null;

    /** @private {?../../../src/service/viewport/viewport-interface.ViewportInterface} */
    this.viewport_ = null;

    /** @private {boolean} */
    this.isVisible_ = false;

    /** @private {number} */
    this.topRatio_ = 0;

    /** @private {number} */
    this.bottomRatio_ = 0;

    /** @private {string} */
    this.topMarginExpr_ = '0';

    /** @private {string} */
    this.bottomMarginExpr_ = '0';

    /** @private {number} */
    this.resolvedTopMargin_ = 0;

    /** @private {number} */
    this.resolvedBottomMargin_ = 0;

    /** @private {?../../../src/layout-rect.LayoutRectDef} */
    this.viewportRect_ = null;

    /** @private {?string} */
    this.targetId_ = null;

    /** @private {?number} */
    this.initialViewportHeight_ = null;

    /** @private {number} */
    this.scrollProgress_ = 0;

    /** @private {number} */
    this.remainingScrollToExit_ = 0;

    /** @private {boolean} */
    this.runOnce_ = false;

    /** @private {boolean} */
    this.firstIterationComplete_ = false;
  }

  /** @override */
  buildCallback() {
    // Since this is a functional component and not visual,
    // layoutCallback is meaningless. We delay the heavy work until
    // we become visible.
    this.getAmpDoc().whenFirstVisible().then(this.init_.bind(this));

    this.runOnce_ = this.element.hasAttribute('once');
  }

  /**
   * @private
   */
  init_() {
    this.parseAttributes_();
    this.action_ = Services.actionServiceForDoc(this.element);
    this.viewport_ = Services.viewportForDoc(this.element);
    this.maybeInstallPositionObserver_();
    this.getAmpDoc()
      .whenReady()
      .then(() => {
        const scene = this.discoverScene_();
        this.positionObserver_.observe(
          scene,
          PositionObserverFidelity_Enum.HIGH,
          /** @type {function(?PositionInViewportEntryDef)} */ (
            this.positionChanged_.bind(this)
          )
        );
      });
  }

  /**
   * Dispatches the `enter` event.
   * @private
   */
  triggerEnter_() {
    const name = 'enter';
    const event = createCustomEvent(this.win, `${TAG}.${name}`, {});
    this.action_.trigger(this.element, name, event, ActionTrust_Enum.LOW);
  }

  /**
   * Dispatches the `exit` event.
   * @private
   */
  triggerExit_() {
    const name = 'exit';
    const event = createCustomEvent(this.win, `${TAG}.${name}`, {});
    this.action_.trigger(this.element, name, event, ActionTrust_Enum.LOW);
  }

  /**
   * Dispatches the `scroll` event (at most every animation frame)
   *
   * This event is triggered only when position-observer triggers which is
   * at most every animation frame.
   *
   * @private
   */
  triggerScroll_() {
    const scrolltop = this.viewport_.getScrollTop();
    const positionObserverData = {
      'start-scroll-offset': scrolltop,
      'end-scroll-offset': scrolltop + this.remainingScrollToExit_,
      'initial-inview-percent': this.scrollProgress_,
    };
    const name = 'scroll';
    const event = createCustomEvent(this.win, `${TAG}.${name}`, {
      'percent': this.scrollProgress_,
      'positionObserverData': positionObserverData,
    });
    this.action_.trigger(this.element, name, event, ActionTrust_Enum.LOW);
    // TODO(nainar): We want to remove the position observer if the scroll
    // event is only used by the AnimationWorklet codepath of amp-animation.
    // This involves having amp-animation signal back to amp-position-observer
    // that it is using AnimationWorklet AND amp-position-observer needs to
    // ensure nothing else other than amp-animation is using scroll AND
    // that `enter` and `exit` events are not used.
  }

  /**
   * Called by position observer.
   * It calculates visibility and progress, and triggers the appropriate events.
   * @param {!PositionInViewportEntryDef} entry PositionObserver entry
   * @private
   */
  positionChanged_(entry) {
    if (this.runOnce_ && this.firstIterationComplete_) {
      return;
    }

    const wasVisible = this.isVisible_;
    const prevViewportHeight = this.viewportRect_ && this.viewportRect_.height;

    this.adjustForSmallViewportResize_(entry);

    this.viewportRect_ = entry.viewportRect;

    if (prevViewportHeight != entry.viewportRect.height) {
      // Margins support viewport sizing.
      this.recalculateMargins_();
    }

    // Adjust viewport based on exclusion margins
    const adjViewportRect = this.applyMargins_(entry.viewportRect);
    const {positionRect} = entry;

    // Relative position of the element to the adjusted viewport.
    let relPos;
    if (!positionRect) {
      this.isVisible_ = false;
      relPos = entry.relativePos;
    } else {
      relPos = layoutRectsRelativePos(positionRect, adjViewportRect);
      this.updateVisibility_(positionRect, adjViewportRect, relPos);
    }

    if (wasVisible && !this.isVisible_) {
      // Send final scroll progress state before exiting to handle fast-scroll.
      this.scrollProgress_ = relPos == RelativePositions_Enum.BOTTOM ? 0 : 1;
      this.triggerScroll_();
      this.triggerExit_();
      this.firstIterationComplete_ = true;
    }

    if (!wasVisible && this.isVisible_) {
      this.triggerEnter_();
    }

    // Send scroll progress if visible.
    if (this.isVisible_) {
      this.updateScrollProgress_(positionRect, adjViewportRect);
      this.triggerScroll_();
    }
  }

  /**
   * Calculates whether the scene is visible considering ratios and margins.
   * @param {!../../../src/layout-rect.LayoutRectDef} positionRect position rect as returned by position observer
   * @param {!../../../src/layout-rect.LayoutRectDef} adjustedViewportRect viewport rect adjusted for margins.
   * @param {!RelativePositions_Enum} relativePos Relative position of rect to viewportRect
   * @private
   */
  updateVisibility_(positionRect, adjustedViewportRect, relativePos) {
    // Fully inside margin-adjusted viewport.
    if (relativePos == RelativePositions_Enum.INSIDE) {
      this.isVisible_ = true;
      return;
    }

    const ratioToUse =
      relativePos == RelativePositions_Enum.TOP
        ? this.topRatio_
        : this.bottomRatio_;

    const offset = positionRect.height * ratioToUse;
    if (relativePos == RelativePositions_Enum.BOTTOM) {
      this.isVisible_ =
        positionRect.top <= adjustedViewportRect.bottom - offset;
    } else {
      this.isVisible_ =
        positionRect.bottom >= adjustedViewportRect.top + offset;
    }
  }

  /**
   * Calculates the current scroll progress as a percentage.
   * Scroll progress is a decimal between 0-1 and shows progress between
   * enter and exit, considering ratio and margins.
   * When a scene becomes visible (enters based on ratio and margins), from
   * bottom, progress is 0 as it moves toward the top, progress increases until
   * it becomes exists with 1 from the top.
   *
   * Entering from the top gives the reverse values, 1 at enter, 0 at exit.
   * @param {?../../../src/layout-rect.LayoutRectDef} positionRect position rect as returned by position observer
   * @param {!../../../src/layout-rect.LayoutRectDef} adjustedViewportRect viewport rect adjusted for margins.
   * @private
   */
  updateScrollProgress_(positionRect, adjustedViewportRect) {
    if (!positionRect) {
      return;
    }
    const totalProgressOffset =
      positionRect.height * this.bottomRatio_ +
      positionRect.height * this.topRatio_;

    const totalProgress =
      adjustedViewportRect.height + positionRect.height - totalProgressOffset;

    const topOffset = Math.abs(
      positionRect.top -
        this.resolvedTopMargin_ -
        (adjustedViewportRect.height - positionRect.height * this.bottomRatio_)
    );

    this.scrollProgress_ = topOffset / totalProgress;
    this.remainingScrollToExit_ = totalProgress - topOffset;
  }

  /**
   * @private
   */
  parseAttributes_() {
    // Ratio is either "<top-bottom:{0,1}>" or "<top:{0,1}> <bottom:{0,1}>" e.g,
    // "0.5 1": use 50% visibility at top and 100% at the bottom of viewport.
    const ratios = this.element.getAttribute('intersection-ratios');
    if (ratios) {
      const topBottom = ratios.trim().split(' ');
      this.topRatio_ = this.validateAndResolveRatio_(topBottom[0]);
      this.bottomRatio_ = this.topRatio_;
      if (topBottom[1]) {
        this.bottomRatio_ = this.validateAndResolveRatio_(topBottom[1]);
      }
    }

    // Margin is either "<top-bottom:{px,vh}>" or "<top:{px,vh}>
    // <bottom:{px,vh}>" e.g, "100px 10vh": exclude 100px from top and 10vh from
    // bottom of viewport.
    const margins = this.element.getAttribute('viewport-margins');
    if (margins) {
      const topBottom = margins.trim().split(' ');
      this.topMarginExpr_ = topBottom[0];
      this.bottomMarginExpr_ = this.topMarginExpr_;
      if (topBottom[1]) {
        this.bottomMarginExpr_ = topBottom[1];
      }
    }

    this.targetId_ = this.element.getAttribute('target');
  }

  /**
   * Finds the container scene. Either parent of the component or specified by
   * `target` attribute.
   * @return {!Element} scene element.
   * @private
   */
  discoverScene_() {
    let scene;
    if (this.targetId_) {
      scene = user().assertElement(
        // TODO(#22733): Change back to this.getAmpDoc once ampdoc-fie
        // experiment is launched.
        this.win.document.getElementById(this.targetId_),
        'No element found with id:' + this.targetId_
      );
    } else {
      scene = this.element.parentNode;
    }
    // Hoist body to documentElement, expected to work for both ampdoc and
    // amp4ads in friendly iframe.
    if (this.win.document.body === scene) {
      scene = this.win.document.documentElement;
    }

    return dev().assertElement(scene);
  }

  /**
   * Parses and validates margins.
   * @private
   * @param {string} val
   * @return {number} resolved margin
   */
  validateAndResolveMargin_(val) {
    val = assertLength(parseLength(val));
    const unit = getLengthUnits(val);
    let num = getLengthNumeral(val);
    if (!num) {
      return 0;
    }
    userAssert(
      unit == 'px' || unit == 'vh',
      'Only pixel or vh are valid as units for exclusion margins: ' + val
    );

    if (unit == 'vh') {
      num = (num / 100) * this.viewportRect_.height;
    }
    return num;
  }

  /**
   * Parses and validates ratios.
   * @param {string} val
   * @return {number} resolved ratio
   * @private
   */
  validateAndResolveRatio_(val) {
    const num = parseFloat(val);
    userAssert(
      num >= 0 && num <= 1,
      'Ratios must be a decimal between 0 and 1: ' + val
    );
    return num;
  }

  /**
   * Margins can be of `vh` unit which means they may need to be recalculated
   * when viewport height changes.
   * @private
   */
  recalculateMargins_() {
    devAssert(this.viewportRect_);
    devAssert(this.bottomMarginExpr_);
    devAssert(this.topMarginExpr_);

    this.resolvedTopMargin_ = this.validateAndResolveMargin_(
      this.topMarginExpr_
    );

    this.resolvedBottomMargin_ = this.validateAndResolveMargin_(
      this.bottomMarginExpr_
    );
  }

  /**
   * Readjusts the given rect using the configured exclusion margins.
   * @param {!../../../src/layout-rect.LayoutRectDef} rect viewport rect adjusted for margins.
   * @private
   * @return {!../../../src/layout-rect.LayoutRectDef}
   */
  applyMargins_(rect) {
    devAssert(rect);
    rect = layoutRectLtwh(
      rect.left,
      rect.top + this.resolvedTopMargin_,
      rect.width,
      rect.height - this.resolvedBottomMargin_ - this.resolvedTopMargin_
    );

    return rect;
  }

  /**
   * Detects whether viewport height has changed and if that change
   * is within our acceptable threshold.
   * If within, we offset calculation by the delta so that small viewport
   * changes caused by hide/show of addressbar on mobile browsers do not
   * cause jumps in scrollbond animations.
   * @param {!PositionInViewportEntryDef} entry PositionObserver entry
   */
  adjustForSmallViewportResize_(entry) {
    if (!this.initialViewportHeight_) {
      this.initialViewportHeight_ = entry.viewportRect.height;
    }
    const viewportHeightChangeDelta =
      this.initialViewportHeight_ - entry.viewportRect.height;
    let resizeOffset = 0;
    if (Math.abs(viewportHeightChangeDelta) < RESIZE_THRESHOLD) {
      resizeOffset = viewportHeightChangeDelta;
    } else {
      this.initialViewportHeight_ = null;
    }
    entry.viewportRect = layoutRectLtwh(
      entry.viewportRect.left,
      entry.viewportRect.top,
      entry.viewportRect.width,
      entry.viewportRect.height + resizeOffset
    );
  }

  /**
   * @private
   */
  maybeInstallPositionObserver_() {
    if (!this.positionObserver_) {
      installPositionObserverServiceForDoc(this.getAmpDoc());
      this.positionObserver_ = Services.positionObserverForDoc(this.element);
    }
  }

  /**
   * @protected
   */
  maybeUninstallPositionObserver_() {
    if (this.positionObserver_) {
      const scene = this.discoverScene_();
      this.positionObserver_.unobserve(scene);
      this.positionObserver_ = null;
    }
  }
}

AMP.extension(TAG, '0.1', (AMP) => {
  AMP.registerElement(TAG, AmpVisibilityObserver);
});
