"""
The help command. The basic idea is that help texts for commands are best
written by those that write the commands - the developers. So command-help is
all auto-loaded and searched from the current command set. The normal,
database-tied help system is used for collaborative creation of other help
topics such as RP help or game-world aides. Help entries can also be created
outside the game in modules given by ``settings.FILE_HELP_ENTRY_MODULES``.

"""

from collections import defaultdict
from dataclasses import dataclass
from itertools import chain

from django.conf import settings

from evennia.help.filehelp import FILE_HELP_ENTRIES
from evennia.help.models import HelpEntry
from evennia.help.utils import help_search_with_index, parse_entry_for_subcategories
from evennia.locks.lockhandler import LockException
from evennia.utils import create, evmore
from evennia.utils.ansi import ANSIString
from evennia.utils.eveditor import EvEditor
from evennia.utils.utils import (
    class_from_module,
    dedent,
    format_grid,
    inherits_from,
    pad,
)

CMD_IGNORE_PREFIXES = settings.CMD_IGNORE_PREFIXES
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
HELP_MORE_ENABLED = settings.HELP_MORE_ENABLED
DEFAULT_HELP_CATEGORY = settings.DEFAULT_HELP_CATEGORY
HELP_CLICKABLE_TOPICS = settings.HELP_CLICKABLE_TOPICS

# limit symbol import for API
__all__ = ("CmdHelp", "CmdSetHelp")


@dataclass
class HelpCategory:
    """
    Mock 'help entry' to search categories with the same code.

    """

    key: str

    @property
    def search_index_entry(self):
        return {
            "key": self.key,
            "aliases": "",
            "category": self.key,
            "no_prefix": "",
            "tags": "",
            "text": "",
        }

    def __hash__(self):
        return hash(id(self))


class CmdHelp(COMMAND_DEFAULT_CLASS):
    """
    Get help.

    Usage:
      help
      help <topic, command or category>
      help <topic>/<subtopic>
      help <topic>/<subtopic>/<subsubtopic> ...

    Use the 'help' command alone to see an index of all help topics, organized
    by category. Some big topics may offer additional sub-topics.

    """

    key = "help"
    aliases = ["?"]
    locks = "cmd:all()"
    arg_regex = r"\s|$"

    # this is a special cmdhandler flag that makes the cmdhandler also pack
    # the current cmdset with the call to self.func().
    return_cmdset = True

    # Help messages are wrapped in an EvMore call (unless using the webclient
    # with separate help popups) If you want to avoid this, simply add
    # 'HELP_MORE_ENABLED = False' in your settings/conf/settings.py
    help_more = HELP_MORE_ENABLED

    # colors for the help index
    index_type_separator_clr = "|w"
    index_category_clr = "|W"
    index_topic_clr = "|G"

    # suggestion cutoff, between 0 and 1 (1 => perfect match)
    suggestion_cutoff = 0.6

    # number of suggestions (set to 0 to remove suggestions from help)
    suggestion_maxnum = 5

    # separator between subtopics:
    subtopic_separator_char = r"/"

    # should topics disply their help entry when clicked
    clickable_topics = HELP_CLICKABLE_TOPICS

    def msg_help(self, text, **kwargs):
        """
        messages text to the caller, adding an extra oob argument to indicate
        that this is a help command result and could be rendered in a separate
        help window.

        """
        if type(self).help_more:
            usemore = True

            if self.session and self.session.protocol_key in (
                "webclient/websocket",
                "webclient/ajax",
            ):
                try:
                    options = self.account.db._saved_webclient_options
                    if options and options["helppopup"]:
                        usemore = False
                except KeyError:
                    pass

            if usemore:
                # adding the 'text_kwargs' keyword means it will be sent with the text outputfunc
                # for every page.
                evmore.msg(
                    self.caller, text, session=self.session, text_kwargs={"type": "help"}, **kwargs
                )
                return

        self.msg(text=(text, {"type": "help"}))

    def format_help_entry(
        self,
        topic="",
        help_text="",
        aliases=None,
        suggested=None,
        subtopics=None,
        click_topics=True,
    ):
        """This visually formats the help entry.
        This method can be overridden to customize the way a help
        entry is displayed.

        Args:
            title (str, optional): The title of the help entry.
            help_text (str, optional): Text of the help entry.
            aliases (list, optional): List of help-aliases (displayed in header).
            suggested (list, optional): Strings suggested reading (based on title).
            subtopics (list, optional): A list of strings - the subcategories available
                for this entry.
            click_topics (bool, optional): Should help topics be clickable. Default is True.

        Returns:
            help_message (str): Help entry formated for console.

        """
        separator = "|C" + "-" * self.client_width() + "|n"
        start = f"{separator}\n"

        title = f"|CHelp for |w{topic}|n" if topic else "|rNo help found|n"

        if aliases:
            aliases = " |C(aliases: {}|C)|n".format("|C,|n ".join(f"|w{ali}|n" for ali in aliases))
        else:
            aliases = ""

        help_text = "\n" + dedent(help_text.strip("\n")) if help_text else ""

        if subtopics:
            if click_topics:
                subtopics = [
                    f"|lchelp {topic}/{subtop}|lt|w{topic}/{subtop}|n|le" for subtop in subtopics
                ]
            else:
                subtopics = [f"|w{topic}/{subtop}|n" for subtop in subtopics]
            subtopics = "\n|CSubtopics:|n\n  {}".format(
                "\n  ".join(
                    format_grid(
                        subtopics, width=self.client_width(), line_prefix=self.index_topic_clr
                    )
                )
            )
        else:
            subtopics = ""

        if suggested:
            suggested = sorted(suggested)
            if click_topics:
                suggested = [f"|lchelp {sug}|lt|w{sug}|n|le" for sug in suggested]
            else:
                suggested = [f"|w{sug}|n" for sug in suggested]
            suggested = "\n|COther topic suggestions:|n\n{}".format(
                "\n  ".join(
                    format_grid(
                        suggested, width=self.client_width(), line_prefix=self.index_topic_clr
                    )
                )
            )
        else:
            suggested = ""

        end = start

        partorder = (start, title + aliases, help_text, subtopics, suggested, end)

        return "\n".join(part.rstrip() for part in partorder if part)

    def format_help_index(
        self, cmd_help_dict=None, db_help_dict=None, title_lone_category=False, click_topics=True
    ):
        """Output a category-ordered g for displaying the main help, grouped by
        category.

        Args:
            cmd_help_dict (dict): A dict `{"category": [topic, topic, ...]}` for
                command-based help.
            db_help_dict (dict): A dict `{"category": [topic, topic], ...]}` for
                database-based help.
            title_lone_category (bool, optional): If a lone category should
                be titled with the category name or not. While pointless in a
                general index, the title should probably show when explicitly
                listing the category itself.
            click_topics (bool, optional): If help-topics are clickable or not
                (for webclient or telnet clients with MXP support).
        Returns:
            str: The help index organized into a grid.

        Notes:
            The input are the pre-loaded help files for commands and database-helpfiles
            respectively. You can override this method to return a custom display of the list of
            commands and topics.

        """

        def _group_by_category(help_dict):
            grid = []
            verbatim_elements = []

            if len(help_dict) == 1 and not title_lone_category:
                # don't list categories if there is only one
                for category in help_dict:
                    # gather and sort the entries from the help dictionary
                    entries = sorted(set(help_dict.get(category, [])))

                    # make the help topics clickable
                    if click_topics:
                        entries = [f"|lchelp {entry}|lt{entry}|le" for entry in entries]

                    # add the entries to the grid
                    grid.extend(entries)
            else:
                # list the categories
                for category in sorted(set(list(help_dict.keys()))):
                    category_str = f"-- {category.title()} "
                    grid.append(
                        ANSIString(
                            self.index_category_clr
                            + category_str
                            + "-" * (width - len(category_str))
                            + self.index_topic_clr
                        )
                    )
                    verbatim_elements.append(len(grid) - 1)

                    # gather and sort the entries from the help dictionary
                    entries = sorted(set(help_dict.get(category, [])))

                    # make the help topics clickable
                    if click_topics:
                        entries = [f"|lchelp {entry}|lt{entry}|le" for entry in entries]

                    # add the entries to the grid
                    grid.extend(entries)

            return grid, verbatim_elements

        help_index = ""
        width = self.client_width()
        grid = []
        verbatim_elements = []
        cmd_grid, db_grid = "", ""

        if any(cmd_help_dict.values()):
            # get the command-help entries by-category
            sep1 = (
                self.index_type_separator_clr
                + pad("Commands", width=width, fillchar="-")
                + self.index_topic_clr
            )
            grid, verbatim_elements = _group_by_category(cmd_help_dict)
            gridrows = format_grid(
                grid,
                width,
                sep="  ",
                verbatim_elements=verbatim_elements,
                line_prefix=self.index_topic_clr,
            )
            cmd_grid = ANSIString("\n").join(gridrows) if gridrows else ""

        if any(db_help_dict.values()):
            # get db-based help entries by-category
            sep2 = (
                self.index_type_separator_clr
                + pad("Game & World", width=width, fillchar="-")
                + self.index_topic_clr
            )
            grid, verbatim_elements = _group_by_category(db_help_dict)
            gridrows = format_grid(
                grid,
                width,
                sep="  ",
                verbatim_elements=verbatim_elements,
                line_prefix=self.index_topic_clr,
            )
            db_grid = ANSIString("\n").join(gridrows) if gridrows else ""

        # only show the main separators if there are actually both cmd and db-based help
        if cmd_grid and db_grid:
            help_index = f"{sep1}\n{cmd_grid}\n{sep2}\n{db_grid}"
        else:
            help_index = f"{cmd_grid}{db_grid}"

        return help_index

    def can_read_topic(self, cmd_or_topic, caller):
        """
        Helper method. If this return True, the given help topic
        be viewable in the help listing. Note that even if this returns False,
        the entry will still be visible in the help index unless `should_list_topic`
        is also returning False.

        Args:
            cmd_or_topic (Command, HelpEntry or FileHelpEntry): The topic/command to test.
            caller: the caller checking for access.

        Returns:
            bool: If command can be viewed or not.

        Notes:
            This uses the 'read' lock. If no 'read' lock is defined, the topic is assumed readable
            by all.

        """
        if inherits_from(cmd_or_topic, "evennia.commands.command.Command"):
            return cmd_or_topic.auto_help and cmd_or_topic.access(caller, "read", default=True)
        else:
            return cmd_or_topic.access(caller, "read", default=True)

    def can_list_topic(self, cmd_or_topic, caller):
        """
        Should the specified command appear in the help table?

        This method only checks whether a specified command should appear in the table of
        topics/commands.  The command can be used by the caller (see the 'should_show_help' method)
        and the command will still be available, for instance, if a character type 'help name of the
        command'.  However, if you return False, the specified command will not appear in the table.
        This is sometimes useful to "hide" commands in the table, but still access them through the
        help system.

        Args:
            cmd_or_topic (Command, HelpEntry or FileHelpEntry): The topic/command to test.
            caller: the caller checking for access.

        Returns:
            bool: If command should be listed or not.

        Notes:
            The `.auto_help` propery is checked for commands. For all help entries,
            the 'view' lock will be checked, and if no such lock is defined, the 'read'
            lock will be used. If neither lock is defined, the help entry is assumed to be
            accessible to all.

        """
        if hasattr(cmd_or_topic, "auto_help") and not cmd_or_topic.auto_help:
            return False

        has_view = (
            "view:" in cmd_or_topic.locks
            if inherits_from(cmd_or_topic, "evennia.commands.command.Command")
            else cmd_or_topic.locks.get("view")
        )

        if has_view:
            return cmd_or_topic.access(caller, "view", default=True)
        else:
            # no explicit 'view' lock - use the 'read' lock
            return cmd_or_topic.access(caller, "read", default=True)

    def collect_topics(self, caller, mode="list"):
        """
        Collect help topics from all sources (cmd/db/file).

        Args:
            caller (Object or Account): The user of the Command.
            mode (str): One of 'list' or 'query', where the first means we are collecting to view
                the help index and the second because of wanting to search for a specific help
                entry/cmd to read. This determines which access should be checked.

        Returns:
            tuple: A tuple of three dicts containing the different types of help entries
            in the order cmd-help, db-help, file-help:
                `({key: cmd,...}, {key: dbentry,...}, {key: fileentry,...}`

        """
        # start with cmd-help
        cmdset = self.cmdset
        # removing doublets in cmdset, caused by cmdhandler
        # having to allow doublet commands to manage exits etc.
        cmdset.make_unique(caller)
        # retrieve all available commands and database / file-help topics.
        # also check the 'cmd:' lock here
        cmd_help_topics = [cmd for cmd in cmdset if cmd and cmd.access(caller, "cmd")]
        # get all file-based help entries, checking perms
        file_help_topics = {topic.key.lower().strip(): topic for topic in FILE_HELP_ENTRIES.all()}
        # get db-based help entries, checking perms
        db_help_topics = {topic.key.lower().strip(): topic for topic in HelpEntry.objects.all()}
        if mode == "list":
            # check the view lock for all help entries/commands and determine key
            cmd_help_topics = {
                cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key: cmd
                for cmd in cmd_help_topics
                if self.can_list_topic(cmd, caller)
            }
            db_help_topics = {
                key: entry
                for key, entry in db_help_topics.items()
                if self.can_list_topic(entry, caller)
            }
            file_help_topics = {
                key: entry
                for key, entry in file_help_topics.items()
                if self.can_list_topic(entry, caller)
            }
        else:
            # query - check the read lock on entries
            cmd_help_topics = {
                cmd.auto_help_display_key if hasattr(cmd, "auto_help_display_key") else cmd.key: cmd
                for cmd in cmd_help_topics
                if self.can_read_topic(cmd, caller)
            }
            db_help_topics = {
                key: entry
                for key, entry in db_help_topics.items()
                if self.can_read_topic(entry, caller)
            }
            file_help_topics = {
                key: entry
                for key, entry in file_help_topics.items()
                if self.can_read_topic(entry, caller)
            }

        return cmd_help_topics, db_help_topics, file_help_topics

    def do_search(self, query, entries, search_fields=None):
        """
        Perform a help-query search, default using Lunr search engine.

        Args:
            query (str): The help entry to search for.
            entries (list): All possibilities. A mix of commands, HelpEntries and FileHelpEntries.
            search_fields (list): A list of dicts defining how Lunr will find the
                search data on the elements. If not given, will use a default.

        Returns:
            tuple: A tuple (match, suggestions).

        """

        def strip_prefix(query):
            if query and query[0] in settings.CMD_IGNORE_PREFIXES:
                return query[1:]
            return query

        if not search_fields:
            # lunr search fields/boosts
            search_fields = [
                {"field_name": "key", "boost": 10},
                {"field_name": "aliases", "boost": 7},
                {"field_name": "no_prefix", "boost": 6},
                {"field_name": "category", "boost": 5},
                {"field_name": "tags", "boost": 1},  # tags are not used by default
            ]
        match, suggestions = None, None
        base_query = strip_prefix(query)
        for match_query in (query, f"{query}*"):
            # We first do an exact word-match followed by a start-by query. The
            # return of this will either be a HelpCategory, a Command or a
            # HelpEntry/FileHelpEntry.
            matches, suggestions = help_search_with_index(
                match_query, entries, suggestion_maxnum=self.suggestion_maxnum, fields=search_fields
            )
            # Move an exact match (including aliases) to the front of the list, treating a prefixed
            # and non-prefixed command as the same thing
            for m in matches[:]:
                aliases = [m.key]
                if not isinstance(m, HelpCategory):
                    # Aliases for help created with 'sethelp' is an AliasHandler
                    aliases += m.aliases if isinstance(m.aliases, list) else m.aliases.all()
                if base_query in [strip_prefix(alias) for alias in aliases]:
                    matches.remove(m)
                    matches.insert(0, m)
                    break
            if matches:
                match = matches[0]
                break
        if match:
            # Move an exact suggestion match to the front of the list
            for s in suggestions[:]:
                if base_query == strip_prefix(s):
                    suggestions.remove(s)
                    suggestions.insert(0, s)
                    break

        return match, suggestions

    def parse(self):
        """
        input is a string containing the command or topic to match.

        The allowed syntax is
        ::

            help <topic>[/<subtopic>[/<subtopic>[/...]]]

        The database/command query is always for `<topic>`, and any subtopics
        is then parsed from there. If a `<topic>` has spaces in it, it is
        always matched before assuming the space begins a subtopic.

        """
        # parse the query

        if self.args:
            self.subtopics = [
                part.strip().lower() for part in self.args.split(self.subtopic_separator_char)
            ]
            self.topic = self.subtopics.pop(0)
        else:
            self.topic = ""
            self.subtopics = []

    def strip_cmd_prefix(self, key, all_keys):
        """
        Conditional strip of a command prefix, such as @ in @desc. By default
        this will be hidden unless there is a duplicate without the prefix
        in the full command set (such as @open and open).

        Args:
            key (str): Command key to analyze.
            all_cmds (list): All command-keys (and potentially aliases).

        Returns:
            str: Potentially modified key to use in help display.

        """
        if key and key[0] in CMD_IGNORE_PREFIXES and key[1:] not in all_keys:
            # filter out e.g. `@` prefixes from display if there is duplicate
            # with the prefix in the set (such as @open/open)
            return key[1:]
        return key

    def func(self):
        """
        Run the dynamic help entry creator.
        """
        caller = self.caller
        query, subtopics, cmdset = self.topic, self.subtopics, self.cmdset
        clickable_topics = self.clickable_topics

        if not query:
            # list all available help entries, grouped by category. We want to
            # build dictionaries {category: [topic, topic, ...], ...}

            cmd_help_topics, db_help_topics, file_help_topics = self.collect_topics(
                caller, mode="list"
            )

            # db-topics override file-based ones
            file_db_help_topics = {**file_help_topics, **db_help_topics}

            # group by category (cmds are listed separately)
            cmd_help_by_category = defaultdict(list)
            file_db_help_by_category = defaultdict(list)

            # get a collection of all keys + aliases to be able to strip prefixes like @
            key_and_aliases = set(chain(*(cmd._keyaliases for cmd in cmd_help_topics.values())))

            for key, cmd in cmd_help_topics.items():
                key = self.strip_cmd_prefix(key, key_and_aliases)
                cmd_help_by_category[cmd.help_category].append(key)
            for key, entry in file_db_help_topics.items():
                file_db_help_by_category[entry.help_category].append(key)

            # generate the index and display
            output = self.format_help_index(
                cmd_help_by_category, file_db_help_by_category, click_topics=clickable_topics
            )
            self.msg_help(output)

            return

        # search for a specific entry. We need to check for 'read' access here before
        # building the set of possibilities.
        cmd_help_topics, db_help_topics, file_help_topics = self.collect_topics(
            caller, mode="query"
        )

        # get a collection of all keys + aliases to be able to strip prefixes like @
        key_and_aliases = set(chain(*(cmd._keyaliases for cmd in cmd_help_topics.values())))

        # db-help topics takes priority over file-help
        file_db_help_topics = {**file_help_topics, **db_help_topics}

        # commands take priority over the other types
        all_topics = {**file_db_help_topics, **cmd_help_topics}

        # get all categories
        all_categories = list(
            set(HelpCategory(topic.help_category) for topic in all_topics.values())
        )

        # all available help options - will be searched in order. We also check # the
        # read-permission here.
        entries = list(all_topics.values()) + all_categories

        # lunr search fields/boosts
        match, suggestions = self.do_search(query, entries)

        if not match:
            # no topic matches found. Only give suggestions.
            help_text = f"There is no help topic matching '{query}'."

            if not suggestions:
                # we don't even have a good suggestion. Run a second search,
                # doing a full-text search in the actual texts of the help
                # entries

                search_fields = [
                    {"field_name": "text", "boost": 1},
                ]

                for match_query in [query, f"{query}*", f"*{query}"]:
                    _, suggestions = help_search_with_index(
                        match_query,
                        entries,
                        suggestion_maxnum=self.suggestion_maxnum,
                        fields=search_fields,
                    )
                    if suggestions:
                        help_text += (
                            "\n... But matches were found within the help "
                            "texts of the suggestions below."
                        )
                        suggestions = [
                            self.strip_cmd_prefix(sugg, key_and_aliases) for sugg in suggestions
                        ]
                        break

            output = self.format_help_entry(
                topic=None,  # this will give a no-match style title
                help_text=help_text,
                suggested=suggestions,
                click_topics=clickable_topics,
            )

            self.msg_help(output)
            return

        if isinstance(match, HelpCategory):
            # no subtopics for categories - these are just lists of topics
            category = match.key
            category_lower = category.lower()
            cmds_in_category = [
                key for key, cmd in cmd_help_topics.items() if category_lower == cmd.help_category
            ]
            topics_in_category = [
                key
                for key, topic in file_db_help_topics.items()
                if category_lower == topic.help_category
            ]
            output = self.format_help_index(
                {category: cmds_in_category},
                {category: topics_in_category},
                title_lone_category=True,
                click_topics=clickable_topics,
            )
            self.msg_help(output)
            return

        if inherits_from(match, "evennia.commands.command.Command"):
            # a command match
            topic = match.key
            help_text = match.get_help(caller, cmdset)
            aliases = match.aliases
            suggested = suggestions[1:]
        else:
            # a database (or file-help) match
            topic = match.key
            help_text = match.entrytext
            aliases = match.aliases if isinstance(match.aliases, list) else match.aliases.all()
            suggested = suggestions[1:]

        # parse for subtopics. The subtopic_map is a dict with the current topic/subtopic
        # text is stored under a `None` key and all other keys are subtopic titles pointing
        # to nested dicts.

        subtopic_map = parse_entry_for_subcategories(help_text)
        help_text = subtopic_map[None]
        subtopic_index = [subtopic for subtopic in subtopic_map if subtopic is not None]

        if subtopics:
            # if we asked for subtopics, parse the found topic_text to see if any match.
            # the subtopics is a list describing the path through the subtopic_map.

            for subtopic_query in subtopics:
                if subtopic_query not in subtopic_map:
                    # exact match failed. Try startswith-match
                    fuzzy_match = False
                    for key in subtopic_map:
                        if key and key.startswith(subtopic_query):
                            subtopic_query = key
                            fuzzy_match = True
                            break

                    if not fuzzy_match:
                        # startswith failed - try an 'in' match
                        for key in subtopic_map:
                            if key and subtopic_query in key:
                                subtopic_query = key
                                fuzzy_match = True
                                break

                    if not fuzzy_match:
                        # no match found - give up
                        checked_topic = topic + f"{self.subtopic_separator_char}{subtopic_query}"
                        output = self.format_help_entry(
                            topic=topic,
                            help_text=f"No help entry found for '{checked_topic}'",
                            subtopics=subtopic_index,
                            click_topics=clickable_topics,
                        )
                        self.msg_help(output)
                        return

                # if we get here we have an exact or fuzzy match

                subtopic_map = subtopic_map.pop(subtopic_query)
                subtopic_index = [subtopic for subtopic in subtopic_map if subtopic is not None]
                # keep stepping down into the tree, append path to show position
                topic = topic + f"{self.subtopic_separator_char}{subtopic_query}"

            # we reached the bottom of the topic tree
            help_text = subtopic_map[None]

        topic = self.strip_cmd_prefix(topic, key_and_aliases)
        if subtopics:
            aliases = None
        else:
            aliases = [self.strip_cmd_prefix(alias, key_and_aliases) for alias in aliases]
        suggested = [self.strip_cmd_prefix(sugg, key_and_aliases) for sugg in suggested]

        output = self.format_help_entry(
            topic=topic,
            help_text=help_text,
            aliases=aliases,
            subtopics=subtopic_index,
            suggested=suggested,
            click_topics=clickable_topics,
        )

        self.msg_help(output)


def _loadhelp(caller):
    entry = caller.db._editing_help
    if entry:
        return entry.entrytext
    else:
        return ""


def _savehelp(caller, buffer):
    entry = caller.db._editing_help
    caller.msg("Saved help entry.")
    if entry:
        entry.entrytext = buffer


def _quithelp(caller):
    caller.msg("Closing the editor.")
    del caller.db._editing_help


class CmdSetHelp(CmdHelp):
    """
    Edit the help database.

    Usage:
      sethelp[/switches] <topic>[[;alias;alias][,category[,locks]]
                [= <text or new value>]
    Switches:
      edit - open a line editor to edit the topic's help text.
      replace - overwrite existing help topic.
      append - add text to the end of existing topic with a newline between.
      extend - as append, but don't add a newline.
      category - change category of existing help topic.
      locks - change locks of existing help topic.
      delete - remove help topic.

    Examples:
      sethelp lore = In the beginning was ...
      sethelp/append pickpocketing,Thievery = This steals ...
      sethelp/replace pickpocketing, ,attr(is_thief) = This steals ...
      sethelp/edit thievery
      sethelp/locks thievery = read:all()
      sethelp/category thievery = classes

    If not assigning a category, the `settings.DEFAULT_HELP_CATEGORY` category
    will be used. If no lockstring is specified, everyone will be able to read
    the help entry.  Sub-topics are embedded in the help text.

    Note that this cannot modify command-help entries - these are modified
    in-code, outside the game.

    # SUBTOPICS

    ## Adding subtopics

    Subtopics helps to break up a long help entry into sub-sections. Users can
    access subtopics with |whelp topic/subtopic/...|n Subtopics are created and
    stored together with the main topic.

    To start adding subtopics, add the text '# SUBTOPICS' on a new line at the
    end of your help text. After this you can now add any number of subtopics,
    each starting with '## <subtopic-name>' on a line, followed by the
    help-text of that subtopic.
    Use '### <subsub-name>' to add a sub-subtopic and so on. Max depth is 5. A
    subtopic's title is case-insensitive and can consist of multiple words -
    the user will be able to enter a partial match to access it.

    For example:

    | Main help text for <topic>
    |
    | # SUBTOPICS
    |
    | ## about
    |
    | Text for the '<topic>/about' subtopic'
    |
    | ### more about-info
    |
    | Text for the '<topic>/about/more about-info sub-subtopic
    |
    | ## extra
    |
    | Text for the '<topic>/extra' subtopic

    """

    key = "sethelp"
    aliases = []
    switch_options = ("edit", "replace", "append", "extend", "category", "locks", "delete")
    locks = "cmd:perm(Helper)"
    help_category = "Building"
    arg_regex = None

    def parse(self):
        """We want to use the default parser rather than the CmdHelp.parse"""
        return COMMAND_DEFAULT_CLASS.parse(self)

    def func(self):
        """Implement the function"""

        switches = self.switches
        lhslist = self.lhslist
        rhslist = self.rhslist

        if not self.args:
            self.msg(
                "Usage: sethelp[/switches] <topic>[[;alias;alias][,category[,locks]] [= <text or new category>]"
            )
            return

        nlist = len(lhslist)
        topicstr = lhslist[0] if nlist > 0 else ""
        if not topicstr:
            self.msg("You have to define a topic!")
            return
        topicstrlist = topicstr.split(";")
        topicstr, aliases = (
            topicstrlist[0],
            topicstrlist[1:] if len(topicstr) > 1 else [],
        )
        aliastxt = ("(aliases: %s)" % ", ".join(aliases)) if aliases else ""
        old_entry = None

        # check if we have an old entry with the same name

        cmd_help_topics, db_help_topics, file_help_topics = self.collect_topics(
            self.caller, mode="query"
        )
        # db-help topics takes priority over file-help
        file_db_help_topics = {**file_help_topics, **db_help_topics}
        # commands take priority over the other types
        all_topics = {**file_db_help_topics, **cmd_help_topics}
        # get all categories
        all_categories = list(
            set(HelpCategory(topic.help_category) for topic in all_topics.values())
        )
        # all available help options - will be searched in order. We also check # the
        # read-permission here.
        entries = list(all_topics.values()) + all_categories

        # default setup
        category = lhslist[1] if nlist > 1 else DEFAULT_HELP_CATEGORY
        lockstring = ",".join(lhslist[2:]) if nlist > 2 else "read:all()"

        # search for existing entries of this or other types
        old_entry = None
        for querystr in topicstrlist:
            match, _ = self.do_search(querystr, entries)
            if match:
                warning = None
                if isinstance(match, HelpCategory):
                    warning = (
                        f"'{querystr}' matches (or partially matches) the name of "
                        f"help-category '{match.key}'. If you continue, your help entry will "
                        "take precedence and the category (or part of its name) *may* not "
                        "be usable for grouping help entries anymore."
                    )
                elif inherits_from(match, "evennia.commands.command.Command"):
                    warning = (
                        f"'{querystr}' matches (or partially matches) the key/alias of "
                        f"Command '{match.key}'. Command-help take precedence over other "
                        "help entries so your help *may* be impossible to reach for those "
                        "with access to that command."
                    )
                elif inherits_from(match, "evennia.help.filehelp.FileHelpEntry"):
                    warning = (
                        f"'{querystr}' matches (or partially matches) the name/alias of the "
                        f"file-based help topic '{match.key}'. File-help entries cannot be "
                        "modified from in-game (they are files on-disk). If you continue, "
                        "your help entry may shadow the file-based one's name partly or "
                        "completely."
                    )
                if warning:
                    # show a warning for a clashing help-entry type. Even if user accepts this
                    # we don't break here since we may need to show warnings for other inputs.
                    # We don't count this as an old-entry hit because we can't edit these
                    # types of entries.
                    self.msg(f"|rWarning:\n|r{warning}|n")
                    repl = yield ("|wDo you still want to continue? Y/[N]?|n")
                    if repl.lower() in ("y", "yes"):
                        # find a db-based help entry if one already exists
                        db_topics = {**db_help_topics}
                        db_categories = list(
                            set(HelpCategory(topic.help_category) for topic in db_topics.values())
                        )
                        entries = list(db_topics.values()) + db_categories
                        match, _ = self.do_search(querystr, entries)
                        if match:
                            old_entry = match
                    else:
                        self.msg("Aborted.")
                        return
                else:
                    # a db-based help entry - this is OK
                    old_entry = match
                    category = lhslist[1] if nlist > 1 else old_entry.help_category
                    lockstring = ",".join(lhslist[2:]) if nlist > 2 else old_entry.locks.get()
                    break

        category = category.lower()

        if "edit" in switches:
            # open the line editor to edit the helptext. No = is needed.
            if old_entry:
                topicstr = old_entry.key
                if self.rhs:
                    # we assume append here.
                    old_entry.entrytext += "\n%s" % self.rhs
                helpentry = old_entry
            else:
                helpentry = create.create_help_entry(
                    topicstr,
                    self.rhs if self.rhs is not None else "",
                    category=category,
                    locks=lockstring,
                    aliases=aliases,
                )
            self.caller.db._editing_help = helpentry

            EvEditor(
                self.caller,
                loadfunc=_loadhelp,
                savefunc=_savehelp,
                quitfunc=_quithelp,
                key="topic {}".format(topicstr),
                persistent=True,
            )
            return

        if "append" in switches or "merge" in switches or "extend" in switches:
            # merge/append operations
            if not old_entry:
                self.msg(f"Could not find topic '{topicstr}'. You must give an exact name.")
                return
            if not self.rhs:
                self.msg("You must supply text to append/merge.")
                return
            if "merge" in switches:
                old_entry.entrytext += " " + self.rhs
            else:
                old_entry.entrytext += "\n%s" % self.rhs
            old_entry.aliases.add(aliases)
            self.msg(f"Entry updated:\n{old_entry.entrytext}{aliastxt}")
            return

        if "category" in switches:
            # set the category
            if not old_entry:
                self.msg(f"Could not find topic '{topicstr}'{aliastxt}.")
                return
            if not self.rhs:
                self.msg("You must supply a category.")
                return
            category = self.rhs.lower()
            old_entry.help_category = category
            self.msg(f"Category for entry '{topicstr}'{aliastxt} changed to '{category}'.")
            return

        if "locks" in switches:
            # set the locks
            if not old_entry:
                self.msg(f"Could not find topic '{topicstr}'{aliastxt}.")
                return
            show_locks = not rhslist
            clear_locks = rhslist and not rhslist[0]
            if show_locks:
                self.msg(f"Current locks for entry '{topicstr}'{aliastxt} are: {old_entry.locks}")
                return
            if clear_locks:
                old_entry.locks.clear()
                old_entry.locks.add("read:all()")
                self.msg(f"Locks for entry '{topicstr}'{aliastxt} reset to: read:all()")
                return
            lockstring = ",".join(rhslist)
            # locks.validate() does not throw an exception for things like "read:id(1),read:id(6)"
            # but locks.add() does
            existing_locks = old_entry.locks.all()
            old_entry.locks.clear()
            try:
                old_entry.locks.add(lockstring)
            except LockException as e:
                old_entry.locks.add(existing_locks)
                self.msg(str(e) + " Locks not changed.")
            else:
                self.msg(f"Locks for entry '{topicstr}'{aliastxt} changed to: {lockstring}")
            return

        if "delete" in switches or "del" in switches:
            # delete the help entry
            if not old_entry:
                self.msg(f"Could not find topic '{topicstr}'{aliastxt}.")
                return
            old_entry.delete()
            self.msg(f"Deleted help entry '{topicstr}'{aliastxt}.")
            return

        # at this point it means we want to add a new help entry.
        if not self.rhs:
            self.msg("You must supply a help text to add.")
            return
        if old_entry:
            if "replace" in switches:
                # overwrite old entry
                old_entry.key = topicstr
                old_entry.entrytext = self.rhs
                old_entry.help_category = category
                old_entry.locks.clear()
                old_entry.locks.add(lockstring)
                old_entry.aliases.add(aliases)
                old_entry.save()
                self.msg(f"Overwrote the old topic '{topicstr}'{aliastxt}.")
            else:
                self.msg(
                    f"Topic '{topicstr}'{aliastxt} already exists. Use /edit to open in editor, or "
                    "/replace, /append and /merge to modify it directly."
                )
        else:
            # no old entry. Create a new one.
            new_entry = create.create_help_entry(
                topicstr, self.rhs, category=category, locks=lockstring, aliases=aliases
            )
            if new_entry:
                self.msg(f"Topic '{topicstr}'{aliastxt} was successfully created.")
                if "edit" in switches:
                    # open the line editor to edit the helptext
                    self.caller.db._editing_help = new_entry
                    EvEditor(
                        self.caller,
                        loadfunc=_loadhelp,
                        savefunc=_savehelp,
                        quitfunc=_quithelp,
                        key="topic {}".format(new_entry.key),
                        persistent=True,
                    )
                    return
            else:
                self.msg(f"Error when creating topic '{topicstr}'{aliastxt}! Contact an admin.")
