-- texdoclib-config.tlu: handling config of texdoc
--
-- The TeX Live Team, GPLv3, see texdoclib.tlu for details

-- dependencies
local lfs = require 'lfs'

-- shortcuts
local M = {}
local C = require 'texdoclib-const'

-- config is local to this file
local config = {}

--------------------------   handling dependencies   --------------------------

-- in this file, dependencies should be required ondemand to prevent infinite
-- recursion.

local texdoc = {}

-- import specified function from the submodule
local function import_function(mod, func)
    texdoc[mod] = texdoc[mod] or require('texdoclib-' .. mod)
    return texdoc[mod][func]
end

--------------------------   hide the config table   --------------------------

-- config is read-only (but not "deep" read-only)
local function set_read_only(table, name)
    assert(next(table) == nil,
        'Internal error: ' .. name .. ' should be empty at this point.')
    local ro = 'Internal error: attempt to update read-only table '
    local real = {}
    setmetatable(table, {
        __index = real,
        __newindex = function() error(ro .. name .. '.') end,
    })
    return function(k, v) real[k] = v end
end
local real_set_config = set_read_only(config, 'config')

-- the accessor
function M.get_value(key) return config[key] end

-------------------------   general config functions   ------------------------

-- interpreting 'context' in this section
local function context_to_string(context)
    if not context then return '(no context)' end
    if context.src == 'cl' then
        return 'from command line option "' .. context.name .. '"'
    elseif context.src == 'env' then
        return 'from environment variable "' .. context.name .. '"'
    elseif context.src == 'loc' then
        return 'from operating system locale'
    elseif context.src == 'file' then
        return 'in file "' .. context.file .. '" on line ' .. context.line
    elseif context.src == 'def' then
        return 'from built-in defaults'
    else
        return 'from unkown source (should not happen, please report)'
    end
end

-- a helper function for warning messages in this section
local function config_warn(key, value, context, unknown)
    local err_print = import_function('util', 'err_print')
    local begin = unknown
        and string.format('Unknown option "%s"', key)
        or string.format('Illegal value "%s" for option "%s"',
            key, tostring(value))
    texdoc.util.err_print('warning',
        '%s %s. Skipping.', begin, context_to_string(context))
end

-- set a config parameter, but don't overwrite it if already set
-- three special types: *_list (list), *_switch (boolean), *_level (number)
function M.set_config_element(key, value, context)
    local dbg_print = import_function('util', 'dbg_print')
    local parse_error = false

    local is_known = false -- is key a valid option?
    local option
    for _, option in ipairs(C.known_options) do
        if string.match(key, '^' .. option .. '$') then
            is_known = true
            break
        end
    end

    -- warn and exit if key is not a known option
    if not is_known then config_warn(key, nil, context, true) return end

    -- exit if key is already set (/!\ must test for nil, not false)
    if not (config[key] == nil) then
        if context.src ~= 'def' then
            dbg_print('config', 'Ignoring "%s=%s" %s.',
                key, tostring(value), context_to_string(context))
        end
        return nil
    end

    -- record the source of the setting
    real_set_config(key .. '_src', context.src)

    -- detect the type of the key
    if string.match(key, '_list$') then
        -- coma-separated list
        local values
        if value == '' then
            values = {}
        else
            values = string.explode(value, ',')
        end

        local inverse = {}
        for i, j in ipairs(values) do -- sanitize values...
            j = string.gsub(j, '%s*$', '')
            j = string.gsub(j, '^%s*', '')
            values[i] = j
            inverse[j] = i -- ... and build inverse mapping on the way
        end

        real_set_config(key, values)
        real_set_config(key .. '_inv', inverse)
        real_set_config(key .. '_max', #values)
    elseif string.find(key, '_switch$') then
        -- boolean
        if value == 'true' then
            real_set_config(key, true)
        elseif value == 'false' then
            real_set_config(key, false)
        else
            config_warn(key, value, context)
            parse_error = true
        end
    elseif string.find(key, '_level$') then
        -- integer
        local val = tonumber(value)
        if val then
            real_set_config(key, val)
        else
            config_warn(key, value, context)
            parse_error = true
        end
    else -- string
        real_set_config(key, value)
    end

    -- special case: if we just set debug_list, print version info now
    if key == 'debug_list' then
        dbg_print('version', '%s v%s', C.fullname, C.version)
    end

    -- now tell what we have just done, for debugging
    if not parse_error then
        dbg_print('config', 'Setting "%s=%s" %s.',
            key, tostring(value), context_to_string(context))
    end
end
local set_config_element = M.set_config_element

-- set a whole list, also without overwriting
local function set_config_list(conf, context)
    for key, value in pairs(conf) do
        set_config_element(key, value, context)
    end
end

------------------------   config from command line   ------------------------

-- set config from the command line
-- Note: Make sure to set a default value in setup_config_from_defaults()
--       if relevant.
local function setup_config_from_cl(cl_config)
    local err_print = import_function('util', 'err_print')

    for _, e in ipairs(cl_config) do
        if e[3] == '-c' then
            local item, value = string.match(e[1], '^([%a%d_]+)%s*=%s*(.+)')
            if item and value then
                set_config_element(item, value, {src='cl', name='-c'})
            else
                err_print('warning',
                    'Invalid argument "%s" for Option -c. Ignoring.', e[1])
            end
        else
            set_config_element(e[1], e[2], {src='cl', name=e[3]})
        end
    end
end

-------------------------   config from environment   --------------------------

-- set config from environment if available
local function setup_config_from_env()
    local function set_config_elt_from_vars(key, vars)
        for _, var in ipairs(vars) do
            local value = os.getenv(var)
            value = value and string.gmatch(value, '([^:]+)')()
            if value then
                set_config_element(key, value, {src='env', name=var})
            end
        end
    end
    set_config_elt_from_vars('viewer_pdf',
      {'PDFVIEWER_texdoc', 'PDFVIEWER', 'TEXDOCVIEW_pdf', 'TEXDOC_VIEWER_PDF'})
    set_config_elt_from_vars('viewer_ps',
      {'PSVIEWER_texdoc', 'PSVIEWER', 'TEXDOCVIEW_ps', 'TEXDOC_VIEWER_PS'})
    set_config_elt_from_vars('viewer_dvi',
      {'DVIVIEWER_texdoc', 'DVIVIEWER', 'TEXDOCVIEW_dvi', 'TEXDOC_VIEWER_DVI'})
    set_config_elt_from_vars('viewer_html',
      {'BROWSER_texdoc', 'BROWSER', 'TEXDOCVIEW_html', 'TEXDOC_VIEWER_HTML'})
    set_config_elt_from_vars('viewer_md',
      {'MDVIEWER_texdoc', 'MDVIEWER', 'TEXDOCVIEW_md', 'TEXDOC_VIEWER_MD'})
    set_config_elt_from_vars('viewer_txt',
      {'PAGER_texdoc', 'PAGER', 'TEXDOCVIEW_txt', 'TEXDOC_VIEWER_TXT'})
end

----------------------   options and aliases from files   ----------------------

-- interpret a confline as a config setting or return false
local function confline_to_config(line, file, pos)
    local key, val = string.match(line, '^([%a%d_]+)%s*=%s*(.+)')
    if key and val then
        set_config_element(key, val, {src='file', file=file, line=pos})
        return true
    end
    return false
end

-- set config and aliases from a particular config file assumed to exist
local function read_config_file(configfile)
    local err_print = import_function('util', 'err_print')
    local confline_to_alias = import_function('alias', 'confline_to_alias')
    local confline_to_score = import_function('score', 'confline_to_score')

    local cnf = assert(io.open(configfile, 'r'))
    local lineno = 0
    while true do
        local line=cnf:read('*line')
        lineno = lineno + 1

        if line == nil then break end  -- EOF
        line = string.gsub(line, '%s*#.*$', '') -- comments begin with #
        line = string.gsub(line, '%s*$', '')    -- remove trailing spaces
        line = string.gsub(line, '^%s*', '')    -- remove leading spaces

        -- try to interpret the line
        local ok = string.match(line, '^%s*$')
            or confline_to_alias(line, configfile, lineno)
            or confline_to_score(line, configfile, lineno)
            or confline_to_config(line, configfile, lineno)

        -- complain if it failed
        if not ok then
            err_print('warning',
                'syntax error in %s at line %d.', configfile, lineno)
        end
    end
    cnf:close()
end

-- return the list of configuration files
local function get_config_files()
    -- get names
    local platform = string.match(kpse.var_value('SELFAUTOLOC'), '.*/(.*)$')
    local names = {
        'texdoc-' .. platform .. '.cnf',
        'texdoc.cnf',
        'texdoc-dist.cnf',
    }

    -- get dirs
    local sep = (os.type == 'windows') and ';' or ':'
    local texmf_texdoc = kpse.expand_path('$TEXMF/texdoc')
    local dirs = texmf_texdoc:explode(sep)

    -- merge them
    local ret = {}
    for _, dir in ipairs(dirs) do
        for _, name in ipairs(names) do
            local pathname = dir .. '/' .. name
            table.insert(ret, pathname)
        end
    end
    return ret
end

-- the config_files table is shared by the following two functions
local setup_config_from_files

do -- begin scope of config_files
local config_files = {}

-- set config/aliases from all config files
setup_config_from_files = function()
    local file_list = get_config_files()

    for i, file in ipairs(file_list) do
        -- determine the status of the config file
        local status
        if lfs.isfile(file) then
            status = config.lastfile_switch and 'disabled' or 'active'
        else
            status = 'not found'
        end

        -- register it
        config_files[i] = {
            path = file,
            status = status,
        }

        -- read only if active
        if status == 'active' then
            read_config_file(file)
        end
    end
end

-- now a special information function (see -f,--file option)
function M.show_config_files(is_action)
    local w32_path = import_function('util', 'w32_path')
    local dbg_print = import_function('util', 'dbg_print')

    -- controled print_func
    local indent = is_action and '    ' or ''
    local print_func = is_action and print
        or function(s) dbg_print('files', s) end

    -- show the list of configuration files
    print_func('Configuration file(s):')
    for i, file in ipairs(config_files) do
        -- if not verbose, do not show "not found" files for -f
        if file.status ~= "not found"
                or (not is_action or config.verbosity_level == 3) then
            print_func(indent .. file.status .. '\t' .. w32_path(file.path))
        end
    end

    -- show the recommendation (only for the "files" action)
    if is_action then
        print_func('Recommended file(s) for personal settings:')
        local sep = (os.type == 'windows') and ';' or ':'
        local texmfhomes = string.explode(kpse.expand_path('$TEXMFHOME'), sep)
        for _, home in ipairs(texmfhomes) do
            print_func(indent .. w32_path(home .. '/texdoc/texdoc.cnf'))
        end
    end
end

end -- end scope of config_files

----------------------   config from locale settings   -------------------------

-- set up the locale from the system setting
-- Note that luatex set the locale to a neutral value for a reason, so we need
-- to set the locale (for the category 'all') to nil to ignore it.
local function setup_config_from_locale()
    local current, native, lang
    current = os.setlocale(nil, 'all')  -- save the default value
    os.setlocale('', 'all')             -- set it to nil temporary
    native = os.setlocale(nil, 'all')   -- get the actual system locale
    os.setlocale(current, 'all')        -- put back the default value
    if native == 'C' then -- the default C locale is en
        lang = 'en'
    else
        lang = string.match(native, '^[a-z][a-z]')
    end
    if lang then
        set_config_element('lang', lang, {src='loc'})
    end
end

----------------------   options from built-in defaults   ----------------------

-- for default viewer on general Unix, we have a list; the following function is
-- used to check in the path which program is available

-- check if "name" is the name of a file in the path
-- Warning: to be used only on Unix! (separators, and PATH irrelevant on win32)
--          the value of PATH is cached
local is_in_path
do local path_list = string.explode(os.getenv('PATH'), ':')
is_in_path = function(name)
    for _, path in ipairs(path_list) do
        if lfs.isfile(path .. '/' .. name) then return true end
    end
    return false
end
end

-- returns a viewer specific to a desktop environment if relevant
-- doesn't work on windows (uses io.popen)
-- logic stolen from xdg-open (http://www.freedesktop.org/) and adapted
local function desktop_environment_viewer()
    local xdg_current_desktop = os.getenv('XDG_CURRENT_DESKTOP') or ''
    if (os.getenv('KDE_SESSION_VERSION') or os.getenv('KDE_FULL_SESSION'))
        or string.match(xdg_current_desktop, '.*KDE.*') then
        if is_in_path('kde-open') then return '(kde-open %s) &' end
        if is_in_path('kfmclient') then return '(kfmclient exec %s) &' end
    end
    if os.getenv('GNOME_DESKTOP_SESSION_ID')
        or string.match(xdg_current_desktop, '.*GNOME.*') then  -- gnome
        if is_in_path('gio') then return '(gio open %s) &' end
        -- followings are deplecated commands but keep these for compatibility
        if is_in_path('gvfs-open') then return '(gvfs-open %s) &' end
        if is_in_path('gnome-open') then return '(gnome-open %s) &' end
    end
    if not is_in_path('xprop') then return end
    local xprop_fh = io.popen('xprop -root _DT_SAVE_MODE 2>/dev/null')
    local xprop_out = xprop_fh:read('*line')
    xprop_fh:close()
    if xprop_out and string.find(xprop_out, '= "xfce4"$') then  -- xfce
        return '(exo-open %s) &'
    end
end

-- guess a viewer from a list:
-- - xdg-open from freedesktop if available
-- - try detecting desktop environments
-- - or return the first element of "list" whose name is found in path
-- - or nil
-- caches results of desktop environment detection
local guess_viewer
do local de_viewer
guess_viewer = function(cmds)
    -- try the freedesktop method
    if is_in_path('xdg-open') then
        return '(xdg-open %s) &'
    end
    -- try desktop environment
    if not de_viewer then de_viewer = desktop_environment_viewer() end
    if de_viewer then return de_viewer end
    -- or look along path
    for _, cmd in ipairs(cmds) do
        if is_in_path(cmd[1]) then return cmd[2] end
    end
end
end

-- set viewers from defaults (done only if necessary)
function M.get_default_viewers()
    local function set_config_ls(ls) set_config_list(ls, {src='def'}) end
    if (os.type == 'windows') then
        set_config_ls {
            -- Use 'start' to get file associations.
            -- We need to quote the filenames, but the first quoted argument
            -- is considered as the title by start, so we provide a dummy title.
            -- Also, since the command line parser removes quotes if there
            -- is no space inside, the dummy title must contain spaces.
            viewer_dvi = 'start "texdoc dvi viewer"',
            viewer_html = 'start "texdoc html viewer"',
            viewer_pdf = 'start "texdoc pdf viewer"',
            viewer_ps = 'start "texdoc ps viewer"',
            -- 'more' is always available.
            -- However, we can't assume texdoc is called from a cmd.exe window
            -- (it can be run from the start->run menu), hence we make sure
            -- to open a new window if needed.
            viewer_txt = 'start cmd /k more',
            viewer_md = viewer_txt,
        }
    elseif (os.name == 'macosx') then
        set_config_ls {
            viewer_dvi = 'open',
            viewer_html = 'open',
            viewer_pdf = 'open',
            viewer_ps = 'open',
            viewer_txt = 'less',
            viewer_md = viewer_txt,
        }
    else -- generic Unix
        set_config_ls {
            viewer_dvi = guess_viewer {
                {'xdvi', '(xdvi %s) &'},
                {'evince', '(evince %s) &'},
                {'okular', '(okular %s) &'},
                {'kdvi', '(kdvi %s) &'},
                {'xgdvi', '(xgdvi %s) &'},
                {'spawg', '(spawg %s) &'},
                {'spawx11', '(spawx11 %s) &'},
                {'tkdvi', '(tkdvi %s) &'},
                {'dvilx', '(dvilx %s) &'},
                {'advi', '(advi %s) &'},
                {'xdvik-ja', '(xdvik-ja %s) &'},
                {'see', '(see %s) &'}
            },
            viewer_html = guess_viewer {
                {'firefox', '(firefox %s) &'},
                {'seamonkey', '(seamonkey %s) &'},
                {'mozilla', '(mozilla %s) &'},
                {'konqueror', '(konqueror %s) &'},
                {'epiphany', '(epiphany %s) &'},
                {'opera', '(opera %s) &'},
                {'w3m', 'w3m'},
                {'links', 'links'},
                {'lynx', 'lynx'},
                {'see', 'see'}
            },
            viewer_pdf = guess_viewer {
                {'xpdf', '(xpdf %s) &'},
                {'evince', '(evince %s) &'},
                {'okular', '(okular %s) &'},
                {'kpdf', '(kpdf %s) &'},
                {'acroread', '(xpdf %s) &'},
                {'see', '(see %s) &'}
            },
            viewer_ps = guess_viewer {
                {'gv', '(gv %s) &'},
                {'evince', '(evince %s) &'},
                {'okular', '(okular %s) &'},
                {'kghostview', '(kghostview %s) &'},
                {'see', '(see %s) &'}
            },
            viewer_txt = guess_viewer {
                {'most', 'most'},
                {'less', 'less'},
                {'more', 'more'}
            },
            viewer_md = viewer_txt,
        }
    end
end

-- set some fall-back default values if no previous value is set
local function setup_config_from_defaults()
    local function set_config_ls(ls) set_config_list(ls, {src='def'}) end
    local function set_config_elt(key, val)
        set_config_element(key, val, {src='def'})
    end
    -- various, platform independent, stuff
    set_config_ls {
        mode = 'view',
        interact_switch = 'true',
        machine_switch = 'false',
        ext_list = 'pdf, htm, html, txt, md, ps, dvi, ',
        basename_list = 'readme, 00readme',
        badext_list = 'txt, ',
        badbasename_list = 'readme, 00readme',
        suffix_list = '',
        verbosity_level = C.def_verbosity,
        debug_list = '',
        max_lines = '20',
        fuzzy_level = '5',
    }
    -- zip-related options
    set_config_ls {
        zipext_list = '',
        rm_file = 'rm -f',
        rm_dir = 'rmdir',
    }
end

--------------------------   set all configuration   ---------------------------

-- populate the config and alias arrays
function M.setup_config_and_alias(cl_config)

    -- setup config from all sources
    setup_config_from_cl(cl_config)
    setup_config_from_env()
    setup_config_from_files()
    setup_config_from_locale()
    setup_config_from_defaults()

    -- machine mode implies no interaction
    if config.machine_switch == true then
        real_set_config('interact_switch', false)
    end

    -- debug implies verbose
    if #config.debug_list > 0 then
        real_set_config('verbosity_level', tonumber(C.max_verbosity))
    end

    -- we were waiting for config.debug_list to be known to do this
    M.show_config_files()
end

return M

-- vim: ft=lua:
