FPP = FPP or {}
local plyMeta = FindMetaTable("Player")
local entMeta = FindMetaTable("Entity")

local bit_bor = bit.bor
local bit_band = bit.band

--[[-------------------------------------------------------------------------
Entity data explanation.
Every ent has a field FPPCanTouch. This is a table with one entry per player.
Every bit in FPPCanTouch represents a type.
    The first bit says whether the player can physgun the ent
    The second bit whether the player can use gravun on the ent
    etc.

Then there is the FPPCanTouchWhy var This var follows the same idea as FPPCanTouch
except there are five bits for each type. These 4 bits represent a number that shows the reason
why a player can or cannot touch a prop.
---------------------------------------------------------------------------]]

local touchTypes = {
    Physgun = 1,
    Gravgun = 2,
    Toolgun = 4,
    PlayerUse = 8,
    EntityDamage = 16
}

local touchTypeNumbers = {
    [1] = "Physgun",
    [2] = "Gravgun",
    [4] = "Toolgun",
    [8] = "PlayerUse",
    [16] = "EntityDamage"
}

local reasonSize = 4 -- bits

local reasonNumbers = {
    ["owner"] = 1,
    ["world"] = 2,
    ["disconnected"] = 3,
    ["blocked"] = 4,
    ["constrained"] = 5,
    ["buddy"] = 6,
    ["shared"] = 7,
    ["player"] = 8,
}

--[[-------------------------------------------------------------------------
Utility functions
---------------------------------------------------------------------------]]
local function getPlySetting(ply, settingName)
    return (ply[settingName] or ply:GetInfo(settingName)) == "1"
end

local function getSetting(touchType)
    return touchType .. "1", string.upper("FPP_" .. touchType .. "1")
end

local constraints = { -- These little buggers think they're not constraints, but they are
    ["phys_spring"] = true,
}
local function isConstraint(ent)
    return ent:IsConstraint() or constraints[ent:GetClass()] or false
end

--[[-------------------------------------------------------------------------
Touch calculations
---------------------------------------------------------------------------]]
local hardWhiteListed = { -- things that mess up when not allowed
    ["worldspawn"] = true, -- constraints with the world
    ["gmod_anchor"] = true, -- used in slider constraints with world
    ["gmod_wire_hologram"] = true, -- created by Expression2, has no physics, but can be constrained to other entities
    ["phys_spring"] = true, -- https://developer.valvesoftware.com/wiki/Phys_spring
    ["env_projectedtexture"] = true, -- https://developer.valvesoftware.com/wiki/Env_projectedtexture
    ["keyframe_rope"] = true, -- https://developer.valvesoftware.com/wiki/Keyframe_rope
    ["env_skypaint"] = true, -- https://developer.valvesoftware.com/wiki/Env_skypaint
    ["env_fog_controller"] = true -- https://developer.valvesoftware.com/wiki/Env_fog_controller
}
local function calculateCanTouchForType(ply, ent, touchType)
    if not IsValid(ent) then return false, 0 end

    ply.FPP_Privileges = ply.FPP_Privileges or {}
    local class = ent:GetClass()
    local setting, tablename = getSetting(touchType)
    local FPPSettings = FPP.Settings[tablename]

    -- hard white list
    if hardWhiteListed[class] then return true, reasonNumbers.world end

    -- picking up players
    if touchType == "Physgun" and ent:IsPlayer() and not getPlySetting(ply, "cl_pickupplayers") then
        return false, reasonNumbers.player
    end

    -- blocked entity
    local whitelist = FPPSettings.iswhitelist ~= 0
    local isInList = FPP.Blocked[setting][string.lower(class)] or false

    local isAdmin = ply.FPP_Privileges.FPP_TouchOtherPlayersProps
    local isBlocked = whitelist ~= isInList -- XOR

    if isBlocked then
        local adminsCanTouchBlocked = FPPSettings.admincanblocked ~= 0
        local playersCanBlocked = FPPSettings.canblocked ~= 0

        local canPhysgunBlocked = (playersCanBlocked or isAdmin and adminsCanTouchBlocked) and not getPlySetting(ply, "FPP_PrivateSettings_BlockedProps")

        if not canPhysgunBlocked then
            return canPhysgunBlocked, reasonNumbers.blocked
        end
    end

    -- touch own props
    local owner = ent.FPPOwner -- Circumvent CPPI for micro-optimisation
    if owner == ply then
        return not getPlySetting(ply, "FPP_PrivateSettings_OwnProps"),
               reasonNumbers.owner
    end

    local noTouchOtherPlayerProps = getPlySetting(ply, "FPP_PrivateSettings_OtherPlayerProps")

    -- Shared entity
    if ent.AllowedPlayers and table.HasValue(ent.AllowedPlayers, ply) then
        return not noTouchOtherPlayerProps, reasonNumbers.shared
    end
    if ent["Share" .. setting] then return not noTouchOtherPlayerProps, reasonNumbers.shared end

    if IsValid(owner) then
        -- Player is buddies with the owner of the entity
        if owner.Buddies and owner.Buddies[ply] and owner.Buddies[ply][touchType] then return not noTouchOtherPlayerProps, reasonNumbers.buddy end

        -- Someone else's prop
        local adminProps = FPPSettings.adminall ~= 0
        return isAdmin and adminProps and not noTouchOtherPlayerProps, reasonNumbers.owner
    end

    -- World props and disconnected players' props
    local adminWorldProps = FPPSettings.adminworldprops ~= 0
    local peopleWorldProps = FPPSettings.worldprops ~= 0
    local restrictWorld = getPlySetting(ply, "FPP_PrivateSettings_WorldProps")

    return not restrictWorld and (peopleWorldProps or (isAdmin and adminWorldProps)),
           owner == nil and reasonNumbers.world or reasonNumbers.disconnected
end

local blockedEnts = {
    ["ai_network"] = true,
    ["network"] = true, -- alternative name for ai_network
    ["ambient_generic"] = true,
    ["beam"] = true,
    ["bodyque"] = true,
    ["env_soundscape"] = true,
    ["env_sprite"] = true,
    ["env_sun"] = true,
    ["env_tonemap_controller"] = true,
    ["func_useableladder"] = true,
    ["gmod_hands"] = true,
    ["info_ladder_dismount"] = true,
    ["info_player_start"] = true,
    ["info_player_terrorist"] = true,
    ["light_environment"] = true,
    ["light_spot"] = true,
    ["physgun_beam"] = true,
    ["player_manager"] = true,
    ["point_spotlight"] = true,
    ["predicted_viewmodel"] = true,
    ["scene_manager"] = true,
    ["shadow_control"] = true,
    ["soundent"] = true,
    ["spotlight_end"] = true,
    ["water_lod_control"] = true,
    ["gmod_gamerules"] = true,
    ["bodyqueue"] = true,
    ["phys_bone_follower"] = true,
}
function FPP.calculateCanTouch(ply, ent)
    local canTouch = 0

    local reasons = 0
    local i = 0

    for Bit, touchType in pairs(touchTypeNumbers) do
        local canTouchType, why = calculateCanTouchForType(ply, ent, touchType)
        if canTouchType then
            canTouch = bit.bor(canTouch, Bit)
        end

        reasons = bit.bor(reasons, bit.lshift(why, i * reasonSize))

        i = i + 1
    end

    ent.FPPCanTouch = ent.FPPCanTouch or {}
    ent.FPPCanTouchWhy = ent.FPPCanTouchWhy or {}

    local changed = ent.FPPCanTouch[ply] ~= canTouch or ent.FPPCanTouchWhy[ply] ~= reasons or ent.FPPOwnerChanged
    ent.FPPCanTouch[ply] = canTouch
    ent.FPPCanTouchWhy[ply] = reasons

    return changed
end

-- try not to call this with both player.GetAll() and ents.GetAll()
local function recalculateCanTouch(players, entities)
    for k, v in pairs(entities) do
        if not IsValid(v) then entities[k] = nil continue end
        if v:IsEFlagSet(EFL_SERVER_ONLY) then entities[k] = nil continue end
        if blockedEnts[v:GetClass()] then entities[k] = nil continue end
        if v:IsWeapon() and IsValid(v:GetOwner()) then entities[k] = nil continue end
    end

    for _, ply in pairs(players) do
        if not IsValid(ply) then continue end
        -- optimisations
        ply.FPPIsAdmin = ply.FPP_Privileges.FPP_TouchOtherPlayersProps
        ply.FPP_PrivateSettings_OtherPlayerProps = ply:GetInfo("FPP_PrivateSettings_OtherPlayerProps")
        ply.cl_pickupplayers = ply:GetInfo("cl_pickupplayers")
        ply.FPP_PrivateSettings_BlockedProps = ply:GetInfo("FPP_PrivateSettings_BlockedProps")
        ply.FPP_PrivateSettings_OwnProps = ply:GetInfo("FPP_PrivateSettings_OwnProps")
        ply.FPP_PrivateSettings_WorldProps = ply:GetInfo("FPP_PrivateSettings_WorldProps")
        local changed = {}

        for _, ent in pairs(entities) do
            local hasChanged = FPP.calculateCanTouch(ply, ent)
            if hasChanged then table.insert(changed, ent) end
        end

        FPP.plySendTouchData(ply, changed)

        -- end optimisations
        ply.FPP_PrivateSettings_OtherPlayerProps = nil
        ply.cl_pickupplayers = nil
        ply.FPP_PrivateSettings_BlockedProps = nil
        ply.FPP_PrivateSettings_OwnProps = nil
        ply.FPP_PrivateSettings_WorldProps = nil
        ply.FPPIsAdmin = nil
    end
end

function FPP.recalculateCanTouch(plys, ens)
    FPP.calculatePlayerPrivilege("FPP_TouchOtherPlayersProps", function() recalculateCanTouch(plys, ens) end)
end

--[[-------------------------------------------------------------------------
Touch interface
---------------------------------------------------------------------------]]
function FPP.plyCanTouchEnt(ply, ent, touchType)
    local entTable = ent:GetTable()
    local entCanTouch = entTable.FPPCanTouch
    if not entCanTouch then
        entCanTouch = {}
        entTable.FPPCanTouch = entCanTouch
    end

    entCanTouch[ply] = entCanTouch[ply] or 0

    local allowedPlayers = entTable.AllowedPlayers
    if not allowedPlayers then
        entTable.AllowedPlayers = {}
    end

    local canTouch = entCanTouch[ply]
    -- if an entity is constrained, return the least of the rights
    local entRestrictConstraint = entTable.FPPRestrictConstraint
    if entRestrictConstraint and entRestrictConstraint[ply] then
        canTouch = bit_band(entRestrictConstraint[ply], entCanTouch[ply])
    end

    -- return the answer for every touch type if parameter is empty
    if not touchType then
        return canTouch
    end

    return bit_bor(canTouch, touchTypes[touchType]) == canTouch
end

local GetTable = entMeta.GetTable

function FPP.entGetOwner(ent)
    return GetTable(ent).FPPOwner
end

--[[-------------------------------------------------------------------------
Networking
---------------------------------------------------------------------------]]
util.AddNetworkString("FPP_TouchabilityData")
-- Sends 13 + 8 + 5 + 20 = 46 bits of ownership data per entity
local function netWriteEntData(ply, ent)
    -- EntIndex for when it's out of the PVS of the player
    net.WriteUInt(ent:EntIndex(), 13)

    local owner = ent:CPPIGetOwner()
    net.WriteUInt(IsValid(owner) and owner:EntIndex() or 255, 8)

    local entTable = ent:GetTable()
    net.WriteUInt(entTable.FPPRestrictConstraint and entTable.FPPRestrictConstraint[ply] or entTable.FPPCanTouch[ply], 5) -- touchability information
    net.WriteUInt(entTable.FPPConstraintReasons and entTable.FPPConstraintReasons[ply] or entTable.FPPCanTouchWhy[ply], 20) -- reasons
end

-- The net message gets too big with 5826 or more entities. In that case,
-- break up the input into chunks and recurse per chunk.
--
-- Note: There is still a limit on the entity count! If there are too many
-- entities, the client will disconnect with "Disconnect: Client 0
-- overflowed reliable channel..". That limit is above the entity limit of
-- 16384, though, so no further work is done to lift that limit.
local count_limit = 5825

function FPP.plySendTouchData(ply, ents)
    local count = #ents
    if count == 0 then return end

    if count > count_limit then
        local accumulator = {}

        for i = 1, count do
            table.insert(accumulator, ents[i])

            if i % count_limit == 0 then
                FPP.plySendTouchData(ply, accumulator)
                accumulator = {}
            end
        end

        if #accumulator > 0 then
            FPP.plySendTouchData(ply, accumulator)
        end

        return
    end

    net.Start("FPP_TouchabilityData")
        for i = 1, count do
            netWriteEntData(ply, ents[i])
            net.WriteBit(i == count)
        end
    net.Send(ply)
end

--[[-------------------------------------------------------------------------
Events that trigger recalculation
---------------------------------------------------------------------------]]
local function handleConstraintCreation(ent)
    local ent1, ent2 = ent:GetConstrainedEntities()
    ent1, ent2 = ent1 or ent.Ent1, ent2 or ent.Ent2

    if not ent1 or not ent2 or not ent1.FPPCanTouch or not ent2.FPPCanTouch then return end
    local reason = 0
    local i = 0
    for Bit, touchType in pairs(touchTypeNumbers) do
        reason = bit.bor(reason, bit.lshift(reasonNumbers.constrained, i * reasonSize))
        i = i + 1
    end

    for _, ply in ipairs(player.GetAll()) do
        local touch1, touch2 = FPP.plyCanTouchEnt(ply, ent1), FPP.plyCanTouchEnt(ply, ent2)

        -- The constrained entities have the same touching rights.
        if touch1 == touch2 then continue end

        local restrictedAccess = bit.band(touch1, touch2)

        local send = {}
        for _, e in pairs(constraint.GetAllConstrainedEntities(ent1) or {}) do
            if not IsValid(e) then continue end
            if FPP.plyCanTouchEnt(ply, e) == restrictedAccess then continue end

            e.FPPRestrictConstraint = e.FPPRestrictConstraint or {}
            e.FPPConstraintReasons = e.FPPConstraintReasons or {}
            e.FPPRestrictConstraint[ply] = restrictedAccess
            e.FPPConstraintReasons[ply] = reason

            table.insert(send, e)
        end

        FPP.plySendTouchData(ply, send)
    end

end

--[[-------------------------------------------------------------------------
On entity created
---------------------------------------------------------------------------]]
local function onEntitiesCreated(ents)
    -- Table from player to list of entities that need to be networked
    local sendToPlayers = {}

    for _, ent in ipairs(ents) do
        if not ent:IsValid() then continue end

        if isConstraint(ent) then
            handleConstraintCreation(ent)
            continue
        end

        -- Don't send information about server only entities to the clients
        if ent:GetSolid() == 0 or ent:IsEFlagSet(EFL_SERVER_ONLY) then
            continue
        end

        if blockedEnts[ent:GetClass()] then continue end

        for _, ply in ipairs(player.GetAll()) do
            local changed = FPP.calculateCanTouch(ply, ent)
            -- Only send data that has been changed
            if not changed then continue end
            sendToPlayers[ply] = sendToPlayers[ply] or {}
            table.insert(sendToPlayers[ply], ent)
        end
    end

    for ply, entsToSend in pairs(sendToPlayers) do
        FPP.plySendTouchData(ply, entsToSend)
    end
end

-- Make a queue of entities created per frame, so the server will send out a maximum-
-- of one message per player per frame
local entQueue = {}
local timerFunc = function()
    onEntitiesCreated(entQueue)
    entQueue = {}
    timer.Remove("FPP_OnEntityCreatedTimer")
end
hook.Add("OnEntityCreated", "FPP_EntityCreated", function(ent)
    table.insert(entQueue, ent)

    if timer.Exists("FPP_OnEntityCreatedTimer") then return end
    timer.Create("FPP_OnEntityCreatedTimer", 0, 1, timerFunc)
end)

--[[-------------------------------------------------------------------------
On entity removed
---------------------------------------------------------------------------]]
-- Recalculates touchability information for constrained entities
-- Note: Assumes normal touchability information is up to date!
-- Update constraints, O(players * (entities + constraints))
function FPP.RecalculateConstrainedEntities(players, entities)
    for i, ent in pairs(entities) do
        if not IsValid(ent) then entities[i] = nil continue end
        if ent:IsEFlagSet(EFL_SERVER_ONLY) then entities[i] = nil continue end
        if blockedEnts[ent:GetClass()] then entities[i] = nil continue end

        ent.FPPRestrictConstraint = ent.FPPRestrictConstraint or {}
        ent.FPPConstraintReasons = ent.FPPConstraintReasons or {}
    end

    -- constrained entities form a graph.
    -- and graphs are things you can traverse with BFS
    for _, ply in pairs(players) do
        local discovered = {}
        -- BFS vars
        local BFSQueue = {}
        local black, gray = {}, {} -- black = fully discovered, gray = seen, but discovery from this point is needed
        local value -- used as key and value of the BFSQueue

        for _, ent in pairs(entities) do
            if discovered[ent] then continue end -- We've seen this ent in a graph
            ent.FPPCanTouch = ent.FPPCanTouch or {}
            ent.FPPCanTouch[ply] = ent.FPPCanTouch[ply] or 0

            local left, right = 1, 2
            BFSQueue[left] = ent

            local FPP_CanTouch = ent.FPPCanTouch[ply] -- Find out the canTouch state
            while BFSQueue[left] do
                value = BFSQueue[left]
                BFSQueue[left] = nil
                left = left + 1

                for _, constr in pairs(value.Constraints or {}) do
                    local otherEnt = constr.Ent1 == value and constr.Ent2 or constr.Ent1

                    if not IsValid(otherEnt) or gray[otherEnt] or black[otherEnt] then continue end

                    gray[otherEnt] = true
                    BFSQueue[right] = otherEnt
                    right = right + 1
                end

                black[value] = true
                discovered[value] = true

                -- The entity doesn't necessarily have CanTouch data at this point
                value.FPPCanTouch = value.FPPCanTouch or {}
                value.FPPCanTouch[ply] = value.FPPCanTouch[ply] or 0
                FPP_CanTouch = bit.band(FPP_CanTouch or 0, value.FPPCanTouch[ply])
            end

            -- now update the ents to the client
            local updated = {}
            for e in pairs(black) do
                if FPP.plyCanTouchEnt(ply, e) ~= FPP_CanTouch then
                    e.FPPRestrictConstraint = e.FPPRestrictConstraint or {}
                    e.FPPRestrictConstraint[ply] = e.FPPCanTouch[ply] ~= FPP_CanTouch and FPP_CanTouch or nil
                    table.insert(updated, e)
                end
            end
            FPP.plySendTouchData(ply, updated)

            -- reset BFS information for next BFS round
            black = {}
            gray = {}
        end
    end
end

local entMem = {}
local function constraintRemovedTimer(ent1, ent2)
    if not IsValid(ent1) and not IsValid(ent2) then return end
    entMem = {}

    local allConstrainedEnts = {}
    table.Add(allConstrainedEnts, constraint.GetAllConstrainedEntities(ent1))
    table.Add(allConstrainedEnts, constraint.GetAllConstrainedEntities(ent2))
    FPP.RecalculateConstrainedEntities(player.GetAll(), allConstrainedEnts)
end

local function handleConstraintRemoved(ent)
    local ent1, ent2 = ent:GetConstrainedEntities()
    ent1, ent2 = ent1 or ent.Ent1, ent2 or ent.Ent2

    if not IsValid(ent1) or not IsValid(ent2) then return end
    -- prevent the function from being called too often when many constraints are removed at once
    if entMem[ent1] or entMem[ent2] then return end
    entMem[ent1] = true
    entMem[ent2] = true

    timer.Create("FPP_ConstraintRemovedTimer", 0, 1, function() constraintRemovedTimer(ent1, ent2) end)
end

local function onEntityRemoved(ent)
    if isConstraint(ent) then handleConstraintRemoved(ent) end
end

hook.Add("EntityRemoved", "FPP_OnEntityRemoved", onEntityRemoved)

--[[-------------------------------------------------------------------------
Player disconnected
---------------------------------------------------------------------------]]
local function playerDisconnected(ply)
    local ownedEnts = {}
    for _, ent in ipairs(ents.GetAll()) do
        if ent:CPPIGetOwner() == ply then
            table.insert(ownedEnts, ent)
        end
    end

    timer.Simple(0, function() FPP.recalculateCanTouch(player.GetAll(), ownedEnts) end)
end
hook.Add("PlayerDisconnected", "FPP_PlayerDisconnected", playerDisconnected)

--[[-------------------------------------------------------------------------
Usergroup changed
---------------------------------------------------------------------------]]
local function userGroupRecalculate(ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end

    timer.Create("FPP_recalculate_cantouch_" .. ply:UserID(), 0, 1, function()
        FPP.recalculateCanTouch({ply}, ents.GetAll())
    end)
end

FPP.oldSetUserGroup = FPP.oldSetUserGroup or plyMeta.SetUserGroup
function plyMeta:SetUserGroup(group)
    userGroupRecalculate(self)

    return FPP.oldSetUserGroup(self, group)
end

FPP.oldSetNWString = FPP.oldSetNWString or entMeta.SetNWString
function entMeta:SetNWString(str, val)
    if str ~= "usergroup" then return FPP.oldSetNWString(self, str, val) end

    userGroupRecalculate(self)
    return FPP.oldSetNWString(self, str, val)
end

FPP.oldSetNetworkedString = FPP.oldSetNetworkedString or entMeta.SetNetworkedString
function entMeta:SetNetworkedString(str, val)
    if str ~= "usergroup" then return FPP.oldSetNetworkedString(self, str, val) end

    userGroupRecalculate(self)
    return FPP.oldSetNetworkedString(self, str, val)
end
