local S = core.get_translator(core.get_current_modname())

local level_key = "yams_rpg:level"
local cur_exp_key = "yams_rpg:current_exp"
local stat_points_key = "yams_rpg:stat_points"

local str_key = "yams_rpg:strength"
local dex_key = "yams_rpg:dexterity"
local int_key = "yams_rpg:intellect"
local con_key = "yams_rpg:constitution"

local stat_cap = 100
local level_cap = stat_cap * 4

local exp_table = {}

-- Basically: 1000, 3000, 6000, 10000, and so on
for i = 0, level_cap - 1 do
    local n = i + 1
    exp_table[i] = ((n^2 + n) / 2) * 1000
end

exp_table[level_cap] = 99999999

-- The amount of total exp required to be a certain level
local total_exp_table = {}
local cur_total = 0

for i = 0, level_cap - 1 do
    total_exp_table[i] = cur_total
    cur_total = cur_total + exp_table[i]
end

total_exp_table[level_cap] = cur_total

local function calc_total_exp(player)
    local meta = player:get_meta()
    local level = meta:get_int(level_key)
    local exp = meta:get_int(cur_exp_key)

    return total_exp_table[level] + exp
end

local damage_bonus = {
    [0] = 0.0,
    [1] = 10.0,
    [2] = 20.0,
    [3] = 30.0,
    [4] = 40.0,
    [5] = 50.0,
    [6] = 57.5,
    [7] = 65.0,
    [8] = 72.5,
    [9] = 80.0,
    [10] = 87.5,
    [11] = 95.0,
    [12] = 100.0,
    [13] = 105.0,
    [14] = 110.0,
    [15] = 115.0,
    [16] = 120.0,
    [17] = 125.0,
    [18] = 130.0,
    [19] = 135.0,
    [20] = 140.0,
    -- then +4.0% until 70, and then +2.0% until 100 (maximum should be 400%)
}

local damage_reduction = {
    [0] = 0.0,
    [1] = 5.0,
    [2] = 9.0,
    [3] = 12.0,
    [4] = 14.0,
    [5] = 16.0,
    [6] = 18.0,
    [7] = 20.0,
    [8] = 22.0,
    [9] = 23.5,
    [10] = 25.0,
    [11] = 26.5,
    [12] = 28.0,
    [13] = 29.5,
    [14] = 31.0,
    [15] = 32.5,
    [16] = 34.0,
    [17] = 35.5,
    [18] = 37.0,
    [19] = 38.5,
    [20] = 40.0,
    [21] = 41.0,
    [22] = 42.0,
    [23] = 43.0,
    [24] = 44.0,
    [25] = 45.0
    -- then +0.5% until 75, and then +0.4% until 100 (maximum should be 80%)
}

local block_chance = {
    [0] = 0.0,
    [1] = 1.0,
    [2] = 1.5,
    [3] = 2.0,
    [4] = 2.5,
    [5] = 3.0,
    [6] = 3.5,
    [7] = 4.0,
    [8] = 4.5,
    [9] = 5.0,
    [10] = 5.4,
    [11] = 5.8,
    [12] = 6.2,
    [13] = 6.6,
    [14] = 7.0,
    [15] = 7.2,
    [16] = 7.4,
    [17] = 7.6,
    [18] = 7.8,
    [19] = 7.9,
    -- then +0.1% until 100 (maximum should be 16%)
}

-- Returns a percentage; divide by 100 to use in gameplay
local function calc_damage_bonus(value)
    if value <= 20 then
        return damage_bonus[value]
    elseif value <= 70 then
        return damage_bonus[20] + (value - 20) * 4.0
    else
        return damage_bonus[20] + 50 * 4.0 + (value - 70) * 2.0
    end
end

local function calc_hp_contribution(value, is_con)
    if value <= 20 then
        if is_con then
            return value * 2
        else
            return value
        end
    elseif is_con then
        return 20 * 2 + (value - 20)
    else
        return 20 + math.floor((value - 20) / 2)
    end
end

local function calc_max_hp(player)
    local meta = player:get_meta()
    local max_hp = 20  -- Base max HP

    -- Strength, dexterity, and intelligence contribute 1 HP for the first
    -- 20 stat points and then 1 HP every two stat points afterwards
    max_hp = max_hp + calc_hp_contribution(meta:get_int(str_key), false)
    max_hp = max_hp + calc_hp_contribution(meta:get_int(dex_key), false)
    max_hp = max_hp + calc_hp_contribution(meta:get_int(int_key), false)

    -- Constitution contributes 2 HP for the first 20 stat points and then
    -- 1 HP every stat point afterwards
    max_hp = max_hp + calc_hp_contribution(meta:get_int(con_key), true)
    return max_hp
end

-- Certain status effects modify max HP, so this function gets the max HP of
-- the player before any status effects are applied
yams.get_base_max_hp = calc_max_hp

local function update_max_hp(player)
    player:set_properties({hp_max = calc_max_hp(player)})
end

local function calc_max_stamina(str)
    local base = 20

    -- Strength gives 1 max stamina per point until 25 strength, then
    -- 1 max stamina per 2 points until 75 strength, and finally 1 max
    -- stamina per 5 points until the stat cap, resulting in a max
    -- stamina cap of 75
    if str <= 25 then
        return base + str
    elseif str <= 75 then
        return base + 25 + math.floor((str - 25) / 2)
    else
        return base + 50 + math.floor((str - 75) / 5)
    end
end

local function update_max_stamina(player)
    local name = player:get_player_name()
    local str = player:get_meta():get_int(str_key)
    sprint_lite.set_max_stamina(name, calc_max_stamina(str))
end

local function calc_move_speed(dex)
    local base = 1.0

    -- Dexterity gives +2% move speed per point until 5 dexterity, then
    -- +1% move speed per point until 25 dexterity, then +0.5% move speed
    -- per point until 75 dexterity, and finally +0.2% move speed per point
    -- until the stat cap, resulting in a maximum move speed bonus of +60%
    if dex <= 5 then
        return base + 0.02 * dex
    elseif dex <= 25 then
        return base + 0.1 + (dex - 5) * 0.01
    elseif dex <= 75 then
        return base + 0.3 + (dex - 25) * 0.005
    else
        return base + 0.55 + (dex - 75) * 0.002
    end
end

local function update_move_speed(player)
    local dex = player:get_meta():get_int(dex_key)
    local val = calc_move_speed(dex)
    player_monoids.speed:add_change(player, val, "yams_rpg:dex")
end

local function calc_max_mana(int)
    local base = 100

    -- Intelligence gives 20 max mana per point until 5 intelligence,
    -- then 10 max mana per point until 25 intelligence, then 5 max
    -- mana per point until 90 intelligence, and then 25 max mana over
    -- the remaining 10 stat points, resulting in a max mana cap of 750
    if int <= 5 then
        return base + int * 20
    elseif int <= 25 then
        return base + 100 + (int - 5) * 10
    elseif int <= 90 then
        return base + 300 + (int - 25) * 5
    else
        return base + 625 + math.floor((25 / 10) * (int - 90))
    end
end

local function update_max_mana(player)
    local name = player:get_player_name()
    local int = player:get_meta():get_int(int_key)
    local new_max = calc_max_mana(int)
    local regen_mod = yams.mana_regen_monoid:value(player)
    local new_regen = (new_max / 200) * regen_mod

    mana.setmax(name, new_max)
    mana.setregen(name, new_regen)
end

-- Returns a percentage; divide by 100 to use in gameplay
local function calc_damage_reduction(con)
    if con <= 25 then
        return damage_reduction[con]
    elseif con <= 75 then
        return damage_reduction[25] + (con - 25) * 0.5
    else
        return damage_reduction[25] + 50 * 0.5 + (con - 75) * 0.4
    end
end

-- Returns a percentage; do not divide by 100 because 3d_armor's 'heal' value
-- is between 0 and 100 anyway
local function calc_block_chance_bonus(con)
    if con <= 19 then
        return block_chance[con]
    else
        return block_chance[19] + (con - 19) * 0.1
    end
end

local function make_formspec(self, player, context)
    -- TODO: don't show increases if a stat is at its cap (low priority)
    local meta = player:get_meta()

    local level = meta:get_int(level_key)
    local cur_exp = meta:get_int(cur_exp_key)
    local to_next = exp_table[level]
    local total_exp = calc_total_exp(player)
    local stat_points = meta:get_int(stat_points_key)

    local str = meta:get_int(str_key)
    local dex = meta:get_int(dex_key)
    local int = meta:get_int(int_key)
    local con = meta:get_int(con_key)

    local exp_text = S("Level @1 | Exp: @2/@3 (Total: @4)",
                       level, cur_exp, to_next, total_exp)
    local stat_points_text = S("Stat Points Remaining: @1", stat_points)

    local str_text = S("Strength: @1", str)
    local melee_val = calc_damage_bonus(str)
    local next_melee_val = calc_damage_bonus(str + 1)
    local melee_text = S("Weapon Damage: +@1% -> +@2%", melee_val, next_melee_val)
    local stam_val = calc_max_stamina(str)
    local next_stam_val = calc_max_stamina(str + 1)
    local stam_text = S("Max Stamina: @1 -> @2", stam_val, next_stam_val)
    local str_hp_val = calc_hp_contribution(str, false)
    local next_str_hp_val = calc_hp_contribution(str + 1, false)
    local str_hp_text = S("Max Health: +@1 -> +@2", str_hp_val, next_str_hp_val)

    local dex_text = S("Dexterity: @1", dex)
    local range_val = calc_damage_bonus(dex)
    local next_range_val = calc_damage_bonus(dex + 1)
    local range_text = S("Weapon Damage: +@1% -> +@2%", range_val, next_range_val)
    local speed_val = (calc_move_speed(dex) - 1.0) * 100
    local next_speed_val = (calc_move_speed(dex + 1) - 1.0) * 100
    local speed_text = S("Move Speed: +@1% -> +@2%", speed_val, next_speed_val)
    local dex_hp_val = calc_hp_contribution(dex, false)
    local next_dex_hp_val = calc_hp_contribution(dex + 1, false)
    local dex_hp_text = S("Max Health: +@1 -> +@2", dex_hp_val, next_dex_hp_val)

    local int_text = S("Intelligence: @1", int)
    local magic_val = calc_damage_bonus(int)
    local next_magic_val = calc_damage_bonus(int + 1)
    local magic_text = S("Weapon Damage: +@1% -> +@2%", magic_val, next_magic_val)
    local mana_val = calc_max_mana(int)
    local next_mana_val = calc_max_mana(int + 1)
    local mana_text = S("Max Mana: @1 -> @2", mana_val, next_mana_val)
    local int_hp_val = calc_hp_contribution(int, false)
    local next_int_hp_val = calc_hp_contribution(int + 1, false)
    local int_hp_text = S("Max Health: +@1 -> +@2", int_hp_val, next_int_hp_val)

    local con_text = S("Constitution: @1", con)
    local dmg_taken_val = calc_damage_reduction(con)
    local next_dmg_taken_val = calc_damage_reduction(con + 1)
    local dmg_taken_text = S("Combat Damage Taken: -@1% -> -@2%",
                             dmg_taken_val, next_dmg_taken_val)
    local block_val = calc_block_chance_bonus(con)
    local next_block_val = calc_block_chance_bonus(con + 1)
    local block_text = S("Block Chance: +@1% -> +@2%",
                         block_val, next_block_val)
    local con_hp_val = calc_hp_contribution(con, true)
    local next_con_hp_val = calc_hp_contribution(con + 1, true)
    local con_hp_text = S("Max Health: +@1 -> +@2", con_hp_val, next_con_hp_val)

    local dmg_explain = S("Weapon damage bonuses apply to weapons with a" ..
        "\n" .. "matching primary or secondary stat.")

    local formspec = {
        "label[0.0,0.0;" .. exp_text .. "]",
        "label[0.0,0.5;" .. stat_points_text .. "]",

        "label[0.0,1.0;" .. str_text .. "]",
        "label[2.25,1.0;" .. melee_text .. "\n",
        stam_text.. "\n", str_hp_text .. "]",
        "button[0.0,1.5;2.0,1.0;str_up_button;" .. S("Upgrade") .."]",
        "tooltip[str_up_button;" .. S("Increase Strength by 1") .. "]",

        "label[0.0,2.5;" .. dex_text .. "]",
        "label[2.25,2.5;" .. range_text .. "\n",
        speed_text .. "\n", dex_hp_text .. "]",
        "button[0.0,3.0;2.0,1.0;dex_up_button;" .. S("Upgrade") .."]",
        "tooltip[dex_up_button;" .. S("Increase Dexterity by 1") .. "]",

        "label[0.0,4.0;" .. int_text .. "]",
        "label[2.25,4.0;" .. magic_text .. "\n",
        mana_text .. "\n", int_hp_text .. "]",
        "button[0.0,4.5;2.0,1.0;int_up_button;" .. S("Upgrade") .."]",
        "tooltip[int_up_button;" .. S("Increase Intellect by 1") .. "]",

        "label[0.0,5.5;" .. con_text .. "]",
        "label[2.25,5.5;" .. dmg_taken_text .. "\n",
        block_text .. "\n", con_hp_text .. "]",
        "button[0.0,6.0;2.0,1.0;con_up_button;" .. S("Upgrade") .."]",
        "tooltip[con_up_button;" .. S("Increase Constitution by 1") .. "]",

        "label[0.0,7.0;" .. dmg_explain .. "]",
    }
    return sfinv.make_formspec(player, context, table.concat(formspec, ""))
end

local function handle_fields(self, player, context, fields)
    local meta = player:get_meta()
    local stat_points = meta:get_int(stat_points_key)

    if stat_points < 1 then
        return
    end

    local changed = false

    if fields.str_up_button then
        local str = meta:get_int(str_key)
        if str < stat_cap then
            meta:set_int(stat_points_key, stat_points - 1)
            meta:set_int(str_key, str + 1)
            core.log("action", string.format("%s increased strength to %d",
                         player:get_player_name(), str + 1))
            changed = true
        end
    elseif fields.dex_up_button then
        local dex = meta:get_int(dex_key)
        if dex < stat_cap then
            meta:set_int(stat_points_key, stat_points - 1)
            meta:set_int(dex_key, dex + 1)
            core.log("action", string.format("%s increased dexterity to %d",
                         player:get_player_name(), dex + 1))
            changed = true
        end
    elseif fields.int_up_button then
        local int = meta:get_int(int_key)
        if int < stat_cap then
            meta:set_int(stat_points_key, stat_points - 1)
            meta:set_int(int_key, int + 1)
            core.log("action", string.format("%s increased intelligence to %d",
                         player:get_player_name(), int + 1))
            changed = true
        end
    elseif fields.con_up_button then
        local con = meta:get_int(con_key)
        if con < stat_cap then
            meta:set_int(stat_points_key, stat_points - 1)
            meta:set_int(con_key, con + 1)
            core.log("action", string.format("%s increased constitution to %d",
                         player:get_player_name(), con + 1))
            changed = true
        end
    end

    if changed then
        local old_max = player:get_properties().hp_max
        local new_max = calc_max_hp(player)

        update_max_hp(player)

        -- Add to the player's current HP if the max HP increased
        if new_max > old_max then
            player:set_hp(player:get_hp() + (new_max - old_max), "level_up")
        end

        -- No point adding to these two resources since they regenerate
        -- fast enough
        update_max_stamina(player)
        update_move_speed(player)
        update_max_mana(player)

        sfinv.set_player_inventory_formspec(player, context)
    end
end

sfinv.register_page("yams_rpg:level_up", {
    title = "Level Up",
    get = make_formspec,
    on_player_receive_fields = handle_fields
})

local huds = {}

local function update_hud_text(player)
    local meta = player:get_meta()
    local level = meta:get_int(level_key)
    local exp = meta:get_int(cur_exp_key)
    local to_next = exp_table[level]
    local exp_pct = string.format("%.2f", (exp / to_next) * 100)

    local hud_id = huds[player:get_player_name()]
    local hud_text = S("Level @1 | Exp: @2%", level, exp_pct)

    player:hud_change(hud_id, "text", hud_text)
end

local function init_player_stats(player)
    local meta = player:get_meta()

    meta:set_int(level_key, 0)
    meta:set_int(cur_exp_key, 0)
    meta:set_int(stat_points_key, 0)

    meta:set_int(str_key, 0)
    meta:set_int(dex_key, 0)
    meta:set_int(int_key, 0)
    meta:set_int(con_key, 0)

    player:set_properties({hp_max = 20})
    update_max_stamina(player)
    update_move_speed(player)
    update_max_mana(player)
end

local function on_join(player, last_login)
    local meta = player:get_meta()

    if last_login == nil then
        -- I'm aware that get_int returns zero if the key doesn't exist, but
        -- I'd rather be explicit about it
        init_player_stats(player)
    end

    update_max_hp(player)
    update_max_stamina(player)
    update_move_speed(player)
    update_max_mana(player)

    huds[player:get_player_name()] = player:hud_add({
        type = "text",
        position = {x = 0.02, y = 0.95},
        scale = {x = 100, y = 100},
        alignment = {x = 1, y = 0},
        size = {x = 2},
        number = 0xFFFFFF,
        text = ""
    })
    update_hud_text(player)
end

core.register_on_joinplayer(on_join)

local function on_leave(player, timed_out)
    huds[player:get_player_name()] = nil
end

core.register_on_leaveplayer(on_leave)

yams.on_levelup_callbacks = {}

-- Callback signature: function(player, new_level)
-- If a player gains multiple levels at once, then the callbacks are called
-- for each level gained
-- Callbacks are called after the player levels up
-- The return value does not matter
function yams.register_on_levelup(func)
    table.insert(yams.on_levelup_callbacks, func)
end

local global_exp_mod = tonumber(core.settings:get("yams_exp_multiplier")) or 1.0
if global_exp_mod < 0.1 then
    global_exp_mod = 0.1
elseif global_exp_mod > 100.0 then
    global_exp_mod = 100.0
end

function yams.add_exp(player, exp, reason)
    if not yams.is_player(player) then
        return
    end

    if reason ~= "exp orb" then
        exp = math.ceil(exp * yams.exp_gain_monoid:value(player))
    end

    exp = exp * global_exp_mod

    local meta = player:get_meta()
    local new_exp = meta:get_int(cur_exp_key) + exp
    meta:set_int(cur_exp_key, new_exp)

    local name = player:get_player_name()
    core.log("action", string.format("%s gained %d exp from %s",
                                         name, exp, reason))

    local level = meta:get_int(level_key)
    if level == level_cap then  -- level cap
        return
    end

    local to_next = exp_table[level]

    while new_exp >= to_next and level < level_cap do
        level = level + 1
        meta:set_int(level_key, level)

        local stat_points = meta:get_int(stat_points_key)
        meta:set_int(stat_points_key, stat_points + 1)

        new_exp = new_exp - to_next
        to_next = exp_table[level]
        meta:set_int(cur_exp_key, new_exp)

        local name = player:get_player_name()
        local msg = S("[yams_rpg] You are now level @1! Stat point gained!", level)
        core.chat_send_player(name, msg)
        core.log("action", string.format("%s is now level %d", name, level))

        for _, cb in pairs(yams.on_levelup_callbacks) do
            cb(player, level)
        end
    end

    update_hud_text(player)
end

function yams.get_block_chance_modifier(player)
    assert(yams.is_player(player), "player arg is not a player")

    local meta = player:get_meta()
    local con = meta:get_int(con_key)
    return calc_block_chance_bonus(con)
end

function yams.calc_element_modifier(attack_elements, defender_resists)
    local res = 100
    for name, val in pairs(attack_elements) do
        if defender_resists[name] then
            if val == 100 then
                res = res * (defender_resists[name].damage / 100)
            else
                assert(false, "partial elemental damage not implemented")
            end
        end
    end

    return res / 100
end

-- target is an ObjectRef of a Mobs Redo mob or nil
-- stat_factors is a table of {str = 0.7, dex = 0.3, int = 0.0} for example
-- elements is a table of {["yams:fire"] = 200} for example; if nil, assumes
-- a physical attack (equal to {["yams:physical"] = 100})
-- The return value is not rounded, so make sure to round it by using
-- yams.round_with_fraction_as_probability if it will be used to inflict damage
-- since Minetest only accepts integers for damage values
function yams.calc_player_damage(player, target, base_dmg, stat_factors, elements)
    assert(yams.is_player(player), "player arg is not a player")
    assert(type(base_dmg) == "number", "base_dmg should be a number")

    -- Calculate damage bonuses from stats
    local meta = player:get_meta()
    local str = meta:get_int(str_key)
    local dex = meta:get_int(dex_key)
    local int = meta:get_int(int_key)

    local str_multi = (calc_damage_bonus(str) / 100) * stat_factors.str
    local dex_multi = (calc_damage_bonus(dex) / 100) * stat_factors.dex
    local int_multi = (calc_damage_bonus(int) / 100) * stat_factors.int

    local result = base_dmg
    result = result + (base_dmg * str_multi)
    result = result + (base_dmg * dex_multi)
    result = result + (base_dmg * int_multi)

    -- Incorporate any status effects
    result = result * yams.damage_given_monoid:value(player)

    if target then
        local ent = target:get_luaentity()

        if ent and ent._cmi_is_mob then  -- Is a Mobs Redo mob
            -- Take elemental resistances and weaknesses into account
            if ent.yams_resists then
                if not elements then  -- Fallback
                    elements = {["yams:physical"] = 100}
                end

                local resists = ent.yams_resists
                local factor = yams.calc_element_modifier(elements, resists)
                result = result * factor
            end
        end
    end

    return result
end

-- Override the default function
function calc_kb_override(player, hitter, tflp, toolcaps, dir, dist, damage)
    -- TODO: handle player melee and ranged attacks in PvP (low priority)
    if hitter == nil then  -- In case the puncher is nil
        return minetest_kb(player, hitter, tflp, toolcaps, dir, dist, damage)
    end
    
    local new_dir = nil
    local ent = hitter:get_luaentity()
    if ent and ent._yams_mob_projectile then  -- For mob projectiles
        --[[
        Since these projectiles use raycasting, the direction vector cannot
        be trusted because the collision check might be performed when the
        projectile already went through the player, which would mean that
        the direction vector would be located on the opposite side from
        where the player was hit; thus, figure out the direction using the
        projectile's velocity instead

        In addition, while the Minetest API docs say:
        "If direction equals nil and puncher does not equal nil, direction
        will be automatically filled in based on the location of puncher"
        ...it turns out that direction is always filled in regardless,
        overriding what was there, so we need to do this hack anyway :(

        The resulting vector is not as precise because the vector going from
        the hitter towards the player and the vector representing the velocity
        of the projectile may be different, but most players will not notice...
        If a projectile is moving really slowly, and you run into the projectile
        at a direction perpendicular to it, then you will notice the flaw in
        this method, but that's an edge case and I think this is better anyway
        --]]
        new_dir = vector.copy(hitter:get_velocity())
        new_dir = vector.normalize(new_dir)
    else
        -- It is a melee attack, so the direction vector is (hopefully) reliable
        new_dir = vector.copy(dir)
    end

    -- We ignore the y-coordinate because even if the player and a mob appear
    -- to be on the same elevation, their positions may not necessarily have
    -- the same y-coordinate; for example, the dirt monster's y-coordinate seems
    -- to be +1 compared to the player's
    new_dir.y = 0
    -- Not sure if this is the right way to do this but it works well enough
    new_dir = vector.normalize(new_dir)

    local horiz_mag = 8.0  -- Baseline value; shoves player back about one node

    if ent then  -- Source of the attack is (probably) Mobs Redo
        if ent._yams_mob_projectile and ent.knockback then  -- Projectile
            horiz_mag = ent.knockback
        elseif ent._cmi_is_mob and ent.melee_knockback then  -- Melee attack
            horiz_mag = ent.melee_knockback
        end
    end

    local vert_mag = 4.0
    if horiz_mag < 2.0 then
        vert_mag = 0.0  -- Don't apply vertical thrust for minor knockback
    end

    assert(horiz_mag >= 0.0, "horiz_mag should not be negative")
    assert(vert_mag >= 0.0, "vert_mag should not be negative")

    local kb = vector.new(horiz_mag * new_dir.x, vert_mag, horiz_mag * new_dir.z)
    player:add_velocity(kb)

    -- The default behavior doesn't seem to work with projectiles at all for
    -- some reason so just handle it above and then return 0.0
    return 0.0
end

-- Replace the old knockback function but keep it around for edge cases
minetest_kb = core.calculate_knockback
core.calculate_knockback = calc_kb_override

local function on_punched_player(player, hitter, tflp, toolcaps, dir, damage)
    if not yams.is_player(player) or hitter == nil or toolcaps == nil or
            toolcaps.full_punch_interval == nil or
            toolcaps.damage_groups == nil or
            toolcaps.damage_groups.fleshy == nil then
        core.log("warning", "[yams_rpg] on_punched_player returned false")
        return false  -- Uh, let the engine handle it I guess
    end

    if player:get_hp() == 0 then
        return false  -- Player is dead so whatever
    end

    if tflp == nil then
        core.log("warning", "[yams_rpg] assuming tflp is 1.0")
        tflp = toolcaps.full_punch_interval -- Assume full punch interval
    end

    -- Normally the engine prevents tflp from exceeding the tool's full punch
    -- interval but we have to do it ourselves or else really high damage
    -- values will occur as a result
    if tflp > toolcaps.full_punch_interval then
        tflp = toolcaps.full_punch_interval
    end

    local meta = player:get_meta()
    local con = meta:get_int(con_key)

    -- TODO: different damage types
    local base_dmg = toolcaps.damage_groups.fleshy
    local tflp_mod = tflp / toolcaps.full_punch_interval
    -- If admin armor is being worn, then fleshy has a nil value, so prevent
    -- a crash in that case
    local armor_mod = (player:get_armor_groups().fleshy or 0) / 100
    local con_mod = 1.0 - (calc_damage_reduction(con) / 100)
    local status_mod = yams.damage_taken_monoid:value(player)

    local cur_dmg = base_dmg * tflp_mod * armor_mod * con_mod * status_mod

    -- The tough skin status effect cannot reduce damage below 1
    -- It is applied after the above multipliers for the best effect
    if cur_dmg > 1 then
        local tough_skin = yams.tough_skin_monoid:value(player)
        cur_dmg = math.max(1, cur_dmg - tough_skin)
    end

    local morale = meta:get_int("yams_rpg:morale")
    if morale == 1 then
        -- Use the unmodified max HP in case the player is plagued
        local max_dmg = math.ceil(calc_max_hp(player) / 4)
        cur_dmg = math.min(cur_dmg, max_dmg)
    end

    if cur_dmg ~= 0 and cur_dmg < 1 then
        if yams.is_player(hitter) then
            cur_dmg = 0  -- prevent click spam from other players
        else
            cur_dmg = 1  -- minimum damage from mobs is 1 unless fully resisted
        end
    else
        -- The damage must be an integer, so depending on the fractional
        -- part of the result, decide whether to round up or down
        -- Normally, Minetest would just round down the damage amount as
        -- soon as any piece of armor is worn, but by doing it this way,
        -- this allows better armor to have more of an impact even with
        -- smaller damage amounts
        cur_dmg = yams.round_with_fraction_as_probability(cur_dmg)
    end

    player:set_hp(player:get_hp() - cur_dmg, {type = "punch", object = hitter})

    return true  -- Override the Minetest engine's damage mechanism
end

core.register_on_punchplayer(on_punched_player)

local function on_player_hpchange(player, hp_change, reason)
    if reason.type == "fall" or reason.type == "drown" then
        -- Scale falling and drowning damage based on max HP but not
        -- completely to allow for some resistance to these types of damage
        -- as max HP increases
        local factor = player:get_properties().hp_max / 20
        if factor > 1.0 then
            factor = 1.0 + ((factor - 1.0) * 0.8)
        elseif factor < 1.0 then
            -- Max HP can be less than 20 if the player is plagued, so ensure
            -- that falling damage cannot be less than usual
            factor = 1.0
        end

        -- math.floor is incorrect for negative numbers since that would
        -- actually increase the damage instead of decreasing it
        -- Anyway, always favor the player and "round down" damage
        hp_change = math.ceil(hp_change * factor)
        -- The built-in log message doesn't take into account any callbacks
        -- that modify falling damage
        local log_msg = string.format("%s actually took %d damage from %s",
                                      player:get_player_name(), -hp_change,
                                      reason.type)
        core.log("action", log_msg)
        return hp_change
    elseif reason.tnt then
        -- Holds the original damage value; flip the sign to cause damage
        -- See: https://github.com/minetest/minetest/issues/14344
        hp_change = -reason.tnt_orig

        local meta = player:get_meta()
        local con = meta:get_int(con_key)

        local armor_mod = (player:get_armor_groups().fleshy or 0) / 100
        local con_mod = 1.0 - (calc_damage_reduction(con) / 100)
        -- math.ceiling is incorrect for negative numbers since that would
        -- actually decrease the damage instead of increasing it
        -- Anyway, always "round up" explosion damage
        hp_change = math.floor(hp_change * armor_mod * con_mod)

        local log_msg = string.format("%s took %d explosion damage",
                                      player:get_player_name(), -hp_change)
        core.log("action", log_msg)
        return hp_change
    else
        return hp_change
    end
end

core.register_on_player_hpchange(on_player_hpchange, true)

local function on_player_death(player, reason)
    local meta = player:get_meta()
    local level = meta:get_int(level_key)

    -- Increase exp loss on death up to a maximum of 50% based on the player's
    -- level; at lower levels, it is assumed that the player is learning how
    -- to play so be less harsh at first
    local loss_factor = level * 0.05
    if level <= 2 then
        loss_factor = 0.1
    elseif level >= 10 then
        loss_factor = 0.5
    end

    local exp = meta:get_int(cur_exp_key)
    local new_exp = math.floor(exp * (1.0 - loss_factor))
    meta:set_int(cur_exp_key, new_exp)

    local lost_exp = exp - new_exp
    if lost_exp > 0 then
        local chat_msg = S("You lost @1 experience points.", lost_exp)
        local name = player:get_player_name()
        core.chat_send_player(name, chat_msg)

        local log_msg = string.format("%s lost %s exp by dying", name, lost_exp)
        core.log("action", log_msg)
    end

    update_hud_text(player)
end

core.register_on_dieplayer(on_player_death)

core.register_privilege("yams_rpg_stats", {
    description = "Can manipulate everyone's RPG stats",
    give_to_singleplayer = false,
    give_to_admin = true,
})

local function reset_stats_command(name, param)
    -- Even if the player wants to use the command on themselves, they need
    -- to specify their own name since all progress will be lost by using
    -- this command
    local player = core.get_player_by_name(param)
    if not yams.is_player(player) then
        return false, S("[yams_rpg] Invalid player name.")
    end

    init_player_stats(player)
    update_hud_text(player)
    core.log("action", string.format("%s used /reset_stats %s", name, param))

    return true, S("[yams_rpg] Stats of @1 reset.", param)
end

core.register_chatcommand("reset_stats", {
    params = S("<name>"),
    description = S("Sets a player's level and experience points to 0 " ..
                    "and resets all of their stats. Use with caution!"),
    privs = {yams_rpg_stats = true},
    func = reset_stats_command
})

local function give_exp_command(name, param)
    local args = {}
    for token in string.gmatch(param, "%S+") do
        table.insert(args, token)
    end

    if #args < 2 then
        return false, S("[yams_rpg] Not enough arguments.")
    elseif #args > 2 then
        return false, S("[yams_rpg] Too many arguments.")
    end

    local player = core.get_player_by_name(args[1])
    if not yams.is_player(player) then
        return false, S("[yams_rpg] Invalid player name.")
    end

    local amount = tonumber(args[2])
    if amount == nil or amount <= 0 then
        return false, S("[yams_rpg] Amount must be a positive number.")
    end

    core.log("action", string.format("%s used /give_exp %s", name, param))
    yams.add_exp(player, amount, "give_exp")

    return true, S("[yams_rpg] Experience points added.")
end

core.register_chatcommand("give_exp", {
    params = S("<name> <amount>"),
    description = S("Gives experience points to a player."),
    privs = {yams_rpg_stats = true},
    func = give_exp_command
})

local function show_stats_command(name, param)
    local player = core.get_player_by_name(param)
    if not yams.is_player(player) then
        return false, S("[yams_rpg] Invalid player name.")
    end

    local meta = player:get_meta()
    local pname = player:get_player_name()

    local level = meta:get_int(level_key)
    local cur_exp = meta:get_int(cur_exp_key)
    local to_next = exp_table[level]
    local total_exp = calc_total_exp(player)
    local stat_points = meta:get_int(stat_points_key)

    local str = meta:get_int(str_key)
    local dex = meta:get_int(dex_key)
    local int = meta:get_int(int_key)
    local con = meta:get_int(con_key)

    local msg = S("Level @1 - @2/@3 (Total: @4 / Stat Points: @5) | " ..
                  "Str: @6 / Dex: @7 / Int: @8 / Con: @9",
                  level, cur_exp, to_next, total_exp, stat_points,
                  str, dex, int, con)

    core.chat_send_player(name, S("[yams_rpg] Stats of @1:", pname))
    core.chat_send_player(name, msg)

    return true
end

core.register_chatcommand("show_stats", {
    params = S("<name>"),
    description = S("Displays a player's stats."),
    privs = {yams_rpg_stats = true},
    func = show_stats_command
})

local function respec_command(name, param)
    local respec_key = "yams_rpg:last_respec_time"
    local player = core.get_player_by_name(name)
    local meta = player:get_meta()

    -- Key is not present until a respec is performed once
    local has_respec_before = meta:contains(respec_key)
    local last_respec_day = meta:get_int(respec_key)  -- 0 if not present
    local days_passed = core.get_day_count() - last_respec_day

    local privs = core.get_player_privs(name)
    local bypass_cooldown = privs["yams_rpg_infinite_respec"]

    -- A respec can only be performed every seven in-game days unless the player
    -- has the above priv
    if days_passed < 7 and has_respec_before and not bypass_cooldown then
        local days_left = 7 - days_passed
        local msg = S("[yams_rpg] Cannot respec for @1 more day(s).", days_left)
        return false, msg
    end

    -- Save this data so that we can restore it after resetting the player's
    -- stats
    local level = meta:get_int(level_key)
    local cur_exp = meta:get_int(cur_exp_key)

    init_player_stats(player)

    meta:set_int(level_key, level)
    meta:set_int(cur_exp_key, cur_exp)

    -- The player always gains one stat point per level
    meta:set_int(stat_points_key, level)

    -- Record the day the respec was performed
    -- Technically, players can respec at 23:59 and then they would only have
    -- to wait six full days but whatever
    meta:set_int(respec_key, core.get_day_count())

    return true, S("[yams_rpg] Respec performed.")
end

core.register_privilege("yams_rpg_infinite_respec", {
    description = "Can bypass the respec cooldown",
    give_to_singleplayer = false,
    give_to_admin = false,
})

core.register_chatcommand("respec", {
    params = "",
    description = S("Resets your stats and refunds all your stat points. " ..
                    "Does not reset your level or experience points. " ..
                    "Can only be used once every seven in-game days unless " ..
                    "you have the 'yams_rpg_infinite_respec' priv."),
    func = respec_command
})

print("yams_rpg loaded")
