/**
 * Copyright 2015 CANAL+ Group
 *
 * 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 type {
  IMediaSource,
  ISourceBufferList,
} from "../../compat/browser_compatibility_types";
import { onSourceOpen, onSourceEnded, onSourceClose } from "../../compat/event_listeners";
import hasIssuesWithHighMediaSourceDuration from "../../compat/has_issues_with_high_media_source_duration";
import log from "../../log";
import type { IReadOnlySharedReference } from "../../utils/reference";
import SharedReference from "../../utils/reference";
import type { CancellationSignal } from "../../utils/task_canceller";
import TaskCanceller from "../../utils/task_canceller";

/** Number of seconds in a regular year. */
const YEAR_IN_SECONDS = 365 * 24 * 3600;

/**
 * Keep the MediaSource's `duration` attribute up-to-date with the duration of
 * the content played on it.
 * @class MediaSourceDurationUpdater
 */
export default class MediaSourceDurationUpdater {
  /**
   * `MediaSource` on which we're going to update the `duration` attribute.
   */
  private _mediaSource: IMediaSource;

  /**
   * Abort the current duration-setting logic.
   * `null` if no such logic is pending.
   */
  private _currentMediaSourceDurationUpdateCanceller: TaskCanceller | null;

  /**
   * Create a new `MediaSourceDurationUpdater`,
   * @param {MediaSource} mediaSource - The MediaSource on which the content is
   * played.
   */
  constructor(mediaSource: IMediaSource) {
    this._mediaSource = mediaSource;
    this._currentMediaSourceDurationUpdateCanceller = null;
  }

  /**
   * Indicate to the `MediaSourceDurationUpdater` the currently known duration
   * of the content.
   *
   * The `MediaSourceDurationUpdater` will then use that value to determine
   * which `duration` attribute should be set on the `MediaSource` associated
   *
   * @param {number} newDuration
   * @param {boolean} isRealEndKnown - If set to `false`, the current content is
   * a dynamic content (it might evolve in the future) and the `newDuration`
   * communicated might be greater still. In effect the
   * `MediaSourceDurationUpdater` will actually set a much higher value to the
   * `MediaSource`'s duration to prevent being annoyed by the HTML-related
   * side-effects of having a too low duration (such as the impossibility to
   * seek over that value).
   */
  public updateDuration(newDuration: number, isRealEndKnown: boolean): void {
    if (this._currentMediaSourceDurationUpdateCanceller !== null) {
      this._currentMediaSourceDurationUpdateCanceller.cancel("manual duration update");
    }
    this._currentMediaSourceDurationUpdateCanceller = new TaskCanceller(
      "MediaSource Duration Update",
    );

    const mediaSource = this._mediaSource;
    const currentSignal = this._currentMediaSourceDurationUpdateCanceller.signal;
    const isMediaSourceOpened = createMediaSourceOpenReference(
      mediaSource,
      currentSignal,
    );

    /** TaskCanceller triggered each time the MediaSource switches to and from "open". */
    let msOpenStatusCanceller = new TaskCanceller(
      undefined /* we do not care for logs here */,
    );
    msOpenStatusCanceller.linkToSignal(currentSignal);
    isMediaSourceOpened.onUpdate(onMediaSourceOpenedStatusChanged, {
      emitCurrentValue: true,
      clearSignal: currentSignal,
    });

    function onMediaSourceOpenedStatusChanged() {
      msOpenStatusCanceller.cancel("MediaSource open status changed");
      if (!isMediaSourceOpened.getValue()) {
        return;
      }
      msOpenStatusCanceller = new TaskCanceller(
        undefined /* we do not care for logs here */,
      );
      msOpenStatusCanceller.linkToSignal(currentSignal);
      const areSourceBuffersUpdating = createSourceBuffersUpdatingReference(
        mediaSource.sourceBuffers,
        msOpenStatusCanceller.signal,
      );
      /** TaskCanceller triggered each time SourceBuffers' updating status changes */
      let sourceBuffersUpdatingCanceller = new TaskCanceller(undefined);
      sourceBuffersUpdatingCanceller.linkToSignal(msOpenStatusCanceller.signal);

      return areSourceBuffersUpdating.onUpdate(
        (areUpdating) => {
          sourceBuffersUpdatingCanceller.cancel("SourceBuffer status update");
          sourceBuffersUpdatingCanceller = new TaskCanceller(undefined);
          sourceBuffersUpdatingCanceller.linkToSignal(msOpenStatusCanceller.signal);
          if (areUpdating) {
            return;
          }

          recursivelyForceDurationUpdate(
            mediaSource,
            newDuration,
            isRealEndKnown,
            sourceBuffersUpdatingCanceller.signal,
          );
        },
        { clearSignal: msOpenStatusCanceller.signal, emitCurrentValue: true },
      );
    }
  }

  /**
   * Abort the last duration-setting operation and free its resources.
   * @param {string | undefined} reason - Human-inspectable reason behind the
   * stop. Used for debugging matters, especially for debug log
   * inspection.
   */
  public stopUpdating(reason: string | undefined) {
    if (this._currentMediaSourceDurationUpdateCanceller !== null) {
      this._currentMediaSourceDurationUpdateCanceller.cancel(reason ?? "stop MSDU");
      this._currentMediaSourceDurationUpdateCanceller = null;
    }
  }
}

/**
 * Checks that duration can be updated on the MediaSource, and then
 * sets it.
 *
 * Returns either:
 *   - the new duration it has been updated to if it has
 *   - `null` if it hasn'nt been updated
 *
 * @param {MediaSource} mediaSource
 * @param {number} duration
 * @param {boolean} isRealEndKnown
 * @returns {string}
 */
function setMediaSourceDuration(
  mediaSource: IMediaSource,
  duration: number,
  isRealEndKnown: boolean,
): MediaSourceDurationUpdateStatus {
  let newDuration = duration;

  if (!isRealEndKnown) {
    newDuration = hasIssuesWithHighMediaSourceDuration()
      ? Infinity
      : getMaximumLiveSeekablePosition(duration);
  }

  let maxBufferedEnd: number = 0;
  for (let i = 0; i < mediaSource.sourceBuffers.length; i++) {
    const sourceBuffer = mediaSource.sourceBuffers[i];
    const sbBufferedLen = sourceBuffer.buffered.length;
    if (sbBufferedLen > 0) {
      maxBufferedEnd = Math.max(
        maxBufferedEnd,
        sourceBuffer.buffered.end(sbBufferedLen - 1),
      );
    }
  }

  if (newDuration === mediaSource.duration) {
    return MediaSourceDurationUpdateStatus.Success;
  } else if (maxBufferedEnd > newDuration) {
    // We already buffered further than the duration we want to set.
    // Keep the duration that was set at that time as a security.
    if (maxBufferedEnd < mediaSource.duration) {
      try {
        log.info("mse", "Updating duration to what is currently buffered", {
          maxBufferedEnd,
        });
        mediaSource.duration = maxBufferedEnd;
      } catch (err) {
        log.warn(
          "mse",
          "Can't update duration on the MediaSource.",
          err instanceof Error ? err : "",
        );
        return MediaSourceDurationUpdateStatus.Failed;
      }
    }
    return MediaSourceDurationUpdateStatus.Partial;
  } else {
    const oldDuration = mediaSource.duration;
    try {
      log.info("mse", "Updating duration", { newDuration });
      mediaSource.duration = newDuration;
      if (mediaSource.readyState === "open" && !isFinite(newDuration)) {
        const maxSeekable = getMaximumLiveSeekablePosition(duration);
        log.info("mse", "calling `mediaSource.setLiveSeekableRange`", { maxSeekable });
        mediaSource.setLiveSeekableRange(0, maxSeekable);
      }
    } catch (err) {
      log.warn(
        "mse",
        "Can't update duration on the MediaSource.",
        err instanceof Error ? err : "",
      );
      return MediaSourceDurationUpdateStatus.Failed;
    }
    const deltaToExpected = Math.abs(mediaSource.duration - newDuration);
    if (deltaToExpected >= 0.1) {
      const deltaToBefore = Math.abs(mediaSource.duration - oldDuration);
      return deltaToExpected < deltaToBefore
        ? MediaSourceDurationUpdateStatus.Partial
        : MediaSourceDurationUpdateStatus.Failed;
    }
    return MediaSourceDurationUpdateStatus.Success;
  }
}

/**
 * String describing the result of the process of updating a MediaSource's
 * duration.
 */
const enum MediaSourceDurationUpdateStatus {
  /** The MediaSource's duration has been updated to the asked duration. */
  Success = "success",
  /**
   * The MediaSource's duration has been updated and is now closer to the asked
   * duration but is not yet the actually asked duration.
   */
  Partial = "partial",
  /**
   * The MediaSource's duration could not have been updated due to an issue or
   * has been updated but to a value actually further from the asked duration
   * from what it was before.
   */
  Failed = "failed",
}

/**
 * Returns a `SharedReference` wrapping a boolean that tells if all the
 * SourceBuffers ended all pending updates.
 * @param {SourceBufferList} sourceBuffers
 * @param {Object} cancelSignal
 * @returns {Object}
 */
function createSourceBuffersUpdatingReference(
  sourceBuffers: ISourceBufferList,
  cancelSignal: CancellationSignal,
): IReadOnlySharedReference<boolean> {
  if (sourceBuffers.length === 0) {
    const notOpenedRef = new SharedReference(false);
    notOpenedRef.finish();
    return notOpenedRef;
  }

  const areUpdatingRef = new SharedReference(false, cancelSignal);
  reCheck();

  for (let i = 0; i < sourceBuffers.length; i++) {
    const sourceBuffer = sourceBuffers[i];
    sourceBuffer.addEventListener("updatestart", reCheck);
    sourceBuffer.addEventListener("update", reCheck);
    cancelSignal.register(() => {
      sourceBuffer.removeEventListener("updatestart", reCheck);
      sourceBuffer.removeEventListener("update", reCheck);
    });
  }

  return areUpdatingRef;

  function reCheck() {
    for (let i = 0; i < sourceBuffers.length; i++) {
      const sourceBuffer = sourceBuffers[i];
      if (sourceBuffer.updating) {
        areUpdatingRef.setValueIfChanged(true);
        return;
      }
    }
    areUpdatingRef.setValueIfChanged(false);
  }
}

/**
 * Returns a `SharedReference` wrapping a boolean that tells if the media
 * source is opened or not.
 * @param {MediaSource} mediaSource
 * @param {Object} cancelSignal
 * @returns {Object}
 */
function createMediaSourceOpenReference(
  mediaSource: IMediaSource,
  cancelSignal: CancellationSignal,
): IReadOnlySharedReference<boolean> {
  const isMediaSourceOpen = new SharedReference(
    mediaSource.readyState === "open",
    cancelSignal,
  );
  onSourceOpen(
    mediaSource,
    () => {
      log.debug("mse", "Reacting to MediaSource open in duration updater");
      isMediaSourceOpen.setValueIfChanged(true);
    },
    cancelSignal,
  );
  onSourceEnded(
    mediaSource,
    () => {
      log.debug("mse", "Reacting to MediaSource ended in duration updater");
      isMediaSourceOpen.setValueIfChanged(false);
    },
    cancelSignal,
  );
  onSourceClose(
    mediaSource,
    () => {
      log.debug("mse", "Reacting to MediaSource close in duration updater");
      isMediaSourceOpen.setValueIfChanged(false);
    },
    cancelSignal,
  );
  return isMediaSourceOpen;
}

/**
 * Immediately tries to set the MediaSource's duration to the most appropriate
 * one.
 *
 * If it fails, wait 2 seconds and retries.
 *
 * @param {MediaSource} mediaSource
 * @param {number} duration
 * @param {boolean} isRealEndKnown
 * @param {Object} cancelSignal
 */
function recursivelyForceDurationUpdate(
  mediaSource: IMediaSource,
  duration: number,
  isRealEndKnown: boolean,
  cancelSignal: CancellationSignal,
): void {
  const res = setMediaSourceDuration(mediaSource, duration, isRealEndKnown);
  if (res === MediaSourceDurationUpdateStatus.Success) {
    return;
  }
  const timeoutId = setTimeout(() => {
    unregisterClear();
    recursivelyForceDurationUpdate(mediaSource, duration, isRealEndKnown, cancelSignal);
  }, 2000);
  const unregisterClear = cancelSignal.register(() => {
    clearTimeout(timeoutId);
  });
}

function getMaximumLiveSeekablePosition(contentLastPosition: number): number {
  // Some targets poorly support setting a very high number for seekable
  // ranges.
  // Yet, in contents whose end is not yet known (e.g. live contents), we
  // would prefer setting a value as high as possible to still be able to
  // seek anywhere we want to (even ahead of the Manifest if we want to).
  // As such, we put it at a safe default value of 2^32 excepted when the
  // maximum position is already relatively close to that value, where we
  // authorize exceptionally going over it.
  return Math.max(Math.pow(2, 32), contentLastPosition + YEAR_IN_SECONDS);
}
