/**
 * 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 config from "../../../config";
import log from "../../../log";
import type { IAdaptation, ISegment, IPeriod, IRepresentation } from "../../../manifest";
import { areSameContent, getLoggableSegmentId } from "../../../manifest";
import isNullOrUndefined from "../../../utils/is_null_or_undefined";
import getMonotonicTimeStamp from "../../../utils/monotonic_timestamp";
import type { IRange } from "../../../utils/ranges";
import type { IBufferedHistoryEntry } from "./buffered_history";
import BufferedHistory from "./buffered_history";
import type { IChunkContext } from "./types";

/** Categorization of a given chunk in the `SegmentInventory`. */
export const enum ChunkStatus {
  /**
   * This chunk is only a part of a partially-pushed segment for now, meaning
   * that it is only a sub-part of a requested segment that was not yet
   * fully-loaded and pushed.
   *
   * Once and if the corresponding segment is fully-pushed, its `ChunkStatus`
   * switches to `FullyLoaded`.
   */
  PartiallyPushed = 0,
  /** This chunk corresponds to a fully-loaded segment. */
  FullyLoaded = 1,
  /**
   * This chunk's push operation failed, in this scenario there is no certitude
   * about the presence of that chunk in the buffer: it may not be present,
   * partially-present, or fully-present depending on why that push operation
   * failed, which is generally only known by the lower-level code.
   */
  Failed = 2,
}

/** Information stored on a single chunk by the SegmentInventory. */
export interface IBufferedChunk {
  /**
   * Value of the monotonically-increasing timestamp used by the RxPlayer at
   * the time the segment was succesfully pushed to the buffer.
   *
   * We add this value here as we may want to wait for some time before
   * synchronizing the buffer to ensure the browser has properly considered the
   * full segment in its `buffered` value.
   */
  insertionTs: number;
  /**
   * Complete size of the pushed chunk, in bytes.
   * Note that this does not always reflect the memory imprint of the segment in
   * memory:
   *
   *   1. It's the size of the original container file. A browser receiving that
   *      segment might then transform it under another form that may be more or
   *      less voluminous.
   *
   *   2. It's the size of the full chunk. In some scenarios only a sub-part of
   *      that chunk is actually considered (examples: when using append
   *      windows, when another chunk overlap that one etc.).
   */
  chunkSize: number | undefined;
  /**
   * Last inferred end in the media buffer this chunk ends at, in seconds.
   *
   * Depending on if contiguous chunks were around it during the first
   * synchronization for that chunk this value could be more or less precize.
   */
  bufferedEnd: number | undefined;
  /**
   * Last inferred start in the media buffer this chunk starts at, in seconds.
   *
   * Depending on if contiguous chunks were around it during the first
   * synchronization for that chunk this value could be more or less precize.
   */
  bufferedStart: number | undefined;
  /** Supposed end, in seconds, the chunk is expected to end at. */
  end: number;
  /**
   * If `true` the `end` property is an estimate the `SegmentInventory` has
   * a high confidence in.
   * In that situation, `bufferedEnd` can easily be compared to it to check if
   * that segment has been partially, or fully, garbage collected.
   *
   * If `false`, it is just a guess based on segment information.
   */
  precizeEnd: boolean;
  /**
   * If `true` the `start` property is an estimate the `SegmentInventory` has
   * a high confidence in.
   * In that situation, `bufferedStart` can easily be compared to it to check if
   * that segment has been partially, or fully, garbage collected.
   *
   * If `false`, it is just a guess based on segment information.
   */
  precizeStart: boolean;
  /** Information on what that chunk actually contains. */
  infos: IChunkContext;
  /**
   * Status of this chunk.
   * @see ChunkStatus
   */
  status: ChunkStatus;
  /**
   * If `true`, the segment as a whole is divided into multiple parts in the
   * buffer, with other segment(s) between them.
   * If `false`, it is contiguous.
   *
   * Splitted segments are a rare occurence that is more complicated to handle
   * than contiguous ones.
   */
  splitted: boolean;
  /**
   * Supposed start, in seconds, the chunk is expected to start at.
   *
   * If the current `chunk` is part of a "partially pushed" segment (see
   * `partiallyPushed`), the definition of this property is flexible in the way
   * that it can correspond either to the start of the chunk or to the start of
   * the whole segment the chunk is linked to.
   * As such, this property should not be relied on until the segment has been
   * fully-pushed.
   */
  start: number; // Supposed start the segment should start from, in seconds
}

/** information to provide when "inserting" a new chunk into the SegmentInventory. */
export interface IInsertedChunkInfos {
  /** The Adaptation that chunk is linked to */
  adaptation: IAdaptation;
  /** The Period that chunk is linked to */
  period: IPeriod;
  /** The Representation that chunk is linked to. */
  representation: IRepresentation;
  /** The Segment that chunk is linked to. */
  segment: ISegment;
  /** Estimated size of the full pushed chunk, in bytes. */
  chunkSize: number | undefined;
  /**
   * Start time, in seconds, this chunk most probably begins from after being
   * pushed.
   * In doubt, you can set it at the start of the whole segment (after
   * considering the possible offsets and append windows).
   */
  start: number;
  /**
   * End time, in seconds, this chunk most probably ends at after being
   * pushed.
   *
   * In doubt, you can set it at the end of the whole segment (after
   * considering the possible offsets and append windows).
   */
  end: number;
}

/**
 * Keep track of every chunk downloaded and currently in the linked media
 * buffer.
 *
 * The main point of this class is to know which chunks are already pushed to
 * the corresponding media buffer, at which bitrate, and which have been garbage-collected
 * since by the browser (and thus may need to be re-loaded).
 * @class SegmentInventory
 */
export default class SegmentInventory {
  /**
   * Keeps track of all the segments which should be currently in the browser's
   * memory.
   * This array contains objects, each being related to a single downloaded
   * chunk or segment which is at least partially added in the media buffer.
   */
  private _inventory: IBufferedChunk[];

  private _bufferedHistory: BufferedHistory;

  constructor() {
    const { BUFFERED_HISTORY_RETENTION_TIME, BUFFERED_HISTORY_MAXIMUM_ENTRIES } =
      config.getCurrent();
    this._inventory = [];
    this._bufferedHistory = new BufferedHistory(
      BUFFERED_HISTORY_RETENTION_TIME,
      BUFFERED_HISTORY_MAXIMUM_ENTRIES,
    );
  }

  /**
   * Reset the whole inventory.
   */
  public reset(): void {
    this._inventory.length = 0;
  }

  /**
   * Infer each segment's `bufferedStart` and `bufferedEnd` properties from the
   * ranges given.
   *
   * The ranges object given should come from the media buffer linked to that
   * SegmentInventory.
   *
   * /!\ A SegmentInventory should not be associated to multiple media buffers
   * at a time, so each `synchronizeBuffered` call should be given ranges coming
   * from the same buffer.
   * @param {Array.<Object>} ranges
   */
  public synchronizeBuffered(ranges: IRange[]): void {
    const inventory = this._inventory;
    let inventoryIndex = 0; // Current index considered.
    let thisSegment = inventory[0]; // Current segmentInfos considered
    const { MINIMUM_SEGMENT_SIZE } = config.getCurrent();
    /** Type of buffer considered, used for logs */
    const bufferType: string | undefined = thisSegment?.infos.adaptation.type;

    if (log.hasLevel("DEBUG")) {
      const prettyPrintedRanges = ranges.map((r) => `${r.start}-${r.end}`).join(",");
      log.debug(
        "SI",
        `synchronizing ${bufferType ?? "unknown"} buffered ranges:`,
        prettyPrintedRanges,
      );
    }

    const rangesLength = ranges.length;
    for (let i = 0; i < rangesLength; i++) {
      if (thisSegment === undefined) {
        // we arrived at the end of our inventory
        return;
      }

      // take the i'nth contiguous buffered range
      const rangeStart = ranges[i].start;
      const rangeEnd = ranges[i].end;
      if (rangeEnd - rangeStart < MINIMUM_SEGMENT_SIZE) {
        log.warn("SI", "skipped range when synchronizing because it was too small", {
          t: bufferType,
          rangeStart,
          rangeEnd,
        });
        continue;
      }

      const indexBefore = inventoryIndex; // keep track of that number

      // Find the first segment either within this range or completely past
      // it:
      // skip until first segment with at least `MINIMUM_SEGMENT_SIZE` past the
      // start of that range.
      while (
        thisSegment !== undefined &&
        (thisSegment.bufferedEnd ?? thisSegment.end) - rangeStart < MINIMUM_SEGMENT_SIZE
      ) {
        thisSegment = inventory[++inventoryIndex];
      }

      // Contains infos about the last garbage-collected segment before
      // `thisSegment`.
      let lastDeletedSegmentInfos: { end: number; precizeEnd: boolean } | null = null;

      // remove garbage-collected segments
      // (Those not in that range nor in the previous one)
      const numberOfSegmentToDelete = inventoryIndex - indexBefore;
      if (numberOfSegmentToDelete > 0) {
        const lastDeletedSegment = inventory[indexBefore + numberOfSegmentToDelete - 1]; // last garbage-collected segment

        lastDeletedSegmentInfos = {
          end: lastDeletedSegment.bufferedEnd ?? lastDeletedSegment.end,
          precizeEnd: lastDeletedSegment.precizeEnd,
        };
        log.debug("SI", `${numberOfSegmentToDelete} segments GCed.`, {
          t: bufferType,
        });
        const removed = inventory.splice(indexBefore, numberOfSegmentToDelete);
        for (const seg of removed) {
          if (
            seg.bufferedStart === undefined &&
            seg.bufferedEnd === undefined &&
            seg.status !== ChunkStatus.Failed
          ) {
            this._bufferedHistory.addBufferedSegment(seg.infos, null);
          }
        }
        inventoryIndex = indexBefore;
      }

      if (thisSegment === undefined) {
        return;
      }

      // If the current segment is actually completely outside that range (it
      // is contained in one of the next one), skip that part.
      if (
        rangeEnd - (thisSegment.bufferedStart ?? thisSegment.start) >=
        MINIMUM_SEGMENT_SIZE
      ) {
        guessBufferedStartFromRangeStart(
          thisSegment,
          rangeStart,
          lastDeletedSegmentInfos,
          bufferType,
        );

        if (inventoryIndex === inventory.length - 1) {
          // This is the last segment in the inventory.
          // We can directly update the end as the end of the current range.
          guessBufferedEndFromRangeEnd(thisSegment, rangeEnd, bufferType);
          return;
        }

        thisSegment = inventory[++inventoryIndex];

        // Make contiguous until first segment outside that range
        let thisSegmentStart = thisSegment.bufferedStart ?? thisSegment.start;
        let thisSegmentEnd = thisSegment.bufferedEnd ?? thisSegment.end;
        const nextRangeStart = i < rangesLength - 1 ? ranges[i + 1].start : undefined;
        while (thisSegment !== undefined) {
          if (rangeEnd < thisSegmentStart) {
            // `thisSegment` is part of the next range
            break;
          }
          if (
            rangeEnd - thisSegmentStart < MINIMUM_SEGMENT_SIZE &&
            thisSegmentEnd - rangeEnd >= MINIMUM_SEGMENT_SIZE
          ) {
            // Ambiguous, but `thisSegment` seems more to come after the current
            // range than during it.
            break;
          }
          if (
            nextRangeStart !== undefined &&
            rangeEnd - thisSegmentStart < thisSegmentEnd - nextRangeStart
          ) {
            // Ambiguous, but `thisSegment` has more chance to be part of the
            // next range than the current one
            break;
          }

          const prevSegment = inventory[inventoryIndex - 1];

          // those segments are contiguous, we have no way to infer their real
          // end
          if (prevSegment.bufferedEnd === undefined) {
            if (thisSegment.precizeStart) {
              prevSegment.bufferedEnd = thisSegment.start;
            } else if (prevSegment.infos.segment.complete) {
              prevSegment.bufferedEnd = prevSegment.end;
            } else {
              // We cannot truly trust the anounced end here as the segment was
              // potentially not complete at its time of announce.
              // Just assume the next's segment announced start is right - as
              // `start` is in that scenario more "trustable" than `end`.
              prevSegment.bufferedEnd = thisSegment.start;
            }
            log.debug("SI", "calculating buffered end of contiguous segment", {
              t: bufferType,
              prevSegmentBufferedEnd: prevSegment.bufferedEnd,
              pse: prevSegment.end,
            });
          }

          thisSegment.bufferedStart = prevSegment.bufferedEnd;
          thisSegment = inventory[++inventoryIndex];
          if (thisSegment !== undefined) {
            thisSegmentStart = thisSegment.bufferedStart ?? thisSegment.start;
            thisSegmentEnd = thisSegment.bufferedEnd ?? thisSegment.end;
          }
        }
      }

      // update the bufferedEnd of the last segment in that range
      const lastSegmentInRange = inventory[inventoryIndex - 1];
      if (lastSegmentInRange !== undefined) {
        guessBufferedEndFromRangeEnd(lastSegmentInRange, rangeEnd, bufferType);
      }
    }

    // if we still have segments left, they are not affiliated to any range.
    // They might have been garbage collected, delete them from here.
    if (!isNullOrUndefined(thisSegment)) {
      const { SEGMENT_SYNCHRONIZATION_DELAY } = config.getCurrent();
      const now = getMonotonicTimeStamp();
      for (let i = inventoryIndex; i < inventory.length; i++) {
        const segmentInfo = inventory[i];
        if (now - segmentInfo.insertionTs >= SEGMENT_SYNCHRONIZATION_DELAY) {
          log.debug(
            "SI",
            "A segment at the end has been completely GCed",
            getLoggableSegmentId(segmentInfo.infos),
          );
          if (
            segmentInfo.bufferedStart === undefined &&
            segmentInfo.bufferedEnd === undefined &&
            segmentInfo.status !== ChunkStatus.Failed
          ) {
            this._bufferedHistory.addBufferedSegment(segmentInfo.infos, null);
          }
          inventory.splice(i, 1);
          i--;
        }
      }
    }
    if (bufferType !== undefined && log.hasLevel("DEBUG")) {
      // TODO: Do not log if it did not change?
      log.debug(
        "SI",
        `current ${bufferType} inventory timeline:\n` +
          prettyPrintInventory(this._inventory),
      );
    }
  }

  /**
   * Add a new chunk in the inventory.
   *
   * Chunks are decodable sub-parts of a whole segment. Once all chunks in a
   * segment have been inserted, you should call the `completeSegment` method.
   * @param {Object} chunkInformation
   * @param {boolean} succeed - If `true` the insertion operation finished with
   * success, if `false` an error arised while doing it.
   * @param {number} insertionTs - The monotonically-increasing timestamp at the
   * time the segment has been confirmed to be inserted by the buffer.
   */
  public insertChunk(
    {
      period,
      adaptation,
      representation,
      segment,
      chunkSize,
      start,
      end,
    }: IInsertedChunkInfos,
    succeed: boolean,
    insertionTs: number,
  ): void {
    if (segment.isInit) {
      return;
    }

    const bufferType = adaptation.type;
    if (start >= end) {
      log.warn("SI", "Invalid chunked inserted: starts before it ends", {
        t: bufferType,
        start,
        end,
      });
      return;
    }

    const inventory = this._inventory;
    const newSegment = {
      status: succeed ? ChunkStatus.PartiallyPushed : ChunkStatus.Failed,
      insertionTs,
      chunkSize,
      splitted: false,
      start,
      end,
      precizeStart: false,
      precizeEnd: false,
      bufferedStart: undefined,
      bufferedEnd: undefined,
      infos: { segment, period, adaptation, representation },
    };

    // begin by the end as in most use cases this will be faster
    for (let i = inventory.length - 1; i >= 0; i--) {
      const segmentI = inventory[i];

      if (segmentI.start <= start) {
        if (segmentI.end <= start) {
          // our segment is after, push it after this one
          //
          // Case 1:
          //   prevSegment  : |------|
          //   newSegment   :        |======|
          //   ===>         : |------|======|
          //
          // Case 2:
          //   prevSegment  : |------|
          //   newSegment   :          |======|
          //   ===>         : |------| |======|
          log.debug("SI", "Pushing segment strictly after previous one.", {
            t: bufferType,
            pse: segmentI.end,
            ss: start,
          });
          this._inventory.splice(i + 1, 0, newSegment);

          i += 2; // Go to segment immediately after newSegment
          while (i < inventory.length && inventory[i].start < newSegment.end) {
            if (inventory[i].end > newSegment.end) {
              // The next segment ends after newSegment.
              // Mutate the next segment.
              //
              // Case 1:
              //   prevSegment  : |------|
              //   newSegment   :        |======|
              //   nextSegment  :            |----|
              //   ===>         : |------|======|-|
              log.debug("SI", "Segment pushed updates the start of the next one", {
                t: bufferType,
                pss: inventory[i].start,
                ss: start,
                se: end,
              });
              inventory[i].start = newSegment.end;
              inventory[i].bufferedStart = undefined;
              inventory[i].precizeStart =
                inventory[i].precizeStart && newSegment.precizeEnd;
              return;
            }
            // The next segment was completely contained in newSegment.
            // Remove it.
            //
            // Case 1:
            //   prevSegment  : |------|
            //   newSegment   :        |======|
            //   nextSegment  :          |---|
            //   ===>         : |------|======|
            //
            // Case 2:
            //   prevSegment  : |------|
            //   newSegment   :        |======|
            //   nextSegment  :          |----|
            //   ===>         : |------|======|
            log.debug("SI", "Segment pushed removes the next one", {
              t: bufferType,
              ss: start,
              se: end,
              pss: inventory[i].start,
              pse: inventory[i].end,
            });
            inventory.splice(i, 1);
          }
          return;
        } else {
          if (segmentI.start === start) {
            if (segmentI.end <= end) {
              // In those cases, replace
              //
              // Case 1:
              //  prevSegment  : |-------|
              //  newSegment   : |=======|
              //  ===>         : |=======|
              //
              // Case 2:
              //  prevSegment  : |-------|
              //  newSegment   : |==========|
              //  ===>         : |==========|
              log.debug("SI", "Segment pushed replace another one", {
                t: bufferType,
                ss: start,
                se: end,
                pss: segmentI.start,
                pse: segmentI.end,
              });
              this._inventory.splice(i, 1, newSegment);
              i += 1; // Go to segment immediately after newSegment
              while (i < inventory.length && inventory[i].start < newSegment.end) {
                if (inventory[i].end > newSegment.end) {
                  // The next segment ends after newSegment.
                  // Mutate the next segment.
                  //
                  // Case 1:
                  //   newSegment   : |======|
                  //   nextSegment  :      |----|
                  //   ===>         : |======|--|
                  log.debug("SI", "Segment pushed updates the start of the next one", {
                    t: bufferType,
                    ss: start,
                    se: end,
                    pss: inventory[i].start,
                    pse: inventory[i].end,
                  });
                  inventory[i].start = newSegment.end;
                  inventory[i].bufferedStart = undefined;
                  inventory[i].precizeStart =
                    inventory[i].precizeStart && newSegment.precizeEnd;
                  return;
                }
                // The next segment was completely contained in newSegment.
                // Remove it.
                //
                // Case 1:
                //   newSegment   : |======|
                //   nextSegment  :   |---|
                //   ===>         : |======|
                //
                // Case 2:
                //   newSegment   : |======|
                //   nextSegment  :   |----|
                //   ===>         : |======|
                log.debug("SI", "Segment pushed removes the next one", {
                  t: bufferType,
                  ss: start,
                  se: end,
                  pss: inventory[i].start,
                  pse: inventory[i].end,
                });
                inventory.splice(i, 1);
              }
              return;
            } else {
              // The previous segment starts at the same time and finishes
              // after the new segment.
              // Update the start of the previous segment and put the new
              // segment before.
              //
              // Case 1:
              //  prevSegment  : |------------|
              //  newSegment   : |==========|
              //  ===>         : |==========|-|
              log.debug("SI", "Segment pushed ends before another with the same start", {
                t: bufferType,
                ss: start,
                se: end,
                pse: segmentI.end,
              });
              inventory.splice(i, 0, newSegment);
              segmentI.start = newSegment.end;
              segmentI.bufferedStart = undefined;
              segmentI.precizeStart = segmentI.precizeStart && newSegment.precizeEnd;
              return;
            }
          } else {
            if (segmentI.end <= newSegment.end) {
              // our segment has a "complex" relation with this one,
              // update the old one end and add this one after it.
              //
              // Case 1:
              //  prevSegment  : |-------|
              //  newSegment   :    |======|
              //  ===>         : |--|======|
              //
              // Case 2:
              //  prevSegment  : |-------|
              //  newSegment   :    |====|
              //  ===>         : |--|====|
              log.debug("SI", "Segment pushed updates end of previous one", {
                t: bufferType,
                ss: start,
                se: end,
                pss: segmentI.start,
                pse: segmentI.end,
              });
              this._inventory.splice(i + 1, 0, newSegment);
              segmentI.end = newSegment.start;
              segmentI.bufferedEnd = undefined;
              segmentI.precizeEnd = segmentI.precizeEnd && newSegment.precizeStart;
              i += 2; // Go to segment immediately after newSegment
              while (i < inventory.length && inventory[i].start < newSegment.end) {
                if (inventory[i].end > newSegment.end) {
                  // The next segment ends after newSegment.
                  // Mutate the next segment.
                  //
                  // Case 1:
                  //   newSegment   : |======|
                  //   nextSegment  :      |----|
                  //   ===>         : |======|--|
                  log.debug("SI", "Segment pushed updates the start of the next one", {
                    t: bufferType,
                    ss: start,
                    se: end,
                    pss: inventory[i].start,
                  });
                  inventory[i].start = newSegment.end;
                  inventory[i].bufferedStart = undefined;
                  inventory[i].precizeStart =
                    inventory[i].precizeStart && newSegment.precizeEnd;
                  return;
                }
                // The next segment was completely contained in newSegment.
                // Remove it.
                //
                // Case 1:
                //   newSegment   : |======|
                //   nextSegment  :   |---|
                //   ===>         : |======|
                //
                // Case 2:
                //   newSegment   : |======|
                //   nextSegment  :   |----|
                //   ===>         : |======|
                log.debug("SI", "Segment pushed removes the next one", {
                  t: bufferType,
                  ss: start,
                  se: end,
                  pss: inventory[i].start,
                  pse: inventory[i].end,
                });
                inventory.splice(i, 1);
              }
              return;
            } else {
              // The previous segment completely recovers the new segment.
              // Split the previous segment into two segments, before and after
              // the new segment.
              //
              // Case 1:
              //  prevSegment  : |---------|
              //  newSegment   :    |====|
              //  ===>         : |--|====|-|
              log.warn("SI", "Segment pushed is contained in a previous one", {
                t: bufferType,
                ss: start,
                se: end,
                pss: segmentI.start,
                pse: segmentI.end,
              });
              const nextSegment = {
                status: segmentI.status,
                insertionTs: segmentI.insertionTs,
                /**
                 * Note: this sadly means we're doing as if
                 * that chunk is present two times.
                 * Thankfully, this scenario should be
                 * fairly rare.
                 */
                chunkSize: segmentI.chunkSize,
                splitted: true,
                start: newSegment.end,
                end: segmentI.end,
                precizeStart:
                  segmentI.precizeStart && segmentI.precizeEnd && newSegment.precizeEnd,
                precizeEnd: segmentI.precizeEnd,
                bufferedStart: undefined,
                bufferedEnd: segmentI.end,
                infos: segmentI.infos,
              };
              segmentI.end = newSegment.start;
              segmentI.splitted = true;
              segmentI.bufferedEnd = undefined;
              segmentI.precizeEnd = segmentI.precizeEnd && newSegment.precizeStart;
              inventory.splice(i + 1, 0, newSegment);
              inventory.splice(i + 2, 0, nextSegment);
              return;
            }
          }
        }
      }
    }

    // if we got here, we are at the first segment
    // check bounds of the previous first segment
    const firstSegment = this._inventory[0];
    if (firstSegment === undefined) {
      // we do not have any segment yet
      log.debug("SI", "first segment pushed", { t: bufferType, ss: start, se: end });
      this._inventory.push(newSegment);
      return;
    }

    if (firstSegment.start >= end) {
      // our segment is before, put it before
      //
      // Case 1:
      //  firstSegment :      |----|
      //  newSegment   : |====|
      //  ===>         : |====|----|
      //
      // Case 2:
      //  firstSegment :        |----|
      //  newSegment   : |====|
      //  ===>         : |====| |----|
      log.debug("SI", "Segment pushed comes before all previous ones", {
        t: bufferType,
        ss: start,
        se: end,
        pss: firstSegment.start,
      });
      this._inventory.splice(0, 0, newSegment);
    } else if (firstSegment.end <= end) {
      // Our segment is bigger, replace the first
      //
      // Case 1:
      //  firstSegment :   |---|
      //  newSegment   : |=======|
      //  ===>         : |=======|
      //
      // Case 2:
      //  firstSegment :   |-----|
      //  newSegment   : |=======|
      //  ===>         : |=======|
      log.debug(
        "SI",
        "Segment pushed starts before and completely " +
          "recovers the previous first one",
        {
          t: bufferType,
          ss: start,
          se: end,
          pss: firstSegment.start,
          pse: firstSegment.end,
        },
      );
      this._inventory.splice(0, 1, newSegment);
      while (inventory.length > 1 && inventory[1].start < newSegment.end) {
        if (inventory[1].end > newSegment.end) {
          // The next segment ends after newSegment.
          // Mutate the next segment.
          //
          // Case 1:
          //   newSegment   : |======|
          //   nextSegment  :      |----|
          //   ===>         : |======|--|
          log.debug("SI", "Segment pushed updates the start of the next one", {
            t: bufferType,
            ss: start,
            se: end,
            pss: inventory[1].start,
            pse: inventory[1].end,
          });
          inventory[1].start = newSegment.end;
          inventory[1].bufferedStart = undefined;
          inventory[1].precizeStart = newSegment.precizeEnd;
          return;
        }
        // The next segment was completely contained in newSegment.
        // Remove it.
        //
        // Case 1:
        //   newSegment   : |======|
        //   nextSegment  :   |---|
        //   ===>         : |======|
        //
        // Case 2:
        //   newSegment   : |======|
        //   nextSegment  :   |----|
        //   ===>         : |======|
        log.debug("SI", "Segment pushed removes the next one", {
          t: bufferType,
          ss: start,
          se: end,
          pss: inventory[1].start,
          pse: inventory[1].end,
        });
        inventory.splice(1, 1);
      }
      return;
    } else {
      // our segment has a "complex" relation with the first one,
      // update the old one start and add this one before it.
      //
      // Case 1:
      //  firstSegment :    |------|
      //  newSegment   : |======|
      //  ===>         : |======|--|
      log.debug("SI", "Segment pushed start of the next one", bufferType, {
        ss: start,
        se: end,
        pss: firstSegment.start,
        pse: firstSegment.end,
      });
      firstSegment.start = end;
      firstSegment.bufferedStart = undefined;
      firstSegment.precizeStart = newSegment.precizeEnd;
      this._inventory.splice(0, 0, newSegment);
      return;
    }
  }

  /**
   * Indicate that inserted chunks can now be considered as a fully-loaded
   * segment.
   * Take in argument the same content than what was given to `insertChunk` for
   * the corresponding chunks.
   * @param {Object} content
   */
  public completeSegment(content: {
    period: IPeriod;
    adaptation: IAdaptation;
    representation: IRepresentation;
    segment: ISegment;
  }): void {
    if (content.segment.isInit) {
      return;
    }
    const inventory = this._inventory;

    const resSegments: IBufferedChunk[] = [];

    for (let i = 0; i < inventory.length; i++) {
      if (areSameContent(inventory[i].infos, content)) {
        let splitted = false;
        if (resSegments.length > 0) {
          splitted = true;
          if (resSegments.length === 1) {
            log.warn(
              "SI",
              "Completed Segment is splitted.",
              getLoggableSegmentId(content),
            );
            resSegments[0].splitted = true;
          }
        }

        const firstI = i;
        let segmentSize = inventory[i].chunkSize;
        i += 1;
        while (i < inventory.length && areSameContent(inventory[i].infos, content)) {
          const chunkSize = inventory[i].chunkSize;
          if (segmentSize !== undefined && chunkSize !== undefined) {
            segmentSize += chunkSize;
          }
          i++;
        }

        const lastI = i - 1;
        const length = lastI - firstI;
        const lastEnd = inventory[lastI].end;
        const lastBufferedEnd = inventory[lastI].bufferedEnd;
        if (length > 0) {
          this._inventory.splice(firstI + 1, length);
          i -= length;
        }
        if (this._inventory[firstI].status === ChunkStatus.PartiallyPushed) {
          this._inventory[firstI].status = ChunkStatus.FullyLoaded;
        }
        this._inventory[firstI].chunkSize = segmentSize;
        this._inventory[firstI].end = lastEnd;
        this._inventory[firstI].bufferedEnd = lastBufferedEnd;
        this._inventory[firstI].splitted = splitted;
        resSegments.push(this._inventory[firstI]);
      }
    }

    if (resSegments.length === 0) {
      log.warn("SI", "Completed Segment not found", getLoggableSegmentId(content));
    } else {
      for (const seg of resSegments) {
        if (seg.bufferedStart !== undefined && seg.bufferedEnd !== undefined) {
          if (seg.status !== ChunkStatus.Failed) {
            this._bufferedHistory.addBufferedSegment(seg.infos, {
              start: seg.bufferedStart,
              end: seg.bufferedEnd,
            });
          }
        } else {
          // TODO FIXME There might be a false positive here when the
          // `SEGMENT_SYNCHRONIZATION_DELAY` config value is at play
          log.debug("SI", "buffered range not known after sync. Skipping history.", {
            ss: seg.start,
            se: seg.end,
          });
        }
      }
    }
  }

  /**
   * Returns the whole inventory.
   *
   * To get a list synchronized with what a media buffer actually has buffered
   * you might want to call `synchronizeBuffered` before calling this method.
   * @returns {Array.<Object>}
   */
  public getInventory(): IBufferedChunk[] {
    return this._inventory;
  }

  /**
   * Returns a recent history of registered operations performed and event
   * received linked to the segment given in argument.
   *
   * Not all operations and events are registered in the returned history.
   * Please check the return type for more information on what is available.
   *
   * Note that history is short-lived for memory usage and performance reasons.
   * You may not receive any information on operations that happened too long
   * ago.
   * @param {Object} context
   * @returns {Array.<Object>}
   */
  public getHistoryFor(context: IChunkContext): IBufferedHistoryEntry[] {
    return this._bufferedHistory.getHistoryFor(context);
  }
}

/**
 * Returns `true` if the buffered start of the given chunk looks coherent enough
 * relatively to what is announced in the Manifest.
 * @param {Object} thisSegment
 * @returns {Boolean}
 */
function bufferedStartLooksCoherent(thisSegment: IBufferedChunk): boolean {
  if (
    thisSegment.bufferedStart === undefined ||
    thisSegment.status !== ChunkStatus.FullyLoaded
  ) {
    return false;
  }
  const { start, end } = thisSegment;
  const duration = end - start;
  const {
    MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE,
    MAX_MANIFEST_BUFFERED_DURATION_DIFFERENCE,
  } = config.getCurrent();
  return (
    Math.abs(start - thisSegment.bufferedStart) <=
      MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE &&
    (thisSegment.bufferedEnd === undefined ||
      (thisSegment.bufferedEnd > thisSegment.bufferedStart &&
        Math.abs(thisSegment.bufferedEnd - thisSegment.bufferedStart - duration) <=
          Math.min(MAX_MANIFEST_BUFFERED_DURATION_DIFFERENCE, duration / 3)))
  );
}

/**
 * Returns `true` if the buffered end of the given chunk looks coherent enough
 * relatively to what is announced in the Manifest.
 * @param {Object} thisSegment
 * @returns {Boolean}
 */
function bufferedEndLooksCoherent(thisSegment: IBufferedChunk): boolean {
  if (
    thisSegment.bufferedEnd === undefined ||
    !thisSegment.infos.segment.complete ||
    thisSegment.status !== ChunkStatus.FullyLoaded
  ) {
    return false;
  }
  const { start, end } = thisSegment;
  const duration = end - start;
  const {
    MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE,
    MAX_MANIFEST_BUFFERED_DURATION_DIFFERENCE,
  } = config.getCurrent();
  return (
    Math.abs(end - thisSegment.bufferedEnd) <=
      MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE &&
    thisSegment.bufferedStart !== undefined &&
    thisSegment.bufferedEnd > thisSegment.bufferedStart &&
    Math.abs(thisSegment.bufferedEnd - thisSegment.bufferedStart - duration) <=
      Math.min(MAX_MANIFEST_BUFFERED_DURATION_DIFFERENCE, duration / 3)
  );
}

/**
 * Evaluate the given buffered Chunk's buffered start from its range's start,
 * considering that this chunk is the first one in it.
 * @param {Object} firstSegmentInRange
 * @param {number} rangeStart
 * @param {Object} lastDeletedSegmentInfos
 */
function guessBufferedStartFromRangeStart(
  firstSegmentInRange: IBufferedChunk,
  rangeStart: number,
  lastDeletedSegmentInfos: { end: number; precizeEnd: boolean } | null,
  bufferType: string,
): void {
  const {
    MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE,
    MISSING_DATA_TRIGGER_SYNC_DELAY,
    SEGMENT_SYNCHRONIZATION_DELAY,
  } = config.getCurrent();
  if (firstSegmentInRange.bufferedStart !== undefined) {
    if (firstSegmentInRange.bufferedStart < rangeStart) {
      log.debug("SI", "Segment partially GCed at the start", {
        t: bufferType,
        firstsbs: firstSegmentInRange.bufferedStart,
        rs: rangeStart,
      });
      firstSegmentInRange.bufferedStart = rangeStart;
    }
    if (
      !firstSegmentInRange.precizeStart &&
      bufferedStartLooksCoherent(firstSegmentInRange)
    ) {
      firstSegmentInRange.start = firstSegmentInRange.bufferedStart;
      firstSegmentInRange.precizeStart = true;
    }
  } else if (firstSegmentInRange.precizeStart) {
    log.debug("SI", "buffered start is precize start", {
      t: bufferType,
      firstss: firstSegmentInRange.start,
    });
    firstSegmentInRange.bufferedStart = firstSegmentInRange.start;
  } else if (
    lastDeletedSegmentInfos !== null &&
    lastDeletedSegmentInfos.end > rangeStart &&
    (lastDeletedSegmentInfos.precizeEnd ||
      firstSegmentInRange.start - lastDeletedSegmentInfos.end <=
        MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE)
  ) {
    log.debug("SI", "buffered start is end of previous segment", {
      t: bufferType,
      rs: rangeStart,
      firstss: firstSegmentInRange.start,
      lastdelse: lastDeletedSegmentInfos.end,
    });
    firstSegmentInRange.bufferedStart = lastDeletedSegmentInfos.end;
    if (bufferedStartLooksCoherent(firstSegmentInRange)) {
      firstSegmentInRange.start = lastDeletedSegmentInfos.end;
      firstSegmentInRange.precizeStart = true;
    }
  } else if (
    firstSegmentInRange.start - rangeStart <=
    MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE
  ) {
    const now = getMonotonicTimeStamp();
    if (
      firstSegmentInRange.start - rangeStart >= MISSING_DATA_TRIGGER_SYNC_DELAY &&
      now - firstSegmentInRange.insertionTs < SEGMENT_SYNCHRONIZATION_DELAY
    ) {
      log.debug("SI", "Ignored bufferedStart synchronization", {
        t: bufferType,
        rs: rangeStart,
        firstss: firstSegmentInRange.start,
        delta: now - firstSegmentInRange.insertionTs,
      });
      return;
    }
    log.debug("SI", "found true buffered start", {
      t: bufferType,
      rs: rangeStart,
      firstss: firstSegmentInRange.start,
    });
    firstSegmentInRange.bufferedStart = rangeStart;
    if (bufferedStartLooksCoherent(firstSegmentInRange)) {
      firstSegmentInRange.start = rangeStart;
      firstSegmentInRange.precizeStart = true;
    }
  } else if (rangeStart < firstSegmentInRange.start) {
    log.debug("SI", "range start too far from expected start", {
      t: bufferType,
      rs: rangeStart,
      firstss: firstSegmentInRange.start,
    });
    firstSegmentInRange.bufferedStart = firstSegmentInRange.start;
  } else {
    const now = getMonotonicTimeStamp();
    if (
      firstSegmentInRange.start - rangeStart >= MISSING_DATA_TRIGGER_SYNC_DELAY &&
      now - firstSegmentInRange.insertionTs < SEGMENT_SYNCHRONIZATION_DELAY
    ) {
      log.debug("SI", "Ignored bufferedStart synchronization", {
        t: bufferType,
        rs: rangeStart,
        firstss: firstSegmentInRange.start,
        delta: now - firstSegmentInRange.insertionTs,
      });
      return;
    }
    log.debug("SI", "Segment appears immediately garbage collected at the start", {
      t: bufferType,
      rs: rangeStart,
      firstss: firstSegmentInRange.start,
    });
    firstSegmentInRange.bufferedStart = rangeStart;
  }
}

/**
 * Evaluate the given buffered Chunk's buffered end from its range's end,
 * considering that this chunk is the last one in it.
 * @param {Object} lastSegmentInRange
 * @param {number} rangeEnd
 * @param {string} bufferType
 */
function guessBufferedEndFromRangeEnd(
  lastSegmentInRange: IBufferedChunk,
  rangeEnd: number,
  bufferType?: string,
): void {
  const {
    MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE,
    MISSING_DATA_TRIGGER_SYNC_DELAY,
    SEGMENT_SYNCHRONIZATION_DELAY,
  } = config.getCurrent();
  if (lastSegmentInRange.bufferedEnd !== undefined) {
    if (lastSegmentInRange.bufferedEnd > rangeEnd) {
      log.debug("SI", "Segment partially GCed at the end", {
        t: bufferType,
        lastsbe: lastSegmentInRange.bufferedEnd,
        re: rangeEnd,
      });
      lastSegmentInRange.bufferedEnd = rangeEnd;
    }
    if (
      !lastSegmentInRange.precizeEnd &&
      rangeEnd - lastSegmentInRange.end <= MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE &&
      bufferedEndLooksCoherent(lastSegmentInRange)
    ) {
      lastSegmentInRange.precizeEnd = true;
      lastSegmentInRange.end = rangeEnd;
    }
  } else if (lastSegmentInRange.precizeEnd) {
    log.debug("SI", "buffered end is precize end", {
      t: bufferType,
      lastse: lastSegmentInRange.end,
    });
    lastSegmentInRange.bufferedEnd = lastSegmentInRange.end;
  } else if (
    rangeEnd - lastSegmentInRange.end <= MAX_MANIFEST_BUFFERED_START_END_DIFFERENCE ||
    !lastSegmentInRange.infos.segment.complete
  ) {
    const now = getMonotonicTimeStamp();
    if (
      rangeEnd - lastSegmentInRange.end >= MISSING_DATA_TRIGGER_SYNC_DELAY &&
      now - lastSegmentInRange.insertionTs < SEGMENT_SYNCHRONIZATION_DELAY
    ) {
      log.debug("SI", "Ignored bufferedEnd synchronization", {
        t: bufferType,
        re: rangeEnd,
        lastse: lastSegmentInRange.end,
        delta: now - lastSegmentInRange.insertionTs,
      });
      return;
    }
    log.debug("SI", "found true buffered end", {
      t: bufferType,
      re: rangeEnd,
      lastse: lastSegmentInRange.end,
    });
    lastSegmentInRange.bufferedEnd = rangeEnd;
    if (bufferedEndLooksCoherent(lastSegmentInRange)) {
      lastSegmentInRange.end = rangeEnd;
      lastSegmentInRange.precizeEnd = true;
    }
  } else if (rangeEnd > lastSegmentInRange.end) {
    log.debug("SI", "range end too far from expected end", {
      t: bufferType,
      re: rangeEnd,
      lastse: lastSegmentInRange.end,
    });
    lastSegmentInRange.bufferedEnd = lastSegmentInRange.end;
  } else {
    const now = getMonotonicTimeStamp();
    if (
      rangeEnd - lastSegmentInRange.end >= MISSING_DATA_TRIGGER_SYNC_DELAY &&
      now - lastSegmentInRange.insertionTs < SEGMENT_SYNCHRONIZATION_DELAY
    ) {
      log.debug("SI", "Ignored bufferedEnd synchronization", {
        t: bufferType,
        re: rangeEnd,
        lastse: lastSegmentInRange.end,
        delta: now - lastSegmentInRange.insertionTs,
      });
      return;
    }
    log.debug("SI", "Segment appears immediately garbage collected at the end", {
      t: bufferType,
      lastsbe: lastSegmentInRange.bufferedEnd,
      re: rangeEnd,
    });
    lastSegmentInRange.bufferedEnd = rangeEnd;
  }
}

/**
 * Pretty print the inventory, to easily note which segments are where in the
 * current buffer.
 *
 * This is mostly useful when logging.
 *
 * @example
 * This function is called by giving it the inventory, such as:
 * ```js
 * prettyPrintInventory(inventory);
 * ```
 *
 * Let's consider this possible return:
 * ```
 * 0.00|A|9.00 ~ 9.00|B|45.08 ~ 282.08|B|318.08
 * [A] P: gen-dash-period-0 || R: video/5(2362822)
 * [B] P: gen-dash-period-0 || R: video/6(2470094)
 * ```
 * We have a first part, from 0 to 9 seconds, which contains segments for
 * the Representation with the id "video/5" and an associated bitrate of
 * 2362822 bits per seconds (in the Period with the id "gen-dash-period-0").
 *
 * Then from 9.00 seconds to 45.08 seconds, we have segments from another
 * Representation from the same Period (with the id "video/6" and a bitrate
 * of 2470094 bits per seconds).
 *
 * At last we have a long time between 45.08 and 282.08 with no segment followed
 * by a segment from that same Representation between 282.08 seconds and 318.08
 * seconds.
 * @param {Array.<Object>} inventory
 * @returns {string}
 */
function prettyPrintInventory(inventory: IBufferedChunk[]): string {
  const roundingError = 1 / 60;
  const encounteredReps: Partial<
    Record<
      string /* Period `id` */,
      Partial<Record<string /* Representation `id` */, string /* associated letter */>>
    >
  > = {};
  const letters: Array<{
    letter: string;
    periodId: string;
    representationId: string | number;
    bitrate?: number;
  }> = [];
  let lastChunk: IBufferedChunk | null = null;
  let lastLetter: string | null = null;

  function generateNewLetter(infos: IChunkContext): string {
    const currentLetter = String.fromCharCode(letters.length + 65);
    letters.push({
      letter: currentLetter,
      periodId: infos.period.id,
      representationId: infos.representation.id,
      bitrate: infos.representation.bitrate,
    });
    return currentLetter;
  }

  let str = "";
  for (const chunk of inventory) {
    if (chunk.bufferedStart !== undefined && chunk.bufferedEnd !== undefined) {
      const periodId = chunk.infos.period.id;
      const representationId = chunk.infos.representation.id;
      const encounteredPeriod = encounteredReps[periodId];

      let currentLetter: string;
      if (encounteredPeriod === undefined) {
        currentLetter = generateNewLetter(chunk.infos);
        encounteredReps[periodId] = { [representationId]: currentLetter };
      } else {
        const previousLetter = encounteredPeriod[representationId];
        if (previousLetter === undefined) {
          currentLetter = generateNewLetter(chunk.infos);
          encounteredPeriod[representationId] = currentLetter;
        } else {
          currentLetter = previousLetter;
        }
      }

      if (lastChunk === null) {
        str += `${chunk.bufferedStart.toFixed(2)}|${currentLetter}|`;
      } else if (lastLetter === currentLetter) {
        if ((lastChunk.bufferedEnd as number) + roundingError < chunk.bufferedStart) {
          str +=
            `${(lastChunk.bufferedEnd as number).toFixed(2)} ~ ` +
            `${chunk.bufferedStart.toFixed(2)}|${currentLetter}|`;
        }
      } else {
        str +=
          `${(lastChunk.bufferedEnd as number).toFixed(2)} ~ ` +
          `${chunk.bufferedStart.toFixed(2)}|${currentLetter}|`;
      }
      lastChunk = chunk;
      lastLetter = currentLetter;
    }
  }
  if (lastChunk !== null) {
    str += String(lastChunk.end.toFixed(2));
  }
  letters.forEach((letterInfo) => {
    str +=
      `\n[${letterInfo.letter}] ` +
      `P: ${letterInfo.periodId} || R: ${letterInfo.representationId}` +
      `(${letterInfo.bitrate ?? "unknown bitrate"})`;
  });
  return str;
}
