# Copyright 2012-2015 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 platform
import shlex
import sys
import tempfile
import webbrowser
from subprocess import PIPE, Popen

from botocore.exceptions import ProfileNotFound
from docutils.core import publish_string
from docutils.writers import (
    html4css1,
    manpage,
)

from awscli import (
    _DEFAULT_BASE_REMOTE_URL,
)
from awscli.argparser import ArgTableArgParser
from awscli.argprocess import ParamShorthandParser
from awscli.bcdoc import docevents
from awscli.bcdoc.restdoc import ReSTDocument
from awscli.bcdoc.textwriter import TextWriter
from awscli.clidocs import (
    OperationDocumentEventHandler,
    ProviderDocumentEventHandler,
    ServiceDocumentEventHandler,
    TopicDocumentEventHandler,
    TopicListerDocumentEventHandler,
)
from awscli.topictags import TopicTagDB
from awscli.utils import ignore_ctrl_c

LOG = logging.getLogger('awscli.help')

REF_PATH = 'reference'
TUT_PATH = 'tutorial'
TOPIC_PATH = 'topic'


class ExecutableNotFoundError(Exception):
    def __init__(self, executable_name):
        super().__init__(
            f'Could not find executable named "{executable_name}"'
        )


def get_renderer(help_output):
    """
    Return the appropriate HelpRenderer implementation for the
    current platform.
    """

    if platform.system() == 'Windows':
        if help_output == "browser":
            return WindowsBrowserHelpRenderer()
        elif help_output == "url":
            return WindowsPagingHelpRenderer()
        return WindowsHelpRenderer()
    else:
        if help_output == "browser":
            return PosixBrowserHelpRenderer()
        elif help_output == "url":
            return PosixPagingHelpRenderer()
        return PosixHelpRenderer()


class HelpRenderer:
    """
    Interface for a help renderer.

    The renderer is responsible for displaying the help content on
    a particular platform.

    """

    def __init__(self, output_stream=sys.stdout):
        self.output_stream = output_stream

    def render(self, contents):
        """
        Each implementation of HelpRenderer must implement this
        render method.
        """
        converted_content = self._convert_doc_content(contents)
        self._send_output_to_destination(converted_content)

    def _send_output_to_destination(self, output):
        """
        Each implementation of HelpRenderer must implement this
        method.
        """
        raise NotImplementedError

    def _popen(self, *args, **kwargs):
        return Popen(*args, **kwargs)

    def _convert_doc_content(self, contents):
        return contents


class PagingHelpRenderer(HelpRenderer):
    """Interface for a help renderer.

    This sends output to the pager.
    """

    PAGER = None
    _DEFAULT_DOCUTILS_SETTINGS_OVERRIDES = {
        # The default for line length limit in docutils is 10,000. However,
        # currently in the documentation, it inlines all possible enums in
        # the JSON syntax which exceeds this limit for some EC2 commands
        # and prevents the manpages from being generated.
        # This is a temporary fix to allow the manpages for these commands
        # to be rendered. Long term, we should avoid enumerating over all
        # enums inline for the JSON syntax snippets.
        'line_length_limit': 50_000
    }

    def get_pager_cmdline(self):
        pager = self.PAGER
        if 'MANPAGER' in os.environ:
            pager = os.environ['MANPAGER']
        elif 'PAGER' in os.environ:
            pager = os.environ['PAGER']
        return shlex.split(pager)

    def _send_output_to_destination(self, output):
        self._send_output_to_pager(output)

    def _send_output_to_pager(self, output):
        cmdline = self.get_pager_cmdline()
        LOG.debug("Running command: %s", cmdline)
        p = self._popen(cmdline, stdin=PIPE)
        p.communicate(input=output)


class BrowserHelpRenderer(HelpRenderer):
    """
    Interface for a help renderer to a web browser.

    The renderer is responsible for displaying the help content on
    a particular platform.

    """

    def __init__(self, output_stream=sys.stdout):
        self.output_stream = output_stream

    _DEFAULT_DOCUTILS_SETTINGS_OVERRIDES = {
        # The default for line length limit in docutils is 10,000. However,
        # currently in the documentation, it inlines all possible enums in
        # the JSON syntax which exceeds this limit for some EC2 commands
        # and prevents the manpages from being generated.
        # This is a temporary fix to allow the manpages for these commands
        # to be rendered. Long term, we should avoid enumerating over all
        # enums inline for the JSON syntax snippets.
        'line_length_limit': 50_000
    }

    def _send_output_to_destination(self, output):
        self._send_output_to_browser(output)

    def _send_output_to_browser(self, output):
        html_file = tempfile.NamedTemporaryFile(
            "wb", suffix=".html", delete=False
        )
        html_file.write(output)
        html_file.close()

        try:
            print("Opening help file in the default browser.")
            return webbrowser.open_new_tab(f'file://{html_file.name}')
        except webbrowser.Error:
            print('Failed to open browser:', file=sys.stderr)


class PosixPagingHelpRenderer(PagingHelpRenderer):
    """
    Render help content on a Posix-like system.  This includes
    Linux and MacOS X.
    """

    PAGER = 'less -R'

    def _send_output_to_pager(self, output):
        cmdline = self.get_pager_cmdline()
        if not (cmdline and self._exists_on_path(cmdline[0])):
            pager = cmdline[0] if cmdline else "<empty>"
            LOG.debug(
                f"Pager '{pager}' not found in PATH, printing raw help."
            )
            self.output_stream.write(output.decode('utf-8') + "\n")
            self.output_stream.flush()
            return
        LOG.debug("Running command: %s", cmdline)
        with ignore_ctrl_c():
            # We can't rely on the KeyboardInterrupt from
            # the CLIDriver being caught because when we
            # send the output to a pager it will use various
            # control characters that need to be cleaned
            # up gracefully.  Otherwise if we simply catch
            # the Ctrl-C and exit, it will likely leave the
            # users terminals in a bad state and they'll need
            # to manually run ``reset`` to fix this issue.
            # Ignoring Ctrl-C solves this issue.  It's also
            # the default behavior of less (you can't ctrl-c
            # out of a manpage).
            p = self._popen(cmdline, stdin=PIPE)
            p.communicate(input=output)

    def _exists_on_path(self, name):
        # Since we're only dealing with POSIX systems, we can
        # ignore things like PATHEXT.
        return any(
            [
                os.path.exists(os.path.join(p, name))
                for p in os.environ.get('PATH', '').split(os.pathsep)
            ]
        )


class PosixHelpRenderer(PosixPagingHelpRenderer):
    """
    Render help content on a Posix-like system.  This includes
    Linux and MacOS X.
    """

    def _convert_doc_content(self, contents):
        settings_overrides = self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES.copy()
        settings_overrides["report_level"] = 3
        man_contents = publish_string(
            contents,
            writer=manpage.Writer(),
            settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES,
        )
        if self._exists_on_path('groff'):
            cmdline = ['groff', '-m', 'man', '-T', 'ascii']
        elif self._exists_on_path('mandoc'):
            cmdline = ['mandoc', '-T', 'ascii']
        else:
            raise ExecutableNotFoundError('groff or mandoc')
        LOG.debug("Running command: %s", cmdline)
        p3 = self._popen(cmdline, stdin=PIPE, stdout=PIPE, stderr=PIPE)
        output = p3.communicate(input=man_contents)[0]
        return output


class PosixBrowserHelpRenderer(BrowserHelpRenderer):
    """
    Render help content in a browser on a Posix-like system.  This includes
    Linux and MacOS X.
    """

    def _convert_doc_content(self, contents):
        settings_overrides = self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES.copy()
        settings_overrides["report_level"] = 3
        man_contents = publish_string(
            contents,
            writer=manpage.Writer(),
            settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES,
        )
        if self._exists_on_path('groff'):
            cmdline = ['groff', '-m', 'man', '-T', 'html']
        elif self._exists_on_path('mandoc'):
            cmdline = ['mandoc', '-T', 'html']
        else:
            raise ExecutableNotFoundError('groff or mandoc')
        LOG.debug("Running command: %s", cmdline)
        p3 = self._popen(cmdline, stdin=PIPE, stdout=PIPE, stderr=PIPE)
        output = p3.communicate(input=man_contents)[0]
        return output

    def _exists_on_path(self, name):
        # Since we're only dealing with POSIX systems, we can
        # ignore things like PATHEXT.
        return any(
            [
                os.path.exists(os.path.join(p, name))
                for p in os.environ.get('PATH', '').split(os.pathsep)
            ]
        )


class WindowsPagingHelpRenderer(PagingHelpRenderer):
    """Render help content on a Windows platform."""

    PAGER = 'more'

    def _popen(self, *args, **kwargs):
        # Also set the shell value to True.  To get any of the
        # piping to a pager to work, we need to use shell=True.
        kwargs['shell'] = True
        return Popen(*args, **kwargs)


class WindowsHelpRenderer(WindowsPagingHelpRenderer):
    """Render help content on a Windows platform."""

    def _convert_doc_content(self, contents):
        text_output = publish_string(
            contents,
            writer=TextWriter(),
            settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES,
        )
        return text_output


class WindowsBrowserHelpRenderer(BrowserHelpRenderer):
    """Render help content in the browser on a Windows platform."""

    def _convert_doc_content(self, contents):
        text_output = publish_string(
            contents,
            writer=html4css1.Writer(),
            settings_overrides=self._DEFAULT_DOCUTILS_SETTINGS_OVERRIDES,
        )
        return text_output


class HelpCommand:
    """
    HelpCommand Interface
    ---------------------
    A HelpCommand object acts as the interface between objects in the
    CLI (e.g. Providers, Services, Operations, etc.) and the documentation
    system (bcdoc).

    A HelpCommand object wraps the object from the CLI space and provides
    a consistent interface to critical information needed by the
    documentation pipeline such as the object's name, description, etc.

    The HelpCommand object is passed to the component of the
    documentation pipeline that fires documentation events.  It is
    then passed on to each document event handler that has registered
    for the events.

    All HelpCommand objects contain the following attributes:

        + ``session`` - A ``botocore`` ``Session`` object.
        + ``obj`` - The object that is being documented.
        + ``command_table`` - A dict mapping command names to
              callable objects.
        + ``arg_table`` - A dict mapping argument names to callable objects.
        + ``doc`` - A ``Document`` object that is used to collect the
              generated documentation.

    In addition, please note the `properties` defined below which are
    required to allow the object to be used in the document pipeline.

    Implementations of HelpCommand are provided here for Provider,
    Service and Operation objects.  Other implementations for other
    types of objects might be needed for customization in plugins.
    As long as the implementations conform to this basic interface
    it should be possible to pass them to the documentation system
    and generate interactive and static help files.
    """

    EventHandlerClass = None
    """
    Each subclass should define this class variable to point to the
    EventHandler class used by this HelpCommand.
    """

    def __init__(self, session, obj, command_table, arg_table):
        self.session = session
        self.obj = obj
        if command_table is None:
            command_table = {}
        self.command_table = command_table
        if arg_table is None:
            arg_table = {}
        self.arg_table = arg_table
        self._subcommand_table = {}
        self._related_items = []
        self.doc = ReSTDocument(target='man')
        self._base_remote_url = _DEFAULT_BASE_REMOTE_URL

        try:
            self._help_output_format = self.session.get_config_variable(
                "cli_help_output"
            )
        except ProfileNotFound:
            self._help_output_format = None

        self.renderer = get_renderer(self._help_output_format)

    @property
    def event_class(self):
        """
        Return the ``event_class`` for this object.

        The ``event_class`` is used by the documentation pipeline
        when generating documentation events.  For the event below::

            doc-title.<event_class>.<name>

        The document pipeline would use this property to determine
        the ``event_class`` value.
        """
        pass

    @property
    def name(self):
        """
        Return the name of the wrapped object.

        This would be called by the document pipeline to determine
        the ``name`` to be inserted into the event, as shown above.
        """
        pass

    @property
    def subcommand_table(self):
        """These are the commands that may follow after the help command"""
        return self._subcommand_table

    @property
    def related_items(self):
        """This is list of items that are related to the help command"""
        return self._related_items

    def __call__(self, args, parsed_globals):
        if args:
            subcommand_parser = ArgTableArgParser({}, self.subcommand_table)
            parsed, remaining = subcommand_parser.parse_known_args(args)
            if getattr(parsed, 'subcommand', None) is not None:
                return self.subcommand_table[parsed.subcommand](
                    remaining, parsed_globals
                )

        # Create an event handler for a Provider Document
        instance = self.EventHandlerClass(self)
        # Now generate all of the events for a Provider document.
        # We pass ourselves along so that we can, in turn, get passed
        # to all event handlers.
        docevents.generate_events(self.session, self)
        if self._help_output_format == 'url':
            self.renderer.render(self.url.encode())
        else:
            self.renderer.render(self.doc.getvalue())
        instance.unregister()


class ProviderHelpCommand(HelpCommand):
    """Implements top level help command.

    This is what is called when ``aws help`` is run.

    """

    EventHandlerClass = ProviderDocumentEventHandler

    def __init__(
        self,
        session,
        command_table,
        arg_table,
        description,
        synopsis,
        usage,
    ):
        HelpCommand.__init__(self, session, None, command_table, arg_table)
        self.description = description
        self.synopsis = synopsis
        self.help_usage = usage
        self._subcommand_table = None
        self._topic_tag_db = None
        self._related_items = ['aws help topics']

    @property
    def event_class(self):
        return 'aws'

    @property
    def name(self):
        return 'aws'

    @property
    def url(self):
        return f"{self._base_remote_url}/index.html"

    @property
    def subcommand_table(self):
        if self._subcommand_table is None:
            if self._topic_tag_db is None:
                self._topic_tag_db = TopicTagDB()
            self._topic_tag_db.load_json_index()
            self._subcommand_table = self._create_subcommand_table()
        return self._subcommand_table

    def _create_subcommand_table(self):
        subcommand_table = {}
        # Add the ``aws help topics`` command to the ``topic_table``
        topic_lister_command = TopicListerCommand(self.session)
        subcommand_table['topics'] = topic_lister_command
        topic_names = self._topic_tag_db.get_all_topic_names()

        # Add all of the possible topics to the ``topic_table``
        for topic_name in topic_names:
            topic_help_command = TopicHelpCommand(self.session, topic_name)
            subcommand_table[topic_name] = topic_help_command
        return subcommand_table


class ServiceHelpCommand(HelpCommand):
    """Implements service level help.

    This is the object invoked whenever a service command
    help is implemented, e.g. ``aws ec2 help``.

    """

    EventHandlerClass = ServiceDocumentEventHandler

    def __init__(
        self, session, obj, command_table, arg_table, name, event_class
    ):
        super().__init__(session, obj, command_table, arg_table)
        self._name = name
        self._event_class = event_class

    @property
    def event_class(self):
        return self._event_class

    @property
    def name(self):
        return self._name

    @property
    def url(self):
        return f"{self._base_remote_url}/{REF_PATH}/{self.name}/index.html"


class OperationHelpCommand(HelpCommand):
    """Implements operation level help.

    This is the object invoked whenever help for a service is requested,
    e.g. ``aws ec2 describe-instances help``.

    """

    EventHandlerClass = OperationDocumentEventHandler

    def __init__(self, session, operation_model, arg_table, name, event_class):
        HelpCommand.__init__(self, session, operation_model, None, arg_table)
        self.param_shorthand = ParamShorthandParser()
        self._name = name
        self._event_class = event_class

    @property
    def event_class(self):
        return self._event_class

    @property
    def name(self):
        return self._name

    @property
    def url(self):
        return f"{self._base_remote_url}/reference/{self.event_class.replace('.', '/')}.html"


class TopicListerCommand(HelpCommand):
    EventHandlerClass = TopicListerDocumentEventHandler

    def __init__(self, session):
        super().__init__(session, None, {}, {})

    @property
    def event_class(self):
        return 'topics'

    @property
    def name(self):
        return 'topics'

    @property
    def url(self):
        return f"{self._base_remote_url}/{TOPIC_PATH}/index.html"


class TopicHelpCommand(HelpCommand):
    EventHandlerClass = TopicDocumentEventHandler

    def __init__(self, session, topic_name):
        super().__init__(session, None, {}, {})
        self._topic_name = topic_name

    @property
    def event_class(self):
        return 'topics.' + self.name

    @property
    def name(self):
        return self._topic_name

    @property
    def url(self):
        return f"{self._base_remote_url}/{TOPIC_PATH}/{self.name}.html"
