local Global = require 'utils.global'
local Event = require 'utils.event'
local Token = require 'utils.token'
local Queue = require 'utils.queue'

local random = math.random
local queue_new = Queue.new
local push = Queue.push
local push_to_end = Queue.push_to_end
local pop = Queue.pop
local peek = Queue.peek
local peek_start = Queue.peek_start
local peek_index = Queue.peek_index
local queue_size = Queue.size
local queue_pairs = Queue.pairs
local pairs = pairs

local Public = {}

local snakes = {} -- player_index -> snake_data {is_marked_for_destroy:bool, queue :Queue of {entity, cord} }
local board = {
    size = 0,
    surface = nil,
    position = nil,
    food_count = 0,
    is_running = false,
    update_rate = 30,
    max_food = 6
}
local cords_map = {} -- cords -> positions

Global.register(
    {snakes = snakes, board = board, cords_map = cords_map},
    function(tbl)
        snakes = tbl.snakes
        board = tbl.board
        cords_map = tbl.cords_map
    end
)

local vectors = {
    [0] = {x = 0, y = -1},
    [1] = {x = 0, y = -1},
    [2] = {x = 1, y = 0},
    [3] = {x = 1, y = 0},
    [4] = {x = 0, y = 1},
    [5] = {x = 0, y = 1},
    [6] = {x = -1, y = 0},
    [7] = {x = -1, y = 0}
}

local function destroy_snake(index, snake)
    for _, element in queue_pairs(snake.queue) do
        local e = element.entity
        if e and e.valid then
            e.destroy()
        end
    end
    snakes[index] = nil

    local player = game.get_player(index)
    if not player or not player.valid then
        return
    end

    player.set_controller{type = defines.controllers.spectator}

    local score = queue_size(snake.queue)
    game.print({'snake.snake_destroyed', player.name, score})

    script.raise_event(Public.events.on_snake_player_died, {
        player = player,
        score = score
    })
end

local function destroy_dead_snakes()
    for index, snake in pairs(snakes) do
        if snake.is_marked_for_destroy then
            destroy_snake(index, snake)
        end
    end
end

local function spawn_food()
    local size = board.size
    local center = math.ceil(size / 2)
    local surface = board.surface
    local find_entity = surface.find_entity

    local food_count = board.food_count
    local max_food = board.max_food
    local tries = max_food - food_count + 10

    while food_count < max_food and tries > 0 do
        while tries > 0 do
            tries = tries - 1
            local x, y = random(size), random(size)

            if x == center and y == center then
                goto continue
            end

            local pos = cords_map[x][y]

            local entity = find_entity('character', pos) or find_entity('compilatron', pos)
            if entity then
                goto continue
            end

            entity =
                surface.create_entity({name = 'compilatron', position = pos, force = 'neutral', direction = random(7)})
            entity.active = false
            entity.destructible = false
            food_count = food_count + 1

            break

            ::continue::
        end
    end

    board.food_count = food_count
end

local function destroy_food()
    local position = board.position
    local size = board.size
    local food =
        board.surface.find_entities_filtered(
        {
            name = 'compilatron',
            area = {left_top = position, right_bottom = {position.x + size * 2, position.y + size * 2}}
        }
    )

    for i = 1, #food do
        local e = food[i]
        if e.valid then
            e.destroy()
        end
    end

    board.food_count = 0
end

local function get_new_head_cord(head_cord, direction)
    local vector = vectors[direction]
    local vec_x, vec_y = vector.x, vector.y
    local x, y = head_cord.x + vec_x, head_cord.y + vec_y

    return x, y
end

local function tick_snake(index, snake)
    local player = game.get_player(index)

    if not player or not player.valid then
        snake.is_marked_for_destroy = true
        return
    end

    local character = player.character
    if not character or not character.valid then
        snake.is_marked_for_destroy = true
        return
    end

    local surface = board.surface
    local find_entity = surface.find_entity
    local snake_queue = snake.queue
    local snake_size = queue_size(snake_queue)
    local head = peek_start(snake_queue)
    local tail = peek(snake_queue)
    local head_cord = head.cord
    local tail_entity = tail.entity
    local tail_cord = tail.cord
    local size = board.size

    local walking_state = character.walking_state
    walking_state.walking = true
    local direction = walking_state.direction
    local x, y = get_new_head_cord(head_cord, direction)

    if x <= 0 or x > size or y <= 0 or y > size then
        snake.is_marked_for_destroy = true
        tail_entity.destroy()
        return
    end

    local new_head_position = cords_map[x][y]

    if snake_size > 1 and find_entity('character', new_head_position) == peek_index(snake_queue, snake_size - 1).entity then
        direction = (direction + 4) % 8
        walking_state.direction = direction
        x, y = get_new_head_cord(head_cord, direction)
    end

    if x <= 0 or x > size or y <= 0 or y > size then
        snake.is_marked_for_destroy = true
        tail_entity.destroy()
        return
    end

    new_head_position = cords_map[x][y]

    tail_entity.teleport(new_head_position)
    tail.cord = {x = x, y = y}

    pop(snake_queue)
    push(snake_queue, tail)

    player.character = nil
    player.character = tail_entity
    tail_entity.walking_state = walking_state
    head.entity.active = false
    tail_entity.active = true

    local entity = find_entity('compilatron', new_head_position)
    if entity and entity.valid then
        entity.destroy()

        entity =
            surface.create_entity {name = 'character', position = cords_map[tail_cord.x][tail_cord.y], force = 'player'}
        entity.character_running_speed_modifier = -1
        entity.color = player.color
        entity.active = false
        entity.destructible = false
        push_to_end(snake_queue, {entity = entity, cord = tail_cord})

        board.food_count = board.food_count - 1
    end
end

local function tick_snakes()
    for index, snake in pairs(snakes) do
        tick_snake(index, snake)
    end
end

local function check_snakes_for_collisions()
    local count_entities_filtered = board.surface.count_entities_filtered
    for index, snake in pairs(snakes) do
        if snake.is_marked_for_destroy then
            goto continue
        end

        if count_entities_filtered({name = 'character', position = peek_start(snake.queue).entity.position}) > 1 then
            snake.is_marked_for_destroy = true
        end

        ::continue::
    end
end

local tick =
    Token.register(
    function()
        tick_snakes()
        check_snakes_for_collisions()
        destroy_dead_snakes()
        spawn_food()
    end
)

local function make_board()
    local size = board.size
    local position = board.position
    local surface = board.surface

    local pos_x, pos_y = position.x, position.y

    for x = 1, size do
        local col = {}
        cords_map[x] = col
        for y = 1, size do
            col[y] = {pos_x + 2 * x - 0.5, pos_y + 2 * y - 0.5}
        end
    end

    size = size * 2
    local tiles = {}

    for x = 0, size do
        for y = 0, size do
            local pos = {pos_x + x, pos_y + y}
            local tile_name

            if x == 0 or x == size or y == 0 or y == size then
                tile_name = 'deepwater'
            elseif x % 2 == 1 and y % 2 == 1 then
                tile_name = 'landfill'
            else
                tile_name = 'water'
            end

            tiles[#tiles + 1] = {position = pos, name = tile_name}
        end
    end

    surface.set_tiles(tiles)
end

local function find_new_snake_position()
    local size = board.size
    local find_entity = board.surface.find_entity

    local min = math.min(4, size)
    local max = math.max(1, size - 4)

    if min > max then
        min, max = max, min
    elseif min == max then
        min = 1
        max = size
    end

    local tries = 10

    while tries > 0 do
        tries = tries - 1

        local x, y = random(min, max), random(min, max)
        local pos = cords_map[x][y]

        local entity = find_entity('character', pos) or find_entity('compilatron', pos)
        if not entity then
            return {x = x, y = y}, pos
        end
    end
end

local function new_snake(player)
    if not board.is_running then
        return
    end

    if not player or not player.valid then
        return
    end

    if snakes[player.index] then
        return
    end

    local character = player.character
    if character and character.valid then
        character.destroy()
    end

    local cord, pos = find_new_snake_position()

    if not cord then
        player.print({'snake.spawn_snake_fail'})
        return
    end

    player.teleport(pos, board.surface)
    player.set_controller{type = defines.controllers.god}
    player.create_character()
    character = player.character
    character.character_running_speed_modifier = -1
    character.destructible = false

    local queue = queue_new()
    push(queue, {entity = character, cord = cord})
    local snake = {queue = queue}

    snakes[player.index] = snake
end

local function new_game(surface, position, size, update_rate, max_food)
    board.size = size or 15
    board.surface = surface
    position = position or {x = 1, y = 1}
    position.x = position.x or position[1]
    position.y = position.y or position[2]
    board.position = position
    board.update_rate = update_rate or 30
    board.max_food = max_food or 6

    make_board()
    destroy_food()
    spawn_food()

    board.is_running = true

    Event.add_removable_nth_tick(board.update_rate, tick)
end

Public = {
    events = {
        --[[
        on_snake_player_died
        Called when a player have died in a game of snake
        Contains
            name :: uint: Unique identifier of the event
            tick :: uint: Tick the event was generated.
            player :: LuaPlayer
            score :: uint: Score reached
        ]]
        on_snake_player_died = Event.generate_event_name('on_snake_player_died')
    }
}

function Public.start_game(surface, top_left_position, size, update_rate, max_food)
    if board.is_running then
        error('Snake game is already running you must end the game first.', 2)
    end

    if not surface then
        error('Surface must be set.', 2)
    end

    new_game(surface, top_left_position, size, update_rate or board.update_rate, max_food or board.max_food)
end

function Public.end_game()
    for index, snake in pairs(snakes) do
        destroy_snake(index, snake)
    end

    destroy_food()

    Event.remove_removable_nth_tick(board.update_rate, tick)

    board.is_running = false
end

function Public.new_snake(player)
    new_snake(player)
end

function Public.is_running()
    return board.is_running
end

return Public