--- Module describing the algorithm used by the FSM properties.
-- @module lqc.fsm.algorithm
-- @alias lib
local Vector = require 'lqc.helpers.vector'
local Var = require 'lqc.fsm.var'
local Command = require 'lqc.fsm.command'
local Action = require 'lqc.fsm.action'
local random = require 'lqc.random'
local deep_copy = require 'lqc.helpers.deep_copy'
local report = require 'lqc.report'
local unpack = unpack or table.unpack -- for compatibility reasons


local lib = {}

--- Creates a small helper object that keeps track of a counter
-- @return counter object
function lib.make_counter()
    local Counter = {
        val = 1,
        increase = function(self)
            self.val = self.val + 1
        end,
        value = function(self)
            return self.val
        end,
    }
    return setmetatable(Counter, {
        __index = Counter,
    })
end

--- Checks if x lies between min and max
-- @param x A number
-- @param min Minimum value
-- @param max Maximum value
-- @return true if min <= x and x <= max; otherwise false.
local function is_between(x, min, max)
    return min <= x and x <= max
end

--- Determines how many items should be shrunk
-- @param max_amount Maximum amount of items that are allowed to be shrunk down
-- @return random number between 1 and max_amount (inclusive)
local function shrink_how_many(max_amount)
    if max_amount <= 1 then
        return 1
    end
    return random.between(1, max_amount)
end

--- Determines if an an action should be marked for deletion
-- @return true if it should be deleted; otherwise false
local function should_select_action()
    return random.between(1, 4) == 1 -- 25% chance
end

--- Finds a specific state in the list of states based on name of the state.
-- @param states List of states to search in
-- @param state_name Name of the state to find
-- @return the state with that name;
--         raises an error if no state is present with the specified name
function lib.find_state(states, state_name)
    for i = 1, #states do
        local state = states[i]
        if state.name == state_name then
            return state
        end
    end
    error('State "' .. state_name .. '" not found in list of FSM states!')
end

--- Finds the next command based on the FSM model and the current state.
-- @param fsm table describing the FSM property
-- @param current_state Variable containing the current state of the FSM
-- @param var_counter Number indicating how many variables have already been used
-- @return 3 values: chosen_command, cmd_generator, updated_current_state
function lib.find_next_action(fsm, current_state, var_counter)
    local numtests, commands, states = fsm.numtests, fsm.commands, fsm.states
    local cmd_gen = commands(current_state)

    for _ = 1, 100 do -- TODO make configurable?
        local cmd = cmd_gen:pick(numtests)
        local selected_state = lib.find_state(states, cmd.state_name)

        if selected_state.precondition(current_state, cmd.args) then -- valid command
            local variable = Var.new(var_counter:value())
            var_counter:increase()
            current_state = selected_state.next_state(current_state, variable, cmd.args)
            return Action.new(variable, cmd, cmd_gen), current_state
        end
    end

    -- Could not find a next action -> stop generating further actions
    return Action.new(Var.new(var_counter:value()), Command.stop, nil), current_state
end

--- Generates a list of steps for a FSM specification
-- @param fsm_table table containing description of a FSM property
-- @return list of generated actions
function lib.generate_actions(fsm_table)
    local generated_actions = Vector.new()
    local counter = lib.make_counter()
    local state = fsm_table.initial_state()

    repeat
        local action, next_state = lib.find_next_action(fsm_table, state, counter)
        state = next_state
        generated_actions:push_back(action)
    until action.command.state_name == 'stop'

    return generated_actions
end

--- Slices of the last actions past index
-- @param action_vector Vector of actions
-- @param index Last position in the vector that should not be removed
-- @return the action vector (modified in place)
function lib.slice_last_actions(action_vector, index)
    local action_vector_copy = deep_copy(action_vector)
    local last_pos = action_vector_copy:size() - 1 -- -1 since we want to keep stop action!
    for i = last_pos, index + 1, -1 do
        action_vector_copy:remove_index(i)
    end
    return action_vector_copy
end

--- Selects at most 'how_many' amount of actions from the vector of actions
--  to be marked for deletion.
-- @param action_vector Vector containing list of actions
-- @return vector of actions which should be deleted.
function lib.select_actions(action_vector)
    local selected_actions, idx_vector = Vector.new(), Vector.new()
    if action_vector:size() <= 2 then
        return selected_actions, idx_vector
    end
    local amount = 0
    local size = action_vector:size() - 2 -- don't remove stop action, keep atleast 1 other action
    local how_many = shrink_how_many(size)

    for i = 1, size do
        if amount >= how_many then
            break
        end
        if should_select_action() then -- TODO make this a variable function and use a while loop?
            idx_vector:push_back(i)
            amount = amount + 1
        end
    end

    for i = 1, amount do
        selected_actions:push_back(action_vector:get(idx_vector:get(i)))
    end

    return selected_actions, idx_vector
end

--- Removes all elements of 'which_actions' from 'action_vector'
-- @param action_vector Vector of actions from which actions will be removed
-- @param which_actions actions to be removed
-- @return an updated vector
function lib.delete_actions(action_vector, which_actions)
    local action_vector_copy = deep_copy(action_vector)
    for i = 1, which_actions:size() do
        action_vector_copy:remove(which_actions:get(i))
    end
    return action_vector_copy
end

--- Does the actual execution of the FSM by executing the list of actions
--  If one of the postconditions fail after an action is applied, then the
--  actions will be shrunk down to a simpler scenario.
--  At the end, the state is cleaned up by the cleanup-callback.
--  Returns 4 values:
--  1) true if the FSM property succeeded for these actions; false otherwise.
--  2) index of last successful step (1-based)
--  3) state of the model right before the failing action
--  4) result of the last failing action
-- @param fsm_table table containing description of a FSM property
-- @param generated_actions list of actions to be executed for this FSM
-- @return 4 values:
--  1. bool: true = FSM succeeded; false = FSM failed
--  2. Amount of actions executed
--  3. Last state of the FSM
--  4. Last result (return value of last command)
function lib.execute_fsm(fsm_table, generated_actions)
    local state = fsm_table.initial_state()
    local last_state, last_result = state, nil

    for i = 1, generated_actions:size() do
        local command = generated_actions:get(i).command
        local selected_state = lib.find_state(fsm_table.states, command.state_name)
        local result = command.func(unpack(command.args)) -- side effects happen here
        local updated_state = selected_state.next_state(state, result, command.args)

        last_state, last_result = state, result

        -- and verify the model matches the actual system
        -- NOTE: the state passed in is the state that the system had BEFORE
        -- executing this specific action!
        if not selected_state.postcondition(state, result, command.args) then
            fsm_table.cleanup(state)
            return false, i, last_state, last_result
        end

        state = updated_state -- update model
       
    end

    fsm_table.cleanup(state)
    return true, generated_actions:size() - 1, last_state, last_result
end

--- Is the list of actions valid to execute on this FSM?
--  Replays sequence symbolically to verify if it is indeed valid.
-- @param fsm_table table containing description of a FSM property
-- @param action_vector list of actions to be checked
-- @return true if list of actions valid; otherwise false.
function lib.is_action_sequence_valid(fsm_table, action_vector)
    local states = fsm_table.states
    local state = fsm_table.initial_state()

    for i = 1, action_vector:size() do
        local action = action_vector:get(i)
        local selected_state = lib.find_state(states, action.command.state_name)

        if not selected_state.precondition(state, action.command.args) then
            return false
        end

        state = selected_state.next_state(state, action.variable, action.command.args)
    end
    return true
end

--- Tries to shrink the list of actions to a simpler form by removing steps of
--  the sequence and checking if it is still valid.
--  The function is recursive and will loop until tries_left is 0.
-- @param fsm_table table containing description of a FSM property
-- @param action_list list of actions to be shrunk down
-- @return 2 values:
--   1) action_list if shrinking was not possible after X amount of tries;
--      otherwise it will return a shrunk list of actions.
--   2) list of deleted actions (empty if shrinking failed after X tries)
local function do_shrink_actions(fsm_table, action_list)
    local which_actions = lib.select_actions(action_list)
    local shrunk_actions = lib.delete_actions(action_list, which_actions)

    if not lib.is_action_sequence_valid(fsm_table, shrunk_actions) then
        return action_list, Vector.new()
    end

    return shrunk_actions, which_actions
end

--- Does the shrinking of the FSM actions
--  choose 1 - N steps and delete them from list of actions
--  repeat X amount of times (recursively)
-- @param fsm_table table containing description of a FSM property
-- @param generated_actions List of actions to be shrunk down
-- @param removed_actions Already removed actions
-- @param tries Remaining tries to recusively try shrinking the actions
-- @return the shrunk down list of actions
local function shrink_actions(fsm_table, generated_actions, removed_actions, tries)
    if tries == 1 then
        return generated_actions, removed_actions
    end

    local shrunk_actions, deleted_actions = do_shrink_actions(fsm_table, generated_actions)
    local total_removed_actions = removed_actions:append(deleted_actions)

    -- TODO add execute fsm here and shrink deleted actions?
    return shrink_actions(fsm_table, shrunk_actions, total_removed_actions, tries - 1)
end

--- Tries to shrink the list of failing actions by selecting a subset and
--  checking if the combination is now valid and if the FSM still fails or not
--  This is a recursive function.
-- @param fsm_table table containing description of a FSM property
-- @param generated_actions List of actions to be shrunk down
-- @param deleted_actions Already deleted actions
-- @param tries Remaining tries to recusively try shrinking the actions
-- @return 3 things:
--   1. the shrunk list of actions (or the original list if no better solution was found)
--   2. the end state of the fsm after these actions
--   3. result of the last action
local function shrink_deleted_actions(fsm_table, generated_actions, deleted_actions, tries)
    if tries == 1 then
        return generated_actions
    end

    local which_actions = lib.select_actions(deleted_actions)
    local shrunk_actions = lib.delete_actions(generated_actions, which_actions)

    -- Retry if invalid sequence
    local is_valid = lib.is_action_sequence_valid(fsm_table, shrunk_actions)
    if not is_valid then
        return shrink_deleted_actions(fsm_table, generated_actions, deleted_actions, tries - 1)
    end

    -- Check FSM again
    -- if FSM succeeds now, (one or more of) the failing actions have been deleted
    --    -> simply retry TODO there is an optimisation possible here..
    -- else FSM still failed -> chosen actions did not matter, ignore them and
    --      further try shrinking
    local is_successful = lib.execute_fsm(fsm_table, shrunk_actions)
    if is_successful then
        deleted_actions = lib.delete_actions(deleted_actions, which_actions)
    end
    return shrink_deleted_actions(fsm_table, shrunk_actions, deleted_actions, tries - 1)
end

--- Tries to shrink down the list of FSM actions. This function is called
--  recursively until all tries are used.
-- @param fsm_table table containing description of a FSM property
-- @param generated_actions List of actions to be shrunk down
-- @param step last step in the series of actions that succeeded while testing
--             the FSM property
-- @param tries Remaining tries to recusively try shrinking the actions
-- @return list of shrunk down actions
function lib.shrink_fsm_actions(fsm_table, generated_actions, step, tries)
    if tries == 1 then
        return generated_actions
    end

    local sliced_actions = lib.slice_last_actions(generated_actions, step) -- cut off actions after failure..
    local shrunk_actions, deleted_actions =
        shrink_actions(fsm_table, sliced_actions, Vector.new(), fsm_table.numshrinks)
    if deleted_actions:size() == 0 then
        -- shrinking did not help, try again
        return lib.shrink_fsm_actions(fsm_table, sliced_actions, step, tries - 1)
    end

    -- shrinking did help, retry FSM:
    local is_successful1, new_step1 = lib.execute_fsm(fsm_table, shrunk_actions)
    if not is_successful1 then
        -- FSM still fails, deleted actions can be ignored, try further shrinking
        return lib.shrink_fsm_actions(fsm_table, shrunk_actions, new_step1, tries - 1)
    end

    -- now FSM works -> faulty action is in the just deleted actions
    local shrunk_down_actions = shrink_deleted_actions(fsm_table, sliced_actions, deleted_actions, fsm_table.numshrinks)
    -- retry fsm:
    -- if a solution could not be found by shrinking down the deleted actions
    --    -> sliced actions is smallest solution found
    -- else if FSM still fails after shrinking deleted_actions
    --    -> try further shrinking
    local is_successful2, new_step2 = lib.execute_fsm(fsm_table, shrunk_down_actions)
    local minimal_actions = is_successful2 and sliced_actions or shrunk_down_actions
    return lib.shrink_fsm_actions(fsm_table, minimal_actions, new_step2, tries - 1)
end

--- Select a group of actions to be marked for shrinking down.
-- @param action_list List of actions to be shrunk down.
-- @return vector of indices for the actions which should be removed
local function select_actions_for_arg_shrinking(action_list)
    local _, idx_vector = lib.select_actions(action_list)
    if idx_vector:size() == 0 and is_between(action_list:size(), 2, 10) then
        -- try shrinking 1 action anyway
        local idx = random.between(1, action_list:size() - 1) -- don't shrink stop action!
        idx_vector:push_back(idx)
    end
    return idx_vector
end

--- Does the actual shrinking of the command arguments of a sequence of actions.
-- @param fsm_table table containing description of a FSM property
-- @param action_list List of actions to be shrunk down
-- @return an updated sequence of the action list (original is modified!) with
--         shrunk arguments.
local function shrink_args(fsm_table, action_list)
    local idx_vector = select_actions_for_arg_shrinking(action_list)

    for i = idx_vector:size(), 1, -1 do -- shrunk from end to beginning (most likely to succeed)
        local idx = idx_vector:get(i)
        local action = action_list:get(idx)

        for _ = 1, fsm_table.numshrinks do
            local command_copy = action.command -- shallow copy (reference only)
            
            action.command = action.cmd_gen:shrink(action.command)
            -- revert if shrink is not valid
            local is_valid = lib.is_action_sequence_valid(fsm_table, action_list)
            if not is_valid then
                action.command = command_copy;
                break
            end
        end
    end

    return action_list
end

--- Shrinks down the arguments provided to the sequence of actions
--  This function is called recursively until all tries are used.
-- @param fsm_table table containing description of a FSM property
-- @param generated_actions List of actions to be shrunk down
-- @param tries Remaining tries to recusively try shrinking the actions
-- @return
local function shrink_fsm_args(fsm_table, generated_actions, tries)
    if tries == 1 then
        return generated_actions
    end

    local shrunk_actions = shrink_args(fsm_table, deep_copy(generated_actions)) -- , fsm_table.numshrinks)

    -- retry FSM
    local is_successful = lib.execute_fsm(fsm_table, shrunk_actions)
    if not is_successful then
        -- FSM still fails, shrinking of args was successful, try further shrinking
        return shrink_fsm_args(fsm_table, shrunk_actions, tries - 1)
    end

    -- FSM works now, shrinking of args unsuccessful -> retry
    return shrink_fsm_args(fsm_table, generated_actions, tries - 1)
end

--- Replays the FSM property.
-- @param fsm_table table containing description of a FSM property
-- @param action_vector List of actions to replay
-- @return last state and result while executing the FSM.
local function replay_fsm(fsm_table, action_vector)
    local _, _, last_state, last_result = lib.execute_fsm(fsm_table, action_vector)
    return last_state, last_result
end

--- Shrinks the list of generated actions for a given FSM.
--  This is a recursive function which keeps trying for X amount of times.
-- @param fsm_table table containing description of a FSM property
-- @param generated_actions List of actions to be shrunk down
-- @param step Last successful step that was executed before FSM failed
-- @return shrunk list of actions or original action list if shrinking did not help.
local function shrink_fsm(fsm_table, generated_actions, step)
    local fsm_shrink_amount = fsm_table.numshrinks
    local shrunk_actions = lib.shrink_fsm_actions(fsm_table, generated_actions, step, fsm_shrink_amount)
    local shrunk_actions_and_args = shrink_fsm_args(fsm_table, shrunk_actions, fsm_shrink_amount)
    local lst_state, lst_result = replay_fsm(fsm_table, shrunk_actions_and_args)
    return shrunk_actions_and_args, lst_state, lst_result
end

--- The main checking function for FSM specifications.
--  Checks a number of times (according to FSM spec) if property is true.
--  If the specification failed, then the result will be shrunk down to a
--  simpler case.
-- @param description string description of the FSM property
-- @param fsm_table table containing description of a FSM property
-- @return nil on success; otherwise a table containing info related to FSM error.
function lib.check(description, fsm_table)
    for _ = 1, fsm_table.numtests do
        local generated_actions = lib.generate_actions(fsm_table)

        local is_successful, last_step = lib.execute_fsm(fsm_table, generated_actions)
        if not is_successful then
            report.report_failed()
            local shrunk_actions, last_state, last_result = shrink_fsm(fsm_table, generated_actions, last_step)
            fsm_table.when_fail(shrunk_actions:to_table(), last_state, last_result)
            return {
                description = description,
                generated_actions = generated_actions,
                shrunk_actions = shrunk_actions,
            }
        end

        report.report_success()
    end

    return nil
end

return lib

