-- utility functions and variables
local utils = {}

-- are we using Lua 5.1 (or below)
utils.lua_5_1 = (_VERSION <= "Lua 5.1")

-- shim functions
utils.loadstring = utils.lua_5_1 and loadstring or load

-- dump object
-- recursively dumps tables, with an optional depth limir (unlimited by default)
-- cf: https://stackoverflow.com/a/27028488/707516
function utils.dump_object(o, depth)
    if depth ~= nil and depth >= 0 then depth = depth - 1 end
    if type(o) == "table" then
        if depth < 0 then return "{...}" end
        local s = "{"
        local c = 0
        for k, v in pairs(o) do
            c = c + 1
            if type(k) ~= "number" then
                k = '"' .. k .. '"'
            end
            s = s .. " [" .. k .. "] = " .. utils.dump_object(v, depth) .. ","
        end
        if c > 0 then s = s:sub(1, -2) .. " " end
        return s .. "}"
    else
        local s = tostring(o)
        if type(o) == "string" then s = '"' .. s .. '"' end
        return s
    end
end

-- table utilities
utils.table = {
    unpack = utils.lua_5_1 and unpack or table.unpack
}

-- update `dst` table by merging the other `src` table
-- `overwrite`: if true (default), overwrite existing `dst` entries with values
-- from `src`, otherwise only merge those not already existing
function utils.table.update(dst, src, overwrite)
    if overwrite == nil then overwrite = true end
    if src then
        for k, v in pairs(src) do
            if overwrite or dst[k] == nil then
                dst[k] = v
            end
        end
    end
    return dst
end

-- get value with default
function utils.table.get(t, k, default)
    if t == nil then t = {} end
    local v = t[k]
    if v == nil then return default end
    return v
end

-- pop values from table (as multiple returns)
-- usage: local a, b = utils.table.pop({ x=1, y=2, z=3 }, 'x', 'z') -> a = 1, b = 3
function utils.table.pop(t, ...)
    if t == nil then t = {} end
    local ret = {}
    for _, k in ipairs(arg) do
        table.insert(ret, t[k])
        t[k] = nil
    end
    return utils.table.unpack(ret)
end

-- lazy table: storing values to be evaluated on the first access
-- usage:
--   local lz = utils.table.lazy()
--   local expensive_eval = function(t) return ... end -- argument `t` is optional
--   lz.foo = expensive_eval
function utils.table.lazy(vars)
    local lazy_t = require('external.lazybag').new()
    getmetatable(lazy_t).__newindex = function(t, k, v)
        t:lazy(k, v)
    end
    for k, v in pairs(vars or {}) do
        lazy_t[k] = v
    end
    return lazy_t
end

-- load Lua file in a separate env to prevent polluting global env
function utils.load_in_env(path, env)
    local _env = env or {}
    if not env then
        setmetatable(_env, { __index = _G }) -- global fallback
    end

    if utils.lua_5_1 then
        local f = loadfile(path)
        if not f then return {} end
        assert(pcall(setfenv(f, _env)))
    else
        local f = loadfile(path, 't', _env)
        if not f then return {} end
        assert(pcall(f))
    end
    if not env then setmetatable(_env, nil) end
    return _env
end

-- enumerate network interfaces, see https://superuser.com/a/1173532/95569
function utils.enum_ifaces()
    local _in_docker = utils.in_docker()
    local ifaces = {}
    local iface_names = utils.sys_call("basename -a /sys/class/net/*")
    for i, l in ipairs(iface_names) do
        local p = utils.sys_call("realpath /sys/class/net/" .. l, true)
        -- for regular host, skip virtual interfaces (including lo)
        -- in container, return all interfaces except lo
        if not p:match("^/sys/devices/virtual/") or (_in_docker and l ~= "lo") then
            table.insert(ifaces, l)
        end
    end
    return ifaces
end

-- enumerate mounted disks
-- by default only show essential filesystems, but customizable
function utils.enum_disks(include_types, exclude_types, exclude_targets)
    local fs_types_default = "ext4,ext3,ext2,xfs,btrfs,zfs,ecryptfs,fuseblk,ntfs3,ntfs,vfat,exfat,fat"
    if utils.in_docker() then
        fs_types_default = fs_types_default .. ",overlay"
    end

    local fs_types = utils.clean_array(
        utils.str_to_array(fs_types_default .. "," .. include_types, ",", true, true),
        utils.str_to_array(exclude_types), true
    )

    local cmd = "findmnt -bPUno TARGET,FSTYPE,SIZE,USED -t " .. utils.join_strs(fs_types, ",")
    local entry_pattern = '^TARGET="(.+)"%s+FSTYPE="(.+)"%s+SIZE="(.+)"%s+USED="(.+)"$'
    local mnt_fs = utils.sys_call(cmd)
    local mnts = {}

    for _, l in ipairs(mnt_fs) do
        local mnt, type, size, used = l:match(entry_pattern)

        for _, p in ipairs(utils.str_to_array(exclude_targets) or {}) do
            if mnt == nil or mnt:match(p) then
                mnt = nil
                break
            end
        end
        if mnt and utils.is_dir(mnt) and utils.is_readable(mnt) then
            table.insert(mnts, {
                mnt = mnt,
                type = type,
                size = tonumber(size),
                used = tonumber(used)
            })
        end
    end
    return mnts
end

-- some environment variables
utils.env = {}
for i, k in ipairs({ "HOME", "USER" }) do
    utils.env[k] = os.getenv(k)
end

-- human friendly file size
local _filesize = require("external.filesize")
function utils.filesize(size)
    return _filesize(size, { round = 0, spacer = "", base = 2 })
end

-- call at interval, similar to Conky's `execi` but for functions
local _interval_call_cache = {}
function utils.interval_call(interv, func, ...)
    if _interval_call_cache[func] == nil then
        _interval_call_cache[func] = {}
    end
    local cache = _interval_call_cache[func]
    local now = os.time()
    if cache.last == nil or (now - cache.last) >= interv then
        cache.result = func(...)
        cache.last = now
    end
    return cache.result
end

-- template renderer. usage:
--   foo_tpl = tpl("this is {%= foo %}")
--   foo_tpl{foo = "bar"} -> "this is bar"
local _liluat = require("external.liluat")
function utils.tpl(t)
    local ct = _liluat.compile(t, { start_tag = "{%", end_tag = "%}" })
    return function(values)
        return _liluat.render(ct, values)
    end
end

-- pad string to `max_len`, `align` mode can be 'l/left', 'r/right' or 'c/center'
function utils.padding(str, max_len, align, char)
    if not max_len then
        return str
    end
    local n = max_len - utils.utf8_len(str)
    if n <= 0 then
        return str
    end

    if not align then
        align = "l"
    end
    if not char then
        char = " "
    end
    assert(utils.utf8_len(char) == 1, "padding `char` must be a single character.")

    local srep = string.rep
    if align == "c" or align == "center" then
        local m = math.floor(n / 2)
        return srep(char, m) .. str .. srep(char, n - m)
    elseif align == "l" or align == "left" then
        return str .. srep(char, n)
    elseif align == "r" or align == "right" then
        return srep(char, n) .. str
    end
end

-- strip surrounding whitespaces
function utils.trim(str)
    return str:match("^%s*(.-)%s*$")
end

-- strip surrounding braces
function utils.unbrace(str)
    if not str then
        return str
    end
    while true do
        local u = str:match("^{(.-)}$")
        if u then
            str = u
        else
            return str
        end
    end
end

-- count characters in a utf-8 encoded string
function utils.utf8_len(str)
    local _, count = string.gsub(str, "[^\128-\193]", "")
    return count
end

-- split comma-separated string to array, supported separators are: ,(default) ; : - _ +
-- if str is already an array (table), it is returned without processing
-- if trim is true, surrounding spaces around each item are trimmed
-- if ignore_empty is true, only non-empty items (after optional trimming) are added
function utils.str_to_array(str, sep, trim, ignore_empty)
    if type(str) == "table" then return str end
    if type(str) ~= "string" then return nil end
    if sep == nil then sep = "," end
    if trim == nil then trim = false end
    if ignore_empty == nil then ignore_empty = false end

    if type(sep) ~= "string" or #sep ~= 1
        or not sep:match("[%,%;%:%-%_%+]") then
        return nil
    end
    local arr = {}
    local p = 1
    local function _append(s)
        if trim then s = utils.trim(s) end
        if #s > 0 or not ignore_empty then
            table.insert(arr, s)
        end
    end
    while true do
        local q = str:find(sep, p, true)
        if q then
            _append(string.sub(str, p, q - 1))
            p = q + 1
        else
            _append(string.sub(str, p))
            break
        end
    end
    return arr
end

-- clean array: exclude certain items and/or remove duplicate items
function utils.clean_array(arr, exclude, dedup)
    local exclude_hash = {}
    local dedup_hash = {}
    local cleaned = {}

    for _, k in ipairs(exclude) do
        exclude_hash[k] = true
    end

    for _, k in ipairs(arr) do
        if dedup and dedup_hash[k] or exclude_hash[k] then else
            table.insert(cleaned, k)
            if dedup then dedup_hash[k] = true end
        end
    end
    return cleaned
end

-- join strings stored in an array
function utils.join_strs(strs, sep)
    return table.concat(strs, sep)
end

-- round float to integer or specified number of digits
function utils.round(x, ndigits)
    ndigits = math.floor(ndigits or 0)
    if ndigits <= 0 then return math.floor(x + 0.5) end
    local pow = 10 ^ ndigits
    return math.floor(x * pow + 0.5) / pow
end

-- calculate ratio as percentage
function utils.ratio_perc(x, y, ndigits)
    return utils.round(100.0 * tonumber(x) / tonumber(y), ndigits)
end

-- run system command and return stdout as lines or a string
function utils.sys_call(cmd, as_string)
    local pipe = io.popen(cmd .. [[;echo "\n$?"]])
    if not pipe then return nil, 1 end

    local lines = {}
    for l in pipe:lines() do
        table.insert(lines, l)
    end
    pipe:close()

    local return_code = tonumber(table.remove(lines))
    if as_string then
        return table.concat(lines, "\n"), return_code
    else
        return lines, return_code
    end
end

-- eval string as system call and check if result is true
function utils.is_true(expr)
    local s = utils.sys_call(expr .. ' && echo "true"', true)
    return (#s > 3)
end

-- is dir or file
function utils.is_dir(p)
    return utils.is_true('[ -d "' .. p .. '" ]')
end

-- is path readable
function utils.is_readable(p)
    return utils.is_true('[ -r "' .. p .. '" ]')
end

-- is running in a docker container
function utils.in_docker()
    return utils.is_true('[ -f /.dockerenv ] || grep -Eq "(lxc|docker)" /proc/1/cgroup')
end

return utils
