"""Remote debugging support for Falcon apps."""

import base64
import json
from abc import ABCMeta, abstractmethod
from logging import getLogger

import epdb

try:
    from cryptography import fernet
except ImportError:  # pragma: no cover
    pass

try:
    import jwt
except ImportError:  # pragma: no cover
    pass


logger = getLogger(__name__)


class EPDBException(Exception):
    """Raised when an error occurs during the processing of an ``X-EPDB`` header."""

    # pylint: disable=too-few-public-methods


class EPDBServe(object):
    """A middleware to enable remote debuging via an `epdb`_ server.

    :param backend: An instance of the class that will validate and decode the ``X-EPDB`` header
    :param exempt_methods: HTTP methods which will be ignored by this middleware
    :param serve_options: Parameters passed-through to :func:`epdb.serve()`
    :type backend: EPDBBackend
    :type exempt_methods: iterable of strings
    :type serve_options: dictionary

    A client may include a special ``X-EPDB`` header containing an appropriately formed payload.
    If they do, the header will be passed to the configured backend for processing. If the
    payload passes authentication and meets the content requirements, the app will be begin
    listening for `epdb`_ client connections.

    A well-formed header has content simply of the form::

        {
            "epdb": {}
        }

    The encoding and encryption of this payload is determined by the :class:`EPDBBackend`
    provided to the middleware.

    .. _epdb: https://pypi.org/project/epdb/
    """

    def __init__(self, backend, exempt_methods=("OPTIONS",), serve_options=None):
        serve_options = serve_options or {}
        self.backend = backend
        self.exempt_methods = exempt_methods
        self.serve_options = serve_options

    def process_request(self, req, resp):  # pylint: disable=unused-argument
        """Check for a well-formed ``X-EPDB`` header and if present activate the `epdb`_ server.

        :param req: The Falcon request object
        :param resp: The Falcon response object (unused)

        This will block, waiting for an `epdb`_ client connection, the first time a valid
        header is received. Once the client is connected, subsequent passes will simply activate
        the connected client and drop it into the `epdb`_ shell.

        The header processing is delegated to the configured :class:`EPDBBackend`.
        """
        if req.method in self.exempt_methods:
            return

        try:
            header_data = self.backend.get_header_data(req)
        except EPDBException as exc:
            # Probably got an invalid header value. Don't start the debugger.
            logger.exception("Attempted, but failed, to serve epdb: %s", exc)
            return
        except Exception:  # pylint: disable=broad-except
            logger.exception(
                "Attempted, but failed, to serve epdb:"
                " Unexpected error when processing the X-EPDB header"
            )
            return

        try:
            if header_data is not None:
                logger.debug("Serving epdb with options: %s", self.serve_options)
                epdb.serve(**self.serve_options)
        except Exception:  # pylint: disable=broad-except
            logger.exception(
                "Attempted, but failed, to serve epdb:"
                " Unexpected error when starting epdb server"
            )


class EPDBBackend(object):
    """The abstract base class defining the header-processing backend interface.

    An inheriting subclass must define :meth:`decode_header_value`, but may define other methods
    if necessary. This class is structured to provide a balance of convenience and flexibility.
    """

    __metaclass__ = ABCMeta

    def get_header_data(self, req):
        """Process a request and return the contents of a conforming payload.

        :param req: The Falcon request object
        :type req: Request
        :returns: The paylod content or None
        :rtype: dictionary or None

        This implementation assumes that the payload is present on the ``X-EPDB`` header, but
        subclasses may override this method if their use-case demands it.

        If the request does not appear to be attempting begin a debugging session, this will
        return :obj:`None`.
        """
        epdb_header = req.headers.get("X-EPDB")
        if epdb_header:
            logger.debug("Found epdb header")
            header_content = self.decode_header_value(epdb_header)
            return self.validate_header_content(header_content)
        return None

    @abstractmethod
    def decode_header_value(self, epdb_header):
        """Process the ``X-EPDB`` header content.

        :param epdb_header: The content of the ``X-EPDB`` header
        :type epdb_header: string
        :returns: The decoded and decrypted header payload
        :rtype: dictionary

        This does not need to do any content validation, as that is handled in
        :meth:`validate_header_content`.
        """

    @staticmethod
    def validate_header_content(header_content):
        """Ensure that the decoded ``X-EPDB`` header content is well-formed.

        :param header_content: The decoded ``X-EPDB`` header content
        :type header_content: dictionary
        :returns: The value of the `epdb`_ key
        :rtype: dictionary
        :raises: EPDBException

        :obj:`header_content` must be of the form::

            {
                "epdb": {}
            }
        """
        if not isinstance(header_content, dict):
            raise EPDBException("Invalid X-EPDB content; must be a dictionary")

        if "epdb" not in header_content:
            raise EPDBException('Invalid X-EPDB content; must contain key named "epdb"')

        if not isinstance(header_content["epdb"], dict):
            raise EPDBException('Invalid X-EPDB content; "epdb" key must map to a dictionary')

        return header_content["epdb"]


class Base64Backend(EPDBBackend):
    """A simple unauthenticated backend for local development."""

    def decode_header_value(self, epdb_header):
        """Pull the encrypted data out of the header, if present.

        :param epdb_header: The content of the ``X-EPDB`` header.
        :type epdb_header: string
        :returns: The decoded header payload
        :rtype: dictionary
        :raises: EPDBException

        It expects :obj:`epdb_header` to have the ``Base64`` prefix.
        """
        try:
            scheme, payload = epdb_header.split()
        except ValueError:
            raise EPDBException("Invalid X-EPDB value; must have two tokens")

        if scheme != "Base64":
            raise EPDBException("Invalid X-EPDB value; scheme must be Base64")

        decoded_bytes = base64.b64decode(payload.encode())
        decoded_string = decoded_bytes.decode()
        return json.loads(decoded_string)


class FernetBackend(EPDBBackend):
    """A Python cryptography-based backend that supports a pre-shared key (ie. password) protocol.

    :param key: The fernet key used to encrypt the header content
    :type key: bytes

    .. note:: To use this backend, one must install the :mod:`cryptography` package. The easiest
        way to do this is to specify the ``[fernet]`` extra when adding the ``falcon-epdb``
        dependency to your project.

        .. code-block:: text
            :caption: **requirements.txt**

            falcon-epdb[fernet]
    """

    def __init__(self, key):
        try:
            self.fernet = fernet.Fernet(key)
        except NameError:
            raise ImportError("Missing optional [fernet] dependency")

    def decode_header_value(self, epdb_header):
        """Pull the encrypted data out of the header, if present.

        :param epdb_header: The content of the ``X-EPDB`` header.
        :type epdb_header: string
        :returns: The decoded and decrypted header payload
        :rtype: dictionary
        :raises: EPDBException

        It expects :obj:`epdb_header` to have the ``Fernet`` prefix.
        """
        try:
            scheme, payload = epdb_header.split(None, 1)
        except ValueError:
            raise EPDBException("Invalid X-EPDB value; must have two tokens")

        if scheme != "Fernet":
            raise EPDBException("Invalid X-EPDB value; scheme must be Fernet")

        decrypted_bytes = self.fernet.decrypt(payload.encode())
        decrypted_string = decrypted_bytes.decode()
        return json.loads(decrypted_string)


class JWTBackend(EPDBBackend):
    """A JWT-based backend that supports a pre-shared key (ie. password) protocol.

    :param key: The JWT key used to encrypt the header content
    :type key: bytes

    .. note:: To use this backend, one must install the :mod:`PyJWT` package. The easiest
        way to do this is to specify the ``[jwt]`` extra when adding the ``falcon-epdb``
        dependency to your project.

        .. code-block:: text
            :caption: **requirements.txt**

            falcon-epdb[jwt]
    """

    def __init__(self, key):
        try:
            jwt
        except NameError:
            raise ImportError("Missing optional [jwt] dependency")

        self.key = key

    def decode_header_value(self, epdb_header):
        """Pull the encrypted data out of the header, if present.

        :param epdb_header: The content of the ``X-EPDB`` header.
        :type epdb_header: string
        :returns: The decoded and decrypted header payload
        :rtype: dictionary
        :raises: EPDBException

        It expects :obj:`epdb_header` to have the ``JWT`` prefix.
        """
        try:
            scheme, payload = epdb_header.split(None, 1)
        except ValueError:
            raise EPDBException("Invalid X-EPDB value; must have two tokens")

        if scheme != "JWT":
            raise EPDBException("Invalid X-EPDB value; scheme must be JWT")

        return jwt.decode(payload.encode(), self.key, algorithms="HS256")
