--!A cross-platform build utility based on Lua
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- Copyright (C) 2015-present, Xmake Open Source Community.
--
-- @author      ruki
-- @file        runjobs.lua
--

-- imports
import("core.base.scheduler")
import("utils.progress")
import("utils.waiting_indicator")

-- print back characters
function _print_backchars(backnum)
    if backnum > 0 then
        local str = ('\b'):rep(backnum) .. (' '):rep(backnum) .. ('\b'):rep(backnum)
        if #str > 0 then
            printf(str)
        end
    end
end

-- init waiting indicator
function _init_waiting_indicator(state, opt)
    opt = opt or {}

    -- init waiting indicator helper
    -- we need to hide wait characters if is not a tty
    local waiting_indicator_opt = opt.waiting_indicator

    -- compatibility: support deprecated opt.progress parameter
    if opt.progress ~= nil and waiting_indicator_opt == nil then
        waiting_indicator_opt = opt.progress
        wprint("opt.progress is deprecated in runjobs, use opt.waiting_indicator instead")
    end

    state.show_waiting_indicator = io.isatty() and (waiting_indicator_opt or type(waiting_indicator_opt) == "table")
    state.backnum = 0
    if state.show_waiting_indicator then
        local indicator_opt = nil
        if type(waiting_indicator_opt) == "table" then
            indicator_opt = waiting_indicator_opt
        end
        state.waiting_indicator_helper = waiting_indicator.new(nil, indicator_opt)
    end
end

-- init progress
function _init_progress(state, opt)
    opt = opt or {}

    -- init progress wrapper
    state.progress_finished_count = 0
    state.progress_factor = opt.progress_factor or 1.0
    local progress_wrapper = {}
    local progress_wrapper_data = {}
    progress_wrapper.current = function ()
        return state.progress_finished_count
    end
    progress_wrapper.total = function ()
        return state.total
    end
    progress_wrapper.percent = function ()
        local total = state.total
        if total and total > 0 then
            return math.floor((state.progress_finished_count * state.progress_factor * 100) / total)
        else
            return 0
        end
    end
    progress_wrapper.set = function (_, key, value)
        local running = scheduler.co_running()
        if running then
            local co_data = progress_wrapper_data[running]
            if not co_data then
                co_data = {}
                progress_wrapper_data[running] = co_data
            end
            co_data[key] = value
        end
    end
    progress_wrapper.get = function (_, key)
        local running = scheduler.co_running()
        if running then
            local co_data = progress_wrapper_data[running]
            if co_data then
                return co_data[key]
            end
        end
    end
    debug.setmetatable(progress_wrapper, {
        __tostring = function ()
            return string.format("%d%%", progress_wrapper.percent())
        end
    })
    state.progress_wrapper = progress_wrapper

end

-- start timer (on_timer callback)
function _start_timer(state, name, opt)
    if opt.on_timer then
        state.on_timer = opt.on_timer
        state.group_timer = state.group_name .. "/timer"
        -- create semaphore for timer loop to wait with timeout for quick exit
        state.timer_semaphore = scheduler.co_semaphore(state.group_name .. "/timer", 0)
        scheduler.co_group_begin(state.group_timer, function (co_group)
            scheduler.co_start_withopt({name = name .. "/timer", isolate = opt.isolate}, _timer_loop, state)
        end)
    end
end

-- start waiting indicator timer
function _start_waiting_indicator_timer(state, name, opt)
    if state.show_waiting_indicator then
        state.group_waiting_indicator_timer = state.group_name .. "/waiting_indicator"
        -- create semaphore for waiting indicator loop to wait with timeout for quick exit
        state.waiting_indicator_semaphore = scheduler.co_semaphore(state.group_name .. "/waiting_indicator", 0)
        scheduler.co_group_begin(state.group_waiting_indicator_timer, function (co_group)
            scheduler.co_start_withopt({name = name .. "/waiting_indicator", isolate = opt.isolate}, _waiting_indicator_loop, state)
        end)
    end
end

-- start progress refresh timer for multirow progress
function _start_progress_refresh_timer(state, name, opt)
    if opt.progress_refresh and progress.is_multirow() then
        state.group_progress_refresh_timer = state.group_name .. "/progress_refresh"
        state.all_tasks_started = false
        -- create semaphore for refresh loop
        state.progress_refresh_semaphore = scheduler.co_semaphore(state.group_name .. "/progress_refresh", 0)
        -- start progress refresh loop
        scheduler.co_group_begin(state.group_progress_refresh_timer, function (co_group)
            scheduler.co_start_withopt({name = name .. "/progress_refresh", isolate = opt.isolate}, _progress_refresh_loop, state)
        end)
    end
end

-- start all timers
function _start_timers(state, name, opt)
    _start_timer(state, name, opt)
    _start_waiting_indicator_timer(state, name, opt)
    _start_progress_refresh_timer(state, name, opt)
end

-- stop all timers and notify them to exit
function _stop_timers(state)
    -- signal all timer loops to exit quickly
    if state.timer_semaphore then
        state.timer_semaphore:post(1)
    end
    if state.waiting_indicator_semaphore then
        state.waiting_indicator_semaphore:post(1)
    end
    if state.progress_refresh_semaphore then
        state.progress_refresh_semaphore:post(1)
    end
end

-- wait all timer jobs exited
function _wait_timers(state)
    if state.group_timer then
        scheduler.co_group_wait(state.group_timer)
    end
    if state.group_waiting_indicator_timer then
        scheduler.co_group_wait(state.group_waiting_indicator_timer)
    end
    if state.group_progress_refresh_timer then
        scheduler.co_group_wait(state.group_progress_refresh_timer)
    end
end

-- exit waiting indicator
function _exit_waiting_indicator(state)
    if state.show_waiting_indicator then
        _print_backchars(state.backnum)
        state.waiting_indicator_helper:stop()
    end
end

-- exit progress
function _exit_progress(state)
    progress.show_abort()
end

-- isolate environments
function _isolate_environments(state, opt)
    local co_running = scheduler.co_running()
    if co_running and opt.isolate then
        local is_isolated = co_running:is_isolated()
        co_running:isolate(true)
        state.isolated_running = co_running
        state.is_isolated = is_isolated
    end
end

-- restore isolated environments
function _restore_isolated_environments(state, opt)
    if state.isolated_running and opt.isolate and state.is_isolated ~= nil then
        state.isolated_running:isolate(state.is_isolated)
    end
end

-- run jobs and wait for completion
function _run_jobs(state, name, opt)
    local distcc = opt.distcc
    state.abort = false
    state.abort_errors = nil
    state.finished_count = 0
    state.curdir = opt.curdir
    state.waiting_count = 0
    state.distcc_waiting_count = 0
    scheduler.co_group_begin(state.group_name, function (co_group)
        state.semaphore = scheduler.co_semaphore(state.group_name, 0)
        if distcc then
            state.distcc_semaphore = scheduler.co_semaphore(state.group_name .. "/distcc", 0)
        end
        -- @note we can set `remote_only = true` to run all jobs in remote only
        local local_comax = 0
        if not opt.remote_only then
            local_comax = math.min(state.total, state.comax)
            for id = 1, local_comax do
                scheduler.co_start_withopt({name = name .. '/' .. tostring(id), isolate = opt.isolate}, _consume_jobs_loop, state, false)
            end
        end
        if distcc then
            local left_comax = state.total - local_comax
            local remote_comax = math.min(distcc:freejobs(), left_comax)
            for id = 1, remote_comax do
                scheduler.co_start_withopt({name = name .. '/distcc/' .. tostring(id), isolate = opt.isolate}, _consume_jobs_loop, state, true)
            end
        end
    end)

    -- wait all jobs exited
    scheduler.co_group_wait(state.group_name)
    state.stop = true

    -- stop all timers and notify them to exit
    _stop_timers(state)

    -- wait all timer jobs exited
    _wait_timers(state)
end

-- cleanup and handle errors after jobs completion
function _cleanup_jobs(state, opt)
    -- restore isolated environments
    _restore_isolated_environments(state, opt)

    -- exit progress
    _exit_progress(state)

    -- exit waiting indicator
    _exit_waiting_indicator(state)

    -- do exit callback
    if opt.on_exit then
        opt.on_exit(state.abort_errors)
    end

    -- re-throw abort errors
    --
    -- @note we cannot throw it in coroutine,
    -- because his causes a direct exit from the entire runloop and
    -- a quick escape from nested try-catch blocks and coroutines groups.
    -- so we can not catch runjobs errors, e.g. build fails
    if state.abort then
        raise(state.abort_errors)
    end
end

-- the timer loop
function _timer_loop(state)
    local timeout = state.timeout
    while not state.stop do
        -- wait for timeout, allows quick exit when state.stop is set via post
        state.timer_semaphore:wait(timeout)
        if not state.stop then
            local indices
            if state.running_jobs_indices then
                indices = table.keys(state.running_jobs_indices)
            end
            state.on_timer(indices)
        end
    end

    -- we cannot post semaphore in a dead coroutine
    state.timer_semaphore = nil
end

-- the refresh loop for multirow progress (independent timer with its own timeout)
function _progress_refresh_loop(state)
    -- wait until all tasks have been started using semaphore
    if state.progress_refresh_semaphore and not state.stop then
        state.progress_refresh_semaphore:wait(-1)
    else
        return
    end

    -- start refreshing progress using semaphore wait with timeout for quick exit
    while not state.stop do
        -- wait for refresh timeout, allows quick exit when state.stop is set via post
        state.progress_refresh_semaphore:wait(state.timeout)

        -- refresh progress if not stopped
        if not state.stop then
            progress.refresh()
        end
    end

    -- we cannot post semaphore in a dead coroutine
    state.progress_refresh_semaphore = nil
end

-- the waiting indicator loop
function _waiting_indicator_loop(state)
    local timeout = state.timeout
    local waiting_indicator_helper = state.waiting_indicator_helper
    while not state.stop do
        -- wait for timeout, allows quick exit when state.stop is set via post
        state.waiting_indicator_semaphore:wait(timeout)
        if not state.stop then

            -- show waitchars
            local tips = nil
            local waitobjs = scheduler.co_group_waitobjs(state.group_name)
            if waitobjs:size() > 0 then
                local names = {}
                for _, obj in waitobjs:keys() do
                    if obj:otype() == scheduler.OT_PROC then
                        table.insert(names, obj:name())
                    elseif obj:otype() == scheduler.OT_SOCK then
                        table.insert(names, "sock")
                    elseif obj:otype() == scheduler.OT_PIPE then
                        table.insert(names, "pipe")
                    end
                end
                names = table.unique(names)
                if #names > 0 then
                    names = table.concat(names, ",")
                    if #names > 16 then
                        names = names:sub(1, 16) .. ".."
                    end
                    tips = string.format("(%d/%s)", waitobjs:size(), names)
                end
            end

            -- print back characters
            waiting_indicator_helper:clear()
            _print_backchars(state.backnum)

            if tips then
                cprintf("${dim}%s${clear} ", tips)
                state.backnum = #tips + 1
            end
            waiting_indicator_helper:write()
        end
    end

    -- we cannot post semaphore in a dead coroutine
    state.waiting_indicator_semaphore = nil
end

-- consume jobs
function _consume_jobs_loop(state, run_in_remote)
    local jobs = state.jobs
    local jobs_cb = state.jobs_cb
    local total = state.total
    local curdir = state.curdir
    local semaphore = state.semaphore
    local distcc_semaphore = state.distcc_semaphore
    local co_running = scheduler.co_running()
    while state.finished_count < total and not state.stop do

        -- get free job
        local job
        local job_func = jobs_cb
        local job_distcc = false
        if not job_func then
            job = jobs:getfree()
            if job then
                if job.distcc then
                    job_distcc = true
                end
                job_func = job.run
                -- notify other coroutines to consume jobs
                if run_in_remote then
                    if state.distcc_waiting_count > 0 then
                        local left_count = total - state.finished_count
                        local post_count = math.min(left_count, state.distcc_waiting_count)
                        distcc_semaphore:post(post_count)
                    end
                else
                    if state.waiting_count > 0 then
                        local left_count = total - state.finished_count
                        local post_count = math.min(left_count, state.waiting_count)
                        semaphore:post(post_count)
                    end
                end
            elseif state.finished_count < total then
                -- no free jobs now, wait other coroutines
                if run_in_remote then
                    state.distcc_waiting_count = state.distcc_waiting_count + 1
                    distcc_semaphore:wait(-1)
                    state.distcc_waiting_count = state.distcc_waiting_count - 1
                else
                    state.waiting_count = state.waiting_count + 1
                    semaphore:wait(-1)
                    state.waiting_count = state.waiting_count - 1
                end
            else
                break
            end
        end

        try
        {
            function ()

                -- mark the current coroutine to run remote job
                if run_in_remote and co_running then
                    co_running:data_set("distcc.distccjob", job_distcc)
                end

                -- run job
                co_running:data_set("runjobs.running", true)
                local job_index = state.finished_count + 1
                state.running_jobs_indices[job_index] = job_index
                if job_func then
                    if curdir then
                        os.cd(curdir)
                    end

                    -- to avoid running the same task repeatedly,
                    -- we need to update the completion count in advance.
                    state.finished_count = state.finished_count + 1

                    -- check if all tasks have been started (all consumed, but may still be running)
                    if state.finished_count >= total and not state.all_tasks_started then
                        state.all_tasks_started = true
                        -- notify refresh loop that all tasks have been started
                        if state.progress_refresh_semaphore then
                            state.progress_refresh_semaphore:post(1)
                        end
                    end

                    -- run job
                    job_func(job_index, total, {progress = state.progress_wrapper})

                    -- update progress
                    state.progress_finished_count = state.progress_finished_count + 1
                end
                state.running_jobs_indices[job_index] = nil
                co_running:data_set("runjobs.running", false)
            end,
            catch
            {
                function (errors)
                    -- stop timer and disable show waitchars first
                    state.stop = true

                    -- stop progress and waiting indicator
                    _exit_progress(state)
                    _exit_waiting_indicator(state)

                    -- we need re-throw this errors outside scheduler
                    state.abort = true
                    if state.abort_errors == nil then
                        state.abort_errors = errors
                    end

                    -- kill all waited objects in this group
                    local waitobjs = scheduler.co_group_waitobjs(state.group_name)
                    if waitobjs:size() > 0 then
                        for _, obj in waitobjs:keys() do
                            -- TODO, kill pipe is not supported now
                            if obj.kill then
                                obj:kill()
                            end
                        end
                    end
                end
            },
            finally
            {
                function ()
                    if job then
                        jobs:remove(job)
                    end
                end
            }
        }
    end

    -- notify left waiting coroutines
    if state.distcc_waiting_count > 0 then
        distcc_semaphore:post(state.distcc_waiting_count)
    end
    if state.waiting_count > 0 then
        semaphore:post(state.waiting_count)
    end
end

-- asynchronous run jobs
--
-- e.g.
-- runjobs("test", function (index) print("hello") end, {total = 100, comax = 6, timeout = 1000, on_timer = function (running_jobs_indices) end})
-- runjobs("test", function () os.sleep(10000) end, { waiting_indicator = true })
-- runjobs("test", function () os.sleep(10000) end, { waiting_indicator = { chars = {'/','\'} } }) -- see module utils.waiting_indicator
-- runjobs("test", function () os.sleep(10000) end, { waiting_indicator = true, progress_refresh = true }) -- enable progress refresh timer for multirow progress
--
-- local jobs = jobpool.new()
-- local root = jobs:addjob("job/root", function (index, total, opt)
--   print(index, total, opt.progress)
-- end)
-- for i = 1, 3 do
--     local job = jobs:addjob("job/" .. i, function (index, total, opt)
--         print(index, total, opt.progress)
--     end, {rootjob = root})
-- end
-- runjobs("test", jobs, {comax = 6, timeout = 1000, on_timer = function (running_jobs_indices) end})
--
-- distributed build:
-- runjobs("test", jobs, {comax = 6, distcc = distcc_build_client.singleton()}
--
function main(name, jobs, opt)
    opt = opt or {}

    -- init state
    local state = {}
    state.total = opt.total or (type(jobs) == "table" and jobs:size()) or 1
    state.comax = opt.comax and tonumber(opt.comax) or math.min(state.total, 4)
    state.timeout = opt.timeout or 500
    state.group_name = name
    state.jobs_cb = type(jobs) == "function" and jobs or nil
    state.stop = false
    state.running_jobs_indices = {}
    assert(state.timeout < 60000, "runjobs: invalid timeout!")

    -- build jobs queue
    if type(jobs) == "table" and jobs.build then
        jobs = jobs:build()
    end
    assert(jobs, "runjobs: no jobs!")
    state.jobs = jobs

    -- init waiting indicator
    _init_waiting_indicator(state, opt)

    -- init progress
    _init_progress(state, opt)

    -- isolate environments
    _isolate_environments(state, opt)

    -- start all timers
    _start_timers(state, name, opt)

    -- run jobs and wait for completion
    _run_jobs(state, name, opt)

    -- cleanup and handle errors
    _cleanup_jobs(state, opt)
end
