/**
 * 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 {
  IManifest,
  IAdaptation,
  IPeriod,
  IRepresentation,
} from "../../../../manifest";
import type { IReadOnlyPlaybackObserver } from "../../../../playback_observer";
import isNullOrUndefined from "../../../../utils/is_null_or_undefined";
import type {
  ISignalCompleteSegmentOperation,
  SegmentSink,
} from "../../../segment_sinks";
import SegmentSinksStore, { SegmentSinkOperation } from "../../../segment_sinks";
import type {
  IBufferDiscontinuity,
  IRepresentationStreamPlaybackObservation,
  IQueuedSegment,
} from "../types";
import checkForDiscontinuity from "./check_for_discontinuity";
import getNeededSegments from "./get_needed_segments";
import getSegmentPriority from "./get_segment_priority";

/** Analysis of the current buffer's status. */
export interface IBufferStatus {
  /**
   * Future discontinuity found in the SegmentSink's buffer: hole that won't
   * be filled by a segment.
   * `null` if no such discontinuity is found in the near future.
   */
  imminentDiscontinuity: IBufferDiscontinuity | null;
  /**
   * If `true`, no segment need to be loaded to be able to play until the end of
   * the Period.
   * Some segments might still be in the process of being pushed.
   */
  hasFinishedLoading: boolean;
  /**
   * Segments that have to be scheduled for download to fill the buffer at least
   * until the given buffer goal.
   * The first element of that list might already be currently downloading.
   */
  neededSegments: IQueuedSegment[];
  /**
   * If `true`, the Manifest has to be reloaded to obtain more information
   * on which segments should be loaded.
   */
  shouldRefreshManifest: boolean;
  /**
   * If 'true', the buffer memory is saturated, thus we may have issues loading
   * new segments.
   */
  isBufferFull: boolean;
}

/**
 * Checks on the current buffered data for the given type and Period
 * and returns what should be done to fill the buffer according to the buffer
 * goal, the Representation chosen, etc.
 * Also emits discontinuities if found, which are parts of the buffer that won't
 * be filled by any segment, even in the future.
 *
 * @param {Object} content
 * @param {number} initialWantedTime
 * @param {Object} playbackObserver
 * @param {number|undefined} fastSwitchThreshold
 * @param {number} bufferGoal
 * @param {number} maxBufferSize
 * @param {Object} segmentSink
 * @returns {Object}
 */
export default function getBufferStatus(
  content: {
    adaptation: IAdaptation;
    manifest: IManifest;
    period: IPeriod;
    representation: IRepresentation;
  },
  initialWantedTime: number,
  playbackObserver: IReadOnlyPlaybackObserver<IRepresentationStreamPlaybackObservation>,
  fastSwitchThreshold: number | undefined,
  bufferGoal: number,
  maxBufferSize: number,
  segmentSink: SegmentSink,
): IBufferStatus {
  const { representation } = content;
  const isPaused =
    playbackObserver.getIsPaused() ??
    playbackObserver.getReference().getValue().paused.pending ??
    playbackObserver.getReference().getValue().paused.last;
  const playbackRate =
    playbackObserver.getPlaybackRate() ??
    playbackObserver.getReference().getValue().speed;
  let askedStart = initialWantedTime;
  if (
    isPaused === undefined ||
    playbackRate === undefined ||
    isPaused ||
    playbackRate <= 0
  ) {
    askedStart -= 0.1;
  }
  const neededRange = getRangeOfNeededSegments(content, askedStart, bufferGoal);
  const shouldRefreshManifest = representation.index.shouldRefresh(
    neededRange.start,
    neededRange.end,
  );

  /**
   * Every segment awaiting an "SignalSegmentComplete" operation, which
   * indicates that a completely-loaded segment is still being pushed to the
   * SegmentSink.
   */
  const segmentsBeingPushed = segmentSink
    .getPendingOperations()
    .filter(
      (operation): operation is ISignalCompleteSegmentOperation =>
        operation.type === SegmentSinkOperation.SignalSegmentComplete,
    )
    .map((operation) => operation.value);

  /** Data on every segments buffered around `neededRange`. */
  const bufferedSegments = segmentSink.getLastKnownInventory();
  let currentPlaybackTime = playbackObserver.getCurrentTime();
  if (currentPlaybackTime === undefined) {
    // We're in a WebWorker, just consider the last known position
    currentPlaybackTime = playbackObserver.getReference().getValue().position.getWanted();
  }

  /** Callback allowing to retrieve a segment's history in the buffer. */
  const getBufferedHistory = segmentSink.getSegmentHistory.bind(segmentSink);

  /** List of segments we will need to download. */
  const { segmentsToLoad, segmentsOnHold, isBufferFull } = getNeededSegments({
    content,
    bufferedSegments,
    currentPlaybackTime,
    fastSwitchThreshold,
    getBufferedHistory,
    neededRange,
    segmentsBeingPushed,
    maxBufferSize,
  });

  const prioritizedNeededSegments: IQueuedSegment[] = segmentsToLoad.map((segment) => ({
    priority: getSegmentPriority(segment.time, askedStart),
    segment,
  }));

  /**
   * `true` if the current `RepresentationStream` has loaded all the
   * needed segments for this Representation until the end of the Period.
   */
  const hasFinishedLoading =
    representation.index.isInitialized() &&
    !representation.index.isStillAwaitingFutureSegments() &&
    neededRange.hasReachedPeriodEnd &&
    prioritizedNeededSegments.length === 0 &&
    segmentsOnHold.length === 0;

  /**
   * Start time in seconds of the next available not-yet pushed segment.
   * `null` if no segment is wanted for the current wanted range.
   */
  let nextSegmentStart: number | null = null;
  if (segmentsBeingPushed.length > 0) {
    nextSegmentStart = Math.min(...segmentsBeingPushed.map((info) => info.segment.time));
  }
  if (segmentsOnHold.length > 0) {
    nextSegmentStart =
      nextSegmentStart !== null
        ? Math.min(nextSegmentStart, segmentsOnHold[0].time)
        : segmentsOnHold[0].time;
  }
  if (prioritizedNeededSegments.length > 0) {
    nextSegmentStart =
      nextSegmentStart !== null
        ? Math.min(nextSegmentStart, prioritizedNeededSegments[0].segment.time)
        : prioritizedNeededSegments[0].segment.time;
  }

  const imminentDiscontinuity = checkForDiscontinuity(
    content,
    neededRange,
    nextSegmentStart,
    hasFinishedLoading,
    bufferedSegments,
  );
  return {
    imminentDiscontinuity,
    hasFinishedLoading,
    neededSegments: prioritizedNeededSegments,
    isBufferFull,
    shouldRefreshManifest,
  };
}

/**
 * Returns both the time range of segments that should be loaded (from a
 * starting position to an ending position) and whether the end of the Period is
 * reached by that range.
 * @param {Object} content
 * @param {number} initialWantedTime
 * @param {number} bufferGoal
 * @returns {Object}
 */
function getRangeOfNeededSegments(
  content: {
    adaptation: IAdaptation;
    manifest: IManifest;
    period: IPeriod;
    representation: IRepresentation;
  },
  initialWantedTime: number,
  bufferGoal: number,
): { start: number; end: number; hasReachedPeriodEnd: boolean } {
  let wantedStartPosition: number;
  const { manifest, period, representation } = content;
  const lastIndexPosition = representation.index.getLastAvailablePosition();
  const representationIndex = representation.index;

  // There is an exception for when the current initially wanted time is already
  // after the last position with segments AND when we're playing the absolute
  // last Period in the Manifest.
  // In that case, we want to actually request at least the last segment to
  // avoid ending the last Period - and by extension the content - with a
  // segment which isn't the last one.
  if (
    !isNullOrUndefined(lastIndexPosition) &&
    SegmentSinksStore.isNative(content.adaptation.type) &&
    initialWantedTime >= lastIndexPosition &&
    representationIndex.isInitialized() &&
    !representationIndex.isStillAwaitingFutureSegments() &&
    isPeriodTheCurrentAndLastOne(manifest, period, initialWantedTime)
  ) {
    wantedStartPosition = lastIndexPosition - 1;
  } else {
    wantedStartPosition = initialWantedTime - 0.1;
  }

  const wantedEndPosition = wantedStartPosition + bufferGoal;

  let hasReachedPeriodEnd;
  if (
    !representation.index.isInitialized() ||
    representation.index.isStillAwaitingFutureSegments() ||
    period.end === undefined
  ) {
    hasReachedPeriodEnd = false;
  } else if (lastIndexPosition === undefined) {
    // We do not know the end of this index.
    hasReachedPeriodEnd = wantedEndPosition >= period.end;
  } else if (lastIndexPosition === null) {
    // There is no available segment in the index currently.
    hasReachedPeriodEnd = true;
  } else {
    // We have a declared end. Check that our range went until the last
    // position available in the index. If that's the case and we're left
    // with no segments after filtering them, it means we already have
    // downloaded the last segments and have nothing left to do: full.
    hasReachedPeriodEnd = wantedEndPosition >= lastIndexPosition;
  }

  return {
    start: Math.max(wantedStartPosition, period.start),
    end: Math.min(wantedEndPosition, period.end ?? Infinity),
    hasReachedPeriodEnd,
  };
}

/**
 * Returns `true` if the given Period is both:
 *   - the one being played (the current position is known from `time`)
 *   - the absolute last one in the Manifest (that is, there will never be a
 *     Period after it).
 * @param {Object} manifest
 * @param {Object} period
 * @param {number} time
 * @returns {boolean}
 */
function isPeriodTheCurrentAndLastOne(
  manifest: IManifest,
  period: IPeriod,
  time: number,
): boolean {
  const nextPeriod = manifest.getPeriodAfter(period);
  return (
    period.containsTime(time, nextPeriod) &&
    manifest.isLastPeriodKnown &&
    period.id === manifest.periods[manifest.periods.length - 1]?.id
  );
}
