-- Functions from The Forgotten Server
local foodCondition = Condition(CONDITION_REGENERATION, CONDITIONID_DEFAULT)

function Player.feed(self, food)
	local condition = self:getCondition(CONDITION_REGENERATION, CONDITIONID_DEFAULT)
	if condition then
		condition:setTicks(condition:getTicks() + (food * 1000))
	else
		local vocation = self:getVocation()
		if not vocation then
			return nil
		end

		foodCondition:setTicks(food * 1000)
		foodCondition:setParameter(CONDITION_PARAM_HEALTHGAIN, vocation:getHealthGainAmount())
		foodCondition:setParameter(CONDITION_PARAM_HEALTHTICKS, vocation:getHealthGainTicks())
		foodCondition:setParameter(CONDITION_PARAM_MANAGAIN, vocation:getManaGainAmount())
		foodCondition:setParameter(CONDITION_PARAM_MANATICKS, vocation:getManaGainTicks())

		self:addCondition(foodCondition)
	end
	return true
end

function Player.getClosestFreePosition(self, position, extended)
	if self:getGroup():getAccess() and self:getAccountType() == ACCOUNT_TYPE_GOD then
		return position
	end
	return Creature.getClosestFreePosition(self, position, extended)
end

function Player.getDepotItems(self, depotId)
	return self:getDepotChest(depotId, true):getItemHoldingCount()
end

function Player.hasFlag(self, flag)
	return self:getGroup():hasFlag(flag)
end

function Player.isPremium(self)
	return self:getPremiumDays() > 0 or configManager.getBoolean(configKeys.FREE_PREMIUM)
end

function Player.sendCancelMessage(self, message)
	if type(message) == "number" then
		message = Game.getReturnMessage(message)
	end
	return self:sendTextMessage(MESSAGE_FAILURE, message)
end

function Player.isUsingOtClient(self)
	return self:getClient().os >= CLIENTOS_OTCLIENT_LINUX
end

function Player.isUsingOTCR(self)
	return self:getClient().os >= CLIENTOS_OTCLIENT_LINUX and self:getClient().os < CLIENTOS_OTCLIENT_MAC
end

function Player.sendExtendedOpcode(self, opcode, buffer)
	if not self:isUsingOtClient() then
		return false
	end

	local networkMessage = NetworkMessage()
	networkMessage:addByte(0x32)
	networkMessage:addByte(opcode)
	networkMessage:addString(buffer, "Player.sendExtendedOpcode - buffer")
	networkMessage:sendToPlayer(self)
	networkMessage:delete()
	return true
end

APPLY_SKILL_MULTIPLIER = true
local addSkillTriesFunc = Player.addSkillTries
function Player.addSkillTries(...)
	local arg = { ... }
	local param4 = arg[4]
	local applySkill = not param4
	APPLY_SKILL_MULTIPLIER = applySkill
	local ret = addSkillTriesFunc(...)
	APPLY_SKILL_MULTIPLIER = true
	return ret
end

local addManaSpentFunc = Player.addManaSpent
function Player.addManaSpent(...)
	local arg = { ... }
	local param3 = arg[3]
	local applySkill = not param3
	APPLY_SKILL_MULTIPLIER = applySkill
	local ret = addManaSpentFunc(...)
	APPLY_SKILL_MULTIPLIER = true
	return ret
end

-- Functions From OTServBR-Global
function Player.depositMoney(self, amount)
	return Bank.deposit(self, amount)
end

function Player.transferMoneyTo(self, target, amount)
	if not target then
		return false
	end
	if not Bank.transfer(self, target, amount) then
		return false
	end

	local targetPlayer = Player(target)
	if targetPlayer then
		targetPlayer:sendTextMessage(MESSAGE_LOOK, self:getName() .. " has transferred " .. FormatNumber(amount) .. " gold coins to you.")
	end
	return true
end

function Player.withdrawMoney(self, amount)
	return Bank.withdraw(self, amount)
end

function Player.removeMoneyBank(self, amount)
	local inventoryMoney = self:getMoney()
	local bankBalance = self:getBankBalance()

	if amount <= inventoryMoney then
		self:removeMoney(amount)
		if amount > 0 then
			self:sendTextMessage(MESSAGE_TRADE, ("Paid %d gold from inventory."):format(amount))
		end
		return true
	end

	if amount <= (inventoryMoney + bankBalance) then
		local remainingAmount = amount

		if inventoryMoney > 0 then
			self:removeMoney(inventoryMoney)
			remainingAmount = remainingAmount - inventoryMoney
		end

		Bank.debit(self, remainingAmount)

		self:setBankBalance(bankBalance - remainingAmount)
		self:sendTextMessage(MESSAGE_TRADE, ("Paid %s from inventory and %s gold from bank account. Your account balance is now %s gold."):format(FormatNumber(amount - remainingAmount), FormatNumber(remainingAmount), FormatNumber(self:getBankBalance())))
		return true
	end
	return false
end

function Player.hasRookgaardShield(self)
	-- Wooden Shield, Studded Shield, Brass Shield, Plate Shield, Copper Shield
	return self:getItemCount(3412) > 0 or self:getItemCount(3426) > 0 or self:getItemCount(3411) > 0 or self:getItemCount(3410) > 0 or self:getItemCount(3430) > 0
end

function Player:vocationAbbrev()
	local vocation = self:getVocation()
	if not vocation then
		return "N"
	end

	local vocationName = vocation:getName():split(" ")
	local abbrev = ""
	for _, name in ipairs(vocationName) do
		abbrev = abbrev .. name:sub(1, 1)
	end
	return abbrev:upper()
end

function Player.isMonk(self)
	return table.contains({ VOCATION.ID.MONK, VOCATION.ID.EXALTED_MONK }, self:getVocation():getId())
end

function Player.isSorcerer(self)
	return table.contains({ VOCATION.ID.SORCERER, VOCATION.ID.MASTER_SORCERER }, self:getVocation():getId())
end

function Player.isDruid(self)
	return table.contains({ VOCATION.ID.DRUID, VOCATION.ID.ELDER_DRUID }, self:getVocation():getId())
end

function Player.isKnight(self)
	return table.contains({ VOCATION.ID.KNIGHT, VOCATION.ID.ELITE_KNIGHT }, self:getVocation():getId())
end

function Player.isPaladin(self)
	return table.contains({ VOCATION.ID.PALADIN, VOCATION.ID.ROYAL_PALADIN }, self:getVocation():getId())
end

function Player.isMage(self)
	return table.contains({ VOCATION.ID.SORCERER, VOCATION.ID.MASTER_SORCERER, VOCATION.ID.DRUID, VOCATION.ID.ELDER_DRUID }, self:getVocation():getId())
end

local ACCOUNT_STORAGES = {}
function Player.getAccountStorage(self, key, forceUpdate)
	local accountId = self:getAccountId()
	if ACCOUNT_STORAGES[accountId] and not forceUpdate then
		return ACCOUNT_STORAGES[accountId]
	end

	local query = db.storeQuery("SELECT `key`, MAX(`value`) as value FROM `player_storage` WHERE `player_id` IN (SELECT `id` FROM `players` WHERE `account_id` = " .. accountId .. ") AND `key` = " .. key .. " GROUP BY `key` LIMIT 1;")
	if query ~= false then
		local value = Result.getNumber(query, "value")
		ACCOUNT_STORAGES[accountId] = value
		Result.free(query)
		return value
	end
	return false
end

function Player:getUpdatedAccountStorage(bucket)
	local fromMemory = self:getStorageValue(bucket) > 0 and self:getStorageValue(bucket) or 0
	local fromDB = self:getAccountStorage(bucket, true) and self:getAccountStorage(bucket, true) or 0
	return bit.bor(fromDB, fromMemory)
end

function Player.getMarriageDescription(thing)
	local descr = ""
	if getPlayerMarriageStatus(thing:getGuid()) == MARRIED_STATUS then
		playerSpouse = getPlayerSpouse(thing:getGuid())
		if self == thing then
			descr = descr .. " You are "
		else
			descr = descr .. " " .. thing:getSubjectPronoun():titleCase() .. " " .. thing:getSubjectVerb() .. " "
		end
		descr = descr .. "married to " .. getPlayerNameById(playerSpouse) .. "."
	end
	return descr
end

function Player:getFamiliarName()
	local vocation = FAMILIAR_ID[self:getVocation():getBaseId()]
	local familiarName
	if vocation then
		familiarName = vocation.name
	end
	return familiarName
end

function Player:CreateFamiliarSpell(spellId)
	local playerPosition = self:getPosition()
	if not self:isPremium() then
		playerPosition:sendMagicEffect(CONST_ME_POFF)
		self:sendCancelMessage("You need a premium account.")
		return false
	end

	if #self:getSummons() >= 1 and self:getAccountType() < ACCOUNT_TYPE_GOD then
		self:sendCancelMessage("You can't have other summons.")
		playerPosition:sendMagicEffect(CONST_ME_POFF)
		return false
	end

	local familiarName = self:getFamiliarName()
	if not familiarName then
		self:sendCancelMessage(RETURNVALUE_NOTPOSSIBLE)
		playerPosition:sendMagicEffect(CONST_ME_POFF)
		return false
	end

	-- Divide by 2 to get half the time (the default total time is 30 / 2 = 15)
	local summonDuration = 60 * configManager.getNumber(configKeys.FAMILIAR_TIME) / 2
	local condition = Condition(CONDITION_SPELLCOOLDOWN, CONDITIONID_DEFAULT, spellId)
	local cooldown = summonDuration * 2
	if self:isVip() then
		local reduction = configManager.getNumber(configKeys.VIP_FAMILIAR_TIME_COOLDOWN_REDUCTION)
		reduction = (reduction > summonDuration and summonDuration) or reduction
		cooldown = cooldown - reduction * 60
	end

	local createdSuccessfully = self:createFamiliar(familiarName, summonDuration)
	if createdSuccessfully then
		condition:setTicks(1000 * cooldown / configManager.getFloat(configKeys.RATE_SPELL_COOLDOWN))
		self:addCondition(condition)
		return true
	end

	return false
end

function Player:createFamiliar(familiarName, timeLeft)
	local playerPosition = self:getPosition()
	if not familiarName then
		self:sendCancelMessage(RETURNVALUE_NOTPOSSIBLE)
		playerPosition:sendMagicEffect(CONST_ME_POFF)
		return false
	end

	local myFamiliar = Game.createMonster(familiarName, playerPosition, true, false, self)
	if not myFamiliar then
		self:sendCancelMessage(RETURNVALUE_NOTENOUGHROOM)
		playerPosition:sendMagicEffect(CONST_ME_POFF)
		return false
	end

	myFamiliar:setOutfit({ lookType = self:getFamiliarLooktype() })
	myFamiliar:registerEvent("FamiliarDeath")
	myFamiliar:changeSpeed(math.max(self:getSpeed() - myFamiliar:getBaseSpeed(), 0))
	playerPosition:sendMagicEffect(CONST_ME_MAGIC_BLUE)
	myFamiliar:getPosition():sendMagicEffect(CONST_ME_TELEPORT)
	-- Divide by 2 to get half the time (the default total time is 30 / 2 = 15)
	self:kv():set("familiar-summon-time", os.time() + timeLeft)
	addEvent(RemoveFamiliar, timeLeft * 1000, myFamiliar:getId(), self:getId())
	for sendMessage = 1, #FAMILIAR_TIMER do
		self:setStorageValue(
			FAMILIAR_TIMER[sendMessage].storage,
			addEvent(
				-- Calling function
				SendMessageFunction,
				-- Time for execute event
				(timeLeft - FAMILIAR_TIMER[sendMessage].countdown) * 1000,
				-- Param "playerId"
				self:getId(),
				-- Param "message"
				FAMILIAR_TIMER[sendMessage].message
			)
		)
	end
	return true
end

function Player:dispellFamiliar()
	local summons = self:getSummons()
	for i = 1, #summons do
		if summons[i]:getName():lower() == self:getFamiliarName():lower() then
			self:getPosition():sendMagicEffect(CONST_ME_MAGIC_BLUE)
			summons[i]:getPosition():sendMagicEffect(CONST_ME_POFF)
			summons[i]:remove()
			return true
		end
	end
	return false
end

function Player.getFinalBaseRateExperience(self)
	-- Experience Stage Multiplier
	local baseRate
	local rateExperience = configManager.getNumber(configKeys.RATE_EXPERIENCE)
	if configManager.getBoolean(configKeys.RATE_USE_STAGES) then
		baseRate = getRateFromTable(experienceStages, self:getLevel(), rateExperience)
	else
		baseRate = rateExperience
	end
	-- Event scheduler
	if SCHEDULE_EXP_RATE ~= 100 then
		baseRate = math.max(0, (baseRate * SCHEDULE_EXP_RATE) / 100)
	end
	return baseRate
end

function Player.getFinalBonusStamina(self)
	local staminaBonus = 1
	if configManager.getBoolean(configKeys.STAMINA_SYSTEM) then
		local staminaMinutes = self:getStamina()
		if staminaMinutes > 2340 and self:isPremium() then
			staminaBonus = 1.5
		elseif staminaMinutes <= 840 then
			staminaBonus = 0.5
		end
	end
	return staminaBonus
end

function Player.getFinalLowLevelBonus(self)
	local level = self:getLevel()
	if level > 0 and level <= 50 then
		self:setGrindingXpBoost(configManager.getNumber(configKeys.LOW_LEVEL_BONUS_EXP))
	else
		self:setGrindingXpBoost(0)
	end
	return self:getGrindingXpBoost()
end

function Player.getSubjectPronoun(self)
	return Pronouns.getPlayerSubjectPronoun(self:getPronoun(), self:getSex(), self:getName())
end

function Player.getObjectPronoun(self)
	return Pronouns.getPlayerObjectPronoun(self:getPronoun(), self:getSex(), self:getName())
end

function Player.getPossessivePronoun(self)
	return Pronouns.getPlayerPossessivePronoun(self:getPronoun(), self:getSex(), self:getName())
end

function Player.getSubjectVerb(self, past)
	return Pronouns.getPlayerSubjectVerb(self:getPronoun(), past)
end

function Player.updateHazard(self)
	local zones = self:getZones()
	if not zones or #zones == 0 then
		self:setHazardSystemPoints(0)
		return true
	end

	self:setHazardSystemPoints(0)
	for _, zone in pairs(zones) do
		local hazard = Hazard.getByName(zone:getName())
		if hazard then
			if self:getParty() then
				self:getParty():refreshHazard()
			else
				self:setHazardSystemPoints(hazard:getPlayerCurrentLevel(self))
			end
			return true
		end
	end
	return true
end

function Player:addItemStoreInboxEx(item, movable, setOwner)
	local inbox = self:getStoreInbox()
	if not movable then
		item:setOwner(self)
		item:setAttribute(ITEM_ATTRIBUTE_STORE, systemTime())
	elseif setOwner then
		item:setOwner(self)
	end
	inbox:addItemEx(item, INDEX_WHEREEVER, FLAG_NOLIMIT)
	return item
end

function Player:addItemStoreInbox(itemId, amount, movable, setOwner)
	if not amount then
		logger.error("[Player:addItemStoreInbox] item '{}' amount is nil.", itemId)
		self:sendTextMessage(MESSAGE_EVENT_ADVANCE, "Item amount is wrong, please contact an administrator.")
		return nil
	end

	local iType = ItemType(itemId)
	if not iType then
		return nil
	end

	local inbox = self:getStoreInbox()
	if not inbox then
		return nil
	end

	local batchUpdate = BatchUpdate(self)
	batchUpdate:add(inbox)

	if iType:isStackable() then
		local stackSize = iType:getStackSize()
		while amount > stackSize do
			local stackItem = Game.createItem(itemId, stackSize)
			if not stackItem then
				batchUpdate:delete()
				return nil
			end

			self:addItemStoreInboxEx(stackItem, movable, setOwner)
			amount = amount - stackSize
		end
	end

	local item
	if iType:getCharges() > 0 then
		item = Game.createItem(itemId, 1)
		if item then
			item:setAttribute(ITEM_ATTRIBUTE_CHARGES, amount)
		end
	else
		item = Game.createItem(itemId, amount)
	end

	if not item then
		batchUpdate:delete()
		return nil
	end

	local inboxItem = self:addItemStoreInboxEx(item, movable, setOwner)
	batchUpdate:delete()
	return inboxItem
end

---@param monster Monster
---@return {factor: number, msgSuffix: string}
function Player:calculateLootFactor(monster)
	if not self:canReceiveLoot() then
		return {
			factor = 0.0,
			msgSuffix = "due to low stamina",
		}
	end

	local participants = { self }
	local factor = 1
	if configManager.getBoolean(configKeys.PARTY_SHARE_LOOT_BOOSTS) then
		local party = self:getParty()
		if party and party:isSharedExperienceEnabled() then
			participants = party:getMembers()
			table.insert(participants, party:getLeader())
		end
	end

	local vipActivators = 0
	local vipBoost = 0
	local suffix = ""

	for _, participant in ipairs(participants) do
		if participant:isVip() then
			local boost = configManager.getNumber(configKeys.VIP_BONUS_LOOT)
			boost = ((boost > 100 and 100) or boost) / 100
			vipBoost = vipBoost + boost
			vipActivators = vipActivators + 1
		end
	end
	if vipActivators > 0 then
		vipBoost = vipBoost / (vipActivators ^ configManager.getFloat(configKeys.PARTY_SHARE_LOOT_BOOSTS_DIMINISHING_FACTOR))
		factor = factor * (1 + vipBoost)
	end
	if vipBoost > 0 then
		suffix = string.format("vip bonus %d%%", math.floor(vipBoost * 100 + 0.5))
	end

	return {
		factor = factor,
		msgSuffix = suffix,
	}
end

function Player:setExhaustion(scope, seconds)
	return self:kv():scoped("exhaustion"):set(scope, os.time() + seconds)
end

function Player:getExhaustion(scope)
	local exhaustionKV = self:kv():scoped("exhaustion"):get(scope) or 0
	return math.max(exhaustionKV - os.time(), 0)
end

function Player:hasExhaustion(scope)
	return self:getExhaustion(scope) > 0 and true or false
end

function Player:setFiendish()
	local position = self:getPosition()
	position:getNextPosition(self:getDirection())

	local tile = Tile(position)
	local thing = tile:getTopVisibleThing(self)
	if not tile or thing and not thing:isMonster() then
		self:sendCancelMessage("Monster not found.")
		return false
	end

	local monster = thing:getMonster()
	if monster then
		monster:setFiendish(position, self)
	end
	return false
end

function Player:showInfoModal(title, message, buttonText)
	local modal = ModalWindow({
		title = title,
		message = message,
	})
	buttonText = buttonText or "Close"
	modal:addButton(buttonText, function() end)
	modal:setDefaultEscapeButton(buttonText)

	modal:sendToPlayer(self)
end

function Player:showConfirmationModal(title, message, yesCallback, noCallback, yesText, noText)
	local modal = ModalWindow({
		title = title,
		message = message,
	})
	yesText = yesText or "Yes"
	modal:addButton(yesText, yesCallback or function() end)
	noText = noText or "No"
	modal:addButton(noText, noCallback or function() end)
	modal:setDefaultEscapeButton(noText)

	modal:sendToPlayer(self)
end

function Player:removeAll(itemId)
	local count = 0
	while self:removeItem(itemId, 1) do
		count = count + 1
	end
	return count
end

local function bossKVScope(bossNameOrId)
	local mType = MonsterType(bossNameOrId)
	if not mType then
		logger.error("bossKVScope - Invalid boss name or id: " .. bossNameOrId)
		return false
	end
	return "boss.cooldown." .. toKey(tostring(mType:raceId()))
end

function Player:getBossCooldown(bossNameOrId)
	local scope = bossKVScope(bossNameOrId)
	if not scope then
		return false
	end
	return self:kv():get(scope) or 0
end

function Player:setBossCooldown(bossNameOrId, time)
	local scope = bossKVScope(bossNameOrId)
	if not scope then
		return false
	end
	local result = self:kv():set(scope, time)
	self:sendBosstiaryCooldownTimer()
	return result
end

function Player:canFightBoss(bossNameOrId)
	local cooldown = self:getBossCooldown(bossNameOrId)
	return cooldown <= os.time()
end

function Player.getCollectionTokens(self)
	return math.max(self:getStorageValue(DailyReward.storages.collectionTokens), 0)
end

function Player.getJokerTokens(self)
	return math.max(self:getStorageValue(DailyReward.storages.jokerTokens), 0)
end

function Player.setJokerTokens(self, value)
	self:setStorageValue(DailyReward.storages.jokerTokens, value)
end

function Player.setCollectionTokens(self, value)
	self:setStorageValue(DailyReward.storages.collectionTokens, value)
end

function Player.getDayStreak(self)
	return math.max(self:getStorageValue(DailyReward.storages.currentDayStreak), 0)
end

function Player.setDayStreak(self, value)
	self:setStorageValue(DailyReward.storages.currentDayStreak, value)
end

function Player.getStreakLevel(self)
	return self:kv():scoped("daily-reward"):get("streak") or 7
end

function Player.setStreakLevel(self, value)
	self:kv():scoped("daily-reward"):set("streak", value)
end

function Player.setNextRewardTime(self, value)
	self:setStorageValue(DailyReward.storages.nextRewardTime, value)
end

function Player.getNextRewardTime(self)
	return math.max(self:getStorageValue(DailyReward.storages.nextRewardTime), 0)
end

function Player.getActiveDailyRewardBonusesName(self)
	local msg = ""
	local streakLevel = self:getStreakLevel()
	if streakLevel >= 2 then
		if streakLevel > 7 then
			streakLevel = 7
		end
		for i = DAILY_REWARD_FIRST, streakLevel do
			if i ~= streakLevel then
				msg = msg .. "" .. DailyReward.strikeBonuses[i].text .. ", "
			else
				msg = msg .. "" .. DailyReward.strikeBonuses[i].text .. "."
			end
		end
	end
	return msg
end

function Player.getDailyRewardBonusesCount(self)
	local count = 1
	local streakLevel = self:getStreakLevel()
	if streakLevel > 2 then
		if streakLevel > 7 then
			streakLevel = 7
		end
		for i = DAILY_REWARD_FIRST, streakLevel do
			count = count + 1
		end
	else
		count = 0
	end
	return count
end

function Player.isBonusActiveById(self, bonusId)
	local streakLevel = self:getStreakLevel()
	local bonus = "locked"
	if streakLevel > 2 then
		if streakLevel > 7 then
			streakLevel = 7
		end
		if streakLevel >= bonusId then
			bonus = "unlocked"
		end
	end
	return bonus
end

function Player.loadDailyRewardBonuses(self)
	local streakLevel = self:getStreakLevel()
	-- Stamina regeneration
	if streakLevel >= DAILY_REWARD_STAMINA_REGENERATION then
		local staminaEvent = DailyRewardBonus.Stamina[self:getId()]
		if not staminaEvent then
			local delay = 3
			if self:getStamina() > 2340 and self:getStamina() <= 2520 then
				delay = 6
			end
			DailyRewardBonus.Stamina[self:getId()] = addEvent(RegenStamina, delay * 60 * 1000, self:getId(), delay * 60 * 1000)
		end
	end
	-- Soul regeneration
	if streakLevel >= DAILY_REWARD_SOUL_REGENERATION then
		local soulEvent = DailyRewardBonus.Soul[self:getId()]
		if not soulEvent then
			local delay = self:getVocation():getSoulGainTicks()
			DailyRewardBonus.Soul[self:getId()] = addEvent(RegenSoul, delay, self:getId(), delay)
		end
	end
	logger.debug("Player: {}, streak level: {}, active bonuses: {}", self:getName(), streakLevel, self:getActiveDailyRewardBonusesName())
end

function Player.getRewardChest(self, autocreate)
	return self:getDepotChest(99, autocreate)
end

function Player.inBossFight(self)
	if not next(_G.GlobalBosses) then
		return false
	end

	local playerGuid = self:getGuid()
	for _, info in pairs(_G.GlobalBosses) do
		local stats = info[playerGuid]
		if stats and stats.active then
			return stats
		end
	end
	return false
end

do
	local loyaltySystem = {
		enable = configManager.getBoolean(configKeys.LOYALTY_ENABLED),
		titles = {
			[1] = { name = "Scout of Tibia", points = 50 },
			[2] = { name = "Sentinel of Tibia", points = 100 },
			[3] = { name = "Steward of Tibia", points = 200 },
			[4] = { name = "Warden of Tibia", points = 400 },
			[5] = { name = "Squire of Tibia", points = 1000 },
			[6] = { name = "Warrior of Tibia", points = 2000 },
			[7] = { name = "Keeper of Tibia", points = 3000 },
			[8] = { name = "Guardian of Tibia", points = 4000 },
			[9] = { name = "Sage of Tibia", points = 5000 },
			[10] = { name = "Savant of Tibia", points = 6000 },
			[11] = { name = "Enlightened of Tibia", points = 7000 },
		},
		bonus = {
			{ minPoints = 360, percentage = 5 },
			{ minPoints = 720, percentage = 10 },
			{ minPoints = 1080, percentage = 15 },
			{ minPoints = 1440, percentage = 20 },
			{ minPoints = 1800, percentage = 25 },
			{ minPoints = 2160, percentage = 30 },
			{ minPoints = 2520, percentage = 35 },
			{ minPoints = 2880, percentage = 40 },
			{ minPoints = 3240, percentage = 45 },
			{ minPoints = 3600, percentage = 50 },
		},
		messageTemplate = "Due to your long-term loyalty to " .. SERVER_NAME .. " you currently benefit from a ${bonusPercentage}% bonus on all of your skills. (You have ${loyaltyPoints} loyalty points)",
	}

	function Player.initializeLoyaltySystem(self)
		if not loyaltySystem.enable then
			return true
		end

		local playerLoyaltyPoints = self:getLoyaltyPoints()

		-- Title
		local title = ""
		for _, titleTable in ipairs(loyaltySystem.titles) do
			if playerLoyaltyPoints >= titleTable.points then
				title = titleTable.name
			end
		end

		if title ~= "" then
			self:setLoyaltyTitle(title)
		end

		-- Bonus
		local playerBonusPercentage = 0
		for _, bonusTable in ipairs(loyaltySystem.bonus) do
			if playerLoyaltyPoints >= bonusTable.minPoints then
				playerBonusPercentage = bonusTable.percentage
			end
		end

		playerBonusPercentage = playerBonusPercentage * configManager.getFloat(configKeys.LOYALTY_BONUS_PERCENTAGE_MULTIPLIER)
		self:setLoyaltyBonus(playerBonusPercentage)

		if self:getLoyaltyBonus() ~= 0 then
			self:sendTextMessage(MESSAGE_STATUS, string.formatNamed(loyaltySystem.messageTemplate, { bonusPercentage = playerBonusPercentage, loyaltyPoints = playerLoyaltyPoints }))
		end

		return true
	end
end

function Player:questKV(questName)
	return self:kv():scoped("quests"):scoped(questName)
end

function Player:canGetReward(rewardId, questName)
	if self:questKV(questName):get("completed") then
		self:sendTextMessage(MESSAGE_EVENT_ADVANCE, "It is empty.")
		return false
	end

	local rewardItem = ItemType(rewardId)
	if not rewardItem then
		self:sendTextMessage(MESSAGE_EVENT_ADVANCE, "Reward item is wrong, please contact an administrator.")
		return false
	end

	local itemWeight = rewardItem:getWeight() / 100
	local baseMessage = "You have found a " .. rewardItem:getName()
	local backpack = self:getSlotItem(CONST_SLOT_BACKPACK)
	if not backpack or backpack:getEmptySlots(true) < 1 then
		baseMessage = baseMessage .. ", but you have no room to take it."
		self:sendTextMessage(MESSAGE_EVENT_ADVANCE, baseMessage)
		return false
	end

	if (self:getFreeCapacity() / 100) < itemWeight then
		baseMessage = baseMessage .. ". Weighing " .. itemWeight .. " oz, it is too heavy for you to carry."
		self:sendTextMessage(MESSAGE_EVENT_ADVANCE, baseMessage)
		return false
	end

	return true
end

function Player.getURL(self)
	local playerName = self:getName():gsub("%s+", "+")
	local serverURL = configManager.getString(configKeys.URL)

	return serverURL .. "/characters/" .. playerName
end

local emojiMap = {
	["knight"] = ":crossed_swords:",
	["paladin"] = ":bow_and_arrow:",
	["druid"] = ":herb:",
	["sorcerer"] = ":crystal_ball:",
}

function Player.getMarkdownLink(self)
	local vocation = self:vocationAbbrev()
	local emoji = emojiMap[self:getVocation():getName():lower()] or ":school_satchel:"
	local playerURL = self:getURL()

	return string.format("**[%s](%s)** %s [_%s_]", self:getName(), playerURL, emoji, vocation)
end

function Player.findItemInInbox(self, itemId, name)
	local inbox = self:getStoreInbox()
	local items = inbox:getItems()

	for _, item in pairs(items) do
		if item:getId() == itemId and (not name or item:getName() == name) then
			return item
		end
	end
	return nil
end
