/*
 * This file is part of Privacy Badger <https://www.eff.org/privacybadger>
 * Copyright (C) 2014 Electronic Frontier Foundation
 *
 * Privacy Badger is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 3 as
 * published by the Free Software Foundation.
 *
 * Privacy Badger is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Privacy Badger.  If not, see <http://www.gnu.org/licenses/>.
 */

/* globals badger:false, log:false */

var constants = require("constants");
var utils = require("utils");

require.scopes.storage = (function() {


/**
 * # Storage Objects
 *
 * snitch_map is our collection of potential tracking base_domains.
 * The key is a base domain (ETLD+1) and the value is an array of first
 * party domains on which this tracker has been seen.
 * it looks like this:
 * {
 *   "third-party.com": ["a.com", "b.com", "c.com"],
 *   "eviltracker.net": ["eff.org", "a.com"]
 * }
 *
 * action_map is where we store the action for each domain that we have
 * decided on an action for. Each subdomain gets its own entry. For example:
 * {
 *   "google.com": { heuristicAction: "block", dnt: false, userAction: ""}
 *   "fonts.google.com": { heuristicAction: "cookieblock", dnt: false, userAction: ""}
 *   "apis.google.com": { heuristicAction: "cookieblock", dnt: false, userAction: "user_block"}
 *   "widget.eff.org": { heuristicAction: "block", dnt: true, userAction: ""}
 * }
 *
 * cookieblock_list is where we store the current yellowlist as
 * downloaded from eff.org. The keys are the domains which should be "cookieblocked".
 * The values are simply 'true'. For example:
 * {
 *   "maps.google.com": true,
 *   "creativecommons.org": true,
 * }
 *
 */

function BadgerPen(callback) {
  let self = this;

  if (!callback) {
    callback = function () {};
  }

  // initialize from extension local storage
  chrome.storage.local.get(self.KEYS, function (store) {
    self.KEYS.forEach(key => {
      if (store.hasOwnProperty(key)) {
        self[key] = new BadgerStorage(key, store[key]);
      } else {
        let storageObj = new BadgerStorage(key, {});
        self[key] = storageObj;
        _syncStorage(storageObj);
      }
    });

    if (!chrome.storage.managed) {
      callback(self);
      return;
    }

    // see if we have any enterprise/admin/group policy overrides
    chrome.storage.managed.get(null, function (managedStore) {
      if (chrome.runtime.lastError) {
        // ignore "Managed storage manifest not found" errors in Firefox
      }

      if (_.isObject(managedStore)) {
        let settings = {};
        for (let key in badger.defaultSettings) {
          if (managedStore.hasOwnProperty(key)) {
            settings[key] = managedStore[key];
          }
        }
        self.settings_map.merge(settings);
      }

      callback(self);
    });
  });
}

BadgerPen.prototype = {
  KEYS: [
    "snitch_map",
    "action_map",
    "cookieblock_list",
    "dnt_hashes",
    "settings_map",
    "private_storage", // misc. utility settings, not for export
  ],

  getStore: function (key) {
    if (this.hasOwnProperty(key)) {
      return this[key];
    }
    console.error("Can't initialize cache from getStore. You are using this API improperly");
  },

  /**
   * Reset the snitch map and action map, forgetting all data the badger has
   * learned from browsing.
   */
  clearTrackerData: function () {
    let self = this;
    ['snitch_map', 'action_map'].forEach(key => {
      self.getStore(key).updateObject({});
    });
  },

  /**
   * Get the current presumed action for a specific fully qualified domain name (FQDN),
   * ignoring any rules for subdomains below or above it
   *
   * @param {(Object|String)} domain domain object from action_map
   * @param {Boolean} [ignoreDNT] whether to ignore DNT status
   * @returns {String} the presumed action for this FQDN
   */
  getAction: function (domain, ignoreDNT) {
    if (!badger.isCheckingDNTPolicyEnabled()) {
      ignoreDNT = true;
    }

    if (_.isString(domain)) {
      domain = this.getStore('action_map').getItem(domain) || {};
    }
    if (domain.userAction) { return domain.userAction; }
    if (domain.dnt && !ignoreDNT) { return constants.DNT; }
    if (domain.heuristicAction) { return domain.heuristicAction; }
    return constants.NO_TRACKING;
  },

  touchDNTRecheckTime: function(domain, time) {
    this._setupDomainAction(domain, time, "nextUpdateTime");
  },

  getNextUpdateForDomain: function(domain) {
    var action_map = this.getStore('action_map');
    if (action_map.hasItem(domain)) {
      return action_map.getItem(domain).nextUpdateTime;
    } else {
      return 0;
    }
  },

  /**
   * Updates the yellowlist to the provided array of domains.
   *
   * For each added domain, sets it to be cookieblocked
   * if its parent domain is set to be blocked.
   *
   * @param {Array} newDomains domains to use for the new yellowlist
   */
  updateYellowlist: function (newDomains) {
    let self = this,
      actionMap = self.getStore('action_map'),
      ylistStorage = self.getStore('cookieblock_list'),
      oldDomains = ylistStorage.keys();

    let addedDomains = _.difference(newDomains, oldDomains),
      removedDomains = _.difference(oldDomains, newDomains);

    log('removing from cookie blocklist:', removedDomains);
    removedDomains.forEach(function (domain) {
      ylistStorage.deleteItem(domain);

      const base = window.getBaseDomain(domain);
      // "subdomains" include the domain itself
      for (const subdomain of actionMap.keys()) {
        if (window.getBaseDomain(subdomain) == base) {
          if (self.getAction(subdomain) != constants.NO_TRACKING) {
            badger.heuristicBlocking.blocklistOrigin(base, subdomain);
          }
        }
      }
    });

    log('adding to cookie blocklist:', addedDomains);
    addedDomains.forEach(function (domain) {
      ylistStorage.setItem(domain, true);

      const base = window.getBaseDomain(domain);
      if (actionMap.hasItem(base)) {
        const action = actionMap.getItem(base).heuristicAction;
        // if the domain's base domain is marked for blocking
        if (action == constants.BLOCK || action == constants.COOKIEBLOCK) {
          // cookieblock the domain
          self.setupHeuristicAction(domain, constants.COOKIEBLOCK);
        }
      }
    });
  },

  /**
   * Update DNT policy hashes
   */
  updateDntHashes: function (hashes) {
    var dnt_hashes = this.getStore('dnt_hashes');
    dnt_hashes.updateObject(_.invert(hashes));
  },

  /**
   * Looks up whether an FQDN would get cookieblocked,
   * ignoring user overrides and the FQDN's current status.
   *
   * @param {String} fqdn the FQDN we want to look up
   *
   * @return {Boolean}
   */
  wouldGetCookieblocked: function (fqdn) {
    // cookieblock if a "parent" domain of the fqdn is on the yellowlist
    let set = false,
      ylistStorage = this.getStore('cookieblock_list'),
      // ignore base domains when exploding to work around PSL TLDs:
      // still want to cookieblock somedomain.googleapis.com with only
      // googleapis.com (and not somedomain.googleapis.com itself) on the ylist
      subdomains = utils.explodeSubdomains(fqdn, true);

    for (let i = 0; i < subdomains.length; i++) {
      if (ylistStorage.hasItem(subdomains[i])) {
        set = true;
        break;
      }
    }

    return set;
  },

  /**
   * Find the best action to take for an FQDN, assuming it is third party and
   * Privacy Badger is enabled. Traverse the action list for the FQDN and each
   * of its subdomains and then takes the most appropriate action
   *
   * @param {String} fqdn the FQDN we want to determine the action for
   * @returns {String} the best action for the FQDN
   */
  getBestAction: function (fqdn) {
    let best_action = constants.NO_TRACKING;
    let subdomains = utils.explodeSubdomains(fqdn);
    let action_map = this.getStore('action_map');

    function getScore(action) {
      switch (action) {
      case constants.NO_TRACKING:
        return 0;
      case constants.ALLOW:
        return 1;
      case constants.BLOCK:
        return 2;
      case constants.COOKIEBLOCK:
        return 3;
      case constants.DNT:
        return 4;
      case constants.USER_ALLOW:
      case constants.USER_BLOCK:
      case constants.USER_COOKIEBLOCK:
        return 5;
      }
    }

    // Loop through each subdomain we have a rule for
    // from least (base domain) to most (FQDN) specific
    // and keep the one which has the best score.
    for (let i = subdomains.length; i >= 0; i--) {
      let domain = subdomains[i];
      if (action_map.hasItem(domain)) {
        let action = this.getAction(
          action_map.getItem(domain),
          // ignore DNT unless it's directly on the FQDN being checked
          domain != fqdn
        );
        if (getScore(action) >= getScore(best_action)) {
          best_action = action;
        }
      }
    }

    return best_action;
  },

  /**
   * Find every domain in the action_map where the presumed action would be {selector}
   *
   * @param {String} selector the action to select by
   * @return {Array} an array of FQDN strings
   */
  getAllDomainsByPresumedAction: function (selector) {
    var action_map = this.getStore('action_map');
    var relevantDomains = [];
    for (var domain in action_map.getItemClones()) {
      if (selector == this.getAction(domain)) {
        relevantDomains.push(domain);
      }
    }
    return relevantDomains;
  },

  /**
   * Get all tracking domains from action_map.
   *
   * @return {Object} An object with domains as keys and actions as values.
   */
  getTrackingDomains: function () {
    let action_map = this.getStore('action_map');
    let origins = {};

    for (let domain in action_map.getItemClones()) {
      let action = badger.storage.getBestAction(domain);
      if (action != constants.NO_TRACKING) {
        origins[domain] = action;
      }
    }

    return origins;
  },

  /**
   * Set up an action for a domain of the given action type in action_map
   *
   * @param {String} domain the domain to set the action for
   * @param {String} action the action to take e.g. BLOCK || COOKIEBLOCK || DNT
   * @param {String} actionType the type of action we are setting, one of "userAction", "heuristicAction", "dnt"
   * @private
   */
  _setupDomainAction: function (domain, action, actionType) {
    let msg = "action_map['%s'].%s = %s",
      action_map = this.getStore("action_map"),
      actionObj = {};

    if (action_map.hasItem(domain)) {
      actionObj = action_map.getItem(domain);
      msg = "Updating " + msg;
    } else {
      actionObj = _newActionMapObject();
      msg = "Initializing " + msg;
    }
    actionObj[actionType] = action;

    if (window.DEBUG) { // to avoid needless JSON.stringify calls
      log(msg, domain, actionType, JSON.stringify(action));
    }
    action_map.setItem(domain, actionObj);
  },

  /**
   * Add a heuristic action for a domain
   *
   * @param {String} domain Domain to add
   * @param {String} action The heuristic action to take
   */
  setupHeuristicAction: function(domain, action) {
    this._setupDomainAction(domain, action, "heuristicAction");
  },

  /**
   * Set up a domain for DNT
   *
   * @param {String} domain Domain to add
   */
  setupDNT: function(domain) {
    this._setupDomainAction(domain, true, "dnt");
  },

  /**
   * Remove DNT setting from a domain*
   * @param {String} domain FQDN string
   */
  revertDNT: function(domain) {
    this._setupDomainAction(domain, false, "dnt");
  },

  /**
   * Add a heuristic action for a domain
   *
   * @param {String} domain Domain to add
   * @param {String} action The heuristic action to take
   */
  setupUserAction: function(domain, action) {
    this._setupDomainAction(domain, action, "userAction");
  },

  /**
   * Remove user set action from a domain
   * @param {String} domain FQDN string
   */
  revertUserAction: function(domain) {
    this._setupDomainAction(domain, "", "userAction");

    // if Privacy Badger never recorded tracking for this domain,
    // remove the domain's entry from Privacy Badger's database
    const actionMap = this.getStore("action_map");
    if (actionMap.getItem(domain).heuristicAction == "") {
      log("Removing %s from action_map", domain);
      actionMap.deleteItem(domain);
    }
  },

  /**
   * Removes a base domain and its subdomains from snitch and action maps.
   * Preserves action map entries with user overrides.
   *
   * @param {String} base_domain
   */
  forget: function (base_domain) {
    let self = this,
      dot_base = '.' + base_domain,
      actionMap = self.getStore('action_map'),
      actions = actionMap.getItemClones(),
      snitchMap = self.getStore('snitch_map');

    if (snitchMap.getItem(base_domain)) {
      log("Removing %s from snitch_map", base_domain);
      badger.storage.getStore("snitch_map").deleteItem(base_domain);
    }

    for (let domain in actions) {
      if (domain == base_domain || domain.endsWith(dot_base)) {
        if (actions[domain].userAction == "") {
          log("Removing %s from action_map", domain);
          actionMap.deleteItem(domain);
        }
      }
    }
  }
};

/**
 * @returns {{userAction: null, dnt: null, heuristicAction: null}}
 * @private
 */
var _newActionMapObject = function() {
  return {
    userAction: "",
    dnt: false,
    heuristicAction: "",
    nextUpdateTime: 0
  };
};

/**
 * Privacy Badger Storage Object. Has methods for getting, setting and deleting
 * should be used for all storage needs, transparently handles data presistence
 * syncing and private browsing.
 * Usage:
 * example_map = getStore('example_map');
 * # instance of BadgerStorage
 * example_map.setItem('foo', 'bar')
 * # null
 * example_map
 * # { foo: "bar" }
 * example_map.hasItem('foo')
 * # true
 * example_map.getItem('foo');
 * # 'bar'
 * example_map.getItem('not_real');
 * # undefined
 * example_map.deleteItem('foo');
 * # null
 * example_map.hasItem('foo');
 * # false
 *
 */

/**
 * BadgerStorage constructor
 * *DO NOT USE DIRECTLY* Instead call `getStore(name)`
 * @param {String} name - the name of the storage object
 * @param {Object} seed - the base object which we are instantiating from
 */
var BadgerStorage = function(name, seed) {
  this.name = name;
  this._store = seed;
};

BadgerStorage.prototype = {
  /**
   * Check if this storage object has an item
   *
   * @param {String} key - the key for the item
   * @return {Boolean}
   */
  hasItem: function(key) {
    var self = this;
    return self._store.hasOwnProperty(key);
  },

  /**
   * Get an item
   *
   * @param {String} key - the key for the item
   * @return {?*} the value for that key or null
   */
  getItem: function(key) {
    var self = this;
    if (self.hasItem(key)) {
      return self._store[key];
    } else {
      return null;
    }
  },

  /**
   * Get all items in the object as a copy
   *
   * @return {*} the items in badgerObject
   */
  getItemClones: function() {
    var self = this;
    return JSON.parse(JSON.stringify(self._store));
  },

  /**
   * Set an item
   *
   * @param {String} key - the key for the item
   * @param {*} value - the new value
   */
  setItem: function(key,value) {
    var self = this;
    self._store[key] = value;
    // Async call to syncStorage.
    setTimeout(function() {
      _syncStorage(self);
    }, 0);
  },

  /**
   * Delete an item
   *
   * @param {String} key - the key for the item
   */
  deleteItem: function(key) {
    var self = this;
    delete self._store[key];
    // Async call to syncStorage.
    setTimeout(function() {
      _syncStorage(self);
    }, 0);
  },

  /**
   * Update the entire object that this instance is storing
   */
  updateObject: function(object) {
    var self = this;
    self._store = object;
    // Async call to syncStorage.
    setTimeout(function() {
      _syncStorage(self);
    }, 0);
  },

  /**
   * @returns {Array} this storage object's store keys
   */
  keys: function () {
    return Object.keys(this._store);
  },

  /**
   * When a user imports a tracker and settings list via the Import function,
   * we want to overwrite any existing settings, while simultaneously merging
   * in any new information (i.e. the list of disabled site domains). In order
   * to do this, we need different logic for each of the storage maps based on
   * their internal structure. The three cases in this function handle each of
   * the three storage maps that can be exported.
   *
   * @param {Object} mapData The object containing storage map data to merge
   */
  merge: function (mapData) {
    const self = this;

    if (self.name == "settings_map") {
      for (let prop in mapData) {
        // combine array settings via intersection/union
        if (prop == "disabledSites" || prop == "widgetReplacementExceptions") {
          self._store[prop] = _.union(self._store[prop], mapData[prop]);

        // string/array map
        } else if (prop == "widgetSiteAllowlist") {
          // for every site host in the import
          for (let site in mapData[prop]) {
            // combine exception arrays
            self._store[prop][site] = _.union(
              self._store[prop][site],
              mapData[prop][site]
            );
          }

        // default: overwrite existing setting with setting from import
        } else {
          if (prop != "isFirstRun") {
            self._store[prop] = mapData[prop];
          }
        }
      }

    } else if (self.name == "action_map") {
      for (let domain in mapData) {
        let action = mapData[domain];

        // Copy over any user settings from the merged-in data
        if (action.userAction) {
          if (self._store.hasOwnProperty(domain)) {
            self._store[domain].userAction = action.userAction;
          } else {
            self._store[domain] = Object.assign(_newActionMapObject(), action);
          }
        }

        // handle Do Not Track
        if (self._store.hasOwnProperty(domain)) {
          // Merge DNT settings if the imported data has a more recent update
          if (action.nextUpdateTime > self._store[domain].nextUpdateTime) {
            self._store[domain].nextUpdateTime = action.nextUpdateTime;
            self._store[domain].dnt = action.dnt;
          }
        } else {
          // Import action map entries for new DNT-compliant domains
          if (action.dnt) {
            self._store[domain] = Object.assign(_newActionMapObject(), action);
          }
        }
      }

    } else if (self.name == "snitch_map") {
      for (let tracker_origin in mapData) {
        let firstPartyOrigins = mapData[tracker_origin];
        for (let i = 0; i < firstPartyOrigins.length; i++) {
          badger.heuristicBlocking.updateTrackerPrevalence(
            tracker_origin,
            tracker_origin,
            firstPartyOrigins[i]
          );
        }
      }
    }

    // Async call to syncStorage.
    setTimeout(function () {
      _syncStorage(self);
    }, 0);
  }
};

var _syncStorage = (function () {
  var debouncedFuncs = {};

  function cb() {
    if (chrome.runtime.lastError) {
      let err = chrome.runtime.lastError.message;
      if (!err.startsWith("IO error:") && !err.startsWith("Corruption:")
      && !err.startsWith("InvalidStateError:") && !err.startsWith("AbortError:")
      && !err.startsWith("QuotaExceededError:")
      ) {
        badger.criticalError = err;
      }
      console.error("Error writing to chrome.storage.local:", err);
    }
  }

  function sync(badgerStorage) {
    var obj = {};
    obj[badgerStorage.name] = badgerStorage._store;
    chrome.storage.local.set(obj, cb);
  }

  // Creates debounced versions of "sync" function,
  // one for each distinct badgerStorage value.
  return function (badgerStorage) {
    if (!debouncedFuncs.hasOwnProperty(badgerStorage.name)) {
      // call sync at most once every two seconds
      debouncedFuncs[badgerStorage.name] = _.debounce(function () {
        sync(badgerStorage);
      }, 2000);
    }
    debouncedFuncs[badgerStorage.name]();
  };
}());

/************************************** exports */
var exports = {};

exports.BadgerPen = BadgerPen;

return exports;
/************************************** exports */
}());
