-- SPDX-FileCopyrightText: 2022 Luke Granger-Brown <depot@lukegb.com>
--
-- SPDX-License-Identifier: Apache-2.0
local c = {r = 1, g = 0.5, b = 0.1}
local c_fatal = {r = 1, g = 0.5, b = 0.5}

local function split_whitespace(s)
    chunks = {}
    for substring in s:gmatch("%S+") do
        table.insert(chunks, substring)
    end
    return chunks
end

local function error_handler(doing_what, player_cause)
    return function(err)
        local full_err = '[' .. player_cause.name .. ' caused error while ' ..
                             doing_what .. '] ' .. err .. '\n' ..
                             debug.traceback()
        print(full_err)
        if not player_cause.admin then
            player_cause.print('Oops, something went wrong when performing ' ..
                                   doing_what .. ': ' .. err, c_fatal)
        end
        for _, player in pairs(game.connected_players) do
            if player.admin then
                player.print(full_err, c_fatal)
            end
        end
    end
end

local starter_items = {
    ["iron-plate"] = 8,
    ["wood"] = 1,
    ["burner-mining-drill"] = 1,
    ["stone-furnace"] = 1
}

local starter_ammo_items = {["pistol"] = 1, ["firearm-magazine"] = 10}

local function get_surface_by_name(name)
    -- Yeet.
    if name == 'spawn' then
        return game.get_surface('nauvis')
    elseif name == 'nauvis' then
        return nil
    end
    return game.get_surface(name)
end

local function get_surface_display_name(surface)
    if surface.name == 'nauvis' then
        return 'spawn'
    end
    return surface.name
end

local function force_name_for_surface(surface)
    if surface.name == 'nauvis' then
        return 'player'
    end
    return 'player_' .. surface.name
end

local function force_for_surface(surface)
    return game.forces[force_name_for_surface(surface)]
end

local inventory_types = {
    -- The order of this dictates the load order from the top.
    -- The save order starts from the bottom.

    -- Armor first, because it grants inventory size buffs.
    defines.inventory.character_armor, defines.inventory.character_guns,
    defines.inventory.character_ammo, defines.inventory.character_vehicle,
    defines.inventory.character_trash, defines.inventory.character_main
}

local function save_inventory(player, inventories, inventory_type)
    if inventories[inventory_type] ~= nil then
        inventories[inventory_type].destroy()
    end
    inventories[inventory_type] = nil

    local src_inventory = player.character.get_inventory(inventory_type)
    if src_inventory == nil then
        return
    end

    local dst_inventory = game.create_inventory(#src_inventory)
    inventories[inventory_type] = dst_inventory
    for i = 1, #src_inventory do
        dst_inventory[i].swap_stack(src_inventory[i])
    end
    src_inventory.clear()
end

local function save_inventories(player, inventories)
    for i = #inventory_types, 1, -1 do
        save_inventory(player, inventories, inventory_types[i])
    end
end

local function restore_inventory(player, inventories, inventory_type)
    local dst_inventory = player.character.get_inventory(inventory_type)
    if dst_inventory == nil then
        return true
    end

    dst_inventory.clear()

    if inventories[inventory_type] == nil then
        -- Special case: we have no inventory to restore from, so give them default spawn stuff.
        if inventory_type == defines.inventory.character_main then
            player.print(
                'Giving you the starter items, since you seem to be new to the world...',
                c)
            for item_name, count in pairs(starter_items) do
                player.insert({name = item_name, count = count})
            end
        elseif inventory_type == defines.inventory.character_ammo then
            for item_name, count in pairs(starter_ammo_items) do
                player.insert({name = item_name, count = count})
            end
        end
        return false
    end
    local src_inventory = inventories[inventory_type]
    for i = 1, #src_inventory do
        dst_inventory[i].swap_stack(src_inventory[i])
    end
    src_inventory.clear()
    return true
end

local function restore_inventories(player, inventories)
    for i = 1, #inventory_types do
        restore_inventory(player, inventories, inventory_types[i])
    end
end

local function get_saved_inventory(surface, player)
    local surface_inventories = global.inventories[surface.name]
    if surface_inventories == nil then
        surface_inventories = {}
        global.inventories[surface.name] = surface_inventories
    end
    local inventories = surface_inventories[player.index]
    if inventories == nil then
        inventories = {}
        surface_inventories[player.index] = inventories
    end
    return inventories
end

local function get_surface_locations(surface)
    if global.locations == nil then
        global.locations = {}
    end
    local surface_locations = global.locations[surface.name]
    if surface_locations == nil then
        surface_locations = {}
        global.locations[surface.name] = surface_locations
    end
    return surface_locations
end

local function port_player_to_world(player, surface)
    local p = player.print

    if player.crafting_queue_size > 0 then
        p("Can't port between worlds while crafting", c)
        return
    end

    player.clear_cursor()
    save_inventories(player, get_saved_inventory(player.surface, player))

    local src_locations = get_surface_locations(player.surface)
    local dst_locations = get_surface_locations(surface)
    src_locations[player.index] = player.character.position
    local force = force_for_surface(surface)
    local dst_location = dst_locations[player.index]
    if dst_location == nil then
        dst_location = force.get_spawn_position(surface)
    end
    local safe_dst_location = surface.find_non_colliding_position("character",
                                                                  dst_location,
                                                                  surface.get_starting_area_radius(),
                                                                  2)
    if safe_dst_location ~= nil then
        dst_location = safe_dst_location
    end
    player.teleport(dst_location, surface)
    player.force = force

    restore_inventories(player, get_saved_inventory(surface, player))
end

local function port_player_to_world_by_name(player, name)
    local p = player.print
    local surface = get_surface_by_name(name)
    if surface == nil then
        p("No such world " .. name, c)
        return
    end
    if player.character.surface == surface then
        p("Already on world " .. name, c)
        return
    end
    port_player_to_world(player, surface)
end

local function show_gui(player)
    local p = player.print
    if player.gui.screen["lukegb-gui"] then
        player.gui.screen["lukegb-gui"].destroy()
    end
    local outer = player.gui.screen.add {
        type = "frame",
        name = "lukegb-gui",
        direction = "vertical"
    }

    local title_flow = outer.add {
        type = "flow",
        name = "title_flow",
        direction = "horizontal"
    }
    title_flow.style.horizontally_stretchable = true
    title_flow.style.horizontal_spacing = 8

    local title = title_flow.add {
        type = "label",
        caption = "World Selector",
        style = "frame_title"
    }
    title.drag_target = outer

    local title_pusher = title_flow.add {
        type = "empty-widget",
        style = "draggable_space_header"
    }
    title_pusher.style.height = 24
    title_pusher.style.horizontally_stretchable = true
    title_pusher.drag_target = outer

    local close_button = title_flow.add {
        type = "sprite-button",
        style = "frame_action_button",
        sprite = "utility/close_white",
        tags = {lukegb_close_btn = true}
    }

    local cont = outer.add {type = "frame", style = "inside_shallow_frame"}
    local surf_scroll = cont.add {
        type = "scroll-pane",
        style = "scroll_pane_under_subheader"
    }
    for _, surf in pairs(game.surfaces) do
        local btn = surf_scroll.add {
            type = "button",
            caption = get_surface_display_name(surf),
            style = "menu_button",
            enabled = surf ~= player.surface,
            tags = {lukegb_dst_world = surf.index}
        }
    end
    outer.force_auto_center()
    player.opened = outer
end

local function handle_gui_click(e)
    local p
    local player = game.players[e.player_index]

    if player == nil then
        print('Sorry, need to be in game')
        return
    end
    p = player.print

    if e.element.tags.lukegb_dst_world ~= nil then
        port_player_to_world(player,
                             game.surfaces[e.element.tags.lukegb_dst_world])
        player.opened = nil
    elseif e.element.tags.lukegb_close_btn then
        player.opened = nil
    end
end

local function handle_gui_closed(e)
    if e.gui_type ~= defines.gui_type.custom then
        return
    end
    if e.element == nil then
        return
    end
    if e.element.name ~= "lukegb-gui" then
        return
    end
    e.element.destroy()
end

local function ws_command(cmd)
    local p
    local player = game.player

    if player == nil then
        print('Sorry, need to be in game')
        return
    end
    p = player.print

    if cmd.parameter ~= nil then
        -- If we got a parameter, put them there.
        port_player_to_world_by_name(player, cmd.parameter)
        return
    end

    -- Otherwise, show the gui.
    show_gui(player)
end

local function reset_inventories(surface, p)
    if global.inventories == nil then
        global.inventories = {}
    end
    if global.inventories[surface.name] ~= nil then
        p('Destroying inventories for surface ' .. surface.name, c)
        for player_idx, inventories in pairs(global.inventories[surface.name]) do
            p(' .. ' .. game.players[player_idx].name, c)
            for inventory_type, inventory in pairs(inventories) do
                inventory.destroy()
            end
        end
    end
    global.inventories[surface.name] = {}
end

local function destroy_world(name, p)
    local surface = get_surface_by_name(name)
    if surface == nil then
        p('No such world ' .. name, c)
        return
    end
    if surface.name == 'nauvis' then
        p('Spawn world cannot be destroyed', c)
        return
    end

    if force_for_surface(surface) ~= nil then
        p('Destroying force ' .. force_name_for_surface(surface), c)
        game.merge_forces(force_name_for_surface(surface), 'player')
    end

    p('Destroying surface ' .. surface.name, c)
    game.delete_surface(surface)

    reset_inventories(surface, p)
end

local function create_world(name, map_gen_settings, p)
    local surface = get_surface_by_name(name)
    if surface ~= nil then
        p('World named ' .. name .. ' already exists', c)
        return
    end

    p('Creating surface ' .. name, c)
    local surface = game.create_surface(name, map_gen_settings)
    p('Creating force ' .. force_name_for_surface(surface), c)
    local force = game.create_force(force_name_for_surface(surface))

    reset_inventories(surface, p)
end

local function destroyworld_command(cmd)
    local p
    local player = game.player

    if player == nil then
        print('Sorry, need to be in game')
        return
    end
    p = player.print

    if player.admin ~= true then
        p('Need to be an admin')
        return
    end

    if cmd.parameter == nil then
        p('/destroyworld <name>', c)
        return
    end

    local bits = split_whitespace(cmd.parameter)
    if #bits ~= 1 then
        p('/destroyworld <name>', c)
        return
    end

    local map_name = bits[1]

    destroy_world(map_name, p)
end

local function createworld_command(cmd)
    local p
    local player = game.player

    if player == nil then
        print('Sorry, need to be in game')
        return
    end
    p = player.print

    if player.admin ~= true then
        p('Need to be an admin', c)
        return
    end

    if cmd.parameter == nil then
        p('/createworld <name> <map exchange string>', c)
        return
    end

    local bits = split_whitespace(cmd.parameter)
    if #bits <= 2 then
        p('/createworld <name> <map exchange string>', c)
        return
    end
    local map_exchange_string_bits = {}
    for k, v in pairs({table.unpack(bits, 2)}) do
        map_exchange_string_bits[k] = v
    end
    local map_exchange_string = table.concat(map_exchange_string_bits, " ")

    local map_name = bits[1]
    local map_exchange = game.parse_map_exchange_string(map_exchange_string)

    create_world(map_name, map_exchange.map_gen_settings, p)
end

local function debugeditor_command(cmd)
    local p
    local player = game.player

    if player == nil then
        print('Sorry, need to be in game')
        return
    end
    p = player.print

    if player.admin ~= true then
        p('Need to be an admin', c)
        return
    end

    player.toggle_map_editor()
end

local function debugrenameworld_command(cmd)
    local p
    local player = game.player

    if player == nil then
        print('Sorry, need to be in game')
        return
    end
    p = player.print

    if player.admin ~= true then
        p('Need to be an admin', c)
        return
    end

    if cmd.parameter == nil then
        p('/debugrenameworld <name>', c)
        return
    end

    local bits = split_whitespace(cmd.parameter)
    if #bits ~= 2 then
        p('/debugrenameworld <name>', c)
        return
    end

    local old_name = bits[1]
    local new_name = bits[2]

    if old_name == 'nauvis' or old_name == 'spawn' then
        p('Sorry, renaming the spawn world is not permitted', c)
        return
    end

    local surface = get_surface_by_name(old_name)
    if surface == nil then
        p('No such world ' .. old_name, c)
        return
    end

    -- We need to get some stuff before we rename the surface, since it's name dependent.
    local force = force_for_surface(surface)

    -- Rename the surface.
    surface.name = new_name
    p('Renamed surface ' .. old_name .. ' to ' .. new_name, c)

    -- Check if we need to rename the force.
    local new_force_name = force_name_for_surface(surface)
    if force == nil then
        p('Creating new force ' .. new_force_name)
        force = game.create_force(new_force_name)
        local player_owned_entities = surface.find_entities_filtered {
            force = "player"
        }
        p('Moving everything (' .. #player_owned_entities ..
              ' entities) from the player force to the ' .. force.name ..
              ' force')
        for _, entity in pairs(player_owned_entities) do
            entity.force = force
        end
    elseif force.name ~= new_force_name then
        p('Renaming force ' .. force.name .. ' to ' .. new_force_name)
        local new_force = game.create_force(new_force_name)
        game.merge_forces(force.name, new_force.name)
        force = new_force
    end

    -- Move inventories and locations, if any, from the old name to the new name.
    if global.inventories == nil then
        global.inventories = {}
    end
    if global.locations == nil then
        global.locations = {}
    end
    local old_surface_inventories = global.inventories[old_name]
    if old_surface_inventories == nil then
        old_surface_inventories = {}
    end
    local old_surface_locations = global.locations[old_name]
    if old_surface_locations == nil then
        old_surface_locations = {}
    end
    global.inventories[new_name] = old_surface_inventories
    global.locations[new_name] = old_surface_locations
end

local function debugresetworldstate_command(cmd)
    local p
    local player = game.player

    if player == nil then
        print('Sorry, need to be in game')
        return
    end
    p = player.print

    if player.admin ~= true then
        p('Need to be an admin', c)
        return
    end

    if cmd.parameter == nil then
        p('/debugresetworldstate <name>', c)
        return
    end

    local bits = split_whitespace(cmd.parameter)
    if #bits ~= 1 then
        p('/debugresetworldstate <name>', c)
        return
    end

    local map_name = bits[1]
    local surface = get_surface_by_name(map_name)
    if surface == nil then
        p('No such world ' .. map_name, c)
        return
    end

    if force_for_surface(surface) ~= nil then
        p('Destroying force ' .. force_name_for_surface(surface), c)
        game.merge_forces(force_name_for_surface(surface), 'player')
    end
    p('Creating force ' .. force_name_for_surface(surface), c)
    local force = game.create_force(force_name_for_surface(surface))

    reset_inventories(surface, p)

    local force = force_for_surface(surface)
    local player_owned_entities = surface.find_entities_filtered {
        force = "player"
    }
    p('Moving everything (' .. #player_owned_entities ..
          ' entities) from the player force to the ' .. force.name .. ' force')
    for _, entity in pairs(player_owned_entities) do
        entity.force = force
    end
end

local function make_event_handler(name, handler)
    return function(e)
        xpcall(function() handler(e) end,
               error_handler(name, game.players[e.player_index]))
    end
end

local multiworld = {}

multiworld.events = {
    [defines.events.on_gui_click] = make_event_handler("on_gui_click",
                                                       handle_gui_click),
    [defines.events.on_gui_closed] = make_event_handler("on_gui_closed",
                                                        handle_gui_closed)
}

multiworld.add_commands = function()
    commands.add_command('ws', 'View the list of worlds', ws_command)

    commands.add_command('destroyworld', 'Destroy a world by name',
                         function(cmd)
        xpcall(function() destroyworld_command(cmd) end,
               error_handler("/destroyworld", game.player))
    end)

    commands.add_command('createworld',
                         'Create a new world given a name and a map exchange string',
                         function(cmd)
        xpcall(function() createworld_command(cmd) end,
               error_handler("/createworld", game.player))
    end)

    commands.add_command('debugeditor',
                         'Toggles into or out of the map editor without disabling achievements',
                         function(cmd)
        xpcall(function() debugeditor_command(cmd) end,
               error_handler("/debugeditor", game.player))
    end)

    commands.add_command('debugrenameworld',
                         'Renames a surface and remaps corresponding state',
                         function(cmd)
        xpcall(function() debugrenameworld_command(cmd) end,
               error_handler("/debugrenameworld", game.player))
    end)

    commands.add_command('debugresetworldstate',
                         'Resets the multiworld state for a surface without touching the surface itself',
                         function(cmd)
        xpcall(function() debugresetworldstate_command(cmd) end,
               error_handler("/debugresetworldstate", game.player))
    end)
end

return multiworld