/**
 * 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.
 */

/**
 * This file allows to create RepresentationStreams.
 *
 * A RepresentationStream downloads and push segment for a single
 * Representation (e.g. a single video stream of a given quality).
 * It chooses which segments should be downloaded according to the current
 * position and what is currently buffered.
 */

import config from "../../../config";
import log from "../../../log";
import type { ISegment } from "../../../manifest";
import objectAssign from "../../../utils/object_assign";
import type { CancellationSignal } from "../../../utils/task_canceller";
import TaskCanceller, { CancellationError } from "../../../utils/task_canceller";
import type {
  IParsedInitSegmentPayload,
  IParsedSegmentPayload,
} from "../../fetchers/segment/segment_queue";
import EncryptionDataNotifier from "./encryption_data_notifier";
import type {
  IQueuedSegment,
  IRepresentationStreamArguments,
  IRepresentationStreamCallbacks,
} from "./types";
import getBufferStatus from "./utils/get_buffer_status";
import getSegmentPriority from "./utils/get_segment_priority";
import pushInitSegment from "./utils/push_init_segment";
import pushMediaSegment from "./utils/push_media_segment";

/**
 * Perform the logic to load the right segments for the given Representation and
 * push them to the given `SegmentSink`.
 *
 * In essence, this is the entry point of the core streaming logic of the
 * RxPlayer, the one actually responsible for finding which are the current
 * right segments to load, loading them, and pushing them so they can be decoded.
 *
 * Multiple RepresentationStream can run on the same SegmentSink.
 * This allows for example smooth transitions between multiple periods.
 *
 * @param {Object} args - Various arguments allowing to know which segments to
 * load, loading them and pushing them.
 * You can check the corresponding type for more information.
 * @param {Object} callbacks - The `RepresentationStream` relies on a system of
 * callbacks that it will call on various events.
 *
 * Depending on the event, the caller may be supposed to perform actions to
 * react upon some of them.
 *
 * This approach is taken instead of a more classical EventEmitter pattern to:
 *   - Allow callbacks to be called synchronously after the
 *     `RepresentationStream` is called.
 *   - Simplify bubbling events up, by just passing through callbacks
 *   - Force the caller to explicitely handle or not the different events.
 *
 * Callbacks may start being called immediately after the `RepresentationStream`
 * call and may be called until either the `parentCancelSignal` argument is
 * triggered, until the `terminating` callback has been triggered AND all loaded
 * segments have been pushed, or until the `error` callback is called, whichever
 * comes first.
 * @param {Object} parentCancelSignal - `CancellationSignal` allowing, when
 * triggered, to immediately stop all operations the `RepresentationStream` is
 * doing.
 */
export default function RepresentationStream<TSegmentDataType>(
  {
    content,
    options,
    playbackObserver,
    segmentSink,
    segmentQueue,
    terminate,
  }: IRepresentationStreamArguments<TSegmentDataType>,
  callbacks: IRepresentationStreamCallbacks,
  parentCancelSignal: CancellationSignal,
): void {
  log.debug("Stream", "Creating RepresentationStream", {
    periodStart: content.period.start,
    bufferType: content.adaptation.type,
    adaptationId: content.adaptation.id,
    representationBitrate: content.representation.bitrate,
    mimeType: content.representation.getMimeTypeString(),
  });
  const { period, adaptation, representation } = content;
  const { bufferGoal, maxBufferSize, drmSystemId, fastSwitchThreshold } = options;
  const bufferType = adaptation.type;

  /** `TaskCanceller` stopping operations performed by the `RepresentationStream` */
  const canceller = new TaskCanceller("RepresentationStream " + bufferType);
  canceller.linkToSignal(parentCancelSignal);

  /** Saved initialization segment state for this representation. */
  const initSegmentState: IInitSegmentState = {
    segment: representation.index.getInitSegment(),
    uniqueId: null,
    isLoaded: false,
  };
  canceller.signal.register(() => {
    // Free initialization segment if one has been declared
    if (initSegmentState.uniqueId !== null) {
      segmentSink.freeInitSegment(initSegmentState.uniqueId);
    }
  });

  /** If `true`, the current Representation has a linked initialization segment. */
  const hasInitSegment = initSegmentState.segment !== null;

  if (!hasInitSegment) {
    initSegmentState.isLoaded = true;
  }

  const drmNotifier = new EncryptionDataNotifier({
    drmSystemId,
    representation,
    notify: (encryptionData) =>
      callbacks.encryptionDataEncountered(
        encryptionData.map((d) => objectAssign({ content }, d)),
      ),
  });
  if (canceller.isUsed()) {
    return;
  }

  segmentQueue.addEventListener(
    "error",
    (err) => {
      if (canceller.signal.isCancelled()) {
        return; // ignore post requests-cancellation loading-related errors,
      }
      canceller.cancel("RepresentationStream: SegmentQueue err"); // Stop every operations
      callbacks.error(err);
    },
    canceller.signal,
  );
  segmentQueue.addEventListener("parsedInitSegment", onParsedChunk, canceller.signal);
  segmentQueue.addEventListener("parsedMediaSegment", onParsedChunk, canceller.signal);
  segmentQueue.addEventListener("emptyQueue", checkStatus, canceller.signal);
  segmentQueue.addEventListener(
    "requestRetry",
    (payload) => {
      callbacks.warning(payload.error);
      if (canceller.signal.isCancelled()) {
        return; // If the previous callback led to loading operations being stopped, skip
      }
      const retriedSegment = payload.segment;
      const { index } = representation;
      if (index.isSegmentStillAvailable(retriedSegment) === false) {
        checkStatus();
      } else if (index.canBeOutOfSyncError(payload.error, retriedSegment)) {
        callbacks.manifestMightBeOufOfSync();
      }
    },
    canceller.signal,
  );
  segmentQueue.addEventListener(
    "fullyLoadedSegment",
    (segment) => {
      segmentSink
        .signalSegmentComplete(objectAssign({ segment }, content))
        .catch(onFatalBufferError);
    },
    canceller.signal,
  );

  /** Emit the last scheduled downloading queue for segments. */
  const segmentsToLoadRef = segmentQueue.resetForContent(
    content,
    hasInitSegment,
    "new RepresentationStream linked to SegmentQueue",
  );

  canceller.signal.register((err) => {
    segmentQueue.stop(err.reason);
  });

  playbackObserver.listen(checkStatus, {
    includeLastObservation: false,
    clearSignal: canceller.signal,
  });
  content.manifest.addEventListener("manifestUpdate", checkStatus, canceller.signal);
  bufferGoal.onUpdate(checkStatus, {
    emitCurrentValue: false,
    clearSignal: canceller.signal,
  });
  maxBufferSize.onUpdate(checkStatus, {
    emitCurrentValue: false,
    clearSignal: canceller.signal,
  });
  terminate.onUpdate(checkStatus, {
    emitCurrentValue: false,
    clearSignal: canceller.signal,
  });
  checkStatus();
  return;

  /**
   * Produce a buffer status update synchronously on call, update the list
   * of current segments to update and check various buffer and manifest related
   * issues at the current time, calling the right callbacks if necessary.
   */
  function checkStatus(): void {
    if (canceller.isUsed()) {
      return; // Stop all buffer status checking if load operations are stopped
    }
    const observation = playbackObserver.getReference().getValue();
    const initialWantedTime = observation.position.getWanted();
    const status = getBufferStatus(
      content,
      initialWantedTime,
      playbackObserver,
      fastSwitchThreshold.getValue(),
      bufferGoal.getValue(),
      maxBufferSize.getValue(),
      segmentSink,
    );
    const { neededSegments } = status;

    let neededInitSegment: IQueuedSegment | null = null;

    // Add initialization segment if required
    if (!representation.index.isInitialized()) {
      if (initSegmentState.segment === null) {
        log.warn("Stream", "Uninitialized index without an initialization segment", {
          bufferType,
          representationBitrate: content.representation.bitrate,
        });
      } else if (initSegmentState.isLoaded) {
        log.warn(
          "Stream",
          "Uninitialized index with an already loaded initialization segment",
          {
            bufferType,
            representationBitrate: content.representation.bitrate,
          },
        );
      } else {
        const wantedStart = observation.position.getWanted();
        neededInitSegment = {
          segment: initSegmentState.segment,
          priority: getSegmentPriority(period.start, wantedStart),
        };
      }
    } else if (
      neededSegments.length > 0 &&
      !initSegmentState.isLoaded &&
      initSegmentState.segment !== null
    ) {
      const initSegmentPriority = neededSegments[0].priority;
      neededInitSegment = {
        segment: initSegmentState.segment,
        priority: initSegmentPriority,
      };
    }

    const terminateVal = terminate.getValue();
    if (terminateVal === null) {
      segmentsToLoadRef.setValue({
        initSegment: neededInitSegment,
        segmentQueue: neededSegments,
      });
    } else if (terminateVal.urgent) {
      log.debug("Stream", "Urgent switch, terminate now.", {
        bufferType,
        representationBitrate: content.representation.bitrate,
      });
      segmentsToLoadRef.setValue({ initSegment: null, segmentQueue: [] });
      segmentsToLoadRef.finish();
      canceller.cancel(terminateVal.reason);
      callbacks.terminating();
      return;
    } else {
      // Non-urgent termination wanted:
      // End the download of the current media segment if pending and
      // terminate once either that request is finished or another segment
      // is wanted instead, whichever comes first.

      const mostNeededSegment = neededSegments[0];
      const initSegmentRequest = segmentQueue.getRequestedInitSegment();
      const currentSegmentRequest = segmentQueue.getRequestedMediaSegment();

      const nextQueue =
        currentSegmentRequest === null ||
        mostNeededSegment === undefined ||
        currentSegmentRequest.id !== mostNeededSegment.segment.id
          ? []
          : [mostNeededSegment];

      const nextInit = initSegmentRequest === null ? null : neededInitSegment;
      segmentsToLoadRef.setValue({
        initSegment: nextInit,
        segmentQueue: nextQueue,
      });
      if (nextQueue.length === 0 && nextInit === null) {
        log.debug("Stream", "No request left, terminate", {
          bufferType,
          representationBitrate: content.representation.bitrate,
        });
        segmentsToLoadRef.finish();
        canceller.cancel(terminateVal.reason);
        callbacks.terminating();
        return;
      }
    }

    callbacks.streamStatusUpdate({
      period,
      position: observation.position.getWanted(),
      bufferType,
      imminentDiscontinuity: status.imminentDiscontinuity,
      isEmptyStream: false,
      hasFinishedLoading: status.hasFinishedLoading,
      neededSegments: status.neededSegments,
    });
    if (canceller.signal.isCancelled()) {
      return; // previous callback has stopped loading operations by side-effect
    }
    const { UPTO_CURRENT_POSITION_CLEANUP } = config.getCurrent();
    if (status.isBufferFull) {
      const gcedPosition = Math.max(0, initialWantedTime - UPTO_CURRENT_POSITION_CLEANUP);
      if (gcedPosition > 0) {
        segmentSink.removeBuffer(0, gcedPosition).catch(onFatalBufferError);
      }
    }
    if (status.shouldRefreshManifest) {
      callbacks.needsManifestRefresh();
    }
  }

  /**
   * Process a chunk that has just been parsed by pushing it to the
   * SegmentSink and emitting the right events.
   * @param {Object} evt
   */
  function onParsedChunk(
    evt:
      | IParsedInitSegmentPayload<TSegmentDataType>
      | IParsedSegmentPayload<TSegmentDataType>,
  ): void {
    drmNotifier.onNewProtectionData(evt.protectionData);

    if (evt.segmentType === "init") {
      if (!representation.index.isInitialized() && evt.segmentList !== undefined) {
        representation.index.initialize(evt.segmentList);
      }
      initSegmentState.isLoaded = true;

      if (evt.initializationData !== null) {
        const initSegmentUniqueId = representation.uniqueId;
        initSegmentState.uniqueId = initSegmentUniqueId;
        segmentSink.declareInitSegment(initSegmentUniqueId, evt.initializationData);
        pushInitSegment(
          {
            playbackObserver,
            bufferGoal,
            content,
            initSegmentUniqueId,
            segment: evt.segment,
            segmentData: evt.initializationData,
            segmentSink,
          },
          canceller.signal,
        )
          .then((result) => {
            if (result !== null) {
              callbacks.addedSegment(result);
            }
          })
          .catch(onFatalBufferError);
      }

      // Sometimes the segment list is only known once the initialization segment
      // is parsed. Thus we immediately re-check if there's new segments to load.
      checkStatus();
      return;
    } else {
      const { inbandEvents, predictedSegments, needsManifestRefresh } = evt;
      if (predictedSegments !== undefined) {
        representation.index.addPredictedSegments(predictedSegments, evt.segment);
      }

      if (needsManifestRefresh === true) {
        callbacks.needsManifestRefresh();
        if (canceller.isUsed()) {
          return; // previous callback has stopped everything by side-effect
        }
      }
      if (inbandEvents !== undefined && inbandEvents.length > 0) {
        callbacks.inbandEvent(inbandEvents);
        if (canceller.isUsed()) {
          return; // previous callback has stopped everything by side-effect
        }
      }

      const initSegmentUniqueId = initSegmentState.uniqueId;
      pushMediaSegment(
        {
          playbackObserver,
          bufferGoal,
          content,
          initSegmentUniqueId,
          parsedSegment: evt,
          segment: evt.segment,
          segmentSink,
        },
        canceller.signal,
      )
        .then((result) => {
          if (result !== null) {
            callbacks.addedSegment(result);
          }
        })
        .catch(onFatalBufferError);
    }
  }

  /**
   * Handle Buffer-related fatal errors by cancelling everything the
   * `RepresentationStream` is doing and calling the error callback with the
   * corresponding error.
   * @param {*} err
   */
  function onFatalBufferError(err: unknown): void {
    if (canceller.isUsed() && err instanceof CancellationError) {
      // The error is linked to cancellation AND we explicitely cancelled buffer
      // operations.
      // We can thus ignore it, it is very unlikely to lead to true buffer issues.
      return;
    }
    log.warn(
      "Stream",
      "Received fatal buffer error",
      {
        bufferType,
        representationBitrate: content.representation.bitrate,
      },
      err instanceof Error ? err : null,
    );
    canceller.cancel("RepresentationStream: fatal buffer err");
    callbacks.error(err);
  }
}

/**
 * Information about the initialization segment linked to the Representation
 * which the RepresentationStream try to download segments for.
 */
interface IInitSegmentState {
  /**
   * Segment Object describing that initialization segment.
   * `null` if there's no initialization segment for that Representation.
   */
  segment: ISegment | null;
  /**
   * Unique identifier used to identify the initialization segment data, used by
   * the `SegmentSink`.
   * `null` either when it doesn't exist or when it has not been declared yet.
   */
  uniqueId: string | null;
  /** `true` if the initialization segment has been loaded and parsed. */
  isLoaded: boolean;
}
