/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/DOMRequestHelper.jsm");
Cu.import("resource://gre/modules/DownloadsIPC.jsm");

XPCOMUtils.defineLazyServiceGetter(this, "cpmm",
                                   "@mozilla.org/childprocessmessagemanager;1",
                                   "nsIMessageSender");
XPCOMUtils.defineLazyServiceGetter(this, "volumeService",
                                   "@mozilla.org/telephony/volume-service;1",
                                    "nsIVolumeService");

/**
  * The content process implementations of navigator.mozDownloadManager and its
  * DOMDownload download objects.  Uses DownloadsIPC.jsm to communicate with
  * DownloadsAPI.jsm in the parent process.
  */

function debug(aStr) {
#ifdef MOZ_DEBUG
  dump("-*- DownloadsAPI.js : " + aStr + "\n");
#endif
}

function DOMDownloadManagerImpl() {
  debug("DOMDownloadManagerImpl constructor");
}

DOMDownloadManagerImpl.prototype = {
  __proto__: DOMRequestIpcHelper.prototype,

  // nsIDOMGlobalPropertyInitializer implementation
  init: function(aWindow) {
    debug("DownloadsManager init");
    this.initDOMRequestHelper(aWindow,
                              ["Downloads:Added",
                               "Downloads:Removed"]);

    // Get the manifest URL if this is an installed app
    let appsService = Cc["@mozilla.org/AppsService;1"]
                        .getService(Ci.nsIAppsService);
    let principal = aWindow.document.nodePrincipal;
    // This returns the empty string if we're not an installed app.  Coerce to
    // null.
    this._manifestURL = appsService.getManifestURLByLocalId(principal.appId) ||
                          null;
  },

  uninit: function() {
    debug("uninit");
    downloadsCache.evict(this._window);
  },

  set ondownloadstart(aHandler) {
    this.__DOM_IMPL__.setEventHandler("ondownloadstart", aHandler);
  },

  get ondownloadstart() {
    return this.__DOM_IMPL__.getEventHandler("ondownloadstart");
  },

  getDownloads: function() {
    debug("getDownloads()");

    return this.createPromise(function (aResolve, aReject) {
      DownloadsIPC.getDownloads().then(
        function(aDownloads) {
          // Turn the list of download objects into DOM objects and
          // send them.
          let array = new this._window.Array();
          for (let id in aDownloads) {
            let dom = createDOMDownloadObject(this._window, aDownloads[id]);
            array.push(this._prepareForContent(dom));
          }
          aResolve(array);
        }.bind(this),
        function() {
          aReject("GetDownloadsError");
        }
      );
    }.bind(this));
  },

  clearAllDone: function() {
    debug("clearAllDone()");
    // This is a void function; we just kick it off.  No promises, etc.
    DownloadsIPC.clearAllDone();
  },

  remove: function(aDownload) {
    debug("remove " + aDownload.url + " " + aDownload.id);
    return this.createPromise(function (aResolve, aReject) {
      if (!downloadsCache.has(this._window, aDownload.id)) {
        debug("no download " + aDownload.id);
        aReject("InvalidDownload");
        return;
      }

      DownloadsIPC.remove(aDownload.id).then(
        function(aResult) {
          let dom = createDOMDownloadObject(this._window, aResult);
          // Change the state right away to not race against the update message.
          dom.wrappedJSObject.state = "finalized";
          aResolve(this._prepareForContent(dom));
        }.bind(this),
        function() {
          aReject("RemoveError");
        }
      );
    }.bind(this));
  },

  adoptDownload: function(aAdoptDownloadDict) {
    // Our AdoptDownloadDict only includes simple types, which WebIDL enforces.
    // We have no object/any types so we do not need to worry about invoking
    // JSON.stringify (and it inheriting our security privileges).
    debug("adoptDownload");
    return this.createPromise(function (aResolve, aReject) {
      if (!aAdoptDownloadDict) {
        debug("Download dictionary is required!");
        aReject("InvalidDownload");
        return;
      }
      if (!aAdoptDownloadDict.storageName || !aAdoptDownloadDict.storagePath ||
          !aAdoptDownloadDict.contentType) {
        debug("Missing one of: storageName, storagePath, contentType");
        aReject("InvalidDownload");
        return;
      }

      // Convert storageName/storagePath to a local filesystem path.
      let volume;
      // getVolumeByName throws if you give it something it doesn't like
      // because XPConnect converts the NS_ERROR_NOT_AVAILABLE to an
      // exception.  So catch it.
      try {
        volume = volumeService.getVolumeByName(aAdoptDownloadDict.storageName);
      } catch (ex) {}
      if (!volume) {
        debug("Invalid storage name: " + aAdoptDownloadDict.storageName);
        aReject("InvalidDownload");
        return;
      }
      let computedPath = volume.mountPoint + '/' +
                           aAdoptDownloadDict.storagePath;
      // We validate that there is actually a file at the given path in the
      // parent process in DownloadsAPI.js because that's where the file
      // access would actually occur either way.

      // Create a DownloadsAPI.jsm 'jsonDownload' style representation.
      let jsonDownload = {
        url: aAdoptDownloadDict.url,
        path: computedPath,
        contentType: aAdoptDownloadDict.contentType,
        startTime: aAdoptDownloadDict.startTime.valueOf() || Date.now(),
        sourceAppManifestURL: this._manifestURL
      };

      DownloadsIPC.adoptDownload(jsonDownload).then(
        function(aResult) {
          let domDownload = createDOMDownloadObject(this._window, aResult);
          aResolve(this._prepareForContent(domDownload));
        }.bind(this),
        function(aResult) {
          // This will be one of: AdoptError (generic catch-all),
          // AdoptNoSuchFile, AdoptFileIsDirectory
          aReject(aResult.error);
        }
      );
    }.bind(this));
  },


  /**
    * Turns a chrome download object into a content accessible one.
    * When we have __DOM_IMPL__ available we just use that, otherwise
    * we run _create() with the wrapped js object.
    */
  _prepareForContent: function(aChromeObject) {
    if (aChromeObject.__DOM_IMPL__) {
      return aChromeObject.__DOM_IMPL__;
    }
    let res = this._window.DOMDownload._create(this._window,
                                            aChromeObject.wrappedJSObject);
    return res;
  },

  receiveMessage: function(aMessage) {
    let data = aMessage.data;
    switch(aMessage.name) {
      case "Downloads:Added":
        debug("Adding " + uneval(data));
        let event = new this._window.DownloadEvent("downloadstart", {
          download:
            this._prepareForContent(createDOMDownloadObject(this._window, data))
        });
        this.__DOM_IMPL__.dispatchEvent(event);
        break;
    }
  },

  classID: Components.ID("{c6587afa-0696-469f-9eff-9dac0dd727fe}"),
  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports,
                                         Ci.nsISupportsWeakReference,
                                         Ci.nsIObserver,
                                         Ci.nsIDOMGlobalPropertyInitializer]),

};

/**
  * Keep track of download objects per window.
  */
var downloadsCache = {
  init: function() {
    this.cache = new WeakMap();
  },

  has: function(aWindow, aId) {
    let downloads = this.cache.get(aWindow);
    return !!(downloads && downloads[aId]);
  },

  get: function(aWindow, aDownload) {
    let downloads = this.cache.get(aWindow);
    if (!(downloads && downloads[aDownload.id])) {
      debug("Adding download " + aDownload.id + " to cache.");
      if (!downloads) {
        this.cache.set(aWindow, {});
        downloads = this.cache.get(aWindow);
      }
      // Create the object and add it to the cache.
      let impl = Cc["@mozilla.org/downloads/download;1"]
                   .createInstance(Ci.nsISupports);
      impl.wrappedJSObject._init(aWindow, aDownload);
      downloads[aDownload.id] = impl;
    }
    return downloads[aDownload.id];
  },

  evict: function(aWindow) {
    this.cache.delete(aWindow);
  }
};

downloadsCache.init();

/**
  * The DOM facade of a download object.
  */

function createDOMDownloadObject(aWindow, aDownload) {
  return downloadsCache.get(aWindow, aDownload);
}

function DOMDownloadImpl() {
  debug("DOMDownloadImpl constructor ");

  this.wrappedJSObject = this;
  this.totalBytes = 0;
  this.currentBytes = 0;
  this.url = null;
  this.path = null;
  this.storageName = null;
  this.storagePath = null;
  this.contentType = null;

  /* fields that require getters/setters */
  this._error = null;
  this._startTime = new Date();
  this._state = "stopped";

  /* private fields */
  this.id = null;
}

DOMDownloadImpl.prototype = {

  createPromise: function(aPromiseInit) {
    return new this._window.Promise(aPromiseInit);
  },

  pause: function() {
    debug("DOMDownloadImpl pause");
    let id = this.id;
    // We need to wrap the Promise.jsm promise in a "real" DOM promise...
    return this.createPromise(function(aResolve, aReject) {
      DownloadsIPC.pause(id).then(aResolve, aReject);
    });
  },

  resume: function() {
    debug("DOMDownloadImpl resume");
    let id = this.id;
    // We need to wrap the Promise.jsm promise in a "real" DOM promise...
    return this.createPromise(function(aResolve, aReject) {
      DownloadsIPC.resume(id).then(aResolve, aReject);
    });
  },

  set onstatechange(aHandler) {
    this.__DOM_IMPL__.setEventHandler("onstatechange", aHandler);
  },

  get onstatechange() {
    return this.__DOM_IMPL__.getEventHandler("onstatechange");
  },

  get error() {
    return this._error;
  },

  set error(aError) {
    this._error = aError;
  },

  get startTime() {
    return this._startTime;
  },

  set startTime(aStartTime) {
    if (aStartTime instanceof Date) {
      this._startTime = aStartTime;
    }
    else {
      this._startTime = new Date(aStartTime);
    }
  },

  get state() {
    return this._state;
  },

  // We require a setter here to simplify the internals of the Download Manager
  // since we actually pass dummy JSON objects to the child process and update
  // them. This is the case for all other setters for read-only attributes
  // implemented in this object.
  set state(aState) {
    // We need to ensure that XPCOM consumers of this API respect the enum
    // values as well.
    if (["downloading",
         "stopped",
         "succeeded",
         "finalized"].indexOf(aState) != -1) {
      this._state = aState;
    }
  },

  /**
    * Initialize a DOMDownload instance for the given window using the
    * 'jsonDownload' serialized format of the download encoded by
    * DownloadsAPI.jsm.
    */
  _init: function(aWindow, aDownload) {
    this._window = aWindow;
    this.id = aDownload.id;
    this._update(aDownload);
    Services.obs.addObserver(this, "downloads-state-change-" + this.id,
                             /* ownsWeak */ true);
    debug("observer set for " + this.id);
  },

  /**
    * Updates the state of the object and fires the statechange event.
    */
  _update: function(aDownload) {
    debug("update " + uneval(aDownload));
    if (this.id != aDownload.id) {
      return;
    }

    let props = ["totalBytes", "currentBytes", "url", "path", "storageName",
                 "storagePath", "state", "contentType", "startTime",
                 "sourceAppManifestURL"];
    let changed = false;
    let changedProps = {};

    props.forEach((prop) => {
      if (prop in aDownload && (aDownload[prop] != this[prop])) {
        this[prop] = aDownload[prop];
        changedProps[prop] = changed = true;
      }
    });

    // When the path changes, we should update the storage name and
    // storage path used for our downloaded file in case our download
    // was re-targetted to a different storage and/or filename.
    if (changedProps["path"]) {
      let storages = this._window.navigator.getDeviceStorages("sdcard");
      let preferredStorageName;
      // Use the first one or the default storage. Just like jsdownloads picks
      // the default / preferred download directory.
      storages.forEach((aStorage) => {
        if (aStorage.default || !preferredStorageName) {
          preferredStorageName = aStorage.storageName;
        }
      });
      // Now get the path for this storage area.
      let volume;
      if (preferredStorageName) {
        let volume = volumeService.getVolumeByName(preferredStorageName);
        if (volume) {
          // Finally, create the relative path of the file that can be used
          // later on to retrieve the file via DeviceStorage. Our path
          // needs to omit the starting '/'.
          this.storageName = preferredStorageName;
          this.storagePath =
            this.path.substring(this.path.indexOf(volume.mountPoint) +
                                volume.mountPoint.length + 1);
        }
      }
    }

    if (aDownload.error) {
      //
      // When we get a generic error failure back from the js downloads api
      // we will verify the status of device storage to see if we can't provide
      // a better error result value.
      //
      // XXX If these checks expand further, consider moving them into their
      // own function.
      //
      let result = aDownload.error.result;
      let storage = this._window.navigator.getDeviceStorage("sdcard");

      // If we don't have access to device storage we'll opt out of these
      // extra checks as they are all dependent on the state of the storage.
      if (result == Cr.NS_ERROR_FAILURE && storage) {
        // We will delay sending the notification until we've inferred which
        // error is really happening.
        changed = false;
        debug("Attempting to infer error via device storage sanity checks.");
        // Get device storage and request availability status.
        let available = storage.available();
        available.onsuccess = (function() {
          debug("Storage Status = '" + available.result + "'");
          let inferredError = result;
          switch (available.result) {
            case "unavailable":
              inferredError = Cr.NS_ERROR_FILE_NOT_FOUND;
              break;
            case "shared":
              inferredError = Cr.NS_ERROR_FILE_ACCESS_DENIED;
              break;
          }
          this._updateWithError(aDownload, inferredError);
        }).bind(this);
        available.onerror = (function() {
          this._updateWithError(aDownload, result);
        }).bind(this);
      }

      this.error =
        new this._window.DOMError("DownloadError", result);
    } else {
      this.error = null;
    }

    // The visible state has not changed, so no need to fire an event.
    if (!changed) {
      return;
    }

    this._sendStateChange();
  },

  _updateWithError: function(aDownload, aError) {
    this.error =
      new this._window.DOMError("DownloadError", aError);
    this._sendStateChange();
  },

  _sendStateChange: function() {
    // __DOM_IMPL__ may not be available at first update.
    if (this.__DOM_IMPL__) {
      let event = new this._window.DownloadEvent("statechange", {
        download: this.__DOM_IMPL__
      });
      debug("Dispatching statechange event. state=" + this.state);
      this.__DOM_IMPL__.dispatchEvent(event);
    }
  },

  observe: function(aSubject, aTopic, aData) {
    debug("DOMDownloadImpl observe " + aTopic);
    if (aTopic !== "downloads-state-change-" + this.id) {
      return;
    }

    try {
      let download = JSON.parse(aData);
      // We get the start time as milliseconds, not as a Date object.
      if (download.startTime) {
        download.startTime = new Date(download.startTime);
      }
      this._update(download);
    } catch(e) {}
  },

  classID: Components.ID("{96b81b99-aa96-439d-8c59-92eeed34705f}"),
  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports,
                                         Ci.nsIObserver,
                                         Ci.nsISupportsWeakReference])
};

this.NSGetFactory = XPCOMUtils.generateNSGetFactory([DOMDownloadManagerImpl,
                                                     DOMDownloadImpl]);
