import codecs
import collections.abc
import json
import logging
from typing import BinaryIO, MutableMapping, MutableSet, Sequence, Tuple, cast
from jose.constants import ALGORITHMS
from jose.exceptions import JWSError
from jose.jws import verify
from nirum.service import Service

from .wsgi_typing import Environ, ResponseStream, StartResponseCallable

__all__ = 'SigningWsgiApp',


class SigningWsgiApp:

    logger = logging.getLogger(f'{__name__}.SigningWsgiApp')

    def __init__(self,
                 service: Service,
                 secret: str,
                 algorithms: Sequence[str]=ALGORITHMS.SUPPORTED) -> None:
        if hasattr(type(service), '__nirum_schema_version__'):
            raise TypeError(
                'service interfaces generated by older versions of '
                'the compiler than Nirum 0.4.0 are unsupported'
            )
        self.service = service
        self.secret = secret
        self.algorithms = algorithms

    def error(self,
              code: int,
              message: str,
              environ: Environ,
              start_response: StartResponseCallable) -> ResponseStream:
        statuses = {
            400: 'Bad Request',
            405: 'Method Not Allowed',
        }
        start_response(
            f'{code} {statuses[code]}',
            [('Content-Type', 'text/plain')]
        )
        return codecs.iterencode(message, 'utf-8')

    def get_method_meta(self, facial_name: str, attr: str):
        cls = type(self.service)
        for cls in type(self.service).__mro__:
            if not hasattr(cls, facial_name):
                continue
            method = getattr(cls, facial_name)
            try:
                return getattr(method, attr)
            except AttributeError:
                pass
        assert False, 'could not find the method meta; please report this bug'

    def wsgi_app(self,
                 environ: Environ,
                 start_response: StartResponseCallable) -> ResponseStream:
        if environ.get('REQUEST_METHOD') != 'POST':
            return self.error(405, 'Only POST is supported.',
                              environ, start_response)
        mimetype = environ.get(
            'CONTENT_TYPE',
            environ.get('HTTP_CONTENT_TYPE', 'application/jose')
        )
        if mimetype != 'application/jose':
            return self.error(400, 'Content-Type must be application/jose.',
                              environ, start_response)
        content: BinaryIO = cast(BinaryIO, environ['wsgi.input'])
        signed_payload = content.read()
        try:
            payload_bytes = verify(
                signed_payload,
                self.secret,
                algorithms=self.algorithms
            )
        except JWSError as e:
            self.logger.getChild('wsgi_app').exception('%s', e)
            return self.error(400, f'The signature is failed to verify:\n{e}',
                              environ, start_response)
        payload: MutableMapping[str, object] = \
            json.loads(payload_bytes.decode('utf-8'))
        if not isinstance(payload, collections.abc.Mapping):
            return self.error(400, f'The payload must be a JSON object.',
                              environ, start_response)
        try:
            behind_method_name = payload.pop('_method')
        except KeyError:
            return self.error(400, 'The payload lacks "_method" field.',
                              environ, start_response)
        service_methods = self.service.__nirum_method_names__.behind_names
        try:
            facial_method_name = service_methods[behind_method_name]
        except KeyError:
            return self.error(
                400,
                f'There is no such method: {behind_method_name}().',
                environ, start_response
            )
        cls = type(self.service)
        logger = logging.getLogger(
            f'{cls.__module__}.{cls.__qualname__}.{facial_method_name}'
        )
        deserialize_args = self.get_method_meta(
            facial_method_name,
            '__nirum_deserialize_arguments__'
        )
        errors: MutableSet[Tuple[str, str]] = set()
        args = deserialize_args(payload, lambda ef, em: errors.add((ef, em)))
        if errors:
            return self.error(
                400, ''.join(f'{f}: {m}\n' for (f, m) in errors),
                environ, start_response
            )
        try:
            result = getattr(self.service, facial_method_name)(**args)
        except Exception as e:
            serialize_error = self.get_method_meta(
                facial_method_name,
                '__nirum_serialize_error__'
            )
            if serialize_error is None:
                logger.exception(
                    'The %s() method%s has no error type, but an exception '
                    'has been raised:\n%s',
                    facial_method_name,
                    (
                        ''
                        if behind_method_name == facial_method_name
                        else f' (i.e., {behind_method_name})'
                    ),
                    e
                )
                raise
            try:
                response_payload = serialize_error(e)
            except TypeError as te:
                logger.exception(
                    'The type of a exception value does not match to '
                    'the error type of %s() method%s:\n%s',
                    facial_method_name,
                    (
                        ''
                        if behind_method_name == facial_method_name
                        else f' (i.e., {behind_method_name})'
                    ),
                    te
                )
                raise
            start_response(
                '400 Bad Request',
                [('Content-Type', 'application/json')]
            )
            return codecs.iterencode(json.dumps(response_payload), 'utf-8')
        serialize_result = self.get_method_meta(
            facial_method_name,
            '__nirum_serialize_result__'
        )
        response_payload = None
        if serialize_result is not None:
            try:
                response_payload = serialize_result(result)
            except TypeError as e:
                logger.exception(
                    'The type of a return value does not match to '
                    'the return type of %s() method%s:\n%s',
                    facial_method_name,
                    (
                        ''
                        if behind_method_name == facial_method_name
                        else f' (i.e., {behind_method_name})'
                    ),
                    e
                )
                raise
        elif result is not None:
            logger.error(
                'The %s() method%s has no return type, but a value is '
                'returned; a return value is ignored.',
                facial_method_name,
                (
                    ''
                    if behind_method_name == facial_method_name
                    else f' (i.e., {behind_method_name})'
                )
            )
        start_response('200 OK', [('Content-Type', 'application/json')])
        return codecs.iterencode(json.dumps(response_payload), 'utf-8')

    def __call__(self,
                 environ: Environ,
                 start_response: StartResponseCallable) -> ResponseStream:
        return self.wsgi_app(environ, start_response)
