import io
import json
import ssl
import urllib.request
from http import HTTPStatus
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError, URLError

import pytest

from justconf import AccessDeniedError, AuthenticationError, NoValidAuthError, SecretNotFoundError
from justconf.processor import (
    AppRoleAuth,
    JwtAuth,
    KubernetesAuth,
    TokenAuth,
    UserpassAuth,
    VaultProcessor,
    vault_auth_from_env,
)
from justconf.processor.vault import (
    _create_ssl_context,
    _detect_approle_auth,
    _detect_jwt_auth,
    _detect_kubernetes_auth,
    _detect_token_auth,
    _detect_userpass_auth,
    _extract_vault_errors,
    _urlopen_with_retry,
)


class TestTokenAuth:
    def test_authenticate__valid_token__returns_token_and_ttl(self):
        # arrange
        auth = TokenAuth(token='hvs.test_token')
        mock_response = {
            'data': {'ttl': 7200},
        }

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            token, ttl = auth.authenticate('http://vault:8200')

        # assert
        assert token == 'hvs.test_token'
        assert ttl == 7200

    def test_authenticate__empty_token__raises_error(self):
        # arrange
        auth = TokenAuth(token='')

        # act & assert
        with pytest.raises(AuthenticationError, match='Token is empty'):
            auth.authenticate('http://vault:8200')

    def test_authenticate__invalid_token__raises_error_with_details(self):
        # arrange
        auth = TokenAuth(token='invalid')

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = create_http_error(
                HTTPStatus.FORBIDDEN,
                body=b'{"errors": ["permission denied"]}',
            )
            with pytest.raises(AuthenticationError, match='Token authentication failed: permission denied'):
                auth.authenticate('http://vault:8200')


class TestAppRoleAuth:
    def test_authenticate__valid_credentials__returns_token(self):
        # arrange
        auth = AppRoleAuth(role_id='role123', secret_id='secret456')
        mock_response = {
            'auth': {
                'client_token': 'hvs.new_token',
                'lease_duration': 3600,
            },
        }

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            token, ttl = auth.authenticate('http://vault:8200')

        # assert
        assert token == 'hvs.new_token'
        assert ttl == 3600

    def test_authenticate__empty_credentials__raises_error(self):
        # arrange
        auth = AppRoleAuth(role_id='', secret_id='')

        # act & assert
        with pytest.raises(AuthenticationError, match='role_id and secret_id are required'):
            auth.authenticate('http://vault:8200')

    def test_authenticate__custom_mount_path__uses_path(self):
        # arrange
        auth = AppRoleAuth(role_id='role', secret_id='secret', mount_path='custom-approle')
        mock_response = {'auth': {'client_token': 'token', 'lease_duration': 3600}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            auth.authenticate('http://vault:8200')

            # assert
            call_args = mock_urlopen.call_args[0][0]
            assert '/auth/custom-approle/login' in call_args.full_url

    def test_authenticate__http_error__raises_auth_error(self):
        # arrange
        auth = AppRoleAuth(role_id='role', secret_id='secret')

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = create_http_error(HTTPStatus.FORBIDDEN)
            with pytest.raises(AuthenticationError, match='AppRole authentication failed'):
                auth.authenticate('http://vault:8200')

    def test_authenticate__invalid_response__raises_auth_error(self):
        # arrange
        auth = AppRoleAuth(role_id='role', secret_id='secret')
        mock_response = {'auth': {}}  # missing client_token

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            with pytest.raises(AuthenticationError, match='Invalid response'):
                auth.authenticate('http://vault:8200')


class TestJwtAuth:
    def test_authenticate__valid_jwt__returns_token(self):
        # arrange
        auth = JwtAuth(role='myproject', jwt='eyJ...')
        mock_response = {
            'auth': {
                'client_token': 'hvs.jwt_token',
                'lease_duration': 1800,
            },
        }

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            token, ttl = auth.authenticate('http://vault:8200')

        # assert
        assert token == 'hvs.jwt_token'
        assert ttl == 1800

    def test_authenticate__empty_jwt__raises_error(self):
        # arrange
        auth = JwtAuth(role='myproject', jwt='')

        # act & assert
        with pytest.raises(AuthenticationError, match='JWT token is empty'):
            auth.authenticate('http://vault:8200')

    def test_authenticate__custom_mount_path__uses_path(self):
        # arrange
        auth = JwtAuth(role='myproject', jwt='eyJ...', mount_path='oidc')
        mock_response = {'auth': {'client_token': 'token', 'lease_duration': 3600}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            auth.authenticate('http://vault:8200')

            # assert
            call_args = mock_urlopen.call_args[0][0]
            assert '/auth/oidc/login' in call_args.full_url

    def test_authenticate__http_error__raises_auth_error(self):
        # arrange
        auth = JwtAuth(role='myproject', jwt='invalid')

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = create_http_error(HTTPStatus.FORBIDDEN)
            with pytest.raises(AuthenticationError, match='JWT authentication failed'):
                auth.authenticate('http://vault:8200')

    def test_authenticate__invalid_response__raises_auth_error(self):
        # arrange
        auth = JwtAuth(role='myproject', jwt='valid')
        mock_response = {'auth': {}}  # missing client_token

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            with pytest.raises(AuthenticationError, match='Invalid response'):
                auth.authenticate('http://vault:8200')


class TestKubernetesAuth:
    def test_authenticate__with_jwt_param__uses_jwt(self):
        # arrange
        auth = KubernetesAuth(role='myapp', jwt='sa_token_content')
        mock_response = {
            'auth': {
                'client_token': 'hvs.k8s_token',
                'lease_duration': 3600,
            },
        }

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            token, ttl = auth.authenticate('http://vault:8200')

        # assert
        assert token == 'hvs.k8s_token'
        assert ttl == 3600

    def test_authenticate__jwt_from_file__reads_file(self, tmp_path):
        # arrange
        jwt_file = tmp_path / 'token'
        jwt_file.write_text('file_token_content')
        auth = KubernetesAuth(role='myapp', jwt_path=str(jwt_file))
        mock_response = {'auth': {'client_token': 'hvs.token', 'lease_duration': 3600}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            auth.authenticate('http://vault:8200')

            # assert
            call_args = mock_urlopen.call_args[0][0]
            body = json.loads(call_args.data)
            assert body['jwt'] == 'file_token_content'

    def test_jwt_property__file_not_found__raises_error(self):
        # arrange
        auth = KubernetesAuth(role='myapp', jwt_path='/nonexistent/token')

        # act & assert
        with pytest.raises(AuthenticationError, match='Kubernetes SA token not found'):
            _ = auth.jwt

    def test_authenticate__custom_mount_path__uses_path(self):
        # arrange
        auth = KubernetesAuth(role='myapp', jwt='token', mount_path='k8s-cluster')
        mock_response = {'auth': {'client_token': 'token', 'lease_duration': 3600}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            auth.authenticate('http://vault:8200')

            # assert
            call_args = mock_urlopen.call_args[0][0]
            assert '/auth/k8s-cluster/login' in call_args.full_url

    def test_authenticate__http_error__raises_auth_error(self):
        # arrange
        auth = KubernetesAuth(role='myapp', jwt='token')

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = create_http_error(HTTPStatus.FORBIDDEN)
            with pytest.raises(AuthenticationError, match='Kubernetes authentication failed'):
                auth.authenticate('http://vault:8200')

    def test_authenticate__invalid_response__raises_auth_error(self):
        # arrange
        auth = KubernetesAuth(role='myapp', jwt='token')
        mock_response = {'auth': {}}  # missing client_token

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            with pytest.raises(AuthenticationError, match='Invalid response'):
                auth.authenticate('http://vault:8200')

    def test_jwt_property__with_jwt_param__returns_param(self):
        # arrange
        auth = KubernetesAuth(role='myapp', jwt='direct_token')

        # act & assert
        assert auth.jwt == 'direct_token'


class TestUserpassAuth:
    def test_authenticate__valid_credentials__returns_token(self):
        # arrange
        auth = UserpassAuth(username='admin', password='secret')
        mock_response = {
            'auth': {
                'client_token': 'hvs.userpass_token',
                'lease_duration': 3600,
            },
        }

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            token, ttl = auth.authenticate('http://vault:8200')

        # assert
        assert token == 'hvs.userpass_token'
        assert ttl == 3600

    def test_authenticate__empty_credentials__raises_error(self):
        # arrange
        auth = UserpassAuth(username='', password='')

        # act & assert
        with pytest.raises(AuthenticationError, match='Username and password are required'):
            auth.authenticate('http://vault:8200')

    def test_authenticate__custom_mount_path__uses_path(self):
        # arrange
        auth = UserpassAuth(username='admin', password='secret', mount_path='ldap')
        mock_response = {'auth': {'client_token': 'token', 'lease_duration': 3600}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            auth.authenticate('http://vault:8200')

            # assert
            call_args = mock_urlopen.call_args[0][0]
            assert '/auth/ldap/login/admin' in call_args.full_url

    def test_authenticate__http_error__raises_auth_error(self):
        # arrange
        auth = UserpassAuth(username='admin', password='wrong')

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = create_http_error(HTTPStatus.FORBIDDEN)
            with pytest.raises(AuthenticationError, match='Userpass authentication failed'):
                auth.authenticate('http://vault:8200')

    def test_authenticate__invalid_response__raises_auth_error(self):
        # arrange
        auth = UserpassAuth(username='admin', password='secret')
        mock_response = {'auth': {}}  # missing client_token

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            with pytest.raises(AuthenticationError, match='Invalid response'):
                auth.authenticate('http://vault:8200')


class TestVaultProcessor:
    def test_init__invalid_url_scheme__raises_error(self):
        # act & assert
        with pytest.raises(ValueError, match='Invalid Vault URL scheme'):
            VaultProcessor(
                url='ftp://vault:8200',
                auth=TokenAuth(token='test'),
            )

    def test_init__missing_host__raises_error(self):
        # act & assert
        with pytest.raises(ValueError, match=r'Invalid Vault URL.*missing host'):
            VaultProcessor(
                url='http://',
                auth=TokenAuth(token='test'),
            )

    def test_init__url_trailing_slash__stripped(self):
        # act
        processor = VaultProcessor(
            url='http://vault:8200/',
            auth=TokenAuth(token='test'),
        )

        # assert
        assert processor.url == 'http://vault:8200'

    def test_init__full_path_used_in_requests(self):
        # arrange
        processor = VaultProcessor(
            url='http://vault:8200',
            auth=TokenAuth(token='test_token'),
        )
        mock_token_response = {'data': {'ttl': 3600}}
        mock_secret_response = {'data': {'data': {'key': 'value'}}}

        def side_effect(*args, **kwargs):
            mock = MagicMock()
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
            if 'lookup-self' in url:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_token_response).encode()
            else:
                # verify full path is used as-is
                assert '/v1/kv/data/secret/test' in url
                mock.__enter__.return_value.read.return_value = json.dumps(mock_secret_response).encode()
            return mock

        # act
        with patch('urllib.request.urlopen', side_effect=side_effect):
            result = processor.resolve('kv/data/secret/test', 'key')

        # assert
        assert result == 'value'

    def test_init__custom_timeout__stored(self):
        # act
        processor = VaultProcessor(
            url='http://vault:8200',
            auth=TokenAuth(token='test'),
            timeout=60,
        )

        # assert
        assert processor.timeout == 60

    def test_resolve__simple_secret__returns_value(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {
            'data': {
                'data': {'password': 'secret123'},
            },
        }

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(
                mock_secret_response
            ).encode()
            result = processor.resolve('secret/data/db', 'password')

        # assert
        assert result == 'secret123'

    def test_resolve__without_key__returns_all_data(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {
            'data': {
                'data': {'user': 'admin', 'pass': 'secret'},
            },
        }

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(
                mock_secret_response
            ).encode()
            result = processor.resolve('secret/data/db')

        # assert
        assert result == {'user': 'admin', 'pass': 'secret'}

    def test_resolve__secret_not_found__raises_error_with_details(self):
        # arrange
        processor = create_processor_with_mock_auth()

        def side_effect(*args, **kwargs):
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
            if 'lookup-self' in url:
                mock = MagicMock()
                mock.__enter__.return_value.read.return_value = json.dumps({'data': {'ttl': 3600}}).encode()
                return mock
            raise create_http_error(HTTPStatus.NOT_FOUND, body=b'{"errors": []}')

        # act & assert
        with patch('urllib.request.urlopen', side_effect=side_effect):
            with pytest.raises(SecretNotFoundError, match='Secret not found: secret/data/nonexistent'):
                processor.resolve('secret/data/nonexistent', 'key')

    def test_resolve__key_not_found__raises_error(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {
            'data': {
                'data': {'other_key': 'value'},
            },
        }

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(
                mock_secret_response
            ).encode()
            with pytest.raises(SecretNotFoundError, match="Key 'password' not found"):
                processor.resolve('secret/data/db', 'password')

    def test_resolve__path_with_trailing_slash__normalized(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {'data': {'data': {'password': 'secret123'}}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(
                mock_secret_response
            ).encode()
            result = processor.resolve('secret/data/database/', 'password')

            # assert
            call_args = mock_urlopen.call_args[0][0]
            assert '/v1/secret/data/database' in call_args.full_url
            assert '/v1/secret/data/database/' not in call_args.full_url
            assert result == 'secret123'

    def test_resolve__path_with_leading_slash__normalized(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {'data': {'data': {'password': 'secret123'}}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(
                mock_secret_response
            ).encode()
            result = processor.resolve('/secret/data/database', 'password')

            # assert
            call_args = mock_urlopen.call_args[0][0]
            assert '/v1/secret/data/database' in call_args.full_url
            assert '//secret' not in call_args.full_url
            assert result == 'secret123'

    def test_resolve__path_with_both_slashes__normalized(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {'data': {'data': {'password': 'secret123'}}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(
                mock_secret_response
            ).encode()
            result = processor.resolve('/secret/data/database/', 'password')

            # assert
            call_args = mock_urlopen.call_args[0][0]
            assert '/v1/secret/data/database' in call_args.full_url
            assert result == 'secret123'

    def test_resolve__caching_enabled__returns_cached_value(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {
            'data': {'data': {'password': 'secret'}},
        }
        call_count = 0

        def mock_urlopen_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            mock = MagicMock()
            mock.__enter__.return_value.read.return_value = json.dumps(mock_secret_response).encode()
            return mock

        # act
        with patch('urllib.request.urlopen', side_effect=mock_urlopen_side_effect):
            with processor.caching():
                processor.resolve('secret/data/db', 'password')
                processor.resolve('secret/data/db', 'password')
                processor.resolve('secret/data/db', 'password')

        # assert (1 auth call + 1 secret call = 2, not 4)
        assert call_count == 2

    def test_auth_fallback__first_fails_second_succeeds__uses_second(self):
        # arrange
        failing_auth = TokenAuth(token='')
        succeeding_auth = TokenAuth(token='valid_token')
        processor = VaultProcessor(
            url='http://vault:8200',
            auth=[failing_auth, succeeding_auth],
        )

        mock_token_response = {'data': {'ttl': 3600}}
        mock_secret_response = {'data': {'data': {'key': 'value'}}}

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.return_value.__enter__.return_value.read.return_value = json.dumps(
                mock_token_response
            ).encode()

            # first call is token lookup, second is secret fetch
            def side_effect(*args, **kwargs):
                mock = MagicMock()
                # determine if this is auth or secret request
                url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
                if 'lookup-self' in url:
                    mock.__enter__.return_value.read.return_value = json.dumps(mock_token_response).encode()
                else:
                    mock.__enter__.return_value.read.return_value = json.dumps(mock_secret_response).encode()
                return mock

            mock_urlopen.side_effect = side_effect
            result = processor.resolve('secret/data/test', 'key')

        # assert
        assert result == 'value'

    def test_auth_fallback__all_fail__raises_no_valid_auth_error(self):
        # arrange
        processor = VaultProcessor(
            url='http://vault:8200',
            auth=[
                TokenAuth(token=''),
                AppRoleAuth(role_id='', secret_id=''),
            ],
        )

        # act & assert
        with pytest.raises(NoValidAuthError, match='All authentication methods failed'):
            processor.resolve('secret/data/test', 'key')

    def test_auth__empty_list__raises_authentication_error(self):
        # arrange
        processor = VaultProcessor(url='http://vault:8200', auth=[])

        # act & assert
        with pytest.raises(AuthenticationError, match='No authentication methods provided'):
            processor.resolve('secret/data/test', 'key')

    def test_token_caching__reuses_token_within_ttl(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {'data': {'data': {'key': 'value'}}}
        auth_call_count = 0

        def side_effect(*args, **kwargs):
            nonlocal auth_call_count
            mock = MagicMock()
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
            if 'lookup-self' in url:
                auth_call_count += 1
                mock.__enter__.return_value.read.return_value = json.dumps({'data': {'ttl': 3600}}).encode()
            else:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_secret_response).encode()
            return mock

        # act
        with patch('urllib.request.urlopen', side_effect=side_effect):
            processor.resolve('secret/data/a', 'key')
            processor.resolve('secret/data/b', 'key')
            processor.resolve('secret/data/c', 'key')

        # assert (only 1 auth call, not 3)
        assert auth_call_count == 1

    def test_verify_false__disables_ssl_verification(self):
        # arrange
        processor = VaultProcessor(
            url='https://vault:8200',
            auth=TokenAuth(token='test'),
            verify=False,
        )

        # assert
        assert processor._ssl_context is not None
        assert processor._ssl_context.verify_mode == ssl.CERT_NONE
        assert processor._ssl_context.check_hostname is False

    def test_verify_custom_ca__uses_ca_bundle(self):
        # arrange
        mock_context = MagicMock(spec=ssl.SSLContext)

        # act
        with patch('ssl.create_default_context', return_value=mock_context) as mock_create:
            processor = VaultProcessor(
                url='https://vault:8200',
                auth=TokenAuth(token='test'),
                verify='/path/to/ca.crt',
            )

            # assert
            mock_create.assert_called_once_with(cafile='/path/to/ca.crt')
            assert processor._ssl_context is mock_context

    def test_verify_nonexistent_ca__raises_error(self):
        # act & assert
        with pytest.raises(FileNotFoundError, match='CA bundle file not found'):
            VaultProcessor(
                url='https://vault:8200',
                auth=TokenAuth(token='test'),
                verify='/nonexistent/ca.crt',
            )

    def test_ssl_context_passed_to_urlopen(self):
        # arrange
        processor = VaultProcessor(
            url='https://vault:8200',
            auth=TokenAuth(token='test'),
            verify=False,
        )
        mock_response = {'data': {'data': {'key': 'value'}}}

        def side_effect(*args, **kwargs):
            mock = MagicMock()
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
            if 'lookup-self' in url:
                mock.__enter__.return_value.read.return_value = json.dumps({'data': {'ttl': 3600}}).encode()
            else:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_response).encode()
            return mock

        # act
        with patch('urllib.request.urlopen', side_effect=side_effect) as mock_urlopen:
            processor.resolve('secret/data/test', 'key')

            # assert
            for call in mock_urlopen.call_args_list:
                assert call.kwargs.get('context') is processor._ssl_context

    def test_resolve__token_expired_during_request__retries_with_new_token(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_token_response = {'data': {'ttl': 3600}}
        mock_secret_response = {'data': {'data': {'key': 'value'}}}
        request_count = 0

        def side_effect(*args, **kwargs):
            nonlocal request_count
            request_count += 1
            mock = MagicMock()
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])

            if 'lookup-self' in url:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_token_response).encode()
            elif '/data/' in url:
                # first request fails with auth error, second succeeds
                if request_count == 2:
                    raise create_http_error(HTTPStatus.UNAUTHORIZED)
                mock.__enter__.return_value.read.return_value = json.dumps(mock_secret_response).encode()
            return mock

        # act
        with patch('urllib.request.urlopen', side_effect=side_effect):
            result = processor.resolve('secret/data/test', 'key')

        # assert
        assert result == 'value'
        # should have made: 1 token lookup, 1 failed secret request, 1 token re-auth, 1 successful secret request
        assert request_count == 4

    def test_resolve__multiple_auth_methods__tries_in_order(self):
        # arrange
        auth1 = AppRoleAuth(role_id='', secret_id='')  # will fail
        auth2 = TokenAuth(token='')  # will fail
        auth3 = TokenAuth(token='valid')  # will succeed

        processor = VaultProcessor(
            url='http://vault:8200',
            auth=[auth1, auth2, auth3],
        )

        mock_token_response = {'data': {'ttl': 3600}}
        mock_secret_response = {'data': {'data': {'key': 'value'}}}

        def side_effect(*args, **kwargs):
            mock = MagicMock()
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
            if 'lookup-self' in url:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_token_response).encode()
            else:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_secret_response).encode()
            return mock

        # act
        with patch('urllib.request.urlopen', side_effect=side_effect):
            result = processor.resolve('secret/data/test', 'key')

        # assert
        assert result == 'value'

    def test_caching__outside_context__does_not_cache(self):
        # arrange
        processor = create_processor_with_mock_auth()
        mock_secret_response = {'data': {'data': {'password': 'secret'}}}
        call_count = 0

        def mock_urlopen_side_effect(*args, **kwargs):
            nonlocal call_count
            mock = MagicMock()
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
            if '/data/' in url:
                call_count += 1
            if 'lookup-self' in url:
                mock.__enter__.return_value.read.return_value = json.dumps({'data': {'ttl': 3600}}).encode()
            else:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_secret_response).encode()
            return mock

        # act (without caching context)
        with patch('urllib.request.urlopen', side_effect=mock_urlopen_side_effect):
            processor.resolve('secret/data/db', 'password')
            processor.resolve('secret/data/db', 'password')
            processor.resolve('secret/data/db', 'password')

        # assert (each call fetches secret)
        assert call_count == 3

    def test_resolve__forbidden__raises_access_denied_error(self):
        # arrange
        processor = create_processor_with_mock_auth()

        def side_effect(*args, **kwargs):
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
            if 'lookup-self' in url:
                mock = MagicMock()
                mock.__enter__.return_value.read.return_value = json.dumps({'data': {'ttl': 3600}}).encode()
                return mock
            raise create_http_error(
                HTTPStatus.FORBIDDEN,
                body=b'{"errors": ["1 error occurred: permission denied"]}',
            )

        # act & assert
        with patch('urllib.request.urlopen', side_effect=side_effect):
            with pytest.raises(
                AccessDeniedError,
                match='Access denied for secret/data/test: 1 error occurred: permission denied',
            ):
                processor.resolve('secret/data/test', 'key')

    @patch('justconf.processor.vault.time.sleep')
    def test_auth_fallback__first_method_gets_server_error__falls_back_to_second(self, _mock_sleep):
        # arrange
        mock_secret_response = {'data': {'data': {'key': 'value'}}}
        mock_token_response = {'data': {'ttl': 3600}}

        def side_effect(*args, **kwargs):
            url = args[0].full_url if hasattr(args[0], 'full_url') else str(args[0])
            if '/auth/approle/login' in url:
                raise create_http_error(
                    HTTPStatus.INTERNAL_SERVER_ERROR,
                    body=b'{"errors": ["internal error"]}',
                )
            mock = MagicMock()
            if 'lookup-self' in url:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_token_response).encode()
            else:
                mock.__enter__.return_value.read.return_value = json.dumps(mock_secret_response).encode()
            return mock

        processor = VaultProcessor(
            url='http://vault:8200',
            auth=[
                AppRoleAuth(role_id='role', secret_id='secret'),
                TokenAuth(token='valid_token'),
            ],
        )

        # act
        with patch('urllib.request.urlopen', side_effect=side_effect):
            result = processor.resolve('secret/data/test', 'key')

        # assert
        assert result == 'value'

    def test_name_property__returns_vault(self):
        # arrange
        processor = VaultProcessor(
            url='http://vault:8200',
            auth=TokenAuth(token='test'),
        )

        # assert
        assert processor.name == 'vault'


class TestUrlOpenWithRetry:
    @patch('justconf.processor.vault.time.sleep')
    def test_success_on_first_attempt(self, _mock_sleep):
        # arrange
        mock_resp = MagicMock()

        # act
        with patch('urllib.request.urlopen', return_value=mock_resp) as mock_urlopen:
            req = urllib.request.Request('http://vault:8200/v1/test')
            result = _urlopen_with_retry(req)

        # assert
        assert result is mock_resp
        mock_urlopen.assert_called_once()

    @pytest.mark.parametrize('status_code', [500, 502, 503])
    @patch('justconf.processor.vault.time.sleep')
    def test_retry_on_retryable_status__success_on_next_attempt(self, mock_sleep, status_code):
        # arrange
        mock_resp = MagicMock()

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = [create_http_error(status_code), mock_resp]
            req = urllib.request.Request('http://vault:8200/v1/test')
            result = _urlopen_with_retry(req, retries=1)

        # assert
        assert result is mock_resp
        assert mock_urlopen.call_count == 2
        mock_sleep.assert_called_once_with(0.5)

    @pytest.mark.parametrize('status_code', [404, 403])
    @patch('justconf.processor.vault.time.sleep')
    def test_no_retry_on_non_retryable_status(self, mock_sleep, status_code):
        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = create_http_error(status_code)
            req = urllib.request.Request('http://vault:8200/v1/test')
            with pytest.raises(HTTPError) as exc_info:
                _urlopen_with_retry(req, retries=3)

        assert exc_info.value.code == status_code
        mock_urlopen.assert_called_once()
        mock_sleep.assert_not_called()

    @patch('justconf.processor.vault.time.sleep')
    def test_retry_on_url_error__success_on_next_attempt(self, mock_sleep):
        # arrange
        mock_resp = MagicMock()

        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = [URLError('connection refused'), mock_resp]
            req = urllib.request.Request('http://vault:8200/v1/test')
            result = _urlopen_with_retry(req, retries=1)

        # assert
        assert result is mock_resp
        assert mock_urlopen.call_count == 2
        mock_sleep.assert_called_once_with(0.5)

    @patch('justconf.processor.vault.time.sleep')
    def test_all_attempts_exhausted__raises_last_error(self, mock_sleep):
        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = [
                create_http_error(HTTPStatus.INTERNAL_SERVER_ERROR),
                create_http_error(HTTPStatus.INTERNAL_SERVER_ERROR),
                create_http_error(HTTPStatus.INTERNAL_SERVER_ERROR),
                create_http_error(HTTPStatus.INTERNAL_SERVER_ERROR),
            ]
            req = urllib.request.Request('http://vault:8200/v1/test')
            with pytest.raises(HTTPError):
                _urlopen_with_retry(req, retries=3)

        # 1 initial + 3 retries = 4
        assert mock_urlopen.call_count == 4
        assert mock_sleep.call_count == 3

    @patch('justconf.processor.vault.time.sleep')
    def test_retries_zero__no_retry(self, mock_sleep):
        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = create_http_error(HTTPStatus.INTERNAL_SERVER_ERROR)
            req = urllib.request.Request('http://vault:8200/v1/test')
            with pytest.raises(HTTPError):
                _urlopen_with_retry(req, retries=0)

        mock_urlopen.assert_called_once()
        mock_sleep.assert_not_called()

    @patch('justconf.processor.vault.time.sleep')
    def test_backoff_delays(self, mock_sleep):
        # act
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = [
                create_http_error(HTTPStatus.INTERNAL_SERVER_ERROR),
                create_http_error(HTTPStatus.BAD_GATEWAY),
                create_http_error(HTTPStatus.SERVICE_UNAVAILABLE),
                create_http_error(HTTPStatus.INTERNAL_SERVER_ERROR),
            ]
            req = urllib.request.Request('http://vault:8200/v1/test')
            with pytest.raises(HTTPError):
                _urlopen_with_retry(req, retries=3, backoff_factor=0.5)

        # assert: delays are 0.5*2^0=0.5, 0.5*2^1=1.0, 0.5*2^2=2.0
        assert mock_sleep.call_args_list == [
            ((0.5,),),
            ((1.0,),),
            ((2.0,),),
        ]


class TestCreateSslContext:
    def test_verify_true__returns_none(self):
        # act
        result = _create_ssl_context(True)

        # assert
        assert result is None

    def test_verify_false__returns_context_with_disabled_verification(self):
        # act
        result = _create_ssl_context(False)

        # assert
        assert isinstance(result, ssl.SSLContext)
        assert result.verify_mode == ssl.CERT_NONE
        assert result.check_hostname is False

    def test_verify_path__returns_context_with_custom_ca(self):
        # arrange
        mock_context = MagicMock(spec=ssl.SSLContext)

        # act
        with patch('ssl.create_default_context', return_value=mock_context) as mock_create:
            result = _create_ssl_context('/path/to/ca.crt')

        # assert
        mock_create.assert_called_once_with(cafile='/path/to/ca.crt')
        assert result is mock_context

    def test_verify_nonexistent_path__raises_file_not_found(self):
        # act & assert
        with pytest.raises(FileNotFoundError, match='CA bundle file not found'):
            _create_ssl_context('/nonexistent/ca.crt')


class TestDetectTokenAuth:
    def test_token_present__returns_token_auth(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_TOKEN', 'hvs.test_token')

        # act
        result = _detect_token_auth()

        # assert
        assert isinstance(result, TokenAuth)
        assert result.token == 'hvs.test_token'

    def test_token_absent__returns_none(self, monkeypatch):
        # arrange
        monkeypatch.delenv('VAULT_TOKEN', raising=False)

        # act
        result = _detect_token_auth()

        # assert
        assert result is None


class TestDetectAppRoleAuth:
    def test_both_credentials_present__returns_approle_auth(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_ROLE_ID', 'role123')
        monkeypatch.setenv('VAULT_SECRET_ID', 'secret456')

        # act
        result = _detect_approle_auth()

        # assert
        assert isinstance(result, AppRoleAuth)
        assert result.role_id == 'role123'
        assert result.secret_id == 'secret456'
        assert result.mount_path == 'approle'

    def test_custom_mount_path__uses_env_value(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_ROLE_ID', 'role123')
        monkeypatch.setenv('VAULT_SECRET_ID', 'secret456')
        monkeypatch.setenv('VAULT_APPROLE_MOUNT_PATH', 'custom-approle')

        # act
        result = _detect_approle_auth()

        # assert
        assert result.mount_path == 'custom-approle'

    def test_only_role_id__returns_none(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_ROLE_ID', 'role123')
        monkeypatch.delenv('VAULT_SECRET_ID', raising=False)

        # act
        result = _detect_approle_auth()

        # assert
        assert result is None

    def test_only_secret_id__returns_none(self, monkeypatch):
        # arrange
        monkeypatch.delenv('VAULT_ROLE_ID', raising=False)
        monkeypatch.setenv('VAULT_SECRET_ID', 'secret456')

        # act
        result = _detect_approle_auth()

        # assert
        assert result is None


class TestDetectKubernetesAuth:
    def test_role_present__returns_kubernetes_auth(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_KUBERNETES_ROLE', 'myapp')

        # act
        result = _detect_kubernetes_auth()

        # assert
        assert isinstance(result, KubernetesAuth)
        assert result.role == 'myapp'
        assert result.mount_path == 'kubernetes'

    def test_custom_mount_path__uses_env_value(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_KUBERNETES_ROLE', 'myapp')
        monkeypatch.setenv('VAULT_KUBERNETES_MOUNT_PATH', 'k8s-cluster')

        # act
        result = _detect_kubernetes_auth()

        # assert
        assert result.mount_path == 'k8s-cluster'

    def test_no_role__returns_none(self, monkeypatch):
        # arrange
        monkeypatch.delenv('VAULT_KUBERNETES_ROLE', raising=False)

        # act
        result = _detect_kubernetes_auth()

        # assert
        assert result is None


class TestDetectJwtAuth:
    def test_both_credentials_present__returns_jwt_auth(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_JWT_ROLE', 'myproject')
        monkeypatch.setenv('VAULT_JWT_TOKEN', 'eyJ...')

        # act
        result = _detect_jwt_auth()

        # assert
        assert isinstance(result, JwtAuth)
        assert result.role == 'myproject'
        assert result.jwt == 'eyJ...'
        assert result.mount_path == 'jwt'

    def test_custom_mount_path__uses_env_value(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_JWT_ROLE', 'myproject')
        monkeypatch.setenv('VAULT_JWT_TOKEN', 'eyJ...')
        monkeypatch.setenv('VAULT_JWT_MOUNT_PATH', 'oidc')

        # act
        result = _detect_jwt_auth()

        # assert
        assert result.mount_path == 'oidc'

    def test_only_role__returns_none(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_JWT_ROLE', 'myproject')
        monkeypatch.delenv('VAULT_JWT_TOKEN', raising=False)

        # act
        result = _detect_jwt_auth()

        # assert
        assert result is None

    def test_only_jwt__returns_none(self, monkeypatch):
        # arrange
        monkeypatch.delenv('VAULT_JWT_ROLE', raising=False)
        monkeypatch.setenv('VAULT_JWT_TOKEN', 'eyJ...')

        # act
        result = _detect_jwt_auth()

        # assert
        assert result is None


class TestDetectUserpassAuth:
    def test_both_credentials_present__returns_userpass_auth(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_USERNAME', 'admin')
        monkeypatch.setenv('VAULT_PASSWORD', 'secret')

        # act
        result = _detect_userpass_auth()

        # assert
        assert isinstance(result, UserpassAuth)
        assert result.username == 'admin'
        assert result.password == 'secret'
        assert result.mount_path == 'userpass'

    def test_custom_mount_path__uses_env_value(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_USERNAME', 'admin')
        monkeypatch.setenv('VAULT_PASSWORD', 'secret')
        monkeypatch.setenv('VAULT_USERPASS_MOUNT_PATH', 'ldap')

        # act
        result = _detect_userpass_auth()

        # assert
        assert result.mount_path == 'ldap'

    def test_only_username__returns_none(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_USERNAME', 'admin')
        monkeypatch.delenv('VAULT_PASSWORD', raising=False)

        # act
        result = _detect_userpass_auth()

        # assert
        assert result is None

    def test_only_password__returns_none(self, monkeypatch):
        # arrange
        monkeypatch.delenv('VAULT_USERNAME', raising=False)
        monkeypatch.setenv('VAULT_PASSWORD', 'secret')

        # act
        result = _detect_userpass_auth()

        # assert
        assert result is None


class TestVaultAuthFromEnv:
    def test_no_credentials__returns_empty_list(self, monkeypatch):
        # arrange
        monkeypatch.delenv('VAULT_TOKEN', raising=False)
        monkeypatch.delenv('VAULT_ROLE_ID', raising=False)
        monkeypatch.delenv('VAULT_SECRET_ID', raising=False)
        monkeypatch.delenv('VAULT_KUBERNETES_ROLE', raising=False)
        monkeypatch.delenv('VAULT_JWT_ROLE', raising=False)
        monkeypatch.delenv('VAULT_JWT_TOKEN', raising=False)
        monkeypatch.delenv('VAULT_USERNAME', raising=False)
        monkeypatch.delenv('VAULT_PASSWORD', raising=False)

        # act
        result = vault_auth_from_env()

        # assert
        assert result == []

    def test_multiple_credentials__returns_sorted_by_priority(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_USERNAME', 'admin')
        monkeypatch.setenv('VAULT_PASSWORD', 'secret')
        monkeypatch.setenv('VAULT_TOKEN', 'hvs.xxx')
        monkeypatch.delenv('VAULT_ROLE_ID', raising=False)
        monkeypatch.delenv('VAULT_SECRET_ID', raising=False)
        monkeypatch.delenv('VAULT_KUBERNETES_ROLE', raising=False)
        monkeypatch.delenv('VAULT_JWT_ROLE', raising=False)
        monkeypatch.delenv('VAULT_JWT_TOKEN', raising=False)

        # act
        result = vault_auth_from_env()

        # assert
        assert len(result) == 2
        assert isinstance(result[0], TokenAuth)
        assert isinstance(result[1], UserpassAuth)

    def test_method_token__only_checks_token(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_TOKEN', 'hvs.xxx')
        monkeypatch.setenv('VAULT_USERNAME', 'admin')
        monkeypatch.setenv('VAULT_PASSWORD', 'secret')

        # act
        result = vault_auth_from_env(method='token')

        # assert
        assert len(result) == 1
        assert isinstance(result[0], TokenAuth)

    def test_method_approle__only_checks_approle(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_TOKEN', 'hvs.xxx')
        monkeypatch.setenv('VAULT_ROLE_ID', 'role123')
        monkeypatch.setenv('VAULT_SECRET_ID', 'secret456')

        # act
        result = vault_auth_from_env(method='approle')

        # assert
        assert len(result) == 1
        assert isinstance(result[0], AppRoleAuth)

    def test_method_not_found__returns_empty_list(self, monkeypatch):
        # arrange
        monkeypatch.delenv('VAULT_TOKEN', raising=False)

        # act
        result = vault_auth_from_env(method='token')

        # assert
        assert result == []

    def test_all_methods_available__returns_in_priority_order(self, monkeypatch):
        # arrange
        monkeypatch.setenv('VAULT_TOKEN', 'hvs.xxx')
        monkeypatch.setenv('VAULT_ROLE_ID', 'role123')
        monkeypatch.setenv('VAULT_SECRET_ID', 'secret456')
        monkeypatch.setenv('VAULT_KUBERNETES_ROLE', 'myapp')
        monkeypatch.setenv('VAULT_JWT_ROLE', 'myproject')
        monkeypatch.setenv('VAULT_JWT_TOKEN', 'eyJ...')
        monkeypatch.setenv('VAULT_USERNAME', 'admin')
        monkeypatch.setenv('VAULT_PASSWORD', 'secret')

        # act
        result = vault_auth_from_env()

        # assert (order: approle, kubernetes, token, jwt, userpass)
        assert len(result) == 5
        assert isinstance(result[0], AppRoleAuth)
        assert isinstance(result[1], KubernetesAuth)
        assert isinstance(result[2], TokenAuth)
        assert isinstance(result[3], JwtAuth)
        assert isinstance(result[4], UserpassAuth)


class TestExtractVaultErrors:
    def test_extract_vault_errors__vault_json_with_errors__returns_list(self):
        # arrange
        error = create_http_error(HTTPStatus.BAD_REQUEST, body=b'{"errors": ["missing client token"]}')

        # act
        result = _extract_vault_errors(error)

        # assert
        assert result == ['missing client token']

    def test_extract_vault_errors__multiple_errors__returns_list(self):
        # arrange
        error = create_http_error(HTTPStatus.BAD_REQUEST, body=b'{"errors": ["error one", "error two"]}')

        # act
        result = _extract_vault_errors(error)

        # assert
        assert result == ['error one', 'error two']

    def test_extract_vault_errors__invalid_json__returns_raw_body(self):
        # arrange
        error = create_http_error(HTTPStatus.BAD_REQUEST, body=b'not json at all')

        # act
        result = _extract_vault_errors(error)

        # assert
        assert result == ['Failed to parse response body']

    def test_extract_vault_errors__unreadable_body__returns_descriptive_message(self):
        # arrange
        error = create_http_error(HTTPStatus.BAD_REQUEST)
        error.read = MagicMock(side_effect=OSError('connection reset'))

        # act
        result = _extract_vault_errors(error)

        # assert
        assert result == ['failed to read response body: connection reset']

    def test_extract_vault_errors__empty_body__returns_descriptive_message(self):
        # arrange
        error = create_http_error(HTTPStatus.BAD_REQUEST)

        # act
        result = _extract_vault_errors(error)

        # assert
        assert result == [f'empty response body (HTTP {HTTPStatus.BAD_REQUEST})']

    def test_extract_vault_errors__json_without_errors_key__returns_empty_list(self):
        # arrange
        body = b'{"message": "something went wrong"}'
        error = create_http_error(HTTPStatus.BAD_REQUEST, body=body)

        # act
        result = _extract_vault_errors(error)

        # assert
        assert result == ['Unable to find error list in response body']


class TestAuthenticationErrorDetail:
    def test_detail__vault_http_error__contains_raw_errors(self):
        # arrange
        auth = TokenAuth(token='invalid')

        # act & assert
        with patch('urllib.request.urlopen') as mock_urlopen:
            mock_urlopen.side_effect = create_http_error(
                HTTPStatus.FORBIDDEN,
                body=b'{"errors": ["permission denied", "token expired"]}',
            )
            with pytest.raises(AuthenticationError) as exc_info:
                auth.authenticate('http://vault:8200')

        # assert
        assert exc_info.value.detail == ['permission denied', 'token expired']

    def test_detail__validation_error__defaults_to_message(self):
        # arrange
        auth = TokenAuth(token='')

        # act & assert
        with pytest.raises(AuthenticationError) as exc_info:
            auth.authenticate('http://vault:8200')

        # assert
        assert exc_info.value.detail == ['Token is empty']


# fixtures and helpers


def create_processor_with_mock_auth():
    return VaultProcessor(
        url='http://vault:8200',
        auth=TokenAuth(token='test_token'),
    )


def create_http_error(status_code, *, body: bytes | None = None):
    fp = io.BytesIO(body) if body is not None else None
    return HTTPError(
        url='http://vault:8200',
        code=status_code,
        msg=str(status_code),
        hdrs={},
        fp=fp,
    )
