import { codecsSetSelectionPreferenceValue } from './codecs';
import { getVideoSelectionOptions } from './hdr';
import { logger } from './logger';
import { stringify } from './safe-json-stringify';
import type Hls from '../hls';
import type { Level, VideoRange } from '../types/level';
import type {
  AudioSelectionOption,
  MediaPlaylist,
  SubtitleSelectionOption,
  VideoSelectionOption,
} from '../types/media-playlist';

export type CodecSetTier = {
  minBitrate: number;
  minHeight: number;
  minFramerate: number;
  minIndex: number;
  maxScore: number;
  videoRanges: Record<string, number>;
  channels: Record<string, number>;
  hasDefaultAudio: boolean;
  fragmentError: number;
};

type AudioTrackGroup = {
  tracks: MediaPlaylist[];
  channels: Record<string, number>;
  hasDefault: boolean;
  hasAutoSelect: boolean;
};
type StartParameters = {
  codecSet: string | undefined;
  videoRanges: Array<VideoRange>;
  preferHDR: boolean;
  minFramerate: number;
  minBitrate: number;
  minIndex: number;
};

export function getStartCodecTier(
  codecTiers: Record<string, CodecSetTier>,
  currentVideoRange: VideoRange | undefined,
  currentBw: number,
  audioPreference: AudioSelectionOption | undefined,
  videoPreference: VideoSelectionOption | undefined,
): StartParameters {
  const codecSets = Object.keys(codecTiers);
  const channelsPreference = audioPreference?.channels;
  const audioCodecPreference = audioPreference?.audioCodec;
  const videoCodecPreference = videoPreference?.videoCodec;
  const preferStereo = channelsPreference && parseInt(channelsPreference) === 2;
  // Use first level set to determine stereo, and minimum resolution and framerate
  let hasStereo = false;
  let hasCurrentVideoRange = false;
  let minHeight = Infinity;
  let minFramerate = Infinity;
  let minBitrate = Infinity;
  let minIndex = Infinity;
  let selectedScore = 0;
  let videoRanges: Array<VideoRange> = [];

  const { preferHDR, allowedVideoRanges } = getVideoSelectionOptions(
    currentVideoRange,
    videoPreference,
  );

  for (let i = codecSets.length; i--; ) {
    const tier = codecTiers[codecSets[i]];
    hasStereo ||= tier.channels[2] > 0;
    minHeight = Math.min(minHeight, tier.minHeight);
    minFramerate = Math.min(minFramerate, tier.minFramerate);
    minBitrate = Math.min(minBitrate, tier.minBitrate);
    const matchingVideoRanges = allowedVideoRanges.filter(
      (range) => tier.videoRanges[range] > 0,
    );
    if (matchingVideoRanges.length > 0) {
      hasCurrentVideoRange = true;
    }
  }
  minHeight = Number.isFinite(minHeight) ? minHeight : 0;
  minFramerate = Number.isFinite(minFramerate) ? minFramerate : 0;
  const maxHeight = Math.max(1080, minHeight);
  const maxFramerate = Math.max(30, minFramerate);
  minBitrate = Number.isFinite(minBitrate) ? minBitrate : currentBw;
  currentBw = Math.max(minBitrate, currentBw);
  // If there are no variants with matching preference, set currentVideoRange to undefined
  if (!hasCurrentVideoRange) {
    currentVideoRange = undefined;
  }
  const hasMultipleSets = codecSets.length > 1;
  const codecSet = codecSets.reduce(
    (selected: string | undefined, candidate: string) => {
      // Remove candiates which do not meet bitrate, default audio, stereo or channels preference, 1080p or lower, 30fps or lower, or SDR/HDR selection if present
      const candidateTier = codecTiers[candidate];
      if (candidate === selected) {
        return selected;
      }
      videoRanges = hasCurrentVideoRange
        ? allowedVideoRanges.filter(
            (range) => candidateTier.videoRanges[range] > 0,
          )
        : [];
      if (hasMultipleSets) {
        if (candidateTier.minBitrate > currentBw) {
          logStartCodecCandidateIgnored(
            candidate,
            `min bitrate of ${candidateTier.minBitrate} > current estimate of ${currentBw}`,
          );
          return selected;
        }
        if (!candidateTier.hasDefaultAudio) {
          logStartCodecCandidateIgnored(
            candidate,
            `no renditions with default or auto-select sound found`,
          );
          return selected;
        }
        if (
          audioCodecPreference &&
          candidate.indexOf(audioCodecPreference.substring(0, 4)) % 5 !== 0
        ) {
          logStartCodecCandidateIgnored(
            candidate,
            `audio codec preference "${audioCodecPreference}" not found`,
          );
          return selected;
        }
        if (channelsPreference && !preferStereo) {
          if (!candidateTier.channels[channelsPreference]) {
            logStartCodecCandidateIgnored(
              candidate,
              `no renditions with ${channelsPreference} channel sound found (channels options: ${Object.keys(
                candidateTier.channels,
              )})`,
            );
            return selected;
          }
        } else if (
          (!audioCodecPreference || preferStereo) &&
          hasStereo &&
          candidateTier.channels['2'] === 0
        ) {
          logStartCodecCandidateIgnored(
            candidate,
            `no renditions with stereo sound found`,
          );
          return selected;
        }
        if (candidateTier.minHeight > maxHeight) {
          logStartCodecCandidateIgnored(
            candidate,
            `min resolution of ${candidateTier.minHeight} > maximum of ${maxHeight}`,
          );
          return selected;
        }
        if (candidateTier.minFramerate > maxFramerate) {
          logStartCodecCandidateIgnored(
            candidate,
            `min framerate of ${candidateTier.minFramerate} > maximum of ${maxFramerate}`,
          );
          return selected;
        }
        if (
          !videoRanges.some((range) => candidateTier.videoRanges[range] > 0)
        ) {
          logStartCodecCandidateIgnored(
            candidate,
            `no variants with VIDEO-RANGE of ${stringify(videoRanges)} found`,
          );
          return selected;
        }
        if (
          videoCodecPreference &&
          candidate.indexOf(videoCodecPreference.substring(0, 4)) % 5 !== 0
        ) {
          logStartCodecCandidateIgnored(
            candidate,
            `video codec preference "${videoCodecPreference}" not found`,
          );
          return selected;
        }
        if (candidateTier.maxScore < selectedScore) {
          logStartCodecCandidateIgnored(
            candidate,
            `max score of ${candidateTier.maxScore} < selected max of ${selectedScore}`,
          );
          return selected;
        }
      }
      // Remove candiates with less preferred codecs or more errors
      if (
        selected &&
        (codecsSetSelectionPreferenceValue(candidate) >=
          codecsSetSelectionPreferenceValue(selected) ||
          candidateTier.fragmentError > codecTiers[selected].fragmentError)
      ) {
        return selected;
      }
      minIndex = candidateTier.minIndex;
      selectedScore = candidateTier.maxScore;
      return candidate;
    },
    undefined,
  );
  return {
    codecSet,
    videoRanges,
    preferHDR,
    minFramerate,
    minBitrate,
    minIndex,
  };
}

function logStartCodecCandidateIgnored(codeSet: string, reason: string) {
  logger.log(
    `[abr] start candidates with "${codeSet}" ignored because ${reason}`,
  );
}

export type AudioTracksByGroup = {
  hasDefaultAudio: boolean;
  hasAutoSelectAudio: boolean;
  groups: Record<string, AudioTrackGroup>;
};

export function getAudioTracksByGroup(allAudioTracks: MediaPlaylist[]) {
  return allAudioTracks.reduce(
    (audioTracksByGroup: AudioTracksByGroup, track) => {
      let trackGroup = audioTracksByGroup.groups[track.groupId];
      if (!trackGroup) {
        trackGroup = audioTracksByGroup.groups[track.groupId] = {
          tracks: [],
          channels: { 2: 0 },
          hasDefault: false,
          hasAutoSelect: false,
        };
      }
      trackGroup.tracks.push(track);
      const channelsKey = track.channels || '2';
      trackGroup.channels[channelsKey] =
        (trackGroup.channels[channelsKey] || 0) + 1;
      trackGroup.hasDefault = trackGroup.hasDefault || track.default;
      trackGroup.hasAutoSelect = trackGroup.hasAutoSelect || track.autoselect;
      if (trackGroup.hasDefault) {
        audioTracksByGroup.hasDefaultAudio = true;
      }
      if (trackGroup.hasAutoSelect) {
        audioTracksByGroup.hasAutoSelectAudio = true;
      }
      return audioTracksByGroup;
    },
    {
      hasDefaultAudio: false,
      hasAutoSelectAudio: false,
      groups: {},
    },
  );
}

export function getCodecTiers(
  levels: Level[],
  audioTracksByGroup: AudioTracksByGroup,
  minAutoLevel: number,
  maxAutoLevel: number,
): Record<string, CodecSetTier> {
  return levels
    .slice(minAutoLevel, maxAutoLevel + 1)
    .reduce((tiers: Record<string, CodecSetTier>, level, index) => {
      if (!level.codecSet) {
        return tiers;
      }
      const audioGroups = level.audioGroups;
      let tier = tiers[level.codecSet];
      if (!tier) {
        tiers[level.codecSet] = tier = {
          minBitrate: Infinity,
          minHeight: Infinity,
          minFramerate: Infinity,
          minIndex: index,
          maxScore: 0,
          videoRanges: { SDR: 0 },
          channels: { '2': 0 },
          hasDefaultAudio: !audioGroups,
          fragmentError: 0,
        };
      }
      tier.minBitrate = Math.min(tier.minBitrate, level.bitrate);
      const lesserWidthOrHeight = Math.min(level.height, level.width);
      tier.minHeight = Math.min(tier.minHeight, lesserWidthOrHeight);
      tier.minFramerate = Math.min(tier.minFramerate, level.frameRate);
      tier.minIndex = Math.min(tier.minIndex, index);
      tier.maxScore = Math.max(tier.maxScore, level.score);
      tier.fragmentError += level.fragmentError;
      tier.videoRanges[level.videoRange] =
        (tier.videoRanges[level.videoRange] || 0) + 1;
      if (__USE_ALT_AUDIO__ && audioGroups) {
        audioGroups.forEach((audioGroupId) => {
          if (!audioGroupId) {
            return;
          }
          const audioGroup = audioTracksByGroup.groups[audioGroupId];
          if (!audioGroup) {
            return;
          }
          // Default audio is any group with DEFAULT=YES, or if missing then any group with AUTOSELECT=YES, or all variants
          tier.hasDefaultAudio =
            tier.hasDefaultAudio || audioTracksByGroup.hasDefaultAudio
              ? audioGroup.hasDefault
              : audioGroup.hasAutoSelect ||
                (!audioTracksByGroup.hasDefaultAudio &&
                  !audioTracksByGroup.hasAutoSelectAudio);
          Object.keys(audioGroup.channels).forEach((channels) => {
            tier.channels[channels] =
              (tier.channels[channels] || 0) + audioGroup.channels[channels];
          });
        });
      }
      return tiers;
    }, {});
}

export function getBasicSelectionOption(
  option:
    | MediaPlaylist
    | AudioSelectionOption
    | SubtitleSelectionOption
    | undefined,
): Partial<AudioSelectionOption | SubtitleSelectionOption> | undefined {
  if (!option) {
    return option;
  }
  const { lang, assocLang, characteristics, channels, audioCodec } =
    option as AudioSelectionOption;
  return { lang, assocLang, characteristics, channels, audioCodec };
}

export function findMatchingOption(
  option: MediaPlaylist | AudioSelectionOption | SubtitleSelectionOption,
  tracks: MediaPlaylist[],
  matchPredicate?: (
    option: MediaPlaylist | AudioSelectionOption | SubtitleSelectionOption,
    track: MediaPlaylist,
  ) => boolean,
): number {
  if ('attrs' in option) {
    const index = tracks.indexOf(option);
    if (index !== -1) {
      return index;
    }
  }
  for (let i = 0; i < tracks.length; i++) {
    const track = tracks[i];
    if (matchesOption(option, track, matchPredicate)) {
      return i;
    }
  }
  return -1;
}

export function matchesOption(
  option: MediaPlaylist | AudioSelectionOption | SubtitleSelectionOption,
  track: MediaPlaylist,
  matchPredicate?: (
    option: MediaPlaylist | AudioSelectionOption | SubtitleSelectionOption,
    track: MediaPlaylist,
  ) => boolean,
): boolean {
  const { groupId, name, lang, assocLang, default: isDefault } = option;
  const forced = (option as SubtitleSelectionOption).forced;
  return (
    (groupId === undefined || track.groupId === groupId) &&
    (name === undefined || track.name === name) &&
    (lang === undefined || languagesMatch(lang, track.lang)) &&
    (lang === undefined || track.assocLang === assocLang) &&
    (isDefault === undefined || track.default === isDefault) &&
    (forced === undefined || track.forced === forced) &&
    (!('characteristics' in option) ||
      characteristicsMatch(
        option.characteristics || '',
        track.characteristics,
      )) &&
    (matchPredicate === undefined || matchPredicate(option, track))
  );
}

function languagesMatch(languageA: string, languageB: string = '--'): boolean {
  if (languageA.length === languageB.length) {
    return languageA === languageB;
  }
  return languageA.startsWith(languageB) || languageB.startsWith(languageA);
}

function characteristicsMatch(
  characteristicsA: string,
  characteristicsB: string = '',
): boolean {
  const arrA = characteristicsA.split(',');
  const arrB = characteristicsB.split(',');
  // Expects each item to be unique:
  return (
    arrA.length === arrB.length && !arrA.some((el) => arrB.indexOf(el) === -1)
  );
}

export function audioMatchPredicate(
  option: MediaPlaylist | AudioSelectionOption,
  track: MediaPlaylist,
) {
  const { audioCodec, channels } = option;
  return (
    (audioCodec === undefined ||
      (track.audioCodec || '').substring(0, 4) ===
        audioCodec.substring(0, 4)) &&
    (channels === undefined || channels === (track.channels || '2'))
  );
}

export function findClosestLevelWithAudioGroup(
  option: MediaPlaylist | AudioSelectionOption,
  levels: Level[],
  allAudioTracks: MediaPlaylist[],
  searchIndex: number,
  matchPredicate: (
    option: MediaPlaylist | AudioSelectionOption,
    track: MediaPlaylist,
  ) => boolean,
): number {
  const currentLevel = levels[searchIndex];
  // Are there variants with same URI as current level?
  // If so, find a match that does not require any level URI change
  const variants = levels.reduce(
    (variantMap: { [uri: string]: number[] }, level, index) => {
      const uri = level.uri;
      const renditions = variantMap[uri] || (variantMap[uri] = []);
      renditions.push(index);
      return variantMap;
    },
    {},
  );
  const renditions = variants[currentLevel.uri];
  if (renditions.length > 1) {
    searchIndex = Math.max.apply(Math, renditions);
  }
  // Find best match
  const currentVideoRange = currentLevel.videoRange;
  const currentFrameRate = currentLevel.frameRate;
  const currentVideoCodec = currentLevel.codecSet.substring(0, 4);
  const matchingVideo = searchDownAndUpList(
    levels,
    searchIndex,
    (level: Level) => {
      if (
        level.videoRange !== currentVideoRange ||
        level.frameRate !== currentFrameRate ||
        level.codecSet.substring(0, 4) !== currentVideoCodec
      ) {
        return false;
      }
      const audioGroups = level.audioGroups;
      const tracks = allAudioTracks.filter((track): boolean =>
        inGroupOrNone(track.groupId, audioGroups),
      );
      return findMatchingOption(option, tracks, matchPredicate) > -1;
    },
  );
  if (matchingVideo > -1) {
    return matchingVideo;
  }
  return searchDownAndUpList(levels, searchIndex, (level: Level) => {
    const audioGroups = level.audioGroups;
    const tracks = allAudioTracks.filter((track): boolean =>
      inGroupOrNone(track.groupId, audioGroups),
    );
    return findMatchingOption(option, tracks, matchPredicate) > -1;
  });
}

function searchDownAndUpList(
  arr: any[],
  searchIndex: number,
  predicate: (item: any) => boolean,
): number {
  for (let i = searchIndex; i > -1; i--) {
    if (predicate(arr[i])) {
      return i;
    }
  }
  for (let i = searchIndex + 1; i < arr.length; i++) {
    if (predicate(arr[i])) {
      return i;
    }
  }
  return -1;
}

export function useAlternateAudio(
  audioTrackUrl: string | undefined,
  hls: Hls,
): boolean {
  return !!audioTrackUrl && audioTrackUrl !== hls.loadLevelObj?.uri;
}

export function inGroupOrNone(
  groupId: string | undefined,
  groupIds: (string | undefined)[] | null | undefined,
): boolean {
  if (!groupIds) {
    return true;
  }
  return groupIds.indexOf(groupId) !== -1;
}

export function getVideoPreference(
  level: Level | null | undefined,
  basePreference?: VideoSelectionOption,
): VideoSelectionOption | undefined {
  if (basePreference || level) {
    const videoPreference = Object.assign({}, basePreference);
    if (level) {
      if (level.videoCodec) {
        videoPreference.videoCodec = level.videoCodec;
      }
      if (level.videoRange) {
        videoPreference.allowedVideoRanges = [level.videoRange];
      }
    }
    return videoPreference;
  }
}
