import {Observable} from '#core/data-structures/observable';
import {Deferred} from '#core/data-structures/promise';

import {devAssert} from '#utils/log';

/**
 * This class implements visibility calculations based on the
 * visibility ratio. It's used for documents, embeds and individual element.
 * @implements {../../../src/service.Disposable}
 */
export class VisibilityModel {
  /**
   * @param {!JsonObject} spec
   * @param {function():number} calcVisibility
   */
  constructor(spec, calcVisibility) {
    /** @const @private */
    this.calcVisibility_ = calcVisibility;

    /**
     * Spec parameters.
     * @private {!JsonObject}
     */
    this.spec_ = {
      'visiblePercentageMin': Number(spec['visiblePercentageMin']) / 100 || 0,
      'visiblePercentageMax': Number(spec['visiblePercentageMax']) / 100 || 1,
      'totalTimeMin': Number(spec['totalTimeMin']) || 0,
      'totalTimeMax': Number(spec['totalTimeMax']) || Infinity,
      'continuousTimeMin': Number(spec['continuousTimeMin']) || 0,
      'continuousTimeMax': Number(spec['continuousTimeMax']) || Infinity,
    };
    // Above, if visiblePercentageMax was not specified, assume 100%.
    // Here, do allow 0% to be the value if that is what was specified.
    if (String(spec['visiblePercentageMax']).trim() === '0') {
      this.spec_['visiblePercentageMax'] = 0;
    }

    /**
     * Accumulate visibility counters but do not fire the trigger until the
     * ready promise resolves.
     * @private @const {boolean}
     */
    this.ignoreVisibilityForReport_ = spec['reportWhen'] !== undefined;

    /** @private {boolean} */
    this.repeat_ = spec['repeat'] === true;

    /** @private {?Observable} */
    this.onTriggerObservable_ = new Observable();

    const deferred = new Deferred();

    /** @private */
    this.eventPromise_ = deferred.promise;

    /** @private {?function()} */
    this.eventResolver_ = deferred.resolve;

    this.eventPromise_.then(() => {
      this.onTriggerObservable_.fire();
    });

    /** @private {!Array<!UnlistenDef>} */
    this.unsubscribe_ = [];

    /** @const @private {time} */
    this.createdTime_ = Date.now();

    // TODO(warrengm): Consider refactoring so that the ready defaults are
    // false.

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

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

    /** @private {?function():!Promise} */
    this.createReportReadyPromise_ = null;

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

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

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

    /** @private {time} duration in milliseconds */
    this.continuousTime_ = 0;

    /** @private {time} duration in milliseconds */
    this.maxContinuousVisibleTime_ = 0;

    /** @private {time} duration in milliseconds */
    this.totalVisibleTime_ = 0;

    /** @private {time} milliseconds since epoch */
    this.firstSeenTime_ = 0;

    /** @private {time} milliseconds since epoch */
    this.lastSeenTime_ = 0;

    /** @private {time} milliseconds since epoch */
    this.firstVisibleTime_ = 0;

    /** @private {time} milliseconds since epoch */
    this.lastVisibleTime_ = 0;

    /** @private {time} percent value in a [0, 1] range */
    this.loadTimeVisibility_ = 0;

    /** @private {number} percent value in a [0, 1] range */
    this.minVisiblePercentage_ = 0;

    /** @private {number} percent value in a [0, 1] range */
    this.maxVisiblePercentage_ = 0;

    /** @private {time} milliseconds since epoch */
    this.lastVisibleUpdateTime_ = 0;

    /** @private {number} Scroll position at ini-load time */
    this.initialScrollDepth_ = 0;

    /**
     * @private {boolean} Whether scroll position at ini-load time has
     * been set
     */
    this.initialScrollDepthAlreadySet_ = false;

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

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

  /**
   * Refresh counter on visible reset.
   * TODO: Right now all state value are scoped state values that gets reset.
   * We may need to add support to global state values,
   * that never reset like globalTotalVisibleTime.
   * Note: loadTimeVisibility is an exception.
   * @private
   */
  reset_() {
    devAssert(
      !this.eventResolver_,
      'Attempt to refresh visible event before previous one resolve'
    );
    const deferred = new Deferred();
    this.eventPromise_ = deferred.promise;
    this.eventResolver_ = deferred.resolve;

    this.eventPromise_.then(() => {
      this.onTriggerObservable_.fire();
    });
    this.scheduleRepeatId_ = null;
    this.everMatchedVisibility_ = false;
    this.matchesVisibility_ = false;
    this.continuousTime_ = 0;
    this.maxContinuousVisibleTime_ = 0;
    this.totalVisibleTime_ = 0;
    this.firstVisibleTime_ = 0;
    this.firstSeenTime_ = 0;
    this.lastSeenTime_ = 0;
    this.lastVisibleTime_ = 0;
    this.minVisiblePercentage_ = 0;
    this.maxVisiblePercentage_ = 0;
    this.lastVisibleUpdateTime_ = 0;
    this.waitToReset_ = false;
  }

  /**
   * Function that visibilityManager can used to dispose model or reset model
   */
  maybeDispose() {
    if (!this.repeat_) {
      this.dispose();
    }
  }

  /** @override */
  dispose() {
    if (this.scheduledUpdateTimeoutId_) {
      clearTimeout(this.scheduledUpdateTimeoutId_);
      this.scheduledUpdateTimeoutId_ = null;
    }
    if (this.scheduleRepeatId_) {
      clearTimeout(this.scheduleRepeatId_);
      this.scheduleRepeatId_ = null;
    }
    this.unsubscribe_.forEach((unsubscribe) => {
      unsubscribe();
    });
    this.unsubscribe_.length = 0;
    this.eventResolver_ = null;
    if (this.onTriggerObservable_) {
      this.onTriggerObservable_.removeAll();
      this.onTriggerObservable_ = null;
    }
  }

  /**
   * Adds the unsubscribe handler that will be called when this visibility
   * model is destroyed.
   * @param {!UnlistenDef} handler
   */
  unsubscribe(handler) {
    this.unsubscribe_.push(handler);
  }

  /**
   * Adds the event handler that will be called when all visibility conditions
   * have been met.
   * @param {function()} handler
   */
  onTriggerEvent(handler) {
    if (this.onTriggerObservable_) {
      this.onTriggerObservable_.add(handler);
    }
    if (this.eventPromise_ && !this.eventResolver_) {
      // If eventPromise has already resolved, need to call handler manually.
      handler();
    }
  }

  /**
   * Sets whether this object is ready. Ready means that visibility is
   * ready to be calculated, e.g. because an element has been
   * sufficiently rendered.
   * @param {boolean} ready
   */
  setReady(ready) {
    this.ready_ = ready;
    this.update();
  }

  /**
   * Sets that the model needs to wait on extra report ready promise
   * after all visibility conditions have been met to call report handler
   * @param {function():!Promise} callback
   */
  setReportReady(callback) {
    this.reportReady_ = false;
    this.createReportReadyPromise_ = callback;
  }

  /**
   * @return {number}
   * @private
   */
  getVisibility_() {
    return this.ready_ ? this.calcVisibility_() : 0;
  }

  /**
   * Runs the calculation cycle.
   */
  update() {
    this.update_(this.getVisibility_());
  }

  /**
   * Returns the calculated state of visibility.
   * @param {time} startTime
   * @return {!JsonObject}
   */
  getState(startTime) {
    return {
      // Observed times, relative to the `startTime`.
      'firstSeenTime': timeBase(this.firstSeenTime_, startTime),
      'lastSeenTime': timeBase(this.lastSeenTime_, startTime),
      'lastVisibleTime': timeBase(this.lastVisibleTime_, startTime),
      'firstVisibleTime': timeBase(this.firstVisibleTime_, startTime),

      // Durations.
      'maxContinuousVisibleTime': this.maxContinuousVisibleTime_,
      'totalVisibleTime': this.totalVisibleTime_,

      // Visibility percents.
      'loadTimeVisibility': this.loadTimeVisibility_ * 100 || 0,
      'minVisiblePercentage': this.minVisiblePercentage_ * 100,
      'maxVisiblePercentage': this.maxVisiblePercentage_ * 100,
    };
  }

  /**
   * @param {number} visibility
   * @private
   */
  update_(visibility) {
    // Update state and check if all conditions are satisfied
    if (this.waitToReset_) {
      if (!this.isVisibilityMatch_(visibility)) {
        // We were waiting for a condition to become unmet, and now it has
        this.reset_();
      }
      return;
    }
    if (!this.eventResolver_) {
      return;
    }

    // When ignoreVisibilityForReport_ is true, we update counters but fire the
    // event when the report ready promise is resolved.
    const conditionsMet =
      this.updateCounters_(visibility) || this.ignoreVisibilityForReport_;
    if (conditionsMet) {
      if (this.scheduledUpdateTimeoutId_) {
        clearTimeout(this.scheduledUpdateTimeoutId_);
        this.scheduledUpdateTimeoutId_ = null;
      }
      if (this.reportReady_) {
        // TODO(jonkeller): Can we eliminate eventResolver_?
        this.eventResolver_();
        this.eventResolver_ = null;
        if (this.repeat_) {
          this.waitToReset_ = true;
          this.continuousTime_ = 0;
        }
      } else if (this.createReportReadyPromise_) {
        // Report when report ready promise resolve
        const reportReadyPromise = this.createReportReadyPromise_();
        this.createReportReadyPromise_ = null;
        reportReadyPromise.then(() => {
          this.reportReady_ = true;
          // Need to update one more time in case time exceeds
          // maxContinuousVisibleTime.
          this.update();
        });
      }
    } else if (this.matchesVisibility_ && !this.scheduledUpdateTimeoutId_) {
      // There is unmet duration condition, schedule a check
      const timeToWait = this.computeTimeToWait_();
      if (timeToWait > 0) {
        this.scheduledUpdateTimeoutId_ = setTimeout(() => {
          this.scheduledUpdateTimeoutId_ = null;
          this.update();
        }, timeToWait);
      }
    } else if (!this.matchesVisibility_ && this.scheduledUpdateTimeoutId_) {
      clearTimeout(this.scheduledUpdateTimeoutId_);
      this.scheduledUpdateTimeoutId_ = null;
    }
  }

  /**
   * Check if visibility fall into the percentage range
   * @param {number} visibility
   * @return {boolean}
   */
  isVisibilityMatch_(visibility) {
    devAssert(
      visibility >= 0 && visibility <= 1,
      'invalid visibility value: %s',
      visibility
    );
    // Special case: If visiblePercentageMin is 100%, then it doesn't make
    // sense to do the usual (min, max] since that would never be true.
    if (this.spec_['visiblePercentageMin'] == 1) {
      return visibility == 1;
    }
    // Special case: If visiblePercentageMax is 0%, then we
    // want to ping when the creative becomes not visible.
    if (this.spec_['visiblePercentageMax'] == 0) {
      return visibility == 0;
    }
    return (
      visibility > this.spec_['visiblePercentageMin'] &&
      visibility <= this.spec_['visiblePercentageMax']
    );
  }

  /**
   * @param {number} visibility
   * @return {boolean} true
   * @private
   */
  updateCounters_(visibility) {
    devAssert(
      visibility >= 0 && visibility <= 1,
      'invalid visibility value: %s',
      visibility
    );
    const now = Date.now();

    if (visibility > 0) {
      this.firstSeenTime_ = this.firstSeenTime_ || now;
      this.lastSeenTime_ = now;
      // Consider it as load time visibility if this happens within 300ms of
      // page load.
      if (!this.loadTimeVisibility_ && now - this.createdTime_ < 300) {
        this.loadTimeVisibility_ = visibility;
      }
    }

    const prevMatchesVisibility = this.matchesVisibility_;
    const timeSinceLastUpdate = this.lastVisibleUpdateTime_
      ? now - this.lastVisibleUpdateTime_
      : 0;
    this.matchesVisibility_ = this.isVisibilityMatch_(visibility);
    if (this.matchesVisibility_) {
      this.everMatchedVisibility_ = true;
      if (prevMatchesVisibility) {
        // Keep counting.
        this.totalVisibleTime_ += timeSinceLastUpdate;
        this.continuousTime_ += timeSinceLastUpdate;
        this.maxContinuousVisibleTime_ = Math.max(
          this.maxContinuousVisibleTime_,
          this.continuousTime_
        );
      } else {
        // The resource came into view: start counting.
        devAssert(!this.lastVisibleUpdateTime_);
        this.firstVisibleTime_ = this.firstVisibleTime_ || now;
      }
      this.lastVisibleUpdateTime_ = now;
      this.minVisiblePercentage_ =
        this.minVisiblePercentage_ > 0
          ? Math.min(this.minVisiblePercentage_, visibility)
          : visibility;

      this.maxVisiblePercentage_ = Math.max(
        this.maxVisiblePercentage_,
        visibility
      );
      this.lastVisibleTime_ = now;
    } else if (prevMatchesVisibility) {
      // The resource went out of view. Do final calculations and reset state.
      devAssert(this.lastVisibleUpdateTime_ > 0);

      this.maxContinuousVisibleTime_ = Math.max(
        this.maxContinuousVisibleTime_,
        this.continuousTime_ + timeSinceLastUpdate
      );

      // Reset for next visibility event.
      this.lastVisibleUpdateTime_ = 0;
      this.totalVisibleTime_ += timeSinceLastUpdate;
      this.continuousTime_ = 0; // Clear only after max is calculated above.
      this.lastVisibleTime_ = now;
    }

    return (
      this.everMatchedVisibility_ &&
      this.totalVisibleTime_ >= this.spec_['totalTimeMin'] &&
      this.totalVisibleTime_ <= this.spec_['totalTimeMax'] &&
      this.maxContinuousVisibleTime_ >= this.spec_['continuousTimeMin'] &&
      this.maxContinuousVisibleTime_ <= this.spec_['continuousTimeMax']
    );
  }

  /**
   * Set the amount that the user had scrolled down the page at the time of
   * page loading.
   * @param {number} depth
   */
  maybeSetInitialScrollDepth(depth) {
    if (!this.initialScrollDepthAlreadySet_) {
      this.initialScrollDepth_ = depth;
      this.initialScrollDepthAlreadySet_ = true;
    }
  }

  /**
   * Gets the amount that the user had scrolled down the page, at the time of
   * ini-load.
   * @return {number} depth
   */
  getInitialScrollDepth() {
    return this.initialScrollDepth_;
  }

  /**
   * Computes time, assuming the object is currently visible, that it'd take
   * it to match all timing requirements.
   * @return {time}
   * @private
   */
  computeTimeToWait_() {
    const waitForContinuousTime = Math.max(
      this.spec_['continuousTimeMin'] - this.continuousTime_,
      0
    );
    const waitForTotalTime = Math.max(
      this.spec_['totalTimeMin'] - this.totalVisibleTime_,
      0
    );
    const maxWaitTime = Math.max(waitForContinuousTime, waitForTotalTime);
    return Math.min(
      maxWaitTime,
      waitForContinuousTime || Infinity,
      waitForTotalTime || Infinity
    );
  }
}

/**
 * Calculates the specified time based on the given `baseTime`.
 * @param {time} time
 * @param {time} baseTime
 * @return {time}
 */
function timeBase(time, baseTime) {
  return time >= baseTime ? time - baseTime : 0;
}
