--- Position module
-- @module Position

local fail_if_missing = require 'stdlib/core'["fail_if_missing"]
local Position = {}

--- Creates a table representing the position from x and y
-- @param x x-position
-- @param y y-position
-- @return Position
function Position.construct(x, y)
    fail_if_missing(x, "missing x position argument")
    fail_if_missing(y, "missing y position argument")
    return { x = x, y = y }
end

--- Creates a position that is a copy of the given position
-- @param pos the position to copy
-- @return Position
function Position.copy(pos)
    fail_if_missing(pos, "missing position argument")
    pos = Position.to_table(pos)
    return { x = pos.x, y = pos.y }
end

--- Creates a position that is offset by x,y coordinate pair
-- @param pos the position to offset
-- @param x the amount to offset the position in the x direction
-- @param y the amount to offset the position in the y direction
-- @return a new position, offset by the x,y coordinates
function Position.offset(pos, x, y)
    fail_if_missing(pos, "missing position argument")
    fail_if_missing(x, "missing x-coordinate value")
    fail_if_missing(y, "missing y-coordinate value")

    if #pos == 2 then
        return { x = pos[1] + x, y = pos[2] + y }
    else
        return { x = pos.x + x, y = pos.y + y }
    end
end

--- Adds 2 positions
-- @param pos1 the first position
-- @param pos2 the second position
-- @return a new position
function Position.add(pos1, pos2)
    fail_if_missing(pos1, "missing first position argument")
    fail_if_missing(pos2, "missing second position argument")

    pos1 = Position.to_table(pos1)
    pos2 = Position.to_table(pos2)
    return { x = pos1.x + pos2.x, y = pos1.y + pos2.y}
end

--- Subtracts 2 positions
-- @param pos1 the first position
-- @param pos2 the second position
-- @return a new position
function Position.subtract(pos1, pos2)
    fail_if_missing(pos1, "missing first position argument")
    fail_if_missing(pos2, "missing second position argument")

    pos1 = Position.to_table(pos1)
    pos2 = Position.to_table(pos2)
    return { x = pos1.x - pos2.x, y = pos1.y - pos2.y }
end

--- Translates a position in the given direction
-- @param pos the position to translate
-- @param direction in which direction to translate (see defines.direction)
-- @param distance distance of the translation
-- @return the translated position
function Position.translate(pos, direction, distance)
    fail_if_missing(pos, "missing position argument")
    fail_if_missing(direction, "missing direction argument")
    fail_if_missing(distance, "missing distance argument")

    pos = Position.to_table(pos)

    if direction == defines.direction.north then
        return { x = pos.x, y = pos.y - distance }
    elseif direction == defines.direction.northeast then
        return { x = pos.x + distance, y = pos.y - distance }
    elseif direction == defines.direction.east then
        return { x = pos.x + distance, y = pos.y }
    elseif direction == defines.direction.southeast then
        return { x = pos.x + distance, y = pos.y + distance }
    elseif direction == defines.direction.south then
        return { x = pos.x, y = pos.y + distance }
    elseif direction == defines.direction.southwest then
        return { x = pos.x - distance, y = pos.y + distance }
    elseif direction == defines.direction.west then
        return { x = pos.x - distance, y = pos.y }
    elseif direction == defines.direction.northwest then
        return { x = pos.x - distance, y = pos.y - distance }
    end
end

--- Expands a position to a square area
-- @param pos the position to expand into an area
-- @param radius half the side length of the area
-- @return a bounding box
function Position.expand_to_area(pos, radius)
    fail_if_missing(pos, "missing position argument")
    fail_if_missing(radius, "missing radius argument")

    if #pos == 2 then
        return { left_top = { x = pos[1] - radius, y = pos[2] - radius }, right_bottom = { x = pos[1] + radius, y = pos[2] + radius } }
    end
    return { left_top = { x = pos.x - radius, y = pos.y - radius}, right_bottom = { x = pos.x + radius, y = pos.y + radius } }
end

--- Calculates the Euclidean distance squared between two positions, useful when sqrt is not needed
-- @param pos1 the first position
-- @param pos2 the second position
-- @return the square of the Euclidean distance
function Position.distance_squared(pos1, pos2)
    fail_if_missing(pos1, "missing first position argument")
    fail_if_missing(pos2, "missing second position argument")

    pos1 = Position.to_table(pos1)
    pos2 = Position.to_table(pos2)
    local axbx = pos1.x - pos2.x
    local ayby = pos1.y - pos2.y
    return axbx * axbx + ayby * ayby
end

--- Calculates the Euclidean distance between two positions
-- @param pos1 the first position
-- @param pos2 the second position
-- @return the square of the Euclidean distance
function Position.distance(pos1, pos2)
    fail_if_missing(pos1, "missing first position argument")
    fail_if_missing(pos2, "missing second position argument")

    return math.sqrt(Position.distance_squared(pos1, pos2))
end

--- Calculates the manhatten distance between two positions
-- @param pos1 the first position
-- @param pos2 the second position
-- @return the square of the Euclidean distance
function Position.manhattan_distance(pos1, pos2)
    fail_if_missing(pos1, "missing first position argument")
    fail_if_missing(pos2, "missing second position argument")
    pos1 = Position.to_table(pos1)
    pos2 = Position.to_table(pos2)

    return math.abs(pos2.x - pos1.x) + math.abs(pos2.y - pos1.y)
end

-- see: https://en.wikipedia.org/wiki/Machine_epsilon
Position._epsilon = 1.19e-07

--- Whether 2 positions are equal
-- @param pos1 the first position
-- @param pos2 the second position
-- @return true if positions are equal
function Position.equals(pos1, pos2)
    if not pos1 or not pos2 then return false end
    -- optimize for a shallow equality check first
    if pos1 == pos2 then return true end

    local epsilon = Position._epsilon
    local abs = math.abs
    if #pos1 == 2 and #pos2 == 2 then
        return abs(pos1[1] - pos2[1]) < epsilon and abs(pos1[2] - pos2[2]) < epsilon
    elseif #pos1 == 2 and #pos2 == 0 then
        return abs(pos1[1] - pos2.x) < epsilon and abs(pos1[2] - pos2.y) < epsilon
    elseif #pos1 == 0 and #pos2 == 2 then
        return abs(pos1.x - pos2[1]) < epsilon and abs(pos1.y - pos2[2]) < epsilon
    elseif #pos1 == 0 and #pos2 == 0 then
        return abs(pos1.x - pos2.x) < epsilon and abs(pos1.y - pos2.y) < epsilon
    end

    return false
end

--- Converts a position in the array format to a position in the table format
-- @param pos_arr the position to convert
-- @return a converted position, { x = pos_arr[1], y = pos_arr[2] }
function Position.to_table(pos_arr)
    fail_if_missing(pos_arr, "missing position argument")

    if #pos_arr == 2 then
        return { x = pos_arr[1], y = pos_arr[2] }
    end
    return pos_arr
end

--- Converts a position to a string
-- @param pos the position to convert
-- @return string representation of pos
function Position.tostring(pos)
    fail_if_missing(pos, "missing position argument")
    if #pos == 2 then
        return "Position {x = " .. pos[1] .. ", y = " .. pos[2] .. "}"
    else
        return "Position {x = " .. pos.x .. ", y = " .. pos.y .. "}"
    end
end

--- Returns the opposite direction - Adapted from Factorio util.lua
-- @param direction the defines.direction to get the opposite of
-- @return the opposite direction
function Position.opposite_direction(direction)
    local opposites = {
        [defines.direction.north] = defines.direction.south,
        [defines.direction.south] = defines.direction.north,
        [defines.direction.east] = defines.direction.west,
        [defines.direction.west] = defines.direction.east,
        [defines.direction.northeast] = defines.direction.southwest,
        [defines.direction.southwest] = defines.direction.northeast,
        [defines.direction.northwest] = defines.direction.southeast,
        [defines.direction.southeast] = defines.direction.northwest,
    }
    return opposites[direction or defines.direction.north]
end

return Position
