HT = {}

HT.version = "1.6.0"
HT.threadUrl = "https://www.unknowncheats.me/forum/payday-2-a/769255-hextrainer-payday2.html"

HT.supportedLocales = {
    "chs",
    "en",
    "es",
    "fr",
    "ru"
}

HT.modPath = nil
HT.settings = {}
HT.saveFilesNames = {}
HT.saveFilesNames.settings = "Hex-settings.json"
HT.saveFilesNames.bltData = "Hex-data.txt"

HT.colors = {
    white = Color("ffffff"),
    info = Color("0000ff"),
    success = Color("00ff00"),
    warning = Color("ffff00"),
    danger = Color("ff0000")
}

HT.keybinds = {
    {id = "UTKeybindOpenMenu", pc = "f1"},
    {id = "UTKeybindGodMode", pc = "f2"},
    {id = "UTKeybindSpawnBag", pc = "f3"},
    {id = "UTKeybindNoclip", pc = "f4"},
    {id = "UTKeybindResetAll", pc = "f5"},
    {id = "UTKeybindToggleStatus", pc = "f6"},
    {id = "UTKeybindTeleport", pc = "h"},
    {id = "UTKeybindReplenish", pc = "right alt"},
}

HT.fakeMaxInteger = 1000000000000000000000000000000

function HT:init()
    HT:setSetting("initialized_version", HT.version)
    if HT:getSetting("enable_anti_cheat_checker") == nil then
        HT:setSetting("enable_anti_cheat_checker", true)
    end

    local bltData = HT:getBltData()
    bltData.keybinds = {}
    for key, value in pairs(HT.keybinds) do
        table.insert(bltData.keybinds, value)
    end
    HT:setBltData(bltData)

    local message = "Hex Trainer Loaded\n\n" ..
        "This menu is FREE. If you paid for it, you were scammed.\n\n" ..
        HT.threadUrl
    local options = {
        {
            text = "Continue",
            is_cancel_button = true
        },
        {
            text = "Open Link (UnknownCheats)",
            callback = function() HT:openSteamBrowser(HT.threadUrl) end
        },
        {
            text = HT:getLocalizedText("ut_exit"),
            callback = HT.exitGame
        }
    }
    QuickMenu:new("Hex Trainer", message, options):Show()
end

function HT:loadSettings()
    HT.settings = HT.Utils:getSaveTable(HT.saveFilesNames.settings)
    return true
end

function HT:saveSettings()
    return HT.Utils:setSaveTable(HT.saveFilesNames.settings, HT.settings)
end

function HT:getSetting(name)
    return HT.settings[name]
end

function HT:setSetting(name, value, save)
    HT.settings[name] = value
    if save or save == nil then
        HT:saveSettings()
    end
end

function HT:updateJsonValue(_json, key, value)
    local content = HT.Utils:readFile(HT.modPath .. "/menus/" .. _json .. ".json")
    if not content then return end
    local data = json.decode(content)
    if not data then return end

    for index, item in pairs(data.items) do
        if item.id == key then
            item.default_value = value
            break
        end
    end

    local file = io.open(HT.modPath .. "/menus/" .. _json .. ".json", "w")
    if file then
        file:write(json.encode(data))
        file:close()
    end
end

function HT:getBltData()
    return HT.Utils:getSaveTable(HT.saveFilesNames.bltData)
end

function HT:setBltData(data)
    return HT.Utils:setSaveTable(HT.saveFilesNames.bltData, data)
end

function HT:log(data)
    log(HT.Utils:toString(data))
end

function HT:getLocalizedText(stringId)
    return managers.localization:text(stringId)
end

-- Update a toggle's displayed state. Operates on the PERSISTENT MenuHelper item
-- objects (MenuHelper.menus[*]._items_list), not the currently-open node, so it
-- works even when the trainer menu is closed (e.g. when Reset All is pressed via
-- keybind in-game). The change is reflected the next time the page is shown.
function HT:setMenuToggle(item_id, value)
    pcall(function()
        local menus = MenuHelper and MenuHelper.menus
        if not menus then return end
        for _, menu in pairs(menus) do
            if type(menu) == "table" and menu._items_list then
                for _, item in ipairs(menu._items_list) do
                    local ok, params = pcall(function() return item:parameters() end)
                    if ok and params and (params.name == item_id or params.id == item_id) then
                        pcall(function() item:set_value(value and "on" or "off") end)
                        -- If this page is open right now, repaint it live too.
                        pcall(function()
                            local active = managers.menu:active_menu()
                            if active and active.renderer and active.renderer.reload_item then
                                active.renderer:reload_item(item)
                            end
                        end)
                        return
                    end
                end
            end
        end
    end)
end

function HT:addAlert(message, color, localized, highLightText, highLightColor)
    if localized or localized == nil then
        message = HT:getLocalizedText(message)
    end
    local parameters = {
        message = message,
        color = color or HT.colors.white,
        time = 5
    }
    if not localized then
        if highLightText then
            parameters.highlight_msg = highLightText
            parameters.highlight_color = highLightColor or HT.colors.white
        end
    end
    HT.debugLogClass:addNewLog(parameters)
end

function HT:showSubtitle(message, color)
    if not managers.mission then return end
    if type(message) ~= "string" then return end
    pcall(function()
        managers.mission:_show_debug_subtitle(message, color)
    end)
end

function HT:isUnitLoaded(name)
    return PackageManager:has(Idstring("unit"), name)
end

function HT:spawnUnit(name, position, rotation)
    if not HT:isUnitLoaded(name) then
        return false
    end
    return World:spawn_unit(name, position, rotation)
end

function HT:InteractType(interactTypes, --[[optional]]cheatEquipmentBoolean)
    local cheatEquipment = cheatEquipmentBoolean or false
    local player = managers.player and managers.player:player_unit()
    if not alive(player) then return end

    -- Collect targets first (guarded), so a dead unit / missing interaction can't
    -- throw before we toggle the cheat helpers.
    local objects = {}
    for _, v in pairs(managers.interaction._interactive_units) do
        if alive(v) then
            local interaction = v:interaction()
            if interaction and table.contains(interactTypes, interaction.tweak_data) then
                table.insert(objects, interaction)
            end
        end
    end

    for _, interaction in ipairs(objects) do
        HT:interactExtension(interaction, cheatEquipment)
    end
end

function HT:interactExtension(interaction, cheatEquipment)
    if not interaction then return false end
    local player = managers.player and managers.player:player_unit()
    if not alive(player) then return false end
    local old_can = interaction.can_interact
    local old_equipment = PlayerManager.remove_equipment
    local old_special = PlayerManager.remove_special
    local old_body_bag = PlayerManager.on_used_body_bag
    if cheatEquipment then
        interaction.can_interact = function() return true end
        PlayerManager.remove_equipment = function() end
        PlayerManager.remove_special = function() end
        PlayerManager.on_used_body_bag = function() end
    end
    local ok, err = pcall(function() interaction:interact(player) end)
    if cheatEquipment then
        interaction.can_interact = old_can
        PlayerManager.remove_equipment = old_equipment
        PlayerManager.remove_special = old_special
        PlayerManager.on_used_body_bag = old_body_bag
    end
    return ok
end

function HT:removeUnit(unit)
    if not alive(unit) then return end
    World:delete_unit(unit)
    managers.network:session():send_to_peers_synched("remove_unit", unit)
end

function HT:removeUnits(units)
    for key, unit in pairs(units) do
        HT:removeUnit(unit)
    end
end

function HT:setUnitTeam(unit, team)
    if not alive(unit) then return end
    local teamId = tweak_data.levels:get_default_team_ID(team)
    local teamData = managers.groupai:state():team_data(teamId)
    unit:movement():set_team(teamData)
end

function HT:enableUnlimitedConversions()
    _G.CloneClass(PlayerManager)
    function PlayerManager:upgrade_value(category, upgrade, default)
        if category == "player" and upgrade == "convert_enemies" then
            return true
        elseif category == "player" and upgrade == "convert_enemies_max_minions" then
            return HT.fakeMaxInteger
        else
            return PlayerManager.orig.upgrade_value(self, category, upgrade, default)
        end
    end
end

function HT:isHost()
    return Network:is_server()
end

function HT:isInGame()
    return Utils:IsInGameState()
end

function HT:isInHeist()
    if not HT:isInGame() then return false end
    return Utils:IsInHeist()
end

function HT:isDriving()
    return game_state_machine:current_state_name() == "ingame_driving"
end

function HT:isInMenu()
    return managers.menu:is_active()
end

function HT:isInStartMenu()
    return game_state_machine:current_state_name() == "menu_main"
end

function HT:isInMultiPlayer()
    return Network:multiplayer()
end

function HT:getCrosshairRay()
    return Utils:GetCrosshairRay()
end

function HT:getPlayerPosition()
    return managers.player:player_unit():position()
end

function HT:getPlayerCameraPosition()
    return managers.player:player_unit():camera():position()
end

function HT:getPlayerCameraRotation()
    return managers.player:player_unit():camera():rotation()
end

function HT:getPlayerCameraRotationFlat()
    return Rotation(HT:getPlayerCameraRotation():yaw(), 0, 0)
end

function HT:getPlayerCameraForward()
    return managers.player:player_unit():camera():forward()
end

function HT:showHitMarker()
    if managers.hud and managers.hud.on_crit_confirmed then
        managers.hud:on_crit_confirmed()
    end
end

function HT:playSound(name)
    managers.menu_component:post_event(name)
end

function HT:teleportPlayer(position, rotation)
    managers.player:warp_to(position, rotation)
end

function HT:openSteamBrowser(url)
    Steam:overlay_activate("url", url)
end

function HT:openThread()
    HT:openSteamBrowser(HT.threadUrl)
end

function HT:exitGame()
    os.exit()
end

function HT:openMenu()
    if not managers.menu then
        error("MenuManager is unavailable")
    end

    -- If another menu surface is already active, reuse it. Otherwise create the
    -- normal in-game menu surface first, then switch its logic to Hex Trainer.
    if not managers.menu:active_menu() then
        managers.menu:open_menu("menu_pause")
    end
    if not managers.menu:active_menu() then
        error("menu_pause did not create an active menu")
    end

    local active = managers.menu:active_menu()
    local selected_name = ""
    pcall(function() selected_name = active.logic:selected_node():parameters().name or "" end)
    HT:refreshMainMenuContext(selected_name)
    managers.menu:open_node("ut_main_menu")
end

-- Rebuild the top-level category list for the current context every time F1 is
-- pressed. SuperBLT stores submenu buttons directly in ut_main_menu._items.
function HT:refreshMainMenuContext(previous_node_name, opened_node)
    local out_order = {
        "ut_player_menu", "ut_preplanning_menu", "ut_mission_menu",
        "ut_dexterity_menu", "ut_cookoff_menu", "ut_spawn_menu",
        "ut_instant_menu", "ut_troll_menu", "ut_extras_menu", "ut_time_menu",
        "ut_tools_menu", "ut_unlocker_menu", "ut_configuration_menu"
    }
    local heist_order = {
        "ut_mission_menu", "ut_dexterity_menu", "ut_cookoff_menu",
        "ut_spawn_menu", "ut_extras_menu", "ut_time_menu", "ut_instant_menu",
        "ut_configuration_menu", "ut_troll_menu"
    }
    local lobby_order = {
        "ut_player_menu", "ut_preplanning_menu", "ut_cookoff_menu",
        "ut_tools_menu", "ut_unlocker_menu", "ut_configuration_menu"
    }
    local cook_levels = { rat = true, alex_1 = true, nail = true, mex_cooking = true }
    local state_name = game_state_machine and game_state_machine:current_state_name() or ""
    local level_id = Global.game_settings and Global.game_settings.level_id
    local is_cook = cook_levels[level_id] == true
    local player = managers.player and managers.player:player_unit()
    local player_spawned = alive(player)
    -- On a listen server PAYDAY 2 may report the contract/preplanning lobby as
    -- an in-heist state. A living local player is the reliable distinction.
    local state_is_lobby = state_name:find("lobby", 1, true) or state_name:find("waiting_for", 1, true)
    local node_name = tostring(previous_node_name or "")
    local node_is_lobby = node_name:find("lobby", 1, true) or node_name:find("kit", 1, true) or
        node_name:find("planning", 1, true) or node_name:find("contract", 1, true)
    local in_lobby = state_is_lobby or node_is_lobby or
        (HT:isInGame() and level_id ~= nil and not player_spawned)
    local in_heist = not in_lobby and HT:isInHeist() and player_spawned
    local order = in_heist and heist_order or (in_lobby and lobby_order or out_order)
    local rank, visible = {}, {}
    for i, id in ipairs(order) do rank[id], visible[id] = i, true end
    if (in_heist or in_lobby) and not is_cook then visible.ut_cookoff_menu = nil end

    local function node_id(item)
        local ok, p = pcall(function() return item:parameters() end)
        return ok and p and (p.next_node or p.name), p
    end
    -- MenuHelper's node is a template. The active MenuManager logic can hold a
    -- separate clone, especially for the host, so update every available copy.
    local nodes, seen = {}, {}
    local function item_list(node)
        return node and (node._items_list or node._items)
    end
    local function add_node(node)
        if node and type(item_list(node)) == "table" and not seen[node] then
            seen[node] = true
            table.insert(nodes, node)
        end
    end
    add_node(MenuHelper and MenuHelper.menus and MenuHelper.menus.ut_main_menu)
    add_node(opened_node)
    pcall(function()
        local active = managers.menu and managers.menu:active_menu()
        local data = active and active.logic and active.logic._data
        add_node(data and data._nodes and data._nodes.ut_main_menu)
        local selected = active and active.logic and active.logic:selected_node()
        if selected and selected:parameters().name == "ut_main_menu" then add_node(selected) end
    end)
    for _, main in ipairs(nodes) do
        local items = item_list(main)
        local original_rank = {}
        for i, item in ipairs(items) do original_rank[item] = i end
        for _, item in ipairs(items) do
            local id, p = node_id(item)
            if p and type(id) == "string" and id:find("^ut_.*_menu$") then
                pcall(function() item:set_visible((not in_heist and not in_lobby) or visible[id] == true) end)
            end
        end
        table.sort(items, function(a, b)
            local aid, ap = node_id(a)
            local bid, bp = node_id(b)
            local ar = rank[aid] or ((ap and ap.back) and 10000 or 9000)
            local br = rank[bid] or ((bp and bp.back) and 10000 or 9000)
            if ar == br then return (original_rank[a] or 0) < (original_rank[b] or 0) end
            return ar < br
        end)
    end
end

function HT:reloadStartMenu()
    if HT:isInMultiPlayer() then
        MenuCallbackHandler:_dialog_leave_lobby_yes()
    end
    setup:load_start_menu()
end

function HT:setHideModsList(value)
    HT:setSetting("enable_hide_mods_list", value)
    if value then
        HT:addAlert("ut_alert_hide_mods_list_enabled", HT.colors.success)
    else
        HT:addAlert("ut_alert_hide_mods_list_disabled", HT.colors.success)
    end
    HT:addAlert("ut_alert_restart_the_game_to_apply_changes", HT.colors.warning)
end
