import {MessageType_Enum} from '#core/3p-frame-messaging';
import {ActionTrust_Enum} from '#core/constants/action-constants';
import {AMPDOC_SINGLETON_NAME_ENUM} from '#core/constants/enums';
import {removeElement} from '#core/dom';
import {
  LayoutPriority_Enum,
  applyFillContent,
  isLayoutSizeDefined,
} from '#core/dom/layout';
import {propagateAttributes} from '#core/dom/propagate-attributes';
import {setStyle} from '#core/dom/style';
import {playIgnoringError} from '#core/dom/video';
import {PauseHelper} from '#core/dom/video/pause-helper';
import {parseJson} from '#core/types/object/json';
import {endsWith} from '#core/types/string';
import {base64EncodeFromBytes} from '#core/types/string/base64';
import {utf8Encode} from '#core/types/string/bytes';

import {isExperimentOn} from '#experiments';

import {Services} from '#service';

import {createCustomEvent, getData, listen} from '#utils/event-helper';
import {IntersectionObserver3pHost} from '#utils/intersection-observer-3p-host';
import {user, userAssert} from '#utils/log';

import {isAdPositionAllowed} from '../../../src/ad-helper';
import * as urls from '../../../src/config/urls';
import {getConsentDataToForward} from '../../../src/consent';
import {
  isAdLike,
  listenFor,
  looksLikeTrackingIframe,
} from '../../../src/iframe-helper';
import {removeFragment} from '../../../src/url';

/** @const {string} */
const TAG_ = 'amp-iframe';

/** @const {!Array<string>} */
const ATTRIBUTES_TO_PROPAGATE = [
  'allowfullscreen',
  'allowpaymentrequest',
  'allowtransparency',
  'allow',
  'frameborder',
  'referrerpolicy',
  'scrolling',
  'tabindex',
  'title',
];

/** @type {number}  */
let count = 0;

/** @type {number}  */
let trackingIframeTimeout = 5000;

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

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

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

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

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

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

    /** @private  {?HTMLIFrameElement} */
    this.iframe_ = null;

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

    /** @private {?IntersectionObserver3pHost} */
    this.intersectionObserverHostApi_ = null;

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

    /** @private {Function} */
    this.unlistenPym_ = null;

    /**
     * The source of the iframe. May change to null for tracking iframes
     * to prevent them from being recreated.
     * @type {?string}
     **/
    this.iframeSrc = null;

    /**
     * The element which will contain the iframe. This may be the amp-iframe
     * itself if the iframe is non-scrolling, or a wrapper element if it is.
     * @private {?Element}
     */
    this.container_ = null;

    /**
     * The origin of URL at `src` attr, if available. Otherwise, null.
     * @private {?string}
     */
    this.targetOrigin_ = null;

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

    /** @private @const */
    this.pauseHelper_ = new PauseHelper(this.element);
  }

  /** @override */
  isLayoutSupported(layout) {
    return isLayoutSizeDefined(layout);
  }

  /**
   * @param {string} src
   * @param {string} containerSrc
   * @param {string} sandbox
   * @return {string}
   * @private
   */
  assertSource_(src, containerSrc, sandbox = '') {
    const {element} = this;
    const urlService = Services.urlForDoc(element);
    const url = urlService.parse(src);
    const {hostname, origin, protocol} = url;
    // Some of these can be easily circumvented with redirects.
    // Checks are mostly there to prevent people easily do something
    // they did not mean to.
    userAssert(
      urlService.isSecure(src) || protocol == 'data:',
      'Invalid <amp-iframe> src. Must start with https://. Found %s',
      element
    );
    const containerUrl = urlService.parse(containerSrc);
    userAssert(
      !this.sandboxContainsToken_(sandbox, 'allow-same-origin') ||
        (origin != containerUrl.origin && protocol != 'data:'),
      'Origin of <amp-iframe> must not be equal to container %s' +
        ' if allow-same-origin is set. See https://github.com/ampproject/' +
        'amphtml/blob/main/docs/spec/amp-iframe-origin-policy.md for details.',
      element
    );
    userAssert(
      !(
        endsWith(hostname, `.${urls.thirdPartyFrameHost}`) ||
        // eslint-disable-next-line local/no-forbidden-terms
        endsWith(hostname, '.ampproject.org')
      ),
      'amp-iframe does not allow embedding of frames from ' +
        // eslint-disable-next-line local/no-forbidden-terms
        'ampproject.*: %s',
      src
    );
    return src;
  }

  /** @private */
  assertPosition_() {
    const pos = this.element.getLayoutBox();
    const minTop = Math.min(600, this.getViewport().getSize().height * 0.75);
    userAssert(
      pos.top >= minTop,
      '<amp-iframe> elements must be positioned outside the first 75% ' +
        'of the viewport or 600px from the top (whichever is smaller): %s ' +
        ' Current position %s. Min: %s' +
        "Positioning rules don't apply for iframes that use `placeholder`." +
        'See https://github.com/ampproject/amphtml/blob/main/extensions/' +
        'amp-iframe/amp-iframe.md#iframe-with-placeholder for details.',
      this.element,
      pos.top,
      minTop
    );
  }

  /**
   * @param {string} sandbox
   * @param {string} token
   * @return {boolean}
   * @private
   */
  sandboxContainsToken_(sandbox, token) {
    const re = new RegExp(`\\s${token}\\s`, 'i');
    return re.test(' ' + sandbox + ' ');
  }

  /**
   * Transforms the src attribute. When possible, it adds `#amp=1` fragment
   * to indicate that the iframe is running in AMP environment.
   * @param {?string} src
   * @return {string|undefined}
   * @private
   */
  transformSrc_(src) {
    if (!src) {
      return;
    }
    const {hash, protocol} = Services.urlForDoc(this.element).parse(src);
    // data-URLs are not modified.
    if (protocol == 'data:') {
      return src;
    }
    // If fragment already exists, it's not modified.
    if (hash && hash != '#') {
      return src;
    }
    // Add `#amp=1` fragment.
    return removeFragment(src) + '#amp=1';
  }

  /**
   * Transforms the srcdoc attribute if present to an equivalent data URI.
   *
   * It may be OK to change this later to leave the `srcdoc` in place and
   * instead ensure that `allow-same-origin` is not present, but this
   * implementation has the right security behavior which is that the document
   * may under no circumstances be able to run JS on the parent.
   * @param {?string} srcdoc
   * @param {string} sandbox
   * @return {string|undefined} Data URI for the srcdoc
   * @private
   */
  transformSrcDoc_(srcdoc, sandbox) {
    if (!srcdoc) {
      return;
    }
    userAssert(
      !(' ' + sandbox + ' ').match(/\s+allow-same-origin\s+/i),
      'allow-same-origin is not allowed with the srcdoc attribute %s.',
      this.element
    );

    return (
      'data:text/html;charset=utf-8;base64,' +
      base64EncodeFromBytes(utf8Encode(srcdoc))
    );
  }

  /**
   * @param {boolean=} onLayout
   * @override
   */
  preconnectCallback(onLayout) {
    if (this.iframeSrc) {
      Services.preconnectFor(this.win).url(
        this.getAmpDoc(),
        this.iframeSrc,
        onLayout
      );
    }
  }

  /** @override */
  buildCallback() {
    this.sandbox_ = this.element.getAttribute('sandbox');

    const iframeSrc = /** @type {string} */ (
      this.transformSrc_(this.element.getAttribute('src')) ||
        this.transformSrcDoc_(
          this.element.getAttribute('srcdoc'),
          this.sandbox_
        )
    );
    this.iframeSrc = this.assertSource_(
      iframeSrc,
      window.location.href,
      this.sandbox_
    );

    this.placeholder_ = this.getPlaceholder();
    this.hasPlaceholder_ = !!this.placeholder_;

    this.isResizable_ = this.element.hasAttribute('resizable');
    if (this.isResizable_) {
      this.element.setAttribute('scrolling', 'no');
    }

    if (!this.element.hasAttribute('frameborder')) {
      this.element.setAttribute('frameborder', '0');
    }

    this.container_ = makeIOsScrollable(this.element);

    this.registerIframeMessaging_();
  }

  /** @override */
  onLayoutMeasure() {
    const {element} = this;

    this.isAdLike_ = isAdLike(element);
    this.isTrackingFrame_ = this.looksLikeTrackingIframe_();
    this.isDisallowedAsAd_ =
      this.isAdLike_ && !isAdPositionAllowed(element, this.win);
  }

  /**
   * @return {boolean}
   * @private
   */
  looksLikeTrackingIframe_() {
    // It may be tempting to inline this method, but it's referenced in tests.
    return looksLikeTrackingIframe(this.element);
  }

  /** @override */
  layoutCallback() {
    userAssert(
      !this.isDisallowedAsAd_,
      'amp-iframe is not used for ' +
        'displaying fixed ad. Please use amp-sticky-ad and amp-ad instead.'
    );

    if (!this.hasPlaceholder_) {
      this.assertPosition_();
    }

    if (this.isResizable_) {
      userAssert(
        this.getOverflowElement(),
        'Overflow element must be defined for resizable frames: %s',
        this.element
      );
    }

    if (!this.iframeSrc) {
      // This failed already, lets not signal another error.
      return Promise.resolve();
    }

    if (this.isTrackingFrame_) {
      if (
        !this.getAmpDoc().registerSingleton(
          AMPDOC_SINGLETON_NAME_ENUM.TRACKING_IFRAME
        )
      ) {
        console /*OK*/
          .error(
            'Only 1 analytics/tracking iframe allowed per ' +
              'page. Please use amp-analytics instead or file a GitHub issue ' +
              'for your use case: ' +
              'https://github.com/ampproject/amphtml/issues/new/choose'
          );
        return Promise.resolve();
      }
    }

    const iframe = this.element.ownerDocument.createElement('iframe');

    this.iframe_ = /** @type {HTMLIFrameElement} */ (iframe);

    applyFillContent(iframe);
    iframe.name = 'amp_iframe' + count++;

    if (this.hasPlaceholder_) {
      setStyle(iframe, 'zIndex', -1);
    }

    propagateAttributes(ATTRIBUTES_TO_PROPAGATE, this.element, iframe);

    // TEMPORARY: disable `allow=autoplay`
    // This is a workaround for M72-M74 user-activation breakage.
    // If this is still here in May 2019, please ping @aghassemi
    // See https://github.com/ampproject/amphtml/issues/21242 for details.
    // TODO(aghassemi, #21247)
    let allowVal = iframe.getAttribute('allow') || '';
    // allow syntax is complex, not worth parsing for temp code.
    allowVal = allowVal.replace('autoplay', 'autoplay-disabled');
    iframe.setAttribute('allow', allowVal);

    setSandbox(this.element, iframe, this.sandbox_);

    iframe.src = this.iframeSrc;

    if (!this.isTrackingFrame_) {
      this.intersectionObserverHostApi_ = new IntersectionObserver3pHost(
        this,
        iframe
      );
    }

    iframe.onload = () => {
      // Chrome does not reflect the iframe readystate.
      iframe.readyState = 'complete';
      this.activateIframe_();

      if (this.isTrackingFrame_) {
        // Prevent this iframe from ever being recreated.
        this.iframeSrc = null;

        Services.timerFor(this.win)
          .promise(trackingIframeTimeout)
          .then(() => {
            removeElement(iframe);
            this.element.setAttribute('amp-removed', '');
            this.iframe_ = null;
          });
      }
    };

    listenFor(
      iframe,
      'embed-size',
      (data) => {
        this.updateSize_(data['height'], data['width']);
      },
      /*opt_is3P*/ undefined,
      /*opt_includingNestedWindows*/ undefined,
      /*opt_allowOpaqueOrigin*/ true
    );

    // Listen for resize messages sent by Pym.js.
    this.unlistenPym_ = listen(this.win, 'message', (event) => {
      return this.listenForPymMessage_(/** @type {!MessageEvent} */ (event));
    });

    if (this.hasPlaceholder_) {
      listenFor(iframe, 'embed-ready', this.activateIframe_.bind(this));
    }

    listenFor(
      iframe,
      MessageType_Enum.SEND_CONSENT_DATA,
      (data, source, origin) => {
        this.sendConsentData_(source, origin);
      }
    );

    this.container_.appendChild(iframe);

    return this.loadPromise(iframe).then(() => {
      // On iOS the iframe at times fails to render inside the `overflow:auto`
      // container. To avoid this problem, we set the `overflow:auto` property
      // 1s later via `amp-active` class.
      if (this.container_ != this.element) {
        Services.timerFor(this.win).delay(() => {
          this.mutateElement(() => {
            this.container_.classList.add('amp-active');
          });
        }, 1000);
      }

      this.pauseHelper_.updatePlaying(true);
    });
  }

  /**
   * Listen for Pym.js messages for 'height' and 'width'.
   *
   * @see http://blog.apps.npr.org/pym.js/
   * @param {!MessageEvent} event
   * @private
   */
  listenForPymMessage_(event) {
    if (!this.iframe_ || event.source !== this.iframe_.contentWindow) {
      return;
    }
    const data = getData(event);
    if (typeof data !== 'string' || !data.startsWith('pym')) {
      return;
    }

    // The format of the message takes the form of `pymxPYMx${id}xPYMx${type}xPYMx${message}`.
    // The id is unnecessary for integration with amp-iframe; the possible types include
    // 'height', 'width', 'parentPositionInfo', 'navigateTo', and  'scrollToChildPos'.
    // Only the 'height' and 'width' messages are currently supported.
    // See <https://github.com/nprapps/pym.js/blob/57feb68/src/pym.js#L85-L102>
    const args = data.split(/xPYMx/);
    if ('height' === args[2]) {
      this.updateSize_(parseInt(args[3], 10), undefined);
    } else if ('width' === args[2]) {
      this.updateSize_(undefined, parseInt(args[3], 10));
    } else {
      user().warn(TAG_, `Unsupported Pym.js message: ${data}`);
    }
  }

  /**
   * Requests consent data from consent module
   * and forwards information to iframe
   * @param {Window} source
   * @param {string} origin
   * @private
   */
  sendConsentData_(source, origin) {
    getConsentDataToForward(this.element, this.getConsentPolicy()).then(
      (consents) => {
        this.sendConsentDataToIframe_(source, origin, {
          'sentinel': 'amp',
          'type': MessageType_Enum.CONSENT_DATA,
          ...consents,
        });
      }
    );
  }

  /**
   * Send consent data to iframe
   * @param {Window} source
   * @param {string} origin
   * @param {JsonObject} data
   * @private
   */
  sendConsentDataToIframe_(source, origin, data) {
    source./*OK*/ postMessage(data, origin);
  }

  /**
   * Removes this iframe from the page, freeing its resources. This is needed
   * to stop the bad eggs who continue to play videos even after the user has
   * swiped away from the doc.
   * @override
   **/
  unlayoutCallback() {
    if (this.unlistenPym_) {
      this.unlistenPym_();
      this.unlistenPym_ = null;
    }
    if (this.iframe_) {
      removeElement(this.iframe_);
      if (this.placeholder_) {
        this.togglePlaceholder(true);
      }

      this.iframe_ = null;
      // Needs to clean up intersectionObserverHostApi_
      if (this.intersectionObserverHostApi_) {
        this.intersectionObserverHostApi_.destroy();
        this.intersectionObserverHostApi_ = null;
      }
    }
    this.pauseHelper_.updatePlaying(false);
    return true;
  }

  /** @override  */
  getLayoutPriority() {
    if (this.isAdLike_) {
      return LayoutPriority_Enum.ADS; // See AmpAd3PImpl.
    }
    if (this.isTrackingFrame_) {
      return LayoutPriority_Enum.METADATA;
    }
    return super.getLayoutPriority();
  }

  /** @override */
  mutatedAttributesCallback(mutations) {
    const src = mutations['src'];
    if (src !== undefined) {
      this.iframeSrc = /** @type {?string} */ (this.transformSrc_(src));
      if (this.iframe_) {
        this.iframe_.src = this.assertSource_(
          /** @type {string} */ (this.iframeSrc),
          window.location.href,
          this.sandbox_
        );
      }
    }
    if (this.iframe_ && mutations['title']) {
      // only propagating title because propagating all causes e2e error:
      propagateAttributes(['title'], this.element, this.iframe_);
    }
  }

  /** @override */
  unlayoutOnPause() {
    return true;
  }

  /**
   * Makes the iframe visible.
   * @private
   */
  activateIframe_() {
    if (this.placeholder_) {
      this.getVsync().mutate(() => {
        if (this.iframe_) {
          setStyle(this.iframe_, 'zIndex', 0);
          this.togglePlaceholder(false);
        }
      });
    }
  }

  /**
   * No need for the default behavior, we'll call togglePlaceholder ourselves.
   * @override
   */
  firstLayoutCompleted() {}

  /**
   * Throws an error if window navigation is disallowed by this element.
   * Otherwise, does nothing.
   * @throws {!Error}
   */
  throwIfCannotNavigate() {
    if (!this.sandboxContainsToken_(this.sandbox_, 'allow-top-navigation')) {
      throw user().createError(
        '"AMP.navigateTo" is only allowed on ' +
          '<amp-iframe> when its "sandbox" attribute contains ' +
          '"allow-top-navigation".'
      );
    }
  }

  /**
   * Updates the element's dimensions to accommodate the iframe's
   *    requested dimensions.
   * @param {number|undefined} height
   * @param {number|undefined} width
   * @private
   */
  updateSize_(height, width) {
    if (!this.isResizable_) {
      if (!this.hasErroredEmbedSize_) {
        this.user().error(
          TAG_,
          'Ignoring embed-size request because this iframe is not resizable',
          this.element
        );
        this.hasErroredEmbedSize_ = true;
      }
      return;
    }

    if (height < 100) {
      this.user().error(
        TAG_,
        'Ignoring embed-size request because the resize height is less ' +
          'than 100px. If you are using amp-iframe to display ads, consider ' +
          'using amp-ad instead.',
        this.element
      );
      return;
    }

    // Calculate new width and height of the container to include the padding.
    // If padding is negative, just use the requested width and height directly.
    let newHeight, newWidth;
    height = parseInt(height, 10);
    if (!isNaN(height)) {
      newHeight = Math.max(
        height +
          (this.element./*OK*/ offsetHeight - this.iframe_./*OK*/ offsetHeight),
        height
      );
    }
    width = parseInt(width, 10);
    if (!isNaN(width)) {
      newWidth = Math.max(
        width +
          (this.element./*OK*/ offsetWidth - this.iframe_./*OK*/ offsetWidth),
        width
      );
    }

    if (newHeight !== undefined || newWidth !== undefined) {
      this.attemptChangeSize(newHeight, newWidth).then(
        () => {
          if (newHeight !== undefined) {
            this.element.setAttribute('height', newHeight);
          }
          if (newWidth !== undefined) {
            this.element.setAttribute('width', newWidth);
          }
          this.element.overflowCallback(
            /* overflown */ false,
            newHeight,
            newWidth
          );
        },
        () => {}
      );
    } else {
      this.user().error(
        TAG_,
        'Ignoring embed-size request because ' +
          'no width or height value is provided',
        this.element
      );
    }
  }

  /**
   * Registers 'postMessage' action and 'message' event.
   * @private
   */
  registerIframeMessaging_() {
    if (!isExperimentOn(this.win, 'iframe-messaging')) {
      return;
    }

    const {element} = this;
    const src = element.getAttribute('src');
    if (src) {
      this.targetOrigin_ = Services.urlForDoc(element).parse(src).origin;
    }

    // Register action (even if targetOrigin_ is not available so we can
    // provide a helpful error message).
    this.registerAction('postMessage', (invocation) => {
      if (this.targetOrigin_) {
        this.iframe_.contentWindow./*OK*/ postMessage(
          invocation.args,
          this.targetOrigin_
        );
      } else {
        user().error(
          TAG_,
          '"postMessage" action is only allowed with "src"' +
            'attribute with an origin.'
        );
      }
    });

    // However, don't listen for 'message' event if targetOrigin_ is null.
    if (!this.targetOrigin_) {
      return;
    }

    const maxUnexpectedMessages = 10;
    let unexpectedMessages = 0;

    const listener = (e) => {
      if (e.source !== this.iframe_.contentWindow) {
        // Ignore messages from other iframes.
        return;
      }
      if (e.origin !== this.targetOrigin_) {
        user().error(
          TAG_,
          '"message" received from unexpected origin: ' +
            e.origin +
            '. Only allowed from: ' +
            this.targetOrigin_
        );
        return;
      }
      if (!this.isUserGesture_()) {
        unexpectedMessages++;
        user().error(
          TAG_,
          '"message" event may only be triggered from a user gesture.'
        );
        // Disable the 'message' event if the iframe is behaving badly.
        if (unexpectedMessages >= maxUnexpectedMessages) {
          user().error(
            TAG_,
            'Too many non-gesture-triggered "message" ' +
              'events; detaching event listener.'
          );
          this.win.removeEventListener('message', listener);
        }
        return;
      }
      const unsanitized = getData(e);
      let sanitized;
      try {
        sanitized = parseJson(JSON.stringify(unsanitized));
      } catch (e) {
        user().error(TAG_, 'Data from "message" event must be JSON.');
        return;
      }
      const event = createCustomEvent(this.win, 'amp-iframe:message', {
        'data': sanitized,
      });
      const actionService = Services.actionServiceForDoc(this.element);
      actionService.trigger(
        this.element,
        'message',
        event,
        ActionTrust_Enum.HIGH
      );
    };
    // TODO(choumx): Consider using global listener in iframe-helper.
    this.win.addEventListener('message', listener);
  }

  /**
   * Returns true if a user gesture was recently performed.
   * @return {boolean}
   * @private
   */
  isUserGesture_() {
    // Best effort polyfill until native support is available: check that
    // iframe has focus and audio playback isn't immediately paused.
    if (this.getAmpDoc().getRootNode().activeElement !== this.iframe_) {
      return false;
    }
    const audio = this.win.document.createElement('audio');
    playIgnoringError(audio);
    if (audio.paused) {
      return false;
    }
    return true;
  }

  /**
   * @param {string} value
   * @visibleForTesting
   */
  setTargetOriginForTesting(value) {
    this.targetOrigin_ = value;
  }
}

/**
 * We always set a sandbox. Default is that none of the things that need
 * to be opted in are allowed.
 * @param {!Element} element
 * @param {!Element} iframe
 * @param {string} sandbox
 */
function setSandbox(element, iframe, sandbox) {
  const allows = sandbox || '';
  iframe.setAttribute('sandbox', allows);
}

/**
 * If scrolling is allowed for the iframe, wraps the element into a container
 * that is scrollable because iOS auto expands iframes to their size.
 * @param {!Element} element
 * @return {!Element} The container or the iframe.
 */
function makeIOsScrollable(element) {
  if (element.getAttribute('scrolling') != 'no') {
    const wrapper = element.ownerDocument.createElement(
      'i-amphtml-scroll-container'
    );
    element.appendChild(wrapper);
    return wrapper;
  }
  return element;
}

/**
 * @param {number} ms
 */
export function setTrackingIframeTimeoutForTesting(ms) {
  trackingIframeTimeout = ms;
}

AMP.extension(TAG_, '0.1', (AMP) => {
  AMP.registerElement(TAG_, AmpIframe);
});
