import { UMP } from './UMP.js';
import { ChunkedDataBuffer } from './ChunkedDataBuffer.js';
import { EventEmitterLike, PART, QUALITY, base64ToU8, getFormatKey } from '../utils/index.js';

import { VideoPlaybackAbrRequest } from '../../protos/generated/video_streaming/video_playback_abr_request.js';
import { MediaHeader } from '../../protos/generated/video_streaming/media_header.js';
import { NextRequestPolicy } from '../../protos/generated/video_streaming/next_request_policy.js';
import { FormatInitializationMetadata } from '../../protos/generated/video_streaming/format_initialization_metadata.js';
import { SabrRedirect } from '../../protos/generated/video_streaming/sabr_redirect.js';
import { SabrError } from '../../protos/generated/video_streaming/sabr_error.js';
import { StreamProtectionStatus } from '../../protos/generated/video_streaming/stream_protection_status.js';
import { PlaybackCookie } from '../../protos/generated/video_streaming/playback_cookie.js';

import type { FormatId } from '../../protos/generated/misc/common.js';
import type { ClientAbrState } from '../../protos/generated/video_streaming/client_abr_state.js';
import type { FetchFunction, InitializedFormat, InitOptions, MediaArgs, ServerAbrResponse, ServerAbrStreamOptions } from '../utils/types.js';

const DEFAULT_QUALITY = QUALITY.HD720;

export class ServerAbrStream extends EventEmitterLike {
  private fetchFunction: FetchFunction;
  private serverAbrStreamingUrl: string;
  private videoPlaybackUstreamerConfig: string;
  private poToken?: string;
  private playbackCookie?: PlaybackCookie;
  private totalDurationMs: number;
  private initializedFormats: InitializedFormat[] = [];
  private formatsByKey: Map<string, InitializedFormat> = new Map();
  private headerIdToFormatKeyMap: Map<number, string> = new Map();
  private previousSequences: Map<string, number[]> = new Map();

  constructor(args: ServerAbrStreamOptions) {
    super();
    this.fetchFunction = args.fetch || fetch;
    this.serverAbrStreamingUrl = args.serverAbrStreamingUrl;
    this.videoPlaybackUstreamerConfig = args.videoPlaybackUstreamerConfig;
    this.poToken = args.poToken;
    this.totalDurationMs = args.durationMs;
  }

  public on(event: 'end', listener: (streamData: ServerAbrResponse) => void): void;
  public on(event: 'data', listener: (streamData: ServerAbrResponse) => void): void;
  public on(event: 'error', listener: (error: Error) => void): void;
  public on(event: string, listener: (...data: any[]) => void): void {
    super.on(event, listener);
  }

  public once(event: 'end', listener: (streamData: ServerAbrResponse) => void): void;
  public once(event: 'data', listener: (streamData: ServerAbrResponse) => void): void;
  public once(event: 'error', listener: (error: Error) => void): void;
  public once(event: string, listener: (...args: any[]) => void): void {
    super.once(event, listener);
  }

  /**
   * Initializes the server ABR stream with the provided options.
   * @param args - The initialization options.
   */
  public async init(args: InitOptions) {
    const { audioFormats, videoFormats, clientAbrState: initialState } = args;

    const firstVideoFormat = videoFormats ? videoFormats[0] : undefined;

    const clientAbrState: ClientAbrState = {
      lastManualDirection: 0,
      timeSinceLastManualFormatSelectionMs: 0,
      lastManualSelectedResolution: videoFormats.length === 1 ? firstVideoFormat?.height : DEFAULT_QUALITY,
      stickyResolution: videoFormats.length === 1 ? firstVideoFormat?.height : DEFAULT_QUALITY,
      playerTimeMs: 0,
      visibility: 0,
      enabledTrackTypesBitfield: 0,
      ...initialState
    };

    const audioFormatIds = audioFormats.map<FormatId>((fmt) => ({
      itag: fmt.itag,
      lastModified: parseInt(fmt.lastModified),
      xtags: fmt.xtags
    }));

    const videoFormatIds = videoFormats.map<FormatId>((fmt) => ({
      itag: fmt.itag,
      lastModified: parseInt(fmt.lastModified),
      xtags: fmt.xtags
    }));

    if (typeof clientAbrState.playerTimeMs !== 'number')
      throw new Error('Invalid media start time');

    try {
      while (clientAbrState.playerTimeMs < this.totalDurationMs) {
        const data = await this.fetchMedia({ clientAbrState, audioFormatIds, videoFormatIds });

        this.emit('data', data);

        if (data.sabrError) break;

        const mainFormat =
          clientAbrState.enabledTrackTypesBitfield === 0
            ? data.initializedFormats.find((fmt) => fmt.mimeType?.includes('video'))
            : data.initializedFormats[0];

        for (const fmt of data.initializedFormats) {
          this.previousSequences.set(fmt.formatKey, fmt.sequenceList.map((seq) => seq.sequenceNumber || 0));
        }

        if (
          !mainFormat ||
          mainFormat.sequenceCount ===
          mainFormat.sequenceList[mainFormat.sequenceList.length - 1]?.sequenceNumber
        ) {
          this.emit('end', data);
          break;
        }

        clientAbrState.playerTimeMs += mainFormat.sequenceList.reduce((acc, seq) => acc + (seq.durationMs || 0), 0);
      }
    } catch (error) {
      this.emit('error', error);
      clientAbrState.playerTimeMs = Infinity;
    }
  }

  private async fetchMedia(args: MediaArgs): Promise<ServerAbrResponse> {
    const { clientAbrState, audioFormatIds, videoFormatIds } = args;

    const body = VideoPlaybackAbrRequest.encode({
      clientAbrState: clientAbrState,
      selectedAudioFormatIds: audioFormatIds,
      selectedVideoFormatIds: videoFormatIds,
      selectedFormatIds: this.initializedFormats.map((fmt) => fmt.formatId),
      videoPlaybackUstreamerConfig: base64ToU8(this.videoPlaybackUstreamerConfig),
      streamerContext: {
        field5: [],
        field6: [],
        poToken: this.poToken ? base64ToU8(this.poToken) : undefined,
        playbackCookie: this.playbackCookie ? PlaybackCookie.encode(this.playbackCookie).finish() : undefined,
        clientInfo: {
          clientName: 1,
          clientVersion: '2.2040620.05.00',
          osName: 'Windows',
          osVersion: '10.0'
        }
      },
      bufferedRanges: this.initializedFormats.map((fmt) => fmt._state),
      field1000: []
    }).finish();

    const response = await this.fetchFunction(this.serverAbrStreamingUrl, { method: 'POST', body });
    const data = await response.arrayBuffer();

    if (response.status !== 200 || !data.byteLength)
      throw new Error(`Received an invalid response from the server: ${response.status}`);

    return this.parseUMPResponse(new Uint8Array(data));
  }

  /**
   * Parses the UMP response data and updates the initialized formats.
   * @param response - The UMP response data as a byte array.
   */
  public async parseUMPResponse(response: Uint8Array): Promise<ServerAbrResponse> {
    this.headerIdToFormatKeyMap.clear();

    this.initializedFormats.forEach((format) => {
      format.sequenceList = [];
      format.mediaChunks = [];
    });

    let sabrError: SabrError | undefined;
    let sabrRedirect: SabrRedirect | undefined;
    let streamProtectionStatus: StreamProtectionStatus | undefined;

    const ump = new UMP(new ChunkedDataBuffer([ response ]));

    ump.parse((part) => {
      const data = part.data.chunks[0];
      switch (part.type) {
        case PART.MEDIA_HEADER:
          this.processMediaHeader(data);
          break;
        case PART.MEDIA:
          this.processMediaData(part.data);
          break;
        case PART.MEDIA_END:
          this.processEndOfMedia(part.data);
          break;
        case PART.NEXT_REQUEST_POLICY:
          this.processNextRequestPolicy(data);
          break;
        case PART.FORMAT_INITIALIZATION_METADATA:
          this.processFormatInitialization(data);
          break;
        case PART.SABR_ERROR:
          sabrError = SabrError.decode(data);
          break;
        case PART.SABR_REDIRECT:
          sabrRedirect = this.processSabrRedirect(data);
          break;
        case PART.STREAM_PROTECTION_STATUS:
          streamProtectionStatus = StreamProtectionStatus.decode(data);
          break;
        default:
          break;
      }
    });

    return {
      initializedFormats: this.initializedFormats,
      streamProtectionStatus,
      sabrRedirect,
      sabrError
    };
  }

  private processMediaHeader(data: Uint8Array) {
    const mediaHeader = MediaHeader.decode(data);
    if (!mediaHeader.formatId) return;

    const formatKey = getFormatKey(mediaHeader.formatId);

    const currentFormat = this.formatsByKey.get(formatKey) || this.registerFormat(mediaHeader);
    if (!currentFormat) return;

    // FIXME: This is a hacky workaround to prevent duplicate sequences from being added. This should be fixed in the future (preferably by figuring out how to make the server not send duplicates).
    if (mediaHeader.sequenceNumber !== undefined && this.previousSequences.get(formatKey)?.includes(mediaHeader.sequenceNumber))
      return;

    // Save the header's ID so we can identify its stream data later.
    if (mediaHeader.headerId !== undefined) {
      if (!this.headerIdToFormatKeyMap.has(mediaHeader.headerId)) {
        this.headerIdToFormatKeyMap.set(mediaHeader.headerId, formatKey);
      }
    }

    if (!currentFormat.sequenceList.some((seq) => seq.sequenceNumber === (mediaHeader.sequenceNumber || 0))) {
      currentFormat.sequenceList.push({
        itag: mediaHeader.itag,
        formatId: mediaHeader.formatId,
        isInitSegment: mediaHeader.isInitSeg,
        durationMs: mediaHeader.durationMs,
        startMs: mediaHeader.startMs,
        startDataRange: mediaHeader.startDataRange,
        sequenceNumber: mediaHeader.sequenceNumber,
        contentLength: mediaHeader.contentLength,
        timeRange: mediaHeader.timeRange
      });

      if (typeof mediaHeader.sequenceNumber === 'number') {
        currentFormat._state.durationMs += mediaHeader.durationMs || 0;
        currentFormat._state.endSegmentIndex += 1;
      }
    }
  }

  private processMediaData(data: ChunkedDataBuffer) {
    const headerId = data.getUint8(0);
    const streamData = data.split(1).remainingBuffer;

    const formatKey = this.headerIdToFormatKeyMap.get(headerId);
    if (!formatKey) return;

    const currentFormat = this.formatsByKey.get(formatKey);
    if (!currentFormat) return;

    currentFormat.mediaChunks.push(streamData.chunks[0]);
  }

  private processEndOfMedia(data: ChunkedDataBuffer) {
    const headerId = data.getUint8(0);
    this.headerIdToFormatKeyMap.delete(headerId);
  }

  private processNextRequestPolicy(data: Uint8Array) {
    const nextRequestPolicy = NextRequestPolicy.decode(data);
    this.playbackCookie = nextRequestPolicy.playbackCookie;
  }

  private processFormatInitialization(data: Uint8Array) {
    const formatInitializationMetadata = FormatInitializationMetadata.decode(data);
    this.registerFormat(formatInitializationMetadata);
  }

  private processSabrRedirect(data: Uint8Array): SabrRedirect {
    const sabrRedirect = SabrRedirect.decode(data);
    if (!sabrRedirect.url) throw new Error('Invalid SABR redirect');
    this.serverAbrStreamingUrl = sabrRedirect.url;
    return sabrRedirect;
  }

  private registerFormat(data: MediaHeader | FormatInitializationMetadata): InitializedFormat | undefined {
    if (!data.formatId)
      return;

    const formatKey = getFormatKey(data.formatId);

    if (!this.formatsByKey.has(formatKey)) {
      const format: InitializedFormat = {
        formatId: data.formatId,
        formatKey: formatKey,
        durationMs: data.durationMs,
        mimeType: 'mimeType' in data ? data.mimeType : undefined,
        sequenceCount: 'field4' in data ? data.field4 : undefined,
        sequenceList: [],
        mediaChunks: [],
        _state: {
          formatId: data.formatId,
          startTimeMs: 0,
          durationMs: 0,
          startSegmentIndex: 1,
          endSegmentIndex: 0
        }
      };

      this.initializedFormats.push(format);
      this.formatsByKey.set(formatKey, this.initializedFormats[this.initializedFormats.length - 1]);

      return format;
    }
  }
}