/**
 * 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 log from "../../../../log";
import type { IManifest } from "../../../../manifest";
import flatMap from "../../../../utils/flat_map";
import idGenerator from "../../../../utils/id_generator";
import isNullOrUndefined from "../../../../utils/is_null_or_undefined";
import getMonotonicTimeStamp from "../../../../utils/monotonic_timestamp";
import objectValues from "../../../../utils/object_values";
import { utf8ToStr } from "../../../../utils/string_parsing";
import type {
  IManifestStreamEvent,
  IParsedAdaptation,
  IParsedAdaptations,
  IParsedPeriod,
} from "../../types";
import type {
  IEventStreamIntermediateRepresentation,
  IPeriodIntermediateRepresentation,
} from "../node_parser_types";
import type ContentProtectionParser from "./content_protection_parser";
import flattenOverlappingPeriods from "./flatten_overlapping_periods";
import getPeriodsTimeInformation from "./get_periods_time_infos";
import type { IAdaptationSetContext } from "./parse_adaptation_sets";
import parseAdaptationSets from "./parse_adaptation_sets";
import resolveBaseURLs from "./resolve_base_urls";

const generatePeriodID = idGenerator();

/** Information about each linked Xlink. */
export type IXLinkInfos = WeakMap<
  IPeriodIntermediateRepresentation,
  {
    /** Real URL (post-redirection) used to download this xlink. */
    url?: string | undefined;
    /** Time at which the request was sent (since the time origin), in ms. */
    sendingTime?: number | undefined;
    /** Time at which the request was received (since the time origin), in ms. */
    receivedTime?: number | undefined;
  }
>;

/**
 * Process intermediate periods to create final parsed periods.
 * @param {Array.<Object>} periodsIR
 * @param {Object} context
 * @returns {Array.<Object>}
 */
export default function parsePeriods(
  periodsIR: IPeriodIntermediateRepresentation[],
  context: IPeriodContext,
): IParsedPeriod[] {
  const parsedPeriods: IParsedPeriod[] = [];
  const periodsTimeInformation = getPeriodsTimeInformation(periodsIR, context);
  if (periodsTimeInformation.length !== periodsIR.length) {
    throw new Error("MPD parsing error: the time information are incoherent.");
  }

  const { isDynamic, manifestBoundsCalculator } = context;

  if (!isDynamic && !isNullOrUndefined(context.duration)) {
    manifestBoundsCalculator.setLastPosition(context.duration);
  }

  // We parse it in reverse because we might need to deduce the buffer depth from
  // the last Periods' indexes
  for (let i = periodsIR.length - 1; i >= 0; i--) {
    const isLastPeriod = i === periodsIR.length - 1;
    const periodIR = periodsIR[i];
    const xlinkInfos = context.xlinkInfos.get(periodIR);
    const periodBaseURLs = resolveBaseURLs(context.baseURLs, periodIR.children.baseURLs);

    const { periodStart, periodDuration, periodEnd } = periodsTimeInformation[i];

    let periodID: string;
    if (isNullOrUndefined(periodIR.attributes.id)) {
      periodID = "gen-dash-period-" + generatePeriodID();
      log.warn("dash", "No usable id found in the Period. Generating one.", {
        periodId: periodID,
      });
    } else {
      periodID = periodIR.attributes.id;
    }

    // Avoid duplicate IDs
    while (parsedPeriods.some((p) => p.id === periodID)) {
      periodID += "-dup";
    }

    const receivedTime =
      xlinkInfos !== undefined ? xlinkInfos.receivedTime : context.receivedTime;

    const unsafelyBaseOnPreviousPeriod =
      context.unsafelyBaseOnPreviousManifest?.getPeriod(periodID) ?? null;

    const availabilityTimeComplete = periodIR.attributes.availabilityTimeComplete;
    const availabilityTimeOffset = periodIR.attributes.availabilityTimeOffset;
    const { manifestProfiles, contentProtectionParser } = context;
    const { segmentTemplate } = periodIR.children;
    contentProtectionParser.addReferences(periodIR.children.contentProtections ?? []);
    const adapCtxt: IAdaptationSetContext = {
      availabilityTimeComplete,
      availabilityTimeOffset,
      baseURLs: periodBaseURLs,
      contentProtectionParser,
      manifestBoundsCalculator,
      end: periodEnd,
      isDynamic,
      isLastPeriod,
      manifestProfiles,
      receivedTime,
      segmentTemplate,
      start: periodStart,
      unsafelyBaseOnPreviousPeriod,
    };
    const { adaptations, thumbnailTracks } = parseAdaptationSets(
      periodIR.children.adaptations,
      adapCtxt,
    );

    const namespaces = (context.xmlNamespaces ?? []).concat(
      periodIR.attributes.namespaces ?? [],
    );
    const streamEvents = generateStreamEvents(
      periodIR.children.eventStreams,
      periodStart,
      namespaces,
    );
    const parsedPeriod: IParsedPeriod = {
      id: periodID,
      start: periodStart,
      end: periodEnd,
      duration: periodDuration,
      thumbnailTracks,
      adaptations,
      streamEvents,
    };
    parsedPeriods.unshift(parsedPeriod);

    if (!manifestBoundsCalculator.lastPositionIsKnown()) {
      const lastPosition = getMaximumLastPosition(adaptations);
      if (!isDynamic) {
        if (typeof lastPosition === "number") {
          manifestBoundsCalculator.setLastPosition(lastPosition);
        }
      } else {
        if (typeof lastPosition === "number") {
          const positionTime = getMonotonicTimeStamp() / 1000;
          manifestBoundsCalculator.setLastPosition(lastPosition, positionTime);
        } else {
          const guessedLastPositionFromClock = guessLastPositionFromClock(
            context,
            periodStart,
          );
          if (guessedLastPositionFromClock !== undefined) {
            const [guessedLastPosition, guessedPositionTime] =
              guessedLastPositionFromClock;
            manifestBoundsCalculator.setLastPosition(
              guessedLastPosition,
              guessedPositionTime,
            );
          }
        }
      }
    }
  }

  if (context.isDynamic && !manifestBoundsCalculator.lastPositionIsKnown()) {
    // Guess a last time the last position
    const guessedLastPositionFromClock = guessLastPositionFromClock(context, 0);
    if (guessedLastPositionFromClock !== undefined) {
      const [lastPosition, positionTime] = guessedLastPositionFromClock;
      manifestBoundsCalculator.setLastPosition(lastPosition, positionTime);
    }
  }
  return flattenOverlappingPeriods(parsedPeriods);
}

/**
 * Try to guess the "last position", which is the last position
 * available in the manifest in seconds, and the "position time", the
 * monotonically-raising timestamp used by the RxPlayer, at which the
 * last position was collected.
 *
 * These values allows to retrieve at any time in the future the new last
 * position, by substracting the position time to the last position, and
 * adding to it the new monotonically-raising timestamp.
 *
 * The last position and position time are returned by this function if and only if
 * it would indicate a last position superior to the `minimumTime` given.
 *
 * This last part allows for example to detect which Period is likely to be the
 * "current" one in multi-periods contents. By giving the Period's start as a
 * `minimumTime`, you ensure that you will get a value only if the current time
 * is in that period.
 *
 * This is useful as guessing the live time from the clock can be seen as a last
 * resort. By detecting that the current time is before the currently considered
 * Period, we can just parse and look at the previous Period. If we can guess
 * the live time more directly from that previous one, we might be better off
 * than just using the clock.
 *
 * @param {Object} context
 * @param {number} minimumTime
 * @returns {Array.<number|undefined>}
 */
function guessLastPositionFromClock(
  context: IPeriodContext,
  minimumTime: number,
): [number, number] | undefined {
  if (!isNullOrUndefined(context.clockOffset)) {
    const lastPosition = context.clockOffset / 1000 - context.availabilityStartTime;
    const positionTime = getMonotonicTimeStamp() / 1000;
    const timeInSec = positionTime + lastPosition;
    if (timeInSec >= minimumTime) {
      return [timeInSec, positionTime];
    }
  } else {
    const now = Date.now() / 1000;
    if (now >= minimumTime) {
      log.warn(
        "dash",
        "no clock synchronization mechanism found. Using the system clock instead.",
      );
      const lastPosition = now - context.availabilityStartTime;
      const positionTime = getMonotonicTimeStamp() / 1000;
      return [lastPosition, positionTime];
    }
  }
  return undefined;
}

/**
 * Try to extract the last position declared for any segments in a Period:
 *   - If at least a single index' last position is defined, take the maximum
 *     among them.
 *   - If segments are available but we cannot define the last position
 *     return undefined.
 *   - If no segment are available in that period, return null
 * @param {Object} adaptationsPerType
 * @returns {number|null|undefined}
 */
function getMaximumLastPosition(
  adaptationsPerType: IParsedAdaptations,
): number | null | undefined {
  let maxEncounteredPosition: number | null = null;
  let allIndexAreEmpty = true;
  const adaptationsVal = objectValues(adaptationsPerType).filter(
    (ada): ada is IParsedAdaptation[] => !isNullOrUndefined(ada),
  );
  const allAdaptations = flatMap(
    adaptationsVal,
    (adaptationsForType) => adaptationsForType,
  );
  for (const adaptation of allAdaptations) {
    const representations = adaptation.representations;
    for (const representation of representations) {
      const position = representation.index.getLastAvailablePosition();
      if (position !== null) {
        allIndexAreEmpty = false;
        if (typeof position === "number") {
          maxEncounteredPosition = isNullOrUndefined(maxEncounteredPosition)
            ? position
            : Math.max(maxEncounteredPosition, position);
        }
      }
    }
  }

  if (!isNullOrUndefined(maxEncounteredPosition)) {
    return maxEncounteredPosition;
  } else if (allIndexAreEmpty) {
    return null;
  }
  return undefined;
}

/**
 * Generate parsed "eventStream" objects from a `StreamEvent` node's
 * intermediate Representation.
 * @param {Array.<Object>} baseIr - The array of every encountered StreamEvent's
 * intermediate representations for a given Period.
 * @param {number} periodStart - The time in seconds at which this corresponding
 * Period starts.
 * @returns {Array.<Object>} - The parsed objects.
 */
function generateStreamEvents(
  baseIr: IEventStreamIntermediateRepresentation[],
  periodStart: number,
  xmlNamespaces: Array<{ key: string; value: string }>,
): IManifestStreamEvent[] {
  const res: IManifestStreamEvent[] = [];
  for (const eventStreamIr of baseIr) {
    const { schemeIdUri = "", timescale = 1 } = eventStreamIr.attributes;
    const allNamespaces = xmlNamespaces.concat(eventStreamIr.attributes.namespaces ?? []);

    for (const eventIr of eventStreamIr.children.events) {
      if (eventIr.eventStreamData !== undefined) {
        const start = (eventIr.presentationTime ?? 0) / timescale + periodStart;
        const end =
          eventIr.duration === undefined
            ? undefined
            : start + eventIr.duration / timescale;

        let element;
        let xmlData;
        try {
          xmlData = {
            namespaces: allNamespaces,
            data:
              typeof eventIr.eventStreamData === "string"
                ? eventIr.eventStreamData
                : utf8ToStr(new Uint8Array(eventIr.eventStreamData)),
          };
        } catch (err) {
          log.error(
            "dash",
            "Error while parsing event-stream:",
            err instanceof Error ? err.message : "Unknown error",
          );
        }
        res.push({
          start,
          end,
          id: eventIr.id,
          data: {
            type: "dash-event-stream",
            value: { schemeIdUri, timescale, element, xmlData },
          },
        });
      }
    }
  }
  return res;
}

/** Context needed when calling `parsePeriods`. */
export interface IPeriodContext extends IInheritedAdaptationContext {
  availabilityStartTime: number;
  /**
   * Difference between the server's clock, in milliseconds, and the
   * monotonically-raising timestamp used by the RxPlayer.
   */
  clockOffset?: number | undefined;
  /** Duration (mediaPresentationDuration) of the whole MPD, in seconds. */
  duration?: number | undefined;
  /**
   * The parser should take this Manifest - which is a previously parsed
   * Manifest for the same dynamic content - as a base to speed-up the parsing
   * process.
   * /!\ If unexpected differences exist between the two, there is a risk of
   * de-synchronization with what is actually on the server,
   * Use with moderation.
   */
  unsafelyBaseOnPreviousManifest: IManifest | null;
  xlinkInfos: IXLinkInfos;

  /**
   * XML namespaces linked to the `<MPD>` element.
   * May be needed to convert EventStream's Event elements back into the
   * Document form.
   */
  xmlNamespaces?: Array<{ key: string; value: string }> | undefined;
  /** Parses contentProtection elements. */
  contentProtectionParser: ContentProtectionParser;
}

type IInheritedAdaptationContext = Omit<
  IAdaptationSetContext,
  | "availabilityTimeComplete"
  | "availabilityTimeOffset"
  | "duration"
  | "isLastPeriod"
  | "start"
  | "unsafelyBaseOnPreviousPeriod"
>;
