# This file is part of the MapProxy project.
# Copyright (C) 2010-2017 Omniscale <http://omniscale.de>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License 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.

"""
Tile retrieval (WMS, TMS, etc.).
"""
import sys
import time
from typing import Any

from mapproxy.version import version
from mapproxy.image import ImageResult
from mapproxy.util.py import reraise_exception
from mapproxy.client.log import log_request

from urllib import request as urllib2
from urllib.error import URLError, HTTPError
from urllib.request import HTTPCookieProcessor
from http import client as httplib
from http.cookiejar import CookieJar

import socket
import ssl


class HTTPClientError(Exception):
    def __init__(self, arg, response_code=None, full_msg=None):
        super().__init__(arg)
        self.response_code = response_code
        self.full_msg = full_msg


def build_https_handler(ssl_ca_certs, insecure):
    if insecure:
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    elif ssl_ca_certs:
        ctx = ssl.create_default_context(cafile=ssl_ca_certs)
    else:
        ctx = ssl.create_default_context()
    return urllib2.HTTPSHandler(context=ctx)


class VerifiedHTTPSConnection(httplib.HTTPSConnection):
    def __init__(self, *args, **kw):
        self._ca_certs = kw.pop('ca_certs', None)
        super().__init__(*args, **kw)

    def connect(self):
        # overrides the version in httplib so that we do
        #    certificate verification

        if hasattr(socket, 'create_connection') and hasattr(self, 'source_address'):
            sock = socket.create_connection((self.host, self.port),
                                            self.timeout, self.source_address)
        else:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.connect((self.host, self.port))

        if hasattr(self, '_tunnel_host') and self._tunnel_host:
            # for Python >= 2.6 with proxy support
            self.sock = sock
            self._tunnel()

        # wrap the socket using verification with the root
        #    certs in self.ca_certs_path
        context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
        context.verify_mode = ssl.CERT_REQUIRED
        context.load_cert_chain(certfile=self.cert_file, keyfile=self.key_file)
        context.load_verify_locations(cafile=self._ca_certs)
        context.minimum_version = ssl.TLSVersion.TLSv1_2

        self.sock = context.wrap_socket(sock, server_hostname=self.host)


def verified_https_connection_with_ca_certs(ca_certs):
    """
    Creates VerifiedHTTPSConnection classes with given ca_certs file.
    """
    def wrapper(*args, **kw):
        kw['ca_certs'] = ca_certs
        return VerifiedHTTPSConnection(*args, **kw)
    return wrapper


class VerifiedHTTPSHandler(urllib2.HTTPSHandler):
    def __init__(self, connection_class=VerifiedHTTPSConnection):
        self.specialized_conn_class = connection_class
        super().__init__()

    def https_open(self, req):
        return self.do_open(self.specialized_conn_class, req)


class _URLOpenerCache(object):
    """
    Creates custom URLOpener with BasicAuth and HTTPS handler.

    Caches and reuses opener if possible (i.e. if they share the same
    ssl_ca_certs).
    """

    def __init__(self):
        self._opener = {}

    def __call__(self, ssl_ca_certs, url, username, password, insecure=False, manage_cookies=False):
        cache_key = (ssl_ca_certs, insecure, manage_cookies)
        if cache_key not in self._opener:
            handlers = []
            https_handler = build_https_handler(ssl_ca_certs, insecure)
            if https_handler:
                handlers.append(https_handler)
            passman = urllib2.HTTPPasswordMgrWithDefaultRealm()
            authhandler = urllib2.HTTPBasicAuthHandler(passman)
            handlers.append(authhandler)
            authhandler = urllib2.HTTPDigestAuthHandler(passman)
            handlers.append(authhandler)
            if manage_cookies:
                cj = CookieJar()
                handlers.append(HTTPCookieProcessor(cj))

            opener = urllib2.build_opener(*handlers)

            opener.addheaders = [('User-agent', 'MapProxy-%s' % (version,))]

            self._opener[cache_key] = (opener, passman)
        else:
            opener, passman = self._opener[cache_key]

        if url is not None and username is not None and password is not None:
            passman.add_password(None, url, username, password)

        return opener


create_url_opener = _URLOpenerCache()


class HTTPClient(object):
    def __init__(self, url=None, username=None, password=None, insecure=False,
                 ssl_ca_certs=None, timeout=None, headers=None, hide_error_details=False,
                 manage_cookies=False):
        self._timeout = timeout
        if url and url.startswith('https') and insecure:
            ssl_ca_certs = None

        self.opener = create_url_opener(ssl_ca_certs, url, username, password,
                                        insecure=insecure, manage_cookies=manage_cookies)
        self.headers = headers if headers else {}
        self.hide_error_details = hide_error_details

    def open(self, url, data=None, method=None, headers=None) -> Any:

        """
        Open a url and return a HTTP response.

        Parameters
        ----------
        url : string
            URL
        data : object | None
            additional data to send to the server.
        method : string | None
             HTTP request method that will be used.
        headers : dict | None
            Dictionary with key:value pairs to pass as headers. This is
            merged with the headers potentially passed to the constructor of
            HTTPClient.

        Returns
        -------
        A slightly modified http.client.HTTPResponse object.
        """

        code = None
        result = None
        start_time = time.time()
        req: urllib2.Request
        try:
            req = urllib2.Request(url, data=data)
        except ValueError as e:
            err = self.handle_url_exception(url, 'URL not correct', e.args[0])
            raise reraise_exception(err, sys.exc_info())
        headers = self.headers | (headers if headers else {})
        for key, value in headers.items():
            req.add_header(key, value)
        if method:
            req.method = method
        try:
            if self._timeout is not None:
                result = self.opener.open(req, timeout=self._timeout)
            else:
                result = self.opener.open(req)
        except HTTPError as e:
            code = e.code
            err = self.handle_url_exception(url, 'HTTP Error', str(code), response_code=code)
            raise reraise_exception(err, sys.exc_info())
        except URLError as e:
            if isinstance(e.reason, ssl.SSLError):
                err = self.handle_url_exception(url, 'Could not verify connection to URL', e.reason.args[1])
                raise reraise_exception(err, sys.exc_info())
            try:
                reason = e.reason.args[1]  # type: ignore
            except (AttributeError, IndexError):
                reason = e.reason
            err = self.handle_url_exception(url, 'No response from URL', reason)
            raise reraise_exception(err, sys.exc_info())
        except ValueError as e:
            err = self.handle_url_exception(url, 'URL not correct', e.args[0])
            raise reraise_exception(err, sys.exc_info())
        except Exception as e:
            err = self.handle_url_exception(url, 'Internal HTTP error', repr(e))
            raise reraise_exception(err, sys.exc_info())
        else:
            code = getattr(result, 'code', 200)
            if code == 204:
                raise HTTPClientError('HTTP Error "204 No Content"', response_code=204)
            return result
        finally:
            log_request(url, code, result, duration=time.time()-start_time, method=req.get_method())

    def open_image(self, url: str, data=None) -> ImageResult:
        resp = self.open(url, data=data)
        if 'content-type' in resp.headers:
            if not resp.headers['content-type'].lower().startswith('image'):
                raise HTTPClientError('response is not an image: (%s)' % (resp.read()))
        return ImageResult(resp)

    def handle_url_exception(self, url, message, reason, response_code=None):
        full_msg = '%s "%s": %s' % (message, url, reason)
        if self.hide_error_details:
            return HTTPClientError(
                '{} (see logs for URL and reason).'.format(message),
                response_code=response_code,
                full_msg=full_msg,
            )
        else:
            return HTTPClientError(
                full_msg,
                response_code=response_code,
            )


def auth_data_from_url(url):
    """
    >>> auth_data_from_url('invalid_url')
    ('invalid_url', (None, None))
    >>> auth_data_from_url('http://localhost/bar')
    ('http://localhost/bar', (None, None))
    >>> auth_data_from_url('http://bar@localhost/bar')
    ('http://localhost/bar', ('bar', None))
    >>> auth_data_from_url('http://bar:baz@localhost/bar')
    ('http://localhost/bar', ('bar', 'baz'))
    >>> auth_data_from_url('http://bar:b:az@@localhost/bar')
    ('http://localhost/bar', ('bar', 'b:az@'))
    >>> auth_data_from_url('http://bar foo; foo@bar:b:az@@localhost/bar')
    ('http://localhost/bar', ('bar foo; foo@bar', 'b:az@'))
    >>> auth_data_from_url('https://bar:foo#;%$@localhost/bar')
    ('https://localhost/bar', ('bar', 'foo#;%$'))
    >>> auth_data_from_url('http://localhost/bar@2x')
    ('http://localhost/bar@2x', (None, None))
    >>> auth_data_from_url('http://bar@localhost/bar@2x')
    ('http://localhost/bar@2x', ('bar', None))
    >>> auth_data_from_url('http://bar:baz@localhost/bar@2x')
    ('http://localhost/bar@2x', ('bar', 'baz'))
    >>> auth_data_from_url('https://bar@localhost/bar/0/0/0@2x.png')
    ('https://localhost/bar/0/0/0@2x.png', ('bar', None))
    >>> auth_data_from_url('http://bar:baz@localhost/bar@2x.png')
    ('http://localhost/bar@2x.png', ('bar', 'baz'))
    """
    if not url or '://' not in url:
        # be silent for invalid URLs
        return url, (None, None)

    schema, url = url.split('://', 1)
    if '/' in url:
        host, request = url.split('/', 1)
    else:
        host, request = url, ''

    username = password = None
    if '@' in host:
        auth_data, host = host.rsplit('@', 1)
        if ':' in auth_data:
            username, password = auth_data.split(':', 1)
        else:
            username = auth_data
    url = schema + "://" + host + "/" + request
    return url, (username, password)


def open_url(url: str):
    url, (username, password) = auth_data_from_url(url)
    http_client = HTTPClient(url, username, password)
    return http_client.open(url)


def retrieve_image(url: str) -> ImageResult:
    """
    Retrive an image from `url`.

    :return: the image as a file object (with url .header and .info)
    :raise HTTPClientError: if response content-type doesn't start with image
    """
    resp = open_url(url)
    if not resp.headers['content-type'].startswith('image'):
        raise HTTPClientError('response is not an image: (%s)' % (resp.read()))
    return ImageResult(resp)
