local _ENV = mkmodule('script-manager')

local utils = require('utils')

---------------------
-- enabled API

-- for each script that can be loaded as a module, calls cb(script_name, env)
function foreach_module_script(cb, preprocess_script_file_fn)
    for _,script_path in ipairs(dfhack.internal.getScriptPaths()) do
        local files = dfhack.filesystem.listdir_recursive(
                                            script_path, nil, false)
        if not files then goto skip_path end
        for _,f in ipairs(files) do
            if f.isdir or not f.path:endswith('.lua') or
                    f.path:startswith('.git') or
                    f.path:startswith('test/') or
                    f.path:startswith('internal/') then
                goto continue
            end
            if preprocess_script_file_fn then
                preprocess_script_file_fn(script_path, f.path)
            end
            local script_name = f.path:sub(1, #f.path - 4) -- remove '.lua'
            local ok, script_env = pcall(reqscript, script_name)
            if ok then
                cb(script_name, script_env)
            end
            ::continue::
        end
        ::skip_path::
    end
end

local enabled_map = nil

local function process_script(env_name, env)
    local global_name = 'isEnabled'
    local fn = env[global_name]
    if not fn then return end
    if type(fn) ~= 'function' then
        dfhack.printerr(
                ('error registering %s() from "%s": global' ..
                ' value is not a function'):format(global_name, env_name))
        return
    end
    enabled_map[env_name] = fn
end

function reload(refresh_active_mod_scripts)
    enabled_map = utils.OrderedTable()
    local force_refresh_fn = refresh_active_mod_scripts and function(script_path, script_name)
        if script_path:find('scripts_modactive') then
            local full_path = script_path..'/'..script_name
            internal_script = dfhack.internal.scripts[full_path]
            if internal_script then
                dfhack.internal.scripts[full_path] = nil
            end
        end
    end or nil
    foreach_module_script(process_script, force_refresh_fn)
end

local function ensure_loaded()
    if not enabled_map then
        reload()
    end
end

function list()
    ensure_loaded()
    for name,fn in pairs(enabled_map) do
        print(('%21s  %-3s'):format(name..':', fn() and 'on' or 'off'))
    end
end

---------------------
-- mod paths

-- this perhaps could/should be queried from the Steam API
-- are there any installation configurations where this will be wrong, though?
local WORKSHOP_MODS_PATH = '../../workshop/content/975370/'
local MODS_PATH = dfhack.filesystem.getBaseDir() .. 'mods/'
local INSTALLED_MODS_PATH = dfhack.filesystem.getBaseDir() .. 'data/installed_mods/'

-- last instance of the same version of the same mod wins, so read them in this
-- order (in increasing order of liklihood that players may have made custom
-- changes to the files)
local MOD_PATH_ROOTS = {WORKSHOP_MODS_PATH, MODS_PATH, INSTALLED_MODS_PATH}

-- returns the values of the given list of tags from the info.txt file in the given mod directory
-- if a requested tag includes the string 'NUMERIC_', it will return the numeric value for that tag
-- (e.g. NUMERIC_VERSION will return the numeric version of the mod as a number instead of a string).
function get_mod_info_metadata(mod_path, tags)
    local idfile = mod_path .. '/info.txt'
    local ok, lines = pcall(io.lines, idfile)
    if not ok then return end

    if type(tags) ~= 'table' then
        tags = {tags}
    end

    local tag_regexes = {}
    for _,tag in ipairs(tags) do
        local tag_regex = ('^%%[%s:'):format(tag)
        if tag:find('NUMERIC_') then
            -- note this doesn't go all the way to the closing brace since some people put
            -- non-number characters in here, and DF only reads the leading digits for
            -- numeric fields
            tag_regex = tag_regex .. '(%d+)'
        else
            tag_regex = tag_regex .. '([^%]]+)'
        end
        tag_regexes[tag] = tag_regex
    end

    local values = {}
    for line in lines do
        local _,_,info_tag = line:find('^%[([^:]+):')
        if not info_tag or not tag_regexes[info_tag] then
            goto continue
        end
        local _,_,value = line:find(tag_regexes[info_tag])
        if value then
            values[info_tag] = value
        end
        ::continue::
    end

    return values
end

local function get_mod_id_and_version(path)
    local values = get_mod_info_metadata(path, {'ID', 'NUMERIC_VERSION'})
    if not values then return end
    return values.ID, values.NUMERIC_VERSION
end

local function add_mod_paths(mod_paths, id, base_path, subdir)
    local sep = base_path:endswith('/') and '' or '/'
    local path = ('%s%s%s'):format(base_path, sep, subdir)
    if dfhack.filesystem.isdir(path) then
        table.insert(mod_paths, {id=id, path=path})
    end
end

function get_mod_paths(installed_subdir, active_subdir)
    -- ordered map of mod id -> {handled=bool, versions=map of version -> path}
    local mods = utils.OrderedTable()
    local mod_paths = {}

    -- if a world is loaded, process active mods first, and lock to active version
    if dfhack.isWorldLoaded() then
        for _,path in ipairs(df.global.world.object_loader.object_load_order_src_dir) do
            path = dfhack.filesystem.getBaseDir() .. path
            -- skip vanilla "mods"
            if not path:startswith(INSTALLED_MODS_PATH) then goto continue end
            local id = get_mod_id_and_version(path)
            if not id then goto continue end
            mods[id] = {handled=true}
            if active_subdir then
                add_mod_paths(mod_paths, id, path, active_subdir)
            end
            add_mod_paths(mod_paths, id, path, installed_subdir)
            ::continue::
        end
    end

    -- assemble version -> path maps for all (non-handled) mod source dirs
    for _,mod_path_root in ipairs(MOD_PATH_ROOTS) do
        local files = dfhack.filesystem.listdir_recursive(mod_path_root, 0)
        if not files then goto skip_path_root end
        for _,f in ipairs(files) do
            if not f.isdir then goto continue end
            local id, version = get_mod_id_and_version(f.path)
            if not id or not version then goto continue end
            local mod = ensure_key(mods, id)
            if mod.handled then goto continue end
            ensure_key(mod, 'versions')[version] = f.path
            ::continue::
        end
        ::skip_path_root::
    end

    -- add paths from most recent version of all not-yet-handled mods
    for id,v in pairs(mods) do
        if v.handled then goto continue end
        local max_version, path
        for version,mod_path in pairs(v.versions) do
            if not max_version or max_version < version then
                path = mod_path
                max_version = version
            end
        end
        add_mod_paths(mod_paths, id, path, installed_subdir)
        ::continue::
    end

    return mod_paths
end

-- returns a list of tables in load order with the following fields:
--   id: mod id
--   name: mod display name
--   version: mod display version
--   numeric_version: numeric mod version
--   path: path to the mod directory
--   vanilla: true if this is a vanilla mod
function get_active_mods()
    if not dfhack.isWorldLoaded() then return {} end

    local mods = {}

    local ol = df.global.world.object_loader

    for idx=0,#ol.object_load_order_id-1 do
        local path = ol.object_load_order_src_dir[idx]
        table.insert(mods, {
            id=ol.object_load_order_id[idx].value,
            name=ol.object_load_order_name[idx].value,
            version=ol.object_load_order_displayed_version[idx].value,
            numeric_version=ol.object_load_order_numeric_version[idx],
            path=path,
            vanilla=path:startswith('data/vanilla/'), -- windows also uses forward slashes
        })
    end

    return mods
end

function get_mod_script_paths()
    local paths = {}
    for _,v in ipairs(get_mod_paths('scripts_modinstalled', 'scripts_modactive')) do
        table.insert(paths, v.path)
    end
    return paths
end

function getModSourcePath(mod_id)
    for _,v in ipairs(get_mod_paths('.')) do
        if v.id == mod_id then
            -- trim off the final '.'
            return v.path:sub(1, #v.path-1)
        end
    end
end

function getModStatePath(mod_id)
    local path = ('dfhack-config/mods/%s/'):format(mod_id)
    if not dfhack.filesystem.mkdir_recursive(path) then
        error(('failed to create mod state directory: "%s"'):format(path))
    end
    return path
end

---------------------
-- perf API

local function format_time(ms)
    return ('%8d ms (%dm %ds)'):format(ms, ms // 60000, (ms % 60000) // 1000)
end

local function format_relative_time(width, name, ms, rel1_ms, desc1, rel2_ms, desc2)
    local fmt = '%' .. tostring(width) .. 's %8d ms (%6.2f%% of %s'
    local str = fmt:format(name, ms, (ms * 100) / rel1_ms, desc1)
    if rel2_ms then
        str = str .. (', %6.2f%% of %s'):format((ms * 100) / rel2_ms, desc2)
    end
    return str .. ')'
end

local function print_sorted_timers(in_timers, width, rel1_ms, desc1, rel2_ms, desc2)
    local sum = 0
    local sorted = {}
    for name,timer in pairs(in_timers) do
        table.insert(sorted, {name=name, ms=timer})
        sum = sum + timer
    end
    table.sort(sorted, function(a, b) return a.ms > b.ms end)
    for _, elem in ipairs(sorted) do
        if elem.ms > 0 then
            print(format_relative_time(width, elem.name, elem.ms, rel1_ms, desc1, rel2_ms, desc2))
        end
    end
    local framework_time = math.max(0, rel1_ms - sum)
    print()
    print(format_relative_time(width, 'framework', framework_time, rel1_ms, desc1, rel2_ms, desc2))
    print(format_relative_time(width, 'all subtimers', sum, rel1_ms, desc1, rel2_ms, desc2))
end

function print_timers()
    local summary, em_per_event, em_per_plugin_per_event, update_per_plugin, state_change_per_plugin,
        update_lua_per_repeat, overlay_per_widget, zscreen_per_focus = dfhack.internal.getPerfCounters()

    local elapsed = summary.elapsed_ms
    local total_update_time = summary.total_update_ms
    local total_overlay_time = summary.total_overlay_ms
    local total_zscreen_time = summary.total_zscreen_ms

    print('Summary')
    print('-------')
    print()
    print(('Measuring %s'):format(summary.unpaused_only == 1 and 'unpaused time only' or 'paused and unpaused time'))
    print()
    print(('%7s %s'):format('elapsed', format_time(elapsed)))

    if elapsed <= 0 then return end

    local sum = summary.total_keybinding_ms + total_update_time + total_overlay_time + total_zscreen_time
    print(format_relative_time(7, 'dfhack', sum, elapsed, 'elapsed'))

    if sum > 0 then
        print()
        print()
        print('DFHack details')
        print('--------------')
        print()
        print(format_relative_time(10, 'keybinding', summary.total_keybinding_ms, sum, 'dfhack', elapsed, 'elapsed'))
        print(format_relative_time(10, 'update', total_update_time, sum, 'dfhack', elapsed, 'elapsed'))
        print(format_relative_time(10, 'overlay', total_overlay_time, sum, 'dfhack', elapsed, 'elapsed'))
        print(format_relative_time(10, 'zscreen', total_zscreen_time, sum, 'dfhack', elapsed, 'elapsed'))
    end

    if total_update_time > 0 then
        print()
        print()
        print('Update details')
        print('--------------')
        print()
        print(format_relative_time(15, 'event manager', summary.update_event_manager_ms, total_update_time, 'update', elapsed, 'elapsed'))
        print(format_relative_time(15, 'plugin onUpdate', summary.update_plugin_ms, total_update_time, 'update', elapsed, 'elapsed'))
        print(format_relative_time(15, 'lua timers', summary.update_lua_ms, total_update_time, 'update', elapsed, 'elapsed'))
    end

    if summary.update_event_manager_ms > 0 then
        print()
        print()
        print('Event manager per event type')
        print('----------------------------')
        print()
        print_sorted_timers(em_per_event, 25, summary.update_event_manager_ms, 'event manager', elapsed, 'elapsed')

        for k,v in pairs(em_per_plugin_per_event) do
            if em_per_event[k] <= 0 then goto continue end
            print()
            print()
            local title = ('Event manager %s event per plugin'):format(k)
            print(title)
            print(('-'):rep(#title))
            print()
            print_sorted_timers(v, 25, em_per_event[k], 'event type', elapsed, 'elapsed')
            ::continue::
        end
    end

    if summary.update_plugin_ms > 0 then
        print()
        print()
        print('Update per plugin')
        print('-----------------')
        print()
        print_sorted_timers(update_per_plugin, 25, summary.update_plugin_ms, 'update', elapsed, 'elapsed')
        print()
        print()
        print('State change per plugin')
        print('-----------------------')
        print()
        print_sorted_timers(state_change_per_plugin, 25, summary.update_plugin_ms, 'update', elapsed, 'elapsed')
    end

    if summary.update_lua_ms > 0 then
        print()
        print()
        print('Lua timer details')
        print('-----------------')
        print()
        print_sorted_timers(update_lua_per_repeat, 45, summary.update_lua_ms, 'lua timers', elapsed, 'elapsed')
    end

    if total_overlay_time > 0 then
        print()
        print()
        print('Overlay details')
        print('---------------')
        print()
        print_sorted_timers(overlay_per_widget, 45, total_overlay_time, 'overlay', elapsed, 'elapsed')
    end

    if total_zscreen_time > 0 then
        print()
        print()
        print('ZScreen details')
        print('---------------')
        print()
        print_sorted_timers(zscreen_per_focus, 45, total_zscreen_time, 'zscreen', elapsed, 'elapsed')
    end
end

return _ENV
