-- laundering/sv_core.lua
-- Coeur de la logique serveur : Execute, finishTransaction, cancelTransaction.
-- Toutes les opérations monétaires sont atomiques :
--   DirtyMoney.Take AVANT le timer, DirtyMoney.Add sur refund.

Laundering = Laundering or {}

-- ============================================================================
-- Helper : log conditionnel
-- ============================================================================

local function doLog(msg)
    if Laundering.Config.Logs then
        print("[Laundering] " .. msg)
    end
end

-- ============================================================================
-- cancelTransaction (interne + API publique)
-- sid    : steamID64 string
-- reason : "player_cancel"|"died"|"disconnected"|"distance_lost"|"entity_lost"|"shutdown"
-- ============================================================================

local function cancelTransaction(sid, reason)
    local tx = Laundering._transactions[sid]
    if not tx then return end

    -- Cleanup préventif (évite toute double-exécution)
    Laundering._transactions[sid] = nil

    -- Arrêter le timer si encore actif
    local timerID = "Laundering.Tx_" .. sid
    if timer.Exists(timerID) then
        timer.Destroy(timerID)
    end

    -- Refund DirtyMoney sauf pour disconnect (géré séparément avec flush)
    local ply = player.GetBySteamID64(sid)
    if reason ~= "disconnected" and IsValid(ply) and ply:IsPlayer() then
        local ok = DirtyMoney.Add(ply, tx.amount, "Laundering refund (" .. reason .. ")")
        if not ok then
            ErrorNoHalt(string.format(
                "[Laundering] CRITIQUE : refund DirtyMoney échoué pour %s (montant=%d, raison=%s). Argent perdu.\n",
                sid, tx.amount, reason
            ))
        end
    end

    -- Hook public
    hook.Run("Laundering.Cancelled", ply, tx.amount, reason, tx.methodId)

    -- Callback addon (optionnel)
    if tx.onCancel then
        local ok, err = pcall(tx.onCancel, ply, tx.amount, reason)
        if not ok then
            ErrorNoHalt("[Laundering] onCancel callback erreur : " .. tostring(err) .. "\n")
        end
    end

    -- Net → client
    if IsValid(ply) and ply:IsPlayer() then
        net.Start("Laundering.Cancelled")
            net.WriteUInt(tx.amount, 30)
            net.WriteString(string.sub(reason, 1, 32))
            net.WriteString(string.sub(tx.methodId, 1, 64))
        net.Send(ply)
    end

    doLog(string.format(
        "Cancelled ply=%s sid=%s amount=%d reason=%s method=%s",
        IsValid(ply) and ply:Nick() or "?",
        sid, tx.amount, reason, tx.methodId
    ))
end

-- Exposer cancelTransaction pour les hooks externes (PlayerDeath, PlayerDisconnected, ShutDown)
Laundering._cancelTransaction = cancelTransaction

-- ============================================================================
-- finishTransaction (interne — appelée par le timer 1-shot)
-- ============================================================================

local function finishTransaction(sid)
    local tx = Laundering._transactions[sid]
    if not tx then return end  -- déjà cancel

    local ply = player.GetBySteamID64(sid)

    -- Joueur invalide (déconnecté entre temps) → cleanup sans refund
    -- Le refund a été géré par PlayerDisconnected avant ce timer
    if not IsValid(ply) or not ply:IsPlayer() then
        Laundering._transactions[sid] = nil
        return
    end

    -- Vérification de proximité si requise
    if IsValid(tx.requiresProximity) then
        local ent = tx.requiresProximity
        if not IsValid(ent) then
            cancelTransaction(sid, "entity_lost")
            return
        end

        local maxDist = tonumber(tx.maxDistance) or Laundering.Config.MaxDistance
        if ply:GetPos():DistToSqr(ent:GetPos()) > (maxDist * maxDist) then
            cancelTransaction(sid, "distance_lost")
            return
        end
    elseif tx.requiresProximity ~= nil then
        -- Entité non nulle mais devenue invalide
        cancelTransaction(sid, "entity_lost")
        return
    end

    -- Cleanup avant l'opération monétaire (atomicité)
    Laundering._transactions[sid] = nil

    -- Calcul stochastique (tx.tiersOverride permet à un addon V2+ de forcer un autre ratio)
    local cleanOut, isPartial, tierUsed = Laundering.Compute(tx.amount, tx.tiersOverride)

    if cleanOut <= 0 then
        -- Tier non trouvé (config incohérente) → annuler avec refund
        local ok = DirtyMoney.Add(ply, tx.amount, "Laundering refund (no_tier)")
        if not ok then
            ErrorNoHalt(string.format(
                "[Laundering] CRITIQUE : refund DirtyMoney échoué pour %s (no_tier). Montant=%d.\n",
                sid, tx.amount
            ))
        end
        hook.Run("Laundering.Cancelled", ply, tx.amount, "no_tier", tx.methodId)
        if IsValid(ply) then
            net.Start("Laundering.Cancelled")
                net.WriteUInt(tx.amount, 30)
                net.WriteString("no_tier")
                net.WriteString(string.sub(tx.methodId, 1, 64))
            net.Send(ply)
        end
        return
    end

    -- Crédit DarkRP money (argent propre)
    ply:addMoney(cleanOut)

    -- Hook public
    hook.Run("Laundering.Completed", ply, tx.amount, cleanOut, isPartial, tx.methodId)

    -- Callback addon (optionnel)
    if tx.onComplete then
        local ok, err = pcall(tx.onComplete, ply, tx.amount, cleanOut, isPartial)
        if not ok then
            ErrorNoHalt("[Laundering] onComplete callback erreur : " .. tostring(err) .. "\n")
        end
    end

    -- Net → client
    net.Start("Laundering.Completed")
        net.WriteUInt(tx.amount, 30)
        net.WriteUInt(cleanOut, 30)
        net.WriteBool(isPartial)
        net.WriteString(string.sub(tx.methodId, 1, 64))
    net.Send(ply)

    -- Roll police alert (utilise le tier réellement appliqué)
    if tierUsed and (tierUsed.policeChance or 0) > 0 then
        if math.random() < tierUsed.policeChance then
            local alertPos = IsValid(tx.requiresProximity) and tx.requiresProximity:GetPos() or ply:GetPos()
            hook.Run("Laundering.PoliceAlert", ply, tx.amount, cleanOut, isPartial, tx.methodId, alertPos)
            doLog(string.format(
                "PoliceAlert ply=%s amount=%d method=%s pos=(%.0f,%.0f,%.0f)",
                ply:Nick(), tx.amount, tx.methodId, alertPos.x, alertPos.y, alertPos.z
            ))
        end
    end

    doLog(string.format(
        "Completed ply=%s dirty=%d clean=%d partial=%s method=%s",
        ply:Nick(), tx.amount, cleanOut,
        isPartial and "true" or "false",
        tx.methodId
    ))
end

-- ============================================================================
-- Laundering.Execute — point d'entrée public pour tous les addons
-- ============================================================================

-- opts = {
--   methodId          : string (default "unknown")
--   duration          : number (default Config.DefaultDuration)
--   requiresProximity : Entity|nil
--   maxDistance       : number (default Config.MaxDistance)
--   tiersOverride     : { tier, ... } | nil  -- surcharge Config.Tiers pour cette tx (ex: ratio fixe)
--   onComplete        : fn(ply, dirtyIn, cleanOut, isPartial) | nil
--   onCancel          : fn(ply, amount, reason) | nil
-- }
--
-- Retourne : success(bool), errorString(string|nil)

function Laundering.Execute(ply, amount, opts)
    -- -------------------------------------------------------------------------
    -- Garde-fous de base
    -- -------------------------------------------------------------------------
    if not IsValid(ply) or not ply:IsPlayer() then
        return false, "Joueur invalide."
    end

    opts = opts or {}

    amount = math.floor(tonumber(amount) or 0)
    if amount <= 0 then
        return false, "Montant invalide (doit être > 0)."
    end
    if amount < Laundering.Config.MinAmount then
        return false, string.format("Montant trop faible (minimum %d).", Laundering.Config.MinAmount)
    end
    if amount > Laundering.Config.MaxAmount then
        return false, string.format("Montant trop élevé (maximum %d).", Laundering.Config.MaxAmount)
    end

    local sid = ply:SteamID64()
    if not sid or sid == "" then
        return false, "Impossible d'identifier le joueur."
    end

    -- -------------------------------------------------------------------------
    -- Anti-spam cooldown
    -- -------------------------------------------------------------------------
    if (Laundering._cooldowns[sid] or 0) > CurTime() then
        local remaining = math.ceil(Laundering._cooldowns[sid] - CurTime())
        return false, string.format("Cooldown actif (%ds restant(s)).", remaining)
    end

    -- -------------------------------------------------------------------------
    -- Transaction déjà en cours
    -- -------------------------------------------------------------------------
    if Laundering._transactions[sid] then
        return false, "Une transaction est déjà en cours."
    end

    -- -------------------------------------------------------------------------
    -- Paramètres optionnels normalisés
    -- -------------------------------------------------------------------------
    local methodId   = type(opts.methodId) == "string" and string.sub(opts.methodId, 1, 64) or "unknown"
    local duration   = tonumber(opts.duration) or Laundering.Config.DefaultDuration
    duration = math.max(0, duration)

    local requiresProximity = IsValid(opts.requiresProximity) and opts.requiresProximity or nil
    local maxDistance = tonumber(opts.maxDistance) or Laundering.Config.MaxDistance

    -- -------------------------------------------------------------------------
    -- Take DirtyMoney atomique (avant toute autre action)
    -- -------------------------------------------------------------------------
    local taken = DirtyMoney.Take(ply, amount, "Blanchiment (" .. methodId .. ")")
    if not taken then
        return false, "Solde d'argent sale insuffisant ou opération refusée."
    end

    -- -------------------------------------------------------------------------
    -- Enregistrement du cooldown
    -- -------------------------------------------------------------------------
    Laundering._cooldowns[sid] = CurTime() + Laundering.Config.Cooldown

    -- -------------------------------------------------------------------------
    -- Enregistrement de la transaction
    -- -------------------------------------------------------------------------
    Laundering._transactions[sid] = {
        amount             = amount,
        methodId           = methodId,
        startedAt          = CurTime(),
        endsAt             = CurTime() + duration,
        requiresProximity  = requiresProximity,
        maxDistance        = maxDistance,
        tiersOverride      = type(opts.tiersOverride) == "table" and opts.tiersOverride or nil,
        onComplete         = type(opts.onComplete) == "function" and opts.onComplete or nil,
        onCancel           = type(opts.onCancel) == "function" and opts.onCancel or nil,
    }

    -- -------------------------------------------------------------------------
    -- Hook public non-bloquant
    -- -------------------------------------------------------------------------
    hook.Run("Laundering.Started", ply, amount, methodId)

    -- -------------------------------------------------------------------------
    -- Timer 1-shot
    -- -------------------------------------------------------------------------
    local timerID = "Laundering.Tx_" .. sid
    if duration == 0 then
        -- Résolution immédiate (Config.DefaultDuration = 0)
        finishTransaction(sid)
    else
        timer.Create(timerID, duration, 1, function()
            finishTransaction(sid)
        end)
    end

    -- -------------------------------------------------------------------------
    -- Net → client : démarrage progress bar
    -- -------------------------------------------------------------------------
    net.Start("Laundering.Started")
        net.WriteUInt(amount, 30)
        net.WriteFloat(duration)
        net.WriteString(string.sub(methodId, 1, 64))
    net.Send(ply)

    doLog(string.format(
        "Started ply=%s dirty=%d duration=%.1fs method=%s",
        ply:Nick(), amount, duration, methodId
    ))

    return true, nil
end

-- ============================================================================
-- Surveillance continue de la proximité — annule la transaction dès que le
-- joueur s'éloigne de l'entité requiresProximity (au lieu d'attendre la fin
-- du timer). Throttlé à 2 Hz pour ne pas surcharger.
-- ============================================================================

hook.Add("Think", "Laundering.ProximityWatch", function()
    if (Laundering._proxNextTick or 0) > CurTime() then return end
    Laundering._proxNextTick = CurTime() + 0.5

    for sid, tx in pairs(Laundering._transactions) do
        if tx.requiresProximity ~= nil then
            if not IsValid(tx.requiresProximity) then
                cancelTransaction(sid, "entity_lost")
            else
                local ply = player.GetBySteamID64(sid)
                if IsValid(ply) and ply:IsPlayer() then
                    local maxDist = tx.maxDistance or Laundering.Config.MaxDistance
                    if ply:GetPos():DistToSqr(tx.requiresProximity:GetPos()) > (maxDist * maxDist) then
                        cancelTransaction(sid, "distance_lost")
                    end
                end
            end
        end
    end
end)
