-- armurier/sv_net.lua
-- Déclaration des net strings + net.Receive handlers côté serveur.
-- JAMAIS AddCSLuaFile de ce fichier (sv_ = serveur uniquement).
--
-- Messages C→S :
--   Armurier.RequestOpenMenu   — joueur demande l'ouverture du menu (Use sur la caisse)
--   Armurier.BuyItem           — acheter un item du catalogue
--   Armurier.AdminAddCategory  — admin crée une catégorie
--   Armurier.AdminRemoveCategory — admin supprime une catégorie
--   Armurier.AdminAddItem      — admin ajoute un item
--   Armurier.AdminRemoveItem   — admin supprime un item
--
-- Messages S→C :
--   Armurier.OpenMenu          — ouvre le menu (envoie entité caisse)
--   Armurier.CatalogUpdate     — met à jour le cache catalogue client (JSON)
--   Armurier.BuyResult         — résultat d'un achat (ok/err)

Armurier = Armurier or {}

-- ============================================================================
-- Network strings (serveur uniquement)
-- ============================================================================

util.AddNetworkString("Armurier.RequestOpenMenu")
util.AddNetworkString("Armurier.BuyItem")
util.AddNetworkString("Armurier.SellItem")
util.AddNetworkString("Armurier.AdminAddCategory")
util.AddNetworkString("Armurier.AdminRemoveCategory")
util.AddNetworkString("Armurier.AdminAddItem")
util.AddNetworkString("Armurier.AdminRemoveItem")
util.AddNetworkString("Armurier.SetCaisseName")
util.AddNetworkString("Armurier.OpenMenu")
util.AddNetworkString("Armurier.CatalogUpdate")
util.AddNetworkString("Armurier.BuyResult")

-- ============================================================================
-- Helper : envoyer un résultat d'achat au client
-- ok   : bool
-- msg  : string (max 128)
-- ============================================================================

local function sendBuyResult(ply, ok, msg)
    if not IsValid(ply) then return end
    net.Start("Armurier.BuyResult")
        net.WriteBool(ok)
        net.WriteString(string.sub(tostring(msg), 1, 128))
    net.Send(ply)
end

-- ============================================================================
-- Helper : trouver une caisse armurier_caisse proche du joueur
-- Retourne l'entité la plus proche dans MaxDistance, ou nil.
-- ============================================================================

local function findNearbyBox(ply)
    local maxDist = (Armurier.Config and Armurier.Config.MaxDistance) or 200
    local maxSqr  = maxDist * maxDist
    local best    = nil
    local bestSqr = maxSqr + 1

    for _, ent in ipairs(ents.FindByClass("armurier_caisse")) do
        if IsValid(ent) then
            local d = ply:GetPos():DistToSqr(ent:GetPos())
            if d < bestSqr then
                -- Vérifier que la caisse appartient à l'armurier OU est libre
                local owner = ent:GetNW2Entity("armurier_owner")
                if not IsValid(owner) or owner == ply then
                    bestSqr = d
                    best    = ent
                end
            end
        end
    end

    return best
end

-- ============================================================================
-- Helper : spawner un shipment DarkRP (kind="weapon")
-- Utilise ents.Create("spawned_shipment") comme DarkRP le fait nativement.
-- ============================================================================

-- Trouve l'entrée CustomShipments (shipment DarkRP enregistré) pour une classe
-- d'arme donnée. Retourne (def, key) ou nil.
local function findShipmentByClass(weaponClass)
    if not CustomShipments then return nil, nil end
    for k, v in pairs(CustomShipments) do
        if v.entity == weaponClass then
            return v, k
        end
    end
    return nil, nil
end

-- ensureShipment — garantit qu'un shipment DarkRP existe pour l'arme du
-- catalogue. Si l'arme n'est pas déjà un shipment, on en enregistre un CACHÉ
-- (noship=true + separate=false + allowed={}) : utilisable par spawned_shipment
-- mais invisible dans le F4 (aucune pollution de l'économie).
-- Retourne (def, key) ou nil.
local function ensureShipment(item)
    local found, key = findShipmentByClass(item.class)
    if found then return found, key end

    if not DarkRP or not DarkRP.createShipment then return nil, nil end

    -- Model de l'arme (flottant sur la caisse) : catalogue → WorldModel → défaut
    local model = item.model
    if not model or model == "" then
        local swep = weapons.Get(item.class)
        model = (swep and swep.WorldModel) or ""
    end
    if model == "" then model = "models/weapons/w_pistol.mdl" end

    local price = math.max(1, math.floor(tonumber(item.price) or 1))

    local ok = pcall(DarkRP.createShipment, "armurier_" .. item.class, {
        model     = model,
        entity    = item.class,
        price     = price,
        amount    = 1,
        separate  = false,
        pricesep  = price,
        noship    = true,   -- caché de l'onglet Shipments du F4
        allowed   = {},     -- aucun job autorisé (sécurité supplémentaire)
        shipmodel = "models/Items/item_item_crate.mdl",
        category  = "Armurier",
    })
    if not ok then
        ErrorNoHalt("[Armurier] createShipment a échoué pour " .. tostring(item.class) .. "\n")
        return nil, nil
    end

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Shipment caché auto-créé pour '%s'.", item.class))
    end

    return findShipmentByClass(item.class)
end

local function spawnShipment(ply, caisse, item, amount)
    local spawnPos = caisse:GetPos() + caisse:GetForward() * 60 + Vector(0, 0, 10)
    local spawnAng = Angle(0, caisse:GetAngles().y, 0)

    -- DarkRP ne sait spawner un shipment que pour une arme enregistrée dans
    -- CustomShipments. On garantit l'enregistrement (auto-création cachée si
    -- besoin). SetContents prend l'INDEX du shipment + le nombre, AVANT Spawn().
    local found, foundKey = ensureShipment(item)

    if found and foundKey then
        local crate = ents.Create(found.shipmentClass or "spawned_shipment")
        if not IsValid(crate) then
            ErrorNoHalt("[Armurier] Impossible de créer le shipment pour " .. item.class .. "\n")
            return false
        end

        crate.SID = ply.SID
        if crate.Setowning_ent then crate:Setowning_ent(ply) end
        -- INDEX + count AVANT Spawn (lus dans Initialize)
        crate:SetContents(foundKey, amount or found.amount or 1)
        crate:SetPos(spawnPos)
        crate:SetAngles(spawnAng)
        crate.nodupe  = true
        crate.ammoadd = found.spareammo
        crate.clip1   = found.clip1
        crate.clip2   = found.clip2
        crate:Spawn()
        if crate.SetPlayer then crate:SetPlayer(ply) end

        local phys = crate:GetPhysicsObject()
        if IsValid(phys) then phys:Wake() end
        return true
    end

    -- Ultime fallback (createShipment a échoué) : SWEP seul au sol.
    local wep = ents.Create(item.class)
    if not IsValid(wep) then
        ErrorNoHalt("[Armurier] Impossible de créer l'arme " .. tostring(item.class) .. "\n")
        return false
    end
    wep:SetPos(spawnPos)
    wep:SetAngles(spawnAng)
    wep:Spawn()
    wep:Activate()

    local phys = wep:GetPhysicsObject()
    if IsValid(phys) then phys:Wake() end
    return true
end

-- ============================================================================
-- Helper : spawner une entité simple (kind="ammo" ou "accessory")
-- ============================================================================

local function spawnEntity(caisse, class)
    local spawnPos = caisse:GetPos() + caisse:GetForward() * 60 + Vector(0, 0, 10)

    local ent = ents.Create(class)
    if not IsValid(ent) then
        ErrorNoHalt("[Armurier] Impossible de créer l'entité " .. class .. "\n")
        return false
    end

    ent:SetPos(spawnPos)
    ent:SetAngles(Angle(0, caisse:GetAngles().y, 0))
    ent:Spawn()
    ent:Activate()

    local phys = ent:GetPhysicsObject()
    if IsValid(phys) then
        phys:Wake()
    end

    return true
end

-- Trouve une munition DarkRP (GAMEMODE.AmmoTypes) par son ammoType. Retourne la def ou nil.
local function findAmmoType(ammoType)
    local gm = GAMEMODE or GM
    if not gm or not gm.AmmoTypes then return nil end
    for _, a in pairs(gm.AmmoTypes) do
        if a.ammoType == ammoType then return a end
    end
    return nil
end

-- Spawn d'une boîte de munition DarkRP (entité spawned_ammo) devant la caisse.
local function spawnAmmo(caisse, ammoType)
    local found = findAmmoType(ammoType)
    if not found then
        ErrorNoHalt("[Armurier] Munition inconnue : " .. tostring(ammoType) .. "\n")
        return false
    end

    local spawnPos = caisse:GetPos() + caisse:GetForward() * 60 + Vector(0, 0, 10)

    local ammo = ents.Create("spawned_ammo")
    if not IsValid(ammo) then
        ErrorNoHalt("[Armurier] Impossible de créer spawned_ammo pour " .. tostring(ammoType) .. "\n")
        return false
    end

    ammo:SetModel(found.model)
    ammo:SetPos(spawnPos)
    ammo:SetAngles(Angle(0, caisse:GetAngles().y, 0))
    ammo.nodupe = true
    -- amountGiven + ammoType définis AVANT Spawn (lus dans le Use de spawned_ammo)
    ammo.amountGiven, ammo.ammoType = found.amountGiven, found.ammoType
    ammo:Spawn()
    ammo:Activate()

    local phys = ammo:GetPhysicsObject()
    if IsValid(phys) then phys:Wake() end

    return true
end

-- ============================================================================
-- C→S : Armurier.RequestOpenMenu
-- Payload : Entity caisse (16 bits index)
-- Le serveur vérifie job + distance puis envoie OpenMenu au client.
-- ============================================================================

net.Receive("Armurier.RequestOpenMenu", function(len, ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end

    -- Anti-spam
    if (ply.armurierNextMenu or 0) + (Armurier.Config.RateMenu or 1.0) > CurTime() then return end
    ply.armurierNextMenu = CurTime()

    -- Lecture entité caisse (index 13 bits = jusqu'à 8191 entités)
    local caisse = net.ReadEntity()
    if not IsValid(caisse) or caisse:GetClass() ~= "armurier_caisse" then return end

    -- Vérification job Armurier
    if not TEAM_ARMURIER or ply:Team() ~= TEAM_ARMURIER then
        DarkRP.notify(ply, 1, 4, "Vous devez être Armurier pour utiliser cette caisse.")
        return
    end

    -- Vérification distance serveur
    local maxDist = (Armurier.Config and Armurier.Config.MaxDistance) or 200
    if ply:GetPos():DistToSqr(caisse:GetPos()) > (maxDist * maxDist) then
        DarkRP.notify(ply, 1, 4, "Vous êtes trop loin de la caisse.")
        return
    end

    -- Envoyer le signal d'ouverture menu
    net.Start("Armurier.OpenMenu")
        net.WriteEntity(caisse)
    net.Send(ply)
end)

-- ============================================================================
-- Helper : calcule la réduction de prix (%) pour un joueur selon son niveau
-- légal dbalevel. Soft-dépendance : retourne 0 si dbalevel est absent.
-- ============================================================================

local function getDiscount(ply)
    if not (DBALevel and DBALevel.GetLevel) then return 0 end
    local lvl = DBALevel.GetLevel(ply, "legal") or 0
    local cfg  = Armurier.Config or {}
    local tierLevels = math.max(1, math.floor(cfg.DiscountTierLevels or 5))
    local perTier    = math.floor(cfg.DiscountPerTier or 2)
    local maxDisc    = math.floor(cfg.DiscountMax or 30)
    return math.min(maxDisc, math.floor(lvl / tierLevels) * perTier)
end

-- ============================================================================
-- C→S : Armurier.BuyItem
-- Payload : Entity caisse + UInt(16) item_id
-- ============================================================================

net.Receive("Armurier.BuyItem", function(len, ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end

    -- Anti-spam
    if (ply.armurierNextBuy or 0) + (Armurier.Config.RateBuy or 1.5) > CurTime() then
        sendBuyResult(ply, false, "Action trop rapide.")
        return
    end
    ply.armurierNextBuy = CurTime()

    local caisse  = net.ReadEntity()
    local item_id = net.ReadUInt(16)

    -- Validation caisse
    if not IsValid(caisse) or caisse:GetClass() ~= "armurier_caisse" then
        sendBuyResult(ply, false, "Caisse invalide.")
        return
    end

    -- Vérification job
    if not TEAM_ARMURIER or ply:Team() ~= TEAM_ARMURIER then
        sendBuyResult(ply, false, "Vous devez être Armurier.")
        return
    end

    -- Distance serveur
    local maxDist = (Armurier.Config and Armurier.Config.MaxDistance) or 200
    if ply:GetPos():DistToSqr(caisse:GetPos()) > (maxDist * maxDist) then
        sendBuyResult(ply, false, "Trop loin de la caisse.")
        return
    end

    -- Validation item dans le catalogue
    item_id = math.floor(tonumber(item_id) or 0)
    local item = Armurier._items[item_id]
    if not item then
        sendBuyResult(ply, false, "Item introuvable au catalogue.")
        return
    end

    -- Vérification stock (fonctionnalité A)
    local curStock = math.floor(tonumber(item.stock) or 0)
    if curStock <= 0 then
        sendBuyResult(ply, false, "Rupture de stock.")
        return
    end

    -- Calcul du prix avec réduction (armes uniquement — fonctionnalité C)
    local basePrice = item.price
    local paid      = basePrice
    if item.kind == "weapon" then
        local discount = getDiscount(ply)
        if discount > 0 then
            paid = math.floor(basePrice * ((100 - discount) / 100))
        end
    end

    -- Vérification du solde (atomicité)
    if not ply:canAfford(paid) then
        local fmt = (DBALib and DBALib.Format and DBALib.Format.Money(paid)) or (tostring(paid) .. " €")
        sendBuyResult(ply, false, "Fonds insuffisants (" .. fmt .. " requis).")
        return
    end

    -- Débiter AVANT de spawner
    ply:addMoney(-paid)

    -- Spawn selon le kind
    local ok = false
    if item.kind == "weapon" then
        local amount = (Armurier.Config and Armurier.Config.ShipmentAmount) or 1
        ok = spawnShipment(ply, caisse, item, amount)
    elseif item.kind == "ammo" then
        -- munition DarkRP : spawned_ammo
        ok = spawnAmmo(caisse, item.class)
    else
        -- accessory : entité simple
        ok = spawnEntity(caisse, item.class)
    end

    if not ok then
        -- Rembourser si spawn échoue
        ply:addMoney(paid)
        sendBuyResult(ply, false, "Erreur lors du spawn de l'item. Remboursé.")
        return
    end

    -- Décrémenter le stock (fonctionnalité A)
    Armurier.Storage.UpdateItemStock(item_id, -1)
    -- Notifier le client du nouveau catalogue (stock mis à jour)
    Armurier.Storage.SendCatalogToPlayer(ply)

    -- XP dbalevel pour les armes (fonctionnalité B — soft-dépendance)
    if item.kind == "weapon" and basePrice > 0 then
        if DBALevel and DBALevel.AddXP then
            local xpPerMoney = math.max(1, math.floor((Armurier.Config and Armurier.Config.XPPerMoney) or 100))
            local xpGain     = math.max(1, math.floor(basePrice / xpPerMoney))
            DBALevel.AddXP(ply, "legal", xpGain, "armurier:buy")
        end
    end

    sendBuyResult(ply, true, "Achat effectué : " .. item.class)

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format(
            "[Armurier] Achat : %s | item=%s (#%d) | prix=%d (base=%d)",
            ply:Nick(), item.class, item_id, paid, basePrice
        ))
    end
end)

-- ============================================================================
-- Helper : trouve une arme droppée (spawned_weapon) d'une classe donnée à
-- portée de la caisse (SellRadius). Retourne l'entité ou nil.
-- ============================================================================

local function findDroppedWeaponNear(caisse, weaponClass)
    if not IsValid(caisse) then return nil end
    local radius    = (Armurier.Config and Armurier.Config.SellRadius) or 150
    local radiusSqr = radius * radius
    for _, e in ipairs(ents.FindInSphere(caisse:GetPos(), radius)) do
        if IsValid(e) and e.IsSpawnedWeapon and e.GetWeaponClass then
            if e:GetWeaponClass() == weaponClass
               and e:GetPos():DistToSqr(caisse:GetPos()) <= radiusSqr then
                return e
            end
        end
    end
    return nil
end

-- ============================================================================
-- C→S : Armurier.SellItem
-- Payload : Entity caisse + UInt(16) item_id
-- Vend une arme droppée (spawned_weapon) à proximité → 70% du prix catalogue.
-- ============================================================================

net.Receive("Armurier.SellItem", function(len, ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end

    -- Anti-spam
    if (ply.armurierNextSellback or 0) + (Armurier.Config.RateSellback or 2.0) > CurTime() then
        sendBuyResult(ply, false, "Action trop rapide.")
        return
    end
    ply.armurierNextSellback = CurTime()

    local caisse  = net.ReadEntity()
    local item_id = math.floor(tonumber(net.ReadUInt(16)) or 0)

    if not IsValid(caisse) or caisse:GetClass() ~= "armurier_caisse" then
        sendBuyResult(ply, false, "Caisse invalide.")
        return
    end
    if not TEAM_ARMURIER or ply:Team() ~= TEAM_ARMURIER then
        sendBuyResult(ply, false, "Vous devez être Armurier.")
        return
    end

    local maxDist = (Armurier.Config and Armurier.Config.MaxDistance) or 200
    if ply:GetPos():DistToSqr(caisse:GetPos()) > (maxDist * maxDist) then
        sendBuyResult(ply, false, "Trop loin de la caisse.")
        return
    end

    local item = Armurier._items[item_id]
    if not item then
        sendBuyResult(ply, false, "Item introuvable au catalogue.")
        return
    end
    if item.kind ~= "weapon" then
        sendBuyResult(ply, false, "Seules les armes sont revendables.")
        return
    end

    -- Chercher une arme droppée de cette classe à portée
    local dropped = findDroppedWeaponNear(caisse, item.class)
    if not IsValid(dropped) then
        sendBuyResult(ply, false, "Aucune arme de ce type droppée près de la caisse.")
        return
    end

    local ratio     = (Armurier.Config and Armurier.Config.SellbackRatio) or 0.70
    local sellPrice = math.floor(item.price * ratio)

    -- Vente à l'UNITÉ : crédit du prix d'UNE arme, +1 au stock, et on décrémente
    -- le stack (l'entité ne disparaît qu'une fois son amount tombé à 0).
    ply:addMoney(sellPrice)
    Armurier.Storage.UpdateItemStock(item_id, 1)

    local remaining = 0
    if dropped.DecreaseAmount and dropped.Getamount then
        dropped:DecreaseAmount()  -- -1 ; se retire automatiquement si amount <= 0
        remaining = IsValid(dropped) and (dropped:Getamount() or 0) or 0
        if remaining < 0 then remaining = 0 end
    else
        dropped:Remove()
    end

    local msg = "Revente : " .. item.class .. " → +" .. sellPrice .. " €"
    if remaining > 0 then
        msg = msg .. " (reste " .. remaining .. ")"
    end
    sendBuyResult(ply, true, msg)

    -- Rafraîchir le menu du joueur (le bouton repasse en "Acheter")
    Armurier.Storage.SendCatalogToPlayer(ply)

    hook.Run("Armurier.OnSell", ply, item.class, 1)

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Revente : %s | arme=%s | +%d €", ply:Nick(), item.class, sellPrice))
    end
end)

-- ============================================================================
-- C→S : Armurier.AdminAddCategory
-- Payload : String page (16) + String name (64)
-- Admin uniquement.
-- ============================================================================

net.Receive("Armurier.AdminAddCategory", function(len, ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end
    if not ply:IsAdmin() then
        DarkRP.notify(ply, 4, 4, "Permission refusée.")
        return
    end

    -- Anti-spam admin
    if (ply.armurierNextAdminWrite or 0) + (Armurier.Config.RateAdminWrite or 2.0) > CurTime() then return end
    ply.armurierNextAdminWrite = CurTime()

    local page = string.sub(net.ReadString(), 1, 16)
    local name = string.sub(net.ReadString(), 1, 64)

    -- Validation page
    local validPages = { weapon = true, ammo = true, accessory = true }
    if not validPages[page] then
        sendBuyResult(ply, false, "Page invalide.")
        return
    end

    -- Nom non vide
    name = name:match("^%s*(.-)%s*$")  -- trim
    if name == "" then
        sendBuyResult(ply, false, "Nom de catégorie vide.")
        return
    end

    local id = Armurier.Storage.AddCategory(page, name)
    if id then
        sendBuyResult(ply, true, "Catégorie créée (#" .. id .. ").")
    else
        sendBuyResult(ply, false, "Erreur lors de la création de catégorie.")
    end
end)

-- ============================================================================
-- C→S : Armurier.AdminRemoveCategory
-- Payload : UInt(16) category_id
-- ============================================================================

net.Receive("Armurier.AdminRemoveCategory", function(len, ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end
    if not ply:IsAdmin() then
        DarkRP.notify(ply, 4, 4, "Permission refusée.")
        return
    end

    if (ply.armurierNextAdminWrite or 0) + (Armurier.Config.RateAdminWrite or 2.0) > CurTime() then return end
    ply.armurierNextAdminWrite = CurTime()

    local cat_id = net.ReadUInt(16)
    cat_id = math.floor(tonumber(cat_id) or 0)

    if cat_id <= 0 then
        sendBuyResult(ply, false, "ID catégorie invalide.")
        return
    end

    local ok = Armurier.Storage.RemoveCategory(cat_id)
    if ok then
        sendBuyResult(ply, true, "Catégorie #" .. cat_id .. " supprimée.")
    else
        sendBuyResult(ply, false, "Erreur lors de la suppression.")
    end
end)

-- ============================================================================
-- C→S : Armurier.AdminAddItem
-- Payload : String kind(16) + String page(16) + UInt(16) cat_id +
--           String class(64) + String model(255) + UInt(24) price
-- ============================================================================

net.Receive("Armurier.AdminAddItem", function(len, ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end
    if not ply:IsAdmin() then
        DarkRP.notify(ply, 4, 4, "Permission refusée.")
        return
    end

    if (ply.armurierNextAdminWrite or 0) + (Armurier.Config.RateAdminWrite or 2.0) > CurTime() then return end
    ply.armurierNextAdminWrite = CurTime()

    local kind       = string.sub(net.ReadString(), 1, 16)
    local page       = string.sub(net.ReadString(), 1, 16)
    local cat_id     = net.ReadUInt(16)
    local class      = string.sub(net.ReadString(), 1, 64)
    local model      = string.sub(net.ReadString(), 1, 255)
    local price      = net.ReadUInt(24)  -- max ~16M

    -- Validation class (regex serveur — anti-injection)
    if not class:match("^[%w_]+$") then
        sendBuyResult(ply, false, "Classe invalide (caractères non autorisés).")
        return
    end

    -- Validation existence de la classe
    local validPages = { weapon = true, ammo = true, accessory = true }
    if not validPages[kind] or not validPages[page] then
        sendBuyResult(ply, false, "Kind ou page invalide.")
        return
    end

    if kind == "weapon" then
        if not weapons.Get(class) then
            sendBuyResult(ply, false, "Arme inconnue sur ce serveur : " .. class)
            return
        end
    elseif kind == "ammo" then
        if not findAmmoType(class) then
            sendBuyResult(ply, false, "Munition inconnue sur ce serveur : " .. class)
            return
        end
    else
        if not scripted_ents.Get(class) then
            sendBuyResult(ply, false, "Entité inconnue sur ce serveur : " .. class)
            return
        end
    end

    price = math.floor(tonumber(price) or 0)
    if price <= 0 then
        sendBuyResult(ply, false, "Prix invalide.")
        return
    end

    local id = Armurier.Storage.AddItem(kind, page, cat_id, class, model, price)
    if id then
        sendBuyResult(ply, true, "Item ajouté (#" .. id .. ").")
    else
        sendBuyResult(ply, false, "Erreur lors de l'ajout de l'item.")
    end
end)

-- ============================================================================
-- C→S : Armurier.AdminRemoveItem
-- Payload : UInt(16) item_id
-- ============================================================================

net.Receive("Armurier.AdminRemoveItem", function(len, ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end
    if not ply:IsAdmin() then
        DarkRP.notify(ply, 4, 4, "Permission refusée.")
        return
    end

    if (ply.armurierNextAdminWrite or 0) + (Armurier.Config.RateAdminWrite or 2.0) > CurTime() then return end
    ply.armurierNextAdminWrite = CurTime()

    local item_id = net.ReadUInt(16)
    item_id = math.floor(tonumber(item_id) or 0)

    if item_id <= 0 then
        sendBuyResult(ply, false, "ID item invalide.")
        return
    end

    local ok = Armurier.Storage.RemoveItem(item_id)
    if ok then
        sendBuyResult(ply, true, "Item #" .. item_id .. " supprimé.")
    else
        sendBuyResult(ply, false, "Erreur lors de la suppression.")
    end
end)

-- ============================================================================
-- C→S : Armurier.SetCaisseName
-- Payload : Entity caisse + String name
-- Autorisé : Armurier (TEAM_ARMURIER) OU Admin.
-- ============================================================================

net.Receive("Armurier.SetCaisseName", function(len, ply)
    if not IsValid(ply) or not ply:IsPlayer() then return end

    -- Permission : armurier OU admin
    local isArmurier = TEAM_ARMURIER and ply:Team() == TEAM_ARMURIER
    if not isArmurier and not ply:IsAdmin() then
        DarkRP.notify(ply, 4, 4, "Permission refusée.")
        return
    end

    -- Rate-limit spécifique au renommage
    local rate = (Armurier.Config and Armurier.Config.RateRename) or 3.0
    if (ply.armurierNextRename or 0) + rate > CurTime() then
        DarkRP.notify(ply, 1, 3, "Action trop rapide.")
        return
    end
    ply.armurierNextRename = CurTime()

    -- Lecture sécurisée
    local caisse = net.ReadEntity()
    local name   = net.ReadString()

    -- Validation caisse
    if not IsValid(caisse) or caisse:GetClass() ~= "armurier_caisse" then
        DarkRP.notify(ply, 4, 4, "Caisse invalide.")
        return
    end

    -- Distance (même contrainte que l'ouverture du menu)
    local maxDist = (Armurier.Config and Armurier.Config.MaxDistance) or 200
    if ply:GetPos():DistToSqr(caisse:GetPos()) > (maxDist * maxDist) then
        DarkRP.notify(ply, 1, 4, "Vous êtes trop loin de la caisse.")
        return
    end

    -- Sanitize du nom : borner, trim, retirer les caractères de contrôle
    local maxLen = math.floor((Armurier.Config and Armurier.Config.ShopNameMaxLen) or 32)
    name = string.sub(tostring(name), 1, maxLen)
    name = name:match("^%s*(.-)%s*$") or ""   -- trim espaces
    name = name:gsub("[%c]", "")               -- retirer caractères de contrôle

    -- Nom vide autorisé (= réinitialise au défaut visuel)

    -- Persister en DB
    local dbId = caisse.armurierDBId
    if dbId and dbId > 0 then
        Armurier.Storage.SetCaisseName(dbId, name)
    end

    -- Mettre à jour la NW2 var : répliquée instantanément à tous les clients
    caisse:SetNW2String("armurier_name", name)

    local displayName = (name ~= "") and name
                        or ((Armurier.Config and Armurier.Config.DefaultShopName) or "Armurerie")
    DarkRP.notify(ply, 0, 4, "Boutique renommée : " .. displayName)

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Caisse #%d renommée par %s : '%s'",
            dbId or -1, ply:Nick(), name))
    end
end)
