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

'use strict';

/**
 * Persistent cache storing images in an indexed database on the hard disk.
 * @constructor
 */
function Cache() {
  /**
   * IndexedDB database handle.
   * @type {IDBDatabase}
   * @private
   */
  this.db_ = null;
}

/**
 * Cache database name.
 * @type {string}
 * @const
 */
Cache.DB_NAME = 'image-loader';

/**
 * Cache database version.
 * @type {number}
 * @const
 */
Cache.DB_VERSION = 11;

/**
 * Memory limit for images data in bytes.
 *
 * @const
 * @type {number}
 */
Cache.MEMORY_LIMIT = 250 * 1024 * 1024;  // 250 MB.

/**
 * Minimal amount of memory freed per eviction. Used to limit number of
 * evictions which are expensive.
 *
 * @const
 * @type {number}
 */
Cache.EVICTION_CHUNK_SIZE = 50 * 1024 * 1024;  // 50 MB.

/**
 * Creates a cache key.
 *
 * @param {Object} request Request options.
 * @return {string} Cache key.
 */
Cache.createKey = function(request) {
  return JSON.stringify({url: request.url,
                         scale: request.scale,
                         width: request.width,
                         height: request.height,
                         maxWidth: request.maxWidth,
                         maxHeight: request.maxHeight});
};

/**
 * Initializes the cache database.
 * @param {function()} callback Completion callback.
 */
Cache.prototype.initialize = function(callback) {
  // Establish a connection to the database or (re)create it if not available
  // or not up to date. After changing the database's schema, increment
  // Cache.DB_VERSION to force database recreating.
  var openRequest = window.webkitIndexedDB.open(Cache.DB_NAME,
                                                Cache.DB_VERSION);

  openRequest.onsuccess = function(e) {
    this.db_ = e.target.result;
    callback();
  }.bind(this);

  openRequest.onerror = callback;

  openRequest.onupgradeneeded = function(e) {
    console.info('Cache database creating or upgrading.');
    var db = e.target.result;
    if (db.objectStoreNames.contains('metadata'))
      db.deleteObjectStore('metadata');
    if (db.objectStoreNames.contains('data'))
      db.deleteObjectStore('data');
    if (db.objectStoreNames.contains('settings'))
      db.deleteObjectStore('settings');
    db.createObjectStore('metadata', {keyPath: 'key'});
    db.createObjectStore('data', {keyPath: 'key'});
    db.createObjectStore('settings', {keyPath: 'key'});
  };
};

/**
 * Sets size of the cache.
 *
 * @param {number} size Size in bytes.
 * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
 *     provided, then a new one is created.
 * @private
 */
Cache.prototype.setCacheSize_ = function(size, opt_transaction) {
  var transaction = opt_transaction ||
      this.db_.transaction(['settings'], 'readwrite');
  var settingsStore = transaction.objectStore('settings');

  settingsStore.put({key: 'size', value: size});  // Update asynchronously.
};

/**
 * Fetches current size of the cache.
 *
 * @param {function(number)} onSuccess Callback to return the size.
 * @param {function()} onFailure Failure callback.
 * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
 *     provided, then a new one is created.
 * @private
 */
Cache.prototype.fetchCacheSize_ = function(
    onSuccess, onFailure, opt_transaction) {
  var transaction = opt_transaction ||
      this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
  var settingsStore = transaction.objectStore('settings');
  var sizeRequest = settingsStore.get('size');

  sizeRequest.onsuccess = function(e) {
    if (e.target.result)
      onSuccess(e.target.result.value);
    else
      onSuccess(0);
  };

  sizeRequest.onerror = function() {
    console.error('Failed to fetch size from the database.');
    onFailure();
  };
};

/**
 * Evicts the least used elements in cache to make space for a new image and
 * updates size of the cache taking into account the upcoming item.
 *
 * @param {number} size Requested size.
 * @param {function()} onSuccess Success callback.
 * @param {function()} onFailure Failure callback.
 * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
 *     provided, then a new one is created.
 * @private
 */
Cache.prototype.evictCache_ = function(
    size, onSuccess, onFailure, opt_transaction) {
  var transaction = opt_transaction ||
      this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');

  // Check if the requested size is smaller than the cache size.
  if (size > Cache.MEMORY_LIMIT) {
    onFailure();
    return;
  }

  var onCacheSize = function(cacheSize) {
    if (size < Cache.MEMORY_LIMIT - cacheSize) {
      // Enough space, no need to evict.
      this.setCacheSize_(cacheSize + size, transaction);
      onSuccess();
      return;
    }

    var bytesToEvict = Math.max(size, Cache.EVICTION_CHUNK_SIZE);

    // Fetch all metadata.
    var metadataEntries = [];
    var metadataStore = transaction.objectStore('metadata');
    var dataStore = transaction.objectStore('data');

    var onEntriesFetched = function() {
      metadataEntries.sort(function(a, b) {
        return b.lastLoadTimestamp - a.lastLoadTimestamp;
      });

      var totalEvicted = 0;
      while (bytesToEvict > 0) {
        var entry = metadataEntries.pop();
        totalEvicted += entry.size;
        bytesToEvict -= entry.size;
        metadataStore.delete(entry.key);  // Remove asynchronously.
        dataStore.delete(entry.key);  // Remove asynchronously.
      }

      this.setCacheSize_(cacheSize - totalEvicted + size, transaction);
    }.bind(this);

    metadataStore.openCursor().onsuccess = function(e) {
      var cursor = event.target.result;
      if (cursor) {
        metadataEntries.push(cursor.value);
        cursor.continue();
      } else {
        onEntriesFetched();
      }
    };
  }.bind(this);

  this.fetchCacheSize_(onCacheSize, onFailure, transaction);
};

/**
 * Saves an image in the cache.
 *
 * @param {string} key Cache key.
 * @param {string} data Image data.
 * @param {number} timestamp Last modification timestamp. Used to detect
 *     if the cache entry becomes out of date.
 */
Cache.prototype.saveImage = function(key, data, timestamp) {
  if (!this.db_) {
    console.warn('Cache database not available.');
    return;
  }

  var onNotFoundInCache = function() {
    var metadataEntry = {key: key,
                         timestamp: timestamp,
                         size: data.length,
                         lastLoadTimestamp: Date.now()};
    var dataEntry = {key: key,
                     data: data};

    var transaction = this.db_.transaction(['settings', 'metadata', 'data'],
                                          'readwrite');
    var metadataStore = transaction.objectStore('metadata');
    var dataStore = transaction.objectStore('data');

    var onCacheEvicted = function() {
      metadataStore.put(metadataEntry);  // Add asynchronously.
      dataStore.put(dataEntry);  // Add asynchronously.
    };

    // Make sure there is enough space in the cache.
    this.evictCache_(data.length, onCacheEvicted, function() {}, transaction);
  }.bind(this);

  // Check if the image is already in cache. If not, then save it to cache.
  this.loadImage(key, timestamp, function() {}, onNotFoundInCache);
};

/**
 * Loads an image from the cache (if available) or returns null.
 *
 * @param {string} key Cache key.
 * @param {number} timestamp Last modification timestamp. If different
 *     that the one in cache, then the entry will be invalidated.
 * @param {function(<string>)} onSuccess Success callback with the image's data.
 * @param {function()} onFailure Failure callback.
 */
Cache.prototype.loadImage = function(key, timestamp, onSuccess, onFailure) {
  if (!this.db_) {
    console.warn('Cache database not available.');
    onFailure();
    return;
  }

  var transaction = this.db_.transaction(['settings', 'metadata', 'data'],
                                         'readwrite');
  var metadataStore = transaction.objectStore('metadata');
  var dataStore = transaction.objectStore('data');
  var metadataRequest = metadataStore.get(key);
  var dataRequest = dataStore.get(key);

  var metadataEntry = null;
  var metadataReceived = false;
  var dataEntry = null;
  var dataReceived = false;

  var onPartialSuccess = function() {
    // Check if all sub-requests have finished.
    if (!metadataReceived || !dataReceived)
      return;

    // Check if both entries are available or both unavailable.
    if (!!metadataEntry != !!dataEntry) {
      console.warn('Inconsistent cache database.');
      onFailure();
      return;
    }

    // Process the responses.
    if (!metadataEntry) {
      // The image not found.
      onFailure();
    } else if (metadataEntry.timestamp != timestamp) {
      // The image is not up to date, so remove it.
      this.removeImage(key, function() {}, function() {}, transaction);
      onFailure();
    } else {
      // The image is available. Update the last load time and return the
      // image data.
      metadataEntry.lastLoadTimestamp = Date.now();
      metadataStore.put(metadataEntry);  // Added asynchronously.
      onSuccess(dataEntry.data);
    }
  }.bind(this);

  metadataRequest.onsuccess = function(e) {
    if (e.target.result)
      metadataEntry = e.target.result;
    metadataReceived = true;
    onPartialSuccess();
  };

  dataRequest.onsuccess = function(e) {
    if (e.target.result)
      dataEntry = e.target.result;
    dataReceived = true;
    onPartialSuccess();
  };

  metadataRequest.onerror = function() {
    console.error('Failed to fetch metadata from the database.');
    metadataReceived = true;
    onPartialSuccess();
  };

  dataRequest.onerror = function() {
    console.error('Failed to fetch image data from the database.');
    dataReceived = true;
    onPartialSuccess();
  };
};

/**
 * Removes the image from the cache.
 *
 * @param {string} key Cache key.
 * @param {function()=} opt_onSuccess Success callback.
 * @param {function()=} opt_onFailure Failure callback.
 * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
 *     provided, then a new one is created.
 */
Cache.prototype.removeImage = function(
    key, opt_onSuccess, opt_onFailure, opt_transaction) {
  if (!this.db_) {
    console.warn('Cache database not available.');
    return;
  }

  var transaction = opt_transaction ||
      this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
  var metadataStore = transaction.objectStore('metadata');
  var dataStore = transaction.objectStore('data');

  var cacheSize = null;
  var cacheSizeReceived = false;
  var metadataEntry = null;
  var metadataReceived = false;

  var onPartialSuccess = function() {
    if (!cacheSizeReceived || !metadataReceived)
      return;

    // If either cache size or metadata entry is not available, then it is
    // an error.
    if (cacheSize === null || !metadataEntry) {
      if (opt_onFailure)
        onFailure();
      return;
    }

    if (opt_onSuccess)
      opt_onSuccess();

    this.setCacheSize_(cacheSize - metadataEntry.size, transaction);
    metadataStore.delete(key);  // Delete asynchronously.
    dataStore.delete(key);  // Delete asynchronously.
  }.bind(this);

  var onCacheSizeFailure = function() {
    cacheSizeReceived = true;
  };

  var onCacheSizeSuccess = function(result) {
    cacheSize = result;
    cacheSizeReceived = true;
    onPartialSuccess();
  };

  // Fetch the current cache size.
  this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction);

  // Receive image's metadata.
  var metadataRequest = metadataStore.get(key);

  metadataRequest.onsuccess = function(e) {
    if (e.target.result)
      metadataEntry = e.target.result;
    metadataReceived = true;
    onPartialSuccess();
  };

  metadataRequest.onerror = function() {
    console.error('Failed to remove an image.');
    metadataReceived = true;
    onPartialSuccess();
  };
};