"""Unit tests for the dotpromptz.credentials module."""

import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from dotpromptz.credentials import (
    _DEFAULT_CREDENTIALS_URL,
    Credential,
    CredentialPool,
    _get_auth_token,
    _load_from_file,
    _load_from_json,
    _load_from_url,
    _parse_credential_list,
    load_credentials,
)

# ── Credential dataclass ──────────────────────────────────────────────────────


class TestCredential:
    def test_basic_creation(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='sk-123')
        assert cred.name == 'test'
        assert cred.adapter == 'openai'
        assert cred.api_key == 'sk-123'
        assert cred.base_url is None
        assert cred.models is None

    def test_with_base_url(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='sk-123', base_url='https://api.example.com')
        assert cred.base_url == 'https://api.example.com'

    def test_with_models(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='sk-123', models=('gpt-4o', 'gpt-4o-mini'))
        assert cred.models == ('gpt-4o', 'gpt-4o-mini')

    def test_frozen(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='sk-123')
        with pytest.raises(AttributeError):
            cred.name = 'changed'  # type: ignore[misc]


# ── CredentialPool ─────────────────────────────────────────────────────────────


class TestCredentialPool:
    def test_empty_pool(self) -> None:
        pool = CredentialPool([])
        assert len(pool) == 0
        assert not pool

    def test_pool_with_credentials(self) -> None:
        creds = [
            Credential(name='a', adapter='openai', api_key='sk-a'),
            Credential(name='b', adapter='openai', api_key='sk-b'),
        ]
        pool = CredentialPool(creds)
        assert len(pool) == 2
        assert pool

    def test_credentials_property_is_copy(self) -> None:
        creds = [Credential(name='a', adapter='openai', api_key='sk-a')]
        pool = CredentialPool(creds)
        copy = pool.credentials
        copy.append(Credential(name='b', adapter='openai', api_key='sk-b'))
        assert len(pool) == 1  # original unchanged


class TestCredentialPoolFilter:
    def _pool(self) -> CredentialPool:
        return CredentialPool([
            Credential(name='east', adapter='openai', api_key='sk-e'),
            Credential(name='west', adapter='openai', api_key='sk-w'),
            Credential(name='main', adapter='anthropic', api_key='sk-a'),
            Credential(name='ds', adapter='openai', api_key='sk-ds', models=('deepseek-chat',)),
        ])

    def test_filter_by_adapter(self) -> None:
        result = self._pool().filter('openai')
        assert len(result) == 3

    def test_filter_by_group(self) -> None:
        result = self._pool().filter('openai', group='east')
        assert len(result) == 1
        assert result[0].name == 'east'

    def test_filter_by_groups(self) -> None:
        result = self._pool().filter('openai', groups=['east', 'west'])
        assert len(result) == 2

    def test_filter_by_model_matches_wildcard(self) -> None:
        # Credentials without models match any model
        result = self._pool().filter('openai', model='gpt-4o')
        assert len(result) == 2  # east and west (no models restriction)

    def test_filter_by_model_matches_specific(self) -> None:
        result = self._pool().filter('openai', model='deepseek-chat')
        assert len(result) == 3  # east, west (wildcard) + ds (specific match)

    def test_filter_by_model_excludes_non_matching(self) -> None:
        result = self._pool().filter('openai', model='gpt-4o')
        names = {c.name for c in result}
        assert 'ds' not in names  # ds only matches deepseek-chat

    def test_filter_no_match(self) -> None:
        result = self._pool().filter('google')
        assert result == []


class TestCredentialPoolNext:
    def _pool(self) -> CredentialPool:
        return CredentialPool([
            Credential(name='a', adapter='openai', api_key='sk-a'),
            Credential(name='b', adapter='openai', api_key='sk-b'),
        ])

    def test_round_robin(self) -> None:
        pool = self._pool()
        first = pool.next('openai')
        second = pool.next('openai')
        third = pool.next('openai')
        assert first.api_key == 'sk-a'
        assert second.api_key == 'sk-b'
        assert third.api_key == 'sk-a'  # wraps around

    def test_no_match_raises(self) -> None:
        pool = self._pool()
        with pytest.raises(ValueError, match='No credentials found'):
            pool.next('anthropic')

    def test_model_filter_in_next(self) -> None:
        pool = CredentialPool([
            Credential(name='general', adapter='openai', api_key='sk-g'),
            Credential(name='specific', adapter='openai', api_key='sk-s', models=('gpt-4o',)),
        ])
        # Both match gpt-4o
        cred1 = pool.next('openai', model='gpt-4o')
        cred2 = pool.next('openai', model='gpt-4o')
        assert {cred1.api_key, cred2.api_key} == {'sk-g', 'sk-s'}

        # Only general matches gpt-3.5
        cred = pool.next('openai', model='gpt-3.5')
        assert cred.api_key == 'sk-g'


# ── _parse_credential_list ─────────────────────────────────────────────────────


class TestParseCredentialList:
    def test_valid_entries(self) -> None:
        raw = [
            {'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a'},
            {'name': 'b', 'adapter': 'openai', 'api_key': 'sk-b', 'base_url': 'https://x.com'},
        ]
        result = _parse_credential_list(raw)
        assert len(result) == 2
        assert result[1].base_url == 'https://x.com'

    def test_with_models(self) -> None:
        raw = [{'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a', 'models': ['gpt-4o', 'gpt-4o-mini']}]
        result = _parse_credential_list(raw)
        assert result[0].models == ('gpt-4o', 'gpt-4o-mini')

    def test_missing_fields_raises(self) -> None:
        with pytest.raises(ValueError, match='missing required fields'):
            _parse_credential_list([{'name': 'a'}])

    def test_empty_list(self) -> None:
        result = _parse_credential_list([])
        assert result == []


# ── _get_auth_token ────────────────────────────────────────────────────────────


class TestGetAuthToken:
    def test_env_var_takes_priority(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv('API_CREDENTIALS_TOKEN', 'env-token-123')
        assert _get_auth_token() == 'env-token-123'

    def test_env_var_whitespace_stripped(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv('API_CREDENTIALS_TOKEN', '  env-token  ')
        assert _get_auth_token() == 'env-token'

    def test_gh_auth_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv('API_CREDENTIALS_TOKEN', raising=False)
        mock_result = MagicMock()
        mock_result.returncode = 0
        mock_result.stdout = 'gh-token-456\n'
        with patch('dotpromptz.credentials.subprocess.run', return_value=mock_result) as mock_run:
            token = _get_auth_token()
            assert token == 'gh-token-456'
            mock_run.assert_called_once_with(
                ['gh', 'auth', 'token'],
                capture_output=True,
                text=True,
                timeout=5,
            )

    def test_gh_auth_not_installed(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv('API_CREDENTIALS_TOKEN', raising=False)
        with patch('dotpromptz.credentials.subprocess.run', side_effect=FileNotFoundError):
            assert _get_auth_token() == ''

    def test_gh_auth_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv('API_CREDENTIALS_TOKEN', raising=False)
        with patch('dotpromptz.credentials.subprocess.run', side_effect=subprocess.TimeoutExpired('gh', 5)):
            assert _get_auth_token() == ''

    def test_gh_auth_nonzero_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv('API_CREDENTIALS_TOKEN', raising=False)
        mock_result = MagicMock()
        mock_result.returncode = 1
        mock_result.stdout = ''
        with patch('dotpromptz.credentials.subprocess.run', return_value=mock_result):
            assert _get_auth_token() == ''

    def test_gh_auth_empty_output(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv('API_CREDENTIALS_TOKEN', raising=False)
        mock_result = MagicMock()
        mock_result.returncode = 0
        mock_result.stdout = '  \n'
        with patch('dotpromptz.credentials.subprocess.run', return_value=mock_result):
            assert _get_auth_token() == ''

    def test_env_var_empty_falls_through(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv('API_CREDENTIALS_TOKEN', '')
        mock_result = MagicMock()
        mock_result.returncode = 0
        mock_result.stdout = 'gh-token\n'
        with patch('dotpromptz.credentials.subprocess.run', return_value=mock_result):
            assert _get_auth_token() == 'gh-token'


# ── load_credentials ───────────────────────────────────────────────────────────


class TestLoadCredentials:
    def test_inline_json(self, monkeypatch: pytest.MonkeyPatch) -> None:
        data = [{'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a'}]
        monkeypatch.setenv('API_CREDENTIALS', json.dumps(data))
        pool = load_credentials()
        assert len(pool) == 1
        assert pool.credentials[0].api_key == 'sk-a'

    def test_at_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
        data = [{'name': 'b', 'adapter': 'openai', 'api_key': 'sk-b'}]
        f = tmp_path / 'creds.json'
        f.write_text(json.dumps(data), encoding='utf-8')
        monkeypatch.setenv('API_CREDENTIALS', f'@{f}')
        pool = load_credentials()
        assert len(pool) == 1
        assert pool.credentials[0].api_key == 'sk-b'

    def test_at_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
        data = [{'name': 'c', 'adapter': 'openai', 'api_key': 'sk-c'}]
        monkeypatch.setenv('API_CREDENTIALS', '@https://example.com/creds.json')
        with patch('dotpromptz.credentials._load_from_url') as mock_url:
            mock_url.return_value = CredentialPool([Credential(**data[0])])
            pool = load_credentials()
            assert len(pool) == 1
            mock_url.assert_called_once_with('https://example.com/creds.json')

    def test_unset_loads_default_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv('API_CREDENTIALS', raising=False)
        with patch('dotpromptz.credentials._load_from_url') as mock_url:
            mock_url.return_value = CredentialPool([])
            load_credentials()
            mock_url.assert_called_once_with(_DEFAULT_CREDENTIALS_URL)

    def test_empty_string_loads_default_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv('API_CREDENTIALS', '   ')
        with patch('dotpromptz.credentials._load_from_url') as mock_url:
            mock_url.return_value = CredentialPool([])
            load_credentials()
            mock_url.assert_called_once_with(_DEFAULT_CREDENTIALS_URL)

    def test_invalid_json_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv('API_CREDENTIALS', '{not valid json}')
        with pytest.raises(ValueError, match='not valid JSON'):
            load_credentials()

    def test_json_not_array_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv('API_CREDENTIALS', '{"key": "value"}')
        with pytest.raises(ValueError, match='must be a JSON array'):
            load_credentials()


# ── _load_from_json ────────────────────────────────────────────────────────────


class TestLoadFromJson:
    def test_valid(self) -> None:
        data = [{'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a'}]
        pool = _load_from_json(json.dumps(data))
        assert len(pool) == 1

    def test_invalid_json(self) -> None:
        with pytest.raises(ValueError, match='not valid JSON'):
            _load_from_json('nope')

    def test_not_array(self) -> None:
        with pytest.raises(ValueError, match='must be a JSON array'):
            _load_from_json('{"a": 1}')


# ── _load_from_file ────────────────────────────────────────────────────────────


class TestLoadFromFile:
    def test_json_file(self, tmp_path: Path) -> None:
        data = [{'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a'}]
        f = tmp_path / 'creds.json'
        f.write_text(json.dumps(data), encoding='utf-8')
        pool = _load_from_file(str(f))
        assert len(pool) == 1

    def test_yaml_file(self, tmp_path: Path) -> None:
        yaml_content = '- name: a\n  adapter: openai\n  api_key: sk-a\n'
        f = tmp_path / 'creds.yaml'
        f.write_text(yaml_content, encoding='utf-8')
        pool = _load_from_file(str(f))
        assert len(pool) == 1

    def test_file_not_found(self) -> None:
        with pytest.raises(ValueError, match='file not found'):
            _load_from_file('/nonexistent/path.json')

    def test_not_array(self, tmp_path: Path) -> None:
        f = tmp_path / 'creds.json'
        f.write_text('{"a": 1}', encoding='utf-8')
        with pytest.raises(ValueError, match='must contain an array'):
            _load_from_file(str(f))


# ── _load_from_url ─────────────────────────────────────────────────────────────


class TestLoadFromUrl:
    def test_json_response(self) -> None:
        data = [{'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a'}]
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps(data).encode('utf-8')
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value=''),
            patch('urllib.request.urlopen', return_value=mock_resp),
        ):
            pool = _load_from_url('https://example.com/creds.json')
            assert len(pool) == 1

    def test_auth_token_sent(self) -> None:
        data = [{'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a'}]
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps(data).encode('utf-8')
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value='my-token'),
            patch('urllib.request.urlopen', return_value=mock_resp) as mock_open,
        ):
            _load_from_url('https://example.com/creds.json')
            # Verify the request had Authorization header
            req = mock_open.call_args[0][0]
            assert req.get_header('Authorization') == 'Bearer my-token'

    def test_http_error(self) -> None:
        import urllib.error

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value=''),
            patch(
                'urllib.request.urlopen',
                side_effect=urllib.error.HTTPError(
                    'https://example.com',
                    404,
                    'Not Found',
                    {},
                    None,  # type: ignore[arg-type]
                ),
            ),
        ):
            with pytest.raises(ValueError, match='HTTP 404'):
                _load_from_url('https://example.com/creds.json')

    def test_not_array(self) -> None:
        mock_resp = MagicMock()
        mock_resp.read.return_value = b'{"a": 1}'
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value=''),
            patch('urllib.request.urlopen', return_value=mock_resp),
        ):
            with pytest.raises(ValueError, match='must be an array'):
                _load_from_url('https://example.com/creds.json')

    def test_timeout_retry_then_success(self) -> None:
        import urllib.error

        data = [{'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a'}]
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps(data).encode('utf-8')
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        timeout_exc = urllib.error.URLError(TimeoutError('timed out'))

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value=''),
            patch('urllib.request.urlopen', side_effect=[timeout_exc, mock_resp]) as mock_open,
            patch('dotpromptz.credentials.time.sleep') as mock_sleep,
        ):
            pool = _load_from_url('https://example.com/creds.json')
            assert len(pool) == 1
            assert mock_open.call_count == 2
            mock_sleep.assert_called_once()

    def test_timeout_retry_exhausted(self) -> None:
        import urllib.error

        timeout_exc = urllib.error.URLError(TimeoutError('timed out'))

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value=''),
            patch('urllib.request.urlopen', side_effect=timeout_exc) as mock_open,
            patch('dotpromptz.credentials.time.sleep') as mock_sleep,
        ):
            with pytest.raises(ValueError, match='after 3 attempts'):
                _load_from_url('https://example.com/creds.json')
            assert mock_open.call_count == 3
            assert mock_sleep.call_count == 2

    def test_ssl_eof_retry_then_success(self) -> None:
        import ssl
        import urllib.error

        data = [{'name': 'a', 'adapter': 'openai', 'api_key': 'sk-a'}]
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps(data).encode('utf-8')
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)

        ssl_eof_exc = urllib.error.URLError(ssl.SSLEOFError(8, 'UNEXPECTED_EOF_WHILE_READING'))

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value=''),
            patch('urllib.request.urlopen', side_effect=[ssl_eof_exc, mock_resp]) as mock_open,
            patch('dotpromptz.credentials.time.sleep') as mock_sleep,
        ):
            pool = _load_from_url('https://example.com/creds.json')
            assert len(pool) == 1
            assert mock_open.call_count == 2
            mock_sleep.assert_called_once()

    def test_ssl_eof_retry_exhausted(self) -> None:
        import ssl
        import urllib.error

        ssl_eof_exc = urllib.error.URLError(ssl.SSLEOFError(8, 'UNEXPECTED_EOF_WHILE_READING'))

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value=''),
            patch('urllib.request.urlopen', side_effect=ssl_eof_exc) as mock_open,
            patch('dotpromptz.credentials.time.sleep') as mock_sleep,
        ):
            with pytest.raises(ValueError, match='after 3 attempts'):
                _load_from_url('https://example.com/creds.json')
            assert mock_open.call_count == 3
            assert mock_sleep.call_count == 2

    def test_404_without_token_on_raw_url_adds_auth_hint(self) -> None:
        import urllib.error

        with (
            patch('dotpromptz.credentials._get_auth_token', return_value=''),
            patch(
                'urllib.request.urlopen',
                side_effect=urllib.error.HTTPError(
                    'https://raw.githubusercontent.com/org/repo/main/credentials.yaml',
                    404,
                    'Not Found',
                    {},
                    None,  # type: ignore[arg-type]
                ),
            ),
        ):
            with pytest.raises(ValueError, match='私有仓库未授权'):
                _load_from_url('https://raw.githubusercontent.com/org/repo/main/credentials.yaml')


# ── _DEFAULT_CREDENTIALS_URL ──────────────────────────────────────────────────


class TestDefaultUrl:
    def test_url_is_correct(self) -> None:
        assert _DEFAULT_CREDENTIALS_URL == (
            'https://raw.githubusercontent.com/my-three-kingdoms/dotpromptz/refs/heads/main/credentials.yaml'
        )
