import {VisibilityState_Enum} from '#core/constants/visibility-state';
import {Observable} from '#core/data-structures/observable';
import {tryResolve} from '#core/data-structures/promise';
import {getVerticalScrollbarWidth, isIframed} from '#core/dom';
import {
  layoutRectFromDomRect,
  layoutRectLtwh,
  moveLayoutRect,
} from '#core/dom/layout/rect';
import {closestAncestorElementBySelector} from '#core/dom/query';
import {computedStyle, setStyle} from '#core/dom/style';
import {numeric} from '#core/dom/transition';
import {clamp} from '#core/math';

import {isExperimentOn} from '#experiments';

import {Services} from '#service';

import {Animation} from '#utils/animation';
import {dev, devAssert} from '#utils/log';

import {ViewportBindingDef} from './viewport-binding-def';
import {ViewportBindingIosEmbedWrapper_} from './viewport-binding-ios-embed-wrapper';
import {ViewportBindingNatural_} from './viewport-binding-natural';
import {ViewportInterface} from './viewport-interface';

import {getFriendlyIframeEmbedOptional} from '../../iframe-helper';
import {getMode} from '../../mode';
import {
  getParentWindowFrameElement,
  registerServiceBuilderForDoc,
} from '../../service-helpers';

const TAG_ = 'Viewport';
const SCROLL_POS_TO_BLOCK = {
  'top': 'start',
  'center': 'center',
  'bottom': 'end',
};
const SMOOTH_SCROLL_DELAY_ = 300;

/**
 * @param {!Window} win
 * @param {!Element} element
 * @param {string=} property
 * @return {number}
 */
function getComputedStylePropertyPixels(win, element, property) {
  const value = parseInt(computedStyle(win, element)[property], 10);
  return isNaN(value) ? 0 : value;
}

/**
 * @param {!Window} win
 * @param {!Element} element
 * @param {string=} property
 * @return {number}
 */
function getScrollPadding(win, element, property) {
  // Due to https://bugs.webkit.org/show_bug.cgi?id=106133, WebKit browsers use
  // use `body` and NOT `documentElement` for scrolling purposes.
  // (We get this node from `ViewportBindingNatural`.)
  // However, `scroll-padding-*` properties are effective only on the `html`
  // selector across browsers, thus we use the `documentElement`.
  const effectiveElement =
    element === win.document.body ? win.document.documentElement : element;
  return getComputedStylePropertyPixels(win, effectiveElement, property);
}

/**
 * @param {!Window} win
 * @param {!Element} element
 * @return {number}
 */
function getScrollPaddingTop(win, element) {
  return getScrollPadding(win, element, 'scrollPaddingTop');
}

/**
 * @param {!Window} win
 * @param {!Element} element
 * @return {number}
 */
function getScrollPaddingBottom(win, element) {
  return getScrollPadding(win, element, 'scrollPaddingBottom');
}

/**
 * This object represents the viewport. It tracks scroll position, resize
 * and other events and notifies interesting parties when viewport has changed
 * and how.
 *
 * @implements {ViewportInterface}
 */
export class ViewportImpl {
  /**
   * @param {!../ampdoc-impl.AmpDoc} ampdoc
   * @param {!ViewportBindingDef} binding
   * @param {!../viewer-interface.ViewerInterface} viewer
   */
  constructor(ampdoc, binding, viewer) {
    const {win} = ampdoc;

    /** @const {!../ampdoc-impl.AmpDoc} */
    this.ampdoc = ampdoc;

    /**
     * Some viewport operations require the global document.
     * @private @const {!Document}
     */
    this.globalDoc_ = this.ampdoc.win.document;

    /** @const {!ViewportBindingDef} */
    this.binding_ = binding;

    /** @const {!../viewer-interface.ViewerInterface} */
    this.viewer_ = viewer;

    /**
     * Used to cache the rect of the viewport.
     * @private {?../../layout-rect.LayoutRectDef}
     */
    this.rect_ = null;

    /**
     * Used to cache the size of the viewport. Also used as last known size,
     * so users should call getSize early on to get a value. The timing should
     * be chosen to avoid extra style recalcs.
     * @private {{width: number, height: number}|null}
     */
    this.size_ = null;

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

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

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

    /** @private {number} */
    this.paddingTop_ = Number(viewer.getParam('paddingTop') || 0);

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

    /** @private {!../timer-impl.Timer} */
    this.timer_ = Services.timerFor(win);

    /** @private {!../vsync-impl.Vsync} */
    this.vsync_ = Services.vsyncFor(win);

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

    /** @private {Element} */
    this.scrollingElement_ = null;

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

    /** @private @const {!Observable<!./viewport-interface.ViewportChangedEventDef>} */
    this.changeObservable_ = new Observable();

    /** @private @const {!Observable} */
    this.scrollObservable_ = new Observable();

    /** @private @const {!Observable<!./viewport-interface.ViewportResizedEventDef>} */
    this.resizeObservable_ = new Observable();

    /** @private {?HTMLMetaElement|undefined} */
    this.viewportMeta_ = undefined;

    /** @private {string|undefined} */
    this.originalViewportMetaString_ = undefined;

    /** @private {?../fixed-layer.FixedLayer} */
    this.fixedLayer_ = null;

    this.viewer_.onMessage('viewport', this.updateOnViewportEvent_.bind(this));
    this.viewer_.onMessage('scroll', this.viewerSetScrollTop_.bind(this));
    this.viewer_.onMessage(
      'disableScroll',
      this.disableScrollEventHandler_.bind(this)
    );
    if (this.viewer_.isEmbedded()) {
      this.binding_.updatePaddingTop(this.paddingTop_);
    }

    this.binding_.onScroll(this.scroll_.bind(this));
    this.binding_.onResize(this.resize_.bind(this));

    this.onScroll(this.sendScrollMessage_.bind(this));

    /** @private {boolean} */
    this.visible_ = false;
    this.ampdoc.onVisibilityChanged(this.updateVisibility_.bind(this));
    this.updateVisibility_();

    // Top-level mode classes.
    const globalDocElement = this.globalDoc_.documentElement;
    if (ampdoc.isSingleDoc()) {
      globalDocElement.classList.add('i-amphtml-singledoc');
    }
    if (viewer.isEmbedded()) {
      globalDocElement.classList.add('i-amphtml-embedded');
    } else {
      globalDocElement.classList.add('i-amphtml-standalone');
    }
    if (isIframed(win)) {
      globalDocElement.classList.add('i-amphtml-iframed');
    }
    if (viewer.getParam('webview') === '1') {
      globalDocElement.classList.add('i-amphtml-webview');
    }

    // To avoid browser restore scroll position when traverse history
    if (isIframed(win) && 'scrollRestoration' in win.history) {
      win.history.scrollRestoration = 'manual';
    }

    // Override global scrollTo if requested.
    if (this.binding_.overrideGlobalScrollTo()) {
      try {
        Object.defineProperty(win, 'scrollTo', {
          value: (x, y) => this.setScrollTop(y),
        });
        ['pageYOffset', 'scrollY'].forEach((prop) => {
          Object.defineProperty(win, prop, {
            get: () => this.getScrollTop(),
          });
        });
      } catch (e) {
        // Ignore errors.
      }
    }

    // BF-cache navigation sometimes breaks clicks in an iframe on iOS. See
    // https://github.com/ampproject/amphtml/issues/30838 for more details.
    // The solution is to make a "fake" scrolling API call.
    const isIframedIos = Services.platformFor(win).isIos() && isIframed(win);
    // We dont want to scroll if we're in a shadow doc, so check that we're
    // in a single doc. Fix for
    // https://github.com/ampproject/amphtml/issues/32165.
    if (isIframedIos && this.ampdoc.isSingleDoc()) {
      this.ampdoc.whenReady().then(() => {
        win./*OK*/ scrollTo(-0.1, 0);
      });
    }
  }

  /** @override */
  dispose() {
    this.binding_.disconnect();
  }

  /** @override */
  ensureReadyForElements() {
    this.binding_.ensureReadyForElements();
  }

  /** @private */
  updateVisibility_() {
    const visible = this.ampdoc.isVisible();
    if (visible != this.visible_) {
      this.visible_ = visible;
      if (visible) {
        this.binding_.connect();
        if (this.size_) {
          // If the size has already been initialized, check it again in case
          // the size has changed between `disconnect` and `connect`.
          this.resize_();
        }
        if (this.scrollTop_) {
          // Remeasure scrollTop when resource becomes visible to fix #11983
          this./*OK*/ scrollTop_ = null;
          this.getScrollTop();
        }
      } else {
        this.binding_.disconnect();
      }
    }
  }

  /** @override */
  getPaddingTop() {
    return this.paddingTop_;
  }

  /** @override */
  getScrollTop() {
    if (this./*OK*/ scrollTop_ == null) {
      this./*OK*/ scrollTop_ = this.binding_.getScrollTop();
    }
    return this./*OK*/ scrollTop_;
  }

  /** @override */
  getScrollLeft() {
    if (this./*OK*/ scrollLeft_ == null) {
      this./*OK*/ scrollLeft_ = this.binding_.getScrollLeft();
    }
    return this./*OK*/ scrollLeft_;
  }

  /** @override */
  setScrollTop(scrollPos) {
    this./*OK*/ scrollTop_ = null;
    this.binding_.setScrollTop(scrollPos);
  }

  /** @override */
  updatePaddingBottom(paddingBottom) {
    this.ampdoc.waitForBodyOpen().then((body) => {
      setStyle(body, 'borderBottom', `${paddingBottom}px solid transparent`);
    });
  }

  /** @override */
  getSize() {
    if (this.size_) {
      return this.size_;
    }
    this.size_ = this.binding_.getSize();
    if (this.size_.width == 0 || this.size_.height == 0) {
      // Only report when the visibility is "visible" or "prerender".
      const visibilityState = this.ampdoc.getVisibilityState();
      // We do NOT want to report for PREVIEW mode.
      if (
        visibilityState == VisibilityState_Enum.PRERENDER ||
        visibilityState == VisibilityState_Enum.VISIBLE
      ) {
        if (Math.random() < 0.01) {
          dev().error(TAG_, 'viewport has zero dimensions');
        }
      }
    }
    return this.size_;
  }

  /** @override */
  getHeight() {
    return this.getSize().height;
  }

  /** @override */
  getWidth() {
    return this.getSize().width;
  }

  /** @override */
  getScrollWidth() {
    return this.binding_.getScrollWidth();
  }

  /** @override */
  getScrollHeight() {
    return this.binding_.getScrollHeight();
  }

  /** @override */
  getContentHeight() {
    return this.binding_.getContentHeight();
  }

  /** @override */
  contentHeightChanged() {
    this.binding_.contentHeightChanged();
  }

  /** @override */
  getRect() {
    if (this.rect_ == null) {
      const scrollTop = this.getScrollTop();
      const scrollLeft = this.getScrollLeft();
      const size = this.getSize();
      this.rect_ = layoutRectLtwh(
        scrollLeft,
        scrollTop,
        size.width,
        size.height
      );
    }
    return this.rect_;
  }

  /** @override */
  getLayoutRect(el) {
    const scrollLeft = this.getScrollLeft();
    const scrollTop = this.getScrollTop();

    // Go up the window hierarchy through friendly iframes.
    const frameElement = getParentWindowFrameElement(el, this.ampdoc.win);
    if (frameElement) {
      const b = this.binding_.getLayoutRect(el, 0, 0);
      const c = this.binding_.getLayoutRect(
        frameElement,
        scrollLeft,
        scrollTop
      );
      return layoutRectLtwh(
        Math.round(b.left + c.left),
        Math.round(b.top + c.top),
        Math.round(b.width),
        Math.round(b.height)
      );
    }

    return this.binding_.getLayoutRect(el, scrollLeft, scrollTop);
  }

  /** @override */
  getClientRectAsync(el) {
    const local = this.vsync_.measurePromise(() => {
      return el./*OK*/ getBoundingClientRect();
    });

    let root = this.binding_.getRootClientRectAsync();
    const frameElement = getParentWindowFrameElement(el, this.ampdoc.win);
    if (frameElement) {
      root = this.vsync_.measurePromise(() => {
        return frameElement./*OK*/ getBoundingClientRect();
      });
    }

    return Promise.all([local, root]).then((values) => {
      const l = values[0];
      const r = values[1];
      if (!r) {
        return layoutRectFromDomRect(l);
      }
      return moveLayoutRect(l, r.left, r.top);
    });
  }

  /** @override */
  supportsPositionFixed() {
    return this.binding_.supportsPositionFixed();
  }

  /** @override */
  isDeclaredFixed(element) {
    if (!this.fixedLayer_) {
      return false;
    }
    return this.fixedLayer_.isDeclaredFixed(element);
  }

  /** @override */
  scrollIntoView(element) {
    if (IS_SXG) {
      element./* OK */ scrollIntoView();
      return Promise.resolve();
    } else {
      return this.getScrollingContainerFor_(element).then((parent) =>
        this.scrollIntoViewInternal_(element, parent)
      );
    }
  }

  /**
   * @param {!Element} element
   * @param {!Element} parent
   */
  scrollIntoViewInternal_(element, parent) {
    const elementTop = this.binding_.getLayoutRect(element).top;
    const scrollPaddingTop = getScrollPaddingTop(this.ampdoc.win, parent);
    const newScrollTopPromise = tryResolve(() =>
      Math.max(0, elementTop - this.paddingTop_ - scrollPaddingTop)
    );

    newScrollTopPromise.then((newScrollTop) =>
      this.setElementScrollTop_(parent, newScrollTop)
    );
  }

  /** @override */
  animateScrollIntoView(element, pos = 'top', opt_duration, opt_curve) {
    if (IS_SXG) {
      return new Promise((resolve, opt_) => {
        element./* OK */ scrollIntoView({
          block: SCROLL_POS_TO_BLOCK[pos],
          behavior: 'smooth',
        });
        setTimeout(resolve, SMOOTH_SCROLL_DELAY_);
      });
    } else {
      devAssert(
        !opt_curve || opt_duration !== undefined,
        "Curve without duration doesn't make sense."
      );

      return this.getScrollingContainerFor_(element).then((parent) =>
        this.animateScrollWithinParent(
          element,
          parent,
          dev().assertString(pos),
          opt_duration,
          opt_curve
        )
      );
    }
  }

  /** @override */
  animateScrollWithinParent(element, parent, pos, opt_duration, opt_curve) {
    devAssert(
      !opt_curve || opt_duration !== undefined,
      "Curve without duration doesn't make sense."
    );

    const elementRect = this.binding_.getLayoutRect(element);

    const {height: parentHeight} = this.isScrollingElement_(parent)
      ? this.getSize()
      : this.getLayoutRect(parent);

    const {win} = this.ampdoc;
    const scrollPaddingTop = getScrollPaddingTop(win, parent);
    const scrollPaddingBottom = getScrollPaddingBottom(win, parent);

    let offset = -scrollPaddingTop; // default pos === 'top'

    if (pos === 'bottom') {
      offset = -parentHeight + scrollPaddingBottom + elementRect.height;
    } else if (pos === 'center') {
      const effectiveParentHeight =
        parentHeight - scrollPaddingTop - scrollPaddingBottom;
      offset = -effectiveParentHeight / 2 + elementRect.height / 2;
    }

    return this.getElementScrollTop_(parent).then((curScrollTop) => {
      const calculatedScrollTop = elementRect.top - this.paddingTop_ + offset;
      const newScrollTop = Math.max(0, calculatedScrollTop);
      if (newScrollTop == curScrollTop) {
        return;
      }
      return this.interpolateScrollIntoView_(
        parent,
        curScrollTop,
        newScrollTop,
        opt_duration,
        opt_curve
      );
    });
  }

  /**
   * @param {!Element} parent
   * @param {number} curScrollTop
   * @param {number} newScrollTop
   * @param {number=} opt_duration
   * @param {string=} curve
   * @private
   */
  interpolateScrollIntoView_(
    parent,
    curScrollTop,
    newScrollTop,
    opt_duration,
    curve = 'ease-in'
  ) {
    const duration =
      opt_duration !== undefined
        ? dev().assertNumber(opt_duration)
        : getDefaultScrollAnimationDuration(curScrollTop, newScrollTop);

    /** @const {!TransitionDef<number>} */
    const interpolate = numeric(curScrollTop, newScrollTop);
    return Animation.animate(
      parent,
      (position) => {
        this.setElementScrollTop_(parent, interpolate(position));
      },
      duration,
      curve
    ).thenAlways(() => {
      this.setElementScrollTop_(parent, newScrollTop);
    });
  }

  /**
   * @param {!Element} element
   * @return {!Promise<!Element>}
   */
  getScrollingContainerFor_(element) {
    return this.vsync_.measurePromise(
      () =>
        closestAncestorElementBySelector(element, '.i-amphtml-scrollable') ||
        this.binding_.getScrollingElement()
    );
  }

  /**
   * @param {!Element} element
   * @param {number} scrollTop
   */
  setElementScrollTop_(element, scrollTop) {
    if (this.isScrollingElement_(element)) {
      this.binding_.setScrollTop(scrollTop);
      return;
    }
    this.vsync_.mutate(() => {
      element./*OK*/ scrollTop = scrollTop;
    });
  }

  /**
   * @param {!Element} element
   * @return {!Promise<number>}
   */
  getElementScrollTop_(element) {
    if (this.isScrollingElement_(element)) {
      return tryResolve(() => this.getScrollTop());
    }
    return this.vsync_.measurePromise(() => element./*OK*/ scrollTop);
  }

  /**
   * @param {!Element} element
   * @return {boolean}
   */
  isScrollingElement_(element) {
    return element == this.binding_.getScrollingElement();
  }

  /** @override */
  getScrollingElement() {
    if (this.scrollingElement_) {
      return this.scrollingElement_;
    }
    return (this.scrollingElement_ = this.binding_.getScrollingElement());
  }

  /** @override */
  onChanged(handler) {
    return this.changeObservable_.add(handler);
  }

  /** @override */
  onScroll(handler) {
    return this.scrollObservable_.add(handler);
  }

  /** @override */
  onResize(handler) {
    return this.resizeObservable_.add(handler);
  }

  /** @override */
  enterLightboxMode(opt_requestingElement, opt_onComplete) {
    this.viewer_.sendMessage('requestFullOverlay', {}, /* cancelUnsent */ true);

    this.enterOverlayMode();
    if (this.fixedLayer_) {
      this.fixedLayer_.enterLightbox(opt_requestingElement, opt_onComplete);
    }

    if (opt_requestingElement) {
      this.maybeEnterFieLightboxMode(
        dev().assertElement(opt_requestingElement)
      );
    }

    return this.binding_.updateLightboxMode(true);
  }

  /** @override */
  leaveLightboxMode(opt_requestingElement) {
    this.viewer_.sendMessage('cancelFullOverlay', {}, /* cancelUnsent */ true);

    if (this.fixedLayer_) {
      this.fixedLayer_.leaveLightbox();
    }
    this.leaveOverlayMode();

    if (opt_requestingElement) {
      this.maybeLeaveFieLightboxMode(
        dev().assertElement(opt_requestingElement)
      );
    }

    return this.binding_.updateLightboxMode(false);
  }

  /**
   * @return {boolean}
   * @visibleForTesting
   */
  isLightboxExperimentOn() {
    return isExperimentOn(this.ampdoc.win, 'amp-lightbox-a4a-proto');
  }

  /**
   * Enters frame lightbox mode if under a Friendly Iframe Embed.
   * @param {!Element} requestingElement
   * @visibleForTesting
   */
  maybeEnterFieLightboxMode(requestingElement) {
    const fieOptional = this.getFriendlyIframeEmbed_(requestingElement);

    if (fieOptional) {
      devAssert(
        this.isLightboxExperimentOn(),
        'Lightbox mode for A4A is only available when ' +
          "'amp-lightbox-a4a-proto' experiment is on"
      );

      fieOptional.enterFullOverlayMode();
    }
  }

  /**
   * Leaves frame lightbox mode if under a Friendly Iframe Embed.
   * @param {!Element} requestingElement
   * @visibleForTesting
   */
  maybeLeaveFieLightboxMode(requestingElement) {
    const fieOptional = this.getFriendlyIframeEmbed_(requestingElement);

    if (fieOptional) {
      devAssert(fieOptional).leaveFullOverlayMode();
    }
  }

  /**
   * Get FriendlyIframeEmbed if available.
   * @param {!Element} element Element supposedly inside the FIE.
   * @return {?../../friendly-iframe-embed.FriendlyIframeEmbed}
   * @private
   */
  getFriendlyIframeEmbed_(element) {
    const iframeOptional = getParentWindowFrameElement(
      element,
      this.ampdoc.win
    );

    return (
      iframeOptional &&
      getFriendlyIframeEmbedOptional(
        /** @type {!HTMLIFrameElement} */
        (dev().assertElement(iframeOptional))
      )
    );
  }

  /** @override */
  enterOverlayMode() {
    this.disableTouchZoom();
    this.disableScroll();
  }

  /** @override */
  leaveOverlayMode() {
    this.resetScroll();
    this.restoreOriginalTouchZoom();
  }

  /** @override */
  disableScroll() {
    const {win} = this.ampdoc;
    const {documentElement} = win.document;
    let requestedMarginRight;

    // Calculate the scrollbar width so we can set it as a right margin. This
    // is so that we do not cause content to shift when we disable scroll on
    // platforms that have a width-taking scrollbar.
    this.vsync_.measure(() => {
      const existingMargin = computedStyle(win, documentElement).marginRight;
      const scrollbarWidth = getVerticalScrollbarWidth(this.ampdoc.win);

      requestedMarginRight = parseInt(existingMargin, 10) + scrollbarWidth;
    });

    this.vsync_.mutate(() => {
      setStyle(documentElement, 'margin-right', requestedMarginRight, 'px');
      this.binding_.disableScroll();
    });
  }

  /** @override */
  resetScroll() {
    const {win} = this.ampdoc;
    const {documentElement} = win.document;

    this.vsync_.mutate(() => {
      setStyle(documentElement, 'margin-right', '');
      this.binding_.resetScroll();
    });
  }

  /** @override */
  resetTouchZoom() {
    const windowHeight = this.ampdoc.win./*OK*/ innerHeight;
    const documentHeight = this.globalDoc_.documentElement./*OK*/ clientHeight;
    if (windowHeight && documentHeight && windowHeight === documentHeight) {
      // This code only works when scrollbar overlay content and take no space,
      // which is fine on mobile. For non-mobile devices this code is
      // irrelevant.
      return;
    }
    if (this.disableTouchZoom()) {
      this.timer_.delay(() => {
        this.restoreOriginalTouchZoom();
      }, 50);
    }
  }

  /** @override */
  disableTouchZoom() {
    const viewportMeta = this.getViewportMeta_();
    if (!viewportMeta) {
      // This should never happen in a valid AMP document, thus shortcircuit.
      return false;
    }
    // Setting maximum-scale=1 and user-scalable=no zooms page back to normal
    // and prohibit further default zooming.
    const newValue = updateViewportMetaString(viewportMeta.content, {
      'maximum-scale': '1',
      'user-scalable': 'no',
    });
    return this.setViewportMetaString_(newValue);
  }

  /** @override */
  restoreOriginalTouchZoom() {
    if (this.originalViewportMetaString_ !== undefined) {
      return this.setViewportMetaString_(this.originalViewportMetaString_);
    }
    return false;
  }

  /** @override */
  updateFixedLayer() {
    if (!this.fixedLayer_) {
      return Promise.resolve();
    }
    return this.fixedLayer_.update();
  }

  /** @override */
  addToFixedLayer(element, opt_forceTransfer) {
    if (!this.fixedLayer_) {
      return Promise.resolve();
    }
    return this.fixedLayer_.addElement(element, opt_forceTransfer);
  }

  /** @override */
  removeFromFixedLayer(element) {
    if (!this.fixedLayer_) {
      return;
    }
    this.fixedLayer_.removeElement(element);
  }

  /** @override */
  createFixedLayer(constructor) {
    this.fixedLayer_ = new constructor(
      this.ampdoc,
      this.vsync_,
      this.binding_.getBorderTop(),
      this.paddingTop_,
      this.binding_.requiresFixedLayerTransfer()
    );
    this.ampdoc.whenReady().then(() => this.fixedLayer_.setup());
  }

  /**
   * Updates touch zoom meta data. Returns `true` if any actual
   * changes have been done.
   * @param {string} viewportMetaString
   * @return {boolean}
   */
  setViewportMetaString_(viewportMetaString) {
    const viewportMeta = this.getViewportMeta_();
    if (viewportMeta && viewportMeta.content != viewportMetaString) {
      dev().fine(TAG_, 'changed viewport meta to:', viewportMetaString);
      viewportMeta.content = viewportMetaString;
      return true;
    }
    return false;
  }

  /**
   * @return {?HTMLMetaElement}
   * @private
   */
  getViewportMeta_() {
    if (isIframed(this.ampdoc.win)) {
      // An embedded document does not control its viewport meta tag.
      return null;
    }
    if (this.viewportMeta_ === undefined) {
      this.viewportMeta_ = /** @type {?HTMLMetaElement} */ (
        this.globalDoc_.querySelector('meta[name=viewport]')
      );
      if (this.viewportMeta_) {
        this.originalViewportMetaString_ = this.viewportMeta_.content;
      }
    }
    return this.viewportMeta_;
  }

  /**
   * @param {!JsonObject} data
   * @private
   */
  viewerSetScrollTop_(data) {
    const targetScrollTop = data['scrollTop'];
    this.setScrollTop(targetScrollTop);
  }

  /**
   * @param {!JsonObject} data
   * @private
   */
  updateOnViewportEvent_(data) {
    const paddingTop = data['paddingTop'];
    const duration = data['duration'] || 0;
    const curve = data['curve'];
    /** @const {boolean} */
    const transient = data['transient'];

    if (paddingTop == undefined || paddingTop == this.paddingTop_) {
      return;
    }

    this.lastPaddingTop_ = this.paddingTop_;
    this.paddingTop_ = paddingTop;

    if (this.fixedLayer_) {
      const animPromise = this.fixedLayer_.animateFixedElements(
        this.paddingTop_,
        this.lastPaddingTop_,
        duration,
        curve,
        transient
      );
      if (paddingTop < this.lastPaddingTop_) {
        this.binding_.hideViewerHeader(transient, this.lastPaddingTop_);
      } else {
        animPromise.then(() => {
          this.binding_.showViewerHeader(transient, paddingTop);
        });
      }
    }
  }

  /**
   * @param {!JsonObject} data
   * @private
   */
  disableScrollEventHandler_(data) {
    if (!!data) {
      this.disableScroll();
    } else {
      this.resetScroll();
    }
  }

  /**
   * @param {boolean} relayoutAll
   * @param {number} velocity
   * @private
   */
  changed_(relayoutAll, velocity) {
    const size = this.getSize();
    const scrollTop = this.getScrollTop();
    const scrollLeft = this.getScrollLeft();
    dev().fine(
      TAG_,
      'changed event:',
      'relayoutAll=',
      relayoutAll,
      'top=',
      scrollTop,
      'left=',
      scrollLeft,
      'bottom=',
      scrollTop + size.height,
      'velocity=',
      velocity
    );
    this.changeObservable_.fire({
      relayoutAll,
      top: scrollTop,
      left: scrollLeft,
      width: size.width,
      height: size.height,
      velocity,
    });
  }

  /** @private */
  scroll_() {
    this.rect_ = null;
    this.scrollCount_++;
    this.scrollLeft_ = this.binding_.getScrollLeft();
    const newScrollTop = this.binding_.getScrollTop();
    if (newScrollTop < 0) {
      // iOS and some other browsers use negative values of scrollTop for
      // overscroll. Overscroll does not affect the viewport and thus should
      // be ignored here.
      return;
    }
    this.scrollTop_ = newScrollTop;
    if (!this.scrollTracking_) {
      this.scrollTracking_ = true;
      const now = Date.now();
      // Wait 2 frames and then request an animation frame.
      this.timer_.delay(() => {
        this.vsync_.measure(() => {
          this.throttledScroll_(now, newScrollTop);
        });
      }, 36);
    }
    this.scrollObservable_.fire();
  }

  /**
   * This method is called about every 3 frames (assuming 60hz) and it
   * is called in a vsync measure task.
   * @param {number} referenceTime Time when the scroll measurement, that
   *     triggered this call made, was made.
   * @param {number} referenceTop Scrolltop at that time.
   * @private
   */
  throttledScroll_(referenceTime, referenceTop) {
    this.scrollTop_ = this.binding_.getScrollTop();
    /**  @const {number} */
    const newScrollTop = this.scrollTop_;
    const now = Date.now();
    let velocity = 0;
    if (now != referenceTime) {
      velocity = (newScrollTop - referenceTop) / (now - referenceTime);
    }
    dev().fine(
      TAG_,
      'scroll: scrollTop=' + newScrollTop + '; velocity=' + velocity
    );
    if (Math.abs(velocity) < 0.03) {
      this.changed_(/* relayoutAll */ false, velocity);
      this.scrollTracking_ = false;
    } else {
      this.timer_.delay(
        () =>
          this.vsync_.measure(
            this.throttledScroll_.bind(this, now, newScrollTop)
          ),
        20
      );
    }
  }

  /**
   * Send scroll message via the viewer per animation frame
   * @private
   */
  sendScrollMessage_() {
    if (!this.scrollAnimationFrameThrottled_) {
      this.scrollAnimationFrameThrottled_ = true;
      this.vsync_.measure(() => {
        this.scrollAnimationFrameThrottled_ = false;
        this.viewer_.sendMessage(
          'scroll',
          {'scrollTop': this.getScrollTop()},
          /* cancelUnsent */ true
        );
      });
    }
  }

  /** @private */
  resize_() {
    this.rect_ = null;
    const oldSize = this.size_;
    this.size_ = null; // Need to recalc.
    const newSize = this.getSize();
    this.updateFixedLayer().then(() => {
      const widthChanged = !oldSize || oldSize.width != newSize.width;
      this.changed_(/*relayoutAll*/ widthChanged, 0);
      const sizeChanged = widthChanged || oldSize.height != newSize.height;
      if (sizeChanged) {
        this.resizeObservable_.fire({
          relayoutAll: widthChanged,
          width: newSize.width,
          height: newSize.height,
        });
      }
    });
  }
}

/**
 * Parses viewport meta value. It usually looks like:
 * ```
 * width=device-width,initial-scale=1,minimum-scale=1
 * ```
 * @param {string} content
 * @return {!{[key: string]: (string|undefined)}}
 * @private Visible for testing only.
 */
export function parseViewportMeta(content) {
  // Ex: width=device-width,initial-scale=1,minimal-ui
  const params = Object.create(null);
  if (!content) {
    return params;
  }
  const pairs = content.split(/,|;/);
  for (let i = 0; i < pairs.length; i++) {
    const pair = pairs[i];
    const split = pair.split('=');
    const name = split[0].trim();
    let value = split[1];
    value = (value || '').trim();
    if (name) {
      params[name] = value;
    }
  }
  return params;
}

/**
 * Stringifies viewport meta value based on the provided map. It usually looks
 * like:
 * ```
 * width=device-width,initial-scale=1,minimum-scale=1
 * ```
 * @param {!{[key: string]: string}} params
 * @return {string}
 * @private Visible for testing only.
 */
export function stringifyViewportMeta(params) {
  // Ex: width=device-width,initial-scale=1,minimal-ui
  let content = '';
  for (const k in params) {
    if (content.length > 0) {
      content += ',';
    }
    if (params[k]) {
      content += k + '=' + params[k];
    } else {
      content += k;
    }
  }
  return content;
}

/**
 * This method makes a minimal effort to keep the original viewport string
 * unchanged if in fact none of the values have been updated. Returns the
 * updated string or the `currentValue` if no changes were necessary.
 *
 * @param {string} currentValue
 * @param {!{[key: string]: string|undefined}} updateParams
 * @return {string}
 * @private Visible for testing only.
 */
export function updateViewportMetaString(currentValue, updateParams) {
  const params = parseViewportMeta(currentValue);
  let changed = false;
  for (const k in updateParams) {
    if (params[k] !== updateParams[k]) {
      changed = true;
      if (updateParams[k] !== undefined) {
        params[k] = /** @type {string} */ (updateParams[k]);
      } else {
        delete params[k];
      }
    }
  }
  if (!changed) {
    return currentValue;
  }
  return stringifyViewportMeta(params);
}

/**
 * Calculates a default duration for a scrollTop animation.
 * @param {number} scrollTopA commutative with b.
 * @param {number} scrollTopB commutative with a.
 * @param {number=} max in ms. default 500ms.
 * @return {number}
 */
function getDefaultScrollAnimationDuration(scrollTopA, scrollTopB, max = 500) {
  // 65% of scroll Δ to ms, eg 1000px -> 650ms, integer between 0 and max
  return Math.floor(clamp(0.65 * Math.abs(scrollTopA - scrollTopB), 0, max));
}

/**
 * @param {!../ampdoc-impl.AmpDoc} ampdoc
 * @return {!ViewportImpl}
 * @private
 */
function createViewport(ampdoc) {
  const viewer = Services.viewerForDoc(ampdoc);
  const {win} = ampdoc;
  let binding;
  if (
    ampdoc.isSingleDoc() &&
    getViewportType(win, viewer) == ViewportType_Enum.NATURAL_IOS_EMBED &&
    !IS_SXG
  ) {
    binding = new ViewportBindingIosEmbedWrapper_(win);
  } else {
    binding = new ViewportBindingNatural_(ampdoc);
  }
  return new ViewportImpl(ampdoc, binding, viewer);
}

/**
 * The type of the viewport.
 * @enum {string}
 */
const ViewportType_Enum = {
  /**
   * Viewer leaves sizing and scrolling up to the AMP document's window.
   */
  NATURAL: 'natural',

  /**
   * This is AMP-specific type and doesn't come from viewer. This is the type
   * that AMP sets when Viewer has requested "natural" viewport on a iOS
   * device.
   * See:
   * https://github.com/ampproject/amphtml/blob/main/docs/spec/amp-html-layout.md
   */
  NATURAL_IOS_EMBED: 'natural-ios-embed',
};

/**
 * @param {!Window} win
 * @param {!../viewer-interface.ViewerInterface} viewer
 * @return {string}
 */
function getViewportType(win, viewer) {
  const isIframedIos = Services.platformFor(win).isIos() && isIframed(win);

  // Enable iOS Embedded mode for iframed tests (e.g. integration tests).
  if (getMode(win).test && isIframedIos) {
    return ViewportType_Enum.NATURAL_IOS_EMBED;
  }

  // Override to ios-embed for iframe-viewer mode.
  if (
    isIframedIos &&
    viewer.isEmbedded() &&
    !viewer.hasCapability('iframeScroll')
  ) {
    return ViewportType_Enum.NATURAL_IOS_EMBED;
  }
  return ViewportType_Enum.NATURAL;
}

/**
 * @param {!../ampdoc-impl.AmpDoc} ampdoc
 */
export function installViewportServiceForDoc(ampdoc) {
  registerServiceBuilderForDoc(
    ampdoc,
    'viewport',
    createViewport,
    /* opt_instantiate */ true
  );
}
