import asyncio
import time
import unittest
from unittest import mock

import aiohttp

from preseem import http_client
from preseem.http_client import HttpClient, egress_proxy_for

PROXY = 'http://10.0.0.9:3128'


class _FakeResponse:

    def __init__(self, status, body='{}', content_type='application/json'):
        self.status = status
        self.content_type = content_type
        self._body = body

    async def text(self, encoding=None):
        return self._body

    async def json(self, encoding=None):
        return {'ok': True}

    def raise_for_status(self):
        raise RuntimeError('status {}'.format(self.status))


class _FakeRequestContext:

    def __init__(self, result):
        self._result = result

    async def __aenter__(self):
        if isinstance(self._result, Exception):
            raise self._result
        return self._result

    async def __aexit__(self, *exc_info):
        return False


class _FakeSession:
    """Replays scripted results and records the proxy used for each attempt."""

    def __init__(self, results):
        self._results = list(results)
        self.proxies = []

    def request(self, method, url, headers=None, proxy=None, **kwargs):
        self.proxies.append(proxy)
        return _FakeRequestContext(self._results.pop(0))


def _client(proxy, results):
    """Build a client around a fake session, bypassing real socket setup."""
    client = object.__new__(HttpClient)
    client._url = 'https://splynx.example.com'
    client._proxy = proxy
    client._session = _FakeSession(results)
    client.auth = None
    client.user_agent = 'test'
    client.vcr_cassette = None
    return client


async def _no_sleep(_seconds):
    return None


class TestEgressProxyFor(unittest.TestCase):
    """Tests for the rules deciding which destinations use the egress proxy."""

    def test_public_host_uses_proxy(self):
        self.assertEqual(egress_proxy_for('https://splynx.example.com/api', PROXY),
                         PROXY)

    def test_public_host_with_port_and_path_uses_proxy(self):
        self.assertEqual(egress_proxy_for('https://example.com:8443/a/b', PROXY), PROXY)

    def test_public_ip_uses_proxy(self):
        for url in ('https://8.8.8.8/api', 'https://35.239.140.197/api',
                    'https://[2606:4700:4700::1111]/api'):
            with self.subTest(url=url):
                self.assertEqual(egress_proxy_for(url, PROXY), PROXY)

    def test_no_proxy_configured_connects_directly(self):
        with mock.patch('preseem.http_client._DEFAULT_PROXY', None):
            self.assertIsNone(egress_proxy_for('https://example.com/api'))

    def test_default_proxy_used_when_no_argument(self):
        with mock.patch('preseem.http_client._DEFAULT_PROXY', PROXY):
            self.assertEqual(egress_proxy_for('https://example.com/api'), PROXY)

    def test_explicit_proxy_overrides_default(self):
        with mock.patch('preseem.http_client._DEFAULT_PROXY', 'http://other:3128'):
            self.assertEqual(egress_proxy_for('https://example.com/api', PROXY), PROXY)

    def test_private_addresses_connect_directly(self):
        for url in ('https://10.44.1.5/api', 'https://192.168.1.1/',
                    'https://172.20.0.30:8080/x', 'http://127.0.0.1:9150/metrics',
                    'http://169.254.169.254/computeMetadata/v1/', 'https://[::1]/api'):
            with self.subTest(url=url):
                self.assertIsNone(egress_proxy_for(url, PROXY))

    def test_shared_address_space_connects_directly(self):
        """RFC 6598 space is common inside ISP networks and is not routable."""
        for url in ('https://100.64.0.1/api', 'https://100.127.255.254:8443/x'):
            with self.subTest(url=url):
                self.assertIsNone(egress_proxy_for(url, PROXY))

    def test_bypass_hosts_connect_directly(self):
        for url in ('http://localhost:9150/metrics',
                    'http://metadata/computeMetadata/v1/',
                    'http://metadata.google.internal/x'):
            with self.subTest(url=url):
                self.assertIsNone(egress_proxy_for(url, PROXY))

    def test_bypass_suffixes_connect_directly(self):
        for url in ('https://storage.googleapis.com/b/o', 'https://gcr.io/preseem/x',
                    'https://us-central1-docker.pkg.dev/x',
                    'https://pi.preseem.com/simple/'):
            with self.subTest(url=url):
                self.assertIsNone(egress_proxy_for(url, PROXY))

    def test_bypass_suffix_does_not_match_longer_label(self):
        """notpreseem.com must not be swallowed by the .preseem.com suffix."""
        self.assertEqual(egress_proxy_for('https://notpreseem.com/api', PROXY), PROXY)

    def test_host_comparison_is_case_insensitive(self):
        self.assertIsNone(egress_proxy_for('https://PI.PRESEEM.COM/simple/', PROXY))

    def test_url_without_scheme(self):
        self.assertEqual(egress_proxy_for('example.com/api', PROXY), PROXY)
        self.assertIsNone(egress_proxy_for('10.44.1.5/api', PROXY))


class TestProxyFallback(unittest.TestCase):
    """Tests for falling back to a direct connection when the proxy path fails."""

    def _run(self, client, fallback=True):
        with mock.patch('preseem.http_client._PROXY_FALLBACK', fallback), \
             mock.patch('preseem.http_client.asyncio.sleep', _no_sleep):
            return asyncio.run(client.request('/api/v1/thing'))

    def test_falls_back_when_proxy_unreachable(self):
        client = _client(PROXY, [
            aiohttp.ClientProxyConnectionError(None, OSError('refused')),
            _FakeResponse(200),
        ])
        self._run(client)
        self.assertEqual(client._session.proxies, [PROXY, None])

    def test_falls_back_when_proxy_cannot_reach_destination(self):
        """Squid answers 503 when the destination refuses it, which is how a
        firewall that has not yet accepted the proxy addresses presents."""
        for status in (502, 503, 504):
            with self.subTest(status=status):
                client = _client(
                    PROXY,
                    [_FakeResponse(status, body='', content_type='text/plain'),
                     _FakeResponse(200)])
                self._run(client)
                self.assertEqual(client._session.proxies, [PROXY, None])

    def test_no_fallback_when_disabled(self):
        client = _client(PROXY, [
            aiohttp.ClientProxyConnectionError(None, OSError('refused')),
            _FakeResponse(200),
        ])
        self._run(client, fallback=False)
        self.assertEqual(client._session.proxies, [PROXY, PROXY])

    def test_fallback_does_not_persist_across_calls(self):
        client = _client(PROXY, [
            aiohttp.ClientProxyConnectionError(None, OSError('refused')),
            _FakeResponse(200),
        ])
        self._run(client)
        client._session = _FakeSession([_FakeResponse(200)])
        self._run(client)
        self.assertEqual(client._session.proxies, [PROXY])

    def test_direct_client_never_reports_a_proxy(self):
        client = _client(None, [_FakeResponse(500, body='', content_type='text/plain'),
                                _FakeResponse(200)])
        self._run(client)
        self.assertEqual(client._session.proxies, [None, None])


class TestFallbackLogging(unittest.TestCase):
    """Fallbacks are reported once per host per interval, not once per request."""

    def setUp(self):
        http_client._fallback_log_state.clear()

    def _burst(self, count, url='https://splynx.example.com/api/graphql'):
        with self.assertLogs(level='INFO') as captured:
            for _ in range(count):
                http_client._log_fallback(PROXY, url, 'unreachable')
        return captured.output

    def test_concurrent_burst_logs_once(self):
        self.assertEqual(len(self._burst(200)), 1)

    def test_message_names_the_host(self):
        self.assertIn('splynx.example.com', self._burst(1)[0])

    def test_suppressed_count_reported_on_next_interval(self):
        self._burst(50)
        later = time.monotonic() + 2 * http_client._FALLBACK_LOG_INTERVAL
        with mock.patch('preseem.http_client.time.monotonic', return_value=later):
            out = self._burst(1)
        self.assertIn('49 more', out[0])

    def test_distinct_failures_reported_separately(self):
        with self.assertLogs(level='INFO') as captured:
            for _ in range(20):
                http_client._log_fallback(PROXY, 'https://a.example.com/x', 'unreachable')
                http_client._log_fallback(PROXY, 'https://a.example.com/x', 'returned 503')
                http_client._log_fallback(PROXY, 'https://b.example.com/x', 'unreachable')
        self.assertEqual(len(captured.output), 3)

    def test_state_is_bounded(self):
        for i in range(1200):
            http_client._log_fallback(PROXY, 'https://h{}.example.com/x'.format(i), 'x')
        self.assertLessEqual(len(http_client._fallback_log_state), 1001)


if __name__ == '__main__':
    unittest.main()
