/* 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 {Ci, Cc, Cr} = require("chrome");
const {OS} = require("resource://gre/modules/osfile.jsm");
const {FileUtils} = require("resource://gre/modules/FileUtils.jsm");
const {NetUtil} = require("resource://gre/modules/NetUtil.jsm");
const promise = require("promise");
const defer = require("devtools/shared/defer");
const DevToolsUtils = require("devtools/shared/DevToolsUtils");
const EventEmitter = require("devtools/shared/event-emitter");

// Bug 1188401: When loaded from xpcshell tests, we do not have browser/ files
// and can't load target.js. Should be fixed by bug 912121.
loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true);

// XXX: bug 912476 make this module a real protocol.js front
// by converting webapps actor to protocol.js

const PR_USEC_PER_MSEC = 1000;
const PR_RDWR = 0x04;
const PR_CREATE_FILE = 0x08;
const PR_TRUNCATE = 0x20;

const CHUNK_SIZE = 10000;

const appTargets = new Map();

function addDirToZip(writer, dir, basePath) {
  let files = dir.directoryEntries;

  while (files.hasMoreElements()) {
    let file = files.getNext().QueryInterface(Ci.nsIFile);

    if (file.isHidden() ||
        file.isSpecial() ||
        file.equals(writer.file))
    {
      continue;
    }

    if (file.isDirectory()) {
      writer.addEntryDirectory(basePath + file.leafName + "/",
                               file.lastModifiedTime * PR_USEC_PER_MSEC,
                               true);
      addDirToZip(writer, file, basePath + file.leafName + "/");
    } else {
      writer.addEntryFile(basePath + file.leafName,
                          Ci.nsIZipWriter.COMPRESSION_DEFAULT,
                          file,
                          true);
    }
  }
}

/**
 * Convert an XPConnect result code to its name and message.
 * We have to extract them from an exception per bug 637307 comment 5.
 */
function getResultText(code) {
  let regexp =
    /^\[Exception... "(.*)"  nsresult: "0x[0-9a-fA-F]* \((.*)\)"  location: ".*"  data: .*\]$/;
  let ex = Cc["@mozilla.org/js/xpc/Exception;1"].
           createInstance(Ci.nsIXPCException);
  ex.initialize(null, code, null, null, null, null);
  let [, message, name] = regexp.exec(ex.toString());
  return { name: name, message: message };
}

function zipDirectory(zipFile, dirToArchive) {
  let deferred = defer();
  let writer = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
  writer.open(zipFile, PR_RDWR | PR_CREATE_FILE | PR_TRUNCATE);

  this.addDirToZip(writer, dirToArchive, "");

  writer.processQueue({
    onStartRequest: function onStartRequest(request, context) {},
    onStopRequest: (request, context, status) => {
      if (status == Cr.NS_OK) {
        writer.close();
        deferred.resolve(zipFile);
      }
      else {
        let { name, message } = getResultText(status);
        deferred.reject(name + ": " + message);
      }
    }
  }, null);

  return deferred.promise;
}

function uploadPackage(client, webappsActor, packageFile, progressCallback) {
  if (client.traits.bulk) {
    return uploadPackageBulk(client, webappsActor, packageFile, progressCallback);
  } else {
    return uploadPackageJSON(client, webappsActor, packageFile, progressCallback);
  }
}

function uploadPackageJSON(client, webappsActor, packageFile, progressCallback) {
  let deferred = defer();

  let request = {
    to: webappsActor,
    type: "uploadPackage"
  };
  client.request(request, (res) => {
    openFile(res.actor);
  });

  let fileSize;
  let bytesRead = 0;

  function emitProgress() {
    progressCallback({
      bytesSent: bytesRead,
      totalBytes: fileSize
    });
  }

  function openFile(actor) {
    let openedFile;
    OS.File.open(packageFile.path)
      .then(file => {
        openedFile = file;
        return openedFile.stat();
      })
      .then(fileInfo => {
        fileSize = fileInfo.size;
        emitProgress();
        uploadChunk(actor, openedFile);
      });
  }
  function uploadChunk(actor, file) {
    file.read(CHUNK_SIZE)
        .then(function (bytes) {
          bytesRead += bytes.length;
          emitProgress();
          // To work around the fact that JSON.stringify translates the typed
          // array to object, we are encoding the typed array here into a string
          let chunk = String.fromCharCode.apply(null, bytes);

          let request = {
            to: actor,
            type: "chunk",
            chunk: chunk
          };
          client.request(request, (res) => {
            if (bytes.length == CHUNK_SIZE) {
              uploadChunk(actor, file);
            } else {
              file.close().then(function () {
                endsUpload(actor);
              });
            }
          });
        });
  }
  function endsUpload(actor) {
    let request = {
      to: actor,
      type: "done"
    };
    client.request(request, (res) => {
      deferred.resolve(actor);
    });
  }
  return deferred.promise;
}

function uploadPackageBulk(client, webappsActor, packageFile, progressCallback) {
  let deferred = defer();

  let request = {
    to: webappsActor,
    type: "uploadPackage",
    bulk: true
  };
  client.request(request, (res) => {
    startBulkUpload(res.actor);
  });

  function startBulkUpload(actor) {
    console.log("Starting bulk upload");
    let fileSize = packageFile.fileSize;
    console.log("File size: " + fileSize);

    let request = client.startBulkRequest({
      actor: actor,
      type: "stream",
      length: fileSize
    });

    request.on("bulk-send-ready", ({copyFrom}) => {
      NetUtil.asyncFetch({
        uri: NetUtil.newURI(packageFile),
        loadUsingSystemPrincipal: true
      }, function (inputStream) {
        let copying = copyFrom(inputStream);
        copying.on("progress", (e, progress) => {
          progressCallback(progress);
        });
        copying.then(() => {
          console.log("Bulk upload done");
          inputStream.close();
          deferred.resolve(actor);
        });
      });
    });
  }

  return deferred.promise;
}

function removeServerTemporaryFile(client, fileActor) {
  let request = {
    to: fileActor,
    type: "remove"
  };
  client.request(request);
}

/**
 * progressCallback argument:
 * Function called as packaged app installation proceeds.
 * The progress object passed to this function contains:
 *  * bytesSent:  The number of bytes sent so far
 *  * totalBytes: The total number of bytes to send
 */
function installPackaged(client, webappsActor, packagePath, appId, progressCallback) {
  let deferred = defer();
  let file = FileUtils.File(packagePath);
  let packagePromise;
  if (file.isDirectory()) {
    let tmpZipFile = FileUtils.getDir("TmpD", [], true);
    tmpZipFile.append("application.zip");
    tmpZipFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8));
    packagePromise = zipDirectory(tmpZipFile, file);
  } else {
    packagePromise = promise.resolve(file);
  }
  packagePromise.then((zipFile) => {
    uploadPackage(client, webappsActor, zipFile, progressCallback)
        .then((fileActor) => {
          let request = {
            to: webappsActor,
            type: "install",
            appId: appId,
            upload: fileActor
          };
          client.request(request, (res) => {
            // If the install method immediatly fails,
            // reject immediatly the installPackaged promise.
            // Otherwise, wait for webappsEvent for completion
            if (res.error) {
              deferred.reject(res);
            }
            if ("error" in res)
              deferred.reject({error: res.error, message: res.message});
            else
              deferred.resolve({appId: res.appId});
          });
          // Ensure deleting the temporary package file, but only if that a temporary
          // package created when we pass a directory as `packagePath`
          if (zipFile != file)
            zipFile.remove(false);
          // In case of success or error, ensure deleting the temporary package file
          // also created on the device, but only once install request is done
          deferred.promise.then(
            () => removeServerTemporaryFile(client, fileActor),
            () => removeServerTemporaryFile(client, fileActor));
        });
  });
  return deferred.promise;
}
exports.installPackaged = installPackaged;

function installHosted(client, webappsActor, appId, metadata, manifest) {
  let deferred = defer();
  let request = {
    to: webappsActor,
    type: "install",
    appId: appId,
    metadata: metadata,
    manifest: manifest
  };
  client.request(request, (res) => {
    if (res.error) {
      deferred.reject(res);
    }
    if ("error" in res)
      deferred.reject({error: res.error, message: res.message});
    else
      deferred.resolve({appId: res.appId});
  });
  return deferred.promise;
}
exports.installHosted = installHosted;

function getTargetForApp(client, webappsActor, manifestURL) {
  // Ensure always returning the exact same JS object for a target
  // of the same app in order to show only one toolbox per app and
  // avoid re-creating lot of objects twice.
  let existingTarget = appTargets.get(manifestURL);
  if (existingTarget)
    return promise.resolve(existingTarget);

  let deferred = defer();
  let request = {
    to: webappsActor,
    type: "getAppActor",
    manifestURL: manifestURL,
  };
  client.request(request, (res) => {
    if (res.error) {
      deferred.reject(res.error);
    } else {
      let options = {
        form: res.actor,
        client: client,
        chrome: false
      };

      TargetFactory.forRemoteTab(options).then((target) => {
        target.isApp = true;
        appTargets.set(manifestURL, target);
        target.on("close", () => {
          appTargets.delete(manifestURL);
        });
        deferred.resolve(target);
      }, (error) => {
        deferred.reject(error);
      });
    }
  });
  return deferred.promise;
}
exports.getTargetForApp = getTargetForApp;

function reloadApp(client, webappsActor, manifestURL) {
  return getTargetForApp(client,
                         webappsActor,
                         manifestURL).
    then((target) => {
      // Request the ContentActor to reload the app
      let request = {
        to: target.form.actor,
        type: "reload",
        options: {
          force: true
        },
        manifestURL: manifestURL
      };
      return client.request(request);
    }, () => {
      throw new Error("Not running");
    });
}
exports.reloadApp = reloadApp;

function launchApp(client, webappsActor, manifestURL) {
  return client.request({
    to: webappsActor,
    type: "launch",
    manifestURL: manifestURL
  });
}
exports.launchApp = launchApp;

function closeApp(client, webappsActor, manifestURL) {
  return client.request({
    to: webappsActor,
    type: "close",
    manifestURL: manifestURL
  });
}
exports.closeApp = closeApp;

function getTarget(client, form) {
  let deferred = defer();
  let options = {
    form: form,
    client: client,
    chrome: false
  };

  TargetFactory.forRemoteTab(options).then((target) => {
    target.isApp = true;
    deferred.resolve(target);
  }, (error) => {
    deferred.reject(error);
  });
  return deferred.promise;
}

/**
 * `App` instances are client helpers to manage a given app
 * and its the tab actors
 */
function App(client, webappsActor, manifest) {
  this.client = client;
  this.webappsActor = webappsActor;
  this.manifest = manifest;

  // This attribute is managed by the AppActorFront
  this.running = false;

  this.iconURL = null;
}

App.prototype = {
  getForm: function () {
    if (this._form) {
      return promise.resolve(this._form);
    }
    let request = {
      to: this.webappsActor,
      type: "getAppActor",
      manifestURL: this.manifest.manifestURL
    };
    return this.client.request(request)
                      .then(res => {
                        return this._form = res.actor;
                      });
  },

  getTarget: function () {
    if (this._target) {
      return promise.resolve(this._target);
    }
    return this.getForm().
      then((form) => getTarget(this.client, form)).
      then((target) => {
        target.on("close", () => {
          delete this._form;
          delete this._target;
        });
        return this._target = target;
      });
  },

  launch: function () {
    return launchApp(this.client, this.webappsActor,
                     this.manifest.manifestURL);
  },

  reload: function () {
    return reloadApp(this.client, this.webappsActor,
                     this.manifest.manifestURL);
  },

  close: function () {
    return closeApp(this.client, this.webappsActor,
                    this.manifest.manifestURL);
  },

  getIcon: function () {
    if (this.iconURL) {
      return promise.resolve(this.iconURL);
    }

    let deferred = defer();

    let request = {
      to: this.webappsActor,
      type: "getIconAsDataURL",
      manifestURL: this.manifest.manifestURL
    };

    this.client.request(request, res => {
      if (res.error) {
        deferred.reject(res.message || res.error);
      } else if (res.url) {
        this.iconURL = res.url;
        deferred.resolve(res.url);
      } else {
        deferred.reject("Unable to fetch app icon");
      }
    });

    return deferred.promise;
  }
};


/**
 * `AppActorFront` is a client for the webapps actor.
 */
function AppActorFront(client, form) {
  this.client = client;
  this.actor = form.webappsActor;

  this._clientListener = this._clientListener.bind(this);
  this._onInstallProgress = this._onInstallProgress.bind(this);

  this._listeners = [];
  EventEmitter.decorate(this);
}

AppActorFront.prototype = {
  /**
   * List `App` instances for all currently running apps.
   */
  get runningApps() {
    if (!this._apps) {
      throw new Error("Can't get running apps before calling watchApps.");
    }
    let r = new Map();
    for (let [manifestURL, app] of this._apps) {
      if (app.running) {
        r.set(manifestURL, app);
      }
    }
    return r;
  },

  /**
   * List `App` instances for all installed apps.
   */
  get apps() {
    if (!this._apps) {
      throw new Error("Can't get apps before calling watchApps.");
    }
    return this._apps;
  },

  /**
   * Returns a `App` object instance for the given manifest URL
   * (and cache it per AppActorFront object)
   */
  _getApp: function (manifestURL) {
    let app = this._apps ? this._apps.get(manifestURL) : null;
    if (app) {
      return promise.resolve(app);
    } else {
      let request = {
        to: this.actor,
        type: "getApp",
        manifestURL: manifestURL
      };
      return this.client.request(request)
                 .then(res => {
                   let app = new App(this.client, this.actor, res.app);
                   if (this._apps) {
                     this._apps.set(manifestURL, app);
                   }
                   return app;
                 }, e => {
                   console.error("Unable to retrieve app", manifestURL, e);
                 });
    }
  },

  /**
   * Starts watching for app opening/closing installing/uninstalling.
   * Needs to be called before using `apps` or `runningApps` attributes.
   */
  watchApps: function (listener) {
    // Fixes race between two references to the same front
    // calling watchApps at the same time
    if (this._loadingPromise) {
      return this._loadingPromise;
    }

    // Only call watchApps for the first listener being register,
    // for all next ones, just send fake appOpen events for already
    // opened apps
    if (this._apps) {
      this.runningApps.forEach((app, manifestURL) => {
        listener("appOpen", app);
      });
      return promise.resolve();
    }

    // First retrieve all installed apps and create
    // related `App` object for each
    let request = {
      to: this.actor,
      type: "getAll"
    };
    return this._loadingPromise = this.client.request(request)
      .then(res => {
        delete this._loadingPromise;
        this._apps = new Map();
        for (let a of res.apps) {
          let app = new App(this.client, this.actor, a);
          this._apps.set(a.manifestURL, app);
        }
      })
      .then(() => {
        // Then retrieve all running apps in order to flag them as running
        let request = {
          to: this.actor,
          type: "listRunningApps"
        };
        return this.client.request(request)
                   .then(res => res.apps);
      })
      .then(apps => {
        let promises = apps.map(manifestURL => {
          // _getApp creates `App` instance and register it to AppActorFront
          return this._getApp(manifestURL)
                      .then(app => {
                        app.running = true;
                        // Fake appOpen event for all already opened
                        this._notifyListeners("appOpen", app);
                      });
        });
        return promise.all(promises);
      })
      .then(() => {
        // Finally ask to receive all app events
        return this._listenAppEvents(listener);
      });
  },

  fetchIcons: function () {
    // On demand, retrieve apps icons in order to be able
    // to synchronously retrieve it on `App` objects
    let promises = [];
    for (let [manifestURL, app] of this._apps) {
      promises.push(app.getIcon());
    }

    return DevToolsUtils.settleAll(promises)
                        .then(null, () => {});
  },

  _listenAppEvents: function (listener) {
    this._listeners.push(listener);

    if (this._listeners.length > 1) {
      return promise.resolve();
    }

    let client = this.client;
    let f = this._clientListener;
    client.addListener("appOpen", f);
    client.addListener("appClose", f);
    client.addListener("appInstall", f);
    client.addListener("appUninstall", f);

    let request = {
      to: this.actor,
      type: "watchApps"
    };
    return this.client.request(request);
  },

  _unlistenAppEvents: function (listener) {
    let idx = this._listeners.indexOf(listener);
    if (idx != -1) {
      this._listeners.splice(idx, 1);
    }

    // Until we released all listener, we don't ask to stop sending events
    if (this._listeners.length != 0) {
      return promise.resolve();
    }

    let client = this.client;
    let f = this._clientListener;
    client.removeListener("appOpen", f);
    client.removeListener("appClose", f);
    client.removeListener("appInstall", f);
    client.removeListener("appUninstall", f);

    // Remove `_apps` in order to allow calling watchApps again
    // and repopulate the apps Map.
    delete this._apps;

    let request = {
      to: this.actor,
      type: "unwatchApps"
    };
    return this.client.request(request);
  },

  _clientListener: function (type, message) {
    let { manifestURL } = message;

    // Reset the app object to get a fresh copy when we (re)install the app.
    if (type == "appInstall" && this._apps && this._apps.has(manifestURL)) {
      this._apps.delete(manifestURL);
    }

    this._getApp(manifestURL).then((app) => {
      switch (type) {
        case "appOpen":
          app.running = true;
          this._notifyListeners("appOpen", app);
          break;
        case "appClose":
          app.running = false;
          this._notifyListeners("appClose", app);
          break;
        case "appInstall":
          // The call to _getApp is going to create App object

          // This app may have been running while being installed, so check the list
          // of running apps again to get the right answer.
          let request = {
            to: this.actor,
            type: "listRunningApps"
          };
          this.client.request(request)
              .then(res => {
                if (res.apps.indexOf(manifestURL) !== -1) {
                  app.running = true;
                  this._notifyListeners("appInstall", app);
                  this._notifyListeners("appOpen", app);
                } else {
                  this._notifyListeners("appInstall", app);
                }
              });
          break;
        case "appUninstall":
          // Fake a appClose event if we didn't got one before uninstall
          if (app.running) {
            app.running = false;
            this._notifyListeners("appClose", app);
          }
          this._apps.delete(manifestURL);
          this._notifyListeners("appUninstall", app);
          break;
        default:
          return;
      }
    });
  },

  _notifyListeners: function (type, app) {
    this._listeners.forEach(f => {
      f(type, app);
    });
  },

  unwatchApps: function (listener) {
    return this._unlistenAppEvents(listener);
  },

  /*
   * Install a packaged app.
   *
   * Events are going to be emitted on the front
   * as install progresses. Events will have the following fields:
   *  * bytesSent:  The number of bytes sent so far
   *  * totalBytes: The total number of bytes to send
   */
  installPackaged: function (packagePath, appId) {
    let request = () => {
      return installPackaged(this.client, this.actor, packagePath, appId,
                             this._onInstallProgress)
      .then(response => ({
        appId: response.appId,
        manifestURL: "app://" + response.appId + "/manifest.webapp"
      }));
    };
    return this._install(request);
  },

  _onInstallProgress: function (progress) {
    this.emit("install-progress", progress);
  },

  _install: function (request) {
    let deferred = defer();
    let finalAppId = null, manifestURL = null;
    let installs = {};

    // We need to resolve only once the request is done *AND*
    // once we receive the related appInstall message for
    // the same manifestURL
    let resolve = app => {
      this._unlistenAppEvents(listener);
      installs = null;
      deferred.resolve({ app: app, appId: finalAppId });
    };

    // Listen for appInstall event, in order to resolve with
    // the matching app object.
    let listener = (type, app) => {
      if (type == "appInstall") {
        // Resolves immediately if the request has already resolved
        // or just flag the installed app to eventually resolve
        // when the request gets its response.
        if (app.manifest.manifestURL === manifestURL) {
          resolve(app);
        } else {
          installs[app.manifest.manifestURL] = app;
        }
      }
    };
    this._listenAppEvents(listener)
        // Execute the request
        .then(request)
        .then(response => {
          finalAppId = response.appId;
          manifestURL = response.manifestURL;

          // Resolves immediately if the appInstall event
          // was dispatched during the request.
          if (manifestURL in installs) {
            resolve(installs[manifestURL]);
          }
        }, deferred.reject);

    return deferred.promise;

  },

  /*
   * Install a hosted app.
   *
   * Events are going to be emitted on the front
   * as install progresses. Events will have the following fields:
   *  * bytesSent:  The number of bytes sent so far
   *  * totalBytes: The total number of bytes to send
   */
  installHosted: function (appId, metadata, manifest) {
    let manifestURL = metadata.manifestURL ||
                      metadata.origin + "/manifest.webapp";
    let request = () => {
      return installHosted(this.client, this.actor, appId, metadata,
                           manifest)
        .then(response => ({
          appId: response.appId,
          manifestURL: manifestURL
        }));
    };
    return this._install(request);
  }
};

exports.AppActorFront = AppActorFront;
