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

let Cc = Components.classes;
let Ci = Components.interfaces;
let Cu = Components.utils;

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

this.EXPORTED_SYMBOLS = ["SettingsDB", "SETTINGSDB_NAME", "SETTINGSSTORE_NAME"];

let DEBUG = false;
let VERBOSE = false;

try {
  DEBUG   =
    Services.prefs.getBoolPref("dom.mozSettings.SettingsDB.debug.enabled");
  VERBOSE =
    Services.prefs.getBoolPref("dom.mozSettings.SettingsDB.verbose.enabled");
} catch (ex) { }

function debug(s) {
  dump("-*- SettingsDB: " + s + "\n");
}

const TYPED_ARRAY_THINGS = new Set([
  "Int8Array",
  "Uint8Array",
  "Uint8ClampedArray",
  "Int16Array",
  "Uint16Array",
  "Int32Array",
  "Uint32Array",
  "Float32Array",
  "Float64Array",
]);

this.SETTINGSDB_NAME = "settings";
this.SETTINGSDB_VERSION = 5;
this.SETTINGSSTORE_NAME = "settings";

Cu.import("resource://gre/modules/IndexedDBHelper.jsm");
Cu.import("resource://gre/modules/FileUtils.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");

this.SettingsDB = function SettingsDB() {}

SettingsDB.prototype = {

  __proto__: IndexedDBHelper.prototype,

  upgradeSchema: function upgradeSchema(aTransaction, aDb, aOldVersion, aNewVersion) {
    let objectStore;
    if (aOldVersion == 0) {
      objectStore = aDb.createObjectStore(SETTINGSSTORE_NAME, { keyPath: "settingName" });
      if (VERBOSE) debug("Created object stores");
    } else if (aOldVersion == 1) {
      if (VERBOSE) debug("Get object store for upgrade and remove old index");
      objectStore = aTransaction.objectStore(SETTINGSSTORE_NAME);
      objectStore.deleteIndex("settingValue");
    } else {
      if (VERBOSE) debug("Get object store for upgrade");
      objectStore = aTransaction.objectStore(SETTINGSSTORE_NAME);
    }

    // Loading resource://app/defaults/settings.json doesn't work because
    // settings.json is not in the omnijar.
    // So we look for the app dir instead and go from here...
    let settingsFile = FileUtils.getFile("DefRt", ["settings.json"], false);
    if (!settingsFile || (settingsFile && !settingsFile.exists())) {
      // On b2g desktop builds the settings.json file is moved in the
      // profile directory by the build system.
      settingsFile = FileUtils.getFile("ProfD", ["settings.json"], false);
      if (!settingsFile || (settingsFile && !settingsFile.exists())) {
        return;
      }
    }

    let chan = NetUtil.newChannel2(settingsFile,
                                   null,
                                   null,
                                   null,      // aLoadingNode
                                   Services.scriptSecurityManager.getSystemPrincipal(),
                                   null,      // aTriggeringPrincipal
                                   Ci.nsILoadInfo.SEC_NORMAL,
                                   Ci.nsIContentPolicy.TYPE_OTHER);
    let stream = chan.open();
    // Obtain a converter to read from a UTF-8 encoded input stream.
    let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
                    .createInstance(Ci.nsIScriptableUnicodeConverter);
    converter.charset = "UTF-8";
    let rawstr = converter.ConvertToUnicode(NetUtil.readInputStreamToString(
                                            stream,
                                            stream.available()) || "");
    let settings;
    try {
      settings = JSON.parse(rawstr);
    } catch(e) {
      if (DEBUG) debug("Error parsing " + settingsFile.path + " : " + e);
      return;
    }
    stream.close();

    objectStore.openCursor().onsuccess = function(event) {
      let cursor = event.target.result;
      if (cursor) {
        let value = cursor.value;
        if (value.settingName in settings) {
          if (VERBOSE) debug("Upgrade " +settings[value.settingName]);
          value.defaultValue = this.prepareValue(settings[value.settingName]);
          delete settings[value.settingName];
          if ("settingValue" in value) {
            value.userValue = this.prepareValue(value.settingValue);
            delete value.settingValue;
          }
          cursor.update(value);
        } else if ("userValue" in value || "settingValue" in value) {
          value.defaultValue = undefined;
          if (aOldVersion == 1 && value.settingValue) {
            value.userValue = this.prepareValue(value.settingValue);
            delete value.settingValue;
          }
          cursor.update(value);
        } else {
          cursor.delete();
        }
        cursor.continue();
      } else {
        for (let name in settings) {
          let value = this.prepareValue(settings[name]);
          if (VERBOSE) debug("Set new:" + name +", " + value);
          objectStore.add({ settingName: name, defaultValue: value, userValue: undefined });
        }
      }
    }.bind(this);
  },

  // If the value is a data: uri, convert it to a Blob.
  convertDataURIToBlob: function(aValue) {
    /* base64 to ArrayBuffer decoding, from
       https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
    */
    function b64ToUint6 (nChr) {
      return nChr > 64 && nChr < 91 ?
          nChr - 65
        : nChr > 96 && nChr < 123 ?
          nChr - 71
        : nChr > 47 && nChr < 58 ?
          nChr + 4
        : nChr === 43 ?
          62
        : nChr === 47 ?
          63
        :
          0;
    }

    function base64DecToArr(sBase64, nBlocksSize) {
      let sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, ""),
          nInLen = sB64Enc.length,
          nOutLen = nBlocksSize ? Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize
                                : nInLen * 3 + 1 >> 2,
          taBytes = new Uint8Array(nOutLen);

      for (let nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) {
        nMod4 = nInIdx & 3;
        nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
        if (nMod4 === 3 || nInLen - nInIdx === 1) {
          for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
            taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255;
          }
          nUint24 = 0;
        }
      }
      return taBytes;
    }

    // Check if we have a data: uri, and if it's base64 encoded.
    // data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEA...
    if (typeof aValue == "string" && aValue.startsWith("data:")) {
      try {
        let uri = Services.io.newURI(aValue, null, null);
        // XXX: that would be nice to reuse the c++ bits of the data:
        // protocol handler instead.
        let mimeType = "application/octet-stream";
        let mimeDelim = aValue.indexOf(";");
        if (mimeDelim !== -1) {
          mimeType = aValue.substring(5, mimeDelim);
        }
        let start = aValue.indexOf(",") + 1;
        let isBase64 = ((aValue.indexOf("base64") + 7) == start);
        let payload = aValue.substring(start);

        return new Blob([isBase64 ? base64DecToArr(payload) : payload],
                        { type: mimeType });
      } catch(e) {
        dump(e);
      }
    }
    return aValue
  },

  getObjectKind: function(aObject) {
    if (aObject === null || aObject === undefined) {
      return "primitive";
    } else if (Array.isArray(aObject)) {
      return "array";
    } else if (aObject instanceof Ci.nsIDOMFile) {
      return "file";
    } else if (aObject instanceof Ci.nsIDOMBlob) {
      return "blob";
    } else if (aObject.constructor.name == "Date") {
      return "date";
    } else if (TYPED_ARRAY_THINGS.has(aObject.constructor.name)) {
      return aObject.constructor.name;
    } else if (typeof aObject == "object") {
      return "object";
    } else {
      return "primitive";
    }
  },

  // Makes sure any property that is a data: uri gets converted to a Blob.
  prepareValue: function(aObject) {
    let kind = this.getObjectKind(aObject);
    if (kind == "array") {
      let res = [];
      aObject.forEach(function(aObj) {
        res.push(this.prepareValue(aObj));
      }, this);
      return res;
    } else if (kind == "file" || kind == "blob" || kind == "date") {
      return aObject;
    } else if (kind == "primitive") {
      return this.convertDataURIToBlob(aObject);
    }

    // Fall-through, we now have a dictionary object.
    let res = {};
    for (let prop in aObject) {
      res[prop] = this.prepareValue(aObject[prop]);
    }
    return res;
  },

  init: function init() {
    this.initDBHelper(SETTINGSDB_NAME, SETTINGSDB_VERSION,
                      [SETTINGSSTORE_NAME]);
  }
}
