#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Application level testing solution
#
# it should make testing easy as shit

import sys
import time
import unittest
import logging
import mimetypes
from tornado.httpclient import AsyncHTTPClient
from tornado.ioloop import IOLoop
from tornado.stack_context import NullContext
from tornado.escape import json_encode
from tornado import web
from tornado.web import HTTPError

from torext.utils import raise_exc_info
from torext.compat import SimpleCookie, quote, urlencode, httplib


COOKIE_HEADER_KEY = 'Set-Cookie'


# The one variable and two functions below are copied from
# requests HTTP library <http://python-requests.org>.
# :copyright: (c) 2013 by Kenneth Reitz.
# :license: Apache 2.0, see LICENSE for more details.

# The unreserved URI characters (RFC 3986)
UNRESERVED_SET = frozenset(
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
    "0123456789-._~")


def unquote_unreserved(uri):
    """Un-escape any percent-escape sequences in a URI that are unreserved
    characters. This leaves all reserved, illegal and non-ASCII bytes encoded.
    """
    parts = uri.split('%')
    for i in range(1, len(parts)):
        h = parts[i][0:2]
        if len(h) == 2 and h.isalnum():
            c = chr(int(h, 16))
            if c in UNRESERVED_SET:
                parts[i] = c + parts[i][2:]
            else:
                parts[i] = '%' + parts[i]
        else:
            parts[i] = '%' + parts[i]
    return ''.join(parts)


def requote_uri(uri):
    """Re-quote the given URI.

    This function passes the given URI through an unquote/quote cycle to
    ensure that it is fully and consistently quoted.
    """
    # Unquote only the unreserved characters
    # Then quote only illegal characters (do not quote reserved, unreserved,
    # or '%')
    return quote(unquote_unreserved(uri), safe="!#$%&'()*+,/:;=?@[]~")


class TestClient(object):
    def __init__(self, app, raise_handler_exc=False):
        # TODO add `host` kwarg
        if app.is_running:
            raise RuntimeError('You should not instance TestClient'
                               'when applicaion is running')

        self.raise_handler_exc = raise_handler_exc

        # start::tornado.testing.AsyncTestCase
        self.__stopped = False
        self.__running = False
        self.__failure = None
        self.__stop_args = None
        self.__timeout = None
        # end::tornado.testing.AsyncTestCase

        self._handler_exc_info = None

        app.update_settings({
            'TESTING': True,
            'PROCESSES': 1
        })
        # redo setup to make settings changes effective
        app.setup()

        self.app = app
        self.setup()

    def setup(self):
        """Setup facilities for running the server

        NOTE if you get all "Address already in use" error except the first one,
        the bug may well happened in this function.
        """
        # start::tornado.testing.AsyncTestCase
        #self.io_loop = self.get_new_ioloop()
        # end::tornado.testing.AsyncTestCase
        #self.app.io_loop = self.io_loop

        # init app.io_loop, app.application and app.http_server
        self.app._init_application()
        self.app.make_http_server()
        self.io_loop = self.app.io_loop

        if self.raise_handler_exc:
            self.patch_app_handlers()

        self.http_server = self.app.http_server
        self.http_client = AsyncHTTPClient(io_loop=self.io_loop)

    def close(self):
        """CLose http_server, io_loop by sequence, to ensure the environment
        is cleaned up and invoking `setup` successfully within next test function

        It is suggested to be called in `TestCase.tearDown`
        """
        # start::tornado.testing.AsyncHTTPTestCase
        self.http_server.stop()
        if (not IOLoop.initialized() or
                self.http_client.io_loop is not IOLoop.instance()):
            self.http_client.close()
        # end::tornado.testing.AsyncHTTPTestCase

        # start::tornado.testing.AsyncTestCase
        if (not IOLoop.initialized() or
                self.io_loop is not IOLoop.instance()):
            # Try to clean up any file descriptors left open in the ioloop.
            # This avoids leaks, especially when tests are run repeatedly
            # in the same process with autoreload (because curl does not
            # set FD_CLOEXEC on its file descriptors)
            self.io_loop.close(all_fds=True)
        # end::tornado.testing.AsyncTestCase

    def _parse_cookies(self, resp):
        if COOKIE_HEADER_KEY in resp.headers:
            c = SimpleCookie(resp.headers.get(COOKIE_HEADER_KEY))
        elif COOKIE_HEADER_KEY.lower() in resp.headers:
            c = SimpleCookie(resp.headers.get(COOKIE_HEADER_KEY.lower()))
        else:
            c = None
        resp.cookies = c

    def _add_cookies(self, cookies, kwgs):
        # SimpleCookie is inherited from dict, so judge it first
        if isinstance(cookies, SimpleCookie):
            c = cookies
        elif isinstance(cookies, dict):
            c = SimpleCookie()
            for k, v in cookies.items():
                c[k] = v
        else:
            raise TypeError('cookies kwarg should be dict or SimpleCookie instance')

        if 'headers' in kwgs:
            headers = kwgs['headers']
        else:
            headers = kwgs['headers'] = {}

        headers['Cookie'] = c.output().lstrip(COOKIE_HEADER_KEY + ': ')
        #print 'headers', headers

    def stop(self, _arg=None, **kwargs):
        '''Stops the ioloop, causing one pending (or future) call to wait()
        to return.

        Keyword arguments or a single positional argument passed to stop() are
        saved and will be returned by wait().
        '''
        assert _arg is None or not kwargs
        self.__stop_args = kwargs or _arg
        if self.__running:
            self.io_loop.stop()
            self.__running = False
        self.__stopped = True

    def wait(self, condition=None, timeout=5):
        """Runs the IOLoop until stop is called or timeout has passed.

        In the event of a timeout, an exception will be thrown.

        If condition is not None, the IOLoop will be restarted after stop()
        until condition() returns true.
        """
        if not self.__stopped:
            if timeout:
                def timeout_func():
                    try:
                        raise self.failureException(
                            'Async operation timed out after %s seconds' %
                            timeout)
                    except Exception:
                        self.__failure = sys.exc_info()
                    self.stop()
                if self.__timeout is not None:
                    self.io_loop.remove_timeout(self.__timeout)
                self.__timeout = self.io_loop.add_timeout(time.time() + timeout, timeout_func)
            while True:
                self.__running = True
                with NullContext():
                    # Wipe out the StackContext that was established in
                    # self.run() so that all callbacks executed inside the
                    # IOLoop will re-run it.
                    self.io_loop.start()
                if (self.__failure is not None or
                        condition is None or condition()):
                    break
        assert self.__stopped
        self.__stopped = False
        self.__rethrow()
        result = self.__stop_args
        self.__stop_args = None
        return result

    def __rethrow(self):
        if self.__failure is not None:
            failure = self.__failure
            self.__failure = None
            raise_exc_info(failure)

    def request(self, method, path,
                data=None, json=False,
                files=None,
                cookies=None, **kwgs):

        kwgs['method'] = method

        ## `path` should be utf-8 encoded string to complete requote process
        #if isinstance(path, str):
        #    path = path.encode('utf8')
        path = requote_uri(path)

        # `body` must be passed if method is one of those three
        if method in ['POST', 'PUT', 'PATCH']:
            headers = kwgs.setdefault('headers', {})
            body = ''
            if files:
                boundary = '1234567890'
                headers['Content-Type'] = 'multipart/form-data; boundary=%s' % boundary
                L = []
                if data:
                    for k, v in data.items():
                        L.append('--' + boundary)
                        L.append('Content-Disposition: form-data; name="%s"' % k)
                        L.append('')
                        L.append(v)
                for k, f in files.items():
                    L.append('--' + boundary)
                    L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (k, f[0]))
                    L.append('Content-Type: %s' % mimetypes.guess_type(f[0])[0] or 'application/octet-stream')
                    L.append('')
                    L.append(f[1])
                L.append('--%s--' % boundary)
                L.append('')
                body = '\r\n'.join(L)
            else:
                if data:
                    if json:
                        body = json_encode(data)
                        headers['Content-Type'] = 'application/json'
                    else:
                        headers['Content-Type'] = 'application/x-www-form-urlencoded'
                        body = urlencode(data)
            kwgs['body'] = body
        else:
            if data:
                path = '%s?%s' % (path, urlencode(data))

        if cookies:
            self._add_cookies(cookies, kwgs)

        #print 'fetch kwgs', kwgs
        url = self.get_url(path)
        logging.debug('testing fetch url: %s', url)
        self.http_client.fetch(url, self.stop, **kwgs)
        resp = self.wait()

        self._parse_cookies(resp)

        return resp

    def get(self, *args, **kwgs):
        return self.request('GET', *args, **kwgs)

    def delete(self, *args, **kwgs):
        return self.request('DELETE', *args, **kwgs)

    def post(self, *args, **kwgs):
        return self.request('POST', *args, **kwgs)

    def put(self, *args, **kwgs):
        return self.request('PUT', *args, **kwgs)

    def patch(self, *args, **kwgs):
        return self.request('PATCH', *args, **kwgs)

    def get_protocol(self):
        return 'http'

    def get_http_port(self):
        return self.app.settings['PORT']

    def get_url(self, path):
        """Returns an absolute url for the given path on the test server."""
        return '%s://localhost:%s%s' % (self.get_protocol(),
                                        self.get_http_port(), path)

    def get_handler_exc(self):
        if self._handler_exc_info:
            raise_exc_info(self._handler_exc_info)

    def patch_handler(self, hdr):
        if not isinstance(hdr, web.StaticFileHandler):
            hdr._testing_app_client = self
            hdr._handle_request_exception = _handle_request_exception

    def patch_app_handlers(self):
        for host_pattern, rules in self.app.application.handlers:
            for r in rules:
                self.patch_handler(r.handler_class)


def _handle_request_exception(self, e):

    if isinstance(e, HTTPError):
        if e.log_message:
            format = "%d %s: " + e.log_message
            args = [e.status_code, self._request_summary()] + list(e.args)
            logging.warning(format, *args)
        if e.status_code not in httplib.responses:
            logging.error("Bad HTTP status code: %d", e.status_code)
            self.send_error(500, exc_info=sys.exc_info())
        else:
            self.send_error(e.status_code, exc_info=sys.exc_info())
    else:
        self._testing_app_client._handler_exc_info = sys.exc_info()

        logging.error("Uncaught exception %s\n%r", self._request_summary(),
                      self.request, exc_info=True)
        self.send_error(500, exc_info=sys.exc_info())


class AppTestCase(unittest.TestCase):
    def setUp(self):
        self.c = self.get_client()

    def tearDown(self):
        self.c.close()

    def get_client(self):
        raise NotImplementedError
