"""
This is a helper class to provide some common functionality around HTTP
client operations.
"""
import asyncio
import ipaddress
import logging
import os
import ssl
import time
from urllib.parse import urlsplit

import aiohttp
import httpx
import sys
import vcr
import yaml

from .namedtuple_cache import cached_namedtuple

# This library used by aiohttp 3.8 needs its logging turned down
logging.getLogger("charset_normalizer").setLevel(logging.ERROR)
logging.getLogger("httpx").setLevel(logging.WARNING)  # STM-10584

# STM-3012 found that enabling enable_cleanup_closed reduced memory leaks.
# STM-10143 found that disabling this fixed a memory leak in python >= 3.11.2.
_enable_cleanup_closed = False
if sys.version_info < (3, 11, 2):
    _enable_cleanup_closed = True

# When PRESEEM_EGRESS_PROXY holds a proxy URL, requests to public destinations
# are sent through it so they leave from a stable, ACL-able source address.
# Unset, every request connects directly.
_DEFAULT_PROXY = os.environ.get('PRESEEM_EGRESS_PROXY') or None
# The proxy carries a pinned source address and a direct connection is its
# fallback, so this is on wherever a proxy is configured.  Set it to 0 for a
# destination that must only ever be reached from the proxy's address.
_PROXY_FALLBACK = (os.environ.get('PRESEEM_EGRESS_PROXY_FALLBACK')
                   or '1').strip() != '0'
# The proxy reports its own failures with this header.  A status arriving
# without it came from the destination and must not be retried by another
# route, which would hide a failing destination behind a working one.
_PROXY_ERROR_HEADER = 'X-Squid-Error'
_BYPASS_SUFFIXES = tuple(
    s.strip().lower()
    for s in (os.environ.get('PRESEEM_EGRESS_PROXY_BYPASS')
              or '.googleapis.com,.gcr.io,.pkg.dev,.preseem.com').split(',')
    if s.strip())
_BYPASS_HOSTS = ('localhost', 'metadata', 'metadata.google.internal')
# RFC 6598 shared address space.  ipaddress does not report these as private,
# but they are not globally routable, so a proxy cannot reach them either.
_SHARED_ADDRESS_SPACE = ipaddress.ip_network('100.64.0.0/10')

# Requests run concurrently against a handful of hosts, so an unreachable proxy
# would otherwise report a fallback per request.  Report each host and failure
# at most once an interval, carrying a count of the ones left out.
_FALLBACK_LOG_INTERVAL = 60.0
_fallback_log_state = {}


def _log_fallback(proxy, url, detail):
    """Report a fallback to a direct connection, throttled per host."""
    host = (urlsplit(url).hostname or url).lower()
    key = (host, detail)
    now = time.monotonic()
    last, suppressed = _fallback_log_state.get(key, (None, 0))
    if last is not None and now - last < _FALLBACK_LOG_INTERVAL:
        _fallback_log_state[key] = (last, suppressed + 1)
        return
    if len(_fallback_log_state) > 1000:  # bound growth over a long process life
        _fallback_log_state.clear()
    _fallback_log_state[key] = (now, 0)
    more = ''
    if suppressed:
        more = ', {} more in the last {:.0f}s'.format(suppressed,
                                                      _FALLBACK_LOG_INTERVAL)
    logging.info('Egress proxy %s %s for %s, connecting directly%s', proxy, detail,
                 host, more)


def egress_proxy_for(url, proxy=None):
    """Return the proxy to use to reach url, or None to connect directly.

    Private, loopback and link-local addresses always connect directly: hosts
    addressed that way are reached over a private path that a proxy sitting on
    a public address has no route to.
    """
    proxy = proxy or _DEFAULT_PROXY
    if not proxy:
        return None
    # A base url is not required to carry a scheme; '//' makes urlsplit treat
    # the leading component as a host rather than a path.
    host = (urlsplit(url if '//' in url else '//' + url).hostname or '').lower()
    if not host or host in _BYPASS_HOSTS:
        return None
    try:
        addr = ipaddress.ip_address(host)
    except ValueError:
        if any(host == s.lstrip('.') or host.endswith(s) for s in _BYPASS_SUFFIXES):
            return None
        return proxy
    if (addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved
            or addr.is_multicast
            or (addr.version == 4 and addr in _SHARED_ADDRESS_SPACE)):
        return None
    return proxy


def _patch_vcr_aiohttp_record_response():
    """PPA-1973: vcrpy 7.0.0 dropped the 'url' field from recorded aiohttp
    responses, but vcrpy 4.4.0 (used on Python 3.7) requires it during
    playback.  Monkey-patch record_response to include it so that .http
    cassettes recorded with newer vcrpy remain compatible with 3.7 tests."""
    if getattr(vcr.cassette.Cassette.append, '_patched', False):
        return

    _orig_append = vcr.cassette.Cassette.append

    def _append_with_url(self, request, response):
        if isinstance(response, dict) and 'url' not in response:
            response['url'] = request.url
        return _orig_append(self, request, response)

    _append_with_url._patched = True
    vcr.cassette.Cassette.append = _append_with_url


class HttpClient(object):
    """Helper class to simplify HTTP operations on an open session."""

    def __init__(self,
                 url,
                 verify_ssl=True,
                 username=None,
                 password=None,
                 auth=None,
                 cert=None,
                 key=None,
                 user_agent=None,
                 connection_limit=None,
                 conn_timeout=None,
                 read_timeout=None,
                 record=None,
                 proxy=None):
        """Initialize the session pool and connector objects."""
        work_around_aiohttp_3535()
        timeout = aiohttp.ClientTimeout(connect=conn_timeout or 30,
                                        sock_read=read_timeout or 300)
        if verify_ssl:
            conn = aiohttp.TCPConnector(limit_per_host=connection_limit,
                                        enable_cleanup_closed=_enable_cleanup_closed)
            self._session = aiohttp.ClientSession(connector=conn, timeout=timeout)
        else:
            ssl_ctx = ssl._create_unverified_context()
            try:
                try:
                    ssl_ctx.minimum_version = ssl.TLSVersion.TLSv1  # STM-6823
                except AttributeError:
                    pass  # Python 3.6
                ssl_ctx.set_ciphers('DEFAULT@SECLEVEL=1')  # STM-4531
            except ssl.SSLError:
                pass  # some platforms don't allow this, ok
            if cert and key:
                ssl_ctx.load_cert_chain(cert, key)
                conn = aiohttp.TCPConnector(
                    ssl_context=ssl_ctx,
                    limit_per_host=connection_limit,
                    enable_cleanup_closed=_enable_cleanup_closed)
            else:
                conn = aiohttp.TCPConnector(
                    ssl_context=ssl_ctx,
                    limit_per_host=connection_limit,
                    enable_cleanup_closed=_enable_cleanup_closed)
            self._session = aiohttp.ClientSession(
                connector=conn,
                timeout=timeout,
                cookie_jar=aiohttp.CookieJar(unsafe=True))
        self._url = url
        # Every request from this client targets self._url, so the destination
        # is fixed and the proxy decision only has to be made once.
        self._proxy = egress_proxy_for(url, proxy)
        self.auth = auth
        if username:
            if password:
                self.auth = aiohttp.BasicAuth(username, password)
            else:
                self.auth = aiohttp.BasicAuth(username)
        self.user_agent = user_agent or 'preseem-netmeta-agent'

        self.vcr_cassette = None
        if record:  # record all http transactions to the provided name
            self.record(record)
        self.http2_client = None
        self.http2_record = None

    def record(self, name):
        if not self.vcr_cassette:
            vcr_log = logging.getLogger("vcr")
            vcr_log.setLevel(logging.WARNING)
            _patch_vcr_aiohttp_record_response()
            self.vcr = vcr.VCR(record_mode='all', cassette_library_dir='/tmp')
            self.vcr_cm = self.vcr.use_cassette(name)
            self.vcr_cassette = self.vcr_cm.__enter__()

    def _make_http2_client(self):
        """Build the http2 client.

        Requests made through it are not sent to the egress proxy.  The httpx
        pinned for python 3.7 predates the argument for it, and its successor
        is not keyword-compatible, so a client configured with an egress proxy
        still reaches http2 destinations directly.
        """
        return httpx.AsyncClient(http2=True,
                                 verify=False,
                                 timeout=httpx.Timeout(5.0, read=30.0))

    async def http2_request(self, path, num_retries=5, **kwargs):
        """Make a http2 request.  Returns the httpx response object."""
        if not self.http2_client:
            self.http2_client = self._make_http2_client()
        url = '{}{}'.format(self._url, path)
        err = None
        for retry_num in range(num_retries + 1):
            if retry_num > 0:
                await asyncio.sleep(2**retry_num)
                if retry_num > 1:
                    # don't log on the first retry, let it fail once silently
                    logging.warning('Retry request to {}, last error {}'.format(
                        url, err))
            method = 'POST' if kwargs.get('data') or kwargs.get('json') or kwargs.get(
                'content') else 'GET'
            try:
                # We want to just return the request to the caller, for now.
                response = await self.http2_client.request(method, url, **kwargs)
                if self.vcr_cassette is not None:
                    if self.http2_record is None:
                        self.http2_record = []
                    self.http2_record.append({
                        'request': {
                            'url': url,
                            **kwargs
                        },
                        'response': {
                            'status_code': response.status_code,
                            'reason_phrase': response.reason_phrase,
                            'http_version': response.http_version,
                            'url': str(response.url),
                            'headers': dict(response.headers),
                            'content': response.content,
                        }
                    })
                return response
            except asyncio.CancelledError:
                raise
            except asyncio.TimeoutError:
                err = 'TimeoutError'
            except httpx.RequestError as e:
                err = e
        raise RuntimeError(f'Error fetching {url}: {err}')

    async def http2_stream(self, path, **kwargs):
        """Make a http2 stream request.  Returns the httpx response object."""
        url = '{}{}'.format(self._url, path)
        method = 'POST' if kwargs.get('data') or kwargs.get('json') or kwargs.get(
            'content') else 'GET'
        if not self.http2_client:
            self.http2_client = self._make_http2_client()
        req = self.http2_client.build_request(method, url, **kwargs)
        r = await self.http2_client.send(req, stream=True)
        r.preseem_client = self
        if self.vcr_cassette is not None:  # record this session
            if self.http2_record is None:
                self.http2_record = []
            self.http2_record.append({
                'request': {
                    'url': url,
                    **kwargs
                },
                'response': {
                    'status_code': r.status_code,
                    'reason_phrase': r.reason_phrase,
                    'http_version': r.http_version,
                    'url': str(r.url),
                    'headers': dict(r.headers),
                },
                'stream': []
            })
        return r

    def record_http2_stream_data(self, r, data):
        """Helper to facilitate recording data for http streams.  This is
           needed because I haven't found a good way to do it transparently."""
        if self.vcr_cassette is not None:
            cur = self.http2_record[-1]
            if 'stream' in cur and cur['request']['url'] == r.url:
                cur['stream'].append(data)

    async def close(self):
        if self.http2_client:
            await self.http2_client.aclose()
        if self.vcr_cassette is not None:
            self.vcr_cm.__exit__()
            self.vcr_cassette = None
        if self._session:
            await self._session.close()

    async def request(self,
                      path,
                      num_retries=5,
                      handler=None,
                      response_encoding=None,
                      **kwargs):
        """Post a request to the session."""
        hdrs = {'user-agent': self.user_agent}
        hdrs.update(kwargs.get('headers') or {})
        if 'headers' in kwargs:
            del kwargs['headers']
        if self.auth and 'auth' not in kwargs:
            kwargs['auth'] = self.auth
        url = '{}{}'.format(self._url, path)
        err = None
        # Scoped to this call: a fallback must not silently change the source
        # address of every later request made through this client.
        proxy = self._proxy
        for retry_num in range(num_retries + 1):
            if retry_num > 0:
                await asyncio.sleep(2**retry_num)
                if retry_num > 1:
                    # don't log on the first retry, let it fail once silently
                    logging.warning('Retry request to {}, last error {}'.format(
                        url, err))
            try:
                method = 'POST' if kwargs.get('data') or kwargs.get('json') else 'GET'
                if 'method' in kwargs:
                    method = kwargs['method']
                    del kwargs['method']
                async with self._session.request(method,
                                                 url,
                                                 headers=hdrs,
                                                 proxy=proxy,
                                                 **kwargs) as r:
                    if handler:
                        # Allow a custom response handler to be passed in
                        return await handler(r)
                    if 200 <= r.status < 300:
                        if r.content_type == 'application/json':
                            if response_encoding:
                                return await r.json(encoding=response_encoding)
                            else:
                                return await r.json()
                        else:
                            if response_encoding:
                                return await r.text(encoding=response_encoding)
                            else:
                                return await r.text()
                    msg = await r.text()
                    err = 'Status code={} text={}'.format(r.status, msg)
                    if proxy and _PROXY_FALLBACK and _PROXY_ERROR_HEADER in r.headers:
                        # The proxy could not reach the destination, which is how
                        # a firewall that does not accept the proxy's address
                        # presents for a plain http destination.
                        _log_fallback(
                            proxy, url,
                            'reported {}'.format(r.headers[_PROXY_ERROR_HEADER]))
                        proxy = None
                        continue
                    if r.status >= 500:
                        err = r.text
                        continue
                    r.raise_for_status()
            except asyncio.TimeoutError:
                err = 'TimeoutError'
            except aiohttp.ServerDisconnectedError:
                # This is just a regular occurrence.
                # We only log an error on the second retry to avoid this.
                err = 'ServerDisconnectedError'
            except aiohttp.ClientProxyConnectionError:
                # Subclasses ClientConnectionError, so this has to come first.
                err = 'ClientProxyConnectionError'
                if proxy and _PROXY_FALLBACK:
                    _log_fallback(proxy, url, 'unreachable')
                    proxy = None
            except aiohttp.ClientHttpProxyError as e:
                # A ClientResponseError rather than a connection error, so no
                # other clause here catches it.  The proxy answering CONNECT with
                # an error is how a firewall that does not accept the proxy's
                # address presents for an https destination.
                err = 'ClientHttpProxyError {}'.format(e.status)
                if proxy and _PROXY_FALLBACK:
                    _log_fallback(proxy, url,
                                  'refused the tunnel with {}'.format(e.status))
                    proxy = None
            except aiohttp.ClientConnectionError:
                err = 'ClientConnectionError'
        raise RuntimeError('Error fetching {}: {}'.format(url, err))


def ignore_aiohttp_ssl_eror(loop, aiohttpversion='3.8.1'):
    """Ignore aiohttp #3535 issue with SSL data after close
    There appears to be an issue on Python 3.7 and aiohttp SSL that throws a
    ssl.SSLError fatal error (ssl.SSLError: [SSL: KRB5_S_INIT] application data
    after close notify (_ssl.c:2609)) after we are already done with the
    connection. See GitHub issue aio-libs/aiohttp#3535

    Given a loop, this sets up a exception handler that ignores this specific
    exception, but passes everything else on to the previous exception handler
    this one replaces.

    If the current aiohttp version is not exactly equal to aiohttpversion
    nothing is done, assuming that the next version will have this bug fixed.
    This can be disabled by setting this parameter to None
    """
    if aiohttpversion is not None and aiohttp.__version__ != aiohttpversion:
        return

    orig_handler = loop.get_exception_handler() or loop.default_exception_handler

    def ignore_ssl_error(loop, context):
        if context.get('message') == 'SSL error in data received':
            # validate we have the right exception, transport and protocol
            exception = context.get('exception')
            protocol = context.get('protocol')
            if (isinstance(exception, ssl.SSLError)
                    and exception.reason == 'KRB5_S_INIT'
                    and isinstance(protocol, asyncio.sslproto.SSLProtocol)
                    and isinstance(protocol._app_protocol,
                                   aiohttp.client_proto.ResponseHandler)):
                if loop.get_debug():
                    asyncio.log.logger.debug('Ignoring aiohttp SSL KRB5_S_INIT error')
                return
        try:
            orig_handler(loop, context)
        except TypeError:
            orig_handler(context)

    loop.set_exception_handler(ignore_ssl_error)


_worked_around_aiohttp_3535 = False


def work_around_aiohttp_3535():
    global _worked_around_aiohttp_3535
    if _worked_around_aiohttp_3535:
        return
    try:
        ignore_aiohttp_ssl_eror(asyncio.get_running_loop())
    except AttributeError:  # predates python 3.7
        ignore_aiohttp_ssl_eror(asyncio.get_event_loop())
    finally:
        _worked_around_aiohttp_3535 = True


def dict2fs(d):
    return frozenset(
        (k, frozenset(v.items()) if isinstance(v, dict) else v) for k, v in d.items())


class ReplayHttpClient(HttpClient):
    """Special HTTP client that replays a capture file.  Currently http2 only,
       we use vcrpy to replay other http."""
    file = None
    http2_client = None
    _session = None

    def __init__(self, url, file=None):
        super().__init__(url)
        self._url = url
        file = file or self.file
        if file:
            with open(file) as f:  # we assume caller set the file class var
                data = yaml.safe_load(f.read())
            self.data = {}
            for rec in data:
                self.data[dict2fs(rec['request'])] = rec
        self.vcr_cassette = None

    async def http2_request(self, path, num_retries=None, **kwargs):
        """Make a http2 request.  Returns a httpx-compatible response object."""
        url = f'{self._url}{path}'
        key = dict2fs({'url': url, **kwargs})
        rec = self.data.get(key)
        if not rec:
            raise RuntimeError(f'No matching record for {key}')
        # return an object that will behave like a httpx.Response
        nt = cached_namedtuple('Response', tuple(rec['response'].keys()))
        return nt(*rec['response'].values())

    class FakeStreamResponse:
        """async iterator that works like the httpx stream response."""

        def __init__(self, chunks):
            self.chunks = chunks

        async def aclose(self):
            pass

        async def aiter_bytes(self):
            for chunk in self.chunks:
                yield chunk

    async def http2_stream(self, path, **kwargs):
        """Make a http2 stream request.  Returns a fake response object."""
        url = '{}{}'.format(self._url, path)
        key = dict2fs({'url': url, **kwargs})
        rec = self.data.get(key)
        if not rec or 'stream' not in rec:
            raise RuntimeError(f'No matching stream record for {key}')
        r = self.FakeStreamResponse(rec['stream'])
        for k, v in rec['response'].items():
            setattr(r, k, v)
        return r

    def record_http2_stream_data(self, r, data):
        pass
