# Copyright 2012-2013 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
#     http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
import logging
import os
import re

from botocore import xform_name
from botocore.model import StringShape
from botocore.utils import is_json_value_header

from awscli import SCALAR_TYPES, __version__ as AWS_CLI_VERSION
from awscli.argprocess import ParamShorthandDocGen
from awscli.bcdoc.docevents import DOC_EVENTS
from awscli.topictags import TopicTagDB
from awscli.utils import (
    find_service_and_method_in_event_name,
    is_document_type,
    is_streaming_blob_type,
    is_tagged_union_type,
    operation_uses_document_types,
)

LOG = logging.getLogger(__name__)
EXAMPLES_DIR = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), 'examples'
)
GLOBAL_OPTIONS_FILE = os.path.join(EXAMPLES_DIR, 'global_options.rst')
GLOBAL_OPTIONS_SYNOPSIS_FILE = os.path.join(
    EXAMPLES_DIR, 'global_synopsis.rst'
)


class CLIDocumentEventHandler:
    def __init__(self, help_command):
        self.help_command = help_command
        self.register(help_command.session, help_command.event_class)
        self._arg_groups = self._build_arg_table_groups(help_command)
        self._documented_arg_groups = []

    def _build_arg_table_groups(self, help_command):
        arg_groups = {}
        for arg in help_command.arg_table.values():
            if arg.group_name is not None:
                arg_groups.setdefault(arg.group_name, []).append(arg)
        return arg_groups

    def _get_argument_type_name(self, shape, default):
        if is_json_value_header(shape):
            return 'JSON'
        if is_document_type(shape):
            return 'document'
        if is_streaming_blob_type(shape):
            return 'streaming blob'
        if is_tagged_union_type(shape):
            return 'tagged union structure'
        return default

    def _map_handlers(self, session, event_class, mapfn):
        for event in DOC_EVENTS:
            event_handler_name = event.replace('-', '_')
            if hasattr(self, event_handler_name):
                event_handler = getattr(self, event_handler_name)
                format_string = DOC_EVENTS[event]
                num_args = len(format_string.split('.')) - 2
                format_args = (event_class,) + ('*',) * num_args
                event_string = event + format_string % format_args
                unique_id = event_class + event_handler_name
                mapfn(event_string, event_handler, unique_id)

    def register(self, session, event_class):
        """
        The default register iterates through all of the
        available document events and looks for a corresponding
        handler method defined in the object.  If it's there, that
        handler method will be registered for the all events of
        that type for the specified ``event_class``.
        """
        self._map_handlers(session, event_class, session.register)

    def unregister(self):
        """
        The default unregister iterates through all of the
        available document events and looks for a corresponding
        handler method defined in the object.  If it's there, that
        handler method will be unregistered for the all events of
        that type for the specified ``event_class``.
        """
        self._map_handlers(
            self.help_command.session,
            self.help_command.event_class,
            self.help_command.session.unregister,
        )

    # These are default doc handlers that apply in the general case.

    def doc_breadcrumbs(self, help_command, **kwargs):
        doc = help_command.doc
        if doc.target != 'man':
            cmd_names = help_command.event_class.split('.')
            doc.write('[ ')
            doc.write(':ref:`aws <cli:aws>`')
            full_cmd_list = ['aws']
            for cmd in cmd_names[:-1]:
                doc.write(' . ')
                full_cmd_list.append(cmd)
                full_cmd_name = ' '.join(full_cmd_list)
                doc.write(f':ref:`{cmd} <cli:{full_cmd_name}>`')
            doc.writeln(' ]')
            doc.writeln('')

    def doc_title(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.new_paragraph()
        reference = help_command.event_class.replace('.', ' ')
        if reference != 'aws':
            reference = 'aws ' + reference
        doc.writeln(f'.. _cli:{reference}:')
        doc.style.h1(help_command.name)

    def doc_description(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.h2('Description')
        doc.include_doc_string(help_command.description)
        doc.style.new_paragraph()

    def doc_synopsis_start(self, help_command, **kwargs):
        self._documented_arg_groups = []
        doc = help_command.doc
        doc.style.h2('Synopsis')
        doc.style.start_codeblock()
        doc.writeln(f'{help_command.name}')

    def doc_synopsis_option(self, arg_name, help_command, **kwargs):
        doc = help_command.doc
        argument = help_command.arg_table[arg_name]
        if argument.group_name in self._arg_groups:
            if argument.group_name in self._documented_arg_groups:
                # This arg is already documented so we can move on.
                return
            option_str = ' | '.join(
                [a.cli_name for a in self._arg_groups[argument.group_name]]
            )
            self._documented_arg_groups.append(argument.group_name)
        elif argument.cli_name.startswith('--'):
            option_str = f'{argument.cli_name} <value>'
        else:
            option_str = f'<{argument.cli_name}>'
        if not (
            argument.required
            or getattr(argument, '_DOCUMENT_AS_REQUIRED', False)
        ):
            option_str = f'[{option_str}]'
        doc.writeln(f'{option_str}')

    def doc_synopsis_end(self, help_command, **kwargs):
        doc = help_command.doc
        # Append synopsis for global options.
        doc.write_from_file(GLOBAL_OPTIONS_SYNOPSIS_FILE)
        doc.style.end_codeblock()
        # Reset the documented arg groups for other sections
        # that may document args (the detailed docs following
        # the synopsis).
        self._documented_arg_groups = []

    def doc_options_start(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.h2('Options')
        if not help_command.arg_table:
            doc.write('*None*\n')

    def doc_option(self, arg_name, help_command, **kwargs):
        doc = help_command.doc
        argument = help_command.arg_table[arg_name]
        if argument.required or getattr(
            argument, '_DOCUMENT_AS_REQUIRED', False
        ):
            required_field = ' [required]'
        else:
            required_field = ''

        if argument.group_name in self._arg_groups:
            if argument.group_name in self._documented_arg_groups:
                # This arg is already documented so we can move on.
                return
            name = ' | '.join(
                [
                    f'``{a.cli_name}``'
                    for a in self._arg_groups[argument.group_name]
                ]
            )
            self._documented_arg_groups.append(argument.group_name)
        else:
            name = f'``{argument.cli_name}``'
        doc.write(
            f'{name} '
            f'({self._get_argument_type_name(argument.argument_model, argument.cli_type_name)})'
            f'{required_field}\n'
        )

        doc.style.indent()
        doc.include_doc_string(argument.documentation)
        if is_streaming_blob_type(argument.argument_model):
            self._add_streaming_blob_note(doc)
        if is_tagged_union_type(argument.argument_model):
            self._add_tagged_union_note(argument.argument_model, doc)
        if hasattr(argument, 'argument_model'):
            self._document_enums(argument.argument_model, doc)
            self._document_constraints(argument.argument_model, doc)
            self._document_nested_structure(argument.argument_model, doc)
        doc.style.dedent()
        doc.style.new_paragraph()

    def doc_global_option(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.h2('Global Options')
        doc.write_from_file(GLOBAL_OPTIONS_FILE)

    def doc_relateditems_start(self, help_command, **kwargs):
        if help_command.related_items:
            doc = help_command.doc
            doc.style.h2('See Also')

    def doc_relateditem(self, help_command, related_item, **kwargs):
        doc = help_command.doc
        doc.write('* ')
        doc.style.sphinx_reference_label(
            label=f'cli:{related_item}', text=related_item
        )
        doc.write('\n')

    def doc_meta_description(self, help_command, **kwargs):
        pass

    def _document_enums(self, model, doc):
        """Documents top-level parameter enums"""
        if isinstance(model, StringShape):
            if model.enum:
                doc.style.new_paragraph()
                doc.write('Possible values:')
                doc.style.start_ul()
                for enum in model.enum:
                    doc.style.li(f'``{enum}``')
                doc.style.end_ul()

    def _document_nested_structure(self, model, doc):
        """Recursively documents parameters in nested structures"""
        member_type_name = getattr(model, 'type_name', None)
        if member_type_name == 'structure':
            required_members = model.metadata.get('required', [])
            for member_name, member_shape in model.members.items():
                is_required = member_name in required_members
                self._doc_member(
                    doc,
                    member_name,
                    member_shape,
                    stack=[model.name],
                    required=is_required,
                )
        elif member_type_name == 'list':
            self._doc_member(
                doc, '', model.member, stack=[model.name], required=False
            )
        elif member_type_name == 'map':
            key_shape = model.key
            key_name = key_shape.serialization.get('name', 'key')
            self._doc_member(
                doc, key_name, key_shape, stack=[model.name], required=False
            )
            value_shape = model.value
            value_name = value_shape.serialization.get('name', 'value')
            self._doc_member(
                doc,
                value_name,
                value_shape,
                stack=[model.name],
                required=False,
            )

    def _doc_member(
        self, doc, member_name, member_shape, stack, required=False
    ):
        if member_shape.name in stack:
            # Document the recursion once, otherwise just
            # note the fact that it's recursive and return.
            if stack.count(member_shape.name) > 1:
                if member_shape.type_name == 'structure':
                    doc.write('( ... recursive ... )')
                return
        stack.append(member_shape.name)
        try:
            self._do_doc_member(
                doc, member_name, member_shape, stack, required
            )
        finally:
            stack.pop()

    def _do_doc_member(
        self, doc, member_name, member_shape, stack, required=False
    ):
        docs = member_shape.documentation
        type_name = self._get_argument_type_name(
            member_shape, member_shape.type_name
        )

        if member_name:
            parameter_string = f'{member_name} -> ({type_name})'
        else:
            parameter_string = f'({type_name})'

        if required:
            parameter_string += ' [required]'

        doc.write(parameter_string)

        doc.style.indent()
        doc.style.new_paragraph()
        doc.include_doc_string(docs)
        if is_tagged_union_type(member_shape):
            self._add_tagged_union_note(member_shape, doc)

        self._document_enums(member_shape, doc)
        self._document_constraints(member_shape, doc)
        doc.style.new_paragraph()
        member_type_name = member_shape.type_name
        if member_type_name == 'structure':
            required_members = member_shape.metadata.get('required', [])
            for sub_name, sub_shape in member_shape.members.items():
                sub_required = sub_name in required_members
                self._doc_member(
                    doc, sub_name, sub_shape, stack, required=sub_required
                )
        elif member_type_name == 'map':
            key_shape = member_shape.key
            key_name = key_shape.serialization.get('name', 'key')
            self._doc_member(doc, key_name, key_shape, stack, required=False)
            value_shape = member_shape.value
            value_name = value_shape.serialization.get('name', 'value')
            self._doc_member(
                doc, value_name, value_shape, stack, required=False
            )
        elif member_type_name == 'list':
            self._doc_member(
                doc, '', member_shape.member, stack, required=False
            )
        doc.style.dedent()
        doc.style.new_paragraph()

    def _add_streaming_blob_note(self, doc):
        doc.style.start_note()
        msg = (
            "This argument is of type: streaming blob. "
            "Its value must be the path to a file "
            "(e.g. ``path/to/file``) and must **not** "
            "be prefixed with ``file://`` or ``fileb://``"
        )
        doc.writeln(msg)
        doc.style.end_note()

    def _add_tagged_union_note(self, shape, doc):
        doc.style.start_note()
        members_str = ", ".join([f'``{key}``' for key in shape.members.keys()])
        msg = (
            "This is a Tagged Union structure. Only one of the "
            f"following top level keys can be set: {members_str}."
        )
        doc.writeln(msg)
        doc.style.end_note()

    def _document_constraints(self, model, doc):
        """Documents parameter value constraints"""
        if not hasattr(model, 'metadata'):
            return
        constraints = ['min', 'max', 'pattern']
        if not any(
            [constraint in model.metadata for constraint in constraints]
        ):
            return
        doc.style.new_paragraph()
        doc.write('Constraints:')
        doc.style.start_ul()
        for constraint in constraints:
            if (val := model.metadata.get(constraint)) is not None:
                doc.style.li(f'{constraint}: ``{val}``')
        doc.style.end_ul()


class ProviderDocumentEventHandler(CLIDocumentEventHandler):
    def doc_breadcrumbs(self, help_command, event_name, **kwargs):
        pass

    def doc_synopsis_start(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.h2('Synopsis')
        doc.style.codeblock(help_command.synopsis)
        doc.include_doc_string(help_command.help_usage)

    def doc_synopsis_option(self, arg_name, help_command, **kwargs):
        pass

    def doc_synopsis_end(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.new_paragraph()

    def doc_options_start(self, help_command, **kwargs):
        pass

    def doc_option(self, arg_name, help_command, **kwargs):
        pass

    def doc_subitems_start(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.h2('Available Services')
        doc.style.toctree()

    def doc_subitem(self, command_name, help_command, **kwargs):
        doc = help_command.doc
        file_name = f'{command_name}/index'
        doc.style.tocitem(command_name, file_name=file_name)


class ServiceDocumentEventHandler(CLIDocumentEventHandler):
    # A service document has no synopsis.
    def doc_synopsis_start(self, help_command, **kwargs):
        pass

    def doc_synopsis_option(self, arg_name, help_command, **kwargs):
        pass

    def doc_synopsis_end(self, help_command, **kwargs):
        pass

    # A service document has no option section.
    def doc_options_start(self, help_command, **kwargs):
        pass

    def doc_option(self, arg_name, help_command, **kwargs):
        pass

    def doc_option_example(self, arg_name, help_command, **kwargs):
        pass

    def doc_options_end(self, help_command, **kwargs):
        pass

    def doc_global_option(self, help_command, **kwargs):
        pass

    def doc_description(self, help_command, **kwargs):
        doc = help_command.doc
        service_model = help_command.obj
        doc.style.h2('Description')
        # TODO: need a documentation attribute.
        doc.include_doc_string(service_model.documentation)

    def doc_subitems_start(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.h2('Available Commands')
        doc.style.toctree()

    def doc_subitem(self, command_name, help_command, **kwargs):
        doc = help_command.doc
        subcommand = help_command.command_table[command_name]
        subcommand_table = getattr(subcommand, 'subcommand_table', {})
        # If the subcommand table has commands in it,
        # direct the subitem to the command's index because
        # it has more subcommands to be documented.
        if len(subcommand_table) > 0:
            file_name = f'{command_name}/index'
            doc.style.tocitem(command_name, file_name=file_name)
        else:
            doc.style.tocitem(command_name)

    def doc_meta_description(self, help_command, **kwargs):
        doc = help_command.doc
        reference = help_command.event_class.replace('.', ' ')
        doc.writeln(".. meta::")
        doc.writeln(f"   :description: Learn about the AWS CLI {AWS_CLI_VERSION} {reference} commands.")
        doc.writeln("")


class OperationDocumentEventHandler(CLIDocumentEventHandler):
    AWS_DOC_BASE = 'https://docs.aws.amazon.com/goto/WebAPI'

    def doc_description(self, help_command, **kwargs):
        doc = help_command.doc
        operation_model = help_command.obj
        doc.style.h2('Description')
        doc.include_doc_string(operation_model.documentation)
        self._add_webapi_crosslink(help_command)
        self._add_note_for_document_types_if_used(help_command)

    def _add_webapi_crosslink(self, help_command):
        doc = help_command.doc
        operation_model = help_command.obj
        service_model = operation_model.service_model
        service_uid = service_model.metadata.get('uid')
        if service_uid is None:
            # If there's no service_uid in the model, we can't
            # be certain if the generated cross link will work
            # so we don't generate any crosslink info.
            return
        doc.style.new_paragraph()
        doc.write("See also: ")
        link = f'{self.AWS_DOC_BASE}/{service_uid}/{operation_model.name}'
        doc.style.external_link(title="AWS API Documentation", link=link)
        doc.writeln('')

    def _add_note_for_document_types_if_used(self, help_command):
        if operation_uses_document_types(help_command.obj):
            help_command.doc.style.new_paragraph()
            help_command.doc.writeln(
                f'``{help_command.name}`` uses document type values. Document types follow the '
                'JSON data model where valid values are: strings, numbers, '
                'booleans, null, arrays, and objects. For command input, '
                'options and nested parameters that are labeled with the type '
                '``document`` must be provided as JSON. Shorthand syntax does '
                'not support document types.'
            )

    def _json_example_value_name(
        self, argument_model, include_enum_values=True
    ):
        # If include_enum_values is True, then the valid enum values
        # are included as the sample JSON value.
        if isinstance(argument_model, StringShape):
            if argument_model.enum and include_enum_values:
                choices = argument_model.enum
                return '|'.join([f'"{c}"' for c in choices])
            else:
                return '"string"'
        elif argument_model.type_name == 'boolean':
            return 'true|false'
        else:
            return f'{argument_model.type_name}'

    def _json_example(self, doc, argument_model, stack):
        if argument_model.name in stack:
            # Document the recursion once, otherwise just
            # note the fact that it's recursive and return.
            if stack.count(argument_model.name) > 1:
                if argument_model.type_name == 'structure':
                    doc.write('{ ... recursive ... }')
                return
        stack.append(argument_model.name)
        try:
            self._do_json_example(doc, argument_model, stack)
        finally:
            stack.pop()

    def _do_json_example(self, doc, argument_model, stack):
        if argument_model.type_name == 'list':
            doc.write('[')
            if argument_model.member.type_name in SCALAR_TYPES:
                doc.write(
                    f'{self._json_example_value_name(argument_model.member)}, ...'
                )
            else:
                doc.style.indent()
                doc.style.new_line()
                self._json_example(doc, argument_model.member, stack)
                doc.style.new_line()
                doc.write('...')
                doc.style.dedent()
                doc.style.new_line()
            doc.write(']')
        elif argument_model.type_name == 'map':
            doc.write('{')
            doc.style.indent()
            key_string = self._json_example_value_name(argument_model.key)
            doc.write(f'{key_string}: ')
            if argument_model.value.type_name in SCALAR_TYPES:
                doc.write(self._json_example_value_name(argument_model.value))
            else:
                doc.style.indent()
                self._json_example(doc, argument_model.value, stack)
                doc.style.dedent()
            doc.style.new_line()
            doc.write('...')
            doc.style.dedent()
            doc.write('}')
        elif argument_model.type_name == 'structure':
            if argument_model.is_document_type:
                self._doc_document_member(doc)
            else:
                self._doc_input_structure_members(doc, argument_model, stack)

    def _doc_document_member(self, doc):
        doc.write('{...}')

    def _doc_input_structure_members(self, doc, argument_model, stack):
        doc.write('{')
        doc.style.indent()
        doc.style.new_line()
        members = argument_model.members
        for i, member_name in enumerate(members):
            member_model = members[member_name]
            member_type_name = member_model.type_name
            if member_type_name in SCALAR_TYPES:
                doc.write(
                    f'"{member_name}": {self._json_example_value_name(member_model)}'
                )
            elif member_type_name == 'structure':
                doc.write(f'"{member_name}": ')
                self._json_example(doc, member_model, stack)
            elif member_type_name == 'map':
                doc.write(f'"{member_name}": ')
                self._json_example(doc, member_model, stack)
            elif member_type_name == 'list':
                doc.write(f'"{member_name}": ')
                self._json_example(doc, member_model, stack)
            if i < len(members) - 1:
                doc.write(',')
                doc.style.new_line()
        doc.style.dedent()
        doc.style.new_line()
        doc.write('}')

    def doc_option_example(self, arg_name, help_command, event_name, **kwargs):
        service_id, operation_name = find_service_and_method_in_event_name(
            event_name
        )
        doc = help_command.doc
        cli_argument = help_command.arg_table[arg_name]
        if cli_argument.group_name in self._arg_groups:
            if cli_argument.group_name in self._documented_arg_groups:
                # Args with group_names (boolean args) don't
                # need to generate example syntax.
                return
        argument_model = cli_argument.argument_model
        docgen = ParamShorthandDocGen()
        if docgen.supports_shorthand(cli_argument.argument_model):
            example_shorthand_syntax = docgen.generate_shorthand_example(
                cli_argument, service_id, operation_name
            )
            if example_shorthand_syntax is None:
                # If the shorthand syntax returns a value of None,
                # this indicates to us that there is no example
                # needed for this param so we can immediately
                # return.
                return
            if example_shorthand_syntax:
                doc.style.new_paragraph()
                doc.write('Shorthand Syntax')
                doc.style.start_codeblock()
                for example_line in example_shorthand_syntax.splitlines():
                    doc.writeln(example_line)
                doc.style.end_codeblock()
        if (
            argument_model is not None
            and argument_model.type_name == 'list'
            and argument_model.member.type_name in SCALAR_TYPES
        ):
            # A list of scalars is special.  While you *can* use
            # JSON ( ["foo", "bar", "baz"] ), you can also just
            # use the argparse behavior of space separated lists.
            # "foo" "bar" "baz".  In fact we don't even want to
            # document the JSON syntax in this case.
            member = argument_model.member
            doc.style.new_paragraph()
            doc.write('Syntax')
            doc.style.start_codeblock()
            example_type = self._json_example_value_name(
                member, include_enum_values=False
            )
            doc.write(f'{example_type} {example_type} ...')
            doc.style.end_codeblock()
            doc.style.new_paragraph()
        elif cli_argument.cli_type_name not in SCALAR_TYPES:
            doc.style.new_paragraph()
            doc.write('JSON Syntax')
            doc.style.start_codeblock()
            self._json_example(doc, argument_model, stack=[])
            doc.style.end_codeblock()
            doc.style.new_paragraph()

    def doc_output(self, help_command, event_name, **kwargs):
        doc = help_command.doc
        doc.style.h2('Output')
        operation_model = help_command.obj
        output_shape = operation_model.output_shape
        if output_shape is None or not output_shape.members:
            doc.write('None')
        else:
            for member_name, member_shape in output_shape.members.items():
                self._doc_member(doc, member_name, member_shape, stack=[])

    def doc_meta_description(self, help_command, **kwargs):
        doc = help_command.doc
        reference = help_command.event_class.replace('.', ' ')
        doc.writeln(".. meta::")
        doc.writeln(f"   :description: Use the AWS CLI {AWS_CLI_VERSION} to run the {reference} command.")
        doc.writeln("")

class TopicListerDocumentEventHandler(CLIDocumentEventHandler):
    DESCRIPTION = (
        'This is the AWS CLI Topic Guide. It gives access to a set '
        'of topics that provide a deeper understanding of the CLI. To access '
        'the list of topics from the command line, run ``aws help topics``. '
        'To access a specific topic from the command line, run '
        '``aws help [topicname]``, where ``topicname`` is the name of the '
        'topic as it appears in the output from ``aws help topics``.'
    )

    def __init__(self, help_command):
        self.help_command = help_command
        self.register(help_command.session, help_command.event_class)
        self._topic_tag_db = TopicTagDB()
        self._topic_tag_db.load_json_index()

    def doc_breadcrumbs(self, help_command, **kwargs):
        doc = help_command.doc
        if doc.target != 'man':
            doc.write('[ ')
            doc.style.sphinx_reference_label(label='cli:aws', text='aws')
            doc.write(' ]')

    def doc_title(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.new_paragraph()
        doc.style.link_target_definition(
            refname=f'cli:aws help {self.help_command.name}', link=''
        )
        doc.style.h1('AWS CLI Topic Guide')

    def doc_description(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.h2('Description')
        doc.include_doc_string(self.DESCRIPTION)
        doc.style.new_paragraph()

    def doc_synopsis_start(self, help_command, **kwargs):
        pass

    def doc_synopsis_end(self, help_command, **kwargs):
        pass

    def doc_options_start(self, help_command, **kwargs):
        pass

    def doc_options_end(self, help_command, **kwargs):
        pass

    def doc_global_option(self, help_command, **kwargs):
        pass

    def doc_subitems_start(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.h2('Available Topics')

        categories = self._topic_tag_db.query('category')
        topic_names = self._topic_tag_db.get_all_topic_names()

        # Sort the categories
        category_names = sorted(categories.keys())
        for category_name in category_names:
            doc.style.h3(category_name)
            doc.style.new_paragraph()
            # Write out the topic and a description for each topic under
            # each category.
            for topic_name in sorted(categories[category_name]):
                description = self._topic_tag_db.get_tag_single_value(
                    topic_name, 'description'
                )
                doc.write('* ')
                doc.style.sphinx_reference_label(
                    label=f'cli:aws help {topic_name}', text=topic_name
                )
                doc.write(f': {description}\n')
        # Add a hidden toctree to make sure everything is connected in
        # the document.
        doc.style.hidden_toctree()
        for topic_name in topic_names:
            doc.style.hidden_tocitem(topic_name)


class TopicDocumentEventHandler(TopicListerDocumentEventHandler):
    def doc_breadcrumbs(self, help_command, **kwargs):
        doc = help_command.doc
        if doc.target != 'man':
            doc.write('[ ')
            doc.style.sphinx_reference_label(label='cli:aws', text='aws')
            doc.write(' . ')
            doc.style.sphinx_reference_label(
                label='cli:aws help topics', text='topics'
            )
            doc.write(' ]')

    def doc_title(self, help_command, **kwargs):
        doc = help_command.doc
        doc.style.new_paragraph()
        doc.style.link_target_definition(
            refname=f'cli:aws help {self.help_command.name}', link=''
        )
        title = self._topic_tag_db.get_tag_single_value(
            help_command.name, 'title'
        )
        doc.style.h1(title)

    def doc_description(self, help_command, **kwargs):
        doc = help_command.doc
        topic_filename = os.path.join(
            self._topic_tag_db.topic_dir, help_command.name + '.rst'
        )
        contents = self._remove_tags_from_content(topic_filename)
        doc.writeln(contents)
        doc.style.new_paragraph()

    def _remove_tags_from_content(self, filename):
        with open(filename) as f:
            lines = f.readlines()

        content_begin_index = 0
        for i, line in enumerate(lines):
            # If a line is encountered that does not begin with the tag
            # end the search for tags and mark where tags end.
            if not self._line_has_tag(line):
                content_begin_index = i
                break

        # Join all of the non-tagged lines back together.
        return ''.join(lines[content_begin_index:])

    def _line_has_tag(self, line):
        for tag in self._topic_tag_db.valid_tags:
            if line.startswith(':' + tag + ':'):
                return True
        return False

    def doc_subitems_start(self, help_command, **kwargs):
        pass


class GlobalOptionsDocumenter:
    """Documenter used to pre-generate global options docs."""

    def __init__(self, help_command):
        self._help_command = help_command

    def _remove_multilines(self, s):
        return re.sub(r'\n+', '\n', s)

    def doc_global_options(self):
        help_command = self._help_command
        for arg in help_command.arg_table:
            argument = help_command.arg_table.get(arg)
            help_command.doc.writeln(
                f"``{argument.cli_name}`` ({argument.cli_type_name})"
            )
            help_command.doc.style.indent()
            help_command.doc.style.new_paragraph()
            help_command.doc.include_doc_string(argument.documentation)
            if argument.choices:
                help_command.doc.style.start_ul()
                for choice in argument.choices:
                    help_command.doc.style.li(choice)
                help_command.doc.style.end_ul()
            help_command.doc.style.dedent()
            help_command.doc.style.new_paragraph()
        global_options = help_command.doc.getvalue().decode('utf-8')
        return self._remove_multilines(global_options)

    def doc_global_synopsis(self):
        help_command = self._help_command
        for arg in help_command.arg_table:
            argument = help_command.arg_table.get(arg)
            if argument.cli_type_name == 'boolean':
                arg_synopsis = f"[{argument.cli_name}]"
            else:
                arg_synopsis = f"[{argument.cli_name} <value>]"
            help_command.doc.writeln(arg_synopsis)
        global_synopsis = help_command.doc.getvalue().decode('utf-8')
        return self._remove_multilines(global_synopsis)
