# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, you can obtain one at http://mozilla.org/MPL/2.0/.

import functools
import logging
import time
import uuid
from collections import OrderedDict
from inspect import isawaitable

from sanic import response

from .. import version
from . import checks


class Dockerflow(object):
    """
    The Dockerflow Sanic extension. Set it up like this:

    .. code-block:: python
       :caption: ``myproject.py``

       from sanic import Sanic
       from dockerflow.sanic import Dockerflow

       app = Sanic(__name__)
       dockerflow = Dockerflow(app)

    Or if you use the Sanic application factory pattern, in
    an own module set up Dockerflow first:

    .. code-block:: python
       :caption: ``myproject/deployment.py``

       from dockerflow.sanic import Dockerflow

       dockerflow = Dockerflow()

    and then import and initialize it with the Sanic application
    object when you create the application:

    .. code-block:: python
       :caption: ``myproject/app.py``

       def create_app(config_filename):
           app = Sanic(__name__)
           app.config.from_pyfile(config_filename)

           from myproject.deployment import dockerflow
           dockerflow.init_app(app)

           from myproject.views.admin import admin
           from myproject.views.frontend import frontend
           app.register_blueprint(admin)
           app.register_blueprint(frontend)

           return app

    See the parameters for a more detailed list of optional features when
    initializing the extension.

    :param app: The Sanic app that this Dockerflow extension should be
                initialized with.
    :type app: ~sanic.Sanic or None

    :param redis: A SanicRedis instance to be used by the built-in Dockerflow
                  check for the sanic_redis connection.
    :param silenced_checks: Dockerflow check IDs to ignore when running
                            through the list of configured checks.
    :type silenced_checks: list

    :param version_path: The filesystem path where the ``version.json`` can
                         be found. Defaults to ``.``.
    """

    def __init__(
        self,
        app=None,
        redis=None,
        silenced_checks=None,
        version_path=".",
        *args,
        **kwargs
    ):

        # The Dockerflow specific logger to be used by internals of this
        # extension.
        self.logger = logging.getLogger("dockerflow.sanic")
        self.logger.addHandler(logging.NullHandler())
        self.logger.setLevel(logging.INFO)

        # The request summary logger to be used by this extension
        # without pre-configuration. See docs for how to set it up.
        self.summary_logger = logging.getLogger("request.summary")

        # An ordered dictionary for storing custom Dockerflow checks in.
        self.checks = OrderedDict()

        # A list of IDs of custom Dockerflow checks to ignore in case they
        # show up.
        self.silenced_checks = silenced_checks or []

        # The path where to find the version JSON file. Defaults to the
        # parent directory of the app root path.
        self.version_path = version_path
        self._version_callback = version.get_version

        # Initialize the app if given.
        if app:
            self.init_app(app)
        # Initialize the built-in checks.
        if redis is not None:
            self.init_check(checks.check_redis_connected, redis)

    def init_check(self, check, obj):
        """
        Adds a given check callback with the provided object to the list
        of checks. Useful for built-ins but also advanced custom checks.
        """
        self.logger.info("Adding extension check %s" % check.__name__)
        partial = functools.wraps(check)(functools.partial(check, obj))
        self.check(func=partial)

    def init_app(self, app):
        """
        Add the Dockerflow views and middleware to app.
        """
        for uri, name, handler in (
            ("/__version__", "version", self._version_view),
            ("/__heartbeat__", "heartbeat", self._heartbeat_view),
            ("/__lbheartbeat__", "lbheartbeat", self._lbheartbeat_view),
        ):
            app.add_route(handler, uri, name="dockerflow." + name)
        app.middleware("request")(self._request_middleware)
        app.middleware("response")(self._response_middleware)
        app.exception(Exception)(self._exception_handler)

    def _request_middleware(self, request):
        """
        The request middleware.
        """
        request["_id"] = str(uuid.uuid4())
        request["_start_timestamp"] = time.time()

    def _response_middleware(self, request, response):
        """
        The response middleware.
        """
        if not request.get("_logged"):
            extra = self.summary_extra(request)
            self.summary_logger.info("", extra=extra)

    def _exception_handler(self, request, exception):
        """
        The exception handler.
        """
        extra = self.summary_extra(request)
        extra["errno"] = 500
        self.summary_logger.error(str(exception), extra=extra)
        request["_logged"] = True

    def summary_extra(self, request):
        """
        Build the extra data for the summary logger.
        """
        out = {
            "errno": 0,
            "agent": request.headers.get("User-Agent", ""),
            "lang": request.headers.get("Accept-Language", ""),
            "method": request.method,
            "path": request.path,
            "uid": "",
        }

        # the rid value to the current request ID
        request_id = request.get("_id", None)
        if request_id is not None:
            out["rid"] = request_id

        # and the t value to the time it took to render
        start_timestamp = request.get("_start_timestamp", None)
        if start_timestamp is not None:
            # Duration of request, in milliseconds.
            out["t"] = int(1000 * (time.time() - start_timestamp))

        return out

    async def _version_view(self, request):
        """
        View that returns the contents of version.json or a 404.
        """
        version_json = self._version_callback(self.version_path)
        if isawaitable(version_json):
            version_json = await version_json
        if version_json is None:
            return response.raw(b"version.json not found", 404)
        else:
            return response.json(version_json)

    async def _lbheartbeat_view(self, request):
        """
        Lets the load balancer know the application is running and available.
        Must return 200 (not 204) for ELB
        http://docs.aws.amazon.com/ElasticLoadBalancing/latest/DeveloperGuide/elb-healthchecks.html
        """
        return response.raw(b"", 200)

    async def _heartbeat_check_detail(self, check):
        result = check()
        if isawaitable(result):
            result = await result
        errors = [e for e in result if e.id not in self.silenced_checks]
        level = max([0] + [e.level for e in errors])

        return {
            "status": checks.level_to_text(level),
            "level": level,
            "messages": {e.id: e.msg for e in errors},
        }

    async def _heartbeat_view(self, request):
        """
        Runs all the registered checks and returns a JSON response with either
        a status code of 200 or 500 depending on the results of the checks.

        Any check that returns a warning or worse (error, critical) will
        return a 500 response.
        """
        details = {}
        statuses = {}
        level = 0

        for name, check in self.checks.items():
            detail = await self._heartbeat_check_detail(check)
            statuses[name] = detail["status"]
            level = max(level, detail["level"])
            if detail["level"] > 0:
                details[name] = detail

        payload = {
            "status": checks.level_to_text(level),
            "checks": statuses,
            "details": details,
        }

        if level < checks.ERROR:
            status_code = 200
        else:
            status_code = 500

        return response.json(payload, status_code)

    def version_callback(self, func):
        """
        A decorator to optionally register a new Dockerflow version callback
        and use that instead of the default of
        :func:`dockerflow.version.get_version`.

        The callback will be passed the value of the
        ``version_path`` parameter to the Dockerflow extension object,
        which defaults to the parent directory of the Sanic app's root path.

        The callback should return a dictionary with the
        version information as defined in the Dockerflow spec,
        or None if no version information could be loaded.

        E.g.::

            import aiofiles

            app = Sanic(__name__)
            dockerflow = Dockerflow(app)

            @dockerflow.version_callback
            async def my_version(root):
                path = os.path.join(root, 'acme_version.json')
                async with aiofiles.open(path, mode='r') as f:
                    raw = await f.read()
                return json.loads(raw)

        """
        self._version_callback = func

    def check(self, func=None, name=None):
        """
        A decorator to register a new Dockerflow check to be run
        when the /__heartbeat__ endpoint is called., e.g.::

            from dockerflow.sanic import checks

            @dockerflow.check
            async def storage_reachable():
                try:
                    acme.storage.ping()
                except SlowConnectionException as exc:
                    return [checks.Warning(exc.msg, id='acme.health.0002')]
                except StorageException as exc:
                    return [checks.Error(exc.msg, id='acme.health.0001')]

        also works without async::

            @dockerflow.check
            def storage_reachable():
                # ...

        or using a custom name::

            @dockerflow.check(name='acme-storage-check')
            async def storage_reachable():
                # ...

        """
        if func is None:
            return functools.partial(self.check, name=name)

        if name is None:
            name = func.__name__

        self.logger.info("Registered Dockerflow check %s", name)

        @functools.wraps(func)
        def decorated_function(*args, **kwargs):
            self.logger.info("Called Dockerflow check %s", name)
            return func(*args, **kwargs)

        self.checks[name] = decorated_function
        return decorated_function