/** \file
 * \brief Fingerprint Detector (FPD) main logic, fingerprinting evaluation and other essentials
 *
 *  \author Copyright (C) 2021-2022  Marek Salon
 *
 *  \license SPDX-License-Identifier: GPL-3.0-or-later
 */
//
//  This program is free software: you can redistribute it and/or modify
//  it under the terms of the GNU General Public License as published by
//  the Free Software Foundation, either version 3 of the License, or
//  (at your option) any later version.
//
//  This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.
//

/**
 * \defgroup FPD Fingerprint Detector
 *
 * \brief Fingerprint Detector (FPD) is a module that detects browser fingerprint extraction and prevents
 * its sharing. To learn more about Browser Fingerprinting topic, see study "Browser Fingerprinting: A survey" available
 * here: https://arxiv.org/pdf/1905.01051.pdf
 *
 * The FPD module uses wrapping technique to inject code that allows log API calls and accesses for every visited web page
 * and its frames. Logged JS APIs can be specified in wrappers-lvl_X.json file, where X represents FPD config identifier.
 * 
 * Fingerprint Detector is based on heuristic system that can be defined in form of API groups. A group represents
 * a set of APIs that may have something in common. Access to the group is triggered when a certain amount APIs is accessed. 
 * Hierarchy of groups creates a tree-like structure, where access to the root group means fingerprinting activity. Groups can 
 * be configured in groups-lvl_X.json file, where X represents FPD config identifier.
 *
 * The FPD evaluate API groups with every request made in scope of certain browser tab. When FPD detects fingerprinting activity, 
 * blocking of subsequent requests is issued (if allowed in settings). Local browsing data of fingerprinting origin are also cleared 
 * to prevent caching extracted fingerprint in browser storage.
 * 
 */

 /** \file
 *
 * \brief This file is part of Fingerprint Detector (FPD) and contains API groups evaluation logic. File also contains
 * event listeners used for API logging, requests blocking and tabs management. 
 *
 * \ingroup FPD
 */


// START persistent configuration data

/**
 * FPD enable flag. Evaluate only when active.
 */
var fpDetectionOn;

/**
 * Associative array of hosts, that are currently among trusted ones.
 */
var fpdWhitelist = {};

/**
 * Associative array of settings supported by this module.
 */
var fpdSettings = {};

// END persistent configuration data


/**
 *  Array containing names of unsupported wrappers that should be treated like supported ones during groups evaluation.
 */
const exceptionWrappers = ["CSSStyleDeclaration.prototype.fontFamily"];

/**
 * API logs database of following structure:
 * 		"tabId" : {
 * 			"resource" : {
 * 				"type" : {
 * 					arguments {
 * 						arg : "access count"
 *					},
 *					total : "total access count"
 *				}
 *			}
 *		}
 *
 *	*values in quotations are substituted by concrete names				
 *
 */
var fpdObservable = new Observable();

// depends on /nscl/common/CachedStorage.js
// session-bound globals
var fpdGlobals = CachedStorage.init({

	fpDb: {},

/**
 *  Contains information about tabs current state.
 */
	availableTabs: {},

	/**
	 * Store if the user was already notified about fingerprinting activity in the tab.
	 */
	stopNotifyFlag: {},

/**
 * A global variable shared with level_cache that controls the collection of calling scripts for FPD
 * report.
 */
	fpd_track_callers_tab: null,

});

/**
 *  Stores latest evaluation statistics for every examined tab. This statistics contains data about accessed groups and resources
 *  and their weights after evaluation. It can be used for debugging or as an informative statement in GUI.
 * 	It also contains flag for every tab to limit number of notifications.
 */
let latestEvals = {};

/**
 *  Parsed groups object containing necessary group information needed for evaluation.
 * 	Groups are indexed by level and name for easier and faster access.
 */
let fpGroups = {};

/**
 *  Object containing information about unsupported wrappers for given browser.
 */
let unsupportedWrappers = {};

/**
* Definition of settings supported by this module.
*/
const FPD_DEF_SETTINGS = {
	behavior: {
		label: browser.i18n.getMessage("fpdBehavior"),
		description: browser.i18n.getMessage("fpdBehaviorDescription"),
		description2: [],
		params: [
			{
				// 0
				short: browser.i18n.getMessage("fpdBehaviorPassive"),
				description: browser.i18n.getMessage("fpdBehaviorPassiveDescription")
			},
			{
				// 1
				short: browser.i18n.getMessage("fpdBehaviorLimitedBlocking"),
				description: browser.i18n.getMessage("fpdBehaviorBlockingDescription"),
				description2: [
					browser.i18n.getMessage("fpdBehaviorBlockingDescription2"),
					browser.i18n.getMessage("fpdBehaviorLimitedBlockingDescription3"),
					browser.i18n.getMessage("fpdBehaviorLimitedBlockingDescription4"),
					browser.i18n.getMessage("fpdBehaviorBlockingDescriptionWarning")
				]
			},
			{
				// 2
				short: browser.i18n.getMessage("fpdBehaviorFullBlocking"),
				description: browser.i18n.getMessage("fpdBehaviorBlockingDescription"),
				description2: [
					browser.i18n.getMessage("fpdBehaviorBlockingDescription2"),
					browser.i18n.getMessage("fpdBehaviorFullBlockingDescription3"),
					browser.i18n.getMessage("fpdBehaviorFullBlockingDescription4"),
					browser.i18n.getMessage("fpdBehaviorBlockingDescriptionWarning")
				],
				permissions: ["browsingData"]
			}
		]
	},
	notifications: {
		label: browser.i18n.getMessage("shieldNotifications"),
		description: browser.i18n.getMessage("fpdNotificationsDescription"),
		description2: [browser.i18n.getMessage("fpdNotificationsDescription2")],
		params: [
			{
				// 0
				short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOff"),
				description: browser.i18n.getMessage("fpdNotificationsOffDescription")
			},
			{
				// 1
				short: browser.i18n.getMessage("protectionConfigurationOptionActivatedOn"),
				description: browser.i18n.getMessage("fpdNotificationsOnDescription")
			}
		]
	},
	detection: {
		label: browser.i18n.getMessage("fpdDetection"),
		description: browser.i18n.getMessage("fpdDetectionDescription"),
		description2: [],
		params: [
			{
				// 0
				short: browser.i18n.getMessage("fpdDetectionDefault"),
				description: browser.i18n.getMessage("fpdDetectionDefaultDescription"),
				description2: [
					browser.i18n.getMessage("fpdDetectionDefaultDescription2"),
					browser.i18n.getMessage("fpdDetectionDefaultDescription3"),
				]
			},
			{
				// 1
				short: browser.i18n.getMessage("fpdStrict"),
				description: browser.i18n.getMessage("fpdStrictDescription"),
				description2: [
					browser.i18n.getMessage("fpdStrictDescription2"),
					browser.i18n.getMessage("fpdStrictDescription3")
				]
			}
		]
	}
};


var actionApi = browser.browserAction || browser.action;
// unify default color of popup badge background between different browsers
actionApi.setBadgeBackgroundColor({color: "#6E7378"});

// unify default color of popup badge text between different browsers
if (typeof actionApi.setBadgeTextColor === "function") {
	actionApi.setBadgeTextColor({color: "#FFFFFF"});
}

/**
 * This function initializes FPD module, loads configuration from storage, and registers listeners needed for fingerprinting detection.
 */
async function initFpd() {
	// fill up fpGroups object with necessary data for evaluation
	for (let groupsLevel in fp_levels.groups) {
		fpGroups[groupsLevel] = fpGroups[groupsLevel] || {};
		processGroupsRecursive(fp_levels.groups[groupsLevel], groupsLevel);

		// Translate severity names
		for (record of fp_levels.groups[groupsLevel].severity) {
			record[1] = browser.i18n.getMessage(record[1]);
		}
	}

	// load configuration and settings from storage
	await fpdLoadConfiguration();
	await fpdGlobals;

	// take care of unsupported resources for cross-browser behaviour uniformity
	balanceUnsupportedWrappers();

	// listen for messages intended for FPD module
	browser.runtime.onMessage.addListener(fpdCommonMessageListener);
	
	// listen for click on notification when FPD detects fingerprinting to create a report
	browser.notifications.onClicked.addListener((notificationId) => {
		if (notificationId.startsWith("fpd")) {
			var tabId = notificationId.split("-")[1];
			generateFpdReport(tabId);
		}
	});
	
	// listen for update of browser tabs and start periodic FP evaluation
	browser.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
		availableTabs[tabId] = tab;
		if (changeInfo.status == "loading") {
			refreshDb(tabId);
			fpDb[tab.id] = {};
			periodicEvaluation(tab.id, 500);
		}
	});

	// listen for removal of browser tabs to clean fpDb object
	browser.tabs.onRemoved.addListener(function (tabId) {
		refreshDb(tabId);
		delete availableTabs[tabId];
	});

	// listen for removal of optional permissions to adjust settings accordingly
	browser.permissions.onRemoved.addListener((permissions) => {
		correctSettingsForRemovedPermissions(permissions.permissions, fpdSettings, FPD_DEF_SETTINGS);
		browser.storage.sync.set({"fpdSettings": fpdSettings});
	});

	if (self.window) {
		// Firefox, event page has window
		// listen for requests through webRequest API and decide whether to block them
		browser.webRequest.onBeforeRequest.addListener(
			fpdRequestCancel,
			{urls: ["<all_urls>"]},
			["blocking"]
		);
	} else {
		// mv3: cannot block!
		// hide behavior settings
		// TODO: shame Google in the setting panels for the missing feature
		delete FPD_DEF_SETTINGS["behavior"];
		// force passive behavior setting
		fpdSettings.behavior = 0;
		browser.storage.sync.set({"fpdSettings": fpdSettings});
	}
	// listen for navigation events to initiate storage clearing of fingerprinting web pages
	browser.webNavigation.onBeforeNavigate.addListener((details) => {
		if (latestEvals[details.tabId] && stopNotifyFlag[details.tabId] && fpdSettings.behavior > 0) {
			// clear storages (using content script) for every frame in this tab
			if (details.tabId >= 0) {
				browser.tabs.sendMessage(details.tabId, {
					cleanStorage: true,
					ignoreWorkaround: fpdSettings.behavior > 1
				});
			}
		}
	});

	// get state of all existing tabs and start periodic FP evaluation
	browser.tabs.query({}).then(function(results) {
		results.forEach(function(tab) {
			availableTabs[tab.id] = tab;
			fpDb[tab.id] ||= {};
			periodicEvaluation(tab.id, 500);
		});
	});
}

/* 
 * --------------------------------------------------
 * 				CRITERIA CORRECTION
 * --------------------------------------------------
 */

/**
 * This function provides ability to balance/optimalize evaluation heuristics for cross-browser behaviour uniformity.
 * It checks which resources are unsupported for given browser and adjust criteria of loaded FPD configuration accordingly.
 */
function balanceUnsupportedWrappers() {
	if (!self.window) return false; // mv3 service worker?
	// object containing groups effected by criteria adjustment and corresponding deleted subgroups
	var effectedGroups = {};

	// check supported wrappers for each level of FPD configuration
	for (let level in fp_levels.wrappers) {
		unsupportedWrappers[level] = [];
		effectedGroups = {};

		// find out which wrappers are unsupported and store them in "unsupportedWrappers" object
		for (let wrapper of fp_levels.wrappers[level]) {
			// split path of object from its name
			var resourceSplitted = split_resource(wrapper.resource);
			
			// access nested object in browser's "window" object using path string
			var resolvedPath = resourceSplitted["path"].split('.').reduce((o, p) => o ? o[p] : undefined, window);

			// if resource or resource path is undefined -> resource unsupported && no exception for the resource
			if (!(resolvedPath && resourceSplitted["name"] in resolvedPath) && !exceptionWrappers.includes(wrapper.resource)) {
				// store wrapper object to "unsupportedWrappers" object
				unsupportedWrappers[level].push(wrapper);

				// mark all groups as effected if containing unsupported resource
				for (let groupObj of wrapper.groups) {
					effectedGroups[groupObj.group] = effectedGroups[groupObj.group] || [];
				}

				// remove wrapper from FPD configuration (in "fp_levels")
				fp_levels.wrappers[level] = fp_levels.wrappers[level].filter((x) => (x != wrapper));
			}
		}

		// adjust/correct effected groups criteria
		correctGroupCriteria(fp_levels.groups[level], effectedGroups, level);
	}

	// refresh "fpGroups" object after criteria adjustment for upcoming evaluations
	for (let groupsLevel in fp_levels.groups) {
		fpGroups[groupsLevel] = {};
		processGroupsRecursive(fp_levels.groups[groupsLevel], groupsLevel);
	}
}

/**
 * The function that corrects groups criteria according to unsupported wrappers.
 * Groups should be deleted when more than half of weights (resources) cannot be obtained in the same group (whole group is invalidated).
 * Groups criteria of "value" type are also recomputed to take into account unsupported resources and deleted groups/subgroups.
 * 
 * \param rootGroup Group object of certain level from FPD configuration (in "fp_levels.wrappers" format).
 * \param effectedGroups Object containing groups effected by criteria adjustment and corresponding deleted subgroups.
 * \param level FPD configuration level identifier.
 *
 * \returns true if group should be deleted because of unsupported wrappers
 * \returns false if group don't need to be deleted
 */
function correctGroupCriteria(rootGroup, effectedGroups, level) {
	// if rootGroup has subgroups, then process all subgroups recursively
	if (rootGroup.groups) {
		for (let groupIdx in rootGroup.groups) {			
			// if correctGroupCriteria return true, subgroup is deleted
			if (correctGroupCriteria(rootGroup.groups[groupIdx], effectedGroups, level)) {
				// rootGroup is now also effected and added to "effectedGroups" with deleted subgroup
				effectedGroups[rootGroup.name] = effectedGroups[rootGroup.name] || [];
				effectedGroups[rootGroup.name].push(rootGroup.groups[groupIdx].name);
			}

			// rootGroup is effected because at least one of its child was effected too
			if (Object.keys(effectedGroups).includes(rootGroup.groups[groupIdx].name)) {
				effectedGroups[rootGroup.name] = effectedGroups[rootGroup.name] || [];		
			}
		}

		// remove deleted subgroups from original group object
		if (effectedGroups[rootGroup.name]) {
			rootGroup.groups = rootGroup.groups.filter((x) => (!effectedGroups[rootGroup.name].includes(x.name)))
		}
	}

	// if group is effected, try to correct its criteria
	if (Object.keys(effectedGroups).includes(rootGroup.name)) {
		// original sum of max weights of direct children
		var maxOriginalWeight = 0;
		
		// sum of max weights after deletion of unsupported wrappers
		var maxNewWeight = 0;
		
		// get values of "maxOriginalWeight" and "maxNewWeight"
		for (let resource in fpGroups[level][rootGroup.name].items) {
			// if resource is subgroup
			if (fpGroups[level][rootGroup.name].items[resource] == "group") {
				let maxWeight = fpGroups[level][resource].criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
				
				maxOriginalWeight += maxWeight;
				maxNewWeight += !effectedGroups[rootGroup.name].includes(resource) ? maxWeight : 0;
			}
			// if resource is property or function
			else {
				// specific resource object from FPD configuration of wrappers
				var resourceObj;

				// array of groups where resource can be found
				var groupsArray;

				// flag - resource is supported (resource found in "fp_levels.wrappers" instead of "unsupportedWrappers")
				var supported = false;

				// get resource from wrappers and extract all group objects in context of parent group (rootGroup)
				if (resourceObj = fp_levels.wrappers[level].filter((x) => (x.resource == resource))[0]) {
					groupsArray = resourceObj.groups.filter((x) => (x.group == rootGroup.name));
					supported = true;
				}
				else {
					resourceObj = unsupportedWrappers[level].filter((x) => (x.resource == resource))[0];
					groupsArray = resourceObj.groups.filter((x) => (x.group == rootGroup.name));
				}

				// get maximal obtainable weight for resource from every group object
				for (let groupObj of groupsArray) {
					if (groupObj.criteria && groupObj.criteria.length > 0) {
						let maxWeight = groupObj.criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
						
						maxOriginalWeight += maxWeight;
						maxNewWeight += supported ? maxWeight : 0;
					}
					else {
						maxOriginalWeight += 1;
						maxNewWeight += supported ? 1 : 0;
					}
				}
			}
		}

		// adjust "value" criteria to follow their original percentage state
		for (let criterion of fpGroups[level][rootGroup.name].criteria) {
			if ("value" in criterion) {
				// original percentage of given criterion
				var percValue = (criterion.value*100) / maxOriginalWeight;

				// get new criterion value adjusted for new max weights
				criterion.value = Math.round((percValue*maxNewWeight) / 100);
			}
		}
		
		// if sum of max weights of unsupported resources is higher than 50% of total original weights
		// in this case, group should be deleted or manually corrected in FPD configuration to contain only supported resources
		if (2*maxNewWeight < maxOriginalWeight) {
			return true;
		}
		return false;
	}
}

/* 
 * --------------------------------------------------
 * 				GROUPS EVALUATION
 * --------------------------------------------------
 */

/**
 * The function initializing evaluation of logged API calls (fpDb) according to groups criteria (fpGroups).
 *
 * \param tabId Integer number representing ID of browser tab that is going to be evaluated.
 *
 * \returns object where "weight" property represents evaluated weight of root group and "severity" property contain severity array
 */
function evaluateGroups(tabId) {
	// get url of evaluated tab
	var url = availableTabs[tabId] ? availableTabs[tabId].url : "";
	var ret = {
		weight: 0,
		severity: []
	}

	// inaccesible or invalid url - do not evaluate
	if (!url) {
		return ret;
	}
	
	// clear old evalStats
	latestEvals[tabId] = latestEvals[tabId] || {};
	latestEvals[tabId].evalStats = [];

	// check if the level exists within FPD configuration, if not use default FPD configuration
	level = fpdSettings.detection ? fpdSettings.detection : 0;

	// getting root group name as a start point for recursive evaluation
	var rootGroup = fp_levels.groups[level] ? fp_levels.groups[level].name : undefined;
	
	// start recursive evaluation if all needed objects are defined
	if (rootGroup && fpGroups[level] && fp_levels.wrappers[level]) {
		let evalRes = evaluateGroupsCriteria(rootGroup, level, tabId)[0];
		ret.weight = evalRes.actualWeight;
		if (fp_levels.groups[level].severity) {
			let sortedSeverity = fp_levels.groups[level].severity.sort((x, y) => x[0] - y[0]);
			ret.severity = sortedSeverity.filter((x) => (x[0] <= evalRes.actualWeightsSum)).reverse()[0];
		}
	}

	return ret;
}

/**
 * The function that evaluates group criteria according to evaluation of its child items (groups/resources).
 *
 * \param rootGroup Group name that needs to be evaluated.
 * \param level Level ID of groups and wrappers used for evaluation.
 * \param tabId Integer number representing ID of evaluated browser tab.
 *
 * \returns Array that contains "Result" objects
 * 
 * Result object contains following properties:
 * 		actualWeight (Obtained weight value of group after evaluation)
 * 		maxWeight (Maximum obtainable weight value of group)
 * 		type (Type of group item - group/call/get/set)
 * 		accesses (Number of accesses to specified resource - groups always 0)
 */
function evaluateGroupsCriteria(rootGroup, level, tabId) {
	// result object that is delegated to parent group
	var res = {};

	// all result objects from child items of rootGroup
	var scores = [];

	// array of relevant criteria based on groupTypes
	var relevantCriteria = [];

	// types of criteria that are relevant for evaluating rootGroup
	var groupTypes = [];
	
	// evaluate every item of rootGroup and add result objects to scores array
	for (let item in fpGroups[level][rootGroup].items) {
		if (fpGroups[level][rootGroup].items[item] == "group") {
			scores = scores.concat(evaluateGroupsCriteria(item, level, tabId));
		}
		else {
			scores = scores.concat(evaluateResourcesCriteria(item, rootGroup ,level, tabId));
		}	
	}

	/*
	 Group type is determined by first criteria object:
		- access - evaluation of child items is based on sum of accesses
		- value/percentage - evaluation of child items is based on sum of weights or percentage of sum of maxWeights
	*/
	groupTypes = Object.keys(fpGroups[level][rootGroup].criteria[0]).includes("access") ? ["access"] : ["value", "percentage"];
	relevantCriteria = fpGroups[level][rootGroup].criteria.filter((x) => (groupTypes.some((y) => (Object.keys(x).includes(y)))));

	// now evaluating group
	res.type = "group";
	
	// get maximal obtainable weight for rootGroup
	res.maxWeight = fpGroups[level][rootGroup].criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);

	// compute actualWeight of rootGroup with value of accesses
	res.accesses = 0;
	res.actualWeight = 0;
	res.actualWeightsSum = 0;
	if (groupTypes.length == 2) {
		// groupTypes contains "value" and "percetange" - take weight of child items into account
		var actualWeightsSum = scores.reduce((x, {actualWeight}) => (x + actualWeight), 0);
		var maxWeightsSum = scores.reduce((x, {maxWeight}) => (x + maxWeight), 0);
		
		// recalculate percentage values of relevant criteria to exact values
		var relativeCriteria = [];
		for (let criteriaObj of relevantCriteria) {
			if (criteriaObj.value) {
				relativeCriteria.push(criteriaObj);
			}
			else {
				relativeCriteria.push({
					value: Math.round(maxWeightsSum * (criteriaObj.percentage/100)),
					weight: criteriaObj.weight
				});
			}
		}

		// sort relevant and relative criteria by value
		relativeCriteria.sort((x, y) => (x.value - y.value));
		
		// filter criteria and take weight of highest achieved criteria
		var filteredCriteria = relativeCriteria.filter((x) => (x.value <= actualWeightsSum)).reverse()[0];
		res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
		res.actualWeightsSum = actualWeightsSum;
	}
	else {
		// groupTypes contains "access" - take access of child items into account
		var accessesSum = scores.reduce((x, {accesses}) => (x + accesses), 0);

		// sort relevant criteria
		relevantCriteria.sort((x, y) => (x.access - y.access));

		// filter criteria and take weight of highest achieved criteria
		var filteredCriteria = relevantCriteria.filter((x) => (x.access <= accessesSum)).reverse()[0];
		res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
		res.actualWeightsSum = accessesSum;
	}

	// update group statistics in latestEvals
	latestEvals[tabId].evalStats.push({ 
		title: rootGroup,
		type: "group",
		weight: res.actualWeight,
		sum: res.actualWeightsSum
	});

	return [res];
}

/**
 * The function that evaluates resource (wrapper) criteria according to API calls logs.
 *
 * \param resource Full name of resource/wrapper.
 * \param groupName Name of direct parent group.
 * \param level Level ID of groups and wrappers used for evaluation.
 * \param tabId Integer number representing ID of evaluated browser tab.
 *
 * \returns Array that contains "Result" objects
 * 
 * Result object contains following properties (all of them in context of parent group):
 * 		actualWeight (Obtained weight value of resource after evaluation)
 * 		maxWeight (Maximum obtainable weight value of resource)
 * 		type (Type of resource - call/get/set)
 * 		accesses (Number of accesses to specified resource)
 */
function evaluateResourcesCriteria(resource, groupName, level, tabId) {
	// all result objects for given resource (set/get/call)
	var scores = [];

	// get resource from wrappers and extract all group objects in context of parent group (groupName)
	var resourceObj = fp_levels.wrappers[level].filter((x) => (x.resource == resource))[0];
	var groupsArray = resourceObj.groups.filter((x) => (x.group == groupName));

	// evaluate every retrieved group object
	for (let groupObj of groupsArray) {
		// initialize new result object
		var res = {}
		
		// get resource type from group object (get/set/call)
		if (resourceObj.type == "property") {
			if (groupObj.property) {
				res.type = groupObj.property
			}
			else {
				// property not defined => implicit get
				res.type = "get";
			}
		}
		else {
			res.type = "call"
		}

		// get maximal obtainable weight for resource from group object
		if (groupObj.criteria && groupObj.criteria.length > 0) {
			res.maxWeight = groupObj.criteria.reduce((x, {weight}) => (x > weight ? x : weight), 0);
		}
		else {
			// criteria not defined => set implicit criteria
			res.maxWeight = 1;
			groupObj.criteria = [{value: 1, weight: 1}];
		}
		
		// compute actualWeight of resource in context of parent group from logs located in fpDb object
		res.actualWeight = 0;
		if (fpDb[tabId] && fpDb[tabId][resource] && fpDb[tabId][resource][res.type]) {
			let record = fpDb[tabId][resource][res.type];
			// logs for given resource and type exist
			if (groupObj.arguments) {
				// if arguments logging is defined, evaluate resource accordingly
				
				if (groupObj.arguments == "diff") {
					// "diff" - accesses depend on number of different arguments
					res.accesses = Object.keys(record.args).length;		
				}
				else if (groupObj.arguments == "same") {
					// "same" - accesses depend on maximum number of same arguments calls
					res.accesses = Object.values(record.args).reduce((x, y) => x > y ? x : y);
				}
				else {
					// try to interpret arguments as regular expression and take accesses that match this expression
					try {
						let re = new RegExp(...groupObj.arguments);
						res.accesses = Object.keys(record.args).reduce(
							(x, y) => (re.test(y) ? x + record.args[y] : x), 0);
					} catch {
						res.accesses = 0;
					}
				}			
			}
			else {
				// arguments logging not defined, simply take total accesses to resource
				res.accesses = record.total
			}

			// sort criteria by value
			groupObj.criteria.sort((x, y) => (x.value - y.value));

			// filter criteria and take weight of highest achieved criteria
			var filteredCriteria = groupObj.criteria.filter((x) => (x.value <= res.accesses)).reverse()[0];
			res.actualWeight = filteredCriteria ? filteredCriteria.weight : 0;
		}
		else {
			// no logs of given criteria
			res.accesses = 0;
		}
		scores.push(res)
	}

	// update resource statistics in latestEvals
	scores.forEach(function (res) {
		latestEvals[tabId].evalStats.push({
			title: resource,
			type: "resource",
			resource: res.type,
			group: groupName,
			weight: res.actualWeight,
			accesses: res.accesses
		});
	});

	return scores;
}

/* 
 * --------------------------------------------------
 * 				EVENT HANDLERS
 * --------------------------------------------------
 */

/**
 * Callback function that parses and handles messages recieved by FPD module.
 * Messages may contain wrappers logging data that are stored into fpDb object.
 * Also listen for popup messages to update FPD state and whitelist.
 *
 * \param message Receives full message.
 * \param sender Sender of the message.
 */
async function fpdCommonMessageListener(record, sender) {
	if (!record) return;
	await fpdGlobals;
	try {
		switch (record.purpose) {
			case "fp-detection":
				/* msg.content
				'{
					"wrapper name#wrapper type":
								{
									"args": {
										"1,2,3,just-string": 1
										},
									"stack": ["the stack"]
								}
				}'
				*/
				// parse the message
				const msg = JSON.parse(record.content);
				// check objects existance => if do not exist, create new one
				fpDb[sender.tab.id] ??= {};
				for (let resource_pair of Object.keys(msg)) {
					const splitted = resource_pair.split("#");
					let record_resource = splitted[0];
					let record_type = splitted[1];
					fpDb[sender.tab.id][record_resource] ??= {};
					fpDb[sender.tab.id][record_resource][record_type] ??= {};
					// create shortcut name to the object that contains the counters of the API calls
					const fpCounterObj = fpDb[sender.tab.id][record_resource][record_type];
					fpCounterObj["args"] ??= {};
					fpCounterObj["total"] ??= 0;
					for (let argsStr of Object.keys(msg[resource_pair].args)) {
						// increase counter for accessed arguments
						fpCounterObj["args"][argsStr] ??= 0;
						fpCounterObj["args"][argsStr] += msg[resource_pair].args[argsStr];
						// increase counter for total accesses
						fpCounterObj["total"] += msg[resource_pair].args[argsStr];
						fpdObservable.update(record_resource, sender.tab.id, record_type, fpCounterObj["total"]);
					}
					// Track the stack traces of the API
					fpCounterObj["callers"] ??= {};
					for (let record_stack of msg[resource_pair].stack) {
						fpCounterObj["callers"][record_stack] = true;
					}
				}
				break;
			case "fpd-state-change":
				browser.storage.sync.get(["fpDetectionOn"]).then(function(result) {
					fpDetectionOn = result.fpDetectionOn;
				});
				break;
			case "fpd-whitelist-check": {
				// answer to popup, when asking whether is the site whitelisted
				return Promise.resolve(isFpdWhitelisted(record.url));
			}
			case "add-fpd-whitelist":
				// obtain current hostname and whitelist it
				var currentHost = record.url;
				fpdWhitelist[currentHost] = true;
				browser.storage.sync.set({"fpdWhitelist": fpdWhitelist});
				break;
			case "remove-fpd-whitelist":
				// obtain current hostname and remove it form whitelist
				var currentHost = record.url;
				delete fpdWhitelist[currentHost];
				browser.storage.sync.set({"fpdWhitelist": fpdWhitelist});
				break;
			case "update-fpd-whitelist":
				// update current fpdWhitelist from storage
				browser.storage.sync.get(["fpdWhitelist"]).then(function(result) {
					fpdWhitelist = result.fpdWhitelist;
				});
				break;
			case "fpd-get-report-data": {
				// get current FPD level for evaluated tab
				if (record.tabId) {
					level = fpdSettings.detection ? fpdSettings.detection : 0;
					return Promise.resolve({
						tabObj: availableTabs[record.tabId],
						groups: {root: fp_levels.groups[level].name, all: fpGroups[level]},
						fpDb: fpDb[record.tabId],
						latestEvals: latestEvals[record.tabId],
						exceptionWrappers: exceptionWrappers
					});
				}
			}
			case "fpd-create-report":
				// create FPD report for the tab
				if (record.tabId) {
					generateFpdReport(record.tabId);
				}
				break;
			case "fpd-fetch-severity": {
				// send severity value of the latest evaluation
				let severity = [];
				if (record.tabId && isFpdOn(record.tabId) && latestEvals[record.tabId]) {
					severity = latestEvals[record.tabId].severity;
				}
				return Promise.resolve(severity);
			}
			case "fpd-get-settings": {
				// send settings definition and current values
				return Promise.resolve({
					def: FPD_DEF_SETTINGS,
					val: fpdSettings
				});
			}
			case "fpd-set-settings":
				// update current settings
				fpdSettings[record.id] = record.value;
				browser.storage.sync.set({"fpdSettings": fpdSettings});
				break;
			case "fpd-load-config":
				// load current configuration
				fpdLoadConfiguration();
				break;
			case "fpd-clear-storage":
				// clear browser storage for the origin
				clearStorageFacilities(record.url);
				break;
			case "fpd-fetch-hits": {
				let {tabId} = record;
				// filter by tabId;
				let hits = Object.create(null);
				if (fpDb[tabId]) {
					for ([resource, recordObj] of Object.entries(fpDb[tabId])) {
						let total = 0;
						for (let stat of Object.values(recordObj)) { // by type
							total += stat.total;
						}
						let group_name = wrapping_groups.wrapper_map[resource];
						if (group_name) {
							get_or_create(hits, group_name, 0);
							hits[group_name] += total;
						}
					}
				}
				return Promise.resolve(hits);
			}
			case "fpd-track-callers": {
				let tabId = Number(record.tabId);
				fpd_track_callers_tab = tabId;
				return browser.tabs.reload(tabId);
			}
			case "fpd-track-callers-stop": {
				fpd_track_callers_tab = undefined;
			}
		}
	} catch (e) {
		console.error(e, "Error processing", record);
		throw e;
	} finally {
		CachedStorage.save();
	}
}

/**
 * The function that makes decisions about requests blocking. If blocking enabled, also clear browsing data.
 *
 * \param requestDetails Details about the request.
 * 
 * \returns Object containing key "cancel" with value true if request is blocked, otherwise with value false
 */
function fpdRequestCancel(requestDetails) {

	// chrome fires onBeforeRequest event before tabs.onUpdated => refreshDb won't happen in time
	// need to refreshDb when main_frame request occur, otherwise also user's requests will be blocked
	if (requestDetails.type == "main_frame") {
		refreshDb(requestDetails.tabId);
	}

	return evaluateFingerprinting(requestDetails.tabId)
}

/* 
 * --------------------------------------------------
 * 				MISCELLANEOUS
 * --------------------------------------------------
 */

/**
 * The function that loads module configuration from sync storage.
 */
async function fpdLoadConfiguration() {
	({fpDetectionOn, fpdWhitelist, fpdSettings} = await browser.storage.sync.get({
		fpDetectionOn: false,
		fpdWhitelist: {},
		fpdSettings: {},
 	}));
}

/**
 * The function transforming recursive groups definition into indexed fpGroups object.
 *
 * \param input Group object from loaded JSON format.
 * \param groupsLevel Level ID of groups to process.
 */
function processGroupsRecursive(input, groupsLevel) {
	fpGroups[groupsLevel][input.name] = {};
	fpGroups[groupsLevel][input.name]["description"] = input["description"] || "";

	// criteria missing => set implicit criteria
	fpGroups[groupsLevel][input.name]["criteria"] = input["criteria"] || [{value:1, weight:1}];
	fpGroups[groupsLevel][input.name]["items"] = {};

	// retrieve associated resources (wrappers) for input group 
	for (let resourceObj of fp_levels.wrappers[groupsLevel]) {
		if (resourceObj.groups.filter((x) => (x.group == input.name)).length > 0) {
			fpGroups[groupsLevel][input.name]["items"][resourceObj.resource] = resourceObj.type;
		}
	}

	// retrieve associated sub-groups for given input group and process them recursively
	if (input["groups"]) {
		for (let groupObj of input["groups"]) {
			fpGroups[groupsLevel][input.name]["items"][groupObj.name] = "group";
			processGroupsRecursive(groupObj, groupsLevel);
		}
	}
}

/**
 * Check if the hostname or any of it's domains is whitelisted.
 *
 * \param hostname Any hostname (subdomains allowed).
 *
 * \returns TRUE when domain (or subdomain) is whitelisted, FALSE otherwise.
 */
function isFpdWhitelisted(hostname) {
	var domains = extractSubDomains(hostname);
	for (var domain of domains) {
		if (fpdWhitelist[domain] != undefined) {
			return true;
		}
	}
	return false;
}

/**
 * The function that returns FPD setting for given url.
 *
 * \param tabId Tab identifier for which FPD setting is needed.
 * 
 * \returns Boolean value TRUE if FPD is on, otherwise FALSE.
 */
function isFpdOn(tabId) {
	if (!availableTabs[tabId]) {
		return false;
	}
	let url = getEffectiveDomain(availableTabs[tabId].url);
	if (fpDetectionOn && !isFpdWhitelisted(url)) {
		return true;
	}
	return false;
}

/**
 * The function that creates notification and informs user about fingerprinting activity.
 *
 * \param tabId Integer number representing ID of suspicious browser tab.
 */
function notifyFingerprintBlocking(tabId) {
	let msg;
	if (fpdSettings.behavior > 0) {
		msg = browser.i18n.getMessage("fpdBlockingSubsequent");
	}
	if (fpdSettings.behavior == 0) {
		msg = browser.i18n.getMessage("fpdClickNotificationDetails");
	}

	browser.notifications.create("fpd-" + tabId, {
		type: "basic",
		iconUrl: browser.runtime.getURL("img/icon-48.png"),
		title: browser.i18n.getMessage("fpdNotificationTitle"),
		message: browser.i18n.getMessage("fpdNotificationMessage",
			[
				msg,
				availableTabs[tabId].title.slice(0, 30),
				getEffectiveDomain(availableTabs[tabId].url)
			])
	});
	setTimeout(() => {
		browser.notifications.clear("fpd-" + tabId);
	}, 6000);
}

/**
 * The function that generates a report about fingerprinting evaluation in a separate window.
 *
 * \param tabId Integer number representing ID of evaluated browser tab.
 */
function generateFpdReport(tabId) {
	// open popup window containing FPD report
	browser.windows.create({
		url: "/fp_report.html?id=" + tabId,
		type: "popup",
		height: 600,
		width: 800
	});
}

/**
 * Clear all stored logs for a tab.
 *
 * \param tabId Integer number representing ID of browser tab.
 */
function refreshDb(tabId) {
	if (fpDb[tabId]) {
		delete fpDb[tabId];
	}
	if (latestEvals[tabId]) {
		delete latestEvals[tabId];
	}
	if (availableTabs[tabId] && availableTabs[tabId].timerId) {
		clearTimeout(availableTabs[tabId].timerId);
	}
	CachedStorage.save();
}

/**
 * The function that periodically starts fingerprinting evaluation without the need for a request. 
 * Delay is increased exponentially and doubles in every call.
 * The periodic evaluation is done mainly to give the user the feedback about the status of FPD, for
 * example, in the badge.
 *
 * \param tabId Integer number representing ID of browser tab.
 * \param delay Initial value of a delay in milliseconds.
 */
function periodicEvaluation(tabId, delay) {
	evaluateFingerprinting(tabId);
	if (availableTabs[tabId]) {
		// limit max delay to 90s per tab
		availableTabs[tabId].timerId = setTimeout(periodicEvaluation, delay, tabId, delay > 90000 ? delay : delay*2);
	}
}

/**
 * The function that starts evaluation process and if fingerprinting is detected, it reacts accordingly.
 *
 * \param tabId Integer number representing ID of evaluated browser tab.
 * 
 * \returns Object containing key "cancel" with value true if request is blocked, otherwise with value false
 */
function evaluateFingerprinting(tabId) {
	// if FPD enabled for the site continue with FP evaluation
	if (isFpdOn(tabId)) {
		
		// start FP evaluation process and store result array
		var evalResult = evaluateGroups(tabId);
		
		// store latest severity value after evaluation of given tab
		if (evalResult.severity) {
			latestEvals[tabId].severity = evalResult.severity;
		}

		// modify color of action icon
		if (evalResult.severity[2]) {
			actionApi.setBadgeBackgroundColor({color: evalResult.severity[2], tabId: tabId});
		}

		// if actualWeight of root group is higher than 0 => reactive phase and applying measures
		if (evalResult.weight) {

			// create notification for user if behavior is "notification" or higher (only once for every tab load)
			if (fpdSettings.notifications == 1 && !stopNotifyFlag[tabId]) {
				notifyFingerprintBlocking(tabId);
				stopNotifyFlag[tabId] = true;
				CachedStorage.save();
			}

			// block request and clear cache data only if "blocking" behavior is set
			if (fpdSettings.behavior > 0) {
				
				// clear storages (using content script) for every frame in this tab
				if (tabId >= 0) {
					browser.tabs.sendMessage(tabId, {
						cleanStorage: true,
						ignoreWorkaround: fpdSettings.behavior > 1
					});
				}

				return {
					cancel: true
				};
			}
		}
	}

	return {
		cancel: false
	};
}

/**
 * The function that clears all supported browser storage mechanisms for a given origin. 
 *
 * \param url URL address of the origin.
 */
function clearStorageFacilities(url) {
	// clear all browsing data for origin of tab url to prevent fingerprint caching
	if (url && fpdSettings.behavior > 1) {
		try {
			// "origins" key only supported by Chromium browsers
			browser.browsingData.remove({
				"origins": [new URL(url).origin]
			}, {
				"appcache": true,
				"cache": true,
				"cacheStorage": true,
				"cookies": true,
				"fileSystems": true,
				"indexedDB": true,
				"localStorage": true,
				"serviceWorkers": true,
				"webSQL": true
			});
		}
		catch (e) {
			// need to use "hostnames" key for Firefox
			if (e.message.includes("origins")) {
				browser.browsingData.remove({
					"hostnames": extractSubDomains(new URL(url).hostname).filter((x) => (x.includes(".")))
				}, {
					"cache": true,
					"cookies": true,
					"indexedDB": true,
					"localStorage": true,
					"serviceWorkers": true
				});
			}
			else {
				throw e;
			}
		}
	}
}

/**
 * The function splitting resource string into path and name.
 * For example: "window.navigator.userAgent" => path: "window.navigator", name: "userAgent"
 *
 * \param wrappers text String representing full name of resource.
 * 
 * \returns Object consisting of two properties (path, name) for given resource.
 */
function split_resource(text) {
	var index = text.lastIndexOf('.');
	return {
		path: text.slice(0, index),
		name: text.slice(index + 1)
	}
}
