--[[
    logging.lua: pandoc-aware logging functions (can also be used standalone)
    Copyright:   (c) 2022 William Lupton
    License:     MIT - see LICENSE file for details
    Usage:       See README.md for details
]]

-- if running standalone, create a 'pandoc' global
if not pandoc then
    _G.pandoc = {utils = {}}
end

-- if there's no pandoc.utils, create a local one
if not pcall(require, 'pandoc.utils') then
    pandoc.utils = {}
end

-- if there's no pandoc.utils.type, create a local one
if not pandoc.utils.type then
    pandoc.utils.type = function(value)
        local typ = type(value)
        if not ({table=1, userdata=1})[typ] then
            -- unchanged
        elseif value.__name then
            typ = value.__name
        elseif value.tag and value.t then
            typ = value.tag
            if typ:match('^Meta.') then
                typ = typ:sub(5)
            end
            if typ == 'Map' then
                typ = 'table'
            end
        end
        return typ
    end
end

-- namespace
local logging = {}

-- helper function to return a sensible typename
logging.type = function(value)
    -- this can return 'Inlines', 'Blocks', 'Inline', 'Block' etc., or
    -- anything that built-in type() can return, namely 'nil', 'number',
    -- 'string', 'boolean', 'table', 'function', 'thread', or 'userdata'
    local typ = pandoc.utils.type(value)

    -- it seems that it can also return strings like 'pandoc Row'; replace
    -- spaces with periods
    -- XXX I'm not sure that this is done consistently, e.g. I don't think
    --     it's done for pandoc.Attr or pandoc.List?
    typ = typ:gsub(' ', '.')

    -- map Inline and Block to the tag name
    -- XXX I guess it's intentional that it doesn't already do this?
    return ({Inline=1, Block=1})[typ] and value.tag or typ
end

-- derived from https://www.lua.org/pil/19.3.html pairsByKeys()
logging.spairs = function(list, comp)
    local keys = {}
    for key, _ in pairs(list) do
        table.insert(keys, tostring(key))
    end
    table.sort(keys, comp)
    local i = 0
    local iter = function()
        i = i + 1
        return keys[i] and keys[i], list[keys[i]] or nil
    end
    return iter
end

-- helper function to dump a value with a prefix (recursive)
-- XXX should detect repetition/recursion
-- XXX would like maxlen logic to apply at all levels? but not trivial
local function dump_(prefix, value, maxlen, level, add)
    local buffer = {}
    if prefix == nil then prefix = '' end
    if level == nil then level = 0 end
    if add == nil then add = function(item) table.insert(buffer, item) end end
    local indent = maxlen and '' or ('  '):rep(level)

    -- get typename, mapping to pandoc tag names where possible
    local typename = logging.type(value)

    -- don't explicitly indicate 'obvious' typenames
    local typ = (({boolean=1, number=1, string=1, table=1, userdata=1})
                 [typename] and '' or typename)

    -- light userdata is just a pointer (can't iterate over it)
    -- XXX is there a better way of checking for light userdata?
    if type(value) == 'userdata' and not pcall(pairs(value)) then
        value = tostring(value):gsub('userdata:%s*', '')

    -- modify the value heuristically
    elseif ({table=1, userdata=1})[type(value)] then
        local valueCopy, numKeys, lastKey = {}, 0, nil
        for key, val in pairs(value) do
            -- pandoc >= 2.15 includes 'tag', nil values and functions
            if key ~= 'tag' and val and type(val) ~= 'function' then
                valueCopy[key] = val
                numKeys = numKeys + 1
                lastKey = key
            end
        end
        if numKeys == 0 then
            -- this allows empty tables to be formatted on a single line
            value = typename == 'Space' and '' or '{}'
        elseif numKeys == 1 and lastKey == 'text' then
            -- this allows text-only types to be formatted on a single line
            typ = typename
            value = value[lastKey]
            typename = 'string'
        else
            value = valueCopy
        end
    end

    -- output the possibly-modified value
    local presep = #prefix > 0 and ' ' or ''
    local typsep = #typ > 0 and ' ' or ''
    local valtyp = type(value)
    if valtyp == 'nil' then
        add('nil')
    elseif ({boolean=1, number=1, string=1})[valtyp] then
        typsep = #typ > 0 and valtyp == 'string' and #value > 0 and ' ' or ''
        -- don't use the %q format specifier; doesn't work with multi-bytes
        local quo = typename == 'string' and '"' or ''
        add(string.format('%s%s%s%s%s%s%s%s', indent, prefix, presep, typ,
                          typsep, quo, value, quo))
    -- light userdata is just a pointer (can't iterate over it)
    -- XXX is there a better way of checking for light userdata?
    elseif valtyp == 'userdata' and not pcall(pairs(value)) then
        add(string.format('%s%s%s%s %s', indent, prefix, presep, typ,
                          tostring(value):gsub('userdata:%s*', '')))
    elseif ({table=1, userdata=1})[valtyp] then
        add(string.format('%s%s%s%s%s{', indent, prefix, presep, typ, typsep))
        -- Attr and Attr.attributes have both numeric and string keys, so
        -- ignore the numeric ones
        -- XXX this is no longer the case for pandoc >= 2.15, so could remove
        --     the special case?
        local first = true
        if prefix ~= 'attributes:' and typ ~= 'Attr' then
            for i, val in ipairs(value) do
                local pre = maxlen and not first and ', ' or ''
                local text = dump_(string.format('%s[%s]', pre, i), val,
                                   maxlen, level + 1, add)
                first = false
            end
        end
        -- report keys in alphabetical order to ensure repeatability
        for key, val in logging.spairs(value) do
            -- pandoc >= 2.15 includes 'tag'
            if not tonumber(key) and key ~= 'tag' then
                local pre = maxlen and not first and ', ' or ''
                local text = dump_(string.format('%s%s:', pre, key), val,
                                   maxlen, level + 1, add)
            end
            first = false
        end
        add(string.format('%s}', indent))
    end
    return table.concat(buffer, maxlen and '' or '\n')
end

logging.dump = function(value, maxlen)
    if maxlen == nil then maxlen = 70 end
    local text = dump_(nil, value, maxlen)
    if #text > maxlen then
        text = dump_(nil, value, nil)
    end
    return text
end

logging.output = function(...)
    local need_newline = false
    for i, item in ipairs({...}) do
        -- XXX space logic could be cleverer, e.g. no space after newline
        local maybe_space = i > 1 and ' ' or ''
        local text = ({table=1, userdata=1})[type(item)] and
            logging.dump(item) or tostring(item)
        io.stderr:write(maybe_space, text)
        need_newline = text:sub(-1) ~= '\n'
    end
    if need_newline then
        io.stderr:write('\n')
    end
end

-- basic logging support (-1=errors, 0=warnings, 1=info, 2=debug, 3=debug2)
-- XXX should support string levels?
logging.loglevel = 0

-- set log level and return the previous level
logging.setloglevel = function(loglevel)
    local oldlevel = logging.loglevel
    logging.loglevel = loglevel
    return oldlevel
end

-- verbosity default is WARNING; --quiet -> ERROR and --verbose -> INFO
-- --trace sets TRACE or DEBUG (depending on --verbose)
if type(PANDOC_STATE) == 'nil' then
    -- use the default level
elseif PANDOC_STATE.trace then
    logging.loglevel = PANDOC_STATE.verbosity == 'INFO' and 3 or 2
elseif PANDOC_STATE.verbosity == 'INFO' then
    logging.loglevel = 1
elseif PANDOC_STATE.verbosity == 'WARNING' then
    logging.loglevel = 0
elseif PANDOC_STATE.verbosity == 'ERROR' then
    logging.loglevel = -1
end

logging.error = function(...)
    if logging.loglevel >= -1 then
        logging.output('(E)', ...)
    end
end

logging.warning = function(...)
    if logging.loglevel >= 0 then
        logging.output('(W)', ...)
    end
end

logging.info = function(...)
    if logging.loglevel >= 1 then
        logging.output('(I)', ...)
    end
end

logging.debug = function(...)
    if logging.loglevel >= 2 then
        logging.output('(D)', ...)
    end
end

logging.debug2 = function(...)
    if logging.loglevel >= 3 then
        logging.warning('debug2() is deprecated; use trace()')
        logging.output('(D2)', ...)
    end
end

logging.trace = function(...)
    if logging.loglevel >= 3 then
        logging.output('(T)', ...)
    end
end

-- for temporary unconditional debug output
logging.temp = function(...)
    logging.output('(#)', ...)
end

return logging
