/* 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;

this.EXPORTED_SYMBOLS = [];

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Downloads.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/osfile.jsm");

XPCOMUtils.defineLazyServiceGetter(this, "ppmm",
                                   "@mozilla.org/parentprocessmessagemanager;1",
                                   "nsIMessageBroadcaster");

/**
  * Parent process logic that services download API requests from the
  * DownloadAPI.js instances in content processeses.  The actual work of managing
  * downloads is done by Toolkit's Downloads.jsm.  This module is loaded by B2G's
  * shell.js
  */

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

function sendPromiseMessage(aMm, aMessageName, aData, aError) {
  debug("sendPromiseMessage " + aMessageName);
  let msg = {
    id: aData.id,
    promiseId: aData.promiseId
  };

  if (aError) {
    msg.error = aError;
  }

  aMm.sendAsyncMessage(aMessageName, msg);
}

var DownloadsAPI = {
  init: function() {
    debug("init");

    this._ids = new WeakMap(); // Maps toolkit download objects to ids.
    this._index = {};          // Maps ids to downloads.

    ["Downloads:GetList",
     "Downloads:ClearAllDone",
     "Downloads:Remove",
     "Downloads:Pause",
     "Downloads:Resume",
     "Downloads:Adopt"].forEach((msgName) => {
      ppmm.addMessageListener(msgName, this);
    });

    let self = this;
    Task.spawn(function () {
      let list = yield Downloads.getList(Downloads.ALL);
      yield list.addView(self);

      debug("view added to download list.");
    }).then(null, Components.utils.reportError);

    this._currentId = 0;
  },

  /**
    * Returns a unique id for each download, hashing the url and the path.
    */
  downloadId: function(aDownload) {
    let id = this._ids.get(aDownload, null);
    if (!id) {
      id = "download-" + this._currentId++;
      this._ids.set(aDownload, id);
      this._index[id] = aDownload;
    }
    return id;
  },

  getDownloadById: function(aId) {
    return this._index[aId];
  },

  /**
    * Converts a download object into a plain json object that we'll
    * send to the DOM side.
    */
  jsonDownload: function(aDownload) {
    let res = {
      totalBytes: aDownload.totalBytes,
      currentBytes: aDownload.currentBytes,
      url: aDownload.source.url,
      path: aDownload.target.path,
      contentType: aDownload.contentType,
      startTime: aDownload.startTime.getTime(),
      sourceAppManifestURL: aDownload._unknownProperties &&
                              aDownload._unknownProperties.sourceAppManifestURL
    };

    if (aDownload.error) {
      res.error = aDownload.error;
    }

    res.id = this.downloadId(aDownload);

    // The state of the download. Can be any of "downloading", "stopped",
    // "succeeded", finalized".

    // Default to "stopped"
    res.state = "stopped";
    if (!aDownload.stopped &&
        !aDownload.canceled &&
        !aDownload.succeeded &&
        !aDownload.DownloadError) {
      res.state = "downloading";
    } else if (aDownload.succeeded) {
      res.state = "succeeded";
    }
    return res;
  },

  /**
    * download view methods.
    */
  onDownloadAdded: function(aDownload) {
    let download = this.jsonDownload(aDownload);
    debug("onDownloadAdded " + uneval(download));
    ppmm.broadcastAsyncMessage("Downloads:Added", download);
  },

  onDownloadRemoved: function(aDownload) {
    let download = this.jsonDownload(aDownload);
    download.state = "finalized";
    debug("onDownloadRemoved " + uneval(download));
    ppmm.broadcastAsyncMessage("Downloads:Removed", download);
    this._index[this._ids.get(aDownload)] = null;
    this._ids.delete(aDownload);
  },

  onDownloadChanged: function(aDownload) {
    let download = this.jsonDownload(aDownload);
    debug("onDownloadChanged " + uneval(download));
    ppmm.broadcastAsyncMessage("Downloads:Changed", download);
  },

  receiveMessage: function(aMessage) {
    if (!aMessage.target.assertPermission("downloads")) {
      debug("No 'downloads' permission!");
      return;
    }

    debug("message: " + aMessage.name);

    switch (aMessage.name) {
    case "Downloads:GetList":
      this.getList(aMessage.data, aMessage.target);
      break;
    case "Downloads:ClearAllDone":
      this.clearAllDone(aMessage.data, aMessage.target);
      break;
    case "Downloads:Remove":
      this.remove(aMessage.data, aMessage.target);
      break;
    case "Downloads:Pause":
      this.pause(aMessage.data, aMessage.target);
      break;
    case "Downloads:Resume":
      this.resume(aMessage.data, aMessage.target);
      break;
    case "Downloads:Adopt":
      this.adoptDownload(aMessage.data, aMessage.target);
      break;
    default:
      debug("Invalid message: " + aMessage.name);
    }
  },

  getList: function(aData, aMm) {
    debug("getList called!");
    let self = this;
    Task.spawn(function () {
      let list = yield Downloads.getList(Downloads.ALL);
      let downloads = yield list.getAll();
      let res = [];
      downloads.forEach((aDownload) => {
        res.push(self.jsonDownload(aDownload));
      });
      aMm.sendAsyncMessage("Downloads:GetList:Return", res);
    }).then(null, Components.utils.reportError);
  },

  clearAllDone: function(aData, aMm) {
    debug("clearAllDone called!");
    Task.spawn(function () {
      let list = yield Downloads.getList(Downloads.ALL);
      list.removeFinished();
    }).then(null, Components.utils.reportError);
  },

  remove: function(aData, aMm) {
    debug("remove id " + aData.id);
    let download = this.getDownloadById(aData.id);
    if (!download) {
      sendPromiseMessage(aMm, "Downloads:Remove:Return",
                         aData, "NoSuchDownload");
      return;
    }

    Task.spawn(function() {
      yield download.finalize(true);
      let list = yield Downloads.getList(Downloads.ALL);
      yield list.remove(download);
    }).then(
      function() {
        sendPromiseMessage(aMm, "Downloads:Remove:Return", aData);
      },
      function() {
        sendPromiseMessage(aMm, "Downloads:Remove:Return",
                           aData, "RemoveError");
      }
    );
  },

  pause: function(aData, aMm) {
    debug("pause id " + aData.id);
    let download = this.getDownloadById(aData.id);
    if (!download) {
      sendPromiseMessage(aMm, "Downloads:Pause:Return",
                         aData, "NoSuchDownload");
      return;
    }

    download.cancel().then(
      function() {
        sendPromiseMessage(aMm, "Downloads:Pause:Return", aData);
      },
      function() {
        sendPromiseMessage(aMm, "Downloads:Pause:Return",
                           aData, "PauseError");
      }
    );
  },

  resume: function(aData, aMm) {
    debug("resume id " + aData.id);
    let download = this.getDownloadById(aData.id);
    if (!download) {
      sendPromiseMessage(aMm, "Downloads:Resume:Return",
                         aData, "NoSuchDownload");
      return;
    }

    download.start().then(
      function() {
        sendPromiseMessage(aMm, "Downloads:Resume:Return", aData);
      },
      function() {
        sendPromiseMessage(aMm, "Downloads:Resume:Return",
                           aData, "ResumeError");
      }
    );
  },

  /**
    * Receive a download to adopt in the same representation we produce from
    * our "jsonDownload" normalizer and add it to the list of downloads.
    */
  adoptDownload: function(aData, aMm) {
    let adoptJsonRep = aData.jsonDownload;
    debug("adoptDownload " + uneval(adoptJsonRep));

    Task.spawn(function* () {
      // Verify that the file exists on disk.  This will result in a rejection
      // if the file does not exist.  We will also use this information for the
      // file size to avoid weird inconsistencies.  We ignore the filesystem
      // timestamp in favor of whatever the caller is telling us.
      let fileInfo = yield OS.File.stat(adoptJsonRep.path);

      // We also require that the file is not a directory.
      if (fileInfo.isDir) {
        throw new Error("AdoptFileIsDirectory");
      }

      // We need to create a Download instance to add to the list.  Create a
      // serialized representation and then from there the instance.
      let serializedRep = {
        // explicit initializations in toSerializable
        source: {
          url: adoptJsonRep.url
          // This is where isPrivate would go if adoption supported private
          // browsing.
        },
        target: {
          path: adoptJsonRep.path,
        },
        startTime: adoptJsonRep.startTime,
        // kPlainSerializableDownloadProperties propagations
        succeeded: true, // (all adopted downloads are required to be completed)
        totalBytes: fileInfo.size,
        contentType: adoptJsonRep.contentType,
        // unknown properties added/used by the DownloadsAPI
        currentBytes: fileInfo.size,
        sourceAppManifestURL: adoptJsonRep.sourceAppManifestURL
      };

      let download = yield Downloads.createDownload(serializedRep);

      // The ALL list is a DownloadCombinedList instance that combines the
      // PUBLIC (persisted to disk) and PRIVATE (ephemeral) download lists..
      // When we call add on it, it dispatches to the appropriate list based on
      // the 'isPrivate' field of the source.  (Which we don't initialize and
      // defaults to false.)
      let allDownloadList = yield Downloads.getList(Downloads.ALL);

      // This add will automatically notify all views of the added download,
      // including DownloadsAPI instances and the DownloadAutoSaveView that's
      // subscribed to the PUBLIC list and will save the download.
      yield allDownloadList.add(download);

      debug("download adopted");
      // The notification above occurred synchronously, and so we will have
      // already dispatched an added notification for our download to the child
      // process in question.  As such, we only need to relay the download id
      // since the download will already have been cached.
      return download;
    }.bind(this)).then(
      (download) => {
        sendPromiseMessage(aMm, "Downloads:Adopt:Return",
                           {
                             id: this.downloadId(download),
                             promiseId: aData.promiseId
                           });
      },
      (ex) => {
        let reportAs = "AdoptError";
        // Provide better error codes for expected errors.
        if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
          reportAs = "AdoptNoSuchFile";
        } else if (ex.message === "AdoptFileIsDirectory") {
          reportAs = ex.message;
        } else {
          // Anything else is unexpected and should be reported to help track
          // down what's going wrong.
          debug("unexpected download error: " + ex);
          Cu.reportError(ex);
        }
        sendPromiseMessage(aMm, "Downloads:Adopt:Return",
                           {
                             promiseId: aData.promiseId
                           },
                           reportAs);
    });
  }
};

DownloadsAPI.init();