text: /*
   * Copyright (C) 2016 Bilibili. All Rights Reserved.
   *
   * @author zheng qian <xqq@xqq.im>
   *
   * 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 '../utils/logger.js';
  import SpeedSampler from './speed-sampler.js';
  import {LoaderStatus, LoaderErrors} from './loader.js';
  import FetchStreamLoader from './fetch-stream-loader.js';
  import MozChunkedLoader from './xhr-moz-chunked-loader.js';
  import MSStreamLoader from './xhr-msstream-loader.js';
  import RangeLoader from './xhr-range-loader.js';
  import WebSocketLoader from './websocket-loader.js';
  import RangeSeekHandler from './range-seek-handler.js';
  import ParamSeekHandler from './param-seek-handler.js';
  import {RuntimeException, IllegalStateException, InvalidArgumentException} from '../utils/exception.js';
  
  /**
   * DataSource: {
   *     url: string,
   *     filesize: number,
   *     cors: boolean,
   *     withCredentials: boolean
   * }
   * 
   */
  
  // Manage IO Loaders
  class IOController {
  
      constructor(dataSource, config, extraData) {
          this.TAG = 'IOController';
  
          this._config = config;
          this._extraData = extraData;
  
          this._stashInitialSize = 64 * 1024;  // default initial size: 64KB
          if (config.stashInitialSize != undefined && config.stashInitialSize > 0) {
              // apply from config
              this._stashInitialSize = config.stashInitialSize;
          }
  
          this._stashUsed = 0;
          this._stashSize = this._stashInitialSize;
          this._bufferSize = 1024 * 1024 * 3;  // initial size: 3MB
          this._stashBuffer = new ArrayBuffer(this._bufferSize);
          this._stashByteStart = 0;
          this._enableStash = true;
          if (config.enableStashBuffer === false) {
              this._enableStash = false;
          }
  
          this._loader = null;
          this._loaderClass = null;
          this._seekHandler = null;
  
          this._dataSource = dataSource;
          this._isWebSocketURL = /wss?:\/\/(.+?)/.test(dataSource.url);
          this._refTotalLength = dataSource.filesize ? dataSource.filesize : null;
          this._totalLength = this._refTotalLength;
          this._fullRequestFlag = false;
          this._currentRange = null;
          this._redirectedURL = null;
  
          this._speedNormalized = 0;
          this._speedSampler = new SpeedSampler();
          this._speedNormalizeList = [32, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096];
  
          this._isEarlyEofReconnecting = false;
  
          this._paused = false;
          this._resumeFrom = 0;
  
          this._onDataArrival = null;
          this._onSeeked = null;
          this._onError = null;
          this._onComplete = null;
          this._onRedirect = null;
          this._onRecoveredEarlyEof = null;
  
          this._selectSeekHandler();
          this._selectLoader();
          this._createLoader();
      }
  
      destroy() {
          if (this._loader.isWorking()) {
              this._loader.abort();
          }
          this._loader.destroy();
          this._loader = null;
          this._loaderClass = null;
          this._dataSource = null;
          this._stashBuffer = null;
          this._stashUsed = this._stashSize = this._bufferSize = this._stashByteStart = 0;
          this._currentRange = null;
          this._speedSampler = null;
  
          this._isEarlyEofReconnecting = false;
  
          this._onDataArrival = null;
          this._onSeeked = null;
          this._onError = null;
          this._onComplete = null;
          this._onRedirect = null;
          this._onRecoveredEarlyEof = null;
  
          this._extraData = null;
      }
  
      isWorking() {
          return this._loader && this._loader.isWorking() && !this._paused;
      }
  
      isPaused() {
          return this._paused;
      }
  
      get status() {
          return this._loader.status;
      }
  
      get extraData() {
          return this._extraData;
      }
  
      set extraData(data) {
          this._extraData = data;
      }
  
      // prototype: function onDataArrival(chunks: ArrayBuffer, byteStart: number): number
      get onDataArrival() {
          return this._onDataArrival;
      }
  
      set onDataArrival(callback) {
          this._onDataArrival = callback;
      }
  
      get onSeeked() {
          return this._onSeeked;
      }
  
      set onSeeked(callback) {
          this._onSeeked = callback;
      }
  
      // prototype: function onError(type: number, info: {code: number, msg: string}): void
      get onError() {
          return this._onError;
      }
  
      set onError(callback) {
          this._onError = callback;
      }
  
      get onComplete() {
          return this._onComplete;
      }
  
      set onComplete(callback) {
          this._onComplete = callback;
      }
  
      get onRedirect() {
          return this._onRedirect;
      }
  
      set onRedirect(callback) {
          this._onRedirect = callback;
      }
  
      get onRecoveredEarlyEof() {
          return this._onRecoveredEarlyEof;
      }
  
      set onRecoveredEarlyEof(callback) {
          this._onRecoveredEarlyEof = callback;
      }
  
      get currentURL() {
          return this._dataSource.url;
      }
  
      get hasRedirect() {
          return (this._redirectedURL != null || this._dataSource.redirectedURL != undefined);
      }
  
      get currentRedirectedURL() {
          return this._redirectedURL || this._dataSource.redirectedURL;
      }
  
      // in KB/s
      get currentSpeed() {
          if (this._loaderClass === RangeLoader) {
              // SpeedSampler is inaccuracy if loader is RangeLoader
              return this._loader.currentSpeed;
          }
          return this._speedSampler.lastSecondKBps;
      }
  
      get loaderType() {
          return this._loader.type;
      }
  
      _selectSeekHandler() {
          let config = this._config;
  
          if (config.seekType === 'range') {
              this._seekHandler = new RangeSeekHandler(this._config.rangeLoadZeroStart);
          } else if (config.seekType === 'param') {
              let paramStart = config.seekParamStart || 'bstart';
              let paramEnd = config.seekParamEnd || 'bend';
  
              this._seekHandler = new ParamSeekHandler(paramStart, paramEnd);
          } else if (config.seekType === 'custom') {
              if (typeof config.customSeekHandler !== 'function') {
                  throw new InvalidArgumentException('Custom seekType specified in config but invalid customSeekHandler!');
              }
              this._seekHandler = new config.customSeekHandler();
          } else {
              throw new InvalidArgumentException(`Invalid seekType in config: ${config.seekType}`);
          }
      }
  
      _selectLoader() {
          if (this._config.customLoader != null) {
              this._loaderClass = this._config.customLoader;
          } else if (this._isWebSocketURL) {
              this._loaderClass = WebSocketLoader;
          } else if (FetchStreamLoader.isSupported()) {
              this._loaderClass = FetchStreamLoader;
          } else if (MozChunkedLoader.isSupported()) {
              this._loaderClass = MozChunkedLoader;
          } else if (RangeLoader.isSupported()) {
              this._loaderClass = RangeLoader;
          } else {
              throw new RuntimeException('Your browser doesn\'t support xhr with arraybuffer responseType!');
          }
      }
  
      _createLoader() {
          this._loader = new this._loaderClass(this._seekHandler, this._config);
          if (this._loader.needStashBuffer === false) {
              this._enableStash = false;
          }
          this._loader.onContentLengthKnown = this._onContentLengthKnown.bind(this);
          this._loader.onURLRedirect = this._onURLRedirect.bind(this);
          this._loader.onDataArrival = this._onLoaderChunkArrival.bind(this);
          this._loader.onComplete = this._onLoaderComplete.bind(this);
          this._loader.onError = this._onLoaderError.bind(this);
      }
  
      open(optionalFrom) {
          this._currentRange = {from: 0, to: -1};
          if (optionalFrom) {
              this._currentRange.from = optionalFrom;
          }
  
          this._speedSampler.reset();
          if (!optionalFrom) {
              this._fullRequestFlag = true;
          }
  
          this._loader.open(this._dataSource, Object.assign({}, this._currentRange));
      }
  
      abort() {
          this._loader.abort();
  
          if (this._paused) {
              this._paused = false;
              this._resumeFrom = 0;
          }
      }
  
      pause() {
          if (this.isWorking()) {
              this._loader.abort();
  
              if (this._stashUsed !== 0) {
                  this._resumeFrom = this._stashByteStart;
                  this._currentRange.to = this._stashByteStart - 1;
              } else {
                  this._resumeFrom = this._currentRange.to + 1;
              }
              this._stashUsed = 0;
              this._stashByteStart = 0;
              this._paused = true;
          }
      }
  
      resume() {
          if (this._paused) {
              this._paused = false;
              let bytes = this._resumeFrom;
              this._resumeFrom = 0;
              this._internalSeek(bytes, true);
          }
      }
  
      seek(bytes) {
          this._paused = false;
          this._stashUsed = 0;
          this._stashByteStart = 0;
          this._internalSeek(bytes, true);
      }
  
      /**
       * When seeking request is from media seeking, unconsumed stash data should be dropped
       * However, stash data shouldn't be dropped if seeking requested from http reconnection
       *
       * @dropUnconsumed: Ignore and discard all unconsumed data in stash buffer
       */
      _internalSeek(bytes, dropUnconsumed) {
          if (this._loader.isWorking()) {
              this._loader.abort();
          }
  
          // dispatch & flush stash buffer before seek
          this._flushStashBuffer(dropUnconsumed);
  
          this._loader.destroy();
          this._loader = null;
  
          let requestRange = {from: bytes, to: -1};
          this._currentRange = {from: requestRange.from, to: -1};
  
          this._speedSampler.reset();
          this._stashSize = this._stashInitialSize;
          this._createLoader();
          this._loader.open(this._dataSource, requestRange);
  
          if (this._onSeeked) {
              this._onSeeked();
          }
      }
  
      updateUrl(url) {
          if (!url || typeof url !== 'string' || url.length === 0) {
              throw new InvalidArgumentException('Url must be a non-empty string!');
          }
  
          this._dataSource.url = url;
  
          // TODO: replace with new url
      }
  
      _expandBuffer(expectedBytes) {
          let bufferNewSize = this._stashSize;
          while (bufferNewSize + 1024 * 1024 * 1 < expectedBytes) {
              bufferNewSize *= 2;
          }
  
          bufferNewSize += 1024 * 1024 * 1;  // bufferSize = stashSize + 1MB
          if (bufferNewSize === this._bufferSize) {
              return;
          }
  
          let newBuffer = new ArrayBuffer(bufferNewSize);
  
          if (this._stashUsed > 0) {  // copy existing data into new buffer
              let stashOldArray = new Uint8Array(this._stashBuffer, 0, this._stashUsed);
              let stashNewArray = new Uint8Array(newBuffer, 0, bufferNewSize);
              stashNewArray.set(stashOldArray, 0);
          }
  
          this._stashBuffer = newBuffer;
          this._bufferSize = bufferNewSize;
      }
  
      _normalizeSpeed(input) {
          let list = this._speedNormalizeList;
          let last = list.length - 1;
          let mid = 0;
          let lbound = 0;
          let ubound = last;
  
          if (input < list[0]) {
              return list[0];
          }
  
          // binary search
          while (lbound <= ubound) {
              mid = lbound + Math.floor((ubound - lbound) / 2);
              if (mid === last || (input >= list[mid] && input < list[mid + 1])) {
                  return list[mid];
              } else if (list[mid] < input) {
                  lbound = mid + 1;
              } else {
                  ubound = mid - 1;
              }
          }
      }
  
      _adjustStashSize(normalized) {
          let stashSizeKB = 0;
  
          if (this._config.isLive) {
              // live stream: always use 1/8 normalized speed for size of stashSizeKB
              stashSizeKB = normalized / 8;
          } else {
              if (normalized < 512) {
                  stashSizeKB = normalized;
              } else if (normalized >= 512 && normalized <= 1024) {
                  stashSizeKB = Math.floor(normalized * 1.5);
              } else {
                  stashSizeKB = normalized * 2;
              }
          }
  
          if (stashSizeKB > 8192) {
              stashSizeKB = 8192;
          }
  
          let bufferSize = stashSizeKB * 1024 + 1024 * 1024 * 1;  // stashSize + 1MB
          if (this._bufferSize < bufferSize) {
              this._expandBuffer(bufferSize);
          }
          this._stashSize = stashSizeKB * 1024;
      }
  
      _dispatchChunks(chunks, byteStart) {
          this._currentRange.to = byteStart + chunks.byteLength - 1;
          return this._onDataArrival(chunks, byteStart);
      }
  
      _onURLRedirect(redirectedURL) {
          this._redirectedURL = redirectedURL;
          if (this._onRedirect) {
              this._onRedirect(redirectedURL);
          }
      }
  
      _onContentLengthKnown(contentLength) {
          if (contentLength && this._fullRequestFlag) {
              this._totalLength = contentLength;
              this._fullRequestFlag = false;
          }
      }
  
      _onLoaderChunkArrival(chunk, byteStart, receivedLength) {
          if (!this._onDataArrival) {
              throw new IllegalStateException('IOController: No existing consumer (onDataArrival) callback!');
          }
          if (this._paused) {
              return;
          }
          if (this._isEarlyEofReconnecting) {
              // Auto-reconnect for EarlyEof succeed, notify to upper-layer by callback
              this._isEarlyEofReconnecting = false;
              if (this._onRecoveredEarlyEof) {
                  this._onRecoveredEarlyEof();
              }
          }
  
          this._speedSampler.addBytes(chunk.byteLength);
  
          // adjust stash buffer size according to network speed dynamically
          let KBps = this._speedSampler.lastSecondKBps;
          if (KBps !== 0) {
              let normalized = this._normalizeSpeed(KBps);
              if (this._speedNormalized !== normalized) {
                  this._speedNormalized = normalized;
                  this._adjustStashSize(normalized);
              }
          }
  
          if (!this._enableStash) {  // disable stash
              if (this._stashUsed === 0) {
                  // dispatch chunk directly to consumer;
                  // check ret value (consumed bytes) and stash unconsumed to stashBuffer
                  let consumed = this._dispatchChunks(chunk, byteStart);
                  if (consumed < chunk.byteLength) {  // unconsumed data remain.
                      let remain = chunk.byteLength - consumed;
                      if (remain > this._bufferSize) {
                          this._expandBuffer(remain);
                      }
                      let stashArray = new Uint8Array(this._stashBuffer, 0, this._bufferSize);
                      stashArray.set(new Uint8Array(chunk, consumed), 0);
                      this._stashUsed += remain;
                      this._stashByteStart = byteStart + consumed;
                  }
              } else {
                  // else: Merge chunk into stashBuffer, and dispatch stashBuffer to consumer.
                  if (this._stashUsed + chunk.byteLength > this._bufferSize) {
                      this._expandBuffer(this._stashUsed + chunk.byteLength);
                  }
                  let stashArray = new Uint8Array(this._stashBuffer, 0, this._bufferSize);
                  stashArray.set(new Uint8Array(chunk), this._stashUsed);
                  this._stashUsed += chunk.byteLength;
                  let consumed = this._dispatchChunks(this._stashBuffer.slice(0, this._stashUsed), this._stashByteStart);
                  if (consumed < this._stashUsed && consumed > 0) {  // unconsumed data remain
                      let remainArray = new Uint8Array(this._stashBuffer, consumed);
                      stashArray.set(remainArray, 0);
                  }
                  this._stashUsed -= consumed;
                  this._stashByteStart += consumed;
              }
          } else {  // enable stash
              if (this._stashUsed === 0 && this._stashByteStart === 0) {  // seeked? or init chunk?
                  // This is the first chunk after seek action
                  this._stashByteStart = byteStart;
              }
              if (this._stashUsed + chunk.byteLength <= this._stashSize) {
                  // just stash
                  let stashArray = new Uint8Array(this._stashBuffer, 0, this._stashSize);
                  stashArray.set(new Uint8Array(chunk), this._stashUsed);
                  this._stashUsed += chunk.byteLength;
              } else {  // stashUsed + chunkSize > stashSize, size limit exceeded
                  let stashArray = new Uint8Array(this._stashBuffer, 0, this._bufferSize);
                  if (this._stashUsed > 0) {  // There're stash datas in buffer
                      // dispatch the whole stashBuffer, and stash remain data
                      // then append chunk to stashBuffer (stash)
                      let buffer = this._stashBuffer.slice(0, this._stashUsed);
                      let consumed = this._dispatchChunks(buffer, this._stashByteStart);
                      if (consumed < buffer.byteLength) {
                          if (consumed > 0) {
                              let remainArray = new Uint8Array(buffer, consumed);
                              stashArray.set(remainArray, 0);
                              this._stashUsed = remainArray.byteLength;
                              this._stashByteStart += consumed;
                          }
                      } else {
                          this._stashUsed = 0;
                          this._stashByteStart += consumed;
                      }
                      if (this._stashUsed + chunk.byteLength > this._bufferSize) {
                          this._expandBuffer(this._stashUsed + chunk.byteLength);
                          stashArray = new Uint8Array(this._stashBuffer, 0, this._bufferSize);
                      }
                      stashArray.set(new Uint8Array(chunk), this._stashUsed);
                      this._stashUsed += chunk.byteLength;
                  } else {  // stash buffer empty, but chunkSize > stashSize (oh, holy shit)
                      // dispatch chunk directly and stash remain data
                      let consumed = this._dispatchChunks(chunk, byteStart);
                      if (consumed < chunk.byteLength) {
                          let remain = chunk.byteLength - consumed;
                          if (remain > this._bufferSize) {
                              this._expandBuffer(remain);
                              stashArray = new Uint8Array(this._stashBuffer, 0, this._bufferSize);
                          }
                          stashArray.set(new Uint8Array(chunk, consumed), 0);
                          this._stashUsed += remain;
                          this._stashByteStart = byteStart + consumed;
                      }
                  }
              }
          }
      }
  
      _flushStashBuffer(dropUnconsumed) {
          if (this._stashUsed > 0) {
              let buffer = this._stashBuffer.slice(0, this._stashUsed);
              let consumed = this._dispatchChunks(buffer, this._stashByteStart);
              let remain = buffer.byteLength - consumed;
  
              if (consumed < buffer.byteLength) {
                  if (dropUnconsumed) {
                      Log.w(this.TAG, `${remain} bytes unconsumed data remain when flush buffer, dropped`);
                  } else {
                      if (consumed > 0) {
                          let stashArray = new Uint8Array(this._stashBuffer, 0, this._bufferSize);
                          let remainArray = new Uint8Array(buffer, consumed);
                          stashArray.set(remainArray, 0);
                          this._stashUsed = remainArray.byteLength;
                          this._stashByteStart += consumed;
                      }
                      return 0;
                  }
              }
              this._stashUsed = 0;
              this._stashByteStart = 0;
              return remain;
          }
          return 0;
      }
  
      _onLoaderComplete(from, to) {
          // Force-flush stash buffer, and drop unconsumed data
          this._flushStashBuffer(true);
  
          if (this._onComplete) {
              this._onComplete(this._extraData);
          }
      }
  
      _onLoaderError(type, data) {
          Log.e(this.TAG, `Loader error, code = ${data.code}, msg = ${data.msg}`);
  
          this._flushStashBuffer(false);
  
          if (this._isEarlyEofReconnecting) {
              // Auto-reconnect for EarlyEof failed, throw UnrecoverableEarlyEof error to upper-layer
              this._isEarlyEofReconnecting = false;
              type = LoaderErrors.UNRECOVERABLE_EARLY_EOF;
          }
  
          switch (type) {
              case LoaderErrors.EARLY_EOF: {
                  if (!this._config.isLive) {
                      // Do internal http reconnect if not live stream
                      if (this._totalLength) {
                          let nextFrom = this._currentRange.to + 1;
                          if (nextFrom < this._totalLength) {
                              Log.w(this.TAG, 'Connection lost, trying reconnect...');
                              this._isEarlyEofReconnecting = true;
                              this._internalSeek(nextFrom, false);
                          }
                          return;
                      }
                      // else: We don't know totalLength, throw UnrecoverableEarlyEof
                  }
                  // live stream: throw UnrecoverableEarlyEof error to upper-layer
                  type = LoaderErrors.UNRECOVERABLE_EARLY_EOF;
                  break;
              }
              case LoaderErrors.UNRECOVERABLE_EARLY_EOF:
              case LoaderErrors.CONNECTING_TIMEOUT:
              case LoaderErrors.HTTP_STATUS_CODE_INVALID:
              case LoaderErrors.EXCEPTION:
                  break;
          }
  
          if (this._onError) {
              this._onError(type, data);
          } else {
              throw new RuntimeException('IOException: ' + data.msg);
          }
      }
  
  }
  
  export default IOController;