// FileLoader - A caching file downloader for Titanium
//
// Version 1.0.0
//
// This is a reinvention of [David Geller's caching code][1]. It will download
// a file and cache it on the device allowing the cached version to be used
// instead of spawning repeated HTTP connections. It is based on the Promise/A+
// specifications and uses a modified version of [then/promise][2] to facilitate
// a promise based API for use in a Titanium project.
//
// FileLoader, Copyright (c) 2013 Devin Weaver
// [then/promise][2], Copyright (c) 2013 Forbes Lindesay
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//
// For the latest version visit: https://github.com/sukima/TiCachedImages
//
// ## Dependencies
// * None
//
// ## API
// Once required, the following methods are available:
//
// - `download()` - Attempt to download a file from a URL or offer a cached version.
// - `gc()` - Search the cache for any expired files and delete them (Garbage Collect).
//
// The `download()` method returns a [promise][3] object. This object can be
// used to attach callbacks to that you want to execute after the correct file
// path has been resolved (either by cache or downloaded). The callbacks are
// passed in a File object which has the following methods and properties:
//
// - `getFile()`  - Returns a `Ti.FilesystemFile` object. Used to pass to a
//                  `ImageView.image`.
// - `getPath()`  - Returns a string to the cached file. Used for properties
//                  that need a string not a file object (`TableViewRow.leftImage`)
// - `expired()`  - Returns true/false for when the expired time has elapsed
//                  since this URL was last requested. By passing in true you will
//                  invalidate this file's cache forcing a download on next
//                  request.
// - `downloaded` - true if this URL was just downloaded, false if it was
//                  already in cached.
// - `is_cached`  - true if this file has been cached or not.
//
// There are several others but these are the few you will need, if that. See more below.
//
// ## Promises
// The `download()` method returns a [then/promise][2] promise. You do not have
// to use promises if you do not want to. However I highly recommend their use.
// The internals are all managed via promises. If after reading this your still
// convinced to avoid them you can use callbacks like such:
//
//     FileLoader.download({
//       url:          "http://example.com/image.png",
//       onload:       function(file) { imageView.image = file.getFile(); },
//       onerror:      function(error) { ... },
//       ondatastream: function(progress) { ... }
//     });
//
// That so not pretty, Let us promise to write better code:
//
//     FileLoader.download("http://example.com/image.png")
//       .then(function(file) { ... })
//       .fail(function(error) { ... })
//       .progress(function(progress) { ... });
//
// Much better. A promise is an object which will remain pending till an event
// assigns it a fulfilled value. Like an HTTP request sending it the
// responseData. When a promise is fulfilled or rejected the corresponding
// functions attached are called. The advantage with promises is that you can
// chain them:
//
//     FileLoader.download("http://example.com/image.png")
//       .then(function(file) { return file.getFile(); })
//       .then(function(tiFile) { imageView.image = tiFile; });
//
// The modified [then/promise][2] implementation in this file even offers two
// convenience methods for the above:
//
//     FileLoader.download("http://example.com/image.png")
//       .invoke("getFile")
//       .then(function(tiFile) { imageView.image = tiFile; });
//
// With the modified Promise you have the following methods at your disposal:
//
// - `then(fn)`     - Attach callbacks (fulfilled, rejected, progress). Returns
//                    a new promise based on the return values / thrown
//                    exceptions of the callbacks.
// - `fail(fn)`     - Same as `then(null, fn)`
// - `progress(fn)` - Same as `then(null, null, fn)`
// - `always(fn)`   - Return a new promise which will resolve regardless if the
//                    former promise is fulfilled or rejected.
// - `fin(fn)`      - Execute the function when the promise is fulfilled or
//                    rejected regardless. Returns the original promise to
//                    continue the chain.
// - `done()`       - Any errors uncaught (or errors in the error function)
//                    will be rethrown. Ends the chain.
// - `get(prop)`    - Same as `then(function(value) { return value[prop]; })`
// - `invoke(prop, args...)` -
//             Same as `then(function(value) { return value[prop](args...); })`
//
// ## Configuration
//
// To set configuration when using Alloy set them in your `app/config.json`
// Otherwise if Alloy is not used set them in `app.js` on `Ti.App`.
//
// You can adjust the following variables:
//
// - `cache_property_key` - The `Ti.App.Property` key to use for storing the
//                            cache metadata.
// - `cache_expiration` - How long a cached file is considered expired since
//                        the last time it was requested.
// - `cache_directory` - The directory to save the cache files. On iOS the
//                       `applicationSupportDirectory` is prefixed. on all
//                       others the `applicationDataDirectory` is prefixed.
// - `cache_requests` - The number of simultaneous network requests allowed.
// - `cache_max_redirects - The maximum number of redirects to follow before
//                          giving up.
//
// [1]: http://developer.appcelerator.com/question/125483/how-to-create-a-generic-image-cache-sample-code#answer-218718
// [2]: https://github.com/then/promise
// [3]: http://promises-aplus.github.io/promises-spec/

// Constants {{{1
// Load constants allowing them to be overwritten with configuration.
var HTTP_TIMEOUT = 10000;
var CACHE_METADATA_PROPERTY, EXPIRATION_TIME, CACHE_PATH_PREFIX, MAX_ASYNC_TASKS;
(function(global) {
  var have_alloy = (typeof Alloy !== 'undefined' && Alloy !== null && Alloy.CFG);

  function loadConfig(name) {
    /* jshint eqnull:true */
    if (have_alloy && Alloy.CFG[name] != null) {
      return Alloy.CFG[name];
    }
    if (global[name] != null) {
      return global[name];
    }
  }

  CACHE_METADATA_PROPERTY = loadConfig("cache_property_key")  || "file_loader_cache_metadata";
  CACHE_PATH_PREFIX       = loadConfig("cache_directory")     || "cached_files";
  EXPIRATION_TIME         = loadConfig("cache_expiration")    || 3600000; // 60 minutes
  MAX_ASYNC_TASKS         = loadConfig("cache_requests")      || 10;
  MAX_REDIRECT_HOPS       = loadConfig("cache_max_redirects") || 5;

})(Ti.App);

// Metadata {{{1
var metadata = Ti.App.Properties.getObject(CACHE_METADATA_PROPERTY) || {};

function saveMetaData() {
  Ti.App.Properties.setObject(CACHE_METADATA_PROPERTY, metadata);
}

// Cache path {{{1
// Make sure we have the directory to store files.
var cache_path = (function() {
  var os = Ti.Platform.osname;
  var data_dir = (os === "iphone" || os === "ipad") ?
    Ti.Filesystem.applicationSupportDirectory :
    Ti.Filesystem.applicationDataDirectory;
  var cache_dir = Ti.Filesystem.getFile(data_dir, CACHE_PATH_PREFIX);
  if (!cache_dir.exists()) {
    cache_dir.createDirectory();
  }
  return cache_dir;
})();

// Class: File {{{1

// Constructor {{{2
function File(id) {
  this.id        = id;
  var cache_data = metadata[this.id];
  this.file_path = Ti.Filesystem.getFile(cache_path.resolve(), this.id);

  if (cache_data) {
    this.is_cached    = this.exists();
    this.last_used_at = cache_data.last_used_at;
    this.md5          = cache_data.md5;
  }
  else {
    this.is_cached    = false;
    this.last_used_at = 0;
    this.md5          = null;
  }
}

// File::updateLastUsedAt {{{2
File.prototype.updateLastUsedAt = function() {
  this.last_used_at = new Date().getTime();
  return this;
};

// File::save {{{2
File.prototype.save = function() {
  metadata[this.id] = {
    last_used_at: this.last_used_at,
    md5:          this.md5
  };
  saveMetaData();
  this.is_cached = true;
  return this;
};

// File::write {{{2
File.prototype.write = function(data) {
  // A Titanium bug cause this to always return false. We need to manually
  // check it exists. And assume it worked.
  // (https://jira.appcelerator.org/browse/TIMOB-1658)
  this.getFile().write(data);
  // Ti.API.debug("Wrote " + this.getPath() + " (" + this.md5 + ")"); // DEBUG
  return this.exists();
};

// File::exists {{{2
File.prototype.exists = function() {
  return this.getFile().exists();
};

// File::expired {{{2
File.prototype.expired = function(invalidate) {
  if (invalidate) {
    this.last_used_at = 0;
    this.save();
  }
  return ((new Date().getTime() - this.last_used_at) > EXPIRATION_TIME);
};

// File::expunge {{{2
File.prototype.expunge = function() {
  this.getFile().deleteFile();
  // Ti.API.debug("Expunged " + this.id); // DEBUG
  delete metadata[this.id];
  saveMetaData();
  this.is_cached = false;
};

// File::getPath {{{2
File.prototype.getPath = function() {
  return this.getFile().resolve();
};

// File::toString {{{2
File.prototype.toString = function() {
  return "" + this.id + ": " +
    (this.is_cached ? "cached" : "new") + " file" +
    (this.pending ? " (pending)" : "") +
    (this.downloaded ? " (downloaded)" : "") +
    (this.expired() ? " (expired)" : "") +
    (this.last_used_at ? ", Last used: " + this.last_used_at : "") +
    (this.md5 ? ", MD5: " + this.md5 : "") +
    " " + this.getPath();
};

// File.getFile {{{2
File.prototype.getFile = function() {
  return this.file_path;
};

// File.getMD5 {{{2
File.getMD5 = function(data) {
  return Ti.Utils.md5HexDigest(data);
};

// File.idFromUrl {{{2
File.idFromUrl = function(url) {
  // Insanely simple conversion to keep id unique to the URL and prevent
  // possible illegal file system characters and removes path separators.
  // MD5 should be fast enough not that this is repeated so much.
  return Ti.Utils.md5HexDigest(url);
};

// File.fromURL {{{2
File.fromURL = function(url) {
  return new File(File.idFromUrl(url));
};

// FileLoader {{{1
var pending_tasks;
var FileLoader = {};

// extendObj {{{2
function extendObj(newObj, otherObj) {
  for (var name in otherObj)
    if (otherObj.hasOwnProperty(name))
      newObj[name] = otherObj[name];
  return newObj;
}

// requestDispatch (private) {{{2
function requestDispatch() {
  var waitForDispatch = Promise.defer();
  if (pending_tasks.request_count < MAX_ASYNC_TASKS) {
    waitForDispatch.resolve();
  }
  else {
    pending_tasks.dispatch_queue.push(waitForDispatch);
  }
  pending_tasks.request_count++;
  return waitForDispatch.promise;
}

// dispatchNextTask (private) {{{2
function dispatchNextTask() {
  var task;
  pending_tasks.request_count--;
  task = pending_tasks.dispatch_queue.shift();
  if (!task) { return; }
  if (task.resolve) { task.resolve(); }
  else { asap(task); }
}

// promisedHTTPClient (private) {{{2
function promisedHTTPClient(url, options, hops) {
  /* jshint eqnull:true */
  if (hops == null) { hops = 0; }

  var waitForHttp = Promise.defer();

  if (hops > MAX_REDIRECT_HOPS) {
    waitForHttp.reject(new Error("Maximum redirects reached (" + MAX_REDIRECT_HOPS + ")"));
    return waitForHttp.promise;
  }

  var httpClientOptions = { timeout: HTTP_TIMEOUT };
  extendObj(httpClientOptions, options);
  extendObj(httpClientOptions, {
    onload:       handleOnLoad(waitForHttp, options, hops),
    onerror:      handleOnError(waitForHttp),
    ondatastream: waitForHttp.notify,
    autoRedirect: false,
    cache:        true
  });

  var http = Ti.Network.createHTTPClient(httpClientOptions);
  http.open("GET", url);
  http.send();

  return waitForHttp.promise;
}

// handleOnLoad (private) {{{2
function handleOnLoad(defer, options, hops) {
  return function(e) {
    // Ti.API.debug("onLoad: " + this.status); // DEBUG
    if (this.status >= 300 && this.status < 400 && this.status !== 304) {
      // Ti.API.debug("  Location: " + this.getResponseHeader("Location")); // DEBUG
      defer.resolve(promisedHTTPClient(this.getResponseHeader("Location"), options, (hops + 1)));
    }
    else {
      // The download function should handle 304 status as this is a caching
      // responsibility not a fetching responsibility. Any other error status
      // like 500 should have triggered onError and not have gotton here.
      defer.resolve(this);
    }
  };
}

// handleOnError (private) {{{2
function handleOnError(defer) {
  return function(e) {
    defer.reject(e);
  };
}

// FileLoader.download - Attempt to download and cache URL {{{2
FileLoader.download = function(url, args) {
  var waitingForPath;
  args = args || {};
  var file = File.fromURL(url);

  function attachCallbacks(promise) {
    if (args.onload || args.onerror || args.ondatastream) {
      return promise
        .then(args.onload, args.onerror, args.ondatastream);
    }
    return promise;
  }

  if (pending_tasks[file.id]) {
    // Ti.API.debug("Pending " + url + ": " + file); // DEBUG
    return attachCallbacks(pending_tasks[file.id]);
  }

  if (!args.force && file.is_cached && !file.expired()) {
    file.updateLastUsedAt().save();
    // Ti.API.debug("Cached " + url + ": " + file); // DEBUG
    waitForPath = Promise.defer();
    waitForPath.resolve(file);
    return attachCallbacks(waitForPath.promise);
  }

  if (!Ti.Network.online && args.offlineCheck !== false) {
    var offlineDefer = Promise.defer();
    offlineDefer.reject("Network offline");
    return attachCallbacks(offlineDefer.promise);
  }

  var waitingForDownload = requestDispatch()
    .then(function() {
      // Ti.API.debug("Downloading " + url + ": " + file); // DEBUG
      return promisedHTTPClient(url, args);
    })
    .get("responseData")
    .then(function(data) {
      var md5sum = File.getMD5(data);
      // Ti.API.debug("Processing " + url + ": " + file); // DEBUG
      if (args.force || md5sum !== file.md5) {
        // Ti.API.debug("File contents have changed: " + md5sum + " <=> " + file.md5); // DEBUG
        if (!file.write(data)) {
          throw new Error("Failed to save data from " + url + ": " + file);
        }
        file.downloaded = true;
        file.md5 = md5sum;
      }
      file.updateLastUsedAt().save();
      return file;
    })
    .fin(function() {
      // Ti.API.debug("Finishing: " + file); // DEBUG
      delete pending_tasks[file.id];
      file.pending = false;
      dispatchNextTask();
    });

  file.pending = true;
  pending_tasks[file.id] = waitingForDownload;

  return attachCallbacks(waitingForDownload);
};

// FileLoader.pruneStaleCache - (alias: gc) Remove stale cache files {{{2
FileLoader.pruneStaleCache = FileLoader.gc = function(force) {
  var id, file;
  for (id in metadata) {
    file = new File(id);
    if (force || file.expired()) {
      file.expunge();
    }
  }
};

// FileLoader.setupTaskStack - initialize pending_tasks (testing) {{{2
FileLoader.setupTaskStack = function() {
  pending_tasks = {
    request_count:  0,
    dispatch_queue: []
  };
};

// Promise {{{1
//
// Promise is a minimalistic implementation of the Promise/A+ spec and is
// available at https://github.com/then/promise under the MIT License.
// Copyright (c) 2013 Forbes Lindesay
//
// This embeded version modified by Devin Weaver.
//
// Promise {{{2
function Promise(fn) {
  if (!(this instanceof Promise)) return new Promise(fn);
  if (typeof fn !== 'function') throw new TypeError('not a function');
  var state = null;
  var value = null;
  var deferreds = [];
  var self = this;

  // Promise.then {{{3
  this.then = function(onFulfilled, onRejected) {
    return new Promise(function(resolve, reject) {
      handle(new Handler(onFulfilled, onRejected, resolve, reject));
    });
  };

  // handle (private) {{{3
  function handle(deferred) {
    if (state === null) {
      deferreds.push(deferred);
      return;
    }
    asap(function() {
      var cb = state ? deferred.onFulfilled : deferred.onRejected;
      if (cb === null) {
        (state ? deferred.resolve : deferred.reject)(value);
        return;
      }
      var ret;
      try {
        ret = cb(value);
      }
      catch (e) {
        deferred.reject(e);
        return;
      }
      deferred.resolve(ret);
    });
  }

  // resolve (private) {{{3
  function resolve(newValue) {
    try { //Promise Resolution Procedure: https://github.com/promises-aplus/promises-spec#the-promise-resolution-procedure
      if (newValue === self) throw new TypeError('A promise cannot be resolved with itself.');
      if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
        var then = newValue.then;
        if (typeof then === 'function') {
          doResolve(polyBind(then, newValue), resolve, reject);
          return;
        }
      }
      state = true;
      value = newValue;
      finale();
    } catch (e) { reject(e); }
  }

  // reject (private) {{{3
  function reject(newValue) {
    state = false;
    value = newValue;
    finale();
  }

  // finale (private) {{{3
  function finale() {
    for (var i = 0, len = deferreds.length; i < len; i++)
      handle(deferreds[i]);
    deferreds = null;
  }
  // }}}3

  doResolve(fn, resolve, reject);
}

// Promise helper functions {{{2
// Handler {{{3
function Handler(onFulfilled, onRejected, resolve, reject){
  this.onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : null;
  this.onRejected = typeof onRejected === 'function' ? onRejected : null;
  this.resolve = resolve;
  this.reject = reject;
}

// doResolve {{{3
/**
 * Take a potentially misbehaving resolver function and make sure
 * onFulfilled and onRejected are only called once.
 *
 * Makes no guarantees about asynchrony.
 */
function doResolve(fn, onFulfilled, onRejected) {
  var done = false;
  try {
    fn(function (value) {
      if (done) return;
      done = true;
      onFulfilled(value);
    }, function (reason) {
      if (done) return;
      done = true;
      onRejected(reason);
    });
  } catch (ex) {
    if (done) return;
    done = true;
    onRejected(ex);
  }
}

// asap {{{3
function asap(fn) {
  setTimeout(fn, 0);
}

// polyBind {{{3
// Titanium does not have a Function.prototype.bind method. We need to polyfill.
function polyBind(fn, ctx) {
  return function() {
    return fn.apply(ctx, Array.prototype.slice.call(arguments));
  };
}

// Promise::progress {{{2
Promise.prototype.progress = function (onProgress) {
  // XXX: This is unimplemented
  return this;
};

// Promise::done {{{2
Promise.prototype.done = function (onFulfilled, onRejected) {
  var self = arguments.length ? this.then.apply(this, arguments) : this;
  self.then(null, function (err) {
    asap(function () {
      throw err;
    });
  });
};

// Promise::fail {{{2
Promise.prototype.fail = function(fn) {
  return this.then(null, fn);
};

// Promise::get {{{2
Promise.prototype.get = function(prop) {
  return this.then(function(obj) {
    return obj[prop];
  });
};

// Promise::invoke {{{2
Promise.prototype.invoke = function(prop /*...args*/) {
  var args = Array.prototype.slice.call(arguments, 1);
  return this.then(function(obj) {
    return obj[prop].apply(obj, args);
  });
};

// Promise::fin {{{2
Promise.prototype.fin = function(onFinished) {
  return this.then(function(x) {
    onFinished(x);
    return x;
  }, function(x) {
    onFinished(x);
    throw x;
  });
};

// Promise.defer {{{2
Promise.defer = function() {
  var resolver, rejecter, notifier;
  var defer = {};

  defer.promise = new Promise(function(resolve, reject, notify) {
    defer.resolve = resolve;
    defer.reject  = reject;
    defer.notify  = function(){}; // XXX: This is unimplemented
  });

  return defer;
};
// }}}1

FileLoader.setupTaskStack();

FileLoader.File    = File;
FileLoader.Promise = Promise;
module.exports     = FileLoader;
/* vim:set ts=2 sw=2 et fdm=marker: */