local LS = wesnoth.require "location_set"
local F = wesnoth.require "functional"
local M = wesnoth.map

-- This is a collection of Lua functions used for custom AI development.
-- Note that this is still work in progress with significant changes occurring
-- frequently. Backward compatibility cannot be guaranteed at this time in
-- development releases, but it is of course easily possible to copy a function
-- from a previous release directly into an add-on if it is needed there.

-- Invisible units ('viewing_side' and 'ignore_visibility' parameters):
-- With their default settings, the ai_helper functions use the vision a player of
-- the respective side would see, that is, they assume no knowledge of invisible
-- units. This can be influenced with the 'viewing_side' and 'ignore_visibility' parameters,
-- which work in the same way as they do in wesnoth.paths.find_reach() and wesnoth.paths.find_path():
--   - If 'viewing_side' is set, vision for that side is used. It must be set to a valid side number.
--   - If 'ignore_visibility' is set to true, all units on the map are seen and shroud is ignored.
--       This overrides 'viewing_side'.
--   - If neither parameter is given and a function takes a parameter linked to a specific side,
--     such as a side number or a unit, as input, vision of that side is used.
--   - For some functions that take no other side-related input, 'viewing_side' is made a required parameter.

---@class ai_helper_visibility_opts
---@field viewing_side? integer If set, vision for that side is used. It must be set to a valid side number. Typically defaults to the side of a specified unit if not set.
---@field ignore_visibility? boolean If set to true, all units on the map are seen and shroud is ignored. This overrides 'viewing_side'.

-- Path finding:
-- All ai_helper functions disregard shroud for path finding (while still ignoring
-- hidden units correctly) as of Wesnoth 1.13.7. This is consistent with default
-- Wesnoth AI behavior and ensures that Lua AIs, including the Micro AIs, can be
-- used for AI sides with shroud=yes. It is accomplished by using
-- ai_helper.find_path_with_shroud() instead of wesnoth.paths.find_path().

---@class ai_helper_lib
local ai_helper = {}

----- Debugging helper functions ------

---Prints out a representation of an HP distribution table
---@param hp_distribution table<integer, number> The HP distribution table, for example from wesnoth.simulate_combat
---@param print? function Optional print function. Pass std_print if you prefer output to the console. Any other function taking a single argument will also work.
function ai_helper.print_hp_distribution(hp_distribution, print)
    print = print or _G.print
    -- hp_distribution is sort of an array, but unlike most Lua arrays it's 0-index
    for i = 0, #hp_distribution - 1 do
        if hp_distribution[i] > 0 then
            print(('P(hp = $hp) = $prob%'):vformat{hp = i, prob = hp_distribution[i] * 100})
        end
    end
end

---Returns true or false (hard-coded). To be used to show messages if in debug mode.
---@return boolean
function ai_helper.show_messages()
    -- Just edit the following line (easier than trying to set WML variable)
    local show_messages_flag = false
    if wesnoth.game_config.debug then return show_messages_flag end
    return false
end

---Returns true or false (hard-coded). To be used to show which CA is being executed if in debug mode.
---@return boolean
function ai_helper.print_exec()
    -- Just edit the following line (easier than trying to set WML variable)
    local print_exec_flag = false
    if wesnoth.game_config.debug then return print_exec_flag end
    return false
end

---Returns true or false (hard-coded). To be used to show which CA is being evaluated if in debug mode.
---@return boolean
function ai_helper.print_eval()
    -- Just edit the following line (easier than trying to set WML variable)
    local print_eval_flag = false
    if wesnoth.game_config.debug then return print_eval_flag end
    return false
end

---@param start_time integer
---@param ca_name string
function ai_helper.done_eval_messages(start_time, ca_name)
    ca_name = ca_name or 'unknown'
    if ai_helper.print_eval() then
        ai_helper.print_ts_delta(start_time, '       - Done evaluating ' .. ca_name .. ':')
    end
end

---Clear all labels on a map
function ai_helper.clear_labels()
    for x, y in wesnoth.current.map:iter(true) do
        M.add_label { x = x, y = y, text = "" }
    end
end

---@class ai_debug_label_opts
---@field show_coords? boolean Use hex coordinates as labels instead of value
---@field factor? number If value is a number, multiply by this factor
---@field keys? any[] If the value to be displayed is a subelement of the LS data, use these keys to access it. For example, if we want to display data[3], set keys = { 3 }; if we want data.arg[3], set keys = { 'arg', 3 }
---@field clear? boolean if set to 'false', do not clear existing labels
---@field color? color the color to be used for the output

---Take map (location set) and put labels containing 'value' onto the map
---Print 'nan' if element exists but is not a number.
---@param map location_set The labels to place on the map.
---@param cfg? ai_debug_label_opts table with optional configuration parameters:
function ai_helper.put_labels(map, cfg)

    cfg = cfg or {}
    local factor = cfg.factor or 1

    local clear_labels = cfg.clear
    if (clear_labels == nil) then clear_labels = true end
    if clear_labels then
        ai_helper.clear_labels()
    end

    map:iter(function(x, y, data)
        local out
        if cfg.show_coords then
            out = x .. ',' .. y
        else
            if cfg.keys then
                for _,key in ipairs(cfg.keys) do data = data[key] end
            end
            if (type(data) == 'string') then
                out = data
            else
                out = tonumber(data) or 'nan'
            end
        end

        if (type(out) == 'number') then out = out * factor end
        M.add_label { x = x, y = y, text = out, color = cfg.color }
    end)
end

---Print arguments preceded by a time stamp in seconds.
---Also return that time stamp
---@param ... unknown
---@return number
function ai_helper.print_ts(...)
    local ts = wesnoth.ms_since_init() / 1000.

    local arg = { ... }
    arg[#arg+1] = string.format('[ t = %.3f ]', ts)

    std_print(table.unpack(arg))

    return ts
end

---Same as ai_helper.print_ts(), but also adds time elapsed since
---the time given in the first argument (in seconds)
---Returns time stamp as well as time elapsed
---@param start_time integer Time stamp in seconds as returned by wesnoth.ms_since_init / 1000.
---@param ... unknown
---@return number
---@return number
function ai_helper.print_ts_delta(start_time, ...)
    local ts = wesnoth.ms_since_init() / 1000.
    local delta = ts - start_time

    local arg = { ... }
    arg[#arg+1] = string.format('[ t = %.3f, dt = %.3f ]', ts, delta)

    std_print(table.unpack(arg))

    return ts, delta
end

----- AI execution helper functions ------

---Checks if the result of a move indicates that it was incomplete
---@param check ai_result
---@return integer|false
function ai_helper.is_incomplete_move(check)
    if (not check.ok) then
        -- Legitimately interrupted moves have the following error codes:
        -- E_AMBUSHED = 2005
        -- E_FAILED_TELEPORT = 2006,
        -- E_NOT_REACHED_DESTINATION = 2007
        if (check.status == 2005) or (check.status == 2006) or (check.status == 2007) then
            return check.status
        end
    end

    return false
end

---Check if the result of a move indicates that it was either incomplete or empty.
---An empty move means an attempt to move the hex the unit is already on.
---@param check ai_result
---@return integer|false
function ai_helper.is_incomplete_or_empty_move(check)
    if (not check.ok) then
        -- Empty moves have the following error code:
        -- E_EMPTY_MOVE = 2001
        if ai_helper.is_incomplete_move(check) or (check.status == 2001) then
            return check.status
        end
    end

    return false
end

---Construct a fake AI result table
---@param gamestate_changed? boolean
---@param ok? boolean
---@param result? string
---@param status? number
---@return ai_result
function ai_helper.dummy_check_action(gamestate_changed, ok, result, status)
    return {
        gamestate_changed = gamestate_changed or false,
        ok = ok or false,
        result = result or 'ai_helper::DUMMY_FAILED_ACTION',
        status = status or 99999
    }
end

---Print an error message about a failed action if debug more is enabled
---@param action string
---@param error_code string|number
function ai_helper.checked_action_error(action, error_code)
    if wesnoth.game_config.debug then
        error(action .. ' could not be executed. Error code: ' .. error_code, 3)
    end
end

---Check if an attack is viable, and execute it if it is.
---If the attack is not viable, the unit's attacks left are cleared.
---@param ai ailib
---@param attacker unit
---@param defender unit
---@param weapon integer
---@return ai_result
function ai_helper.checked_attack(ai, attacker, defender, weapon)
    local check = ai.check_attack(attacker, defender, weapon)

    if (not check.ok) then
        ai.stopunit_attacks(attacker)
        ai_helper.checked_action_error('ai.attack from ' .. attacker.x .. ',' .. attacker.y .. ' to ' .. defender.x .. ',' .. defender.y, check.status .. ' (' .. check.result .. ')')
        return check
    end

    return ai.attack(attacker, defender, weapon)
end

---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@param move_type string
---@return ai_result
function ai_helper.checked_move_core(ai, unit, x, y, move_type)
    local check = ai.check_move(unit, x, y)

    if (not check.ok) then
        if (not ai_helper.is_incomplete_or_empty_move(check)) then
            ai.stopunit_moves(unit)
            ai_helper.checked_action_error(move_type .. ' from ' .. unit.x .. ',' .. unit.y .. ' to ' .. x .. ',' .. y, check.status .. ' (' .. check.result .. ')')
            return check
        end
    end

    if (move_type == 'ai.move_full') then
        return ai.move_full(unit, x, y)
    else
        return ai.move(unit, x, y)
    end
end

---Check if a move is viable, and execute it if it is.
---Whether the move is viable or not, the unit's moves left are cleared.
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@return ai_result
function ai_helper.checked_move_full(ai, unit, x, y)
    return ai_helper.checked_move_core(ai, unit, x, y, 'ai.move_full')
end

---Check if a move is viable, and execute it if it is.
---If the move is not viable, the unit's moves left are cleared.
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@return ai_result
function ai_helper.checked_move(ai, unit, x, y)
    return ai_helper.checked_move_core(ai, unit, x, y, 'ai.move')
end

---Check if a recruit is viable, and execute it if it is.
---@param ai ailib
---@param unit_type string
---@param x integer
---@param y integer
---@return ai_result
function ai_helper.checked_recruit(ai, unit_type, x, y)
    local check = ai.check_recruit(unit_type, x, y)

    if (not check.ok) then
        ai_helper.checked_action_error('ai.recruit of ' .. unit_type .. ' at ' .. x .. ',' .. y, check.status .. ' (' .. check.result .. ')')
        return check
    end

    return ai.recruit(unit_type, x, y)
end

---Check if it's viable to clear the unit's moves and attacks, and do so if it is.
---@param ai ailib
---@param unit unit
---@return ai_result
function ai_helper.checked_stopunit_all(ai, unit)
    local check = ai.check_stopunit(unit)

    if (not check.ok) then
        ai_helper.checked_action_error('ai.stopunit_all of ' .. unit.x .. ',' .. unit.y, check.status .. ' (' .. check.result .. ')')
        return check
    end

    return ai.stopunit_all(unit)
end

---Check if it's viable to clear the unit's attacks, and do so if it is.
---@param ai ailib
---@param unit unit
---@return ai_result
function ai_helper.checked_stopunit_attacks(ai, unit)
    local check = ai.check_stopunit(unit)

    if (not check.ok) then
        ai_helper.checked_action_error('ai.stopunit_attacks of ' .. unit.x .. ',' .. unit.y, check.status .. ' (' .. check.result .. ')')
        return check
    end

    return ai.stopunit_attacks(unit)
end

---Check if it's viable to clear the unit's moves, and do so if it is.
---@param ai ailib
---@param unit unit
---@return ai_result
function ai_helper.checked_stopunit_moves(ai, unit)
    local check = ai.check_stopunit(unit)

    if (not check.ok) then
        ai_helper.checked_action_error('ai.stopunit_moves of ' .. unit.x .. ',' .. unit.y, check.status .. ' (' .. check.result .. ')')
        return check
    end

    return ai.stopunit_moves(unit)
end

---@class robust_move_opts : move_out_of_way_opts
---@field partial_move boolean By default, this function performs full moves. If this parameter is true, a partial move is done instead.
---@field weapon integer The number (starting at 1) of the attack weapon to be used. If omitted, the best weapon is automatically selected.

---Perform a move and/or attack with an AI unit in a way that is robust against
---unexpected outcomes such as being ambushed or changes caused by WML events.
---As much as possible, this function also tries to ensure that the gamestate
---is changed in case an action turns out to be impossible due to such an
---unexpected outcome.
---@param ai ailib The ai module
---@param src location current coordinates of the AI unit to be used
---@param dst location coordinates to which the unit should move. This does not have to be different from src. In fact, the unit does not even need to have moves left, as long as an attack is specified in the latter case. If another AI unit is at dst, it is moved out of the way.
---@param target_loc? location Coordinates of the enemy unit to be attacked. If not given, no attack is attempted.
---@param cfg? robust_move_opts Additional configuration options
---@return table
function ai_helper.robust_move_and_attack(ai, src, dst, target_loc, cfg)
    -- Notes:
    -- - If an avoid_map is given in the options table, it is assumed that dst has been
    --   checked to lie outside the area to avoid.
    -- - src, dst and target_loc can be any table (including proxy units) that contains
    --   the coordinates of the respective locations using either indices .x/.y or [1]/[2].
    --   If both are given, .x/.y takes precedence over [1]/[2].
    -- - This function only safeguards AI moves against outcomes that the AI cannot know
    --   about, such as hidden units and WML events. It is assumed that the potential
    --   move was tested for general feasibility (units are on AI side and have moves
    --   left, terrain is passable, etc.) beforehand. If that is not done, it might
    --   lead to very undesirable behavior, incl. the CA being blacklisted or even the
    --   entire AI turn being ended.

    local src_x, src_y = src.x or src[1], src.y or src[2] -- this works with units or locations
    local dst_x, dst_y = dst.x or dst[1], dst.y or dst[2]

    local unit = wesnoth.units.get(src_x, src_y)
    if (not unit) then
        return ai_helper.dummy_check_action(false, false, 'robust_move_and_attack::NO_UNIT')
    end

    -- Getting target at beginning also, in case events mess up things along the way
    local target, target_x, target_y
    if target_loc then
        target_x, target_y = target_loc.x or target_loc[1], target_loc.y or target_loc[2]
        target = wesnoth.units.get(target_x, target_y)

        if (not target) then
            return ai_helper.dummy_check_action(false, false, 'robust_move_and_attack::NO_TARGET')
        end
    end

    local gamestate_changed = false
    local move_result = ai_helper.dummy_check_action(false, false, 'robust_move_and_attack::NO_ACTION')
    if (unit.moves > 0) then
        if (src_x == dst_x) and (src_y == dst_y) then
            move_result = ai.stopunit_moves(unit)

            -- The only possible failure modes are non-recoverable (such as E_NOT_OWN_UNIT)
            if (not move_result.ok) then return move_result end

            if (not unit) or (not unit.valid) then
                return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
            end

            gamestate_changed = true
        else
            local unit_old_moves = unit.moves

            local unit_in_way = wesnoth.units.get(dst_x, dst_y)
            if unit_in_way and (unit_in_way.side == wesnoth.current.side) and (unit_in_way.moves > 0) then
                local uiw_old_moves = unit_in_way.moves
                ai_helper.move_unit_out_of_way(ai, unit_in_way, cfg)

                if (not unit_in_way) or (not unit_in_way.valid) then
                    return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_IN_WAY_DISAPPEARED')
                end

                -- Failed move out of way: abandon remaining actions
                if (unit_in_way.x == dst_x) and (unit_in_way.y == dst_y) then
                    if (unit_in_way.moves == uiw_old_moves) then
                        -- Forcing a gamestate change, if necessary
                        ai.stopunit_moves(unit_in_way)
                    end
                    return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_IN_WAY_EMPTY_MOVE')
                end

                -- Check whether dst hex is free now (an event could have done something funny)
                local unit_in_way_temp = wesnoth.units.get(dst_x, dst_y)
                if unit_in_way_temp then
                    return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::ANOTHER_UNIT_IN_WAY')
                end

                gamestate_changed = true
            end

            if (not unit) or (not unit.valid) or (unit.x ~= src_x) or (unit.y ~= src_y) then
                return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
            end

            local check_result = ai.check_move(unit, dst_x, dst_y)
            if (not check_result.ok) then
                if (not ai_helper.is_incomplete_or_empty_move(check_result)) then
                    if (not gamestate_changed) then
                        ai.stopunit_moves(unit)
                    end
                    return check_result
                end
            end

            if cfg and cfg.partial_move then
                move_result = ai.move(unit, dst_x, dst_y)
            else
                move_result = ai.move_full(unit, dst_x, dst_y)
            end
            if (not move_result.ok) then return move_result end

            if (not unit) or (not unit.valid) then
                return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
            end

            -- Failed move: abandon rest of actions
            if (unit.x == src_x) and (unit.y == src_y) then
                if (not gamestate_changed) and (unit.moves == unit_old_moves) then
                    -- Forcing a gamestate change, if necessary
                    ai.stopunit_moves(unit)
                end
                return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNPLANNED_EMPTY_MOVE')
            end

            gamestate_changed = true
        end
    end

    -- Tests after the move, before continuing to attack, to ensure WML events
    -- did not do something funny
    if (not unit) or (not unit.valid) then
        return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
    end
    if (unit.x ~= dst_x) or (unit.y ~= dst_y) then
        return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_NOT_AT_DESTINATION')
    end

    -- In case all went well and there's no attack to be done
    if (not target_x) then return move_result end

    if (not target) or (not target.valid) then
        return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::TARGET_DISAPPEARED')
    end
    if (target.x ~= target_x) or (target.y ~= target_y) then
        return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::TARGET_MOVED')
    end

    local weapon = cfg and cfg.weapon
    local old_attacks_left = unit.attacks_left

    local check_result = ai.check_attack(unit, target, weapon)
    if (not check_result.ok) then
        if (not gamestate_changed) then
            ai.stopunit_all(unit)
        end
        return check_result
    end

    move_result = ai.attack(unit, target, weapon)
    -- This should not happen, given that we just checked, but just in case
    if (not move_result.ok) then return move_result end

    if (not unit) or (not unit.valid) then
        return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
    end

    if (unit.attacks_left == old_attacks_left) and (not gamestate_changed) then
        ai.stopunit_all(unit)
        return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::NO_ATTACK')
    end

    return move_result
end

----- General functionality and maths helper functions ------

---Make a copy of a table (rather than just another pointer to the same table)
---Note: This is only a shallow copy. Any nested references to tables, including WML tags,
---will still remain as a reference to the original table.
---@param t table
---@return table
function ai_helper.table_copy(t)
    local copy = {}
    for k,v in pairs(t) do copy[k] = v end
    return copy
end

---Merge two arrays without overwriting @a1 or @a2 -> create a new table
---This only works with arrays, not general tables
---@param a1 table
---@param a2 table
---@return table
function ai_helper.array_merge(a1, a2)
    local merger = {}
    for _,a in pairs(a1) do table.insert(merger, a) end
    for _,a in pairs(a2) do table.insert(merger, a) end
    return merger
end

---Convert input to a string in a format corresponding to the type of input
---The string is all put into one line
---@param input any
---@return string
function ai_helper.serialize(input)
    local str = ''
    if (type(input) == "number") or (type(input) == "boolean") then
        str = tostring(input)
    elseif type(input) == "string" then
        str = string.format("%q", input)
    elseif type(input) == "table" then
        str = str .. "{ "
        for k,v in pairs(input) do
            str = str .. "[" .. ai_helper.serialize(k)  .. "] = "
            str = str .. ai_helper.serialize(v)
            str = str .. ", "
        end
        str = str .. "}"
    else
        error("cannot serialize a " .. type(input), 2)
    end

    return str
end

---Split string str into a table using the delimiter sep (default: ',')
---@param str string
---@param sep string
---@return table
function ai_helper.split(str, sep)
    sep = sep or ","
    local fields = {}
    local pattern = string.format("([^%s]+)", sep)
    local _ = string.gsub(str, pattern, function(c) fields[#fields+1] = c end)
    return fields
end

ai_helper.split = wesnoth.deprecate_api('ai_helper.split', 'stringx.split', 3, '1.20', ai_helper.split)

--------- Location set related helper functions ----------

---Get the x,y coordinates for the index of a location set
---For some reason, there doesn't seem to be a LS function for this
---@param index integer
---@return integer
---@return integer
function ai_helper.get_LS_xy(index)
    local tmp_set = LS.create()
    tmp_set.values[index] = 1
    local xy = tmp_set:to_pairs()[1]

    return xy[1], xy[2]
end

--------- Location, position or hex related helper functions ----------

---Converts coordinates from hex geometry to cartesian coordinates,
---meaning that y coordinates are offset by 0.5 every other hex
---Example: (1,1) stays (1,1) and (3,1) remains (3,1), but (2,1) -> (2,1.5) etc.
---@param x integer
---@param y integer
---@return number
---@return number
function ai_helper.cartesian_coords(x, y)
    return x, y + ((x + 1) % 2) / 2.
end

---Returns the angle of the direction from @from_hex to @to_hex
---Angle is in radians and goes from -pi to pi. 0 is toward east.
---Input hex tables can be of form { x, y } or { x = x, y = y }, which
---means that it is also possible to pass a unit table
---@param from_hex location
---@param to_hex location
---@return number
function ai_helper.get_angle(from_hex, to_hex)
    local x1, y1 = from_hex.x or from_hex[1], from_hex.y or from_hex[2]
    local x2, y2 = to_hex.x or to_hex[1], to_hex.y or to_hex[2]

    local _, y1cart =  ai_helper.cartesian_coords(x1, y1)
    local _, y2cart =  ai_helper.cartesian_coords(x2, y2)

    return math.atan(y2cart - y1cart, x2 - x1)
end

---Returns an integer index for the direction from from_hex to to_hex
---with the full circle divided into n slices
---1 is always to the east, with indices increasing clockwise
---Input hex tables can be of form { x, y } or { x = x, y = y }, which
---means that it is also possible to pass a unit table
---@param from_hex location
---@param to_hex location
---@param n integer
---@param center_on_east? boolean By default, the eastern direction is the northern border of the first slice. If this parameter is set, east will instead be the center direction of the first slice
---@return integer
function ai_helper.get_direction_index(from_hex, to_hex, n, center_on_east)
    local d_east = 0
    if center_on_east then d_east = 0.5 end

    local angle = ai_helper.get_angle(from_hex, to_hex)
    local index = math.floor((angle / math.pi * n/2 + d_east) % n ) + 1

    return index
end

---@param from_hex location
---@param to_hex location
---@return string
function ai_helper.get_cardinal_directions(from_hex, to_hex)
    local dirs = { "E", "S", "W", "N" }
    return dirs[ai_helper.get_direction_index(from_hex, to_hex, 4, true)]
end

---@param from_hex location
---@param to_hex location
---@return string
function ai_helper.get_intercardinal_directions(from_hex, to_hex)
    local dirs = { "E", "SE", "S", "SW", "W", "NW", "N", "NE" }
    return dirs[ai_helper.get_direction_index(from_hex, to_hex, 8, true)]
end

---@param from_hex location
---@param to_hex location
---@return string
function ai_helper.get_hex_facing(from_hex, to_hex)
    local dirs = { "se", "s", "sw", "nw", "n", "ne" }
    return dirs[ai_helper.get_direction_index(from_hex, to_hex, 6)]
end

---Find the hex that is opposite of hex with respect to center_hex
---Returns nil if hex and centre_hex are not adjacent.
---@param hex location
---@param center_hex location
---@return location?
function ai_helper.find_opposite_hex_adjacent(hex, center_hex)
    -- If the two input hexes are not adjacent, return nil
    if (M.distance_between(hex[1], hex[2], center_hex[1], center_hex[2]) ~= 1) then return nil end

    -- Finding the opposite x position is easy
    local opp_x = center_hex[1] + (center_hex[1] - hex[1])

    -- y is slightly more tricky, because of the hexagonal shape, but there's a trick
    -- that saves us from having to build in a lot of if statements
    -- Among the adjacent hexes, it is the one with the correct x, and y _different_ from hex[2]
    for xa,ya in wesnoth.current.map:iter_adjacent(center_hex) do
        if (xa == opp_x) and (ya ~= hex[2]) then return { xa, ya } end
    end

    return nil
end

---Find the hex that is opposite of @hex with respect to @center_hex
---Using "square coordinate" method by JaMiT
---Note: this also works for non-adjacent hexes, but might return hexes that are not on the map!
---@param hex location
---@param center_hex location
---@return location
function ai_helper.find_opposite_hex(hex, center_hex)
    -- Finding the opposite x position is easy
    local opp_x = center_hex[1] + (center_hex[1] - hex[1])

    -- Going to "square geometry" for y coordinate
    local y_sq = hex[2] * 2 - (hex[1] % 2)
    local yc_sq = center_hex[2] * 2 - (center_hex[1] % 2)

    -- Now the same equation as for x can be used for y
    local opp_y = yc_sq + (yc_sq - y_sq)
    opp_y = math.floor((opp_y + 1) / 2)

    return {opp_x, opp_y}
end

---Returns true if hex1 and hex2 are opposite from each other with respect to @center_hex
---@param hex1 location
---@param hex2 location
---@param center_hex location
---@return boolean
function ai_helper.is_opposite_adjacent(hex1, hex2, center_hex)
    local opp_hex = ai_helper.find_opposite_hex_adjacent(hex1, center_hex)

    if opp_hex and (opp_hex[1] == hex2[1]) and (opp_hex[2] == hex2[2]) then return true end
    return false
end

---Get coordinates for either a named location or from x/y coordinates specified
---in @cfg. The location can be provided:
---  - as name: cfg[param_core .. '_loc'] (string)
---  - or as coordinates: cfg[param_core .. '_x'] and cfg[param_core .. '_y'] (integers)
---This is the syntax used by many Micro AIs.
---Exception to this variable name syntax: if param_core = '', then the location
---variables are 'location_id', 'x' and 'y'
---
---An error is raised if the named location does not exist, or if
---the coordinates are not on the map. In addition, if required_for
---is provided, an error is also raised if neither a named location
---nor both coordinates are provided. If required_for is not passed and neither
---input exists, nil is returned. Omitting required_for does not suppress
---the other two error conditions.
---@param param_core string The base name of the key to look up in the config.
---@param cfg WMLTable The AI configuration to find the location in.
---@param required_for? string A string to be included in the error message in the event of failure.
---@return location?
function ai_helper.get_named_loc_xy(param_core, cfg, required_for)
    local param_loc = 'location_id'
    if (param_core ~= '') then param_loc = param_core .. '_loc' end
    local loc_id = cfg[param_loc]
    if loc_id then
        local loc = wesnoth.current.map.special_locations[loc_id]
        if loc then
            return loc
        else
            wml.error("Named location does not exist: " .. loc_id .. " " .. (required_for or ''))
        end
    end

    local param_x, param_y = 'x', 'y'
    if (param_core ~= '') then param_x, param_y = param_core .. '_x', param_core .. '_y' end
    local x, y = cfg[param_x], cfg[param_y]
    if x and y then
        if not wesnoth.current.map:on_board(x, y) then
            wml.error("Location is not on map: " .. param_x .. ',' .. param_y .. ' = ' .. x .. ',' .. y .. " " .. (required_for or ''))
        end

        return { x, y }
    end

    if required_for then
        wml.error(required_for .. " requires either " .. param_loc .. "= or " .. param_x .. "/" .. param_y .. "= keys")
    end
end

---Same as ai_helper.get_named_loc_xy, except that it takes comma separated
---lists of locations.
---The result is returned as an array of locations.
---Empty table is returned if no locations are found.
---An error is raised if x and y are provided but the sizes don't match.
---The error conditions in get_named_loc_xy can also be raised.
---@param param_core string The base name of the key to look up in the config.
---@param cfg WMLTable The AI configuration to find the location in.
---@param required_for? string A string to be included in the error message in the event of failure.
---@return location[]
function ai_helper.get_multi_named_locs_xy(param_core, cfg, required_for)
    local locs = {}
    local param_loc = 'location_id'
    if (param_core ~= '') then param_loc = param_core .. '_loc' end
    local cfg_loc = cfg[param_loc]
    if cfg_loc then
        local loc_ids = stringx.split(cfg_loc, ",")
        for _,loc_id in ipairs(loc_ids) do
            local tmp_cfg = {}
            tmp_cfg[param_loc] = loc_id
            local loc = ai_helper.get_named_loc_xy(param_core, tmp_cfg, required_for)
            table.insert(locs, loc)
        end
        return locs
    end

    local param_x, param_y = 'x', 'y'
    if (param_core ~= '') then param_x, param_y = param_core .. '_x', param_core .. '_y' end
    local cfg_x, cfg_y = cfg[param_x], cfg[param_y]
    if cfg_x and cfg_y then
        local xs = stringx.split(cfg_x, ",")
        local ys = stringx.split(cfg_y, ",")
        if (#xs ~= #ys) then
            wml.error("Coordinate lists need to have same number of elements: " .. param_x .. ' and ' .. param_y)
        end

        for i,x in ipairs(xs) do
            local tmp_cfg = {}
            tmp_cfg[param_x] = tonumber(x)
            tmp_cfg[param_y] = tonumber(ys[i])
            local loc = ai_helper.get_named_loc_xy(param_core, tmp_cfg, required_for)
            table.insert(locs, loc)
        end
        return locs
    end

    if required_for then
        wml.error(required_for .. " requires either " .. param_loc .. "= or " .. param_x .. "/" .. param_y .. "= keys")
    end

    return locs
end

---Returns the same locations array as wesnoth.map.find(location_filter),
---but excluding hexes on the map border.
---
---Note that this might not work if location_filter is a vconfig object.
---@param location_filter WMLTable
---@return terrain_hex[]
function ai_helper.get_locations_no_borders(location_filter)
    local old_include_borders = location_filter.include_borders
    location_filter.include_borders = false
    local locs = wesnoth.map.find(location_filter)
    location_filter.include_borders = old_include_borders
    return locs
end

---Get the location closest to hex (in format { x, y })
---that matches location_filter (in WML table format)
---Returns nil if no terrain matching the filter was found
---@param hex location Anchor location
---@param location_filter WMLTable Filter to match
---@param unit? unit Can be passed as an optional third parameter, in which case the terrain needs to be passable for that unit.
---@param avoid_map? location_set If given, the hexes in avoid_map are excluded
---@return terrain_hex?
function ai_helper.get_closest_location(hex, location_filter, unit, avoid_map)
    -- Find the maximum distance from 'hex' that's possible on the map
    local max_distance = 0
    local map = wesnoth.current.map
    local to_top_left = M.distance_between(hex, 0, 0)
    if (to_top_left > max_distance) then max_distance = to_top_left end
    local to_top_right = M.distance_between(hex, map.width-1, 0)
    if (to_top_right > max_distance) then max_distance = to_top_right end
    local to_bottom_left = M.distance_between(hex, 0, map.height-1)
    if (to_bottom_left > max_distance) then max_distance = to_bottom_left end
    local to_bottom_right = M.distance_between(hex, map.width-1, map.height-1)
    if (to_bottom_right > max_distance) then max_distance = to_bottom_right end

    -- If the hex is supposed to be passable for a unit, it cannot be on the map border
    local include_borders
    if unit then include_borders = 'no' end

    local radius = 0
    while (radius <= max_distance) do
        local loc_filter = {}
        if (radius == 0) then
            loc_filter = {
                { "and", { x = hex[1], y = hex[2], include_borders = include_borders, radius = radius } },
                { "and", location_filter }
            }
        else
            loc_filter = {
                { "and", { x = hex[1], y = hex[2], include_borders = include_borders, radius = radius } },
                { "not", { x = hex[1], y = hex[2], radius = radius - 1 } },
                { "and", location_filter }
            }
        end

        local locs = wesnoth.map.find(loc_filter)

        if avoid_map then
            for i = #locs,1,-1 do
                if avoid_map:get(locs[i][1], locs[i][2]) then
                    table.remove(locs, i)
                end
            end
        end

        if unit then
            for _,loc in ipairs(locs) do
                local movecost = unit:movement_on(wesnoth.current.map[loc])
                if (movecost <= unit.max_moves) then return loc end
            end
        else
            if locs[1] then return locs[1] end
        end

        radius = radius + 1
    end

    return nil
end

---Finds all locations matching location_filter that are passable for
---the unit. This also excludes hexes on the map border.
---@param location_filter WMLTable Filter
---@param unit unit Unit to check passability; if omitted, all hexes matching the filter, but excluding border hexes are returned
---@return terrain_hex[]
function ai_helper.get_passable_locations(location_filter, unit)
    -- All hexes that are not on the map border
    local all_locs = ai_helper.get_locations_no_borders(location_filter)

    -- If a unit is provided, exclude terrain that's impassable for the unit
    if unit then
        local locs = {}
        for _,loc in ipairs(all_locs) do
            local movecost = unit:movement_on(wesnoth.current.map[loc])
            if (movecost <= unit.max_moves) then table.insert(locs, loc) end
        end
        return locs
    end

    return all_locs
end

---Finds all locations matching @location_filter that provide healing, excluding border hexes.
---@param location_filter WMLTable
---@return terrain_hex[]
function ai_helper.get_healing_locations(location_filter)
    local all_locs = ai_helper.get_locations_no_borders(location_filter)

    local locs = {}
    for _,loc in ipairs(all_locs) do
        if wesnoth.terrain_types[wesnoth.current.map[loc]].healing > 0 then
            table.insert(locs, loc)
        end
    end

    return locs
end

---Get the distance map DM for specified units (as a location set)
---DM = sum ( distance_from_unit )
---This is done for all elements of map (a location set), or for the entire map if map is not given
---@param units unit[]
---@param map? location_set
---@return location_set
function ai_helper.distance_map(units, map)
    local DM = LS.create()

    if map then
        map:iter(function(x, y, data)
            local dist = 0
            for _,unit in ipairs(units) do
                dist = dist + M.distance_between(unit, x, y)
            end
            DM:insert(x, y, dist)
        end)
    else
        for x, y in wesnoth.current.map:iter() do
            local dist = 0
            for _,unit in ipairs(units) do
                dist = dist + M.distance_between(unit, x, y)
            end
            DM:insert(x, y, dist)
        end
    end

    return DM
end

---Get the inverse distance map IDM for specified units (as a location set)
---IDM = sum ( 1 / (distance_from_unit+1) )
---This is done for all elements of map (a location set), or for the entire map if map is not given
---@param units unit[]
---@param map? location_set
---@return location_set
function ai_helper.inverse_distance_map(units, map)
    local IDM = LS.create()
    if map then
        map:iter(function(x, y, data)
            local dist = 0
            for _,unit in ipairs(units) do
                dist = dist + 1. / (M.distance_between(unit, x, y) + 1)
            end
            IDM:insert(x, y, dist)
        end)
    else
        for x, y in wesnoth.current.map:iter() do
            local dist = 0
            for _,unit in ipairs(units) do
                dist = dist + 1. / (M.distance_between(unit, x, y) + 1)
            end
            IDM:insert(x, y, dist)
        end
    end

    return IDM
end

---Determines distance of (x1,y1) from (x2,y2) even if
---x2 and y2 are not necessarily both given (or not numbers)
---@param x1 integer
---@param y1 integer
---@param x2 integer?
---@param y2 integer?
---@return integer
function ai_helper.generalized_distance(x1, y1, x2, y2)
    -- Return 0 if neither is given
    if (not x2) and (not y2) then return 0 end

    -- If only one of the parameters is set
    if (not x2) then return math.abs(y1 - y2) end
    if (not y2) then return math.abs(x1 - x2) end

    -- Otherwise, return standard distance
    return M.distance_between(x1, y1, x2, y2)
end

---Convert a list of locations as returned by wesnoth.map.find into a pair of strings
---suitable for passing in as x,y coordinate lists to wesnoth.map.find.
---Could alternatively convert to a WML table and use the find_in argument, but this is simpler.
---@param list location[]
---@return string #All the X coordinates as a comma-separated list
---@return string #All the Y coordinates as a comma-separated list
function ai_helper.split_location_list_to_strings(list)
    local locsx, locsy = {}, {}
    for i,loc in ipairs(list) do
        locsx[i] = loc[1]
        locsy[i] = loc[2]
    end
    return table.concat(locsx, ","), table.concat(locsy, ",")
end

---Returns a location set of hexes to be avoided by the AI. Information about
---these hexes can be provided in different ways:
---1. If avoid_tag is passed, we always use that. An example of this is when a
---   Micro AI configuration contains an [avoid] tag
---2. If use_ai_aspect (boolean) is set, we use the avoid aspect of the default AI.
---3. default_avoid_tag is used when avoid_tag is not passed and either
---   use_ai_aspect == false or the default AI aspect is not set.
---@param ai ailib
---@param avoid_tag WMLTable A location filter loaded from the MicroAI configuration
---@param use_ai_aspect boolean Whether to check the default AI avoid aspect
---@param default_avoid_tag WMLTable A hard-coded location filter to use if the aspect is not set
---@return location_set
function ai_helper.get_avoid_map(ai, avoid_tag, use_ai_aspect, default_avoid_tag)
    if avoid_tag then
        return LS.of_pairs(wesnoth.map.find(avoid_tag))
    end

    if use_ai_aspect then
        -- We need to be careful here as ai.aspects.avoid is an empty table both
        -- when the aspect is not set and when no hexes match the [avoid] tag.
        -- If @default_avoid_tag is not set, we can simply return the content of
        -- the aspect, it does not matter why it is an empty array (if it is).
        -- However, if @default_avoid_tag is set, we need to check whether the
        -- [avoid] tag is set for the default AI or not.

        if (not default_avoid_tag) then
            return LS.of_pairs(ai.aspects.avoid)
        else
            local ai_tag = wml.get_child(wesnoth.sides[wesnoth.current.side].__cfg, 'ai')
            for aspect in wml.child_range(ai_tag, 'aspect') do
                if (aspect.id == 'avoid') then
                    local facet = wml.get_child(aspect, 'facet')
                    if facet or aspect.name ~= "composite_aspect" then
                        -- If there's a [facet] child, it's set as a composite aspect,
                        -- with at least one facet.
                        -- But it might not be a composite aspect; it could be
                        -- a Lua aspect or a standard aspect.
                        return LS.of_pairs(ai.aspects.avoid)
                    end
                end
            end
        end
    end

    -- If we got here, that means neither @avoid_tag nor the default AI [avoid] aspect were used
    if default_avoid_tag then
        return LS.of_pairs(wesnoth.map.find(default_avoid_tag))
    else
        return LS.create()
    end
end


--------- Unit related helper functions ----------

---Check that viewing_side is valid and set to an existing side
---@param viewing_side? integer
---@param function_str? string
function ai_helper.check_viewing_side(viewing_side, function_str)
    if (not viewing_side) then
        error('ai_helper: missing required parameter viewing_side', 2)
    end
    if (type(viewing_side) ~= 'number') or (not wesnoth.sides[viewing_side]) then
        error('ai_helper: parameter viewing_side must be a valid side number', 2)
    end
end

---Check if the specified leader is set to be passive.
---@param aspect_value boolean|string[] The value of ai.aspects.passive_leader
---@param id string The leader's ID
---@return boolean
function ai_helper.is_passive_leader(aspect_value, id)
    if (type(aspect_value) == 'boolean') then return aspect_value end

    for _,aspect_id in ipairs(aspect_value) do
        if (aspect_id == id) then
            return true
        end
    end

    return false
end

---Find all living units on the map matching the specified filter.
---Living units is equivalent to non-petrified units.
---@param filter WMLTable
---@return unit[]
function ai_helper.get_live_units(filter)
    -- Note: the order of the filters and the [and] tags are important for speed reasons
    return wesnoth.units.find_on_map { { "not", { status = "petrified" } }, { "and", filter } }
end

---Find units who can move that match the specified filter.
---@param filter WMLTable
---@param exclude_guardians? boolean Whether to consider guardian units as able to move.
---@return unit[]
function ai_helper.get_units_with_moves(filter, exclude_guardians)
    -- Optional input: @exclude_guardians: set to 'true' to exclude units with ai_special=guardian
    -- Note: the order of the filters and the [and] tags are important for speed reasons
    local exclude_status = 'petrified'
    if exclude_guardians then
        exclude_status = exclude_status .. ',guardian'
    end
    return wesnoth.units.find_on_map {
        { "and", { formula = "moves > 0" } },
        { "not", { status = exclude_status } },
        { "and", filter }
    }
end

---Find units who can attack that match the specified filte.
---@param filter WMLTable
---@return unit[]
function ai_helper.get_units_with_attacks(filter)
    -- Note: the order of the filters and the [and] tags are important for speed reasons
    return wesnoth.units.find_on_map {
        { "and", { formula = "attacks_left > 0 and size(attacks) > 0" } },
        { "not", { status = "petrified" } },
        { "and", filter }
    }
end

---Get units that are visible to the specified side
---@param viewing_side integer Must be set to a valid side number. If visibility is to be ignored, use wesnoth.units.find_on_map() instead.
---@param filter? WMLTable Standard unit filter WML table for the units; defaults to all units.
---@return table
function ai_helper.get_visible_units(viewing_side, filter)
    ai_helper.check_viewing_side(viewing_side)

    local filter_plus_vision = {}
    if filter then filter_plus_vision = ai_helper.table_copy(filter) end
    table.insert(filter_plus_vision, wml.tag.filter_vision { side = viewing_side, visible = 'yes' })

    local units = {}
    local all_units = wesnoth.units.find_on_map{}
    for _,unit in ipairs(all_units) do
        if unit:matches(filter_plus_vision) then
            table.insert(units, unit)
        end
    end

    return units
end

---Check whether a unit exists and is visible to the specified side
---@param viewing_side integer Must be set to a valid side number
---@param unit unit
---@return boolean
function ai_helper.is_visible_unit(viewing_side, unit)
    ai_helper.check_viewing_side(viewing_side)

    if (not unit) then return false end

    if unit:matches({ { "filter_vision", { side = viewing_side, visible = 'no' } } }) then
        return false
    end

    return true
end

---@class get_enemies_opts : ai_helper_visibility_opts
---@field avoid_map location_set If given, an enemy is included only if it does not have at least one adjacent hex outside of avoid_map

---Attackable enemies are defined as being being
---  - enemies of the specified side,
---  - not petrified
---  - and visible to the side as defined in cfg.viewing_side and cfg.ignore_visibility.
---  - have at least one adjacent hex that is not inside an area to avoid
---For speed reasons, this is done separately, rather than calling ai_helper.get_visible_units().
---@param filter? WMLTable Standard unit filter WML table for the enemies
---@param side? integer Side number, if side other than current side is to be considered
---@param cfg? get_enemies_opts
---@return table
function ai_helper.get_attackable_enemies(filter, side, cfg)
    side = side or wesnoth.current.side
    local viewing_side = cfg and cfg.viewing_side or side
    ai_helper.check_viewing_side(viewing_side)
    local ignore_visibility = cfg and cfg.ignore_visibility

    local filter_plus_vision = {}
    if filter then filter_plus_vision = ai_helper.table_copy(filter) end
    if (not ignore_visibility) then
        filter_plus_vision = {
            { "and", filter_plus_vision },
            { "filter_vision", { side = viewing_side, visible = 'yes' } }
        }
    end

    local enemies = {}
    local all_units = wesnoth.units.find_on_map{}
    for _,unit in ipairs(all_units) do
        if wesnoth.sides.is_enemy(side, unit.side)
           and (not unit.status.petrified)
           and unit:matches(filter_plus_vision)
        then
            local is_avoided = false
            if cfg and cfg.avoid_map then
                is_avoided = true
                for xa,ya in wesnoth.current.map:iter_adjacent(unit) do
                    if (not cfg.avoid_map:get(xa, ya)) then
                        is_avoided = false
                        break
                    end
                end
            end
            if (not is_avoided) then
                table.insert(enemies, unit)
            end
        end
    end

    return enemies
end

---Check if a unit exists, is an enemy of the specifed side, is visible to the side as defined
---by cfg.viewing_side and cfg.ignore_visibility and is not petrified.
---@param unit unit
---@param side? integer Side number, defaults to current side.
---@param cfg? ai_helper_visibility_opts
---@return boolean
function ai_helper.is_attackable_enemy(unit, side, cfg)
    side = side or wesnoth.current.side
    local viewing_side = cfg and cfg.viewing_side or side
    ai_helper.check_viewing_side(viewing_side)
    local ignore_visibility = cfg and cfg.ignore_visibility

    if (not unit)
        or (not wesnoth.sides.is_enemy(side, unit.side))
        or unit.status.petrified
        or ((not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit)))
    then
        return false
    end

    return true
end

---Return the enemy closest to the specified location and its distance from the location, or to the
---leader of the specified side if a location is not specified
---@param loc? location
---@param side? integer Number of side for which to find enemy; defaults to current side
---@param cfg? get_enemies_opts
---@return nil
---@return number
function ai_helper.get_closest_enemy(loc, side, cfg)
    side = side or wesnoth.current.side

    local enemies = ai_helper.get_attackable_enemies({}, side, cfg)

    local x, y
    if not loc then
        local leader = wesnoth.units.find_on_map { side = side, canrecruit = 'yes' }[1]
        x, y = leader.x, leader.y
    else
        x, y = loc[1], loc[2]
    end

    local closest_distance, closest_enemy = math.huge, nil
    for _,enemy in ipairs(enemies) do
        local enemy_distance = M.distance_between(x, y, enemy)
        if (enemy_distance < closest_distance) then
            closest_enemy = enemy
            closest_distance = enemy_distance
        end
    end

    return closest_enemy, closest_distance
end

---Get the cost of the cheapest unit that can be recruited by the current side.
---@param leader unit if given, find the cheapest recruit cost for this leader, otherwise for the combination of all leaders of the current side
---@return number
function ai_helper.get_cheapest_recruit_cost(leader)
    local recruit_ids = {}
    for _,recruit_id in ipairs(wesnoth.sides[wesnoth.current.side].recruit) do
        table.insert(recruit_ids, recruit_id)
    end

    local leaders
    if leader then
        leaders = { leader }
    else
        leaders = wesnoth.units.find_on_map { side = wesnoth.current.side, canrecruit = 'yes' }
    end
    for _,l in ipairs(leaders) do
        for _,recruit_id in ipairs(l.extra_recruit) do
            table.insert(recruit_ids, recruit_id)
        end
    end

    local cheapest_unit_cost = math.huge
    for _,recruit_id in ipairs(recruit_ids) do
        if wesnoth.unit_types[recruit_id].cost < cheapest_unit_cost then
            cheapest_unit_cost = wesnoth.unit_types[recruit_id].cost
        end
    end

    return cheapest_unit_cost
end

--------- Move related helper functions ----------

ai_helper.no_path = 42424242  -- Value returned by C++ engine for distance when no path is found

---@class dst_src_opts : reach_options
---@field moves? "'max'"|"'current'" If set to 'max', unit MP is set to max_moves before calculation

---Get the dst_src location set for the specified units.
---@param units unit[]
---@param cfg? dst_src_opts
---@return location_set
function ai_helper.get_dst_src_units(units, cfg)
    local max_moves = false
    if cfg then
        if (cfg['moves'] == 'max') then max_moves = true end
    end

    local dstsrc = LS.create()
    for _,unit in ipairs(units) do
        local tmp_moves = unit.moves
        if max_moves then
            unit.moves = unit.max_moves
        end
        local reach = wesnoth.paths.find_reach(unit, cfg)
        if max_moves then
            unit.moves = tmp_moves
        end

        for _,loc in ipairs(reach) do
            local tmp_dst = dstsrc:get(loc[1], loc[2]) or {}
            table.insert(tmp_dst, { x = unit.x, y = unit.y })
            dstsrc:insert(loc[1], loc[2], tmp_dst)
        end
    end

    return dstsrc
end

---Get the dst_src location set for the specified units.
---If the units table is not given, use all units on the curent side.
---@param units? unit[]
---@param cfg? dst_src_opts
---@return location_set
function ai_helper.get_dst_src(units, cfg)
    if (not units) then
        units = wesnoth.units.find_on_map { side = wesnoth.current.side }
    end

    return ai_helper.get_dst_src_units(units, cfg)
end

---Get the dst_src location set for the specified enemy units.
---If the units table is not given, use all units on any enemy side.
---@param enemies? unit[]
---@param cfg? reach_options
---@return location_set
function ai_helper.get_enemy_dst_src(enemies, cfg)
    if (not enemies) then
        enemies = wesnoth.units.find_on_map {
            { "filter_side", { { "enemy_of", { side = wesnoth.current.side} } } }
        }
    end

    ---@type dst_src_opts
    local cfg_copy = {}
    if cfg then cfg_copy = ai_helper.table_copy(cfg) end
    cfg_copy.moves = 'max'

    return ai_helper.get_dst_src_units(enemies, cfg_copy)
end

---@class ai_move
---@field src location
---@field dst location

---Find all possible moves the current side can make
---@return ai_move[]
function ai_helper.my_moves()
    -- Produces an array with each field of form:
    --   [1] = { dst = { x = 7, y = 16 },
    --           src = { x = 6, y = 16 } }

    local dstsrc = ai.get_dst_src()

    ---@type ai_move[]
    local my_moves = {}
    for key,value in pairs(dstsrc) do
        local hex_x, hex_y = ai_helper.get_LS_xy(key)
        table.insert( my_moves,
            {   src = { x = value[1].x , y = value[1].y },
                dst = { x = hex_x , y = hex_y }
            }
        )
    end

    return my_moves
end

---Find all possible moves the enemy sides can make
---@return ai_move[]
function ai_helper.enemy_moves()
    -- Produces an array with each field of form:
    --   [1] = { dst = { x = 7, y = 16 },
    --           src = { x = 6, y = 16 } }

    local dstsrc = ai.get_enemy_dst_src()

    local enemy_moves = {}
    for key,value in pairs(dstsrc) do
        local hex_x, hex_y = ai_helper.get_LS_xy(key)
        table.insert( enemy_moves,
            {   src = { x = value[1].x , y = value[1].y },
                dst = { x = hex_x , y = hex_y }
            }
        )
    end

    return enemy_moves
end

---@class ai_next_hop_opts : shrouded_path_opts, reachmap_opts
---@field ignore_own_units? boolean If set to true, then own units that can move out of the way are ignored.
---@field path? location[] If given, find the next hop along this path, rather than doing new path finding In this case, it is assumed that the path is possible, in other words, that cost has been checked.
---@field fan_out? boolean Prior to Wesnoth 1.16, the unit strictly followed the path, which can lead to a line-up of units if there are allied units in the way (e.g. when multiple units are moved by the same candidate action. Now they fan out instead, trying to get as close to the ideal next_hop goal (defined as where the unit could get if there were no allied units in the way) as possible. Setting 'fan_out=false' restores the old behavior. The main disadvantage of the new method is that it needs to do more path finding and therefore takes longer.

---Finds the next "hop" of @unit on its way to (@x,@y)
---Returns coordinates of the endpoint of the hop (or nil if no path to
---(x,y) is found for the unit), and movement cost to get there.
---Only unoccupied hexes are considered
---@param unit unit The unit who's moving
---@param x integer Destination X coordinate
---@param y integer Destination Y coordinate
---@param cfg? ai_next_hop_opts Extra options, most of which are passed through to wesnoth.paths.find_path()
---@return location?
---@return integer
function ai_helper.next_hop(unit, x, y, cfg)
    local viewing_side = cfg and cfg.viewing_side or unit.side
    ai_helper.check_viewing_side(viewing_side)
    local ignore_visibility = cfg and cfg.ignore_visibility

    local path, cost
    if cfg and cfg.path then
        path = cfg.path
    else
        path, cost = ai_helper.find_path_with_shroud(unit, x, y, cfg)
        if cost >= ai_helper.no_path then return nil, cost end
    end

    -- If none of the hexes are unoccupied, use current position as default
    local next_hop, nh_cost = { unit.x, unit.y }, 0
    local next_hop_ideal = { unit.x, unit.y }

    -- Go through loop to find reachable, unoccupied hex along the path
    -- Start at second index, as first is just the unit position itself
    for i = 2,#path do
        if (not cfg) or (not cfg.avoid_map) or (not cfg.avoid_map:get(path[i][1], path[i][2])) then
            local sub_path, sub_cost = ai_helper.find_path_with_shroud(unit, path[i][1], path[i][2], cfg)

            if sub_cost <= unit.moves then
                -- Check for unit in way only if cfg.ignore_units is not set
                local unit_in_way
                if (not cfg) or (not cfg.ignore_units) then
                    unit_in_way = wesnoth.units.get(path[i][1], path[i][2])
                    if unit_in_way and (not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit_in_way)) then
                        unit_in_way = nil
                    end

                    -- If ignore_own_units is set, ignore own side units that can move out of the way
                    if cfg and cfg.ignore_own_units then
                        if unit_in_way and (unit_in_way.side == unit.side) then
                            local reach = ai_helper.get_reachable_unocc(unit_in_way, cfg)
                            if (reach:size() > 1) then unit_in_way = nil end
                        end
                    end
                end

                if not unit_in_way then
                    next_hop, nh_cost = path[i], sub_cost
                end
                next_hop_ideal = path[i]
            else
                break
            end
        end
    end

    local fan_out = cfg and cfg.fan_out
    if (fan_out == nil) then fan_out = true end
    if fan_out and ((next_hop[1] ~= next_hop_ideal[1]) or (next_hop[2] ~= next_hop_ideal[2]))
    then
        -- If we cannot get to the ideal next hop, try fanning out instead
        local reach = wesnoth.paths.find_reach(unit, cfg)

        -- Need the reach map of the unit from the ideal next hop hex
        -- There will always be another unit there, otherwise we would not have gotten here
        local unit_in_way = wesnoth.units.get(next_hop_ideal[1], next_hop_ideal[2])
        unit_in_way:extract()
        local old_x, old_y = unit.x, unit.y
        unit:extract()
        unit:to_map(next_hop_ideal[1], next_hop_ideal[2])
        local inverse_reach = wesnoth.paths.find_reach(unit, { ignore_units = true }) -- no ZoC
        unit:extract()
        unit:to_map(old_x, old_y)
        unit_in_way:to_map()

        local terrain1 = wesnoth.current.map[next_hop_ideal]
        local move_cost_endpoint = unit:movement_on(terrain1)
        local inverse_reach_map = LS.create()
        for _,r in pairs(inverse_reach) do
            -- We want the moves left for moving into the opposite direction in which the reach map was calculated
            local terrain2 = wesnoth.current.map[r]
            local move_cost = unit:movement_on(terrain2)
            local inverse_cost = r[3] + move_cost - move_cost_endpoint
            inverse_reach_map:insert(r[1], r[2], inverse_cost)
        end

        local units
        if ignore_visibility then
            units = wesnoth.units.find_on_map({ { "not", { id = unit.id } } })
        else
            units = ai_helper.get_visible_units(viewing_side, { { "not", { id = unit.id } } })
        end
        local unit_map = LS.create()
        for _,u in ipairs(units) do unit_map:insert(u.x, u.y, u.id) end

        -- Do not move farther away, but if next_hop is out of reach from next_hop_ideal,
        -- anything in reach is better -> set to -infinity in that case.
        local max_rating = inverse_reach_map:get(next_hop[1], next_hop[2]) or - math.huge
        for _,loc in ipairs(reach) do
            if (not unit_map:get(loc[1], loc[2]))
                and ((not cfg) or (not cfg.avoid_map) or (not cfg.avoid_map:get(loc[1], loc[2])))
            then
                local rating = inverse_reach_map:get(loc[1], loc[2]) or - math.huge
                if (rating > max_rating) then
                    max_rating = rating
                    next_hop = { loc[1], loc[2] } -- eliminating the third argument
                end
            end
        end
    end

    return next_hop, nh_cost
end

---@class can_reach_opts : reachmap_opts, shrouded_path_opts

---Returns true if a hex is unoccupied (by a visible unit), or
---at most occupied by unit on same side as the seeking unit that can move away
---(can be modified with options below)
---@param unit unit
---@param x integer
---@param y integer
---@param cfg can_reach_opts
---@return boolean
function ai_helper.can_reach(unit, x, y, cfg)
    cfg = cfg or {}
    local viewing_side = cfg.viewing_side or unit.side
    ai_helper.check_viewing_side(viewing_side)
    local ignore_visibility = cfg and cfg.ignore_visibility

    -- Is there a visible unit at the goal hex?
    ---@type unit?
    local unit_in_way = wesnoth.units.get(x, y)
    if unit_in_way and (not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit_in_way)) then
        unit_in_way = nil
    end

    if (cfg.exclude_occupied) and unit_in_way then
        return false
    end

    -- Otherwise, if 'ignore_units' is not set, return false if there's a unit of other side,
    -- or a unit of own side that cannot move away (this might be slow, don't know)
    if (not cfg.ignore_units) then
        -- If there's a unit at the goal that's not on own side (even ally), return false
        if unit_in_way and (unit_in_way.side ~= unit.side) then
            return false
        end

        -- If the unit in the way is on 'unit's' side and cannot move away, also return false
        if unit_in_way and (unit_in_way.side == unit.side) then
            -- need to pass the cfg here so that it works for enemy units (generally with no moves left) also
            local move_away = ai_helper.get_reachable_unocc(unit_in_way, cfg)
            if (move_away:size() <= 1) then return false end
        end
    end

    -- After all that, test whether our unit can actually get there
    local old_moves = unit.moves
    if (cfg.moves == 'max') then unit.moves = unit.max_moves end

    local can_reach = false
    local path, cost = ai_helper.find_path_with_shroud(unit, x, y, cfg)
    if (cost <= unit.moves) then can_reach = true end

    unit.moves = old_moves

    return can_reach
end

---@class reachmap_opts : dst_src_opts, ai_helper_visibility_opts
---@field exclude_occupied? boolean If true, exclude hexes that have units on them, irrespective of the value of ignore_units; defaults to false, in which case hexes with own units with moves > 0 are included
---@field avoid_map? location_set A location set with the hexes the unit is not allowed to step on.

---Get all reachable hexes for @unit that are actually available; that is,
---hexes that, at most, have own units on them which can move out of the way.
---By contrast, wesnoth.paths.find_reach also includes hexes with allied units on
---them, as well as own unit with no moves left.
---Returned array is a location set, with values set to remaining MP after the
---unit moves to the respective hexes.
---@param unit unit
---@param cfg reachmap_opts
---@return location_set
function ai_helper.get_reachmap(unit, cfg)
    --   plus all other parameters to wesnoth.paths.find_reach

    local viewing_side = cfg and cfg.viewing_side or unit.side
    ai_helper.check_viewing_side(viewing_side)
    local ignore_visibility = cfg and cfg.ignore_visibility

    local old_moves = unit.moves
    if cfg and (cfg.moves == 'max') then unit.moves = unit.max_moves end

    local reachmap = LS.create()
    local initial_reach = wesnoth.paths.find_reach(unit, cfg)
    for _,loc in ipairs(initial_reach) do
        local is_available = true
        if cfg and cfg.avoid_map and cfg.avoid_map:get(loc[1], loc[2]) then
            is_available = false
        else
            ---@type unit?
            local unit_in_way = wesnoth.units.get(loc[1], loc[2])
            if unit_in_way and (unit_in_way.id == unit.id) then
                unit_in_way = nil
            end
            if unit_in_way and (not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit_in_way)) then
                unit_in_way = nil
            end

            if unit_in_way then
                if cfg and cfg.exclude_occupied then
                    is_available = false
                elseif (unit_in_way.side ~= unit.side) or (unit_in_way.moves == 0) then
                    is_available = false
                end
            end
        end

        if is_available then
            reachmap:insert(loc[1], loc[2], loc[3])
        end
    end

    unit.moves = old_moves

    return reachmap
end

---Same as ai_helper.get_reachmap with exclude_occupied = true
---This function is now redundant, but we keep it for backward compatibility.
---@param unit unit
---@param cfg reachmap_opts
---@return location_set
function ai_helper.get_reachable_unocc(unit, cfg)

    local cfg_GRU = cfg and ai_helper.table_copy(cfg) or {}
    cfg_GRU.exclude_occupied = true

    return ai_helper.get_reachmap(unit, cfg_GRU)
end

---@class shrouded_path_opts : path_options, ai_helper_visibility_opts

---Same as wesnoth.paths.find_path, just that it works under shroud as well while still ignoring invisible units. It does this by using ignore_visibility=true and taking invisible units off the map for the path finding process.
---@param unit unit The unit who wants to move.
---@param x integer Destination X coordinate.
---@param y integer Destination Y coordinate.
---@param cfg? shrouded_path_opts
---@return location[]
---@return integer
function ai_helper.find_path_with_shroud(unit, x, y, cfg)
    -- Notes on some of the optional parameters that can be passed in @cfg:
    --  - viewing_side: If not given, use side of the unit (not the current side!)
    --    for determining which units are hidden and need to be extracted, as that
    --    is what the path_finder code uses. If set to an invalid side, we can use
    --    default path finding as shroud is ignored then anyway.
    --  - ignore_visibility: see comments at beginning of this file. Defaults to nil.
    --      This applies to the units only in this function, as it always ignores shroud.
    --  - ignore_units: if true, hidden units do not need to be extracted because
    --    all units are ignored anyway

    local viewing_side = (cfg and cfg.viewing_side) or unit.side
    ai_helper.check_viewing_side(viewing_side)
    local ignore_visibility = cfg and cfg.ignore_visibility

    local path, cost
    if wesnoth.sides[viewing_side].shroud then
        local extracted_units = {}
        if (not cfg) or (not cfg.ignore_units) then
            local all_units = wesnoth.units.find_on_map{}
            for _,u in ipairs(all_units) do
                if (u.id ~= unit.id) and (u.side ~= viewing_side)
                    and (not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, u))
                then
                    u:extract()
                    table.insert(extracted_units, u)
                end
            end
        end

        local cfg_copy = {}
        if cfg then cfg_copy = ai_helper.table_copy(cfg) end
        cfg_copy.ignore_visibility = true
        wesnoth.interface.handle_user_interact()
        path, cost = wesnoth.paths.find_path(unit, x, y, cfg_copy)

        for _,extracted_unit in ipairs(extracted_units) do
            extracted_unit:to_map()
        end
    else
        wesnoth.interface.handle_user_interact()
        path, cost = wesnoth.paths.find_path(unit, x, y, cfg)
    end

    return path, cost
end

---Custom cost function for path finding which takes hexes to be avoided into account.
---See the notes in function ai_helper.find_path_with_avoid()
---
---For efficiency reasons, this function requires quite a few arguments to be passed to it.
---Function ai_helper.find_path_with_avoid() does most of this automatically, but the custom cost
---function can be accessed directly also for even more customized behavior.
---@param x integer
---@param y integer
---@param prev_cost integer
---@param unit unit
---@param avoid_map location_set
---@param ally_map location_set
---@param enemy_map location_set
---@param enemy_zoc_map location_set
---@param strict_avoid boolean
---@return integer
function ai_helper.custom_cost_with_avoid(x, y, prev_cost, unit, avoid_map, ally_map, enemy_map, enemy_zoc_map, strict_avoid)
    if enemy_map and enemy_map:get(x, y) then
        return ai_helper.no_path
    end
    if strict_avoid and avoid_map and avoid_map:get(x, y) then
        return ai_helper.no_path
    end

    local max_moves = unit.max_moves
    local terrain = wesnoth.current.map[{x, y}]
    local move_cost = unit:movement_on(terrain)

    if (move_cost > max_moves) then
        return ai_helper.no_path
    end

    local prev_moves = math.floor(prev_cost)  -- remove all the minor ratings
    -- Note that prev_moves_left == max_moves if the unit ended turn on previous hex, as it should
    local prev_moves_left = max_moves - (unit.max_moves - unit.moves + prev_moves) % max_moves

    if enemy_zoc_map and enemy_zoc_map:get(x,y) then
        if (move_cost < prev_moves_left) then
            move_cost = prev_moves_left
        end
    end

    local moves_left = prev_moves_left - move_cost

    -- Determine whether previous hex was marked as unusable for ending the turn on (in the ones' place
    -- after multiplying by 100000), and also how many allied units are lines up along the path (tenth' place)
    -- Working with large integers for this part, in order to prevent rounding errors
    local prev_cost_int = math.floor(prev_cost * 100000 + 0.001)
    local unit_penalty = math.floor((prev_cost * 100000 - prev_cost_int + 0.001) * 10) / 10
    local avoid_penalty = math.floor(prev_cost_int - math.floor(prev_cost_int / 10) * 10 + 0.001)
    local move_cost_int = math.floor(move_cost * 100000 + 0.001)

    -- Apply unit_penalty only for the first turn
    local is_first_turn = false
    if (prev_moves < unit.moves) then is_first_turn = true end

    if is_first_turn then
        -- If the hex is both not-avoided and does not have a unit on it, we clear unit_penalty.
        -- Otherwise we add in the move cost of the current hex.
        -- The purpose of this is to have units spread out rather than move in a line, but note
        -- that this only works between paths to the hex that use up the same movement cost.
        -- It is fundamentally impossible with the Wesnoth A* search algorithm to make the unit
        -- choose a longer path in this way.
        if (ally_map and ally_map:get(x, y)) or (avoid_map and avoid_map:get(x, y)) then
            unit_penalty = unit_penalty + move_cost / 10
            -- We restrict this two 9 MP, even for units with more moves
            if (unit_penalty > 0.9) then unit_penalty = 0.9 end
            move_cost_int = move_cost_int + unit_penalty
        else
            move_cost_int = move_cost_int - unit_penalty
            unit_penalty = 0
        end
    end

    if (moves_left < 0) then
        -- This is the situation when there were moves left on the previous hex,
        -- but not enough to enter this hex. In this case, we need to apply the appropriate penalty:
        --  - If avoided hex: disqualify it
        --  - Otherwise use up full move on previous hex
        --  - Also, apply the unit line-up penalty, but only if this is the first move
        if (avoid_penalty > 0) then -- avoided hex
            return ai_helper.no_path
        end
        move_cost_int = move_cost_int + prev_moves_left * 100000
        if is_first_turn then
            move_cost_int = move_cost_int + unit_penalty * 10 * 100000 -- unit_penalty is multiples of 0.1
        end
    elseif (moves_left == 0) then
        -- And this is the case when moving to this hex uses up all moves for the turn
        if avoid_map and avoid_map:get(x, y) then
            return ai_helper.no_path
        end
        if is_first_turn then
            move_cost_int = move_cost_int + unit_penalty * 10 * 100000 -- unit_penalty is multiples of 0.1
        end
    end

    -- Here's the part that marks the hex as (un)usable
    -- We first need to subtract out the previous penalty
    move_cost_int = move_cost_int - avoid_penalty
    -- Then we need to add in a small number (remember everything is divided by 100000 at the end)
    -- because the move cost returned by this functions needs to be >= 1.  Use defense for this,
    -- thus giving a small bonus (low resulting move cost) for good terrain.
    -- Note that the returned cost is rounded to an integer by the engine, so for very long paths this
    -- will potentially add to the cost and might make the path inaccurate. However, for an average
    -- defense of 50 along the path, this will not happen until the path is 1000 hexes long. Also,
    -- in most cases this will simply add to the cost, rather than change the path itself.
    local defense = unit:defense_on(terrain)
    -- We need this to be multiples of 10 for the penalty identification to work
    defense = mathx.round(defense / 10) * 10
    if (defense > 90) then defense = 90 end
    if (defense < 10) then defense = 10 end
    move_cost_int = move_cost_int + (100 - defense)
    -- And finally we add a (very small) penalty for this hex if it is to be avoided
    -- This is used for the next hex to determine whether the previous hex was to be
    -- avoided via avoid_penalty above.
    if avoid_map and avoid_map:get(x, y) then
        move_cost_int = move_cost_int + 1
    end

    return move_cost_int / 100000
end

---@class path_with_avoid_opts
---@field strict_avoid? boolean If 'true', trigger the "strict avoid" mode described above
---@field ignore_enemies? boolean If 'true', enemies will not be taken into account.
---@field ignore_allies? boolean If 'true', allied units will not be taken into account.

---Find path while taking hexes to be avoided into account. In its default setting,
-- it also finds the path so that the unit does not end a move on a hex with an allied
-- unit, which is one of the main shortcomings of the default path finder.
--
-- Important notes:
--  - There are two modes of avoiding hexes: the default for which the unit may move through
--    the avoided area but not end a move on it; and a "strict avoid" mode for which the
--    path may not lead through the avoided area at all.
--  - Not ending turns on hexes with allied units is meant to with units moving around each other,
--    but this can cause problems in narrow passages. It can therefore also be turned off.
--  - This cost function does not provide all the configurability of the default path finder.
--    The functionality is as follows:
--     - Hexes with visible enemy units are always excluded, and enemy ZoC is taken into account
--     - Invisible enemies are always ignored (including those under shroud)
--     - Hexes with higher terrain defense are preferred, all else being equal.
---@param unit unit
---@param x integer
---@param y integer
---@param avoid_map location_set
---@param options path_with_avoid_opts
---@return nil
---@return integer
function ai_helper.find_path_with_avoid(unit, x, y, avoid_map, options)
    options = options or {}

    -- This needs to be done separately, otherwise a path that only goes a short time into the
    -- avoided area might not be disqualified correctly. It also saves evaluation time in other cases.
    if avoid_map:get(x,y) then
        return nil, ai_helper.no_path
    end

    local all_units = wesnoth.units.find_on_map{}
    local ally_map, enemy_map = LS.create(), LS.create()
    for _,u in ipairs(all_units) do
        if (u.id ~= unit.id) and ai_helper.is_visible_unit(wesnoth.current.side, u) then
            if wesnoth.sides.is_enemy(u.side, wesnoth.current.side) then
                if (not options.ignore_enemies) then
                    enemy_map:insert(u.x, u.y, u.level)
                end
            else
                if (not options.ignore_allies) then
                    ally_map:insert(u.x, u.y, u.level)
                end
            end
        end
    end

    local enemy_zoc_map = LS.create()
    if (not options.ignore_enemies) and (not unit:ability("skirmisher")) then
        enemy_map:iter(function(xx, yy, level)
            if (level > 0) then
                for xa,ya in wesnoth.current.map:iter_adjacent(xx, yy) do
                    enemy_zoc_map:insert(xa, ya, level)
                end
            end
        end)
    end

    -- Note: even though the cost function returns a float, the engine rounds the cost to an integer
    return ai_helper.find_path_with_shroud(unit, x, y, {
        calculate = function(xc, yc, current_cost)
            return ai_helper.custom_cost_with_avoid(xc, yc, current_cost, unit, avoid_map, ally_map, enemy_map, enemy_zoc_map, options.strict_avoid)
        end
    })
end

---@class best_move_opts : reachmap_opts
---@field labels? boolean If set, put labels with ratings onto map
---@field no_random? boolean If set, do not add random value between 0.0001 and 0.0099 to each hex

---Find the best move and best unit based on a rating_function
---@param units unit|unit[] single unit or list of units
---@param rating_function fun(x:integer, y:integer):integer rating function for the hexes the unit can reach
---@param cfg any
---@return location? #best_hex - nil if no valid moves were found
---@return unit? #best_unit - unit for which this rating function produced the maximum value; nil if no valid moves were found
---@return number #max_rating - the rating found for this hex/unit combination
function ai_helper.find_best_move(units, rating_function, cfg)
    cfg = cfg or {}

    -- If this is an individual unit, turn it into an array
    if units.hitpoints then units = { units } end

    local max_rating, best_hex, best_unit = - math.huge, nil, nil
    for _,unit in ipairs(units) do
        -- Hexes each unit can reach
        local reach_map = ai_helper.get_reachable_unocc(unit, cfg)
        reach_map:iter( function(x, y, v)
            -- Rate based on rating_function argument
            local rating = rating_function(x, y)

            -- If cfg.random is set, add some randomness (on 0.0001 - 0.0099 level)
            if (not cfg.no_random) then rating = rating + math.random(99) / 10000. end
            -- If cfg.labels is set: insert values for label map
            if cfg.labels then reach_map:insert(x, y, rating) end

            if rating > max_rating then
                max_rating, best_hex, best_unit = rating, { x, y }, unit
            end
        end)
        if cfg.labels then ai_helper.put_labels(reach_map) end
    end

    return best_hex, best_unit, max_rating
end

---@class move_out_of_way_opts : reach_options, ai_helper_visibility_opts
---@field dx? integer The direction in which moving out of the way is preferred
---@field dy? integer The direction in which moving out of the way is preferred
---@field avoid_map? location_set If given, the hexes in avoid_map are excluded
---@field labels? boolean if set, display labels of the rating for each hex the unit can reach

---Move aunit to the best close location.
---Main rating is the moves the unit still has left after that
---@param ai ailib
---@param unit unit
---@param cfg move_out_of_way_opts
function ai_helper.move_unit_out_of_way(ai, unit, cfg)
    cfg = cfg or {}
    local viewing_side = cfg.viewing_side or unit.side
    ai_helper.check_viewing_side(viewing_side)
    local ignore_visibility = cfg and cfg.ignore_visibility

    local dx, dy
    if cfg.dx and cfg.dy then
        local r = math.sqrt(cfg.dx * cfg.dx + cfg.dy * cfg.dy)
        if (r ~= 0) then dx, dy = cfg.dx / r, cfg.dy / r end
    end

    local reach = wesnoth.paths.find_reach(unit, cfg)
    local reach_map = LS.create()

    local max_rating, best_hex = - math.huge, nil
    for _,loc in ipairs(reach) do
        local unit_in_way = wesnoth.units.get(loc[1], loc[2])
        if (not unit_in_way)       -- also excludes current hex
            or ((not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit_in_way)))
        then
            local avoid_this_hex = cfg and cfg.avoid_map and cfg.avoid_map:get(loc[1], loc[2])
            if (not avoid_this_hex) then
                local rating = loc[3]  -- also disfavors hexes next to visible enemy units for which loc[3] = 0

                if dx then
                    rating = rating + (loc[1] - unit.x) * dx * 0.01
                    rating = rating + (loc[2] - unit.y) * dy * 0.01
                end

                if cfg.labels then reach_map:insert(loc[1], loc[2], rating) end

                if (rating > max_rating) then
                    max_rating, best_hex = rating, { loc[1], loc[2] }
                end
            end
        end
    end
    if cfg.labels then ai_helper.put_labels(reach_map) end

    if best_hex then
        ai_helper.checked_move(ai, unit, best_hex[1], best_hex[2])
    end
end

---Does ai.move_full for the unit if not at the specified location, otherwise ai.stopunit_moves
---Uses ai_helper.next_hop(), so that it works if unit cannot get there in one move
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@return ai_result
---@overload fun(ai:ailib, unit:unit, loc:location):ai_result
function ai_helper.movefull_stopunit(ai, unit, x, y)
    if (type(x) ~= 'number') then
        if x[1] then
            x, y = x[1], x[2]
        else
            x, y = x.x, x.y
        end
    end

    local next_hop = ai_helper.next_hop(unit, x, y)
    if next_hop and ((next_hop[1] ~= unit.x) or (next_hop[2] ~= unit.y)) then
        return ai_helper.checked_move_full(ai, unit, next_hop[1], next_hop[2])
    else
        return ai_helper.checked_stopunit_moves(ai, unit)
    end
end

---@class full_move_opts : shrouded_path_opts, move_out_of_way_opts

---Same as ai_help.movefull_stopunit(), but also moves a unit out of the way if there is one
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@param cfg? full_move_opts
---@overload fun(ai:ailib, unit:unit, loc:location, cfg?:full_move_opts)
function ai_helper.movefull_outofway_stopunit(ai, unit, x, y, cfg)
    local viewing_side = cfg and cfg.viewing_side or unit.side
    ai_helper.check_viewing_side(viewing_side)
    local ignore_visibility = cfg and cfg.ignore_visibility

    if (type(x) ~= 'number') then
        if x[1] then
            x, y = x[1], x[2]
        else
            x, y = x.x, x.y
        end
    end

    -- Only move unit out of way if the main unit can get there
    local path, cost = ai_helper.find_path_with_shroud(unit, x, y, cfg)
    if (cost <= unit.moves) then
        local unit_in_way = wesnoth.units.get(x, y)
        if unit_in_way and (unit_in_way ~= unit)
            and (ignore_visibility or ai_helper.is_visible_unit(viewing_side, unit_in_way))
        then
            ai_helper.move_unit_out_of_way(ai, unit_in_way, cfg)
        end
    end

    local next_hop = ai_helper.next_hop(unit, x, y)
    if next_hop and ((next_hop[1] ~= unit.x) or (next_hop[2] ~= unit.y)) then
        ai_helper.checked_move_full(ai, unit, next_hop[1], next_hop[2])
    else
        ai_helper.checked_stopunit_moves(ai, unit)
    end
end

---------- Attack related helper functions --------------

---@class get_attacks_opts : dst_src_opts, ai_helper_visibility_opts
---@field include_occupied? boolean If set, also include hexes occupied by own-side units that can move away
---@field simulate_combat? boolean If set, also simulate the combat and return result (this is slow; only set if needed)

---@class ai_attack
---@field src location The location of the unit that can attack.
---@field dst location The location of the hex the unit should attack from.
---@field target location The location of the defending unit to be attacked.
---@field att_stats stats_evaluation?
---@field def_stats stats_evaluation?
---@field attack_hex_occupied boolean Whether an own unit that can move away is on the attack hex (dst).

---Get all attacks the specified units can do. Enemies invisible to the side
-- of those units are excluded, unless the option cfg.ignore_visibility=true is used.
---@param units unit[]
---@param cfg get_attacks_opts
---@return ai_attack[]
function ai_helper.get_attacks(units, cfg)
    cfg = cfg or {}

    ---@type ai_attack[]
    local attacks = {}
    if (not units[1]) then return attacks end

    local side = units[1].side  -- all units need to be on same side
    local ignore_visibility = cfg and cfg.ignore_visibility

    -- 'moves' can be either "current" or "max"
    -- For unit on current side: use "current" by default, or override by cfg.moves
    local moves = cfg.moves or "current"
    -- For unit on any other side, only moves="max" makes sense
    if (side ~= wesnoth.current.side) then moves = "max" end

    local old_moves = {}
    if (moves == "max") then
        for i,unit in ipairs(units) do
            old_moves[i] = unit.moves
            unit.moves = unit.max_moves
        end
    end

    -- Note: the remainder is optimized for speed, so we only get_units once,
    -- do not use WML filters, etc.
    local all_units = wesnoth.units.find_on_map{}

    local enemy_map, my_unit_map, other_unit_map = LS.create(), LS.create(), LS.create()
    for i,unit in ipairs(all_units) do
        -- The value of all the location sets is the index of the
        -- unit in the all_units array
        if ai_helper.is_attackable_enemy(unit, side, cfg) then
            enemy_map:insert(unit.x, unit.y, i)
        end

        if (unit.side == side) then
            my_unit_map:insert(unit.x, unit.y, i)
        else
            if ignore_visibility or ai_helper.is_visible_unit(side, unit) then
                other_unit_map:insert(unit.x, unit.y, i)
            end
        end
    end

    local attack_hex_map = LS.create()
    enemy_map:iter(function(e_x, e_y, i)
        for xa,ya in wesnoth.current.map:iter_adjacent(e_x, e_y) do
            -- If there's no unit of another side on this hex, include it
            -- as possible attack location (this includes hexes occupied
            -- by own units at this time)
            if (not other_unit_map:get(xa, ya)) then
                local target_table = attack_hex_map:get(xa, ya) or {}
                table.insert(target_table, { x = e_x, y = e_y, i = i })
                attack_hex_map:insert(xa, ya, target_table)
            end
        end
    end)

    -- The following is done so that we at most need to do find_reach() once per unit
    -- It is needed for all units in @units and for testing whether units can move out of the way
    local reaches = LS.create()

    for _,unit in ipairs(units) do
        wesnoth.interface.handle_user_interact()
        local reach
        if reaches:get(unit.x, unit.y) then
            reach = reaches:get(unit.x, unit.y)
        else
            reach = wesnoth.paths.find_reach(unit, cfg)
            reaches:insert(unit.x, unit.y, reach)
        end

        for _,loc in ipairs(reach) do
            if attack_hex_map:get(loc[1], loc[2]) then
                local add_target = true
                local attack_hex_occupied = false

                -- If another unit of same side is on this hex:
                if my_unit_map:get(loc[1], loc[2]) and ((loc[1] ~= unit.x) or (loc[2] ~= unit.y)) then
                    attack_hex_occupied = true
                    add_target = false

                    if cfg.include_occupied then -- Test whether it can move out of the way
                        local unit_in_way = all_units[my_unit_map:get(loc[1], loc[2])]
                        local uiw_reach
                        if reaches:get(unit_in_way.x, unit_in_way.y) then
                            uiw_reach = reaches:get(unit_in_way.x, unit_in_way.y)
                        else
                            uiw_reach = wesnoth.paths.find_reach(unit_in_way, cfg)
                            reaches:insert(unit_in_way.x, unit_in_way.y, uiw_reach)
                        end

                        -- Check whether the unit to move out of the way has an unoccupied hex to move to.
                        -- We do not deal with cases where a unit can move out of the way for a
                        -- unit that is moving out of the way of the initial unit (etc.).
                        for _,uiw_loc in ipairs(uiw_reach) do
                            -- Unit in the way of the unit in the way
                            local uiw_uiw = wesnoth.units.get(uiw_loc[1], uiw_loc[2])
                            if (not uiw_uiw)
                                or ((not ignore_visibility) and (not ai_helper.is_visible_unit(side, uiw_uiw)))
                            then
                                add_target = true
                                break
                            end
                        end
                    end
                end

                if add_target then
                    for _,target in ipairs(attack_hex_map:get(loc[1], loc[2])) do
                        local att_stats, def_stats
                        if cfg.simulate_combat then
                            local unit_dst = unit:clone()
                            unit_dst.x, unit_dst.y = loc[1], loc[2]

                            local enemy = all_units[target.i]
                            att_stats, def_stats = wesnoth.simulate_combat(unit_dst, enemy)
                        end

                        table.insert(attacks, {
                            src = { x = unit.x, y = unit.y },
                            dst = { x = loc[1], y = loc[2] },
                            target = { x = target.x, y = target.y },
                            att_stats = att_stats,
                            def_stats = def_stats,
                            attack_hex_occupied = attack_hex_occupied
                        })
                    end
                end
            end
        end
    end

    if (moves == "max") then
        for i,unit in ipairs(units) do
            unit.moves = old_moves[i]
        end
    end

    return attacks
end

---This is called from ai_helper.get_attack_combos_full() and
---builds up the combos for the next recursion level.
---It also calls the next recursion level, if possible
---@param combos table?
---@param attacks ai_attack[]
---@return table<integer, integer>[]
function add_next_attack_combo_level(combos, attacks)
    -- Important: function needs to make a copy of the input array, otherwise original is changed

    -- Set up the array, if this is the first recursion level
    if (not combos) then combos = {} end

    -- Array to hold combinations for this recursion level only
    local combos_this_level = {}

    for _,attack in ipairs(attacks) do
        local dst = attack.dst.y + attack.dst.x * 1000.  -- attack hex (src)
        local src = attack.src.y + attack.src.x * 1000.  -- attacker hex (dst)
        if (not combos[1]) then  -- if this is the first recursion level, set up new combos for this level
            local move = {}
            move[dst] = src
            table.insert(combos_this_level, move)
        else
            -- Otherwise, we need to go through the already existing elements in 'combos'
            -- to see if either hex, or attacker is already used; and then add new attack to each
            for _,combo in ipairs(combos) do
                local this_combo = {}  -- needed because tables are pointers, need to create a separate one
                local add_combo = true
                for d,s in pairs(combo) do
                    if (d == dst) or (s == src) then
                        add_combo = false
                        break
                    end
                    this_combo[d] = s  -- insert individual moves to a combo
                end
                if add_combo then  -- and add it into the array, if it contains only unique moves
                    this_combo[dst] = src
                    table.insert(combos_this_level, this_combo)
                end
            end
        end
    end

    local combos_next_level = {}
    if combos_this_level[1] then  -- If moves were found for this level, also find those for the next level
        combos_next_level = ai_helper.add_next_attack_combo_level(combos_this_level, attacks)
    end

    -- Finally, combine this level and next level combos
    combos_this_level = ai_helper.array_merge(combos_this_level, combos_next_level)
    return combos_this_level
end

---Calculate attack combination result by @units on @enemy
---All combinations of all units are taken into account, as well as their order
---This can result in a _very_ large number of possible combinations
---Use ai_helper.get_attack_combos() instead if order does not matter
---@param units unit[]
---@param enemy unit
---@param cfg get_attacks_opts
---@return table<integer, integer>[]
function ai_helper.get_attack_combos_full(units, enemy, cfg)
    local all_attacks = ai_helper.get_attacks(units, cfg)

    -- Eliminate those that are not on @enemy
    local attacks = {}
    for _,attack in ipairs(all_attacks) do
        if (attack.target.x == enemy.x) and (attack.target.y == enemy.y) then
            table.insert(attacks, attack)
        end
    end
    if (not attacks[1]) then return {} end

    -- This recursive function does all the work:
    local combos = ai_helper.add_next_attack_combo_level(nil, attacks)

    return combos
end

---Calculate attack combination result by @units on @enemy
---All the unit/hex combinations are considered, but without specifying the order of the
---attacks. Use ai_helper.get_attack_combos_full() if order matters.
---@param units unit[]
---@param enemy unit
---@param cfg shrouded_path_opts
---@return table<integer, integer>[]
---@return table<integer, integer[]>
function ai_helper.get_attack_combos(units, enemy, cfg)
    -- Return values:
    --   1. Attack combinations in form { dst = src }
    --   2. All the attacks indexed by [dst][src]

    -- We don't need the full attacks here, just the coordinates,
    -- so for speed reasons, we do not use ai_helper.get_attacks()

    -- For units on the current side, we need to make sure that
    -- there isn't a unit in the way that cannot move any more
    -- TODO: generalize it so that it works not only for units with moves=0, but blocked units etc.
    local blocked_hexes = LS.create()
    if units[1] and (units[1].side == wesnoth.current.side) then
        local all_units = wesnoth.units.find_on_map { side = wesnoth.current.side }
        for _,unit in ipairs(all_units) do
            if (unit.moves == 0) then
                blocked_hexes:insert(unit.x, unit.y)
            end
        end
    end

    -- For sides other than the current, we always use max_moves,
    -- for the current side we always use current moves
    local old_moves = {}
    for i,unit in ipairs(units) do
        if (unit.side ~= wesnoth.current.side) then
            old_moves[i] = unit.moves
            unit.moves = unit.max_moves
        end
    end

    -- Find which units in @units can get to hexes next to the enemy
    local attacks_dst_src = {}
    local found_attacks = false
    for xa,ya in wesnoth.current.map:iter_adjacent(enemy) do
        -- Make sure the hex is not occupied by unit that cannot move out of the way

        local dst = xa * 1000 + ya

        for _,unit in ipairs(units) do
            if ((unit.x == xa) and (unit.y == ya)) or (not blocked_hexes:get(xa, ya)) then

                -- wesnoth.map.distance_between() is much faster than wesnoth.paths.find_path()
                --> pre-filter using the former
                local cost = M.distance_between(unit.x, unit.y, xa, ya)

                -- If the distance is <= the unit's MP, then see if it can actually get there
                -- This also means that only short paths have to be evaluated (in most situations)
                if (cost <= unit.moves) then
                    local path  -- since cost is already defined outside this block
                    path, cost = ai_helper.find_path_with_shroud(unit, xa, ya, cfg)
                end

                if (cost <= unit.moves) then
                    -- for attack by no unit on this hex
                    if (not attacks_dst_src[dst]) then
                        attacks_dst_src[dst] = { 0, unit.x * 1000 + unit.y }
                        found_attacks = true  -- since attacks_dst_src is not a simple array, this is easier
                    else
                        table.insert(attacks_dst_src[dst], unit.x * 1000 + unit.y )
                    end
                end
            end
        end
    end

    for i,unit in ipairs(units) do
        if (unit.side ~= wesnoth.current.side) then
            unit.moves = old_moves[i]
        end
    end

    if (not found_attacks) then return {}, {} end

    -- Now we set up an array of all attack combinations
    -- at this time, this includes all the 'no unit attacks this hex' elements
    -- which have a value of 0 for 'src'
    -- They need to be kept in this part, so that we get the combos that do not
    -- use the maximum amount of units possible. They will be eliminated below.
    local attack_array = {}
    -- For all values of 'dst'
    for dst,ads in pairs(attacks_dst_src) do
        local org_array = ai_helper.table_copy(attack_array)
        attack_array = {}

        -- Go through all the values of 'src'
        for _,src in ipairs(ads) do
            -- If the array does not exist, set it up
            if (not org_array[1]) then
                local tmp = {}
                tmp[dst] = src
                table.insert(attack_array, tmp)
            else  -- otherwise, add the new dst-src pair to each element of the existing array
                for _,org in ipairs(org_array) do
                    -- but only do so if that 'src' value does not exist already
                    -- except for 0's those all need to be kept
                    local add_attack = true
                    for _,s in pairs(org) do
                        if (s == src) and (src ~=0) then
                            add_attack = false
                            break
                        end
                    end
                    -- Finally, add it to the array
                    if add_attack then
                        local tmp = ai_helper.table_copy(org)
                        tmp[dst] = src
                        table.insert(attack_array, tmp)
                    end
                end
            end
        end
    end

    -- Now eliminate all the 0s
    -- Also eliminate the combo that has no attacks on any hex (all zeros)
    local i_empty = 0
    for i,att in ipairs(attack_array) do
        local count = 0
        for dst,src in pairs(att) do
            if (src == 0) then
                att[dst] = nil
            else
                count = count + 1
            end
        end
        if (count == 0) then i_empty = i end
    end

    -- This last step eliminates the "empty attack combo" (the one with all zeros)
    -- Since this is only one, it's okay to use table.remove (even though it's slow)
    table.remove(attack_array, i_empty)

    return attack_array, attacks_dst_src
end

---Get the damage multiplier for a given alignment and turn.
---@param alignment string The alignment to check.
---@param lawful_bonus integer The lawful bonus from the schedule for the desired turn.
---@return number
function ai_helper.get_unit_time_of_day_bonus(alignment, lawful_bonus)
    local multiplier = 1
    if (lawful_bonus ~= 0) then
        if (alignment == 'lawful') then
            multiplier = (1 + lawful_bonus / 100.)
        elseif (alignment == 'chaotic') then
            multiplier = (1 - lawful_bonus / 100.)
        elseif (alignment == 'liminal') then
            multiplier = (1 - math.abs(lawful_bonus) / 100.)
        end
    end
    return multiplier
end

return ai_helper
