-- The help text database and query interface.
--
-- Help text is read from the rendered text in hack/docs/docs/. If no rendered
-- text exists, it is read from the script sources (for scripts) or the string
-- passed to the PluginCommand initializer (for plugins).
--
-- For plugins that don't register a command with the same name as the plugin,
-- the plugin name is registered as a separate entry so documentation on what
-- happens when you enable the plugin can be found.
--
-- The database is lazy-loaded when an API method is called. It rechecks its
-- help sources for updates if an API method has not been called in the last
-- 60 seconds.

local _ENV = mkmodule('helpdb')

-- paths
local RENDERED_PATH = 'hack/docs/docs/tools/'
local BUILTIN_HELP = 'hack/docs/docs/Builtin.txt'
local TAG_DEFINITIONS = 'hack/docs/docs/Tags.txt'

-- used when reading help text embedded in script sources
local SCRIPT_DOC_BEGIN = '[====['
local SCRIPT_DOC_END = ']====]'
local SCRIPT_DOC_BEGIN_RUBY = '=begin'
local SCRIPT_DOC_END_RUBY = '=end'

local ENTRY_TYPES = {
    BUILTIN='builtin',
    PLUGIN='plugin',
    COMMAND='command'
}

local HELP_SOURCES = {
    STUB='stub',
    RENDERED='rendered',
    PLUGIN='plugin',
    SCRIPT='script',
}

-- entry name -> {
--   entry_types (set of ENTRY_TYPES),
--   short_help (string),
--   long_help (string),
--   tags (set),
--   help_source (element of HELP_SOURCES),
--   source_timestamp (mtime, 0 for non-files),
--   source_path (string, nil for non-files)
-- }
--
-- entry_types is a set because plugin commands can also be the plugin names.
db = db or {}

-- tag name -> list of entry names
-- Tags defined in the TAG_DEFINITIONS file that have no associated db entries
-- will have an empty list.
tag_index = tag_index or {}

local function get_rendered_path(entry_name)
    return RENDERED_PATH .. entry_name .. '.txt'
end

local function has_rendered_help(entry_name)
    return dfhack.filesystem.mtime(get_rendered_path(entry_name)) ~= -1
end

local DEFAULT_HELP_TEMPLATE = [[
%s
%s

No help available.
]]
local function make_default_entry(entry_name, entry_types, source,
                                  source_timestamp, source_path)
    local default_long_help = DEFAULT_HELP_TEMPLATE:format(
                                            entry_name, ('*'):rep(#entry_name))
    return {
        entry_types=entry_types,
        short_help='No help available.',
        long_help=default_long_help,
        tags={},
        help_source=source,
        source_timestamp=source_timestamp or 0,
        source_path=source_path}
end

-- updates the short_text, the long_text, and the tags in the given entry based
-- on the text returned from the iterator.
-- if defined, opts can have the following fields:
--   begin_marker (string that marks the beginning of the help text; all text
--                 before this marker is ignored)
--   end_marker (string that marks the end of the help text; text will stop
--               being parsed after this marker is seen)
--   no_header (don't try to find the entity name at the top of the help text)
--   first_line_is_short_help (read the short help text from the first commented
--                             line of the script instead of using the first
--                             sentence of the long help text)
local function update_entry(entry, iterator, opts)
    opts = opts or {}
    local lines = {}
    local first_line_is_short_help = opts.first_line_is_short_help
    local begin_marker_found,header_found = not opts.begin_marker,opts.no_header
    local tags_found, short_help_found, in_short_help = false, false, false
    for line in iterator do
        if not short_help_found and first_line_is_short_help then
            line = line:trim()
            local _,_,text = line:find('^%-%-%s*(.*)') or line:find('^#%s*(.*)')
            if not text then
                -- if no first-line short help found, fall back to getting the
                -- first sentence of the help text.
                first_line_is_short_help = false
            else
                if not text:endswith('.') then
                    text = text .. '.'
                end
                entry.short_help = text
                short_help_found = true
                goto continue
            end
        end
        if not begin_marker_found then
            local _, endpos = line:find(opts.begin_marker, 1, true)
            if endpos == #line then
                begin_marker_found = true
            end
            goto continue
        end
        if opts.end_marker then
            local _, endpos = line:find(opts.end_marker, 1, true)
            if endpos == #line then
                break
            end
        end
        if not header_found and line:find('%w') then
            header_found = true
        elseif not tags_found and line:find('^Tags: [%w, ]+$') then
            local _,_,tags = line:trim():find('Tags: (.*)')
            entry.tags = {}
            for _,tag in ipairs(tags:split('[ ,]+')) do
                entry.tags[tag] = true
            end
            tags_found = true
        elseif not short_help_found and not line:find('^Keybinding:') and
                line:find('%w') then
            if in_short_help then
                entry.short_help = entry.short_help .. ' ' .. line
            else
                entry.short_help = line
            end
            local sentence_end = entry.short_help:find('.', 1, true)
            if sentence_end then
                entry.short_help = entry.short_help:sub(1, sentence_end)
                short_help_found = true
            else
                in_short_help = true
            end
        end
        table.insert(lines, line)
        ::continue::
    end
    entry.long_help = table.concat(lines, '\n')
end

-- create db entry based on parsing sphinx-rendered help text
local function make_rendered_entry(old_entry, entry_name, entry_types)
    local rendered_path = get_rendered_path(entry_name)
    local source_timestamp = dfhack.filesystem.mtime(rendered_path)
    if old_entry and old_entry.source == HELP_SOURCES.RENDERED and
            old_entry.source_timestamp >= source_timestamp then
        -- we already have the latest info
        return old_entry
    end
    local entry = make_default_entry(entry_name, entry_types,
                HELP_SOURCES.RENDERED, source_timestamp, rendered_path)
    update_entry(entry, io.lines(rendered_path))
    return entry
end

-- create db entry based on the help text in the plugin source (used by
-- out-of-tree plugins)
local function make_plugin_entry(old_entry, entry_name, entry_types)
    if old_entry and old_entry.source == HELP_SOURCES.PLUGIN then
        -- we can't tell when a plugin is reloaded, so we can either choose to
        -- always refresh or never refresh. let's go with never for now for
        -- performance.
        return old_entry
    end
    local entry = make_default_entry(entry_name, entry_types,
                                     HELP_SOURCES.PLUGIN)
    local long_help = dfhack.internal.getCommandHelp(entry_name)
    if long_help and #long_help:trim() > 0 then
        update_entry(entry, long_help:trim():gmatch('[^\n]*'), {no_header=true})
    end
    return entry
end

-- create db entry based on the help text in the script source (used by
-- out-of-tree scripts)
local function make_script_entry(old_entry, entry_name, script_source_path)
    local source_timestamp = dfhack.filesystem.mtime(script_source_path)
    if old_entry and old_entry.source == HELP_SOURCES.SCRIPT and
            old_entry.script_source_path == script_source_path and
            old_entry.source_timestamp >= source_timestamp then
        -- we already have the latest info
        return old_entry
    end
    local entry = make_default_entry(entry_name, {[ENTRY_TYPES.COMMAND]=true},
                    HELP_SOURCES.SCRIPT, source_timestamp, script_source_path)
    local ok, lines = pcall(io.lines, script_source_path)
    if not ok then return entry end
    local is_rb = script_source_path:endswith('.rb')
    update_entry(entry, lines,
            {begin_marker=(is_rb and SCRIPT_DOC_BEGIN_RUBY or SCRIPT_DOC_BEGIN),
             end_marker=(is_rb and SCRIPT_DOC_BEGIN_RUBY or SCRIPT_DOC_END),
             first_line_is_short_help=true})
    return entry
end

-- updates the db (and associated tag index) with a new entry if the entry_name
-- doesn't already exist in the db.
local function update_db(old_db, db, source, entry_name, kwargs)
    if db[entry_name] then
        -- already in db (e.g. from a higher-priority script dir); skip
        return
    end
    local entry, old_entry = nil, old_db[entry_name]
    if source == HELP_SOURCES.RENDERED then
        entry = make_rendered_entry(old_entry, entry_name, kwargs.entry_types)
    elseif source == HELP_SOURCES.PLUGIN then
        entry = make_plugin_entry(old_entry, entry_name, kwargs.entry_types)
    elseif source == HELP_SOURCES.SCRIPT then
        entry = make_script_entry(old_entry, entry_name, kwargs.script_source)
    elseif source == HELP_SOURCES.STUB then
        entry = make_default_entry(entry_name, kwargs.entry_types,
                                   HELP_SOURCES.STUB)
    else
        error('unhandled help source: ' .. source)
    end
    db[entry_name] = entry
    for _,tag in ipairs(entry.tags) do
        -- ignore unknown tags
        if tag_index[tag] then
            table.insert(tag_index[tag], entry_name)
        end
    end
end

local BUILTINS = {
    alias='Configure helper aliases for other DFHack commands.',
    cls='Clear the console screen.',
    clear='Clear the console screen.',
    die='Force DF to close immediately, without saving.',
    enable='Enable a plugin or persistent script.',
    disable='Disable a plugin or persistent script.',
    fpause='Force DF to pause.',
    help='Usage help for the given plugin, command, or script.',
    hide='Hide the terminal window (Windows only).',
    keybinding='Modify bindings of commands to in-game key shortcuts.',
    ['kill-lua']='Stop a misbehaving Lua script.',
    ['load']='Load and register a plugin library.',
    unload='Unregister and unload a plugin.',
    reload='Unload and reload a plugin library.',
    ls='List commands, optionally filtered by a tag or substring.',
    dir='List commands, optionally filtered by a tag or substring.',
    plug='List plugins and whether they are enabled.',
    ['sc-script']='Automatically run specified scripts on state change events.',
    script='Run commands specified in a file.',
    show='Show a hidden terminal window (Windows only).',
    tags='List the tags that the DFHack tools are grouped by.',
    ['type']='Discover how a command is implemented.',
}

-- add the builtin commands to the db
local function scan_builtins(old_db, db)
    local entry = make_default_entry('builtin',
                {[ENTRY_TYPES.BUILTIN]=true, [ENTRY_TYPES.COMMAND]=true},
                HELP_SOURCES.RENDERED, 0, BUILTIN_HELP)
    -- read in builtin help
    local f = io.open(BUILTIN_HELP)
    if f then
        entry.long_help = f:read('*all')
    end
    for b,short_help in pairs(BUILTINS) do
        local builtin_entry = copyall(entry)
        builtin_entry.short_help = short_help
        db[b] = builtin_entry
    end
end

-- scan for plugins and plugin-provided commands and add their help to the db
local function scan_plugins(old_db, db)
    local plugin_names = dfhack.internal.listPlugins()
    for _,plugin in ipairs(plugin_names) do
        local commands = dfhack.internal.listCommands(plugin)
        if #commands == 0 then
            -- use plugin name as the command so we have something to anchor the
            -- documentation to
            update_db(old_db, db,
                      has_rendered_help(plugin) and
                            HELP_SOURCES.RENDERED or HELP_SOURCES.STUB,
                      plugin, {entry_types={[ENTRY_TYPES.PLUGIN]=true}})
            goto continue
        end
        for _,command in ipairs(commands) do
            local entry_types = {[ENTRY_TYPES.COMMAND]=true}
            if command == plugin then
                entry_types[ENTRY_TYPES.PLUGIN]=true
            end
            update_db(old_db, db,
                      has_rendered_help(command) and
                            HELP_SOURCES.RENDERED or HELP_SOURCES.PLUGIN,
                      command, {entry_types=entry_types})
        end
        ::continue::
    end
end

-- scan for scripts and add their help to the db
local function scan_scripts(old_db, db)
    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') and not f.path:endswith('.rb')) or
                    f.path:startswith('test/') or
                    f.path:startswith('internal/') then
                goto continue
            end
            local dot_index = f.path:find('%.[^.]*$')
            local script_source = script_path .. '/' .. f.path
            update_db(old_db, db,
                      has_rendered_help(f.path) and
                            HELP_SOURCES.RENDERED or HELP_SOURCES.SCRIPT,
                      f.path:sub(1, dot_index - 1),
                      {script_source=script_source})
            ::continue::
        end
        ::skip_path::
    end
end

-- read tags and descriptions from the TAG_DEFINITIONS file and add them all
-- to tag_index, initizlizing each entry with an empty list.
local function initialize_tags()
    local tag, desc, in_desc = nil, nil, false
    local ok, lines = pcall(io.lines, TAG_DEFINITIONS)
    if not ok then return end
    for line in lines do
        if in_desc then
            line = line:trim()
            if #line == 0 then
                in_desc = false
                goto continue
            end
            desc = desc .. ' ' .. line
            tag_index[tag].description = desc
        else
            _,_,tag,desc = line:find('^%* (%w+): (.+)')
            if not tag then goto continue end
            tag_index[tag] = {description=desc}
            in_desc = true
        end
        ::continue::
    end
end

-- ensures the db is up to date by scanning all help sources. does not do
-- anything if it has already been run within the last 60 seconds.
last_refresh_ms = last_refresh_ms or 0
local function ensure_db()
    local now_ms = dfhack.getTickCount()
    if now_ms - last_refresh_ms < 60000 then return end
    last_refresh_ms = now_ms

    local old_db = db
    db, tag_index = {}, {}

    initialize_tags()
    scan_builtins(old_db, db)
    scan_plugins(old_db, db)
    scan_scripts(old_db, db)
end

---------------------------------------------------------------------------
-- get API
---------------------------------------------------------------------------

local function get_db_property(entry_name, property)
    ensure_db()
    if not db[entry_name] then
        error(('helpdb entry not found: "%s"'):format(entry_name))
    end
    return db[entry_name][property]
end

-- returns the ~54 char summary blurb associated with the entry
function get_entry_short_help(entry)
    return get_db_property(entry, 'short_help')
end

-- returns the full help documentation associated with the entry
function get_entry_long_help(entry)
    return get_db_property(entry, 'long_help')
end

local function set_to_sorted_list(set)
    local list = {}
    for item in pairs(set) do
        table.insert(list, item)
    end
    table.sort(list)
    return list
end

-- returns the list of tags associated with the entry, in alphabetical order
function get_entry_tags(entry)
    return set_to_sorted_list(get_db_property(entry, 'tags'))
end

-- returns whether the given string matches a tag name
function is_tag(str)
    if not str or #str == 0 then
        return false
    end
    ensure_db()
    if type(str) == "string" then str = {str} end
    for _,s in ipairs(str) do
        if not tag_index[s] then
            return false
        end
    end
    return true
end

-- returns the defined tags in alphabetical order
function get_tags()
    ensure_db()
    return set_to_sorted_list(tag_index)
end

-- returns the description associated with the given tag
function get_tag_description(tag)
    ensure_db()
    if not tag_index[tag] then
        error('invalid tag: ' .. tag)
    end
    return tag_index[tag].description
end

---------------------------------------------------------------------------
-- search API
---------------------------------------------------------------------------

-- returns a list of path elements in reverse order
local function chunk_for_sorting(str)
    local parts = str:split('/')
    local chunks = {}
    for i=1,#parts do
        chunks[#parts - i + 1] = parts[i]
    end
    return chunks
end

-- sorts by last path component, then by parent path components.
-- something comes before nothing.
-- e.g. gui/autofarm comes immediately before autofarm
local function sort_by_basename(a, b)
    local a = chunk_for_sorting(a)
    local b = chunk_for_sorting(b)
    local i = 1
    while a[i] do
        if not b[i] then
            return true
        end
        if a[i] ~= b[i] then
            return a[i] < b[i]
        end
        i = i + 1
    end
    return false
end

local function matches(entry_name, filter)
    local db_entry = db[entry_name]
    if filter.tag then
        local matched = false
        for _,tag in ipairs(filter.tag) do
            if db_entry.tags[tag] then
                matched = true
                break
            end
        end
        if not matched then
            return false
        end
    end
    if filter.types then
        local matched = false
        for _,etype in ipairs(filter.types) do
            if db_entry.entry_types[etype] then
                matched = true
                break
            end
        end
        if not matched then
            return false
        end
    end
    if filter.str then
        local matched = false
        for _,str in ipairs(filter.str) do
            if entry_name:find(str, 1, true) then
                matched = true
                break
            end
        end
        if not matched then
            return false
        end
    end
    return true
end

-- converts strings into single-element lists containing that string
local function normalize_string_list(l)
    if not l or #l == 0 then return nil end
    if type(l) == 'string' then
        return {l}
    end
    return l
end

-- normalizes the lists in the filter and returns nil if no filter elements are
-- populated
local function normalize_filter(f)
    if not f then return nil end
    local filter = {}
    filter.str = normalize_string_list(f.str)
    filter.tag = normalize_string_list(f.tag)
    filter.types = normalize_string_list(f.types)
    if not filter.str and not filter.tag and not filter.types then
        return nil
    end
    return filter
end

-- returns a list of entry names, alphabetized by their last path component,
-- with populated path components coming before null path components (e.g.
-- autobutcher will immediately follow gui/autobutcher).
-- the optional include and exclude filter params are maps with the following
-- elements:
--   str - if a string, filters by the given substring. if a table of strings,
--         includes entry names that match any of the given substrings.
--   tag - if a string, filters by the given tag name. if a table of strings,
--         includes entries that match any of the given tags.
--   types - if a string, matches entries of the given type. if a table of
--           strings, includes entries that match any of the given types. valid
--           types are: "builtin", "plugin", "command". note that many plugin
--           commands have the same name as the plugin, so those entries will
--           match both "plugin" and "command" types.
function search_entries(include, exclude)
    ensure_db()
    include = normalize_filter(include)
    exclude = normalize_filter(exclude)
    local commands = {}
    for command in pairs(db) do
        if (not include or matches(command, include)) and
                (not exclude or not matches(command, exclude)) then
            table.insert(commands, command)
        end
    end
    table.sort(commands, sort_by_basename)
    return commands
end

---------------------------------------------------------------------------
-- print API (outputs to console)
---------------------------------------------------------------------------

-- implements the 'help' builtin command
function help(entry)
    ensure_db()
    if not db[entry] then
        dfhack.printerr(('No help entry found for "%s"'):format(entry))
        return
    end
    print(get_entry_long_help(entry))
end

local function get_max_width(list, min_width)
    local width = min_width or 0
    for _,item in ipairs(list) do
        width = math.max(width, #item)
    end
    return width
end

-- implements the 'tags' builtin command
function tags()
    local tags = get_tags()
    local width = get_max_width(tags, 10)
    for _,tag in ipairs(tags) do
        print(('  %-'..width..'s %s'):format(tag, get_tag_description(tag)))
    end
end

-- prints the requested entries to the console. include and exclude filters are
-- defined as in search_entries() above.
function list_entries(skip_tags, include, exclude)
    local entries = search_entries(include, exclude)
    local width = get_max_width(entries, 10)
    for _,entry in ipairs(entries) do
        print(('  %-'..width..'s  %s'):format(
                entry, get_entry_short_help(entry)))
        if not skip_tags then
            local tags = get_entry_tags(entry)
            if #tags > 0 then
                print(('    tags: %s'):format(table.concat(tags, ', ')))
            end
        end
    end
    if #entries == 0 then
        print('no entries found.')
    end
end

-- wraps the list_entries() API to provide a more convenient interface for Core
-- to implement the 'ls' builtin command.
--   filter_str - if a tag name, will filter by that tag. otherwise, will filter
--                as a substring
--   skip_tags - whether to skip printing tag info
--   show_dev_commands - if true, will include scripts in the modtools/ and
--                       devel/ directories. otherwise those scripts will be
--                       excluded
function ls(filter_str, skip_tags, show_dev_commands)
    local include = {types={ENTRY_TYPES.COMMAND}}
    if is_tag(filter_str) then
        include.tag = filter_str
    else
        include.str = filter_str
    end
    list_entries(skip_tags, include,
                 show_dev_commands and {} or {str={'modtools/', 'devel/'}})
end

return _ENV
