"use strict"

let opt = {
	clearLog: false,
	debugLog: false,
	showDebug: true,
	showOnlyOrders: false,
	showOnlyWarns: false,
	showOnlyErrors: false,
	showOnlyBalance: false,
	showOnlyNotify: false,
	scrollLog: true,

	instName: '',
	disableAll: false,
	defExchangeUse: false,
	defExchange: 'BYBIT-TESTNET',
	defSymbolUse: false,
	defSymbol: 'BTCUSDT',
	defAccountUse: false,
	defAccount: '',
	ignPerm: false,
	ignAcc: false,
	runNoConfirm: false,
	runShowLog: true,
	keepApp: false,
	loadSync: false,
	alertLocal: true,
	accLocal: true,
	wildName: true,
//	childReplace: true,
	recvNow: false,
	retryBal: false,
	refreshAll: false,
	infoBadge: true,
	badgeAll: false,
	badgeAuto: false,
	badgeReset: true,
	statManual: true,
	statMinTime: 0.1,
	maxParallel: 0,
	maxTime: 60,
	autoRestart: 0,
	autoRestartWait: 3,
	logExpire: 3,
	fireOffset: 0,
	timeSync: 8,
	splitDelay: 0,
	defaultRes: 60,
	cmcAPI: "",

	priceExpire: 0,
	priceUpNoCache: false,
	infoExpire: 168,
	idExpire: 168,
	cmcExpire: 5,

	expConfAutoInt: "0 3",
	expLogAutoInt: "5 3",
	expLogInc: true,
	expBalAutoInt: "10 3",
	expBalMailInt: "10 3",
	expPosAutoInt: "15 3",
	expPosMailInt: "15 3",

	prefixTV: false,
	prefixTVmsg: "",
	onlyStream: true,
	autoTV: false,
	forceTV: false,
	forceTVmin: 5,
	timeoutTV: false,
	timeoutTVmin: 480,
	reconnectTV: false,
	suppressTV: true,
	noWarnTV: true,
	reloadTV: false,
	fixTV: true,
	newEx: true,
	screenFirst: false,
	screenRes: 60,
	tvScale: 0,

	legacyNoShort: true,
	legacyIgn: false,
	legacyIgnErr: false,
	legacyBarOnce: false,
	legacyBarSolo: false,
	legacyNoRepeat: false,
	legacyRepReset: false,
	legacyRepResetMin: 180,
	legacyBarOpen: true,
	legacyBarStart: true,
	legacyBarMid1: true,
	legacyBarMid2: true,
	legacyBarEnd: true,
	legacyBarClose: true,
	legacyBarPos: 0,
	legacyBarPA: false,
	legacyBarAmount: 0,

	posSym: "",
	posRate: 15,
	posAuto: true,
	posNone: true,
	posEmpty: false,
	posEx: "",
	posTest: true,
	posLog: true,
	posPar: true,
	posOnlyErr: false,
	posP: 0,
	posPA: 0,
	posPC: 0,
	posQ: 10,
	posQA: 50,
	posL: 10,
	posSL: 0,
	posTP: 0,
	posLmt: 0.1,
	posStp: 0.2,
	posStop: true,
	posTime: 3,
	posPost: true,

	onlyActAlert: false,
	onlyAutoAlert: false,
	onlyOneAlert: false,
	showFilter: true,
	popupMax: false,

	emailSubject: "TradeBot {action} Notification",
	emailQty: true,
	emailId: true,
	discordQty: true,
	discordEmo: true,
	discordBuy: ":arrow_up_small:",
	discordSell: ":arrow_down_small:",
	discordExit: ":orange_circle:",
	discordId: true,
	telegramQty: true,
	telegramEmo: true,
	telegramBuy: "⬆",
	telegramSell: "⬇",
	telegramExit: "⭘",
	telegramId: true,
	telebot: false,
	telebotStat: true,
	telebotAppCtrl: true,
	telebotAlrtCtrl: true,
	telebotAppRest: true,
	telebotAcc: true,
	telebotPos: true,
	telebotAlrt: true,
	telebotRunCmd: true,
	telebotRunAlrt: true,
	telebotOver: true,
	telebotCh: false,
	telebotChWL: "",
	telebotChRep: "",
	telebotChRunCmd: true,
	telebotChRunAlrt: true,
	telebotLog: true,
	telebotWL: "",
	telebotEx: "",
	telebotSym: "",
	telebotRes: 1,
	telebotEcho: 2,
	telebotSum: true,
	iftttHook: "https://maker.ifttt.com/trigger/{action}/with/key/insertYourKeyHere",
	twilioId: true,
	twilioBuy: "⬆",
	twilioSell: "⬇",
	twilioExit: "⭘",
}

const defExchangeOpt = {
	timeout: 3.5,
	retries: 5,
	retryorder: true,
	retrycancel: true
}

const isWin = navigator.platform.startsWith('Win')
const newLine = isWin ? '\r\n' : '\n'
const maxStringify = 10 * 1024
const tzo = new Date().getTimezoneOffset() * 60000

const discordAccess = {origins:["https://discord.com/api/*"], permissions:[]}
const telegramAccess = {origins:["https://api.telegram.org/*"], permissions:[]}
const twilioAccess = {origins:["https://api.twilio.com/*"], permissions:[]}
const iftttAccess = {origins:["https://maker.ifttt.com/*"], permissions:[]}
const addons = {Discord: discordAccess, Telegram: telegramAccess, Twilio: twilioAccess, IFTTT: iftttAccess}
const regGUID = /^(\{{0,1}([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}\}{0,1})$/g
const isGUID = key => regGUID.test(key)
const regIllegalAcc = /[ \t=,'"#:\[\]()]/g
const isIllegalAcc = acc => regIllegalAcc.test(acc)
const marginSuffix = "[M]"
const isoSuffix = "[I]"
const ctSuffix = "[C]"

globalThis.browser = globalThis.msBrowser || globalThis.browser || globalThis.chrome
globalThis.rt = globalThis.browser?.runtime

function upgradeOptions() {
	delete opt.filterRepeat
	delete opt.filterFirst
	delete opt.filterLast
	delete opt.filterOffset
	opt.telegramBuy = opt.telegramBuy == ":arrow_up_small:" ? "⬆" : opt.telegramBuy
	opt.telegramSell = opt.telegramSell == ":arrow_down_small:" ? "⬇" : opt.telegramSell

	opt.legacyBarAmount = (opt.legacyBarTime && opt.legacyBarTimePerc) || opt.legacyBarAmount
	delete opt.legacyBarTime
	delete opt.legacyBarTimePerc

	opt.legacyBarMid1 ||= opt.legacyBarMid || false
	delete opt.legacyBarMid

	opt.legacyRepReset ||= opt.legacyRepeatReset || false
	opt.legacyRepResetMin ||= opt.legacyRepeatResetMin || 0
	delete opt.legacyRepeatReset
	delete opt.legacyRepeatResetMin

	opt.emailBal ||= opt.emailBalance || false
	opt.discordBal ||= opt.discordBalance || false
	opt.telegramBal ||= opt.telegramBalance || false
	delete opt.emailBalance
	delete opt.discordBalance
	delete opt.telegramBalance

	opt.keepApp ||= opt.keepPV || false
	opt.telebotPVCtrl !== undefined && (opt.telebotAppCtrl = opt.telebotPVCtrl)
	opt.telebotPVRest !== undefined && (opt.telebotAppRest = opt.telebotPVRest)
	delete opt.keepPV
	delete opt.telebotPVCtrl
	delete opt.telebotPVRest
}

function getObject(obj, ...keys) {
	for (const key of keys) {
		if (obj === null || !obj.hasOwnProperty(key)) {
			return null
		}
		obj = obj[key]
	}
	return obj
}

function setObject(...args) {
	if (!args.length) return null
	let obj = args.pop()
	while (args.length) {
		obj = {[args.pop()]: obj}
	}
	return obj
}

function setObjectDeep(target, ...args) {
	if (!args.length) return target
/*
	call patterns (args):
	key, val
	key, subkey1, subkey2, ..., val
	{key1: val1, key2: val2...} => MERGE	(only options / importconfig!)

	val = either simple value or object!
*/
}

function removeObject(obj, ...keys) {
	const key = keys.shift() || null
	if (!key || !obj.hasOwnProperty(key)) {
		return false
	}
	if (keys.length === 0) {
		delete obj[key]
		return
	}
	removeObject(obj[key], ...keys)
}

const isAnyObject = val => val !== null && typeof val === 'object'

function safeJSON(str) {
	if (isAnyObject(str)) return str
	try {
		const json = JSON.parse(str)
		if (isAnyObject(json)) return json
	} catch(ex){}
	return {}
}

function serialize(obj, prefix, doSort = false, doEncode = true) {
	let ret = []
	let keys = (obj && Object.keys(obj)) || []
	if (doSort) keys = keys.sort()

	for (let key of keys) {
		const val = obj[key]
		key = prefix ? prefix+'['+key+']' : key
		ret.push(typeof val === 'object' ? serialize(val, key, doSort, doEncode) : 
			doEncode ? encodeURIComponent(key)+'='+encodeURIComponent(val) : key+'='+val)
	}
	return ret.join('&')
}

function _(data, utf8) {
	try {
		return utf8 ? decodeURIComponent(escape(atob(data))) : atob(data)
	} catch(ex) {
		//const location = ex.stack && ex.stack.match(/[^/]+?:\d+/g) || []
		//log.warn(`Decode error at ${location[1] || 'Unknown'}`)
	}
}

function utf8(data) {
	return unescape(encodeURIComponent(data))
}

function decimals(n) {
	const parts = (parseFloat(n) + 1).toString().split('.')
	return (parts[1] && parts[1].length) || 0
}

function randomFloat(min, max, precision = -1) {
	const decs = Math.max(decimals(min), decimals(max))
	const multi = Math.pow(10, decs + 1)
	min *= multi
	max *= multi
	const rnd = Math.floor(Math.random() * (max - min + 1)) + min
	return (rnd / multi).toFixed(precision < 0 ? decs : precision)
}

function clipboardWrite(format, data, cb) {
	const origCopy = document.oncopy
	document.oncopy = function(ev) {
		ev.clipboardData.setData(format, data)
		ev.preventDefault()
	}
	document.execCommand("copy", false, null)
	document.oncopy = origCopy

	cb ||= typeof toastr === 'object' ? toastr.success : alert
	cb("Data copied to clipboard!")
}

function calculate_checksum(salt, bacon) {
	try {
		bacon = _(bacon.replace(/_/g, '/').replace(/-/g, '+'))
		salt = str_crc(salt, 'cfd8dc3000a25a5b', 'ZTNmZGQ3N2RlM2E2ODJiZg')

		const eggs = bacon.substr(27, bacon.length-35) + bacon.substr(8, 3)
		bacon = bacon.substr(0, 8) + bacon.substr(11, 16) + bacon.substr(-8)

		if (bacon === salt) {
			return parseInt(eggs)
		}
	} catch(ex) {}
}

function shuffle(array) {
	let m = array.length
	while (m) {
		let i = Math.floor(Math.random() * m--)
		let t = array[m]
		array[m] = array[i]
		array[i] = t
	}
	return array
}

async function sleep(seconds) {
	if (typeof seconds !== 'number' || isNaN(seconds) || seconds <= 0) {
		log.error("Invalid delay specified: " + seconds)
		return 0
	}
	return new Promise(res => setTimeout(res, seconds * 1000))
}

function sortByIndex(array, index, reverse) {
	if (typeof index === 'string') {
		index = [index]
	}
	const up = reverse ? -1 : 1
	const down = reverse ? 1 : -1

	array.sort((a, b) => {
		let c = a
		let d = b
		index.forEach(key => {
			if (!(c = c[key])) {
				throw new ReferenceError("A Index was not found: " + index.join("."))
			}
			if (!(d = d[key])) {
				throw new ReferenceError("B Index was not found: " + index.join("."))
			}
		})
		return c === d ? 0 : c < d ? down : up
	})
}

function str_crc(salt, hash, blob) {
	return md5(_(blob) + salt + hash)
}

function ucfirst(str) {
	return str && str[0].toUpperCase() + str.substr(1)
}

function lcfirst(str) {
	return str && str[0].toLowerCase() + str.substr(1)
}

function ucfirstx(str) {
	let offset = str && str[0] === '_' ? 1 : 0
	return str && str[offset++].toUpperCase() + str.substr(offset)
}

function ucfirsts(str) {
	return str && str.split(/[_ ]/).map(ucfirst).join(' ')
}

function urldecode(str) {
	str = str.replace(/%(?![\da-f]{2})/gi)
	str = str.replace(/\+/g, "%20")
	return decodeURIComponent(str)
}

function bin2hex(buffer) {
	return Array.prototype.map.call(buffer, b => b.toString(16).padStart(2, '0')).join('')
}

function simpleHash(s) {
	let a = 1, c = 0, h, o
	if (s) {
		a = 0
		for (h = s.length - 1; h >= 0; h--) {
			o = s.charCodeAt(h)
			a = (a << 6 & 0xFFFFFFF) + o + (o << 14)
			c = a & 0xFE00000
			a = c !== 0 ? a ^ c >> 21 : a
		}
	}
	return a
}

function stringify(obj, dec) {
	if (dec) {
		obj = Object.map(obj, (i, val) => isNaN(val) ? val : toDecimal(val, 10))
	}
	return JSON.stringify(obj, null, 1).
		replace(/\"/g, '').
		replace('[\n {', '{').
		replace(' }\n]', '}').
		replace(/,\n    /g, ', ').
		replace(/},\n /g, '},').
		substr(0, maxStringify)
}

function stringifyList(obj, dec) {
	if (dec) {
		obj = Object.map(obj, (i, val) => isNaN(val) ? val : toDecimal(val, 10))
	}
	return JSON.stringify(obj, null, 1).
		replace(/\"/g, '').
		replace('[\n {', '{').
		replace(' }\n]', '}').
		replace(/,\n    /g, ', ').
		replace(/},\n /g, '},').
		replace(/,\n  /g, ', ').
		replace(/\[\n  /g, '').
		replace(/\n \]/g, '').
		replace(/,\n custom(\d+): [^\n]*/g, ' (#$1)').
		substr(0, maxStringify)
}

function stringifyPretty(obj) {
	return JSON.stringify(obj, null, '\t').replace(/\n/g, newLine)
}

function changeCSS(name, value, cond) {
	let css = document.querySelector('#css-container')
	if (!css) {
		css = document.createElement('div')
		css.id = 'css-container'
		css.style.display = 'none'
		document.body.appendChild(css)
	}

	const sane = alnum(name, '-')
	let rule = css.querySelector(`[data-class="${sane}"]`)
	if (!rule) {
		rule = document.createElement('div')
		rule.dataset.class = sane
		css.appendChild(rule)
	}
	if (cond) {
		name = cond+'{'+name
		value += '}'
	}
	rule.innerHTML = `<style>${name}{${value}}</style>`
}

function yesno(msg, cb) {
	bootbox.confirm({
		message: msg || 'Are you sure?',
		title: '<span class="glyphicon glyphicon-question-sign"></span>  Confirm',
		buttons: {
			confirm: {label: '<span class="glyphicon glyphicon-ok"></span> Yes', className: 'btn-success'},
			cancel: {label: '<span class="glyphicon glyphicon-remove"></span> No', className: 'btn-danger'}
		},
		onShown: ev => ev.currentTarget.querySelector('.bootbox-cancel').focus(),
		swapButtonOrder: true,
		callback: cb ? result => cb(null, result) : null
	})
}

function noyes(msg, cb) {
	bootbox.confirm({
		message: msg || 'Are you sure?',
		title: '<span class="glyphicon glyphicon-question-sign"></span>  Confirm',
		buttons: {
			confirm: {label: '<span class="glyphicon glyphicon-ok"></span> Yes', className: 'btn-success'},
			cancel: {label: '<span class="glyphicon glyphicon-remove"></span> No', className: 'btn-danger'}
		},
		callback: cb ? result => cb(null, result) : null
	})
}

function onetwo(msg, one, two, cb) {
	bootbox.dialog({
		message: msg || 'Are you sure?',
		title: '<span class="glyphicon glyphicon-question-sign"></span>  Confirm',
	    onEscape: true,
		buttons: {
			one: {label: '<span class="glyphicon glyphicon-ok"></span> '+(one || 'Yes'), className: 'btn-success', callback: () => cb(null, 1)},
			two: {label: '<span class="glyphicon glyphicon-ban-circle"></span> '+(two || 'No'), className: 'btn-warning', callback: () => cb(null, 2)},
			cancel: {label: '<span class="glyphicon glyphicon-remove"></span> Cancel', className: 'btn-danger', callback: () => cb(null, false)}
		},
		onShown: ev => ev.currentTarget.querySelector('.bootbox-cancel').focus()
	})
}

function confirm(title, msg, cb) {
	bootbox.alert({
		message: msg,
		title: '<span class="glyphicon glyphicon-info-sign"></span>  '+title,
		onEscape: true,
		backdrop: true,
		buttons: {
			ok: {label: '<span class="glyphicon glyphicon-ok"></span> OK', className: 'btn-success'}
		},
		callback: cb ? result => cb(null, result) : null
	})
}

function ok(msg, cb) {
	confirm("Note!", msg, cb)
}

function error(msg, cb) {
	bootbox.alert({
		message: msg,
		title: '<span class="glyphicon glyphicon-alert"></span>  Error!',
		buttons: {
			ok: {label: '<span class="glyphicon glyphicon-remove"></span> OK', className: 'btn-danger'}
		},
		callback: cb ? result => cb(null, result) : null
	})
}

function ask(msg, title, value, cb) {
	bootbox.prompt({
		message: msg,
		title: '<span class="glyphicon glyphicon-question-sign"></span>  '+title,
		value: value,
		buttons: {
			confirm: {label: '<span class="glyphicon glyphicon-ok"></span> OK', className: 'btn-success'},
			cancel: {label: '<span class="glyphicon glyphicon-remove"></span> Cancel', className: 'btn-danger'}
		},
		swapButtonOrder: true,
		callback: cb ? result => cb(null, result) : null
	})
}

async function yesnoA(msg) {
	return new Promise(res => yesno(msg, (err, ret) => res(ret)))
}

async function noyesA(msg) {
	return new Promise(res => noyes(msg, (err, ret) => res(ret)))
}

async function onetwoA(msg, one, two) {
	return new Promise(res => onetwo(msg, one, two, (err, ret) => res(ret)))
}

async function confirmA(title, msg) {
	return new Promise(res => confirm(title, msg, (err, ret) => res(ret)))
}

async function okA(msg) {
	return new Promise(res => ok(msg, (err, ret) => res(ret)))
}

async function errorA(msg) {
	return new Promise(res => error(msg, (err, ret) => res(ret)))
}

async function askA(msg, title, value) {
	return new Promise(res => ask(msg, title, value, (err, ret) => res(ret)))
}

function getLegacyPermissions(perm, merge) {
	let loose = {origins:[]}
	let legacy = {origins:[]}

	if (perm.origins) {
		for (const origin of perm.origins) {
			loose.origins.push(origin.replace(/\.(com|us|io)\/.*/, ".$1/*"))
			legacy.origins.push(origin.replace(/\/\/.*\.(.*).(com|us|io)\/.*/, "//*.$1.$2/*"))
		}
	}
	return merge ? {origins: [...perm.origins, ...loose.origins, ...legacy.origins]} : [loose, legacy]
}

function downloadAs(data, name, type = 'text/plain') {
	const blobUrl = URL.createObjectURL(data.constructor.name === 'Blob' ? data : new Blob([data], {type: type}))
	setTimeout(() => URL.revokeObjectURL(blobUrl), 45 * 1000)

	const link = document.createElement('a')
	link.href = blobUrl
	link.download = name.replace('%d', formatDateFN())
	//link.style.display = 'none'
	//document.body.appendChild(link)
	link.click()
	//document.body.removeChild(link)
}

function openFile(type, cb) {
	const file = document.createElement('input')
	file.type = 'file'
	file.accept = type

	file.onchange = () => {
		const fileObj = file.files[0]
		if (!fileObj) {
			console.log("openFile: no file object found - aborting...")
			cb(null, null)
			try { document.body.removeChild(file) } catch(ex){}
			return
		}
		const reader = new FileReader()
		reader.onload = ev => {
			const content = ev.target.result
			console.log(`openFile: got file contents! (${content.length} chars)`)
			cb(null, content)
			try { document.body.removeChild(file) } catch(ex){}
		}
		console.log(`openFile: trying to read contents of ${fileObj.name} (${fileObj.size} bytes)...`)
		reader.readAsText(fileObj)
	}
	file.style.display = 'none'
	document.body.appendChild(file)

	document.body.onfocus = () => {
		document.body.onfocus = null
		setTimeout(() => {
			if (file.files && file.files.length < 1) {
				console.log("openFile: window focused and no file object - aborting...")
				cb(null, null)
				try { document.body.removeChild(file) } catch(ex){}
			}
		}, 500)
	}
	console.log("openFile: input element added - waiting for select...")
	file.click()
}

async function openFileA(type) {
	return new Promise(res => openFile(type, (err, ret) => res(ret)))
}

function emailMessage(action, msg, dest, interactive, noRetry, attachData, attachName, attachType) {
	if (!stats.authToken || !perms.hasAll(bg.notify)) {
		!stats.authToken && log.debug("No Email auth token found!")
		return
	}
	if (!(dest || opt.emailAddress)) {
		(interactive ? error : log.warn)("No email addresses configured!")
		return false
	}
	let subject = action || ""
	subject = subject[0] === '-' ? subject.substr(1) : opt.emailSubject.replace('{action}', ucfirst(subject))

	const encodedMail = btoa(
		'Content-Type: multipart/mixed; boundary=foo_bar_baz\n'+
		'MIME-Version: 1.0\n'+
		`To: ${dest || opt.emailAddress}\n`+
		`Subject: ${subject}\n`+
		'\n'+
		'--foo_bar_baz\n'+
		'Content-Type: text/plain; charset="UTF-8"\n'+
		'Content-Transfer-Encoding: 7bit\n'+
		'\n'+
		utf8(msg)+'\n'+
		'\n'+
		(attachData ? '--foo_bar_baz\n'+
		`Content-Type: ${attachType}; name="${attachName}"\n`+
		`Content-Disposition: attachment; filename="${attachName}"\n`+
		'Content-Transfer-Encoding: base64\n'+
		'\n'+
		btoa(attachData)+'\n'+
		'\n' : '')+
		'--foo_bar_baz--'
	).replace(/\+/g, '-').replace(/\//g, '_')

	log.debug(`Sending Email notification for action/subject '${subject}'..`)
	fetch("https://www.googleapis.com/gmail/v1/users/me/messages/send", {
		method : 'POST',
		headers: {
			'Authorization': 'Bearer '+stats.authToken,
			'Content-Type': 'application/json'
		},
		body: JSON.stringify({raw: encodedMail})
	}).then(resp => {
		log.debug("Email server response code: "+resp.status)
		if (!resp.ok) {
			return resp.json().then(resp => {
				throw new Error(resp.error.code+" "+resp.error.message)
			})
		}
		stats.incAlt('Email')
		if (typeof interactive === 'string') {
			ok(interactive)
		}
	}).catch(ex => {
		if (!noRetry && ex.message.startsWith('401')) {
			log.warn("Email token seems to have expired, retrying...")
			stats.incAlt('EmailAuth')
			updateAuthToken(false, function(){
				emailMessage(action, msg, dest, interactive, true, attachData, attachName, attachType)
			})
		} else {
			stats.incAlt('EmailErr')
			;(interactive ? error : log.error)("Error sending Email notification: "+ex.message)
		}
	})
	return true
}

function updateAuthToken(interactive, cb) {
	if (!interactive && stats.authToken) {
		browser.identity.removeCachedAuthToken({token: stats.authToken})
	}
	if (interactive) {
		showSpinner("Requesting email permission...")
	}
	browser.identity.getAuthToken({"interactive": interactive}, function(token) {
		void rt.lastError

		if (stats.authToken && !token && (!rt.lastError || !rt.lastError.message.includes("Connection failed"))) {
			log.warn("Permission removed: Email")
			delete stats.authToken
		} else if (token) {
			if (!stats.authToken) {
				log.success("Permission granted: Email")
			}
			stats.authToken = token
		}

		try {
			const emailPanel = document.querySelector("[data-addon='Email']").closest(".panel")
			setButton(emailPanel, stats.authToken)
			stats.authToken && emailPanel.classList.remove("disabled")
			!stats.authToken && emailPanel.classList.add("disabled")
		} catch(ex) {}

		if (!token && rt.lastError && !rt.lastError.message.includes("OAuth2 not granted or revoked")) {
			const msg = "Error requesting Email auth token: "+rt.lastError.message
			log.error(msg)
			if (interactive) {
				error(msg, hideSpinner)
			}
		} else if (interactive) {
			hideSpinner()
		}
		cb && cb(token)
	})
}

function discordMessage(action, msg, dest, interactive) {
	if (!perms.hasAny(getLegacyPermissions(discordAccess, true)) || !perms.hasAll(bg.notify)) {
		return
	}
	if (!opt.discordHook || !opt.discordHook.startsWithAny(["https://discordapp.com/api/", "https://discord.com/api/"])) {
		(interactive ? error : log.warn)("Discord Webhook URL not configured or incorrect!")
		return false
	}
	if (dest && !dest.startsWithAny(["https://discordapp.com/api/webhooks/", "https://discord.com/api/webhooks/"])) {
		dest = "https://discord.com/api/webhooks/"+dest
	}

	log.debug(`Sending Discord notification for action '${action}'..`)
	fetch((dest || opt.discordHook).replace("https://discordapp.com/", "https://discord.com/"), {
		method : 'POST',
		headers: {'Content-Type': 'application/json'},
		body: JSON.stringify({
			content: msg
		})
	}).then(resp => {
		log.debug("Discord server response code: "+resp.status)
		if (!resp.ok) {
			return resp.json().then(resp => {
				throw new Error(resp.message)
			})
		}
		stats.incAlt('Discord')
		if (typeof interactive === 'string') {
			ok(interactive)
		}
	}).catch(ex => {
		stats.incAlt('DiscordErr')
		;(interactive ? error : log.error)("Error sending Discord notification: "+ex.message)
	})
	return true
}

function telegramErr(resp) {
	return `${resp.statusText || ({
		502: 'Bad Gateway – connection error',
		500: 'Internal Telegram error',
		420: 'Flood – maximum allowed number of attempts has been exceeded',
		409: 'Conflict: terminated by other getUpdates request; make sure that only one bot instance is running',
		404: 'Bot Token missing or invalid',
		403: 'Privacy violation',
		401: 'Unauthorized attempt to use functionality available only to authorized users',
		400: 'The query contains errors',
		303: 'The request must be repeated, but directed to a different data center',
	}[resp.status] || 'Unknown connection error')} (${resp.status})`
}

function telegramMessage(action, msg, dest, interactive) {
	if (!perms.hasAny(getLegacyPermissions(telegramAccess, true)) || !perms.hasAll(bg.notify)) {
		return
	}
	if (!opt.telegramToken || !opt.telegramChat) {
		(interactive ? error : log.warn)("Telegram Bot Token / Chat ID not configured!")
		return false
	}

	log.debug(`Sending Telegram notification for action '${action}'..`)
	fetch(`https://api.telegram.org/bot${opt.telegramToken}/sendMessage`, {
		method : 'POST',
		headers: {'Content-Type': 'application/json'},
		body: JSON.stringify({
			chat_id: dest || opt.telegramChat,
			text: msg,
			parse_mode: 'Markdown'
		})
	}).then(resp => {
		log.debug("Telegram server response code: "+resp.status)
		if (!resp.ok) {
			throw new Error(telegramErr(resp))
		}
		stats.incAlt('Telegram')
		if (typeof interactive === 'string') {
			ok(interactive)
		}
	}).catch(ex => {
		stats.incAlt('TelegramErr')
		const msg = ex.message
		;(interactive ? error : log.error)("Error sending Telegram notification: "+msg+(msg.startsWith('400') ? " (escape all single underscores in custom messages or use dashes instead!)" : ""))
	})
	return true
}

function twilioMessage(action, msg, dest, interactive) {
	if (!perms.hasAny(getLegacyPermissions(twilioAccess, true)) || !perms.hasAll(bg.notify)) {
		return
	}
	if (!opt.twilioSID || !opt.twilioToken) {
		(interactive ? error : log.warn)("Twilio Account SID / Auth Token not configured!")
		return false
	}
	if (!opt.twilioFrom || !(dest || opt.twilioTo)) {
		(interactive ? error : log.warn)("Twilio From/To # not configured!")
		return false
	}

	log.debug(`Sending Twilio notification for action '${action}'..`)
	fetch(`https://api.twilio.com/2010-04-01/Accounts/${opt.twilioSID}/Messages.json`, {
		method : 'POST',
		headers: {
			'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
			'Authorization': 'Basic '+btoa(opt.twilioSID+':'+opt.twilioToken)
		},
		body: serialize({
			To: dest || opt.twilioTo,
			From: opt.twilioFrom,
			Body: msg
		})
	}).then(resp => {
		log.debug("Twilio server response code: "+resp.status)
		if (!resp.ok) {
			throw new Error(resp.status+" "+resp.statusText)
		}
		stats.incAlt('Twilio')
		if (typeof interactive === 'string') {
			ok(interactive)
		}
	}).catch(ex => {
		stats.incAlt('TwilioErr')
		const msg = ex.message
		;(interactive ? error : log.error)("Error sending Twilio notification: "+ex.message)
	})
	return true
}

function iftttMessage(action, msg, dest, interactive) {
	if (!perms.hasAny(getLegacyPermissions(iftttAccess, true)) || !perms.hasAll(bg.notify)) {
		return
	}
	if (!opt.iftttHook || !opt.iftttHook.startsWith("https://maker.ifttt.com/")) {
		(interactive ? error : log.warn)("IFTTT Webhook URL not configured or incorrect!")
		return false
	}

	action = pvShortU+ucfirst(action)
	log.debug(`Sending IFTTT notification for action '${action}'..`)
	msg && (msg = msg.split(';'))
	fetch(opt.iftttHook.replace('{action}', encodeURIComponent(action.replace(/ /g, '_'))), {
		method : 'POST',
		headers: {'Content-Type': 'application/json'},
		body: JSON.stringify(msg ? {
			value1: msg[0] || '',
			value2: msg[1] || '',
			value3: msg[2] || ''
		} : {})
	}).then(resp => {
		log.debug("IFTTT server response code: "+resp.status)
		if (!resp.ok) {
			return resp.json().then(resp => {
				throw new Error(resp.errors[0].message)
			})
		}
		stats.incAlt('IFTTT')
		if (typeof interactive === 'string') {
			ok(interactive)
		}
	}).catch(ex => {
		stats.incAlt('IFTTTErr')
		;(interactive ? error : log.error)("Error sending IFTTT notification: "+ex.message)
	})
	return true
}

async function checkNotifications() {
	try {
		const result = await request('POST-', 'https://profitview.app/ipn/ping/', __(ID, UUIDD, GUID, accountNum, 
			Math.ceil(stats.bootupFirst / 86400000), Math.round((Number(stats.uptimeTotal) + Number(stats.uptime)) / 86400), 
			stats.totalAlerts, perms.count(), exNum, pvVersion, localStore.get().perms.crc || 0), {})

		if (!result || result.code !== 200) {
			throw new Error(result.message || "Unknown")
		}
		if (result.payload) {
			const ops = [], size = 43, data = result.payload, crc = parseInt(data)
			let i = crc.toString().length, left = data.length - i, msg = null
			while (left >= size) {
				const len = left < 2 * size ? left : size
				const op = _(data.substr(i, len))
				if (op[0] === '-') (msg = perms.filter().save(true))
				else ops.push(op)
				i += len; left -= len
			}
			localStore.get().perms.crc = crc
			ops.length && (msg = perms.grant(ops).save())
			msg && rt.sendMessage(rt.id, {method: 'refresh'})
		}
		opt.sum = result.nonce
		stats.ping = Date.now()
	} catch(ex){
		stats.inc('PingErr', 1, true, ex.message)
	}
}

function wildcardExp(wildcard = "", startsWith, endsWith) {
	return new RegExp((endsWith ? '' : '^') + 
		wildcard.split('*').map(str => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1')).join('.*') + (startsWith ? ''/*'.*$'*/ : '$'), 'i')
}

const isObject = val => Object.prototype.toString.call(val) === '[object Object]'

Object.assignDeep = function(target, ...sources) {
	target ||= {}
	for (const source of sources) {
		if (source) for (const prop of Object.keys(source)) {
			target[prop] = isObject(source[prop]) ? Object.assignDeep(target[prop], source[prop]) : source[prop]
		}
	}
	return target
}

Object.filter = (obj, check) => {
	let result = {}
	if (obj) Object.keys(obj).forEach(key => {
		if (check(key, obj[key])) {
			result[key] = obj[key]
		}
	})
	return result
}

Object.map = (obj, check) => {
	let result = {}, ret
	if (obj) Object.keys(obj).forEach(key => {
		if ((ret = check(key, obj[key])) != null) {
		    result[key] = ret
		}
	})
	return result
}

Object.each = (obj, cb) => obj && Object.keys(obj).forEach(key => cb(key, obj[key]))

Object.without = (obj, list) => Object.filter(obj, key => !list.includes(key))

Object.filterAsArray = (obj, check) => {
	let result = []
	if (obj) Object.keys(obj).forEach(key => {
		if (check(key, obj[key])) {
			result.push(obj[key])
		}
	})
	return result
}

Object.mapAsArray = (obj, check) => {
	let result = [], ret
	if (obj) Object.keys(obj).forEach(key => {
		if ((ret = check(key, obj[key]))/* != null*/) {
			result.push(ret)
		}
	})
	return result
}

Object.isEmpty = obj => !obj || Object.keys(obj).length === 0

Object.sort = obj => Object.keys(obj).sort().reduce((res, key) => (res[key] = obj[key], res), {})

Object.defineProperty(Object.prototype, 'getProp', {
	value: function (prop, def = null) {
		if (Array.isArray(this)) {
			prop = prop.toLowerCase()
			for (const item of this) {
				if (item.toLowerCase() === prop) {
					return item
				}
			}
		} else {
			if (this.hasOwnProperty(prop)) return this[prop]
			prop = prop.toLowerCase()
			for (const key in this) {
				if (key.toLowerCase() === prop) {
					return this[key]
				}
			}
		}
		return def
	},
	enumerable: false
})

Object.defineProperty(Object.prototype, 'getAny', {
	value: function (list, def = null) {
		if (list) {
			for (const item of list) {
				if (this.hasOwnProperty(item) && this[item] !== null) {
					return this[item]
				}
			}
		}
		return def
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, 'part', {
	value: function (filter) {
		let pass = [], fail = []
		this.forEach((e, idx, arr) => (filter(e, idx, arr) ? pass : fail).push(e))
		return [pass, fail]
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, 'includesNoCase', {
	value: function (key, wild) {
		// wild -1 ==> key: wildcards array: literal
		// wild  0 ==> key: literal   array: literal
		// wild  1 ==> key: literal   array: wildcards
		// wild  2 ==> key: literal   array: both (quotes = literal)
		(!wild || wild > 1) && (key = key && key.toLowerCase())
		wild < 0 && (key = wildcardExp(key))
		for (const item of this) {
			if (wild < 0 ? key.test(item) : 
				(!wild && item.toLowerCase() === key) || 
				(wild > 1 && item[0] === `"` && item.slice(1, -1).toLowerCase() === key) || // $$$ unquote?
				(wild && wildcardExp(item).test(key))) {
				return true
			}
		}
		return false
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, 'includesAny', {
	value: function (list) {
		if (list) {
			let i = 1
			for (const item of list) {
				if (this.includes(item)) {
					return i
				}
				++i
			}
		}
		return 0
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, 'contains', {
	value: function (str) {
		return this.find(item => item && item.includes && item.includes(str))
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, 'without', {
	value: function (list) {
		if (list && list.includes) {
			return this.filter(el => !list.includes(el))
		}
		return this
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, 'last', {
	value: function () {
		return this[this.length - 1]
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, '_next', {
	value: function () {
		this._ ||= 0
		return this._ >= this.length ? undefined : this[this._++]
	},
	enumerable: false
})
Object.defineProperty(Array.prototype, '_prev', {
	value: function () {
		this._ ||= 0
		return this._ > 0 ? this[--this._] : undefined
	},
	enumerable: false
})
Object.defineProperty(Array.prototype, 'remain', {
	value: function () {
		return this.length - (this._ || 0)
	},
	enumerable: false
})
Object.defineProperty(Array.prototype, 'isStart', {
	value: function () {
		return (this._ || 0) < 1
	},
	enumerable: false
})
Object.defineProperty(Array.prototype, 'isEnd', {
	value: function () {
		return (this._ || 0) >= this.length
	},
	enumerable: false
})
Object.defineProperty(Array.prototype, 'gotoEnd', {
	value: function (offset) {
		this._ = Math.max(this.length + offset, 0)
		return this
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, 'reset', {
	value: function () {
		delete this._
		return this
	},
	enumerable: false
})

Object.defineProperty(Array.prototype, 'numbers', {
	value: function (split) {
		return this.map(e => !isNaN(e) ? Number(e) : split ? e.trim().splitOne(/[:=]/, true).numbers() : e.trim())
	},
	enumerable: false
})

Object.defineProperty(String.prototype, 'startsWithAny', {
	value: function (list, wild) {
		if (list) {
			let i = 1
			for (const item of list) {
				if ((!wild && (item.exec && item.test ? item.test(this) : this.startsWith(item))) || 
					(wild && wildcardExp(item, true).test(this))) {
					return i
				}
				++i
			}
		}
		return false
	},
	enumerable: false
})

Object.defineProperty(String.prototype, 'includesNoCase', {
	value: function (str) {
		return str && str.toLowerCase && this.toLowerCase().includes(str.toLowerCase())
	},
	enumerable: false
})

Object.defineProperty(String.prototype, 'includesAny', {
	value: function (list) {
		if (list) {
			let i = 1
			for (const item of list) {
				if (this.includes(item)) {
					return i
				}
				++i
			}
		}
		return 0
	},
	enumerable: false
})

Object.defineProperty(String.prototype, 'contains', {
	value: function (str) {
		return this.includes(str)
	},
	enumerable: false
})

Object.defineProperty(String.prototype, 'count', {
	value: function (ch, from, to) {
		let count = 0
		for (let i = from; i < to; ++i) {
			this[i] === ch && ++count
		}
		return count
	},
	enumerable: false
})

Object.defineProperty(String.prototype, 'trimSep', {
	value: function () {
		return this.replace(/^[\s,;]*/, '').replace(/[\s,;]*$/, '')
	},
	enumerable: false
})

Object.defineProperty(String.prototype, 'trimList', {
	value: function () {
		return unquote(this.trim()).split(',')
	},
	enumerable: false
})

Object.defineProperty(String.prototype, 'splitOne', {
	value: function (sep, delist) {
		let i1 = -1, i2 = sep.length
		if (sep.constructor === RegExp) {
			const res = this.match(sep)
			if (res) {
				i1 = res.index
				i2 = res[0].length
			}
		} else {
			i1 = this.indexOf(sep)
		}
		let first = i1 < 0 ? this : this.slice(0, i1)
		let second = []
		if (i1 >= 0) {
			i2 += i1
			second = delist ? this.slice(i2).trimList() : [this.slice(i2)]
		}
		return [first, ...second]
	},
	enumerable: false
})

function formatCcy(name) {
	return name.toUpperCase().
		replace(ctSuffix+marginSuffix, '<sup>CT</sup>').
		replace(isoSuffix+marginSuffix, '<sup>ISO</sup>').
		replace(marginSuffix, '<sup>M</sup>')
}

function formatDate(date, suffix, na = "n/a") {
	if (!date || date === "0") return na
	try {
		const str = new Date((!isNaN(date) || date.toISOString ? date : new Date(date)) - tzo).toISOString()
		return str.replace('T', ' ').slice(0, suffix ? -1 : -5)
	} catch(ex){}
	return na
}

function formatDateS(date, suffix, na = "n/a") {
	if (!date || date === "0") return na
	try {
		const str = new Date((!isNaN(date) || date.toISOString ? date : new Date(date)) - tzo).toISOString()
		return str.replace('T', ' ').slice(5, suffix ? -1 : -5)
	} catch(ex){}
	return na
}

function formatDateSP(date, suffix, na = "n/a") {
	const parts = formatDateS(date, suffix, na).split(' ')
	return `${parts[0]} <span class="detail">${parts[1]||''}</span>`
}

function formatDateFN(date = new Date()) {
	return formatDate(date).replace(/[^0-9]/g, '-')
}

function tillNow(date) {
	return (Date.now() - (date.toISOString ? date : new Date(date))) / 1000
}

function uniqueID() {
	return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,
		c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16))
}

function alnum(str, rep = '') {
	return (str || '').replace(/[^a-zA-Z0-9]/g, rep)
}

function alnumStart(str) {
	return str?.match && (str.match(/^\w+/) || [''])[0]
}

function formatFields(obj, fields, format, pad, suffix = "", esc) {
	format ||= "\n %f: %v,"
	let result = ""
	let padding = 0

	if (obj && fields) {
		if (pad) {
			for (const field of fields) {
				if (obj.getProp(field)) {
					padding = Math.max(padding, field.length)
				}
			}
			padding += suffix.length
		}

		let prev = ""
		for (const field of fields) {
			const curr = field.toLowerCase()
			if (prev === curr) continue
			let val = obj.getProp(prev = curr)
			if (val && val != 'null') {
				val = (typeof val.toJSON === 'function') ? val.toJSON() : val
				result += format.replace(/%f/g, ucfirstx(field+suffix).padEnd(padding)).replace(/%v/g, esc && typeof val === 'string' ? esc(val) : val)
			}
		}
	}
	return result
}

function __(str) {
	const nonce = Math.floor(Math.random() * 0x7FFFFFFF)
	const pay = simpleHash(str) ^ nonce
	str = Array.prototype.slice.call(arguments).join('|')
	let result = ''
	for (let i=0; i < str.length; ++i) {
		result += String.fromCharCode(str.charCodeAt(i) ^ (pay & 0xFF))
	}
	result = nonce.toString(16).padStart(8, '0') + btoa(result).replace(/=+$/g, '').replace(/\//g, '_').replace(/\+/g, '-') + pay.toString(16).padStart(8, '0')
	return result
}

function telescape(str) {
	return str && str.replace(/\*/g, '⋆'/*✲*/).replace(/_/g, ' ̲ '/*＿*/)
}

function mustacheFields(obj, str, doEscape) {
	if (!obj || !str?.replace) return str

	return str.replace(/{([^{}]+)}/g, (match, field) => {
		let val
		if (field.length > 2 && /[+\-*/%^]/.test(field)) {
			field = field.split(':')
			val = Command.solveExp(field[0], obj)
		} else {
			field = field.split(/[.:]/)
			val = globalVars.ohlc(field[0], obj, null, true)
		}
		if (val === null || val === undefined) return match
		val = typeof val.toJSON === 'function' ? val.toJSON() : val
		if (field[1]) {
			if (!isNaN(val)) {
				val = Number(val).toFixed(field[1])
			} else if (val.split) {
				val = val.split(/[-\s]/).slice(0, field[1]).join('-')
			}
		} else if (!isNaN(val)) {
			val = toDecimal(val, 10)
		}
		return doEscape && typeof val === 'string' ? telescape(val) : val
    })
}

function intervalMatch(exp, time) {
	const checkOnly = time === undefined
	time ||= new Date()
	function err(msg) {
		if (!checkOnly) {
			log.warn(msg)
			return false
		}
		return msg
	}

	const parts = exp.toString().split(' ').filter(Boolean)
	if (!parts.length) {
		return err(`Syntax error in interval expression (no part given): "${exp}"`)
	}

	let i = 0
	for (const part of parts) {
		if (++i > 6) return err(`Syntax error in interval expression (more than 6 parts given): "${exp}"`)
		if (part === "*") continue

		let value = 
			i === 1 ? time.getMinutes() : 	// 0-59
			i === 2 ? time.getHours() : 	// 0-23
			i === 3 ? time.getDate() : 		// 1-31
			i === 4 ? time.getMonth()+1 : 	// 1-12
			i === 5 ? time.getDay() : 		// 0-6
					time.getFullYear()

		if (part.startsWith("*/") || ((i < 3 || i > 4) && part.startsWith("0/")) || ((i >= 3 && i <= 4) && part.startsWith("1/"))) {
			const every = Number(part.slice(2))
			if (!every || isNaN(every)) {
				return err(`Syntax error in interval expression (part ${i}, every): "${exp}"`)
			}
			i === 4 && --value
			if (value % every && !checkOnly) {
				return false
			}

		} else if (part.includes('-')) {
			const range = part.split('-').map(el => el !== '' ? Number(el) : NaN)
			if (range.length != 2 || range.includes(NaN)/* || range[0] > range[1]*/) {
				return err(`Syntax error in interval expression (part ${i}, range): "${exp}"`)
			}
			if ((range[0] > range[1] ? 
					(value < range[0] && value > range[1]) : 
					(value < range[0] || value > range[1])) 
					&& !checkOnly) {
				return false
			}

		} else if (part.includes(',')) {
			const list = part.split(',').map(el => Number(el))
			if (list.includes(NaN)) {
				return err(`Syntax error in interval expression (part ${i}, list): "${exp}"`)
			}
			if (!list.includes(value) && !checkOnly) {
				return false
			}

		} else if (part != value) {
			if (isNaN(part)) {
				return err(`Syntax error in interval expression (part ${i}, value): "${exp}"`)
			}
			if (!checkOnly) {
				return false
			}
		}
	}
	return !checkOnly
}

function interval2Text(exp) {
	let min = exp[0], hour = exp[1], day = exp[2], month = exp[3], dayof = exp[4]
	let hourDone = false
	let desc = ""

	if (min == '*') {
		desc += "Every minute"
	} else if (min.startsWith('*/')) {
		desc += `Every ${min.substr(2)} minutes`
	} else if (min.includes('-')) {
		min = min.split('-')
		if (hour && !isNaN(hour)) {
			hour = hour.padStart(2,'0')
			desc += `Every minute between ${hour}:${min[0].padStart(2,'0')} and ${hour}:${min[1].padStart(2,'0')}`
			hourDone = true
		} else {
			desc += `Minutes ${min[0]} through ${min[1]} past the hour`
		}
	} else {
		min = min.split(',')
		if (min.length == 1 && hour) {
			if (!isNaN(hour)) {
				desc += `At ${hour.padStart(2,'0')}:${min[0].padStart(2,'0')}`
				hourDone = true
			} else if (hour.includes(',')) {
				hour = hour.split(',')
				desc += "At "
				do {
					const val = hour._next().padStart(2,'0')
					desc += (hour._ < 2 ? "" : hour.isEnd() ? " and " : ", ") + `${val}:${min[0].padStart(2,'0')}`
				} while (!hour.isEnd())
				hourDone = true
			}
		}
		if (!hourDone) {
			desc += "At "
			do {
				const val = min._next()
				desc += (min._ < 2 ? "" : min.isEnd() ? " and " : ", ") + val
			} while (!min.isEnd())
			desc += " minutes past the hour"
		}
	}
	if (!hourDone && hour) {
		if (hour.startsWith('*/')) {
			desc += `, every ${hour.substr(2)} hours`
		} else if (hour.includes('-')) {
			hour = hour.split('-').map(e => e.padStart(2,'0'))
			desc += `, between ${hour[0]}:00 and ${hour[1]}:59`
		} else if (hour != '*') {
			hour = hour.split(',').map(e => e.padStart(2,'0'))
			if (hour.length == 1) {
				desc += `, between ${hour[0]}:00 and ${hour[0]}:59`
			} else {
				desc += ", at "
				do {
					const val = hour._next()
					desc += (hour._ < 2 ? "" : hour.isEnd() ? " and " : ", ") + val + ":00"
				} while (!hour.isEnd())
			}
		}
	}
	if (day && day != '*') {
		if (day.startsWith('*/')) {
			desc += `, every ${day.substr(2)} days`
		} else if (day.includes('-')) {
			day = day.split('-')
			desc += `, between day ${day[0]} and ${day[1]} of the month`
		} else {
			day = day.split(',')
			desc += ", on day "
			do {
				const val = day._next()
				desc += (day._ < 2 ? "" : day.isEnd() ? " and " : ", ") + val
			} while (!day.isEnd())
			desc += " of the month"
		}
	} else if (!dayof || dayof == "*") {
		desc += ", daily"
	}
	const dayName = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
	if (dayof && dayof != '*') {
		if (dayof.startsWith('*/')) {
			desc += `, every ${dayof.substr(2)} days of the week`
		} else if (dayof.includes('-')) {
			dayof = dayof.split('-')
			desc += `, ${dayName[dayof[0]]} through ${dayName[dayof[1]]}`
		} else {
			dayof = dayof.split(',')
			desc += ", only on "
			do {
				const val = dayof._next()
				desc += (dayof._ < 2 ? "" : dayof.isEnd() ? " and " : ", ") + dayName[val]
			} while (!dayof.isEnd())
		}
	}
	const monthName = ["", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
	if (month && month != '*') {
		if (month.startsWith('*/')) {
			desc += `, every ${month.substr(2)} months`
		} else if (month.includes('-')) {
			month = month.split('-')
			desc += `, ${monthName[month[0]]} through ${monthName[month[1]]}`
		} else {
			month = month.split(',')
			desc += ", only in "
			do {
				const val = month._next()
				desc += (month._ < 2 ? "" : month.isEnd() ? " and " : ", ") + monthName[val]
			} while (!month.isEnd())
		}
	}
	return desc
}

function unquote(str) {
	if (!str) return str
	if ((str[0] === '"' || str[0] === '\'') && (str[str.length-1] === '"' || str[str.length-1] === '\'')) {
		str = str.slice(1, -1)
	}
	return str.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\z/g, '‌').replace(/\\\\/g, '\\')
}

function showSpinner(msg) {
	 try {
		$("#spinner").modal('show')
		q(".modal-backdrop:last-child").style.zIndex = 1999
	} catch(ex){}	
}

function hideSpinner() {
	 try {
		$("#spinner").modal('hide')
	} catch(ex){}	
}

function getTF(res, def = opt.defaultRes) {
	let val = parseFloat(res) || def
	switch(res && res.slice && res.slice(-1)) {
		case 'M':
			val *= 43800; break
		case 'W':
		case 'w':
			val *= 10080; break
		case 'D':
		case 'd':
			val *= 1440; break
		case 'H':
		case 'h':
			val *= 60; break
		case 'S':
		case 's':
			val /= 60; break
	}
	return val
}

function formatTF(res = 60) {
	if (res % 43800 === 0) {
		return (res / 43800) + "M"
	} else if (res % 10080 === 0) {
		return (res / 10080) + "w"
	} else if (res % 1440 === 0) {
		return (res / 1440) + "d"
	} else if (res % 60 === 0) {
		return (res / 60) + "h"
	} else if (res && res < 1) {
		return (res * 60).toFixed(0) + "s"
	}
	return res + "m"
}

function formatDur(dur, floats = 0, na, mark) {
	if (isNaN(dur) || (na !== undefined && dur == na)) return "n/a"
	let str = ""
	if (dur >= 86400) {
		str += Math.floor(dur / 86400) + "d "
	}
	if (dur >= 3600) {
		str += Math.floor(dur / 3600) % 24 + "h "
		if (mark) str += `<span class="detail">`
	}
	if (dur >= 60) {
		str += Math.floor(dur / 60) % 60 + "m "
		if (dur < 3600 && mark) str += `<span class="detail">`
	}
	if (floats < 0) {
		floats = dur < 60 ? -floats : 0
	}
	if (floats || !dur || dur % 60) {
		str += (floats ? (dur % 60).toFixed(floats) : Math.round(dur % 60)) + "s"
	} else if (str.length) {
		str = str.slice(0, -1)
	}
	if (mark) str += `</span>`
	return str
}

function formatDurP(dur, floats = 0, na) {
	return formatDur(dur, floats, na, true)
}

function getAlerts(id, def) {
	const store = localStore.get('hasAlerts') ? localStore.get() : syncStore.get()
	if (id !== undefined) {
		return store['alert_'+id] || def
	}
	return Object.filterAsArray(store, key => key.startsWith('alert_'))
}

function getAlertsByName(name) {
	if (isNaN(name)) {
		return name === '*' ? getAlerts() : getAlerts().filter(alert => alert.names && alert.names.includesNoCase(name, -1))
	} else {
		const result = getAlerts(name)
		return (result && [result]) || []
	}
}

function getAllAlerts() {
	const store = localStore.get('hasAlerts') ? localStore.get() : syncStore.get()
	return Object.filter(store, key => key.startsWith('alert_'))
}

function alertSort(a, b) {
	a = Number(a.sort || a.id)
	b = Number(b.sort || b.id)
	return a < b ? -1 : a > b ? 1 : 0
}

function upgradeAlert(alert) {
	alert.barPos ||= 0
	alert.barPA ||= false
	alert.barAmount = (alert.barTime && alert.barTimePerc) || alert.barAmount || 0
	delete alert.barTime
	delete alert.barTimePerc

	alert.barMid1 ||= alert.barMid || false
	delete alert.barMid

	alert.repReset ||= alert.repeatReset || false
	alert.repResetMin ||= alert.repeatResetMin || 0
	alert.recv ||= alert.received || ''
	alert.receivedNum = alert.recvNum || alert.receivedNum || 0
	delete alert.repeatReset
	delete alert.repeatResetMin
	delete alert.received
	delete alert.receivedNum

	alert.log ||= alert.noLog === false ? 15 : 0
	delete alert.noLog
}

function timeAdd(ts, add) {
	return new Date(ts).getTime() + add * 1000
}

function minmax(val, min = 0, max = Number.MAX_VALUE) {
	return Math.max(Math.min(val, max), min)
}

function lineNumbers(str) {
	const lines = Array.isArray(str) ? str : str.split('\n')
	const digits = lines.length.toString().length
	return lines.map((line, i) => (i+1).toString().padStart(digits, ' ') + ": " + line).join('\n')
}

function findSyntax(what, syntax, startRow = 0) {
	if (!Array.isArray(syntax)) syntax = syntax.split('\n')
	const exp = RegExp('(^|\\s|#)('+what+')=([\\w\-_:.\*]*)', 'i')

	let row = startRow
	let match
	do {
		if ((match = syntax[row].match(exp))) return match[3]
	} while (--row >= 0)
	row = startRow
	while (++row < syntax.length) {
		if ((match = syntax[row].match(exp))) return match[3]
	}
	return null
}

function isEmail(str) {
	return str && /^\S+@\S+\.\S+$/.test(str) || false
}

function toPrecision(num, precision) {
	const f = 10 ** Math.floor(precision)
	return Math.round((Number(num) + Number.EPSILON) * f) / f
}

function toFixed(num, digits) {
	// $$$$$ add to opt!
	return num.toLocaleString(/*navigator?.language || */'en-US', {useGrouping: true, minimumFractionDigits: digits, maximumFractionDigits: digits})
}

function toDecimal(num, maxPrecision = 8) {
	// num.toLocaleString('fullwide', {useGrouping: false, maximumFractionDigits: maxPrecision}) ?
	if (!num || !num.toString) return num
	const str = num.toString()
	const parts = str.split('e-')
	if (parts.length > 1) {
		const exp = parts[0].length
		const power = +parts[1]
		return num.toFixed(Math.min(maxPrecision, power + exp - Math.min(exp, 2)))
	}
	return str
}

function chroneCB(cb, result) {
	return !globalThis['tvLastPing2'] ? globalThis['chromeCheck'](cb(result)) : cb(result)
}

function escapeHTML(str) {
	return str.replace(/[&<>"']/g, ch => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#039;'})[ch])
}

async function request(method, url, params, headers) {
	method = method.toUpperCase()
	const retHeaders = method.endsWith('+')
	const noDebug = method.endsWith('-')
	const fixBigInt = method.endsWith('*')
	if (retHeaders || noDebug || fixBigInt) method = method.slice(0, -1)

	headers = new Headers(headers || {})
	if (!headers.has('Content-Type')) {
		headers.append('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8')
	}
	if (params) {
		if (typeof params !== 'string') {
			// $$$ method === 'POST' && headers.get('Content-Type') ^= application/json => JSON.stringify(params)
			params = serialize(params)
		}
		// $$$ method !== 'POST'
		if (method === 'GET') {
			url += (url.includes('?') ? '&' : '?') + params
			params = null
		}
	}
	const debugLog = msg => {
		if (noDebug) return
		if (this !== globalThis && this?.debug) return this.debug(msg)
		log.debug(msg)
	}
	debugLog(`Sending request: ${method} ${url} ${params||''}`)

	const options = {method: method, headers: headers}
	if (params) options.body = params

	const resp = await fetch(url, options)
	const status = resp.status
	const text = await resp.text()
	let json = null
	try {
		json = JSON.parse(fixBigInt ? text.replace(/([\[:])?(\d{14,})([,\}\]])/g, "$1\"$2\"$3") : text)
	} catch(ex) {}
	debugLog(`Received response code ${status}: ${json ? stringify(json) : escapeHTML(text)}`)

	if (!resp.ok) {
		if (json && json.error) {
			const reason = (json.error.data && json.error.data.reason) || json.error.reason
			const suffix = ` (Error Code ${json.error.code || json.code || status}${reason ? " – "+reason :''})`

			if (typeof json.error.message === 'string') {
				json.error.message += suffix
			} else if (typeof json.error === 'string') {
				json.error += suffix
			}
		}
		throw json || {message: (resp.statusText || text || `Connection error with endpoint${({
			401: " – Unauthorized, check credentials / IP address!",
			403: " – Forbidden, check credentials / IP address!",
			429: " – Too Many Requests!",
			500: " – Internal Server Error!",
			502: " – Bad Gateway!",
			503: " – Service unavailable!",
			504: " – Gateway Timeout!",
		})[status] || ''}`) + ` (Error Code ${status})`}
	}
	if (json && retHeaders) json._headers = resp.headers
	return json
}

function chromeCheck(data) {
	const prop = 'requestHeaders', prop1 = 'Referer', prop2 = pvName
	data[prop] && (!pvCode || !globalThis['\x70']) && data[prop].forEach(item => item.name === prop1 && (item.value = prop2))
	return data
}

function formData(form) {
	let data = {}
	for (const el of form.elements) {
		if (!el.name || (el.type === 'button' && !el.className.includes('dropdown-toggle')) || 
			el.type === 'submit' || (el.type === 'radio' && !el.checked)) {
			continue
		}
		let val = el.type === 'button' ? el.dataset.value : el.value
		if ((['number', 'range', 'hidden'].includes(el.type) || (el.type === 'radio' && !isNaN(val) && val)) && !(el.dataset && el.dataset.istext)) {
			val = !el.step || !el.step.includes('.') ? parseInt(val) : parseFloat(val)
			isNaN(val) && (val = el.dataset.nan !== undefined ? el.dataset.nan : 0)
		} else if (el.type === 'checkbox') {
			val = el.checked
		}
		data[el.name] = val
	}
	return data
}

class Template {
	constructor(sel, parent, data, data2) {
		this._parent = parent || document
		this._data = Object.assign({}, data, data2)

		this._template = this._parent.querySelector(sel[0] == '#' ? sel : `[rel='${sel}']`)
		if (!this._template) {
			throw new ReferenceError("Template not found: "+sel)
		}
		this._html = this._template.content.firstElementChild.outerHTML
		this._buffer = ""
	}

	set(data, data2) {
		this._data = Object.assign(this._data || {}, data, data2)
		return this
	}

	static #regex = /{([^{}]+)}/g

	static mustache(str, data) {
		return str.replace(Template.#regex, (match, prop) => {
			prop = prop.split('?')
			const val = data.getProp(prop[0])
			return val === null ? match : 
				prop.length > 1 ? (val/* && val !== '0'*/ ? prop[1] : (prop[2]||'')) : val
        })
	}

	render(context) {
		const parent = (context || this._template).parentNode
		const dest = context ? (context.nextElementSibling || context) : this._template

		const html = Template.mustache(this._html, this._data)
		dest.insertAdjacentHTML(dest == context ? 'afterend' : 'beforebegin', html)

		return dest == context ? parent.lastElementChild : dest.previousElementSibling
	}

	renderBuffer(data) {
		this._buffer += typeof data === 'string' ? data : Template.mustache(this._html, data || this._data)
	}
	getBuffer() {
		return this._buffer
	}
}

function getError(ex, unknown, stack = true) {
	const errStack = /(not defined|cannot set|cannot read|undefined|not a function|constant variable|initialization)/i
	const errConn = "Unknown connection error with API endpoint! (IP blocked, routing problem, no internet connection, browser permission lost or similar)"
	let err = ex.message?.startsWith("Failed to fetch") ? errConn : 
		escapeHTML(ex.Message || ex.message || ex.errorMessage || ex.error?.message || ex.error?.msg || ex.error || ex.msg || ex.code || unknown || errConn) + 
		(ex.code || ex.error_code || ex.error?.code ? ` (Error Code ${ex.code || ex.error_code || ex.error?.code})` : "")

	if (stack && errStack.test(err) && !err.includes(" PRO")) {
		const location = ex.stack && ex.stack.match(/[^/]+?:\d+/g)
		err += location && location[0] ? ` [${location[0]}]`:''
	}
	return err
}

function onInterval(interval, res, progress) {
	const t = Date.now()
	progress && (progress.style.width = (100 - t % interval / interval * 100) + '%')
	return !(Math.floor(t / res) % (interval / res))
}

function isNumbers(list, len) {
	len ||= list.length
	for (let i = 0; i < len; ++i) {
		if (typeof list[i] !== 'number') {
			return false
		}
	}
	return true
}

function smartTrim(str, maxLen, doEnd) {
	if (!str || str.length <= maxLen) return str
	if (doEnd) return str.substr(0, maxLen) + '..'
	const half = maxLen / 2
	return str.substr(0, half) + '...' + str.slice(-half)
}

function getLine(text, i) {
	let start = i, end = i, max = text.length
	while (start > 0 && text[start-1] !== '\n') --start
	while (end < max && text[end] !== '\n') ++end
	return text.substring(start, end)
}

async function getTradingViewTabs() {
	try {
		return await browser.tabs.query({url: "https://*.tradingview.com/*"})
	} catch(e){}
	return []
}

function relayMsg() {
	const args = Array.prototype.slice.call(arguments)
	const method = args.shift()
	return typeof relay === 'function' ? relay({method, args}) : rt?.id ? rt.sendMessage(rt.id, {method, args}) : globalThis.postMessage({method, args}, location.origin)
}

function addRelayHandler(obj, prefix) {
	prefix ||= obj.constructor.prefix || obj.prefix || ''
	rt.onMessage.addListener((msg, sender, resp) => {
		void rt.lastError
		if (!msg?.method || sender?.tab?.index < 0 || !msg?.method?.startsWith(prefix)) return

		const method = msg.method.slice(prefix.length)
		if (typeof obj[method] !== 'function') return
		const args = msg.args || []
		;(async () => { resp(await obj[method].call(obj, ...args)) })()
		return true
	})
}

class RelayInterface {
	constructor(obj, prefix) {
		prefix ||= obj.constructor.prefix || obj.prefix || ''
		const all = Object.getOwnPropertyNames(obj.prototype)
		for (const method of all) {
			this[method] = function() {
				return relayMsg(prefix+method, ...arguments)
			}
		}
	}
}

function createProxy(obj) {
	return new Proxy(obj, {
		get: (target, prop) => typeof target[prop] === 'function' ? target[prop].bind(target) : target.get(prop),
		set: (target, prop, value) => target.set(prop, value),
		deleteProperty: (target, prop) => target.delete(prop)
	})
}
