// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {FileType} from 'chrome://file-manager/common/js/file_type.js';
import {assert, assertInstanceof} from 'chrome://resources/ash/common/assert.js';

import {ImageCache} from './cache.js';
import {ImageLoaderUtil} from './image_loader_util.js';
import {ImageOrientation} from './image_orientation.js';
import {LoadImageRequest, LoadImageResponse, LoadImageResponseStatus} from './load_image_request.js';
import {PiexLoader} from './piex_loader.js';

/**
 * Creates and starts downloading and then resizing of the image. Finally,
 * returns the image using the callback.
 *
 * @param {string} id Request ID.
 * @param {ImageCache} cache Cache object.
 * @param {!LoadImageRequest} request Request message as a hash array.
 * @param {function(!LoadImageResponse)} callback Response handler.
 * @constructor
 */
export function ImageRequestTask(id, cache, request, callback) {
  /**
   * Global ID (concatenated client ID and client request ID).
   * @type {string}
   * @private
   */
  this.id_ = id;

  /**
   * @type {ImageCache}
   * @private
   */
  this.cache_ = cache;

  /**
   * @type {!LoadImageRequest}
   * @private
   */
  this.request_ = request;

  /**
   * @type {function(!LoadImageResponse)}
   * @private
   */
  this.sendResponse_ = callback;

  /**
   * Temporary image used to download images.
   * @type {Image}
   * @private
   */
  this.image_ = new Image();

  /**
   * MIME type of the fetched image.
   * @type {?string}
   * @private
   */
  this.contentType_ = null;

  /**
   * IFD data of the fetched image. Only RAW images provide a non-null
   * ifd at this time. Drive images might provide an ifd in future.
   * @type {?string}
   * @private
   */
  this.ifd_ = null;

  /**
   * Used to download remote images using http:// or https:// protocols.
   * @type {XMLHttpRequest}
   * @private
   */
  this.xhr_ = null;

  /**
   * Temporary canvas used to resize and compress the image.
   * @type {HTMLCanvasElement}
   * @private
   */
  this.canvas_ =
      /** @type {HTMLCanvasElement} */ (document.createElement('canvas'));

  /**
   * @type {CanvasRenderingContext2D}
   * @private
   */
  this.context_ =
      /** @type {CanvasRenderingContext2D} */ (this.canvas_.getContext('2d'));

  /**
   * @type {ImageOrientation|null}
   */
  this.renderOrientation_ = null;

  /**
   * Callback to be called once downloading is finished.
   * @type {?function()}
   * @private
   */
  this.downloadCallback_ = null;

  /**
   * @type {boolean}
   * @private
   */
  this.aborted_ = false;
}

/**
 * Seeks offset to generate video thumbnail.
 * TODO(ryoh):
 *   What is the best position for the thumbnail?
 *   The first frame seems not good -- sometimes it is a black frame.
 * @const
 * @type {number}
 */
ImageRequestTask.VIDEO_THUMBNAIL_POSITION = 3;  // [sec]

/**
 * The maximum milliseconds to load video. If loading video exceeds the limit,
 * we give up generating video thumbnail and free the consumed memory.
 * @const
 * @type {number}
 */
ImageRequestTask.MAX_MILLISECONDS_TO_LOAD_VIDEO = 3000;

/**
 * The default size (width and height) of a square thumbnail. The value is set
 * to match the behavior of drivefs thumbnail generation.
 * See chromeos/ash/components/drivefs/mojom/drivefs.mojom
 * @const
 * @type {number}
 */
ImageRequestTask.DEFAULT_THUMBNAIL_SQUARE_SIZE = 360;

/**
 * The default width of a non-square thumbnail. The value is set to match the
 * behavior of drivefs thumbnail generation.
 * See chromeos/ash/components/drivefs/mojom/drivefs.mojom
 * @const
 * @type {number}
 */
ImageRequestTask.DEFAULT_THUMBNAIL_WIDTH = 500;

/**
 * The default height of a non-square thumbnail. The value is set to match the
 * behavior of drivefs thumbnail generation.
 * See chromeos/ash/components/drivefs/mojom/drivefs.mojom
 * @const
 * @type {number}
 */
ImageRequestTask.DEFAULT_THUMBNAIL_HEIGHT = 500;

/**
 * A map which is used to estimate content type from extension.
 * @enum {string}
 */
ImageRequestTask.ExtensionContentTypeMap = {
  gif: 'image/gif',
  png: 'image/png',
  svg: 'image/svg',
  bmp: 'image/bmp',
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
};

/**
 * Extracts MIME type of a data URL.
 * @param {string|undefined} dataUrl Data URL.
 * @return {?string} MIME type string, or null if the URL is invalid.
 */
ImageRequestTask.getDataUrlMimeType = function(dataUrl) {
  const dataUrlMatches = (dataUrl || '').match(/^data:([^,;]*)[,;]/);
  return dataUrlMatches ? dataUrlMatches[1] : null;
};

/**
 * Returns ID of the request.
 * @return {string} Request ID.
 */
ImageRequestTask.prototype.getId = function() {
  return this.id_;
};

/**
 * Returns the client's task ID for the request.
 * @return {number}
 */
ImageRequestTask.prototype.getClientTaskId = function() {
  // Every incoming request should have been given a taskId.
  assert(this.request_.taskId);
  return this.request_.taskId;
};

/**
 * Returns priority of the request. The higher priority, the faster it will
 * be handled. The highest priority is 0. The default one is 2.
 *
 * @return {number} Priority.
 */
ImageRequestTask.prototype.getPriority = function() {
  return (this.request_.priority !== undefined) ? this.request_.priority : 2;
};

/**
 * Tries to load the image from cache, if it exists in the cache, and sends
 * the response. Fails if the image is not found in the cache.
 *
 * @param {function()} onSuccess Success callback.
 * @param {function()} onFailure Failure callback.
 */
ImageRequestTask.prototype.loadFromCacheAndProcess = function(
    onSuccess, onFailure) {
  this.loadFromCache_(
      function(width, height, ifd, data) {  // Found in cache.
        this.ifd_ = ifd;
        this.sendImageData_(width, height, data);
        onSuccess();
      }.bind(this),
      onFailure);  // Not found in cache.
};

/**
 * Tries to download the image, resizes and sends the response.
 *
 * @param {function()} callback Completion callback.
 */
ImageRequestTask.prototype.downloadAndProcess = function(callback) {
  if (this.downloadCallback_) {
    throw new Error('Downloading already started.');
  }

  this.downloadCallback_ = callback;
  this.downloadThumbnail_(
      this.onImageLoad_.bind(this), this.onImageError_.bind(this));
};

/**
 * Fetches the image from the persistent cache.
 *
 * @param {function(number, number, ?string, string)} onSuccess
 *    Success callback with the image width, height, ?ifd, and data.
 * @param {function()} onFailure Failure callback.
 * @private
 */
ImageRequestTask.prototype.loadFromCache_ = function(onSuccess, onFailure) {
  const cacheKey = LoadImageRequest.cacheKey(this.request_);

  if (!cacheKey) {
    // Cache key is not provided for the request.
    onFailure();
    return;
  }

  if (!this.request_.cache) {
    // Cache is disabled for this request; therefore, remove it from cache
    // if existed.
    this.cache_.removeImage(cacheKey);
    onFailure();
    return;
  }

  const timestamp = this.request_.timestamp;
  if (!timestamp) {
    // Persistent cache is available only when a timestamp is provided.
    onFailure();
    return;
  }

  this.cache_.loadImage(cacheKey, timestamp, onSuccess, onFailure);
};

/**
 * Saves the image to the persistent cache.
 *
 * @param {number} width Image width.
 * @param {number} height Image height.
 * @param {string} data Image data.
 * @private
 */
ImageRequestTask.prototype.saveToCache_ = function(width, height, data) {
  const timestamp = this.request_.timestamp;

  if (!this.request_.cache || !timestamp) {
    // Persistent cache is available only when a timestamp is provided.
    return;
  }

  const cacheKey = LoadImageRequest.cacheKey(this.request_);
  if (!cacheKey) {
    // Cache key is not provided for the request.
    return;
  }

  this.cache_.saveImage(cacheKey, timestamp, width, height, this.ifd_, data);
};

/**
 * Gets the target image size for external thumbnails, where supported.
   The defaults replicate drivefs thumbnailer behavior.
 * @return {{width: !number, height: !number}}
 */
ImageRequestTask.prototype.targetThumbnailSize_ = function() {
  const crop = !!this.request_.crop;
  const defaultWidth = crop ? ImageRequestTask.DEFAULT_THUMBNAIL_SQUARE_SIZE :
                              ImageRequestTask.DEFAULT_THUMBNAIL_WIDTH;
  const defaultHeight = crop ? ImageRequestTask.DEFAULT_THUMBNAIL_SQUARE_SIZE :
                               ImageRequestTask.DEFAULT_THUMBNAIL_HEIGHT;
  return {
    width: this.request_.width || defaultWidth,
    height: this.request_.height || defaultHeight,
  };
};

/**
 * Loads |this.image_| with the |this.request_.url| source or the thumbnail
 * image of the source.
 *
 * @param {function()} onSuccess Success callback.
 * @param {function()} onFailure Failure callback.
 * @private
 */
ImageRequestTask.prototype.downloadThumbnail_ = function(onSuccess, onFailure) {
  // Load methods below set |this.image_.src|. Call revokeObjectURL(src) to
  // release resources if the image src was created with createObjectURL().
  this.image_.onload = function() {
    URL.revokeObjectURL(this.image_.src);
    onSuccess();
  }.bind(this);
  this.image_.onerror = function() {
    URL.revokeObjectURL(this.image_.src);
    onFailure();
  }.bind(this);

  // Load dataURL sources directly.
  const dataUrlMimeType =
      ImageRequestTask.getDataUrlMimeType(this.request_.url);
  if (dataUrlMimeType) {
    this.image_.src = this.request_.url;
    this.contentType_ = dataUrlMimeType;
    return;
  }

  const resolveLocalFileSystemUrl = (url, onResolveSuccess) => {
    window.webkitResolveLocalFileSystemURL(url, onResolveSuccess, error => {
      console.warn(error);
      onFailure();
    });
  };

  const onExternalThumbnail = (dataUrl) => {
    if (chrome.runtime.lastError) {
      console.warn(chrome.runtime.lastError.message);
      onFailure();
    } else if (dataUrl) {
      this.image_.src = dataUrl;
      this.contentType_ = ImageRequestTask.getDataUrlMimeType(dataUrl);
    } else {
      onFailure();
    }
  };

  // Load Drive source thumbnail.
  const drivefsUrlMatches = this.request_.url.match(/^drivefs:(.*)/);
  if (drivefsUrlMatches) {
    const url = drivefsUrlMatches[1];
    const cropToSquare = !!this.request_.crop;
    resolveLocalFileSystemUrl(
        url,
        entry => chrome.fileManagerPrivate.getDriveThumbnail(
            entry, cropToSquare, onExternalThumbnail));
    return;
  }

  // Load PDF source thumbnail.
  if (this.request_.url.endsWith('.pdf')) {
    const {width, height} = this.targetThumbnailSize_();
    resolveLocalFileSystemUrl(
        this.request_.url,
        entry => chrome.fileManagerPrivate.getPdfThumbnail(
            entry, width, height, onExternalThumbnail));
    return;
  }

  // Load DocumentsProvider thumbnail, if supported.
  const isDocumentsProviderRequest = !!this.request_.url.match(RegExp(
      'filesystem:chrome-extension://[a-z]+/external/arc-documents-provider/.*'));
  if (isDocumentsProviderRequest) {
    const {width, height} = this.targetThumbnailSize_();
    resolveLocalFileSystemUrl(this.request_.url, entry => {
      chrome.fileManagerPrivate.getArcDocumentsProviderThumbnail(
          entry, width, height, onExternalThumbnail);
    });
    return;
  }

  const fileType = FileType.getTypeForName(this.request_.url);

  // Load RAW image source thumbnail.
  if (fileType.type === 'raw') {
    PiexLoader.load(this.request_.url, chrome.runtime.reload)
        .then(
            function(data) {
              this.renderOrientation_ =
                  ImageOrientation.fromExifOrientation(data.orientation);
              this.ifd_ = data.ifd;
              this.contentType_ = data.mimeType;
              const blob = new Blob([data.thumbnail], {type: data.mimeType});
              this.image_.src = URL.createObjectURL(blob);
            }.bind(this),
            function() {
              // PiexLoader calls console.warn on errors.
              onFailure();
            });
    return;
  }

  // Load video source thumbnail.
  if (fileType.type === 'video') {
    this.createVideoThumbnailUrl_(this.request_.url)
        .then(function(url) {
          this.image_.src = url;
        }.bind(this))
        .catch(function(error) {
          console.warn('Video thumbnail error: ', error);
          onFailure();
        });
    return;
  }

  // Load the source directly.
  this.load(this.request_.url, (contentType, blob) => {
    this.image_.src = blob ? URL.createObjectURL(blob) : '!';
    this.contentType_ = contentType || null;
    if (this.contentType_ === 'image/jpeg') {
      this.renderOrientation_ = ImageOrientation.fromExifOrientation(1);
    }
  }, onFailure);
};

/**
 * Creates a video thumbnail data url from video file.
 *
 * @param {string} url Video URL.
 * @return {!Promise<Blob>}  Promise that resolves with the data url of video
 *    thumbnail.
 * @private
 */
ImageRequestTask.prototype.createVideoThumbnailUrl_ = function(url) {
  const video =
      assertInstanceof(document.createElement('video'), HTMLVideoElement);
  return Promise
      .race([
        new Promise((resolve, reject) => {
          video.addEventListener('canplay', resolve);
          video.addEventListener('error', reject);
          video.currentTime = ImageRequestTask.VIDEO_THUMBNAIL_POSITION;
          video.preload = 'auto';
          video.src = url;
          video.load();
        }),
        new Promise((resolve) => {
          setTimeout(resolve, ImageRequestTask.MAX_MILLISECONDS_TO_LOAD_VIDEO);
        }).then(() => {
          // If we don't receive 'canplay' event after 3 seconds have passed for
          // some reason (e.g. unseekable video), we give up generating
          // thumbnail.
          video.src =
              '';  // Make sure to stop loading remaining part of the video.
          throw new Error('Seeking video failed.');
        }),
      ])
      .then(() => {
        const canvas = assertInstanceof(
            document.createElement('canvas'), HTMLCanvasElement);
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        assertInstanceof(canvas.getContext('2d'), CanvasRenderingContext2D)
            .drawImage(video, 0, 0);
        return canvas.toDataURL();
      });
};

/**
 * Loads an image.
 *
 * @param {string} url URL to the resource to be fetched.
 * @param {function(string, Blob)} onSuccess Success callback with the content
 *     type and the fetched data.
 * @param {function()} onFailure Failure callback.
 */
ImageRequestTask.prototype.load = function(url, onSuccess, onFailure) {
  this.aborted_ = false;

  // Do not call any callbacks when aborting.
  const onMaybeSuccess =
      /** @type {function(string, Blob)} */ (function(contentType, response) {
        // When content type is not available, try to estimate it from url.
        if (!contentType) {
          contentType =
              ImageRequestTask
                  .ExtensionContentTypeMap[this.extractExtension_(url)];
        }

        if (!this.aborted_) {
          onSuccess(contentType, response);
        }
      }.bind(this));

  const onMaybeFailure = /** @type {function(number=)} */ (function(opt_code) {
    if (!this.aborted_) {
      onFailure();
    }
  }.bind(this));

  // The query parameter is workaround for crbug.com/379678, which forces the
  // browser to obtain the latest contents of the image.
  const noCacheUrl = url + '?nocache=' + Date.now();
  this.xhr_ =
      ImageRequestTask.load_(noCacheUrl, onMaybeSuccess, onMaybeFailure);
};

/**
 * Extracts extension from url.
 * @param {string} url Url.
 * @return {string} Extracted extension, e.g. png.
 */
ImageRequestTask.prototype.extractExtension_ = function(url) {
  const result = (/\.([a-zA-Z]+)$/i).exec(url);
  return result ? result[1] : '';
};

/**
 * Fetches data using XmlHttpRequest.
 *
 * @param {string} url URL to the resource to be fetched.
 * @param {function(string, Blob)} onSuccess Success callback with the content
 *     type and the fetched data.
 * @param {function(number=)} onFailure Failure callback with the error code
 *     if available.
 * @return {XMLHttpRequest} XHR instance.
 * @private
 */
ImageRequestTask.load_ = function(url, onSuccess, onFailure) {
  const xhr = new XMLHttpRequest();
  xhr.responseType = 'blob';

  xhr.onreadystatechange = function() {
    if (xhr.readyState != 4) {
      return;
    }
    if (xhr.status != 200) {
      onFailure(xhr.status);
      return;
    }
    const response = /** @type {Blob} */ (xhr.response);
    const contentType = xhr.getResponseHeader('Content-Type') || response.type;
    onSuccess(contentType, response);
  }.bind(this);

  // Perform a xhr request.
  try {
    xhr.open('GET', url, true);
    xhr.send();
  } catch (e) {
    onFailure();
  }

  return xhr;
};

/**
 * Sends the resized image via the callback. If the image has been changed,
 * then packs the canvas contents, otherwise sends the raw image data.
 *
 * @param {boolean} imageChanged Whether the image has been changed.
 * @private
 */
ImageRequestTask.prototype.sendImage_ = function(imageChanged) {
  let width;
  let height;
  let data;

  if (!imageChanged) {
    // The image hasn't been processed, so the raw data can be directly
    // forwarded for speed (no need to encode the image again).
    width = this.image_.width;
    height = this.image_.height;
    data = this.image_.src;
  } else {
    // The image has been resized or rotated, therefore the canvas has to be
    // encoded to get the correct compressed image data.
    width = this.canvas_.width;
    height = this.canvas_.height;

    switch (this.contentType_) {
      case 'image/gif':
      case 'image/png':
      case 'image/svg':
      case 'image/bmp':
        data = this.canvas_.toDataURL('image/png');
        break;
      case 'image/jpeg':
      default:
        data = this.canvas_.toDataURL('image/jpeg', 0.9);
        break;
    }
  }

  // Send the image data and also save it in the persistent cache.
  this.sendImageData_(width, height, data);
  this.saveToCache_(width, height, data);
};

/**
 * Sends the resized image via the callback.
 *
 * @param {number} width Image width.
 * @param {number} height Image height.
 * @param {string} data Image data.
 * @private
 */
ImageRequestTask.prototype.sendImageData_ = function(width, height, data) {
  const result = {width, height, ifd: this.ifd_, data};
  this.sendResponse_(new LoadImageResponse(
      LoadImageResponseStatus.SUCCESS, this.getClientTaskId(), result));
};

/**
 * Handler, when contents are loaded into the image element. Performs image
 * processing operations if needed, and finalizes the request process.
 * @private
 */
ImageRequestTask.prototype.onImageLoad_ = function() {
  const requestOrientation = this.request_.orientation;

  // Override the request orientation before processing if needed.
  if (this.renderOrientation_) {
    this.request_.orientation = this.renderOrientation_;
  }

  // Perform processing if the url is not a data url, or if there are some
  // operations requested.
  let imageChanged = false;
  if (!(this.request_.url.match(/^data/) ||
        this.request_.url.match(/^drivefs:/)) ||
      ImageLoaderUtil.shouldProcess(
          this.image_.width, this.image_.height, this.request_)) {
    ImageLoaderUtil.resizeAndCrop(this.image_, this.canvas_, this.request_);
    imageChanged = true;  // The image is now on the <canvas>.
  }

  // Restore the request orientation after processing.
  if (this.renderOrientation_) {
    this.request_.orientation = requestOrientation;
  }

  // Finalize the request.
  this.sendImage_(imageChanged);
  this.cleanup_();
  this.downloadCallback_();
};

/**
 * Handler, when loading of the image fails. Sends a failure response and
 * finalizes the request process.
 * @private
 */
ImageRequestTask.prototype.onImageError_ = function() {
  this.sendResponse_(new LoadImageResponse(
      LoadImageResponseStatus.ERROR, this.getClientTaskId()));
  this.cleanup_();
  this.downloadCallback_();
};

/**
 * Cancels the request.
 */
ImageRequestTask.prototype.cancel = function() {
  this.cleanup_();

  // If downloading has started, then call the callback.
  if (this.downloadCallback_) {
    this.downloadCallback_();
  }
};

/**
 * Cleans up memory used by this request.
 * @private
 */
ImageRequestTask.prototype.cleanup_ = function() {
  this.image_.onerror = function() {};
  this.image_.onload = function() {};

  // Transparent 1x1 pixel gif, to force garbage collecting.
  this.image_.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAA' +
      'ABAAEAAAICTAEAOw==';

  this.aborted_ = true;
  if (this.xhr_) {
    this.xhr_.abort();
  }

  // Dispose memory allocated by Canvas.
  this.canvas_.width = 0;
  this.canvas_.height = 0;
};