-- armurier/sv_storage.lua
-- Persistance SQLite du catalogue d'armurier.
-- API : Init, AddCategory, RemoveCategory, LoadCategories,
--       AddItem, RemoveItem, LoadItems, ReloadCache, BroadcastCatalog,
--       UpdateItemStock, RegenStock
--
-- Règles SQL :
--   • sql.SQLStr(s) (avec guillemets) pour les strings
--   • math.floor(tonumber(x) or 0) pour les entiers
--   • JAMAIS de concaténation brute avec un input utilisateur

Armurier         = Armurier         or {}
Armurier.Storage = Armurier.Storage or {}

-- ============================================================================
-- Initialisation du schéma
-- ============================================================================

function Armurier.Storage.Init()
    -- Table catégories
    local qCat = [[
        CREATE TABLE IF NOT EXISTS armurier_categories (
            id   INTEGER PRIMARY KEY AUTOINCREMENT,
            page TEXT    NOT NULL,
            name TEXT    NOT NULL
        )
    ]]
    if sql.Query(qCat) == false then
        ErrorNoHalt("[Armurier] SQL CREATE armurier_categories : " .. sql.LastError() .. "\n")
        return
    end

    -- Table items (avec colonne stock pour bases neuves)
    local qItems = [[
        CREATE TABLE IF NOT EXISTS armurier_items (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            kind        TEXT    NOT NULL,
            page        TEXT    NOT NULL,
            category_id INTEGER NOT NULL,
            class       TEXT    NOT NULL,
            model       TEXT    NOT NULL DEFAULT '',
            price       INTEGER NOT NULL DEFAULT 0,
            stock       INTEGER NOT NULL DEFAULT 10
        )
    ]]
    if sql.Query(qItems) == false then
        ErrorNoHalt("[Armurier] SQL CREATE armurier_items : " .. sql.LastError() .. "\n")
        return
    end

    -- Index sur category_id (requis pour les lookups par catégorie)
    sql.Query("CREATE INDEX IF NOT EXISTS idx_armurier_items_cat ON armurier_items(category_id)")
    -- Index sur class (requis pour le sellback lookup)
    sql.Query("CREATE INDEX IF NOT EXISTS idx_armurier_items_class ON armurier_items(class)")

    -- Table caisses : positions persistées par map (placées par un admin)
    local qCaisses = [[
        CREATE TABLE IF NOT EXISTS armurier_caisses (
            id   INTEGER PRIMARY KEY AUTOINCREMENT,
            map  TEXT NOT NULL,
            posx REAL NOT NULL,
            posy REAL NOT NULL,
            posz REAL NOT NULL,
            angp REAL NOT NULL,
            angy REAL NOT NULL,
            angr REAL NOT NULL,
            name TEXT NOT NULL DEFAULT ''
        )
    ]]
    if sql.Query(qCaisses) == false then
        ErrorNoHalt("[Armurier] SQL CREATE armurier_caisses : " .. sql.LastError() .. "\n")
        return
    end
    sql.Query("CREATE INDEX IF NOT EXISTS idx_armurier_caisses_map ON armurier_caisses(map)")

    -- Table meta : clés/valeurs persistées (stock_last_regen, etc.)
    local qMeta = [[
        CREATE TABLE IF NOT EXISTS armurier_meta (
            key   TEXT PRIMARY KEY,
            value TEXT NOT NULL
        )
    ]]
    if sql.Query(qMeta) == false then
        ErrorNoHalt("[Armurier] SQL CREATE armurier_meta : " .. sql.LastError() .. "\n")
        return
    end

    -- Migration robuste de la colonne `stock`.
    -- IMPORTANT : on NE se fie PAS à `PRAGMA user_version` — c'est une valeur
    -- GLOBALE à toute la base sv.db, partagée par TOUS les addons (dbalevel,
    -- laundromat, …). Un autre addon peut l'avoir déjà avancée, ce qui ferait
    -- sauter la migration et laisserait la colonne manquante (LoadItems/AddItem
    -- plantent alors sur "no such column: stock").
    -- À la place : on inspecte directement les colonnes de la table et on ajoute
    -- `stock` si absente. Idempotent et fiable à chaque démarrage.
    local function columnExists(tbl, col)
        local info = sql.Query("PRAGMA table_info(" .. tbl .. ")")
        if not info then return false end
        for _, c in ipairs(info) do
            if c.name == col then return true end
        end
        return false
    end

    if not columnExists("armurier_items", "stock") then
        if sql.Query("ALTER TABLE armurier_items ADD COLUMN stock INTEGER NOT NULL DEFAULT 10") == false then
            ErrorNoHalt("[Armurier] ALTER ADD COLUMN stock a échoué : " .. (sql.LastError() or "?") .. "\n")
        elseif Armurier.Config and Armurier.Config.Logs then
            print("[Armurier] Colonne 'stock' ajoutée à armurier_items (migration).")
        end
    end

    -- Migration robuste de la colonne `name` sur armurier_caisses.
    -- Même principe que pour `stock` : introspection directe, pas PRAGMA user_version.
    if not columnExists("armurier_caisses", "name") then
        if sql.Query("ALTER TABLE armurier_caisses ADD COLUMN name TEXT NOT NULL DEFAULT ''") == false then
            ErrorNoHalt("[Armurier] ALTER ADD COLUMN name a échoué : " .. (sql.LastError() or "?") .. "\n")
        elseif Armurier.Config and Armurier.Config.Logs then
            print("[Armurier] Colonne 'name' ajoutée à armurier_caisses (migration).")
        end
    end

    -- Initialiser stock_last_regen si absent
    local lastRegen = sql.QueryValue("SELECT value FROM armurier_meta WHERE key = 'stock_last_regen'")
    if not lastRegen then
        local nowStr = tostring(os.time())
        sql.Query(string.format(
            "INSERT INTO armurier_meta (key, value) VALUES ('stock_last_regen', %s)",
            sql.SQLStr(nowStr)
        ))
    end

    -- Charger le cache en mémoire
    Armurier.Storage.ReloadCache()

    -- Lancer le timer de régénération de stock
    Armurier.Storage.StartRegenTimer()

    -- Régen au boot (rattrapage si le serveur était éteint)
    Armurier.Storage.RegenStock()

    if Armurier.Config and Armurier.Config.Logs then
        print("[Armurier] Schéma SQLite initialisé.")
    end
end

-- ============================================================================
-- LoadCategories — retourne table { [id] = {id, page, name} }
-- ============================================================================

function Armurier.Storage.LoadCategories()
    local rows = sql.Query("SELECT id, page, name FROM armurier_categories ORDER BY id")
    if rows == false then
        ErrorNoHalt("[Armurier] LoadCategories erreur SQL : " .. sql.LastError() .. "\n")
        return {}
    end
    if not rows then return {} end

    local result = {}
    for _, row in ipairs(rows) do
        local id = tonumber(row.id)
        result[id] = {
            id   = id,
            page = row.page,
            name = row.name,
        }
    end
    return result
end

-- ============================================================================
-- LoadItems — retourne table { [id] = {id, kind, page, category_id, class, model, price} }
-- ============================================================================

function Armurier.Storage.LoadItems()
    local rows = sql.Query("SELECT id, kind, page, category_id, class, model, price, stock FROM armurier_items ORDER BY id")
    if rows == false then
        ErrorNoHalt("[Armurier] LoadItems erreur SQL : " .. sql.LastError() .. "\n")
        return {}
    end
    if not rows then return {} end

    local stockMax = (Armurier.Config and Armurier.Config.StockMax) or 10
    local result = {}
    for _, row in ipairs(rows) do
        local id = tonumber(row.id)
        result[id] = {
            id          = id,
            kind        = row.kind,
            page        = row.page,
            category_id = tonumber(row.category_id) or 0,
            class       = row.class,
            model       = row.model or "",
            price       = math.floor(tonumber(row.price) or 0),
            stock       = math.max(0, math.min(stockMax, math.floor(tonumber(row.stock) or stockMax))),
        }
    end
    return result
end

-- ============================================================================
-- ReloadCache — recharge _categories et _items depuis la DB
-- ============================================================================

function Armurier.Storage.ReloadCache()
    Armurier._categories = Armurier.Storage.LoadCategories()
    Armurier._items      = Armurier.Storage.LoadItems()
end

-- ============================================================================
-- BroadcastCatalog — envoie le catalogue complet à tous les clients connectés
-- Appelé après chaque write (AddCategory, RemoveCategory, AddItem, RemoveItem).
-- ============================================================================

function Armurier.Storage.BroadcastCatalog()
    -- Sérialiser les categories en liste
    local cats  = {}
    for id, cat in pairs(Armurier._categories) do
        cats[#cats + 1] = cat
    end

    -- Sérialiser les items en liste
    local items = {}
    for id, item in pairs(Armurier._items) do
        items[#items + 1] = item
    end

    -- Encodage JSON compact
    local payload = util.TableToJSON({ categories = cats, items = items })

    -- Limiter à 64Ko (valeur max net message GMod ~65535 octets)
    if #payload > 60000 then
        ErrorNoHalt("[Armurier] BroadcastCatalog : payload trop grand (" .. #payload .. " octets). Catalogue tronqué.\n")
    end

    net.Start("Armurier.CatalogUpdate")
        net.WriteString(payload)
    net.Broadcast()
end

-- ============================================================================
-- SendCatalogToPlayer — envoie le catalogue complet à un joueur spécifique
-- ============================================================================

function Armurier.Storage.SendCatalogToPlayer(ply)
    if not IsValid(ply) then return end

    local cats = {}
    for id, cat in pairs(Armurier._categories) do
        cats[#cats + 1] = cat
    end
    local items = {}
    for id, item in pairs(Armurier._items) do
        items[#items + 1] = item
    end

    local payload = util.TableToJSON({ categories = cats, items = items })

    net.Start("Armurier.CatalogUpdate")
        net.WriteString(payload)
    net.Send(ply)
end

-- ============================================================================
-- AddCategory — insère une catégorie, recharge le cache, broadcast
-- page : "weapon" | "ammo" | "accessory"
-- name : string (max 64)
-- Retourne id (int) ou nil sur erreur.
-- ============================================================================

local VALID_PAGES = { weapon = true, ammo = true, accessory = true }

function Armurier.Storage.AddCategory(page, name)
    -- Validation
    if not VALID_PAGES[page] then
        ErrorNoHalt("[Armurier] AddCategory : page invalide : " .. tostring(page) .. "\n")
        return nil
    end
    name = string.sub(tostring(name or ""), 1, 64)
    if name == "" then
        ErrorNoHalt("[Armurier] AddCategory : nom vide.\n")
        return nil
    end

    local q = string.format(
        "INSERT INTO armurier_categories (page, name) VALUES (%s, %s)",
        sql.SQLStr(page),
        sql.SQLStr(name)
    )
    if sql.Query(q) == false then
        ErrorNoHalt("[Armurier] AddCategory erreur SQL : " .. sql.LastError() .. "\n")
        return nil
    end

    local newId = tonumber(sql.QueryValue("SELECT last_insert_rowid()"))

    Armurier.Storage.ReloadCache()
    Armurier.Storage.BroadcastCatalog()

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Catégorie ajoutée #%d page=%s name=%s", newId, page, name))
    end

    return newId
end

-- ============================================================================
-- RemoveCategory — supprime la catégorie ET ses items (cascade manuelle)
-- id : integer
-- Retourne bool.
-- ============================================================================

function Armurier.Storage.RemoveCategory(id)
    id = math.floor(tonumber(id) or 0)
    if id <= 0 then return false end

    -- Supprimer les items associés d'abord
    local qItems = string.format("DELETE FROM armurier_items WHERE category_id = %d", id)
    if sql.Query(qItems) == false then
        ErrorNoHalt("[Armurier] RemoveCategory (items) erreur SQL : " .. sql.LastError() .. "\n")
        return false
    end

    -- Supprimer la catégorie
    local qCat = string.format("DELETE FROM armurier_categories WHERE id = %d", id)
    if sql.Query(qCat) == false then
        ErrorNoHalt("[Armurier] RemoveCategory erreur SQL : " .. sql.LastError() .. "\n")
        return false
    end

    Armurier.Storage.ReloadCache()
    Armurier.Storage.BroadcastCatalog()

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Catégorie #%d supprimée (avec ses items).", id))
    end

    return true
end

-- ============================================================================
-- AddItem — insère un item dans le catalogue
-- kind        : "weapon" | "ammo" | "accessory"
-- page        : "weapon" | "ammo" | "accessory" (doit correspondre à kind)
-- category_id : integer (doit exister et appartenir à la même page)
-- class       : string (classe SWEP ou SENT validée)
-- model       : string (WorldModel, déduit si vide)
-- price       : integer > 0
-- Retourne id (int) ou nil sur erreur.
-- ============================================================================

local VALID_KINDS = { weapon = true, ammo = true, accessory = true }

function Armurier.Storage.AddItem(kind, page, category_id, class, model, price)
    -- Validation kind
    if not VALID_KINDS[kind] then
        ErrorNoHalt("[Armurier] AddItem : kind invalide : " .. tostring(kind) .. "\n")
        return nil
    end
    if not VALID_PAGES[page] then
        ErrorNoHalt("[Armurier] AddItem : page invalide : " .. tostring(page) .. "\n")
        return nil
    end

    -- Validation category_id : doit exister et être de la bonne page
    category_id = math.floor(tonumber(category_id) or 0)
    local cat = Armurier._categories[category_id]
    if not cat then
        ErrorNoHalt(string.format("[Armurier] AddItem : catégorie #%d inexistante.\n", category_id))
        return nil
    end
    if cat.page ~= page then
        ErrorNoHalt(string.format(
            "[Armurier] AddItem : catégorie #%d appartient à la page '%s', pas '%s'.\n",
            category_id, cat.page, page
        ))
        return nil
    end

    -- Validation class (alphanum + underscore uniquement, max 64)
    class = string.sub(tostring(class or ""), 1, 64)
    if not class:match("^[%w_]+$") then
        ErrorNoHalt("[Armurier] AddItem : class invalide : " .. tostring(class) .. "\n")
        return nil
    end

    -- Déduction automatique du model si vide
    model = tostring(model or "")
    if model == "" then
        if kind == "weapon" then
            local swep = weapons.Get(class)
            model = (swep and swep.WorldModel) or ""
        elseif kind == "ammo" then
            -- Munition DarkRP : model de la boîte dans GAMEMODE.AmmoTypes
            local gm = GAMEMODE or GM
            local ammoTypes = (gm and gm.AmmoTypes) or {}
            for _, a in pairs(ammoTypes) do
                if a.ammoType == class then
                    model = a.model or ""
                    break
                end
            end
        else
            local sent = scripted_ents.Get(class)
            model = (sent and sent.t and sent.t.Model) or ""
        end
    end
    model = string.sub(model, 1, 255)

    -- Validation price
    price = math.floor(tonumber(price) or 0)
    if price <= 0 then
        ErrorNoHalt("[Armurier] AddItem : prix invalide : " .. tostring(price) .. "\n")
        return nil
    end

    -- Insertion SQL (stock initialisé à StockMax)
    local stockMax = math.floor((Armurier.Config and Armurier.Config.StockMax) or 10)
    local q = string.format(
        "INSERT INTO armurier_items (kind, page, category_id, class, model, price, stock) VALUES (%s, %s, %d, %s, %s, %d, %d)",
        sql.SQLStr(kind),
        sql.SQLStr(page),
        category_id,
        sql.SQLStr(class),
        sql.SQLStr(model),
        price,
        stockMax
    )
    if sql.Query(q) == false then
        ErrorNoHalt("[Armurier] AddItem erreur SQL : " .. sql.LastError() .. "\n")
        return nil
    end

    local newId = tonumber(sql.QueryValue("SELECT last_insert_rowid()"))

    Armurier.Storage.ReloadCache()
    Armurier.Storage.BroadcastCatalog()

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format(
            "[Armurier] Item ajouté #%d kind=%s class=%s price=%d",
            newId, kind, class, price
        ))
    end

    return newId
end

-- ============================================================================
-- RemoveItem — supprime un item par id
-- ============================================================================

function Armurier.Storage.RemoveItem(id)
    id = math.floor(tonumber(id) or 0)
    if id <= 0 then return false end

    local q = string.format("DELETE FROM armurier_items WHERE id = %d", id)
    if sql.Query(q) == false then
        ErrorNoHalt("[Armurier] RemoveItem erreur SQL : " .. sql.LastError() .. "\n")
        return false
    end

    Armurier.Storage.ReloadCache()
    Armurier.Storage.BroadcastCatalog()

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Item #%d supprimé.", id))
    end

    return true
end

-- ============================================================================
-- GetItemByClass — lookup par classe dans le cache (utilisé par sellback)
-- Retourne item (table) ou nil.
-- ============================================================================

function Armurier.Storage.GetItemByClass(class)
    for _, item in pairs(Armurier._items) do
        if item.class == class then
            return item
        end
    end
    return nil
end

-- ============================================================================
-- UpdateItemStock — décrémente (ou modifie) le stock d'un item dans DB + cache.
-- delta : entier (négatif = consommation). Ne descend pas en-dessous de 0.
-- Retourne le nouveau stock (int).
-- ============================================================================

function Armurier.Storage.UpdateItemStock(itemId, delta)
    itemId = math.floor(tonumber(itemId) or 0)
    delta  = math.floor(tonumber(delta)  or 0)
    if itemId <= 0 then return 0 end

    local item = Armurier._items[itemId]
    if not item then return 0 end

    local stockMax  = math.floor((Armurier.Config and Armurier.Config.StockMax) or 10)
    local newStock  = math.max(0, math.min(stockMax, item.stock + delta))

    local q = string.format(
        "UPDATE armurier_items SET stock = %d WHERE id = %d",
        newStock, itemId
    )
    if sql.Query(q) == false then
        ErrorNoHalt("[Armurier] UpdateItemStock erreur SQL : " .. sql.LastError() .. "\n")
        return item.stock
    end

    -- Mettre à jour le cache en mémoire
    item.stock = newStock
    return newStock
end

-- ============================================================================
-- RegenStock — régénération périodique du stock selon les heures écoulées.
-- Lit stock_last_regen, calcule le nombre d'heures entières écoulées, applique
-- l'UPDATE global, met à jour le timestamp et recharge le cache.
-- Appelée au boot ET par un timer périodique.
-- ============================================================================

function Armurier.Storage.RegenStock()
    local lastStr = sql.QueryValue("SELECT value FROM armurier_meta WHERE key = 'stock_last_regen'")
    local last    = math.floor(tonumber(lastStr) or os.time())
    local now     = os.time()
    local elapsed = now - last

    -- Nombre d'heures entières écoulées
    local elapsedH = math.floor(elapsed / 3600)
    if elapsedH < 1 then return end

    local regenPerHour = math.floor((Armurier.Config and Armurier.Config.StockRegenPerHour) or 1)
    local stockMax     = math.floor((Armurier.Config and Armurier.Config.StockMax)          or 10)
    local totalRegen   = elapsedH * regenPerHour

    -- UPDATE global : tous les items sous le max reçoivent totalRegen, plafonné à stockMax
    local qUpdate = string.format(
        "UPDATE armurier_items SET stock = MIN(%d, stock + %d) WHERE stock < %d",
        stockMax, totalRegen, stockMax
    )
    if sql.Query(qUpdate) == false then
        ErrorNoHalt("[Armurier] RegenStock erreur SQL : " .. sql.LastError() .. "\n")
        return
    end

    -- Avancer le timestamp de exactement elapsedH heures (pas "now") pour éviter
    -- la dérive si la vérification est légèrement décalée.
    local newLast = last + elapsedH * 3600
    local qMeta  = string.format(
        "UPDATE armurier_meta SET value = %s WHERE key = 'stock_last_regen'",
        sql.SQLStr(tostring(newLast))
    )
    sql.Query(qMeta)

    -- Recharger le cache + diffuser
    Armurier.Storage.ReloadCache()
    Armurier.Storage.BroadcastCatalog()

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format(
            "[Armurier] RegenStock : +%d stock (%d h × %d/h) — diffusé.",
            totalRegen, elapsedH, regenPerHour
        ))
    end
end

-- ============================================================================
-- StartRegenTimer — démarre le timer périodique de régénération.
-- Appelé une fois par Init(). Timer "Armurier.StockRegen" avec ID unique.
-- ============================================================================

function Armurier.Storage.StartRegenTimer()
    local interval = math.max(10, math.floor((Armurier.Config and Armurier.Config.StockRegenCheck) or 60))

    -- Supprimer le timer existant (hot-reload)
    if timer.Exists("Armurier.StockRegen") then
        timer.Remove("Armurier.StockRegen")
    end

    timer.Create("Armurier.StockRegen", interval, 0, function()
        Armurier.Storage.RegenStock()
    end)
end

-- ============================================================================
-- CAISSES — persistance des positions par map (placées par un admin)
-- ============================================================================

-- AddCaisse — enregistre une caisse pour la map courante. Retourne l'id (int) ou nil.
function Armurier.Storage.AddCaisse(map, pos, ang)
    if not isvector(pos) or not isangle(ang) then return nil end
    map = string.sub(tostring(map or game.GetMap()), 1, 64)

    local q = string.format(
        "INSERT INTO armurier_caisses (map, posx, posy, posz, angp, angy, angr) VALUES (%s, %f, %f, %f, %f, %f, %f)",
        sql.SQLStr(map),
        pos.x, pos.y, pos.z,
        ang.p, ang.y, ang.r
    )
    if sql.Query(q) == false then
        ErrorNoHalt("[Armurier] AddCaisse erreur SQL : " .. sql.LastError() .. "\n")
        return nil
    end

    local newId = tonumber(sql.QueryValue("SELECT last_insert_rowid()"))
    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Caisse enregistrée #%d sur %s", newId or -1, map))
    end
    return newId
end

-- UpdateCaissePos — met à jour la position/angle d'une caisse existante.
function Armurier.Storage.UpdateCaissePos(id, pos, ang)
    id = math.floor(tonumber(id) or 0)
    if id <= 0 or not isvector(pos) or not isangle(ang) then return false end

    local q = string.format(
        "UPDATE armurier_caisses SET posx=%f, posy=%f, posz=%f, angp=%f, angy=%f, angr=%f WHERE id=%d",
        pos.x, pos.y, pos.z, ang.p, ang.y, ang.r, id
    )
    if sql.Query(q) == false then
        ErrorNoHalt("[Armurier] UpdateCaissePos erreur SQL : " .. sql.LastError() .. "\n")
        return false
    end
    return true
end

-- RemoveCaisse — supprime une caisse de la DB par id. Retourne bool.
function Armurier.Storage.RemoveCaisse(id)
    id = math.floor(tonumber(id) or 0)
    if id <= 0 then return false end

    local q = string.format("DELETE FROM armurier_caisses WHERE id = %d", id)
    if sql.Query(q) == false then
        ErrorNoHalt("[Armurier] RemoveCaisse erreur SQL : " .. sql.LastError() .. "\n")
        return false
    end

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Caisse #%d supprimée de la DB.", id))
    end
    return true
end

-- LoadCaisses — retourne la liste des caisses { {id, pos, ang, name}, ... } pour une map.
function Armurier.Storage.LoadCaisses(map)
    map = string.sub(tostring(map or game.GetMap()), 1, 64)

    local q = string.format(
        "SELECT id, posx, posy, posz, angp, angy, angr, name FROM armurier_caisses WHERE map = %s ORDER BY id",
        sql.SQLStr(map)
    )
    local rows = sql.Query(q)
    if rows == false then
        ErrorNoHalt("[Armurier] LoadCaisses erreur SQL : " .. sql.LastError() .. "\n")
        return {}
    end
    if not rows then return {} end

    local result = {}
    for _, row in ipairs(rows) do
        result[#result + 1] = {
            id   = math.floor(tonumber(row.id) or 0),
            pos  = Vector(tonumber(row.posx) or 0, tonumber(row.posy) or 0, tonumber(row.posz) or 0),
            ang  = Angle(tonumber(row.angp) or 0, tonumber(row.angy) or 0, tonumber(row.angr) or 0),
            name = row.name or "",
        }
    end
    return result
end

-- SetCaisseName — met à jour le nom d'une caisse dans la DB. Retourne bool.
-- Le nom doit déjà être sanitizé par l'appelant ; cette fonction re-borne
-- par sécurité défensive.
function Armurier.Storage.SetCaisseName(id, name)
    id   = math.floor(tonumber(id) or 0)
    if id <= 0 then return false end

    -- Borne défensive (l'appelant doit déjà avoir tronqué)
    local maxLen = (Armurier.Config and Armurier.Config.ShopNameMaxLen) or 32
    name = string.sub(tostring(name or ""), 1, maxLen)

    local q = string.format(
        "UPDATE armurier_caisses SET name = %s WHERE id = %d",
        sql.SQLStr(name),
        id
    )
    if sql.Query(q) == false then
        ErrorNoHalt("[Armurier] SetCaisseName erreur SQL : " .. sql.LastError() .. "\n")
        return false
    end

    if Armurier.Config and Armurier.Config.Logs then
        print(string.format("[Armurier] Caisse #%d renommée : '%s'", id, name))
    end
    return true
end
