import re
import ssl
from http.client import HTTPException, BadStatusLine
from urllib.request import (
    build_opener,
    HTTPPasswordMgrWithDefaultRealm,
    ProxyBasicAuthHandler,
    ProxyDigestAuthHandler,
    ProxyHandler,
    Request,
)
from urllib.error import HTTPError, URLError
from socket import error as ConnectionError

from .. import text
from ..ca_certs import get_ca_bundle_path, get_user_ca_bundle_path
from ..console_write import console_write
from ..http.validating_https_handler import ValidatingHTTPSHandler
from ..http.debuggable_http_handler import DebuggableHTTPHandler
from .downloader_exception import DownloaderException
from .basic_auth_downloader import BasicAuthDownloader
from .caching_downloader import CachingDownloader
from .decoding_downloader import DecodingDownloader
from .limiting_downloader import LimitingDownloader


class UrlLibDownloader(DecodingDownloader, LimitingDownloader, CachingDownloader, BasicAuthDownloader):

    """
    A downloader that uses the Python urllib module

    :param settings:
        A dict of the various Package Control settings. The Sublime Text
        Settings API is not used because this code is run in a thread.
    """

    def __init__(self, settings):
        self.opener = None
        self.settings = settings

    def close(self):
        """
        Closes any persistent/open connections
        """

        if not self.opener:
            return
        handler = self.get_handler()
        if handler:
            handler.close()
        self.opener = None

    def download(self, url, error_message, timeout, tries, prefer_cached=False):
        """
        Downloads a URL and returns the contents

        Uses the proxy settings from the Package Control.sublime-settings file,
        however there seem to be a decent number of proxies that this code
        does not work with. Patches welcome!

        :param url:
            The URL to download

        :param error_message:
            A string to include in the console error that is printed
            when an error occurs

        :param timeout:
            The int number of seconds to set the timeout to

        :param tries:
            The int number of times to try and download the URL in the case of
            a timeout or HTTP 503 error

        :param prefer_cached:
            If a cached version should be returned instead of trying a new request

        :raises:
            RateLimitException: when a rate limit is hit
            DownloaderException: when any other download error occurs

        :return:
            The string contents of the URL
        """

        if prefer_cached:
            cached = self.retrieve_cached(url)
            if cached:
                return cached

        self.setup_opener(url, timeout)

        debug = self.settings.get('debug')
        tried = tries
        error_string = None
        while tries > 0:
            tries -= 1
            try:
                request_headers = {
                    # Don't be alarmed if the response from the server does not
                    # select one of these since the server runs a relatively new
                    # version of OpenSSL which supports compression on the SSL
                    # layer, and Apache will use that instead of HTTP-level
                    # encoding.
                    "Accept-Encoding": self.supported_encodings()
                }
                user_agent = self.settings.get('user_agent')
                if user_agent:
                    request_headers["User-Agent"] = user_agent

                request_headers.update(self.build_auth_header(url))

                request_headers = self.add_conditional_headers(url, request_headers)
                request = Request(url, headers=request_headers)
                http_file = self.opener.open(request, timeout=timeout)
                self.handle_rate_limit(http_file.headers, url)

                result = http_file.read()
                # Make sure the response is closed so we can re-use the connection
                http_file.close()

                encoding = http_file.headers.get('content-encoding')
                result = self.decode_response(encoding, result)

                return self.cache_result('get', url, http_file.getcode(), http_file.headers, result)

            except (ssl.CertificateError) as e:
                error_string = 'Certificate validation for %s failed: %s' % (url, str(e))

            except (HTTPException) as e:
                # Since we use keep-alives, it is possible the other end closed
                # the connection, and we may just need to re-open
                if isinstance(e, BadStatusLine):
                    handler = self.get_handler()
                    if handler and handler.use_count > 1:
                        self.close()
                        self.setup_opener(url, timeout)
                        tries += 1
                        continue

                exception_type = e.__class__.__name__
                error_string = text.format(
                    '''
                    %s HTTP exception %s (%s) downloading %s.
                    ''',
                    (error_message, exception_type, str(e), url)
                )

            except (HTTPError) as e:
                # Make sure the response is closed so we can re-use the connection
                e.read()
                e.close()

                # Make sure we obey Github's rate limiting headers
                self.handle_rate_limit(e.headers, url)

                # Handle cached responses
                if str(e.code) == '304':
                    return self.cache_result('get', url, int(e.code), e.headers, b'')

                # Bitbucket and Github return 503 a decent amount
                if str(e.code) == '503' and tries != 0:
                    if tries and debug:
                        console_write(
                            '''
                            Downloading %s was rate limited, trying again
                            ''',
                            url
                        )
                    continue

                error_string = text.format(
                    '''
                    %s HTTP error %s downloading %s.
                    ''',
                    (error_message, str(e.code), url)
                )

            except (URLError) as e:

                # Bitbucket and Github timeout a decent amount
                if str(e.reason) == 'The read operation timed out' \
                        or str(e.reason) == 'timed out':
                    if tries and debug:
                        console_write(
                            '''
                            Downloading %s timed out, trying again
                            ''',
                            url
                        )
                    continue

                error_string = text.format(
                    '''
                    %s URL error %s downloading %s.
                    ''',
                    (error_message, str(e.reason), url)
                )

            except (ConnectionError):
                # Handle broken pipes/reset connections by creating a new opener, and
                # thus getting new handlers and a new connection
                if debug:
                    console_write(
                        '''
                        Connection went away while trying to download %s, trying again
                        ''',
                        url
                    )

                self.opener = None
                self.setup_opener(url, timeout)

                continue

            break

        if error_string is None:
            plural = 's' if tried > 1 else ''
            error_string = 'Unable to download %s after %d attempt%s' % (url, tried, plural)

        raise DownloaderException(error_string)

    def get_handler(self):
        """
        Get the HTTPHandler object for the current connection
        """

        if not self.opener:
            return None

        for handler in self.opener.handlers:
            if isinstance(handler, ValidatingHTTPSHandler) or isinstance(handler, DebuggableHTTPHandler):
                return handler

    def setup_opener(self, url, timeout):
        """
        Sets up a urllib OpenerDirector to be used for requests. There is a
        fair amount of custom urllib code in Package Control, and part of it
        is to handle proxies and keep-alives. Creating an opener the way
        below is because the handlers have been customized to send the
        "Connection: Keep-Alive" header and hold onto connections so they
        can be re-used.

        :param url:
            The URL to download

        :param timeout:
            The int number of seconds to set the timeout to
        """

        if not self.opener:
            http_proxy = self.settings.get('http_proxy')
            https_proxy = self.settings.get('https_proxy')
            if http_proxy or https_proxy:
                proxies = {}
                if http_proxy:
                    proxies['http'] = http_proxy
                if https_proxy:
                    proxies['https'] = https_proxy
                proxy_handler = ProxyHandler(proxies)
            else:
                proxy_handler = ProxyHandler()

            password_manager = HTTPPasswordMgrWithDefaultRealm()
            proxy_username = self.settings.get('proxy_username')
            proxy_password = self.settings.get('proxy_password')
            if proxy_username and proxy_password:
                if http_proxy:
                    password_manager.add_password(None, http_proxy, proxy_username, proxy_password)
                if https_proxy:
                    password_manager.add_password(None, https_proxy, proxy_username, proxy_password)

            handlers = [
                proxy_handler,
                ProxyBasicAuthHandler(password_manager),
                ProxyDigestAuthHandler(password_manager)
            ]

            debug = self.settings.get('debug')

            if debug:
                console_write(
                    '''
                    Urllib Debug Proxy
                      http_proxy: %s
                      https_proxy: %s
                      proxy_username: %s
                      proxy_password: %s
                    ''',
                    (http_proxy, https_proxy, proxy_username, proxy_password)
                )

            secure_url_match = re.match(r'^https://([^/#?]+)', url)
            if secure_url_match is not None:
                if hasattr(ssl.SSLContext, 'load_default_certs'):
                    # python 3.8 ssl module is able to load CA from native OS
                    # certificate stores, just need to merge in user defined CA
                    # No need to create home grown merged CA bundle anymore.
                    handlers.append(ValidatingHTTPSHandler(
                        ca_certs=None,
                        extra_ca_certs=get_user_ca_bundle_path(self.settings),
                        debug=debug,
                        passwd=password_manager,
                        user_agent=self.settings.get('user_agent')
                    ))

                else:
                    # python 3.3 ssl module is not able to access OS cert stores
                    handlers.append(ValidatingHTTPSHandler(
                        ca_certs=get_ca_bundle_path(self.settings),
                        extra_ca_certs=None,
                        debug=debug,
                        passwd=password_manager,
                        user_agent=self.settings.get('user_agent')
                    ))

            else:
                handlers.append(DebuggableHTTPHandler(debug=debug))

            self.opener = build_opener(*handlers)

    def supports_ssl(self):
        """
        Indicates if the object can handle HTTPS requests

        :return:
            If the object supports HTTPS requests
        """
        return True

    def supports_plaintext(self):
        """
        Indicates if the object can handle non-secure HTTP requests

        :return:
            If the object supports non-secure HTTP requests
        """

        return True
