/**
 * RemoteFile is a representation of a file on a remote server which can be
 * fetched in chunks, e.g. using a Range request.
 * @flow
 */
'use strict';

import Q from 'q';
import AbstractFile from './AbstractFile';

type Chunk = {
  start: number;
  stop: number;
  buffer: ArrayBuffer;
  // TODO(danvk): priority: number;
}


class RemoteFile extends AbstractFile{
  url: string;
  fileLength: number;
  chunks: Array<Chunk>;  // regions of file that have already been loaded.
  numNetworkRequests: number;  // track this for debugging/testing

  constructor(url: string) {
    super();
    this.url = url;
    this.fileLength = -1;  // unknown
    this.chunks = [];
    this.numNetworkRequests = 0;
  }

  getBytes(start: number, length: number): Q.Promise<ArrayBuffer> {
    if (length < 0) {
      return Q.reject(`Requested <0 bytes (${length}) from ${this.url}`);
    }

    // If the remote file length is known, clamp the request to fit within it.
    var stop = start + length - 1;
    if (this.fileLength != -1) {
      stop = Math.min(this.fileLength - 1, stop);
    }

    // First check the cache.
    var buf = this.getFromCache(start, stop);
    if (buf) {
      return Q.when(buf);
    }

    // TODO: handle partial overlap of request w/ cache.

    // Need to fetch from the network.
    return this.getFromNetwork(start, stop);
  }

  // Read the entire file -- not recommended for large files!
  getAll(): Q.Promise<ArrayBuffer> {
    // Check cache if file length is available.
    if (this.fileLength != -1) {
      var buf = this.getFromCache(0, this.fileLength - 1);
      if (buf) {
        return Q.when(buf);
      }
    }

    var xhr = new XMLHttpRequest();
    xhr.open('GET', this.url);
    xhr.responseType = 'arraybuffer';
    return this.promiseXHR(xhr).then(([buffer]) => {
      this.fileLength = buffer.byteLength;
      this.chunks = [{start: 0, stop: this.fileLength - 1, buffer}];
      return buffer;
    });
  }

  // Reads the entire file as a string (not an ArrayBuffer).
  // This does not use the cache.
  getAllString(): Q.Promise<string> {
    var xhr = new XMLHttpRequest();
    xhr.open('GET', this.url);
    return this.promiseXHR(xhr).then(([string]) => {
      return string;
    });
  }

  // Returns a promise for the number of bytes in the remote file.
  getSize(): Q.Promise<number> {
    if (this.fileLength != -1) {
      return Q.when(this.fileLength);
    }

    var xhr = new XMLHttpRequest();
    xhr.open('HEAD', this.url);

    return this.promiseXHR(xhr).then(() => {
      // event.total would be better, see facebook/flow#357
      var len = xhr.getResponseHeader('Content-Length');
      if (len !== null) {
        return Number(len);
      } else {
        throw 'Remote resource has unknown length';
      }
    });
  }

  getFromCache(start: number, stop: number): ?ArrayBuffer {
    for (var i = 0; i < this.chunks.length; i++) {
      var chunk = this.chunks[i];
      if (chunk.start <= start && chunk.stop >= stop) {
        return chunk.buffer.slice(start - chunk.start, stop - chunk.start + 1);
      }
    }
    return null;
  }

  getFromNetwork(start: number, stop: number): Q.Promise<ArrayBuffer> {
    var length = stop - start + 1;
    if (length > 50000000) {
      throw `Monster request: Won't fetch ${length} bytes from ${this.url}`;
    }

    var xhr = new XMLHttpRequest();
    xhr.open('GET', this.url);
    xhr.responseType = 'arraybuffer';
    xhr.setRequestHeader('Range', `bytes=${start}-${stop}`);

    return this.promiseXHR(xhr).then(([buffer]) => {
      // The actual length of the response may be less than requested if it's
      // too short, e.g. if we request bytes 0-1000 of a 500-byte file.
      var newChunk = { start, stop: start + buffer.byteLength - 1, buffer };
      this.chunks.push(newChunk);

      // Record the full file length if it's available.
      var size = this._getLengthFromContentRange(xhr);
      if (size !== null && size !== undefined) {
        if (this.fileLength != -1 && this.fileLength != size) {
          console.warn(`Size of remote file ${this.url} changed from ` +
                       `${this.fileLength} to ${size}`);
        } else {
          this.fileLength = size;
        }
      }

      return buffer;
    });
  }

  // Wrapper to convert XHRs to Promises.
  // The promised values are the response (e.g. an ArrayBuffer) and the Event.
  promiseXHR(xhr: XMLHttpRequest): Q.Promise<[any, Event]> {
    var url = this.url;
    var deferred = Q.defer();
    xhr.addEventListener('load', function(e: any) {
      if (this.status >= 400) {
        deferred.reject(`Request for ${url} failed with status: ${this.status} ${this.statusText}`);
      } else {
        deferred.resolve([this.response, e]);
      }
    });
    xhr.addEventListener('error', function(e: any) {
      deferred.reject(`Request for ${url} failed with status: ${this.status} ${this.statusText}`);
    });
    this.numNetworkRequests++;
    xhr.send();
    return deferred.promise;
  }

  // Attempting to access Content-Range directly may raise security errors.
  // This ensures the access is safe before making it.
  _getLengthFromContentRange(xhr: XMLHttpRequest): ?number {
    if (!/Content-Range/i.exec(xhr.getAllResponseHeaders())) {
      return null;
    }

    var contentRange = xhr.getResponseHeader('Content-Range');
    var m = /\/(\d+)$/.exec(contentRange);
    if (m) {
      return Number(m[1]);
    }
    console.warn(`Received improper Content-Range value for ` +
                 `${this.url}: ${contentRange}`);
    return null;
  }

  clearCache() {
    this.chunks = [];
  }
}

module.exports = RemoteFile;
