/**
 * PlaylistLoader - delegate for media manifest/playlist loading tasks. Takes care of parsing media to internal data-models.
 *
 * Once loaded, dispatches events with parsed data-models of manifest/levels/audio/subtitle tracks.
 *
 * Uses loader(s) set in config to do actual internal loading of resource tasks.
 */

import M3U8Parser from './m3u8-parser';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { LoaderContextType, PlaylistLevelType } from '../types/loader';
import { AttrList } from '../utils/attr-list';
import {
  areCodecsMediaSourceSupported,
  sampleEntryCodesISO,
} from '../utils/codecs';
import { computeReloadInterval } from '../utils/level-helper';
import type { LevelDetails } from './level-details';
import type { LoaderConfig, RetryConfig } from '../config';
import type Hls from '../hls';
import type { NetworkComponentAPI } from '../types/component-api';
import type {
  ErrorData,
  LevelLoadingData,
  LevelsUpdatedData,
  ManifestLoadingData,
  TrackLoadingData,
} from '../types/events';
import type { Level, LevelParsed, VariableMap } from '../types/level';
import type {
  Loader,
  LoaderCallbacks,
  LoaderConfiguration,
  LoaderContext,
  LoaderResponse,
  LoaderStats,
  PlaylistContextType,
  PlaylistLoaderContext,
} from '../types/loader';
import type { MediaAttributes, MediaPlaylist } from '../types/media-playlist';
import type { NullableNetworkDetails } from '../types/network-details';

function mapContextToLevelType(
  context: PlaylistLoaderContext,
): PlaylistLevelType {
  const { type } = context;

  switch (type) {
    case LoaderContextType.AUDIO_TRACK:
      return PlaylistLevelType.AUDIO;
    case LoaderContextType.SUBTITLE_TRACK:
      return PlaylistLevelType.SUBTITLE;
    default:
      return PlaylistLevelType.MAIN;
  }
}

function getResponseUrl(
  response: LoaderResponse,
  context: PlaylistLoaderContext,
): string {
  let url = response.url;
  // responseURL not supported on some browsers (it is used to detect URL redirection)
  // data-uri mode also not supported (but no need to detect redirection)
  if (url === undefined || url.indexOf('data:') === 0) {
    // fallback to initial URL
    url = context.url;
  }
  return url;
}

class PlaylistLoader implements NetworkComponentAPI {
  private readonly hls: Hls;
  private readonly loaders: {
    [key: string]: Loader<LoaderContext>;
  } = Object.create(null);
  private variableList: VariableMap | null = null;
  public onManifestLoaded = this.checkAutostartLoad;

  constructor(hls: Hls) {
    this.hls = hls;
    this.registerListeners();
  }

  public startLoad(startPosition: number): void {}

  public stopLoad(): void {
    this.destroyInternalLoaders();
  }

  private registerListeners() {
    const { hls } = this;
    hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
    hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
    hls.on(Events.AUDIO_TRACK_LOADING, this.onAudioTrackLoading, this);
    hls.on(Events.SUBTITLE_TRACK_LOADING, this.onSubtitleTrackLoading, this);
    hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
  }

  private unregisterListeners() {
    const { hls } = this;
    hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
    hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
    hls.off(Events.AUDIO_TRACK_LOADING, this.onAudioTrackLoading, this);
    hls.off(Events.SUBTITLE_TRACK_LOADING, this.onSubtitleTrackLoading, this);
    hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
  }

  /**
   * Returns defaults or configured loader-type overloads (pLoader and loader config params)
   */
  private createInternalLoader(
    context: PlaylistLoaderContext,
  ): Loader<LoaderContext> {
    const config = this.hls.config;
    const PLoader = config.pLoader;
    const Loader = config.loader;
    const InternalLoader = PLoader || Loader;
    const loader = new InternalLoader(config) as Loader<PlaylistLoaderContext>;

    this.loaders[context.type] = loader;
    return loader;
  }

  private getInternalLoader(
    context: PlaylistLoaderContext,
  ): Loader<LoaderContext> | undefined {
    return this.loaders[context.type];
  }

  private resetInternalLoader(contextType: PlaylistContextType): void {
    if (this.loaders[contextType]) {
      delete this.loaders[contextType];
    }
  }

  /**
   * Call `destroy` on all internal loader instances mapped (one per context type)
   */
  private destroyInternalLoaders(): void {
    for (const contextType in this.loaders) {
      const loader = this.loaders[contextType];
      if (loader) {
        loader.destroy();
      }

      this.resetInternalLoader(contextType as PlaylistContextType);
    }
  }

  public destroy(): void {
    this.variableList = null;
    this.unregisterListeners();
    this.destroyInternalLoaders();
  }

  private onManifestLoading(
    event: Events.MANIFEST_LOADING,
    data: ManifestLoadingData,
  ) {
    const { url } = data;
    this.variableList = null;
    this.load({
      id: null,
      level: 0,
      responseType: 'text',
      type: LoaderContextType.MANIFEST,
      url,
      deliveryDirectives: null,
      levelOrTrack: null,
    });
  }

  private onLevelLoading(event: Events.LEVEL_LOADING, data: LevelLoadingData) {
    const { id, level, pathwayId, url, deliveryDirectives, levelInfo } = data;
    this.load({
      id,
      level,
      pathwayId,
      responseType: 'text',
      type: LoaderContextType.LEVEL,
      url,
      deliveryDirectives,
      levelOrTrack: levelInfo,
    });
  }

  private onAudioTrackLoading(
    event: Events.AUDIO_TRACK_LOADING,
    data: TrackLoadingData,
  ) {
    const { id, groupId, url, deliveryDirectives, track } = data;
    this.load({
      id,
      groupId,
      level: null,
      responseType: 'text',
      type: LoaderContextType.AUDIO_TRACK,
      url,
      deliveryDirectives,
      levelOrTrack: track,
    });
  }

  private onSubtitleTrackLoading(
    event: Events.SUBTITLE_TRACK_LOADING,
    data: TrackLoadingData,
  ) {
    const { id, groupId, url, deliveryDirectives, track } = data;
    this.load({
      id,
      groupId,
      level: null,
      responseType: 'text',
      type: LoaderContextType.SUBTITLE_TRACK,
      url,
      deliveryDirectives,
      levelOrTrack: track,
    });
  }

  private onLevelsUpdated(
    event: Events.LEVELS_UPDATED,
    data: LevelsUpdatedData,
  ) {
    // abort and delete loader of removed levels
    const loader = this.loaders[LoaderContextType.LEVEL];
    if (loader) {
      const context = loader.context;
      if (
        context &&
        !data.levels.some(
          (lvl) => lvl === (context as PlaylistLoaderContext).levelOrTrack,
        )
      ) {
        loader.abort();
        delete this.loaders[LoaderContextType.LEVEL];
      }
    }
  }

  private load(context: PlaylistLoaderContext): void {
    const config = this.hls.config;

    // logger.debug(`[playlist-loader]: Loading playlist of type ${context.type}, level: ${context.level}, id: ${context.id}`);

    // Check if a loader for this context already exists
    let loader = this.getInternalLoader(context);
    if (loader) {
      const logger = this.hls.logger;
      const loaderContext = loader.context as PlaylistLoaderContext;
      if (
        loaderContext?.levelOrTrack === context.levelOrTrack &&
        (loaderContext.url === context.url ||
          (loaderContext.deliveryDirectives && !context.deliveryDirectives))
      ) {
        // same URL can't overlap, or wait for blocking request
        if (loaderContext.url === context.url) {
          logger.log(
            `[playlist-loader]: ignore ${context.url} ongoing request`,
          );
        } else {
          logger.log(
            `[playlist-loader]: ignore ${context.url} in favor of ${loaderContext.url}`,
          );
        }
        return;
      }
      logger.log(
        `[playlist-loader]: aborting previous loader for type: ${context.type}`,
      );
      loader.abort();
    }

    // apply different configs for retries depending on
    // context (manifest, level, audio/subs playlist)
    let loadPolicy: LoaderConfig;
    if (context.type === LoaderContextType.MANIFEST) {
      loadPolicy = config.manifestLoadPolicy.default;
    } else {
      loadPolicy = Object.assign({}, config.playlistLoadPolicy.default, {
        timeoutRetry: null,
        errorRetry: null,
      });
    }
    loader = this.createInternalLoader(context);

    // Override level/track timeout for LL-HLS requests
    // (the default of 10000ms is counter productive to blocking playlist reload requests)
    if (Number.isFinite(context.deliveryDirectives?.part)) {
      let levelDetails: LevelDetails | undefined;
      if (context.type === LoaderContextType.LEVEL && context.level !== null) {
        levelDetails = this.hls.levels[context.level].details;
      } else if (
        context.type === LoaderContextType.AUDIO_TRACK &&
        context.id !== null
      ) {
        levelDetails = this.hls.audioTracks[context.id].details;
      } else if (
        context.type === LoaderContextType.SUBTITLE_TRACK &&
        context.id !== null
      ) {
        levelDetails = this.hls.subtitleTracks[context.id].details;
      }
      if (levelDetails) {
        const partTarget = levelDetails.partTarget;
        const targetDuration = levelDetails.targetduration;
        if (partTarget && targetDuration) {
          const maxLowLatencyPlaylistRefresh =
            Math.max(partTarget * 3, targetDuration * 0.8) * 1000;
          loadPolicy = Object.assign({}, loadPolicy, {
            maxTimeToFirstByteMs: Math.min(
              maxLowLatencyPlaylistRefresh,
              loadPolicy.maxTimeToFirstByteMs,
            ),
            maxLoadTimeMs: Math.min(
              maxLowLatencyPlaylistRefresh,
              loadPolicy.maxTimeToFirstByteMs,
            ),
          });
        }
      }
    }

    const legacyRetryCompatibility: RetryConfig | Record<string, void> =
      loadPolicy.errorRetry || loadPolicy.timeoutRetry || {};
    const loaderConfig: LoaderConfiguration = {
      loadPolicy,
      timeout: loadPolicy.maxLoadTimeMs,
      maxRetry: legacyRetryCompatibility.maxNumRetry || 0,
      retryDelay: legacyRetryCompatibility.retryDelayMs || 0,
      maxRetryDelay: legacyRetryCompatibility.maxRetryDelayMs || 0,
    };

    const loaderCallbacks: LoaderCallbacks<PlaylistLoaderContext> = {
      onSuccess: (response, stats, context, networkDetails) => {
        const loader = this.getInternalLoader(context) as
          | Loader<PlaylistLoaderContext>
          | undefined;
        this.resetInternalLoader(context.type);

        const string = response.data as string;

        stats.parsing.start = performance.now();
        if (
          M3U8Parser.isMediaPlaylist(string) ||
          context.type !== LoaderContextType.MANIFEST
        ) {
          this.handleTrackOrLevelPlaylist(
            response,
            stats,
            context,
            networkDetails || null,
            loader,
          );
        } else {
          this.handleMasterPlaylist(response, stats, context, networkDetails);
        }
      },
      onError: (response, context, networkDetails, stats) => {
        this.handleNetworkError(
          context,
          networkDetails,
          false,
          response,
          stats,
        );
      },
      onTimeout: (stats, context, networkDetails) => {
        this.handleNetworkError(
          context,
          networkDetails,
          true,
          undefined,
          stats,
        );
      },
    };

    // logger.debug(`[playlist-loader]: Calling internal loader delegate for URL: ${context.url}`);

    loader.load(context, loaderConfig, loaderCallbacks);
  }

  private checkAutostartLoad() {
    if (!this.hls) {
      return;
    }
    const {
      config: { autoStartLoad, startPosition },
      forceStartLoad,
    } = this.hls;
    if (autoStartLoad || forceStartLoad) {
      this.hls.logger.log(
        `${autoStartLoad ? 'auto' : 'force'} startLoad with configured startPosition ${startPosition}`,
      );
      this.hls.startLoad(startPosition);
    }
  }

  private handleMasterPlaylist(
    response: LoaderResponse,
    stats: LoaderStats,
    context: PlaylistLoaderContext,
    networkDetails: NullableNetworkDetails,
  ): void {
    const hls = this.hls;
    const string = response.data as string;

    const url = getResponseUrl(response, context);

    const parsedResult = M3U8Parser.parseMasterPlaylist(string, url);

    if (parsedResult.playlistParsingError) {
      stats.parsing.end = performance.now();
      this.handleManifestParsingError(
        response,
        context,
        parsedResult.playlistParsingError,
        networkDetails,
        stats,
      );
      return;
    }

    const {
      contentSteering,
      levels,
      iframeVariants,
      sessionData,
      sessionKeys,
      startTimeOffset,
      variableList,
    } = parsedResult;

    this.variableList = variableList;

    // Treat unknown codec as audio or video codec based on passing `isTypeSupported` check
    // (allows for playback of any supported codec even if not indexed in utils/codecs)
    levels.forEach((levelParsed: LevelParsed) => {
      const { unknownCodecs } = levelParsed;
      if (unknownCodecs) {
        const { preferManagedMediaSource } = this.hls.config;
        let { audioCodec, videoCodec } = levelParsed;
        for (let i = unknownCodecs.length; i--; ) {
          const unknownCodec = unknownCodecs[i];
          if (
            areCodecsMediaSourceSupported(
              unknownCodec,
              'audio',
              preferManagedMediaSource,
            )
          ) {
            levelParsed.audioCodec = audioCodec = audioCodec
              ? `${audioCodec},${unknownCodec}`
              : unknownCodec;
            sampleEntryCodesISO.audio[audioCodec.substring(0, 4)] = 2;
            unknownCodecs.splice(i, 1);
          } else if (
            areCodecsMediaSourceSupported(
              unknownCodec,
              'video',
              preferManagedMediaSource,
            )
          ) {
            levelParsed.videoCodec = videoCodec = videoCodec
              ? `${videoCodec},${unknownCodec}`
              : unknownCodec;
            sampleEntryCodesISO.video[videoCodec.substring(0, 4)] = 2;
            unknownCodecs.splice(i, 1);
          }
        }
      }
    });

    const {
      AUDIO: audioTracks = [],
      SUBTITLES: subtitles,
      'CLOSED-CAPTIONS': captions,
    } = M3U8Parser.parseMasterPlaylistMedia(string, url, parsedResult);

    if (audioTracks.length) {
      // check if we have found an audio track embedded in main playlist (audio track without URI attribute)
      const embeddedAudioFound: boolean = audioTracks.some(
        (audioTrack) => !audioTrack.url,
      );

      // if no embedded audio track defined, but audio codec signaled in quality level,
      // we need to signal this main audio track this could happen with playlists with
      // alt audio rendition in which quality levels (main)
      // contains both audio+video. but with mixed audio track not signaled
      if (
        !embeddedAudioFound &&
        levels[0].audioCodec &&
        !levels[0].attrs.AUDIO
      ) {
        this.hls.logger.log(
          '[playlist-loader]: audio codec signaled in quality level, but no embedded audio track signaled, create one',
        );
        audioTracks.unshift({
          type: 'main',
          name: 'main',
          groupId: 'main',
          default: false,
          autoselect: false,
          forced: false,
          id: -1,
          attrs: new AttrList({}) as MediaAttributes,
          bitrate: 0,
          url: '',
        });
      }
    }
    hls.trigger(Events.MANIFEST_LOADED, {
      levels,
      audioTracks,
      subtitles,
      iframeVariants,
      captions,
      contentSteering,
      url,
      stats,
      networkDetails,
      sessionData,
      sessionKeys,
      startTimeOffset,
      variableList,
    });
  }

  private handleTrackOrLevelPlaylist(
    response: LoaderResponse,
    stats: LoaderStats,
    context: PlaylistLoaderContext,
    networkDetails: NullableNetworkDetails,
    loader: Loader<PlaylistLoaderContext> | undefined,
  ): void {
    const hls = this.hls;
    const { id, level, type } = context;

    const url = getResponseUrl(response, context);
    const levelId = Number.isFinite(level as number)
      ? (level as number)
      : Number.isFinite(id as number)
        ? (id as number)
        : 0;
    const levelType = mapContextToLevelType(context);
    const levelDetails = M3U8Parser.parseLevelPlaylist(
      response.data as string,
      url,
      levelId,
      levelType,
      0,
      this.variableList,
    );

    // We have done our first request (Manifest-type) and receive
    // not a master playlist but a chunk-list (track/level)
    // We fire the manifest-loaded event anyway with the parsed level-details
    // by creating a single-level structure for it.
    if (type === LoaderContextType.MANIFEST) {
      const singleLevel: LevelParsed = {
        attrs: new AttrList({}),
        bitrate: 0,
        details: levelDetails,
        name: '',
        url,
      };
      levelDetails.requestScheduled =
        stats.loading.start + computeReloadInterval(levelDetails, 0);

      hls.trigger(Events.MANIFEST_LOADED, {
        levels: [singleLevel],
        audioTracks: [],
        iframeVariants: [],
        url,
        stats,
        networkDetails,
        sessionData: null,
        sessionKeys: null,
        contentSteering: null,
        startTimeOffset: null,
        variableList: null,
      });
    }

    // save parsing time
    stats.parsing.end = performance.now();

    // extend the context with the new levelDetails property
    context.levelDetails = levelDetails;

    this.handlePlaylistLoaded(
      levelDetails,
      response,
      stats,
      context,
      networkDetails,
      loader,
    );
  }

  private handleManifestParsingError(
    response: LoaderResponse,
    context: PlaylistLoaderContext,
    error: Error,
    networkDetails: NullableNetworkDetails,
    stats: LoaderStats,
  ): void {
    this.hls.trigger(Events.ERROR, {
      type: ErrorTypes.NETWORK_ERROR,
      details: ErrorDetails.MANIFEST_PARSING_ERROR,
      fatal: context.type === LoaderContextType.MANIFEST,
      url: response.url,
      err: error,
      error,
      reason: error.message,
      response,
      context,
      networkDetails,
      stats,
    });
  }

  private handleNetworkError(
    context: PlaylistLoaderContext,
    networkDetails: NullableNetworkDetails,
    timeout = false,
    response: { code: number; text: string } | undefined,
    stats: LoaderStats,
  ): void {
    let message = `A network ${
      timeout
        ? 'timeout'
        : 'error' + (response ? ' (status ' + response.code + ')' : '')
    } occurred while loading ${context.type}`;
    if (context.type === LoaderContextType.LEVEL) {
      message += `: ${context.level} id: ${context.id}`;
    } else if (
      context.type === LoaderContextType.AUDIO_TRACK ||
      context.type === LoaderContextType.SUBTITLE_TRACK
    ) {
      message += ` id: ${context.id} group-id: "${context.groupId}"`;
    }
    const error = new Error(message);
    this.hls.logger.warn(`[playlist-loader]: ${message}`);
    let details = ErrorDetails.UNKNOWN;
    let fatal = false;

    const loader = this.getInternalLoader(context);

    switch (context.type) {
      case LoaderContextType.MANIFEST:
        details = timeout
          ? ErrorDetails.MANIFEST_LOAD_TIMEOUT
          : ErrorDetails.MANIFEST_LOAD_ERROR;
        fatal = true;
        break;
      case LoaderContextType.LEVEL:
        details = timeout
          ? ErrorDetails.LEVEL_LOAD_TIMEOUT
          : ErrorDetails.LEVEL_LOAD_ERROR;
        fatal = false;
        break;
      case LoaderContextType.AUDIO_TRACK:
        details = timeout
          ? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT
          : ErrorDetails.AUDIO_TRACK_LOAD_ERROR;
        fatal = false;
        break;
      case LoaderContextType.SUBTITLE_TRACK:
        details = timeout
          ? ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT
          : ErrorDetails.SUBTITLE_LOAD_ERROR;
        fatal = false;
        break;
    }

    if (loader) {
      this.resetInternalLoader(context.type);
    }

    const errorData: ErrorData = {
      type: ErrorTypes.NETWORK_ERROR,
      details,
      fatal,
      url: context.url,
      loader,
      context,
      error,
      networkDetails,
      stats,
    };

    if (response) {
      let url = context.url;
      if (networkDetails && 'url' in networkDetails) {
        url = networkDetails.url;
      }
      errorData.response = { url, data: undefined as any, ...response };
    }

    this.hls.trigger(Events.ERROR, errorData);
  }

  private handlePlaylistLoaded(
    levelDetails: LevelDetails,
    response: LoaderResponse,
    stats: LoaderStats,
    context: PlaylistLoaderContext,
    networkDetails: NullableNetworkDetails,
    loader: Loader<PlaylistLoaderContext> | undefined,
  ): void {
    const hls = this.hls;
    const { type, level, levelOrTrack, id, groupId, deliveryDirectives } =
      context;
    const url = getResponseUrl(response, context);
    const parent = mapContextToLevelType(context);
    let levelIndex =
      typeof context.level === 'number' && parent === PlaylistLevelType.MAIN
        ? (level as number)
        : undefined;
    if (
      __USE_IFRAMES__ &&
      levelOrTrack &&
      'iframes' in levelOrTrack &&
      levelOrTrack.iframes &&
      !levelDetails.iframesOnly
    ) {
      levelDetails.playlistParsingError = new Error(
        `EXT-X-I-FRAME-STREAM-INF media playlist MUST contain an EXT-X-I-FRAMES-ONLY tag`,
      );
      levelDetails.iframesOnly = true;
    }
    const error = levelDetails.playlistParsingError;
    if (error) {
      this.hls.logger.warn(`${error} ${levelDetails.url}`);
      if (!hls.config.ignorePlaylistParsingErrors) {
        hls.trigger(Events.ERROR, {
          type: ErrorTypes.NETWORK_ERROR,
          details: ErrorDetails.LEVEL_PARSING_ERROR,
          fatal: false,
          url,
          error,
          reason: error.message,
          response,
          context,
          level: levelIndex,
          parent,
          networkDetails,
          stats,
        });
        return;
      }
      levelDetails.playlistParsingError = null;
    }
    if (!levelDetails.fragments.length) {
      const error = (levelDetails.playlistParsingError = new Error(
        'No Segments found in Playlist',
      ));
      hls.trigger(Events.ERROR, {
        type: ErrorTypes.NETWORK_ERROR,
        details: ErrorDetails.LEVEL_EMPTY_ERROR,
        fatal: false,
        url,
        error,
        reason: error.message,
        response,
        context,
        level: levelIndex,
        parent,
        networkDetails,
        stats,
      });
      return;
    }

    if (levelDetails.live && loader) {
      if (loader.getCacheAge) {
        levelDetails.ageHeader = loader.getCacheAge() || 0;
      }
      if (!loader.getCacheAge || isNaN(levelDetails.ageHeader)) {
        levelDetails.ageHeader = 0;
      }
    }

    switch (type) {
      case LoaderContextType.MANIFEST:
      case LoaderContextType.LEVEL:
        if (levelIndex) {
          if (!levelOrTrack) {
            // fall-through to hls.levels[0]
            levelIndex = 0;
          } else {
            if (levelOrTrack !== hls.levels[levelIndex]) {
              // correct levelIndex when lower levels were removed from hls.levels
              const updatedIndex = hls.levels.indexOf(levelOrTrack as Level);
              if (updatedIndex > -1) {
                levelIndex = updatedIndex;
              }
            }
          }
        }
        hls.trigger(Events.LEVEL_LOADED, {
          details: levelDetails,
          levelInfo: (levelOrTrack as Level | null) || hls.levels[0],
          level: levelIndex || 0,
          id: id || 0,
          stats,
          networkDetails,
          deliveryDirectives,
          withoutMultiVariant: type === LoaderContextType.MANIFEST,
          context,
        });
        break;
      case LoaderContextType.AUDIO_TRACK:
        hls.trigger(Events.AUDIO_TRACK_LOADED, {
          details: levelDetails,
          track: levelOrTrack as MediaPlaylist,
          id: id || 0,
          groupId: groupId || '',
          stats,
          networkDetails,
          deliveryDirectives,
          context,
        });
        break;
      case LoaderContextType.SUBTITLE_TRACK:
        hls.trigger(Events.SUBTITLE_TRACK_LOADED, {
          details: levelDetails,
          track: levelOrTrack as MediaPlaylist,
          id: id || 0,
          groupId: groupId || '',
          stats,
          networkDetails,
          deliveryDirectives,
          context,
        });
        break;
    }
  }
}

export default PlaylistLoader;
