/**
 * Copyright 2016 The AMP HTML Authors. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS-IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {ActionTrust} from '../action-constants';
import {
  EMPTY_METADATA,
  parseFavicon,
  parseOgImage,
  parseSchemaImage,
  setMediaSession,
  validateMediaMetadata,
} from '../mediasession-helper';
import {
  MIN_VISIBILITY_RATIO_FOR_AUTOPLAY,
  PlayingStates,
  VideoAnalyticsEvents,
  VideoAttributes,
  VideoEvents,
  VideoServiceSignals,
  setIsMediaComponent,
  userInteractedWith,
  videoAnalyticsCustomEventTypeKey,
} from '../video-interface';
import {Services} from '../services';
import {VideoSessionManager} from './video-session-manager';
import {VideoUtils, getInternalVideoElementFor} from '../utils/video';
import {clamp} from '../utils/math';
import {
  createCustomEvent,
  getData,
  listen,
  listenOnce,
  listenOncePromise,
} from '../event-helper';
import {dev, devAssert, user, userAssert} from '../log';
import {dict, map} from '../utils/object';
import {getMode} from '../mode';
import {installAutoplayStylesForDoc} from './video/install-autoplay-styles';
import {isFiniteNumber} from '../types';
import {once} from '../utils/function';
import {registerServiceBuilderForDoc} from '../service';
import {removeElement} from '../dom';
import {renderIcon, renderInteractionOverlay} from './video/autoplay';
import {startsWith} from '../string';
import {toggle} from '../style';

/** @private @const {string} */
const TAG = 'video-manager';

/**
 * @private {number} The minimum number of milliseconds to wait between each
 * video-seconds-played analytics event.
 */
const SECONDS_PLAYED_MIN_DELAY = 1000;

/**
 * VideoManager keeps track of all AMP video players that implement
 * the common Video API {@see ../video-interface.VideoInterface}.
 *
 * It is responsible for providing a unified user experience and analytics for
 * all videos within a document.
 *
 * @implements {../service.Disposable}
 */
export class VideoManager {
  /**
   * @param {!./ampdoc-impl.AmpDoc} ampdoc
   */
  constructor(ampdoc) {
    /** @const {!./ampdoc-impl.AmpDoc}  */
    this.ampdoc = ampdoc;

    /** @const */
    this.installAutoplayStyles = once(() =>
      installAutoplayStylesForDoc(this.ampdoc)
    );

    /** @private {!../service/viewport/viewport-interface.ViewportInterface} */
    this.viewport_ = Services.viewportForDoc(this.ampdoc);

    /** @private {?Array<!VideoEntry>} */
    this.entries_ = null;

    /**
     * Keeps last found entry as a small optimization for multiple state calls
     * during one task.
     * @private {?VideoEntry}
     */
    this.lastFoundEntry_ = null;

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

    /** @private @const */
    this.timer_ = Services.timerFor(ampdoc.win);

    /** @private @const */
    this.actions_ = Services.actionServiceForDoc(ampdoc.getHeadNode());

    /**
     * @private
     * @const
     * @return {undefined}
     */
    this.boundSecondsPlaying_ = () => this.secondsPlaying_();

    /** @private @const {function():!AutoFullscreenManager} */
    this.getAutoFullscreenManager_ = once(
      () => new AutoFullscreenManager(this.ampdoc, this)
    );

    // TODO(cvializ, #10599): It would be nice to only create the timer
    // if video analytics are present, since the timer is not needed if
    // video analytics are not present.
    this.timer_.delay(this.boundSecondsPlaying_, SECONDS_PLAYED_MIN_DELAY);
  }

  /** @override */
  dispose() {
    this.getAutoFullscreenManager_().dispose();

    if (!this.entries_) {
      return;
    }
    for (let i = 0; i < this.entries_.length; i++) {
      const entry = this.entries_[i];
      entry.dispose();
    }
  }

  /**
   * Each second, trigger video-seconds-played for videos that are playing
   * at trigger time.
   * @private
   */
  secondsPlaying_() {
    for (let i = 0; i < this.entries_.length; i++) {
      const entry = this.entries_[i];
      if (entry.getPlayingState() !== PlayingStates.PAUSED) {
        analyticsEvent(entry, VideoAnalyticsEvents.SECONDS_PLAYED);
        this.timeUpdateActionEvent_(entry);
      }
    }
    this.timer_.delay(this.boundSecondsPlaying_, SECONDS_PLAYED_MIN_DELAY);
  }

  /**
   * Triggers a LOW-TRUST timeupdate event consumable by AMP actions.
   * Frequency of this event is controlled by SECONDS_PLAYED_MIN_DELAY and is
   * every 1 second for now.
   * @param {!VideoEntry} entry
   * @private
   */
  timeUpdateActionEvent_(entry) {
    const name = 'timeUpdate';
    const currentTime = entry.video.getCurrentTime();
    const duration = entry.video.getDuration();
    if (
      isFiniteNumber(currentTime) &&
      isFiniteNumber(duration) &&
      duration > 0
    ) {
      const perc = currentTime / duration;
      const event = createCustomEvent(
        this.ampdoc.win,
        `${TAG}.${name}`,
        dict({'time': currentTime, 'percent': perc})
      );
      this.actions_.trigger(entry.video.element, name, event, ActionTrust.LOW);
    }
  }

  /** @param {!../video-interface.VideoInterface} video */
  register(video) {
    devAssert(video);

    this.registerCommonActions_(video);

    if (!video.supportsPlatform()) {
      return;
    }

    this.entries_ = this.entries_ || [];
    const entry = new VideoEntry(this, video);
    this.maybeInstallVisibilityObserver_(entry);
    this.entries_.push(entry);

    const {element} = entry.video;
    element.dispatchCustomEvent(VideoEvents.REGISTERED);

    setIsMediaComponent(element);

    // Unlike events, signals are permanent. We can wait for `REGISTERED` at any
    // moment in the element's lifecycle and the promise will resolve
    // appropriately each time.
    const signals = /** @type {!../base-element.BaseElement} */ (video).signals();

    signals.signal(VideoEvents.REGISTERED);

    // Add a class to element to indicate it implements the video interface.
    element.classList.add('i-amphtml-video-interface');
  }

  /**
   * Register common actions such as play, pause, etc... on the video element
   * so they can be called using AMP Actions.
   * For example: <button on="tap:myVideo.play">
   *
   * @param {!../video-interface.VideoOrBaseElementDef} video
   * @private
   */
  registerCommonActions_(video) {
    // Only require ActionTrust.LOW for video actions to defer to platform
    // specific handling (e.g. user gesture requirement for unmuted playback).
    const trust = ActionTrust.LOW;

    registerAction('play', () => video.play(/* isAutoplay */ false));
    registerAction('pause', () => video.pause());
    registerAction('mute', () => video.mute());
    registerAction('unmute', () => video.unmute());

    // fullscreen/fullscreenenter are a special case.
    // - fullscreenenter is kept as a standard name for symmetry with internal
    //   internal interfaces
    // - fullscreen is an undocumented alias for backwards compatibility.
    const fullscreenEnter = () => video.fullscreenEnter();
    registerAction('fullscreenenter', fullscreenEnter);
    registerAction('fullscreen', fullscreenEnter);

    /**
     * @param {string} action
     * @param {function()} fn
     */
    function registerAction(action, fn) {
      video.registerAction(
        action,
        () => {
          userInteractedWith(video);
          fn();
        },
        trust
      );
    }
  }

  /**
   * Install the necessary listeners to be notified when a video becomes visible
   * in the viewport.
   *
   * Visibility of a video is defined by being in the viewport AND having
   * {@link MIN_VISIBILITY_RATIO_FOR_AUTOPLAY} of the video element visible.
   *
   * @param {VideoEntry} entry
   * @private
   */
  maybeInstallVisibilityObserver_(entry) {
    const {element} = entry.video;

    listen(element, VideoEvents.VISIBILITY, (details) => {
      const data = getData(details);
      if (data && data['visible'] == true) {
        entry.updateVisibility(/* opt_forceVisible */ true);
      } else {
        entry.updateVisibility();
      }
    });

    listen(element, VideoEvents.RELOAD, () => {
      entry.videoLoaded();
    });

    // TODO(aghassemi, #6425): Use IntersectionObserver
    if (!this.scrollListenerInstalled_) {
      const scrollListener = () => {
        for (let i = 0; i < this.entries_.length; i++) {
          this.entries_[i].updateVisibility();
        }
      };
      this.viewport_.onScroll(scrollListener);
      this.viewport_.onChanged(scrollListener);
      this.scrollListenerInstalled_ = true;
    }
  }

  /**
   * Returns the entry in the video manager corresponding to the video or
   * element provided
   * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement
   * @return {VideoEntry} entry
   */
  getEntry_(videoOrElement) {
    if (isEntryFor(this.lastFoundEntry_, videoOrElement)) {
      return this.lastFoundEntry_;
    }

    for (let i = 0; i < this.entries_.length; i++) {
      const entry = this.entries_[i];
      if (isEntryFor(entry, videoOrElement)) {
        this.lastFoundEntry_ = entry;
        return entry;
      }
    }

    return devAssert(
      null,
      '%s not registered to VideoManager',
      videoOrElement.element || videoOrElement
    );
  }

  /** @param {!VideoEntry} entry */
  registerForAutoFullscreen(entry) {
    this.getAutoFullscreenManager_().register(entry);
  }

  /**
   * @return {!AutoFullscreenManager}
   * @visibleForTesting
   */
  getAutoFullscreenManagerForTesting_() {
    return this.getAutoFullscreenManager_();
  }

  /**
   * Gets the current analytics details property for the given video.
   * Fails silently if the video is not registered.
   * @param {string} id
   * @param {string} property
   * @return {!Promise<string>}
   */
  getVideoStateProperty(id, property) {
    const root = this.ampdoc.getRootNode();
    const videoElement = user().assertElement(
      root.getElementById(/** @type {string} */ (id)),
      `Could not find an element with id="${id}" for VIDEO_STATE`
    );
    const entry = this.getEntry_(videoElement);
    return (entry
      ? entry.getAnalyticsDetails()
      : Promise.resolve()
    ).then((details) => (details ? details[property] : ''));
  }

  // TODO(go.amp.dev/issue/27010): For getters below, let's expose VideoEntry
  // instead and use directly. This is better for size and sanity. Users can
  // also then keep the entry reference for their own use.
  // (Can't expose yet due to package-level methods to be restructured, e.g
  // videoLoaded(). See issue)

  /**
   * Returns whether the video is paused or playing after the user interacted
   * with it or playing through autoplay
   *
   * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement
   * @return {!../video-interface.PlayingStateDef}
   */
  getPlayingState(videoOrElement) {
    return this.getEntry_(videoOrElement).getPlayingState();
  }

  /**
   * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement
   * @return {boolean}
   */
  isMuted(videoOrElement) {
    return this.getEntry_(videoOrElement).isMuted();
  }

  /**
   * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement
   * @return {boolean}
   */
  userInteracted(videoOrElement) {
    return this.getEntry_(videoOrElement).userInteracted();
  }

  /**
   * @param {!../video-interface.VideoOrBaseElementDef|!Element} videoOrElement
   * @return {boolean}
   */
  isRollingAd(videoOrElement) {
    return this.getEntry_(videoOrElement).isRollingAd();
  }

  /**
   * @param {!VideoEntry} entryBeingPlayed
   */
  pauseOtherVideos(entryBeingPlayed) {
    this.entries_.forEach((entry) => {
      if (
        entry.isPlaybackManaged() &&
        entry !== entryBeingPlayed &&
        entry.getPlayingState() == PlayingStates.PLAYING_MANUAL
      ) {
        entry.video.pause();
      }
    });
  }
}

/**
 * @param {?VideoEntry=} entry
 * @param {?../video-interface.VideoOrBaseElementDef|!Element=} videoOrElement
 * @return {boolean}
 */
const isEntryFor = (entry, videoOrElement) =>
  !!entry &&
  (entry.video === videoOrElement || entry.video.element === videoOrElement);

/**
 * VideoEntry represents an entry in the VideoManager's list.
 */
class VideoEntry {
  /**
   * @param {!VideoManager} manager
   * @param {!../video-interface.VideoOrBaseElementDef} video
   */
  constructor(manager, video) {
    /** @private @const {!VideoManager} */
    this.manager_ = manager;

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

    /** @package @const {!../video-interface.VideoOrBaseElementDef} */
    this.video = video;

    /** @private {boolean} */
    this.managePlayback_ = true;

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

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

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

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

    /** @private @const */
    this.actionSessionManager_ = new VideoSessionManager();

    this.actionSessionManager_.onSessionEnd(() =>
      analyticsEvent(this, VideoAnalyticsEvents.SESSION)
    );

    /** @private @const */
    this.visibilitySessionManager_ = new VideoSessionManager();

    this.visibilitySessionManager_.onSessionEnd(() =>
      analyticsEvent(this, VideoAnalyticsEvents.SESSION_VISIBLE)
    );

    // eslint-disable-next-line jsdoc/require-returns
    /** @private @const {function(): !Promise<boolean>} */
    this.supportsAutoplay_ = () => {
      const {win} = this.ampdoc_;
      return VideoUtils.isAutoplaySupported(win, getMode(win).lite);
    };

    /** @private @const {function(): !AnalyticsPercentageTracker} */
    this.getAnalyticsPercentageTracker_ = once(
      () => new AnalyticsPercentageTracker(this.ampdoc_.win, this)
    );

    // Autoplay Variables

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

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

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

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

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

    this.hasAutoplay = video.element.hasAttribute(VideoAttributes.AUTOPLAY);

    if (this.hasAutoplay) {
      this.manager_.installAutoplayStyles();
    }

    // Media Session API Variables

    /** @private {!../mediasession-helper.MetadataDef} */
    this.metadata_ = EMPTY_METADATA;

    /** @private @const {function()} */
    this.boundMediasessionPlay_ = () => {
      this.video.play(/* isAutoplay */ false);
    };

    /** @private @const {function()} */
    this.boundMediasessionPause_ = () => {
      this.video.pause();
    };

    listenOncePromise(video.element, VideoEvents.LOAD).then(() =>
      this.videoLoaded()
    );
    listen(video.element, VideoEvents.PAUSE, () => this.videoPaused_());
    listen(video.element, VideoEvents.PLAY, () => {
      this.hasSeenPlayEvent_ = true;
      analyticsEvent(this, VideoAnalyticsEvents.PLAY);
    });
    listen(video.element, VideoEvents.PLAYING, () => this.videoPlayed_());
    listen(video.element, VideoEvents.MUTED, () => (this.muted_ = true));
    listen(video.element, VideoEvents.UNMUTED, () => {
      this.muted_ = false;
      this.manager_.pauseOtherVideos(this);
    });

    listen(video.element, VideoEvents.CUSTOM_TICK, (e) => {
      const data = getData(e);
      const eventType = data['eventType'];
      if (!eventType) {
        // CUSTOM_TICK is a generic event for 3p players whose semantics
        // don't fit with other video events.
        // If `eventType` is unset, it's not meant for analytics.
        return;
      }
      this.logCustomAnalytics_(eventType, data['vars']);
    });

    listen(video.element, VideoEvents.ENDED, () => {
      this.isRollingAd_ = false;
      analyticsEvent(this, VideoAnalyticsEvents.ENDED);
    });

    listen(video.element, VideoEvents.AD_START, () => {
      this.isRollingAd_ = true;
      analyticsEvent(this, VideoAnalyticsEvents.AD_START);
    });

    listen(video.element, VideoEvents.AD_END, () => {
      this.isRollingAd_ = false;
      analyticsEvent(this, VideoAnalyticsEvents.AD_END);
    });

    video
      .signals()
      .whenSignal(VideoEvents.REGISTERED)
      .then(() => this.onRegister_());

    /**
     * Trigger event for first manual play.
     * @private @const {!function()}
     */
    this.firstPlayEventOrNoop_ = once(() => {
      const firstPlay = 'firstPlay';
      const trust = ActionTrust.LOW;
      const event = createCustomEvent(
        this.ampdoc_.win,
        firstPlay,
        /* detail */ dict({})
      );
      const {element} = this.video;
      const actions = Services.actionServiceForDoc(element);
      actions.trigger(element, firstPlay, event, trust);
    });

    this.listenForPlaybackDelegation_();
  }

  /** @public */
  dispose() {
    this.getAnalyticsPercentageTracker_().stop();
  }

  /**
   * @param {string} eventType
   * @param {!Object<string, string>} vars
   */
  logCustomAnalytics_(eventType, vars) {
    const prefixedVars = {[videoAnalyticsCustomEventTypeKey]: eventType};

    Object.keys(vars).forEach((key) => {
      prefixedVars[`custom_${key}`] = vars[key];
    });

    analyticsEvent(this, VideoAnalyticsEvents.CUSTOM, prefixedVars);
  }

  /** Listens for signals to delegate playback to a different module. */
  listenForPlaybackDelegation_() {
    const signals = this.video.signals();
    signals.whenSignal(VideoServiceSignals.PLAYBACK_DELEGATED).then(() => {
      this.managePlayback_ = false;

      if (this.isPlaying_) {
        this.video.pause();
      }
    });
  }

  /** @return {boolean} */
  isMuted() {
    return this.muted_;
  }

  /** @return {boolean} */
  isPlaybackManaged() {
    return this.managePlayback_;
  }

  /** @private */
  onRegister_() {
    if (this.requiresAutoFullscreen_()) {
      this.manager_.registerForAutoFullscreen(this);
    }

    this.updateVisibility();
    if (this.hasAutoplay) {
      this.autoplayVideoBuilt_();
    }
  }

  /**
   * @return {boolean}
   * @private
   */
  requiresAutoFullscreen_() {
    const {element} = this.video;
    if (
      this.video.preimplementsAutoFullscreen() ||
      !element.hasAttribute(VideoAttributes.ROTATE_TO_FULLSCREEN)
    ) {
      return false;
    }
    return userAssert(
      this.video.isInteractive(),
      'Only interactive videos are allowed to enter fullscreen on rotate. ' +
        'Set the `controls` attribute on %s to enable.',
      element
    );
  }

  /**
   * Callback for when the video starts playing
   * @private
   */
  videoPlayed_() {
    this.isPlaying_ = true;

    if (this.getPlayingState() == PlayingStates.PLAYING_MANUAL) {
      this.firstPlayEventOrNoop_();
      this.manager_.pauseOtherVideos(this);
    }

    const {video} = this;
    const {element} = video;

    if (
      !video.preimplementsMediaSessionAPI() &&
      !element.classList.contains('i-amphtml-disable-mediasession')
    ) {
      validateMediaMetadata(element, this.metadata_);
      setMediaSession(
        this.ampdoc_.win,
        this.metadata_,
        this.boundMediasessionPlay_,
        this.boundMediasessionPause_
      );
    }

    this.actionSessionManager_.beginSession();
    if (this.isVisible_) {
      this.visibilitySessionManager_.beginSession();
    }

    // The PLAY event was omitted from the original VideoInterface. Thus
    // not every implementation emits it. It should always happen before
    // PLAYING. Hence we treat the PLAYING as an indication to emit the
    // Analytics PLAY event if we haven't seen PLAY.
    if (!this.hasSeenPlayEvent_) {
      analyticsEvent(this, VideoAnalyticsEvents.PLAY);
    }
  }

  /**
   * Callback for when the video has been paused
   * @private
   */
  videoPaused_() {
    analyticsEvent(this, VideoAnalyticsEvents.PAUSE);
    this.isPlaying_ = false;

    // Prevent double-trigger of session if video is autoplay and the video
    // is paused by a the user scrolling the video out of view.
    if (!this.pauseCalledByAutoplay_) {
      this.actionSessionManager_.endSession();
    } else {
      // reset the flag
      this.pauseCalledByAutoplay_ = false;
    }
  }
  /**
   * Called when the video is loaded and can play.
   */
  videoLoaded() {
    this.loaded_ = true;

    this.internalElement_ = getInternalVideoElementFor(this.video.element);

    this.fillMediaSessionMetadata_();

    this.getAnalyticsPercentageTracker_().start();

    this.updateVisibility();
    if (this.isVisible_) {
      // Handles the case when the video becomes visible before loading
      this.loadedVideoVisibilityChanged_();
    }
  }

  /**
   * Gets the provided metadata and fills in missing fields
   * @private
   */
  fillMediaSessionMetadata_() {
    if (this.video.preimplementsMediaSessionAPI()) {
      return;
    }

    if (this.video.getMetadata()) {
      this.metadata_ = map(
        /** @type {!../mediasession-helper.MetadataDef} */
        (this.video.getMetadata())
      );
    }

    const doc = this.ampdoc_.win.document;

    if (!this.metadata_.artwork || this.metadata_.artwork.length == 0) {
      const posterUrl =
        parseSchemaImage(doc) || parseOgImage(doc) || parseFavicon(doc);

      if (posterUrl) {
        this.metadata_.artwork = [
          {
            'src': posterUrl,
          },
        ];
      }
    }

    if (!this.metadata_.title) {
      const title =
        this.video.element.getAttribute('title') ||
        this.video.element.getAttribute('aria-label') ||
        this.internalElement_.getAttribute('title') ||
        this.internalElement_.getAttribute('aria-label') ||
        doc.title;
      if (title) {
        this.metadata_.title = title;
      }
    }
  }

  /**
   * Called when visibility of a video changes.
   * @private
   */
  videoVisibilityChanged_() {
    if (this.loaded_) {
      this.loadedVideoVisibilityChanged_();
    }
  }

  /**
   * Only called when visibility of a loaded video changes.
   * @private
   */
  loadedVideoVisibilityChanged_() {
    if (!this.ampdoc_.isVisible()) {
      return;
    }
    this.supportsAutoplay_().then((supportsAutoplay) => {
      const canAutoplay = this.hasAutoplay && !this.userInteracted();

      if (canAutoplay && supportsAutoplay) {
        this.autoplayLoadedVideoVisibilityChanged_();
      } else {
        this.nonAutoplayLoadedVideoVisibilityChanged_();
      }
    });
  }

  /* Autoplay Behavior */

  /**
   * Called when an autoplay video is built.
   * @private
   */
  autoplayVideoBuilt_() {
    // Hide controls until we know if autoplay is supported, otherwise hiding
    // and showing the controls quickly becomes a bad user experience for the
    // common case where autoplay is supported.
    if (this.video.isInteractive()) {
      this.video.hideControls();
    }

    this.supportsAutoplay_().then((supportsAutoplay) => {
      if (!supportsAutoplay && this.video.isInteractive()) {
        // Autoplay is not supported, show the controls so user can manually
        // initiate playback.
        this.video.showControls();
        return;
      }

      // Only muted videos are allowed to autoplay
      this.video.mute();

      this.installAutoplayArtifacts_();
    });
  }

  /**
   * Installs autoplay animation and interaction mask when interactive.
   * The animated icon is appended always, but only displayed by CSS when
   * `controls` is set. See `video-autoplay.css`.
   * @private
   */
  installAutoplayArtifacts_() {
    const {video} = this;
    const {element, win} = this.video;

    if (
      element.hasAttribute(VideoAttributes.NO_AUDIO) ||
      element.signals().get(VideoServiceSignals.USER_INTERACTED)
    ) {
      return;
    }

    const animation = renderIcon(win, element);

    /** @param {boolean} isPlaying */
    const toggleAnimation = (isPlaying) => {
      video.mutateElementSkipRemeasure(() =>
        animation.classList.toggle('amp-video-eq-play', isPlaying)
      );
    };

    video.mutateElementSkipRemeasure(() => element.appendChild(animation));

    const unlisteners = [
      listen(element, VideoEvents.PAUSE, () => toggleAnimation(false)),
      listen(element, VideoEvents.PLAYING, () => toggleAnimation(true)),
    ];

    video
      .signals()
      .whenSignal(VideoServiceSignals.USER_INTERACTED)
      .then(() => {
        const {video} = this;
        const {element} = video;
        this.firstPlayEventOrNoop_();
        if (video.isInteractive()) {
          video.showControls();
        }
        video.unmute();
        unlisteners.forEach((unlistener) => {
          unlistener();
        });
        const animation = element.querySelector('.amp-video-eq');
        const mask = element.querySelector('i-amphtml-video-mask');
        if (animation) {
          removeElement(animation);
        }
        if (mask) {
          removeElement(mask);
        }
      });

    if (!video.isInteractive()) {
      return;
    }

    const mask = renderInteractionOverlay(element);

    /** @param {boolean} display */
    const setMaskDisplay = (display) => {
      video.mutateElementSkipRemeasure(() => toggle(mask, display));
    };

    video.hideControls();

    video.mutateElementSkipRemeasure(() => element.appendChild(mask));

    [
      listen(mask, 'click', () => userInteractedWith(video)),
      listen(element, VideoEvents.AD_START, () => {
        setMaskDisplay(false);
        video.showControls();
      }),
      listen(element, VideoEvents.AD_END, () => {
        setMaskDisplay(true);
        video.hideControls();
      }),
      listen(element, VideoEvents.UNMUTED, () => userInteractedWith(video)),
    ].forEach((unlistener) => unlisteners.push(unlistener));
  }

  /**
   * Called when visibility of a loaded autoplay video changes.
   * @private
   */
  autoplayLoadedVideoVisibilityChanged_() {
    if (!this.managePlayback_) {
      return;
    }
    if (this.isVisible_) {
      this.visibilitySessionManager_.beginSession();
      this.video.play(/*autoplay*/ true);
      this.playCalledByAutoplay_ = true;
    } else {
      if (this.isPlaying_) {
        this.visibilitySessionManager_.endSession();
      }
      this.video.pause();
      this.pauseCalledByAutoplay_ = true;
    }
  }

  /**
   * Called when visibility of a loaded non-autoplay video changes.
   * @private
   */
  nonAutoplayLoadedVideoVisibilityChanged_() {
    if (this.isVisible_) {
      this.visibilitySessionManager_.beginSession();
    } else if (this.isPlaying_) {
      this.visibilitySessionManager_.endSession();
    }
  }

  /**
   * Called by all possible events that might change the visibility of the video
   * such as scrolling or {@link ../video-interface.VideoEvents#VISIBILITY}.
   * @param {?boolean=} opt_forceVisible
   * @package
   */
  updateVisibility(opt_forceVisible) {
    const wasVisible = this.isVisible_;

    if (opt_forceVisible) {
      this.isVisible_ = true;
    } else {
      const {element} = this.video;
      const ratio = element.getIntersectionChangeEntry().intersectionRatio;
      this.isVisible_ =
        (!isFiniteNumber(ratio) ? 0 : ratio) >=
        MIN_VISIBILITY_RATIO_FOR_AUTOPLAY;
    }

    if (this.isVisible_ != wasVisible) {
      this.videoVisibilityChanged_();
    }
  }

  /**
   * Returns whether the video is paused or playing after the user interacted
   * with it or playing through autoplay
   * @return {!../video-interface.PlayingStateDef}
   */
  getPlayingState() {
    if (!this.isPlaying_) {
      return PlayingStates.PAUSED;
    }

    if (
      this.isPlaying_ &&
      this.playCalledByAutoplay_ &&
      !this.userInteracted()
    ) {
      return PlayingStates.PLAYING_AUTO;
    }

    return PlayingStates.PLAYING_MANUAL;
  }

  /** @return {boolean} */
  isRollingAd() {
    return this.isRollingAd_;
  }

  /**
   * Returns whether the video was interacted with or not
   * @return {boolean}
   */
  userInteracted() {
    return (
      this.video.signals().get(VideoServiceSignals.USER_INTERACTED) != null
    );
  }

  /**
   * Collects a snapshot of the current video state for video analytics
   * @return {!Promise<!VideoAnalyticsDetailsDef>}
   */
  getAnalyticsDetails() {
    const {video} = this;
    return this.supportsAutoplay_().then((supportsAutoplay) => {
      const {width, height} = video.element.getLayoutBox();
      const autoplay = this.hasAutoplay && supportsAutoplay;
      const playedRanges = video.getPlayedRanges();
      const playedTotal = playedRanges.reduce(
        (acc, range) => acc + range[1] - range[0],
        0
      );

      return {
        'autoplay': autoplay,
        'currentTime': video.getCurrentTime(),
        'duration': video.getDuration(),
        // TODO(cvializ): add fullscreen
        'height': height,
        'id': video.element.id,
        'muted': this.muted_,
        'playedTotal': playedTotal,
        'playedRangesJson': JSON.stringify(playedRanges),
        'state': this.getPlayingState(),
        'width': width,
      };
    });
  }
}

/**
 * @param {!AmpElement} video
 * @return {boolean}
 * @restricted
 */
function supportsFullscreenViaApi(video) {
  // TODO(alanorozco): Determine this via a flag in the component itself.
  return !!{
    'amp-dailymotion': true,
    'amp-ima-video': true,
  }[video.tagName.toLowerCase()];
}

/** Manages rotate-to-fullscreen video. */
export class AutoFullscreenManager {
  /**
   * @param {!./ampdoc-impl.AmpDoc} ampdoc
   * @param {!VideoManager} manager
   */
  constructor(ampdoc, manager) {
    /** @private @const {!VideoManager} */
    this.manager_ = manager;

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

    /** @private {?../video-interface.VideoOrBaseElementDef} */
    this.currentlyInFullscreen_ = null;

    /** @private {?../video-interface.VideoOrBaseElementDef} */
    this.currentlyCentered_ = null;

    /** @private @const {!Array<!../video-interface.VideoOrBaseElementDef>} */
    this.entries_ = [];

    /**
     * Unlisteners for global objects
     * @private {!Array<!UnlistenDef>}
     */
    this.unlisteners_ = [];

    // eslint-disable-next-line jsdoc/require-returns
    /** @private @const {function()} */
    this.boundSelectBestCentered_ = () => this.selectBestCenteredInPortrait_();

    /**
     * @param {!../video-interface.VideoOrBaseElementDef} video
     * @return {boolean}
     */
    this.boundIncludeOnlyPlaying_ = (video) =>
      this.getPlayingState_(video) == PlayingStates.PLAYING_MANUAL;

    /**
     * @param {!../video-interface.VideoOrBaseElementDef} a
     * @param {!../video-interface.VideoOrBaseElementDef} b
     * @return {number}
     */
    this.boundCompareEntries_ = (a, b) => this.compareEntries_(a, b);

    this.installOrientationObserver_();
    this.installFullscreenListener_();
  }

  /** @public */
  dispose() {
    this.unlisteners_.forEach((unlisten) => unlisten());
    this.unlisteners_.length = 0;
  }

  /** @param {!VideoEntry} entry */
  register(entry) {
    const {video} = entry;
    const {element} = video;

    if (!this.canFullscreen_(element)) {
      return;
    }

    this.entries_.push(video);

    listen(element, VideoEvents.PAUSE, this.boundSelectBestCentered_);
    listen(element, VideoEvents.PLAYING, this.boundSelectBestCentered_);
    listen(element, VideoEvents.ENDED, this.boundSelectBestCentered_);

    video
      .signals()
      .whenSignal(VideoServiceSignals.USER_INTERACTED)
      .then(this.boundSelectBestCentered_);

    // Set always
    this.selectBestCenteredInPortrait_();
  }

  /** @private */
  installFullscreenListener_() {
    const root = this.ampdoc_.getRootNode();
    const exitHandler = () => this.onFullscreenExit_();
    this.unlisteners_.push(
      listen(root, 'webkitfullscreenchange', exitHandler),
      listen(root, 'mozfullscreenchange', exitHandler),
      listen(root, 'fullscreenchange', exitHandler),
      listen(root, 'MSFullscreenChange', exitHandler)
    );
  }

  /**
   * @return {boolean}
   * @visibleForTesting
   */
  isInLandscape() {
    return isLandscape(this.ampdoc_.win);
  }

  /**
   * @param {!AmpElement} video
   * @return {boolean}
   * @private
   */
  canFullscreen_(video) {
    // Safari and iOS can only fullscreen <video> elements directly. In cases
    // where the player component is implemented via an <iframe>, we need to
    // rely on a postMessage API to fullscreen. Such an API is not necessarily
    // provided by every player.
    const internalElement = getInternalVideoElementFor(video);
    if (internalElement.tagName.toLowerCase() == 'video') {
      return true;
    }
    const platform = Services.platformFor(this.ampdoc_.win);
    if (!(platform.isIos() || platform.isSafari())) {
      return true;
    }
    return supportsFullscreenViaApi(video);
  }

  /** @private */
  onFullscreenExit_() {
    this.currentlyInFullscreen_ = null;
  }

  /** @private */
  installOrientationObserver_() {
    // TODO(alanorozco) Update based on support
    const {win} = this.ampdoc_;
    const {screen} = win;
    // Chrome considers 'orientationchange' to be an untrusted event, but
    // 'change' on screen.orientation is considered a user interaction.
    // We still need to listen to 'orientationchange' on Chrome in order to
    // exit fullscreen since 'change' does not fire in this case.
    if (screen && 'orientation' in screen) {
      const orient = /** @type {!ScreenOrientation} */ (screen.orientation);
      this.unlisteners_.push(
        listen(orient, 'change', () => this.onRotation_())
      );
    }
    // iOS Safari does not have screen.orientation but classifies
    // 'orientationchange' as a user interaction.
    this.unlisteners_.push(
      listen(win, 'orientationchange', () => this.onRotation_())
    );
  }

  /** @private */
  onRotation_() {
    if (this.isInLandscape()) {
      if (this.currentlyCentered_ != null) {
        this.enter_(this.currentlyCentered_);
      }
      return;
    }
    if (this.currentlyInFullscreen_) {
      this.exit_(this.currentlyInFullscreen_);
    }
  }

  /**
   * @param {!../video-interface.VideoOrBaseElementDef} video
   * @private
   */
  enter_(video) {
    const platform = Services.platformFor(this.ampdoc_.win);

    this.currentlyInFullscreen_ = video;

    if (platform.isAndroid() && platform.isChrome()) {
      // Chrome on Android somehow knows what we're doing and executes a nice
      // transition by default. Delegating to browser.
      video.fullscreenEnter();
      return;
    }

    this.scrollIntoIfNotVisible_(video).then(() => video.fullscreenEnter());
  }

  /**
   * @param {!../video-interface.VideoOrBaseElementDef} video
   * @private
   */
  exit_(video) {
    this.currentlyInFullscreen_ = null;

    this.scrollIntoIfNotVisible_(video, 'center').then(() =>
      video.fullscreenExit()
    );
  }

  /**
   * Scrolls to a video if it's not in view.
   * @param {!../video-interface.VideoOrBaseElementDef} video
   * @param {?string=} optPos
   * @return {*} TODO(#23582): Specify return type
   * @private
   */
  scrollIntoIfNotVisible_(video, optPos = null) {
    const {element} = video;
    const viewport = this.getViewport_();

    return this.onceOrientationChanges_().then(() => {
      const {boundingClientRect} = element.getIntersectionChangeEntry();
      const {top, bottom} = boundingClientRect;
      const vh = viewport.getSize().height;
      const fullyVisible = top >= 0 && bottom <= vh;
      if (fullyVisible) {
        return Promise.resolve();
      }
      const pos = optPos
        ? dev().assertString(optPos)
        : bottom > vh
        ? 'bottom'
        : 'top';
      return viewport.animateScrollIntoView(element, pos);
    });
  }

  /**
   * @private
   * @return {*} TODO(#23582): Specify return type
   */
  getViewport_() {
    return Services.viewportForDoc(this.ampdoc_);
  }

  /**
   * @private
   * @return {!Promise}
   */
  onceOrientationChanges_() {
    const magicNumber = 330;
    return Services.timerFor(this.ampdoc_.win).promise(magicNumber);
  }

  /**
   * @private
   * @return {*} TODO(#23582): Specify return type
   */
  selectBestCenteredInPortrait_() {
    if (this.isInLandscape()) {
      return this.currentlyCentered_;
    }

    this.currentlyCentered_ = null;

    const selected = this.entries_
      .filter(this.boundIncludeOnlyPlaying_)
      .sort(this.boundCompareEntries_)[0];

    if (selected) {
      const {intersectionRatio} = selected.element.getIntersectionChangeEntry();
      if (intersectionRatio >= MIN_VISIBILITY_RATIO_FOR_AUTOPLAY) {
        this.currentlyCentered_ = selected;
      }
    }

    return this.currentlyCentered_;
  }

  /**
   * Compares two videos in order to sort them by "best centered".
   * @param {!../video-interface.VideoOrBaseElementDef} a
   * @param {!../video-interface.VideoOrBaseElementDef} b
   * @return {number}
   */
  compareEntries_(a, b) {
    const {
      intersectionRatio: ratioA,
      boundingClientRect: rectA,
    } = a.element.getIntersectionChangeEntry();
    const {
      intersectionRatio: ratioB,
      boundingClientRect: rectB,
    } = b.element.getIntersectionChangeEntry();

    // Prioritize by how visible they are, with a tolerance of 10%
    const ratioTolerance = 0.1;
    const ratioDelta = ratioA - ratioB;
    if (Math.abs(ratioDelta) > ratioTolerance) {
      return ratioDelta;
    }

    // Prioritize by distance from center.
    const viewport = Services.viewportForDoc(this.ampdoc_);
    const centerA = centerDist(viewport, rectA);
    const centerB = centerDist(viewport, rectB);
    if (centerA < centerB || centerA > centerB) {
      return centerA - centerB;
    }

    // Everything else failing, choose the highest element.
    return rectA.top - rectB.top;
  }

  /**
   * @param {!../video-interface.VideoOrBaseElementDef} video
   * @return {!../video-interface.PlayingStateDef}
   * @private
   */
  getPlayingState_(video) {
    return this.manager_.getPlayingState(
      /** @type {!../video-interface.VideoInterface} */ (video)
    );
  }
}

/**
 * @param {!./viewport/viewport-interface.ViewportInterface} viewport
 * @param {{top: number, height: number}} rect
 * @return {number}
 */
function centerDist(viewport, rect) {
  const centerY = rect.top + rect.height / 2;
  const centerViewport = viewport.getSize().height / 2;
  return Math.abs(centerY - centerViewport);
}

/**
 * @param {!Window} win
 * @return {boolean}
 */
function isLandscape(win) {
  if (win.screen && 'orientation' in win.screen) {
    return startsWith(win.screen.orientation.type, 'landscape');
  }
  return Math.abs(win.orientation) == 90;
}

/** @visibleForTesting */
export const PERCENTAGE_INTERVAL = 5;

/** @visibleForTesting */
export const PERCENTAGE_FREQUENCY_WHEN_PAUSED_MS = 500;

/** @private */
const PERCENTAGE_FREQUENCY_MIN_MS = 250;

/** @private */
const PERCENTAGE_FREQUENCY_MAX_MS = 4000;

/**
 * Calculates the "ideal" analytics check frequency from playback start, e.g.
 * the amount of ms after each PERCENTAGE_INTERVAL.
 * @param {number} durationSeconds
 * @return {number}
 */
function calculateIdealPercentageFrequencyMs(durationSeconds) {
  return durationSeconds * 10 * PERCENTAGE_INTERVAL;
}

/**
 * Calculates the "actual" analytics check frequency by calculating the ideal
 * frequency and clamping it between MIN and MAX.
 * @param {number} durationSeconds
 * @return {number}
 */
function calculateActualPercentageFrequencyMs(durationSeconds) {
  return clamp(
    calculateIdealPercentageFrequencyMs(durationSeconds),
    PERCENTAGE_FREQUENCY_MIN_MS,
    PERCENTAGE_FREQUENCY_MAX_MS
  );
}

/**
 * Handle cases such as livestreams or videos with no duration information is
 * available, where 1 second is the default duration for some video players.
 * @param {?number=} duration
 * @return {boolean}
 */
const isDurationFiniteNonZero = (duration) =>
  !!duration && !isNaN(duration) && duration > 1;

/** @visibleForTesting */
export class AnalyticsPercentageTracker {
  /**
   * @param {!Window} win
   * @param {!VideoEntry} entry
   */
  constructor(win, entry) {
    // This is destructured in `calculate_()`, but the linter thinks it's unused
    /** @private @const {!./timer-impl.Timer} */
    this.timer_ = Services.timerFor(win); // eslint-disable-line

    /** @private @const {!VideoEntry} */
    this.entry_ = entry;

    /** @private {?Array<!UnlistenDef>} */
    this.unlisteners_ = null;

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

    /**
     * Counter for each trigger `start`. This is to prevent duplicate events if
     * two consecutive triggers take place, or to prevent events firing once
     * the tracker is stopped.
     * @private {number}
     */
    this.triggerId_ = 0;
  }

  /** @public */
  start() {
    const {element} = this.entry_.video;

    this.stop();

    this.unlisteners_ = this.unlisteners_ || [];

    // If the video has already emitted LOADEDMETADATA, the event below
    // will never fire, so we check if it's already available here.
    if (this.hasDuration_()) {
      this.calculate_(this.triggerId_);
    } else {
      this.unlisteners_.push(
        listenOnce(element, VideoEvents.LOADEDMETADATA, () => {
          if (this.hasDuration_()) {
            this.calculate_(this.triggerId_);
          }
        })
      );
    }

    this.unlisteners_.push(
      listen(element, VideoEvents.ENDED, () => {
        if (this.hasDuration_()) {
          this.maybeTrigger_(/* normalizedPercentage */ 100);
        }
      })
    );
  }

  /** @public */
  stop() {
    if (!this.unlisteners_) {
      return;
    }
    while (this.unlisteners_.length > 0) {
      this.unlisteners_.pop()();
    }
    this.triggerId_++;
  }

  /**
   * @return {boolean}
   * @private
   */
  hasDuration_() {
    const {video} = this.entry_;
    const duration = video.getDuration();

    if (!isDurationFiniteNonZero(duration)) {
      return false;
    }

    if (
      calculateIdealPercentageFrequencyMs(duration) <
      PERCENTAGE_FREQUENCY_MIN_MS
    ) {
      const bestResultLength = Math.ceil(
        (PERCENTAGE_FREQUENCY_MIN_MS * (100 / PERCENTAGE_INTERVAL)) / 1000
      );

      this.warnForTesting_(
        'This video is too short for `video-percentage-played`. ' +
          'Reports may be innacurate. For best results, use videos over',
        bestResultLength,
        'seconds long.',
        video.element
      );
    }

    return true;
  }

  /**
   * @param  {...*} args
   * @private
   */
  warnForTesting_(...args) {
    user().warn.apply(user(), [TAG].concat(args));
  }

  /**
   * @param {number=} triggerId
   * @private
   */
  calculate_(triggerId) {
    if (triggerId != this.triggerId_) {
      return;
    }

    const {entry_: entry, timer_: timer} = this;
    const {video} = entry;

    const calculateAgain = () => this.calculate_(triggerId);

    if (entry.getPlayingState() == PlayingStates.PAUSED) {
      timer.delay(calculateAgain, PERCENTAGE_FREQUENCY_WHEN_PAUSED_MS);
      return;
    }

    const duration = video.getDuration();
    // TODO(#25954): Further investigate root cause and remove this protection
    // if appropriate.
    if (!isDurationFiniteNonZero(duration)) {
      timer.delay(calculateAgain, PERCENTAGE_FREQUENCY_WHEN_PAUSED_MS);
      return;
    }

    const frequencyMs = calculateActualPercentageFrequencyMs(duration);

    const percentage = (video.getCurrentTime() / duration) * 100;
    const normalizedPercentage =
      Math.floor(percentage / PERCENTAGE_INTERVAL) * PERCENTAGE_INTERVAL;

    devAssert(isFiniteNumber(normalizedPercentage));

    this.maybeTrigger_(normalizedPercentage);

    timer.delay(calculateAgain, frequencyMs);
  }

  /**
   * @param {number} normalizedPercentage
   * @private
   */
  maybeTrigger_(normalizedPercentage) {
    if (normalizedPercentage <= 0) {
      return;
    }

    if (this.last_ == normalizedPercentage) {
      return;
    }

    this.last_ = normalizedPercentage;

    this.analyticsEventForTesting_(normalizedPercentage);
  }

  /**
   * @param {number} normalizedPercentage
   * @private
   */
  analyticsEventForTesting_(normalizedPercentage) {
    analyticsEvent(this.entry_, VideoAnalyticsEvents.PERCENTAGE_PLAYED, {
      'normalizedPercentage': normalizedPercentage.toString(),
    });
  }
}

/**
 * @param {!VideoEntry} entry
 * @param {!VideoAnalyticsEvents} eventType
 * @param {!Object<string, string>=} opt_vars A map of vars and their values.
 * @private
 */
function analyticsEvent(entry, eventType, opt_vars) {
  const {video} = entry;

  entry.getAnalyticsDetails().then((details) => {
    if (opt_vars) {
      Object.assign(details, opt_vars);
    }
    video.element.dispatchCustomEvent(eventType, details);
  });
}

/** @param {!Node|!./ampdoc-impl.AmpDoc} nodeOrDoc */
export function installVideoManagerForDoc(nodeOrDoc) {
  registerServiceBuilderForDoc(nodeOrDoc, 'video-manager', VideoManager);
}
