--[[
    Leads — Adds leads for transporting animals to Minetest.
    Copyright © 2023, Silver Sandstone <@SilverSandstone@craftodon.social>

    Permission is hereby granted, free of charge, to any person obtaining a
    copy of this software and associated documentation files (the "Software"),
    to deal in the Software without restriction, including without limitation
    the rights to use, copy, modify, merge, publish, distribute, sublicense,
    and/or sell copies of the Software, and to permit persons to whom the
    Software is furnished to do so, subject to the following conditions:

    The above copyright notice and this permission notice shall be included in
    all copies or substantial portions of the Software.

    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    DEALINGS IN THE SOFTWARE.
]]


--- Lead entity definition.
-- @module leads


local S = leads.S;


leads.SLACK_MODELS =
{
    [0] = 'leads_lead.obj',
    'leads_lead_slack1.obj',
    'leads_lead_slack2.obj',
    'leads_lead_slack3.obj',
    'leads_lead_slack4.obj',
    'leads_lead_slack5.obj',
    'leads_lead_slack6.obj',
    'leads_lead_slack7.obj',
    'leads_lead_slack8.obj',
    'leads_lead_slack9.obj',
    'leads_lead_slack10.obj',
    'leads_lead_slack11.obj',
    'leads_lead_slack12.obj',
};

leads.STRETCH_SOUND_INTERVAL = 2.0;

if leads.settings.drop_mode == 'drop' then
    leads.DROP_ITEM = true;
elseif leads.settings.drop_mode == 'give' then
    leads.DROP_ITEM = false;
else
    leads.DROP_ITEM = (minetest.get_modpath('mcl_core') or minetest.get_modpath('rp_default') or minetest.get_modpath('item_drop')) ~= nil;
end;


--- The main lead entity.
-- @type LeadEntity
leads.LeadEntity = {};

leads.LeadEntity.description = S'Lead';
leads.LeadEntity._leads_immobile = true;

leads.LeadEntity.initial_properties =
{
    visual       = 'mesh';
    mesh         = 'leads_lead.obj';
    textures     = {leads.DEFAULT_LEAD_TEXTURE};
    physical     = false;
    selectionbox = {-0.5, -0.5, -0.5, 0.5, 0.5, 0.5};
};

--- Spawns or unloads a lead.
function leads.LeadEntity:on_activate(staticdata, dtime_s)
    self.current_length = 0.24;
    self.max_length = leads.settings.lead_length;
    self.rotation = vector.zero();
    self.leader_attach_offset = vector.zero();
    self.follower_attach_offset = vector.zero();
    self.sound_timer = 0.0;
    self.item = ItemStack();
    self.texture = leads.DEFAULT_LEAD_TEXTURE;
    self.strength = leads.settings.lead_strength;
    self.breaking = 0.0;

    local data = minetest.deserialize(staticdata);
    if data then
        self:load_from_data(data);
    end;

    self.object:set_armor_groups{fleshy = 0};
end;

--- Initialises the lead's state from a table.
function leads.LeadEntity:load_from_data(data)
    self.max_length = data.max_length or self.max_length;
    self.leader_id = data.leader_id or {};
    self.follower_id = data.follower_id or {};
    self.leader_attach_offset = data.leader_attach_offset or self.leader_attach_offset;
    self.follower_attach_offset = data.follower_attach_offset or self.follower_attach_offset;

    self.leader_id.pos = vector.new(self.leader_id.pos);
    self.follower_id.pos = vector.new(self.follower_id.pos);

    if data.item then
        self:set_item(data.item);
    end;

    self:update_visuals();
end;

--- Sets the lead's item, updating relevant properties.
function leads.LeadEntity:set_item(item)
    item = ItemStack(item);
    self.item = item;
    local def = item:get_definition();

    self.strength = def._leads_strength or leads.settings.lead_strength;
    self.max_length = def._leads_length or leads.settings.lead_length;
    self.texture = def._leads_texture or leads.DEFAULT_LEAD_TEXTURE;

    if not leads.settings.dynamic_textures then
        self.object:set_properties{textures = {self.texture}};
    end;
end;

--- Steps the knot.
function leads.LeadEntity:on_step(dtime)
    self:_update_connectors();
    local success, pos, offset = self:step_physics(dtime);
    if success then
        self.current_length = leads.util.clamp(offset:length(), 0.25, 256);
        self.rotation = offset:dir_to_rotation();
        self.object:move_to(pos, true);
        self:update_visuals();
    end;
end;

--- Simulates the lead's physics.
-- @param dtime [number]     The time elapsed since the last tick, in seconds.
-- @return      [boolean]    true if the lead is functioning correctly, or false if it should break.
-- @return      [vector|nil] The centre position of the lead, or nil on failure.
-- @return      [vector|nil] The offset between the leader and the follower, or nil on failure.
function leads.LeadEntity:step_physics(dtime)
    dtime = math.min(dtime, 0.125);

    local l_pos = self.leader_pos;
    local f_pos = self.follower_pos;
    if not (l_pos and f_pos) then
        self:break_lead();
        return false, nil, nil;
    end;

    l_pos = l_pos + self.leader_attach_offset;
    f_pos = f_pos + self.follower_attach_offset;

    local pull_distance = self.max_length;
    local break_distance = pull_distance * 2;
    local distance = l_pos:distance(f_pos);
    if distance > break_distance then
        -- Lead is too long, break:
        local overextension = distance - break_distance;
        self.breaking = self.breaking + overextension * dtime;
        if self.breaking > self.strength then
            self:break_lead(nil, true);
            return false, nil, nil;
        end;
    else
        self.breaking = 0.0;
    end;

    local pos = (f_pos + l_pos) / 2;
    if self.leader and self.follower and distance > pull_distance then
        local base_force;

        local function _pull_connector(connector, this_pos, other_pos)
            if leads.is_immobile(connector) then
                return;
            end;

            if not base_force then
                base_force = (distance - pull_distance) * leads.settings.pull_force / pull_distance;
            end;

            local force = base_force / math.sqrt(leads.util.get_object_mass(connector));
            local pull_direction = (other_pos - this_pos):normalize();
            connector:add_velocity(pull_direction * dtime * force ^ 1.5);
        end;

        -- Pull follower:
        _pull_connector(self.follower, f_pos, l_pos);

        -- Pull leader if symmetrical mode is enabled:
        if leads.settings.symmetrical then
            _pull_connector(self.leader, l_pos, f_pos);
        end;

        -- Play stretching sound:
        self.sound_timer = self.sound_timer + dtime;
        if self.sound_timer >= leads.STRETCH_SOUND_INTERVAL then
            self.sound_timer = self.sound_timer - leads.STRETCH_SOUND_INTERVAL;
            if leads.util.rng:next(0, 8) == 0 then
                minetest.sound_play(leads.sounds.stretch, {pos = pos}, true);
            end;
        end;
    end;
    return true, pos, f_pos - l_pos;
end;

--- Updates the connector references and stored positions.
-- @local
function leads.LeadEntity:_update_connectors()
    local function _get_pos(key)
        local object = self[key];
        local pos = object and object:get_pos();
        local id = self[key .. '_id'];
        if not pos then
            pos = id.pos;
            if not pos then
                return nil;
            end;

            local object = leads.util.deserialise_objref(id);
            if object then
                pos = object:get_pos();
                self[key] = object;
                self[key .. '_attach_offset'] = leads.util.get_attach_offset(object);
                leads.leads_by_connector[object][self.object] = true;
            else
                -- The object reference is invalid, and deserialising the
                -- object failed. This could mean that the object has been
                -- removed and the lead should break, or it could mean that
                -- the object's mapblock has been unloaded, and the lead
                -- should just wait until it gets loaded again. We can figure
                -- out which one by checking if the mapblock is active.
                if minetest.compare_block_status(pos, 'active') then
                    return nil;
                end;
            end;
        end;
        id.pos = pos or id.pos;
        return pos;
    end;

    self.leader_pos   = _get_pos('leader');
    self.follower_pos = _get_pos('follower');
end;

--- Handles the lead being punched.
function leads.LeadEntity:on_punch(puncher, time_from_last_punch, tool_capabilities, dir, damage)
    local name = puncher and puncher:get_player_name() or '';

    -- Check protection:
    local is_protected, protected_pos = self:is_protected(name);
    if is_protected then
        minetest.record_protection_violation(protected_pos, name);
        return true;
    end;

    -- Break the lead:
    self:break_lead(puncher);

    -- Block the player's interaction for a moment to prevent accidentally breaking the node behind the lead:
    if name ~= '' then
        leads.util.block_player_interaction(name, 0.25);
    end;

    return true;
end;

--- Handles the lead being interacted with while holding a lead item.
function leads.LeadEntity:_leads_on_interact(itemstack, user, pointed_thing, is_punch)
    if is_punch then
        self:on_punch(user);
        return true, nil;
    end;
    return false, nil;
end;

--- Handles the lead being ‘killed’.
function leads.LeadEntity:on_death(killer)
    self:break_lead(killer);
end;

--- Returns the lead's state as a table.
function leads.LeadEntity:get_staticdata()
    local data = {};
    data.item = self.item:to_string();
    data.max_length = self.max_length;
    data.leader_id = self.leader_id;
    data.follower_id = self.follower_id;
    data.leader_attach_offset = self.leader_attach_offset;
    data.follower_attach_offset = self.follower_attach_offset;
    return minetest.serialize(data);
end;

--- Breaks the lead, possibly giving/dropping an item.
-- @param breaker [ObjectRef|nil] The object breaking the lead.
-- @param snap    [boolean|nil]   true if the lead is breaking due to tension.
function leads.LeadEntity:break_lead(breaker, snap)
    if leads.settings.debug then
        minetest.log(debug.traceback(('[Leads] Breaking lead %s at %s.'):format(self, self.object:get_pos())));
    end;

    -- Notify leader and follower:
    self:notify_connector_removed(self.leader,   true);
    self:notify_connector_removed(self.follower, false);

    -- Give or drop item:
    if not self.item:is_empty() then
        local owner = breaker;
        if not minetest.is_player(owner) then
            owner = self.leader;
        end;
        local pos = self.object:get_pos();
        local item = self.item;
        if not leads.DROP_ITEM then
            local inventory = minetest.is_player(owner) and owner:get_inventory();
            if inventory then
                if minetest.is_creative_enabled(owner) and inventory:contains_item('main', item, true) then
                    item = ItemStack();
                else
                    item = inventory:add_item('main', item);
                end;
            end;
        end;
        minetest.add_item(pos, item);
    end;

    -- Play sound:
    if snap then
        minetest.sound_play(leads.sounds.snap, {pos = self.object:get_pos()}, true);
    else
        minetest.sound_play(leads.sounds.remove, {pos = self.object:get_pos()}, true);
    end;

    -- Remove lead:
    self.object:remove();
    self.item = ItemStack();
end;

--- Checks if either end of the lead is in an area protected from the specified player.
-- If protection support is disabled, this always returns false.
-- @param player [string|ObjectRef] A player object or username.
-- @return       [boolean]          true if the player is not allowed to break the lead due to protection.
-- @return       [vector|nil]       The protected position, if any.
function leads.LeadEntity:is_protected(player)
    if not leads.settings.respect_protection then
        return false, nil; -- Protection support is disabled.
    end;

    local name;
    if type(player) == 'string' then
        name = player;
    else
        name = player:get_player_name();
    end;
    name = name or '';

    if minetest.check_player_privs(name, 'protection_bypass') then
        return false, nil; -- The player is exempt from protection.
    end;

    if name == self.leader_id.player_name then
        return false, nil; -- The player is holding the lead.
    end;
    
    for __, connector_id in ipairs{self.leader_id, self.follower_id} do
        if connector_id and connector_id.pos then
            local pos = vector.round(connector_id.pos);
            if minetest.is_protected(pos, name) then
                return true, pos; -- An end of the lead is in a protected area.
            end;
        end;
    end;

    return false, nil;
end;

--- Updates the visual properties of the lead to show its current state.
function leads.LeadEntity:update_visuals()
    local SCALE = 8;

    if self.current_length == self.old_length then
        return;
    end;
    self.old_length = self.current_length;

    local properties = {visual_size = vector.new(1, 1, self.current_length)};
    local selbox_offset = 0;
    -- Dynamic textures:
    if leads.settings.dynamic_textures then
        local texture = leads.util.tile_texture(self.texture, 96 * SCALE, 2 * SCALE, math.floor(self.current_length * 16 * SCALE), 2 * SCALE);
        properties.textures = {texture};
    elseif self.texture ~= self.old_texture then
        self.old_texture = self.texture;
        properties.textures = {self.texture};
    end;
    -- Slack model:
    if leads.settings.enable_slack then
        if self.leader_pos and self.follower_pos then
            local slack, mesh = self:get_slack();
            properties.mesh = mesh;
            selbox_offset = selbox_offset - slack / 12;
        end;
    end;
    -- Selection box:
    if leads.settings.rotate_selection_box then
        properties.selectionbox = {-0.0625, -0.0625 + selbox_offset, -self.current_length / 2,
                                    0.0625,  0.0625 + selbox_offset,  self.current_length / 2, rotate = true};
    end;
    self.object:set_properties(properties);
    self.object:set_rotation(self.rotation);
end;

--- Calculates the slack value and chooses a model to represent it.
-- @return [number] The current slack value.
-- @return [string] A model name.
function leads.LeadEntity:get_slack()
    local span = self.follower_pos - self.leader_pos;
    local slack = 0.5 + 1 - span:length() / self.max_length;

    -- Scale the slack by how horizontal the lead is, otherwise it would droop sideways when vertical.
    slack = slack * (1 - math.abs(span:normalize().y));

    slack = leads.util.clamp(slack, 0.0, 1.0)
    local model_index = math.floor(slack * #leads.SLACK_MODELS);
    return slack, leads.SLACK_MODELS[model_index];
end;

--- Updates the connector IDs to reflect the current connectors.
function leads.LeadEntity:update_objref_ids()
    self.leader_id   = leads.util.serialise_objref(self.leader)   or self.leader_id;
    self.follower_id = leads.util.serialise_objref(self.follower) or self.follower_id;
    self:update_attach_offsets();
end;

--- Updates the attachment offsets to reflect the current connectors' properties.
function leads.LeadEntity:update_attach_offsets()
    self.leader_attach_offset   = leads.util.get_attach_offset(self.leader)   or self.leader_attach_offset;
    self.follower_attach_offset = leads.util.get_attach_offset(self.follower) or self.follower_attach_offset;
end;

--- Transfers this lead to a new leader.
-- @param leader [ObjectRef] The new leader object.
-- @return       [boolean]   true on success.
function leads.LeadEntity:set_leader(leader)
    return self:set_connector(leader, true);
end;

--- Transfers this lead to a new follower.
-- @param follower [ObjectRef] The new follower object.
-- @return         [boolean]   true on success.
function leads.LeadEntity:set_follower(follower)
    return self:set_connector(follower, false);
end;

--- Transfers this lead to a new leader or follower.
-- @param object    [ObjectRef] The new connector.
-- @param is_leader [boolean]   true to set the leader, false to set the follower.
-- @return          [boolean]   true on success.
function leads.LeadEntity:set_connector(object, is_leader)
    if (self.leader   and leads.util.is_same_object(object, self.leader)) or
       (self.follower and leads.util.is_same_object(object, self.follower)) then
        return false;
    end;

    local key = is_leader and 'leader' or 'follower';
    local old_object = self[key];
    self:notify_connector_removed(old_object, is_leader);
    self[key] = object;
    self:notify_connector_added(object, is_leader);
    self:update_objref_ids();
    return true;
end;

--- Reverses the direction of the lead, swapping the leader and follower.
function leads.LeadEntity:reverse()
    self.leader,    self.follower    = self.follower,    self.leader;
    self.leader_id, self.follower_id = self.follower_id, self.leader_id;
end;

--- Notifies the connector that this lead has been added.
-- @param object    [ObjectRef|nil] The connector to notify.
-- @param is_leader [boolean]       true if the connector is the leader.
function leads.LeadEntity:notify_connector_added(object, is_leader)
    if not object then
        return;
    end;

    leads.leads_by_connector[object][self.object] = true;

    local entity = object:get_luaentity();
    if entity and entity._leads_lead_add then
        entity:_leads_lead_add(self, is_leader or false);
    end;
end;

--- Notifies the connector that this lead has been removed.
-- @param object    [ObjectRef|nil] The connector to notify.
-- @param is_leader [boolean]       true if the connector was the leader.
function leads.LeadEntity:notify_connector_removed(object, is_leader)
    if not object then
        return;
    end;

    leads.leads_by_connector[object][self.object] = nil;

    local entity = object:get_luaentity();
    if entity and entity._leads_lead_remove then
        entity:_leads_lead_remove(self, is_leader or false);
    end;
end;

--- Gets the lead's leader if it's loaded.
-- @return [ObjectRef|nil] The leader object.
function leads.LeadEntity:get_leader()
    if self.leader and self.leader:get_pos() then
        return self.leader;
    end;
    return nil;
end;

--- Gets the lead's follower if it's loaded.
-- @return [ObjectRef|nil] The follower object.
function leads.LeadEntity:get_follower()
    if self.leader and self.follower:get_pos() then
        return self.follower;
    end;
    return nil;
end;

minetest.register_entity('leads:lead', leads.LeadEntity);
