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

const {classes: Cc, interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/KeyValueParser.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.importGlobalProperties(['File']);

XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                  "resource://gre/modules/PromiseUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                  "resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                  "resource://gre/modules/Task.jsm");

this.EXPORTED_SYMBOLS = [
  "CrashSubmit"
];

const STATE_START = Ci.nsIWebProgressListener.STATE_START;
const STATE_STOP = Ci.nsIWebProgressListener.STATE_STOP;

const SUCCESS = "success";
const FAILED  = "failed";
const SUBMITTING = "submitting";

const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function parseINIStrings(file) {
  var factory = Cc["@mozilla.org/xpcom/ini-parser-factory;1"].
                getService(Ci.nsIINIParserFactory);
  var parser = factory.createINIParser(file);
  var obj = {};
  var en = parser.getKeys("Strings");
  while (en.hasMore()) {
    var key = en.getNext();
    obj[key] = parser.getString("Strings", key);
  }
  return obj;
}

// Since we're basically re-implementing part of the crashreporter
// client here, we'll just steal the strings we need from crashreporter.ini
function getL10nStrings() {
  let dirSvc = Cc["@mozilla.org/file/directory_service;1"].
               getService(Ci.nsIProperties);
  let path = dirSvc.get("GreD", Ci.nsIFile);
  path.append("crashreporter.ini");
  if (!path.exists()) {
    // see if we're on a mac
    path = path.parent;
    path = path.parent;
    path.append("MacOS");
    path.append("crashreporter.app");
    path.append("Contents");
    path.append("Resources");
    path.append("crashreporter.ini");
    if (!path.exists()) {
      // very bad, but I don't know how to recover
      return null;
    }
  }
  let crstrings = parseINIStrings(path);
  let strings = {
    'crashid': crstrings.CrashID,
    'reporturl': crstrings.CrashDetailsURL
  };

  path = dirSvc.get("XCurProcD", Ci.nsIFile);
  path.append("crashreporter-override.ini");
  if (path.exists()) {
    crstrings = parseINIStrings(path);
    if ('CrashID' in crstrings)
      strings['crashid'] = crstrings.CrashID;
    if ('CrashDetailsURL' in crstrings)
      strings['reporturl'] = crstrings.CrashDetailsURL;
  }
  return strings;
}

XPCOMUtils.defineLazyGetter(this, "strings", getL10nStrings);

function getDir(name) {
  let directoryService = Cc["@mozilla.org/file/directory_service;1"].
                         getService(Ci.nsIProperties);
  let dir = directoryService.get("UAppData", Ci.nsIFile);
  dir.append("Crash Reports");
  dir.append(name);
  return dir;
}

function writeFile(dirName, fileName, data) {
  let path = getDir(dirName);
  if (!path.exists())
    path.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt('0700', 8));
  path.append(fileName);
  var fs = Cc["@mozilla.org/network/file-output-stream;1"].
           createInstance(Ci.nsIFileOutputStream);
  // open, write, truncate
  fs.init(path, -1, -1, 0);
  var os = Cc["@mozilla.org/intl/converter-output-stream;1"].
           createInstance(Ci.nsIConverterOutputStream);
  os.init(fs, "UTF-8", 0, 0x0000);
  os.writeString(data);
  os.close();
  fs.close();
}

function getPendingMinidump(id) {
  let pendingDir = getDir("pending");
  let dump = pendingDir.clone();
  let extra = pendingDir.clone();
  let memory = pendingDir.clone();
  dump.append(id + ".dmp");
  extra.append(id + ".extra");
  memory.append(id + ".memory.json.gz");
  return [dump, extra, memory];
}

function getAllPendingMinidumpsIDs() {
  let minidumps = [];
  let pendingDir = getDir("pending");

  if (!(pendingDir.exists() && pendingDir.isDirectory()))
    return [];
  let entries = pendingDir.directoryEntries;

  while (entries.hasMoreElements()) {
    let entry = entries.getNext().QueryInterface(Ci.nsIFile);
    if (entry.isFile()) {
      let matches = entry.leafName.match(/(.+)\.extra$/);
      if (matches)
        minidumps.push(matches[1]);
    }
  }

  return minidumps;
}

function pruneSavedDumps() {
  const KEEP = 10;

  let pendingDir = getDir("pending");
  if (!(pendingDir.exists() && pendingDir.isDirectory()))
    return;
  let entries = pendingDir.directoryEntries;
  let entriesArray = [];

  while (entries.hasMoreElements()) {
    let entry = entries.getNext().QueryInterface(Ci.nsIFile);
    if (entry.isFile()) {
      let matches = entry.leafName.match(/(.+)\.extra$/);
      if (matches)
        entriesArray.push(entry);
    }
  }

  entriesArray.sort(function(a, b) {
    let dateA = a.lastModifiedTime;
    let dateB = b.lastModifiedTime;
    if (dateA < dateB)
      return -1;
    if (dateB < dateA)
      return 1;
    return 0;
  });

  if (entriesArray.length > KEEP) {
    for (let i = 0; i < entriesArray.length - KEEP; ++i) {
      let extra = entriesArray[i];
      let matches = extra.leafName.match(/(.+)\.extra$/);
      if (matches) {
        let dump = extra.clone();
        dump.leafName = matches[1] + '.dmp';
        dump.remove(false);

        let memory = extra.clone();
        memory.leafName = matches[1] + '.memory.json.gz';
        if (memory.exists()) {
          memory.remove(false);
        }

        extra.remove(false);
      }
    }
  }
}

function addFormEntry(doc, form, name, value) {
  var input = doc.createElement("input");
  input.type = "hidden";
  input.name = name;
  input.value = value;
  form.appendChild(input);
}

function writeSubmittedReport(crashID, viewURL) {
  var data = strings.crashid.replace("%s", crashID);
  if (viewURL)
     data += "\n" + strings.reporturl.replace("%s", viewURL);

  writeFile("submitted", crashID + ".txt", data);
}

// the Submitter class represents an individual submission.
function Submitter(id, recordSubmission, noThrottle, extraExtraKeyVals) {
  this.id = id;
  this.recordSubmission = recordSubmission;
  this.noThrottle = noThrottle;
  this.additionalDumps = [];
  this.extraKeyVals = extraExtraKeyVals || {};
  this.deferredSubmit = PromiseUtils.defer();
}

Submitter.prototype = {
  submitSuccess: function Submitter_submitSuccess(ret)
  {
    // Write out the details file to submitted/
    writeSubmittedReport(ret.CrashID, ret.ViewURL);

    // Delete from pending dir
    try {
      this.dump.remove(false);
      this.extra.remove(false);

      if (this.memory) {
        this.memory.remove(false);
      }

      for (let i of this.additionalDumps) {
        i.dump.remove(false);
      }
    }
    catch (ex) {
      // report an error? not much the user can do here.
    }

    this.notifyStatus(SUCCESS, ret);
    this.cleanup();
  },

  cleanup: function Submitter_cleanup() {
    // drop some references just to be nice
    this.iframe = null;
    this.dump = null;
    this.extra = null;
    this.memory = null;
    this.additionalDumps = null;
    // remove this object from the list of active submissions
    let idx = CrashSubmit._activeSubmissions.indexOf(this);
    if (idx != -1)
      CrashSubmit._activeSubmissions.splice(idx, 1);
  },

  submitForm: function Submitter_submitForm()
  {
    if (!('ServerURL' in this.extraKeyVals)) {
      return false;
    }
    let serverURL = this.extraKeyVals.ServerURL;

    // Override the submission URL from the environment

    var envOverride = Cc['@mozilla.org/process/environment;1'].
      getService(Ci.nsIEnvironment).get("MOZ_CRASHREPORTER_URL");
    if (envOverride != '') {
      serverURL = envOverride;
    }

    let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
              .createInstance(Ci.nsIXMLHttpRequest);
    xhr.open("POST", serverURL, true);

    let formData = Cc["@mozilla.org/files/formdata;1"]
                   .createInstance(Ci.nsIDOMFormData);
    // add the data
    for (let [name, value] of Object.entries(this.extraKeyVals)) {
      if (name != "ServerURL") {
        formData.append(name, value);
      }
    }
    if (this.noThrottle) {
      // tell the server not to throttle this, since it was manually submitted
      formData.append("Throttleable", "0");
    }
    // add the minidumps
    formData.append("upload_file_minidump", File.createFromFileName(this.dump.path));
    if (this.memory) {
      formData.append("memory_report", File.createFromFileName(this.memory.path));
    }
    if (this.additionalDumps.length > 0) {
      let names = [];
      for (let i of this.additionalDumps) {
        names.push(i.name);
        formData.append("upload_file_minidump_"+i.name,
                        File.createFromFileName(i.dump.path));
      }
    }

    let manager = Services.crashmanager;
    let submissionID = manager.generateSubmissionID();

    xhr.addEventListener("readystatechange", (evt) => {
      if (xhr.readyState == 4) {
        let ret =
          xhr.status == 200 ? parseKeyValuePairs(xhr.responseText) : {};
        let submitted = !!ret.CrashID;

        if (this.recordSubmission) {
          let result = submitted ? manager.SUBMISSION_RESULT_OK :
                                   manager.SUBMISSION_RESULT_FAILED;
          manager.addSubmissionResult(this.id, submissionID, new Date(),
                                      result);
          if (submitted) {
            manager.setRemoteCrashID(this.id, ret.CrashID);
          }
        }

        if (submitted) {
          this.submitSuccess(ret);
        }
        else {
           this.notifyStatus(FAILED);
           this.cleanup();
        }
      }
    }, false);

    if (this.recordSubmission) {
      manager.addSubmissionAttempt(this.id, submissionID, new Date());
    }
    xhr.send(formData);
    return true;
  },

  notifyStatus: function Submitter_notify(status, ret)
  {
    let propBag = Cc["@mozilla.org/hash-property-bag;1"].
                  createInstance(Ci.nsIWritablePropertyBag2);
    propBag.setPropertyAsAString("minidumpID", this.id);
    if (status == SUCCESS) {
      propBag.setPropertyAsAString("serverCrashID", ret.CrashID);
    }

    let extraKeyValsBag = Cc["@mozilla.org/hash-property-bag;1"].
                          createInstance(Ci.nsIWritablePropertyBag2);
    for (let key in this.extraKeyVals) {
      extraKeyValsBag.setPropertyAsAString(key, this.extraKeyVals[key]);
    }
    propBag.setPropertyAsInterface("extra", extraKeyValsBag);

    Services.obs.notifyObservers(propBag, "crash-report-status", status);

    switch (status) {
      case SUCCESS:
        this.deferredSubmit.resolve(ret.CrashID);
        break;
      case FAILED:
        this.deferredSubmit.reject();
        break;
      default:
        // no callbacks invoked.
    }
  },

  submit: function Submitter_submit()
  {
    let [dump, extra, memory] = getPendingMinidump(this.id);

    if (!dump.exists() || !extra.exists()) {
      this.notifyStatus(FAILED);
      this.cleanup();
      return this.deferredSubmit.promise;
    }
    this.dump = dump;
    this.extra = extra;

    // The memory file may or may not exist
    if (memory.exists()) {
      this.memory = memory;
    }

    let extraKeyVals = parseKeyValuePairsFromFile(extra);
    for (let key in extraKeyVals) {
      if (!(key in this.extraKeyVals)) {
        this.extraKeyVals[key] = extraKeyVals[key];
      }
    }

    let additionalDumps = [];
    if ("additional_minidumps" in this.extraKeyVals) {
      let names = this.extraKeyVals.additional_minidumps.split(',');
      for (let name of names) {
        let [dump, extra, memory] = getPendingMinidump(this.id + "-" + name);
        if (!dump.exists()) {
          this.notifyStatus(FAILED);
          this.cleanup();
          return this.deferredSubmit.promise;
        }
        additionalDumps.push({'name': name, 'dump': dump});
      }
    }

    this.notifyStatus(SUBMITTING);

    this.additionalDumps = additionalDumps;

    if (!this.submitForm()) {
       this.notifyStatus(FAILED);
       this.cleanup();
    }
    return this.deferredSubmit.promise;
  }
};

// ===================================
// External API goes here
this.CrashSubmit = {
  /**
   * Submit the crash report named id.dmp from the "pending" directory.
   *
   * @param id
   *        Filename (minus .dmp extension) of the minidump to submit.
   * @param params
   *        An object containing any of the following optional parameters:
   *        - recordSubmission
   *          If true, a submission event is recorded in CrashManager.
   *        - noThrottle
   *          If true, this crash report should be submitted with
   *          an extra parameter of "Throttleable=0" indicating that
   *          it should be processed right away. This should be set
   *          when the report is being submitted and the user expects
   *          to see the results immediately. Defaults to false.
   *        - extraExtraKeyVals
   *          An object whose key-value pairs will be merged with the data from
   *          the ".extra" file submitted with the report.  The properties of
   *          this object will override properties of the same name in the
   *          .extra file.
   *
   *  @return a Promise that is fulfilled with the server crash ID when the
   *          submission succeeds and rejected otherwise.
   */
  submit: function CrashSubmit_submit(id, params)
  {
    params = params || {};
    let recordSubmission = false;
    let submitSuccess = null;
    let submitError = null;
    let noThrottle = false;
    let extraExtraKeyVals = null;

    if ('recordSubmission' in params)
      recordSubmission = params.recordSubmission;
    if ('noThrottle' in params)
      noThrottle = params.noThrottle;
    if ('extraExtraKeyVals' in params)
      extraExtraKeyVals = params.extraExtraKeyVals;

    let submitter = new Submitter(id, recordSubmission,
                                  noThrottle, extraExtraKeyVals);
    CrashSubmit._activeSubmissions.push(submitter);
    return submitter.submit();
  },

  /**
   * Delete the minidup from the "pending" directory.
   *
   * @param id
   *        Filename (minus .dmp extension) of the minidump to delete.
   */
  delete: function CrashSubmit_delete(id) {
    let [dump, extra, memory] = getPendingMinidump(id);
    dump.remove(false);
    extra.remove(false);
    if (memory.exists()) {
      memory.remove(false);
    }
  },

  /**
   * Add a .dmg.ignore file along side the .dmp file to indicate that the user
   * shouldn't be prompted to submit this crash report again.
   *
   * @param id
   *        Filename (minus .dmp extension) of the report to ignore
   */

  ignore: function CrashSubmit_ignore(id) {
    let [dump, extra, mem] = getPendingMinidump(id);
    return OS.File.open(dump.path + ".ignore", {create: true},
                        {unixFlags: OS.Constants.libc.O_CREAT})
      .then((file) => { file.close(); });
  },

  /**
   * Get the list of pending crash IDs.
   *
   * @return an array of string, each being an ID as
   *         expected to be passed to submit()
   */
  pendingIDs: function CrashSubmit_pendingIDs() {
    return getAllPendingMinidumpsIDs();
  },

  /**
   * Get the list of pending crash IDs, excluding those marked to be ignored
   * @param maxFileDate
   *     A Date object. Any files last modified before that date will be ignored
   *
   * @return a Promise that is fulfilled with an array of string, each
   * being an ID as expected to be passed to submit() or ignore()
   */
  pendingIDsAsync: Task.async(function* CrashSubmit_pendingIDsAsync(maxFileDate) {
    let ids = [];
    let info = null;
    try {
      info = yield OS.File.stat(getDir("pending").path)
    } catch (ex) {
      /* pending dir doesn't exist, ignore */
      return ids;
    }

    if (info.isDir) {
      let iterator = new OS.File.DirectoryIterator(getDir("pending").path);
      try {
        yield iterator.forEach(
          function onEntry(file) {
            if (file.name.endsWith(".dmp")) {
              return OS.File.exists(file.path + ".ignore")
                .then(ignoreExists => {
                  if (!ignoreExists) {
                    let id = file.name.slice(0, -4);
                    if (UUID_REGEX.test(id)) {
                      return OS.File.stat(file.path)
                        .then(info => {
                          if (info.lastAccessDate.valueOf() >
                              maxFileDate.valueOf()) {
                            ids.push(id);
                          }
                        });
                    }
                  }
                  return null;
                });
            }
            return null;
          }
        );
      } catch (ex) {
        Cu.reportError(ex);
      } finally {
        iterator.close();
      }
    }
    return ids;
  }),

  /**
   * Prune the saved dumps.
   */
  pruneSavedDumps: function CrashSubmit_pruneSavedDumps() {
    pruneSavedDumps();
  },

  // List of currently active submit objects
  _activeSubmissions: []
};