from __future__ import annotations

import builtins
import importlib.util
import sys
from types import ModuleType, SimpleNamespace
from typing import Any, Literal, cast

from configobj import ConfigObj
import pymysql
import pytest

from mycli import client_connection
from mycli.client_connection import ClientConnectionMixin
from mycli.constants import DEFAULT_CHARSET, EMPTY_PASSWORD_FLAG_SENTINEL, ER_MUST_CHANGE_PASSWORD_LOGIN
from mycli.password_sources import KNOWN_PASSWORD_SOURCES, PasswordCandidates


class DummyLogger:
    def __init__(self) -> None:
        self.debug_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
        self.error_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []

    def debug(self, *args: Any, **kwargs: Any) -> None:
        self.debug_calls.append((args, kwargs))

    def error(self, *args: Any, **kwargs: Any) -> None:
        self.error_calls.append((args, kwargs))


class DummyClient(ClientConnectionMixin):
    def __init__(
        self,
        *,
        cnf: dict[str, Any] | None = None,
        config: dict[str, Any] | None = None,
        config_without_package_defaults: dict[str, Any] | None = None,
    ) -> None:
        self.cnf = cnf or default_cnf()
        self.mylogin_cnf = object()
        self.config = ConfigObj(config) or ConfigObj({
            'main': {
                'password_sources': KNOWN_PASSWORD_SOURCES,
                'keyring_sources': ['prompt', 'literal', 'file', 'environment', 'dsn', 'login_path', 'fallback'],
            },
            'connection': {},
        })
        self.config_without_package_defaults = config_without_package_defaults or {}
        self.keepalive_ticks: int | None = None
        self.sandbox_mode = False
        self.sqlexecute: Any = None
        self.logger = DummyLogger()
        self.echo_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []

    def read_mylogin_cnf(self, cnf: Any) -> dict[str, Any]:
        assert cnf is self.mylogin_cnf
        return dict(self.cnf)

    def echo(self, *args: Any, **kwargs: Any) -> None:
        self.echo_calls.append((args, kwargs))


class WritableConfig(dict[str, Any]):
    encoding: str | None = None

    def __init__(self, value: dict[str, Any]) -> None:
        super().__init__(value)
        self.write_calls = 0

    def write(self) -> None:
        self.write_calls += 1


class FakeSQLExecute:
    calls: list[dict[str, Any]] = []
    effects: list[Any] = []
    sandbox_mode_value = False

    def __init__(self, **kwargs: Any) -> None:
        type(self).calls.append(dict(kwargs))
        if type(self).effects:
            effect = type(self).effects.pop(0)
            if isinstance(effect, BaseException):
                raise effect
            if callable(effect):
                effect(kwargs)
        self.kwargs = kwargs
        self.sandbox_mode = type(self).sandbox_mode_value


def default_cnf() -> dict[str, Any]:
    return {
        'user': None,
        'password': None,
        'host': None,
        'port': None,
        'socket': None,
    }


@pytest.fixture(autouse=True)
def reset_fake_sql_execute(monkeypatch: pytest.MonkeyPatch) -> None:
    FakeSQLExecute.calls = []
    FakeSQLExecute.effects = []
    FakeSQLExecute.sandbox_mode_value = False
    monkeypatch.setattr(client_connection, 'SQLExecute', FakeSQLExecute)
    monkeypatch.setattr(client_connection, 'WIN', False)
    monkeypatch.setattr(client_connection, 'guess_socket_location', lambda: None)


def op_error(code: int, message: str = 'error') -> pymysql.OperationalError:
    return pymysql.OperationalError(code, message)


def load_without_pwd_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
    assert client_connection.__file__ is not None
    module_name = 'test_client_connection_without_pwd'
    spec = importlib.util.spec_from_file_location(module_name, client_connection.__file__)
    assert spec is not None
    assert spec.loader is not None
    module = importlib.util.module_from_spec(spec)
    original_import = builtins.__import__

    def fake_import(name: str, *args: Any, **kwargs: Any) -> Any:
        if name == 'pwd':
            raise ImportError('no pwd')
        return original_import(name, *args, **kwargs)

    monkeypatch.setattr(builtins, '__import__', fake_import)
    sys.modules[module_name] = module
    try:
        spec.loader.exec_module(module)
    finally:
        sys.modules.pop(module_name, None)
    return module


def test_import_swallows_missing_pwd_module(monkeypatch: pytest.MonkeyPatch) -> None:
    module = load_without_pwd_module(monkeypatch)

    assert not hasattr(module, 'getpwuid')


def test_connect_defaults_to_port_socket_and_config_character_set(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    client = DummyClient(config={'connection': {'default_character_set': 'latin1'}, 'main': {'password_sources': KNOWN_PASSWORD_SOURCES}})
    monkeypatch.setenv('USER', 'env_user')
    monkeypatch.setattr(client_connection, 'guess_socket_location', lambda: '/tmp/mysql.sock')
    monkeypatch.setattr(client_connection, 'WIN', True)

    client.connect(host='')

    call = FakeSQLExecute.calls[-1]
    assert call['user'] == 'env_user'
    assert call['port'] == 3306
    assert call['socket'] == '/tmp/mysql.sock'
    assert call['character_set'] == 'latin1'
    assert call['ssl'] == {}


def test_connect_uses_character_set_from_connection_config() -> None:
    client = DummyClient(config={'main': {'password_sources': KNOWN_PASSWORD_SOURCES}, 'connection': {'default_character_set': 'utf16'}})

    client.connect(host='db', port=3307)

    assert FakeSQLExecute.calls[-1]['character_set'] == 'utf16'


def test_connect_migrates_deprecated_character_set_from_main_config(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_wo = WritableConfig({'main': {'password_sources': KNOWN_PASSWORD_SOURCES, 'default_character_set': 'utf32'}})
    client = DummyClient(
        config={'main': {'password_sources': KNOWN_PASSWORD_SOURCES}, 'connection': {'default_character_set': 'utf16'}},
        config_without_package_defaults=config_wo,
    )
    secho_calls: list[tuple[str, dict[str, Any]]] = []
    monkeypatch.setattr(
        client_connection.click,
        'secho',
        lambda message, **kwargs: secho_calls.append((message, kwargs)),
    )

    client.connect(host='db', port=3307)

    assert FakeSQLExecute.calls[-1]['character_set'] == 'utf32'
    assert config_wo.encoding == 'utf-8'
    assert config_wo['connection']['default_character_set'] == 'utf32'
    assert 'default_character_set' not in config_wo['main']
    assert config_wo.write_calls == 1
    assert secho_calls == [
        (
            'Mycli 2.0 migration: automatically moving default_character_set from [main] to [connection] in ~/.myclirc .',
            {'err': True, 'fg': 'red'},
        )
    ]


def test_connect_uses_existing_connection_character_set_when_migrating(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    config_wo = WritableConfig({
        'main': {'default_character_set': 'utf32'},
        'connection': {'default_character_set': 'utf16'},
    })
    client = DummyClient(
        config={'main': {'password_sources': KNOWN_PASSWORD_SOURCES}, 'connection': {'default_character_set': 'latin1'}},
        config_without_package_defaults=config_wo,
    )
    secho_calls: list[tuple[str, dict[str, Any]]] = []
    monkeypatch.setattr(
        client_connection.click,
        'secho',
        lambda message, **kwargs: secho_calls.append((message, kwargs)),
    )

    client.connect(host='db', port=3307)

    assert FakeSQLExecute.calls[-1]['character_set'] == 'utf16'
    assert config_wo['connection']['default_character_set'] == 'utf16'
    assert 'default_character_set' not in config_wo['main']
    assert config_wo.write_calls == 1
    assert secho_calls == [
        (
            'Mycli 2.0 migration: automatically moving default_character_set from [main] to [connection] in ~/.myclirc .',
            {'err': True, 'fg': 'red'},
        ),
        (
            'But connection.default_character_set already existed, with the value: "utf16".',
            {'err': True, 'fg': 'red'},
        ),
    ]


def test_connect_uses_default_character_set_when_none_configured() -> None:
    client = DummyClient()

    client.connect(host='db', port=3307)

    assert FakeSQLExecute.calls[-1]['character_set'] == DEFAULT_CHARSET


def test_connect_accepts_local_infile_true() -> None:
    client = DummyClient()

    client.connect(host='db', port=3307, local_infile=True)

    assert FakeSQLExecute.calls[-1]['local_infile'] is True


def test_connect_retrieves_password_from_keyring(monkeypatch: pytest.MonkeyPatch) -> None:
    client = DummyClient()
    get_password_calls: list[tuple[str, str]] = []

    def fake_get_password(domain: str, identifier: str) -> str:
        get_password_calls.append((domain, identifier))
        return 'from-keyring'

    monkeypatch.setattr(client_connection.keyring, 'get_password', fake_get_password)

    client.connect(user='alice', host='db', port=3307, use_keyring=True)

    assert FakeSQLExecute.calls[-1]['password'] == 'from-keyring'
    assert get_password_calls == [('mycli.net', 'alice@db:3307:')]


def test_connect_uses_mylogin_password_before_keyring(monkeypatch: pytest.MonkeyPatch) -> None:
    client = DummyClient(cnf={**default_cnf(), 'password': 'from-mylogin'})
    get_password_calls: list[tuple[str, str]] = []
    monkeypatch.setattr(
        client_connection.keyring,
        'get_password',
        lambda domain, identifier: get_password_calls.append((domain, identifier)) or 'from-mylogin',  # type: ignore[func-returns-value]
    )
    monkeypatch.setattr(client_connection.keyring, 'set_password', lambda *_args: None)

    client.connect(user='alice', host='db', port=3307, use_keyring=True)

    assert FakeSQLExecute.calls[-1]['password'] == 'from-mylogin'
    assert get_password_calls == [('mycli.net', 'alice@db:3307:')]


def test_connect_resolves_supplied_candidates_after_connection_details(monkeypatch: pytest.MonkeyPatch) -> None:
    client = DummyClient(cnf={**default_cnf(), 'password': 'from-mylogin'})
    candidates = PasswordCandidates()
    candidates.add_value('dsn', 'from-dsn')
    keyring_calls: list[tuple[str, str]] = []
    monkeypatch.setattr(
        client_connection.keyring,
        'get_password',
        lambda domain, identifier: keyring_calls.append((domain, identifier)) or 'from-dsn',  # type: ignore[func-returns-value]
    )
    monkeypatch.setattr(client_connection.keyring, 'set_password', lambda *_args: None)

    client.connect(user='alice', host='db', port=3307, password_candidates=candidates, use_keyring=True)

    assert FakeSQLExecute.calls[-1]['password'] == 'from-dsn'
    assert keyring_calls == [('mycli.net', 'alice@db:3307:')]


def test_connect_prompts_for_cli_password_candidate(monkeypatch: pytest.MonkeyPatch) -> None:
    client = DummyClient()
    candidates = PasswordCandidates()
    candidates.add_value('prompt', EMPTY_PASSWORD_FLAG_SENTINEL)
    prompts: list[str] = []
    monkeypatch.setattr(
        client_connection.click,
        'prompt',
        lambda text, **_kwargs: prompts.append(text) or 'prompted-secret',  # type: ignore[func-returns-value]
    )

    client.connect(user='alice', host='db', port=3307, password_candidates=candidates)

    assert prompts == ['Enter password for alice']
    assert FakeSQLExecute.calls[-1]['password'] == 'prompted-secret'


def test_connect_saves_selected_password_to_keyring(monkeypatch: pytest.MonkeyPatch) -> None:
    client = DummyClient()
    candidates = PasswordCandidates()
    candidates.add_value('dsn', 'new-secret')
    set_password_calls: list[tuple[str, str, str]] = []
    secho_calls: list[tuple[str, dict[str, Any]]] = []
    monkeypatch.setattr(client_connection.keyring, 'get_password', lambda *_args: 'old-secret')
    monkeypatch.setattr(
        client_connection.keyring,
        'set_password',
        lambda domain, identifier, password: set_password_calls.append((domain, identifier, password)),
    )
    monkeypatch.setattr(client_connection.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs)))

    client.connect(user='alice', host='db', port=3307, password_candidates=candidates, use_keyring=True)

    assert set_password_calls == [('mycli.net', 'alice@db:3307:', 'new-secret')]
    assert secho_calls == [('Password from source: "dsn" saved to the system keyring at mycli.net/alice@db:3307:', {'err': True})]


def test_connect_reports_keyring_save_error(monkeypatch: pytest.MonkeyPatch) -> None:
    client = DummyClient()
    candidates = PasswordCandidates()
    candidates.add_value('dsn', 'new-secret')
    secho_calls: list[tuple[str, dict[str, Any]]] = []
    monkeypatch.setattr(client_connection.keyring, 'get_password', lambda *_args: 'old-secret')

    def fail_set_password(*_args: Any) -> None:
        raise RuntimeError('locked')

    monkeypatch.setattr(client_connection.keyring, 'set_password', fail_set_password)
    monkeypatch.setattr(client_connection.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs)))

    client.connect(user='alice', host='db', port=3307, password_candidates=candidates, use_keyring=True)

    assert secho_calls == [('Password not saved to the system keyring: locked', {'err': True, 'fg': 'red'})]


def test_connect_uses_ssh_jump_with_remote_socket(monkeypatch: pytest.MonkeyPatch) -> None:
    tunnel_calls: list[dict[str, Any]] = []

    class FakeTunnel:
        local_socket = '/tmp/mycli-ssh.sock'
        remote_socket = '/var/run/mysqld/mysqld.sock'

        @classmethod
        def from_target(
            cls,
            ssh_jump: str,
            *,
            remote_host: str,
            remote_port: int,
            remote_socket: str | None = None,
            ssh_executable: str = 'ssh',
            ssh_config_options: str | None = None,
            ssh_cli_options: str | None = None,
            tunnel_method: Literal['auto', 'socket', 'port'] = 'auto',
        ) -> 'FakeTunnel':
            tunnel_calls.append({
                'ssh_jump': ssh_jump,
                'remote_host': remote_host,
                'remote_port': remote_port,
                'remote_socket': remote_socket,
                'ssh_executable': ssh_executable,
                'ssh_config_options': None,
                'ssh_cli_options': None,
            })
            return cls()

        def start(self) -> None:
            pass

        def close(self) -> None:
            pass

    monkeypatch.setattr(client_connection, 'SshTunnel', FakeTunnel)
    client = DummyClient(
        config={'main': {'password_sources': KNOWN_PASSWORD_SOURCES}, 'ssh': {'ssh_executable': '/opt/bin/ssh'}, 'connection': {}}
    )

    client.connect(
        user='alice',
        host=None,
        port=None,
        socket='/var/run/mysqld/mysqld.sock',
        ssh_jump='bastion',
    )

    assert tunnel_calls == [
        {
            'ssh_jump': 'bastion',
            'remote_host': 'localhost',
            'remote_port': 3306,
            'remote_socket': '/var/run/mysqld/mysqld.sock',
            'ssh_executable': '/opt/bin/ssh',
            'ssh_config_options': None,
            'ssh_cli_options': None,
        }
    ]
    assert FakeSQLExecute.calls[-1]['host'] is None
    assert FakeSQLExecute.calls[-1]['port'] is None
    assert FakeSQLExecute.calls[-1]['socket'] == '/tmp/mycli-ssh.sock'
    assert FakeSQLExecute.calls[-1]['display_dsn'] == 'mysql://alice@localhost?socket=%2Fvar%2Frun%2Fmysqld%2Fmysqld.sock&ssh_jump=bastion'


def test_connect_uses_ssh_jump_with_local_port(monkeypatch: pytest.MonkeyPatch) -> None:
    class FakeTunnel:
        local_socket = None
        local_host = '127.0.0.1'
        local_port = 4406

        @classmethod
        def from_target(
            cls,
            _ssh_jump: str,
            *,
            remote_host: str,
            remote_port: int,
            remote_socket: str | None = None,
            ssh_executable: str = 'ssh',
            ssh_config_options: str | None = None,
            ssh_cli_options: str | None = None,
            tunnel_method: Literal['auto', 'socket', 'port'] = 'auto',
        ) -> 'FakeTunnel':
            return cls()

        def start(self) -> None:
            pass

        def close(self) -> None:
            pass

    monkeypatch.setattr(client_connection, 'SshTunnel', FakeTunnel)
    client = DummyClient()

    client.connect(user='alice', host='db.internal', port=3307, ssh_jump='bastion')

    assert FakeSQLExecute.calls[-1]['host'] == '127.0.0.1'
    assert FakeSQLExecute.calls[-1]['port'] == 4406
    assert FakeSQLExecute.calls[-1]['socket'] is None
    assert FakeSQLExecute.calls[-1]['display_dsn'] == 'mysql://alice@db.internal:3307?ssh_jump=bastion'


def test_connect_with_vault_password_keeps_explicit_user_in_display_dsn() -> None:
    client = DummyClient()

    client.connect(user='alice', host='db.internal', port=3307, vault_secret='database/prod')

    assert FakeSQLExecute.calls[-1]['display_dsn'] == 'mysql://alice@db.internal:3307?vault_secret=database%2Fprod'


def test_connect_with_vault_username_omits_user_from_display_dsn() -> None:
    client = DummyClient()

    client.connect(
        user='vault-user',
        host='db.internal',
        port=3307,
        vault_secret='database/prod',
        vault_username_from_vault=True,
    )

    assert FakeSQLExecute.calls[-1]['display_dsn'] == 'mysql://db.internal:3307?vault_secret=database%2Fprod'


def test_connect_with_ssh_jump_and_vault_username_omits_user_from_display_dsn(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeTunnel:
        local_socket = None
        local_host = '127.0.0.1'
        local_port = 4406

        @classmethod
        def from_target(
            cls,
            _ssh_jump: str,
            *,
            remote_host: str,
            remote_port: int,
            remote_socket: str | None = None,
            ssh_executable: str = 'ssh',
            ssh_config_options: str | None = None,
            ssh_cli_options: str | None = None,
            tunnel_method: Literal['auto', 'socket', 'port'] = 'auto',
        ) -> 'FakeTunnel':
            return cls()

        def start(self) -> None:
            pass

        def close(self) -> None:
            pass

    monkeypatch.setattr(client_connection, 'SshTunnel', FakeTunnel)
    client = DummyClient()

    client.connect(
        user='vault-user',
        host='db.internal',
        port=3307,
        ssh_jump='bastion',
        vault_secret='database/prod',
        vault_username_from_vault=True,
    )

    assert FakeSQLExecute.calls[-1]['display_dsn'] == ('mysql://db.internal:3307?ssh_jump=bastion&vault_secret=database%2Fprod')


def test_connect_passes_config_and_cli_ssh_options(monkeypatch: pytest.MonkeyPatch) -> None:
    tunnel_calls: list[dict[str, Any]] = []

    class FakeTunnel:
        local_socket = '/tmp/mycli-ssh.sock'

        @classmethod
        def from_target(
            cls,
            ssh_jump: str,
            *,
            remote_host: str,
            remote_port: int,
            remote_socket: str | None = None,
            ssh_executable: str = 'ssh',
            ssh_config_options: str | None = None,
            ssh_cli_options: str | None = None,
            tunnel_method: Literal['auto', 'socket', 'port'] = 'auto',
        ) -> 'FakeTunnel':
            tunnel_calls.append({
                'ssh_jump': ssh_jump,
                'ssh_config_options': ssh_config_options,
                'ssh_cli_options': ssh_cli_options,
            })
            return cls()

        def start(self) -> None:
            pass

        def close(self) -> None:
            pass

    monkeypatch.setattr(client_connection, 'SshTunnel', FakeTunnel)
    client = DummyClient(
        config={'main': {'password_sources': KNOWN_PASSWORD_SOURCES}, 'ssh': {'ssh_options': '-o LogLevel=ERROR'}, 'connection': {}}
    )

    client.connect(host='db.internal', ssh_jump='bastion', ssh_cli_options='-o Compression=yes')

    assert tunnel_calls == [
        {
            'ssh_jump': 'bastion',
            'ssh_config_options': '-o LogLevel=ERROR',
            'ssh_cli_options': '-o Compression=yes',
        }
    ]


def test_connect_reports_ssh_jump_start_error_and_closes_tunnel(monkeypatch: pytest.MonkeyPatch) -> None:
    close_calls: list[bool] = []
    secho_calls: list[tuple[str, dict[str, Any]]] = []

    class FakeTunnel:
        local_socket = '/tmp/mycli-ssh.sock'
        remote_socket = None

        @classmethod
        def from_target(
            cls,
            _ssh_jump: str,
            *,
            remote_host: str,
            remote_port: int,
            remote_socket: str | None = None,
            ssh_executable: str = 'ssh',
            ssh_config_options: str | None = None,
            ssh_cli_options: str | None = None,
            tunnel_method: Literal['auto', 'socket', 'port'] = 'auto',
        ) -> 'FakeTunnel':
            return cls()

        def start(self) -> None:
            raise client_connection.SshTunnelError('no tunnel')

        def close(self) -> None:
            close_calls.append(True)

    monkeypatch.setattr(client_connection, 'SshTunnel', FakeTunnel)
    monkeypatch.setattr(client_connection.click, 'secho', lambda message, **kwargs: secho_calls.append((message, kwargs)))
    client = DummyClient()

    with pytest.raises(SystemExit) as excinfo:
        client.connect(host='db.internal', ssh_jump='bastion')

    assert excinfo.value.code == 1
    assert close_calls == [True]
    assert secho_calls == [('Error: Unable to start SSH tunnel: no tunnel', {'err': True, 'fg': 'red'})]
    assert FakeSQLExecute.calls == []


def test_connect_swallows_ssh_jump_cleanup_error(monkeypatch: pytest.MonkeyPatch) -> None:
    class FakeTunnel:
        local_socket = '/tmp/mycli-ssh.sock'
        remote_socket = None

        @classmethod
        def from_target(
            cls,
            _ssh_jump: str,
            *,
            remote_host: str,
            remote_port: int,
            remote_socket: str | None = None,
            ssh_executable: str = 'ssh',
            ssh_config_options: str | None = None,
            ssh_cli_options: str | None = None,
            tunnel_method: Literal['auto', 'socket', 'port'] = 'auto',
        ) -> 'FakeTunnel':
            return cls()

        def start(self) -> None:
            raise OSError('no process')

        def close(self) -> None:
            raise RuntimeError('close failed')

    monkeypatch.setattr(client_connection, 'SshTunnel', FakeTunnel)
    client = DummyClient()

    with pytest.raises(SystemExit) as excinfo:
        client.connect(host='db.internal', ssh_jump='bastion')

    assert excinfo.value.code == 1


def test_connect_retries_without_ssl_for_auto_handshake_error() -> None:
    client = DummyClient()
    FakeSQLExecute.effects = [op_error(client_connection.HANDSHAKE_ERROR), None]

    client.connect(host='db', port=3307, ssl={'mode': 'auto', 'ca': '/ca.pem'})

    assert len(FakeSQLExecute.calls) == 2
    assert FakeSQLExecute.calls[0]['ssl'] == {'mode': 'auto', 'ca': '/ca.pem'}
    assert FakeSQLExecute.calls[1]['ssl'] is None


def test_connect_adds_default_ssl_ca_path() -> None:
    client = DummyClient(config={'main': {'password_sources': KNOWN_PASSWORD_SOURCES}, 'connection': {'default_ssl_ca_path': '/ca/path'}})

    client.connect(host='db', port=3307, ssl={'mode': 'on'})

    assert FakeSQLExecute.calls[-1]['ssl'] == {'mode': 'on', 'capath': '/ca/path'}


def test_connect_exits_when_ssl_retry_also_fails() -> None:
    client = DummyClient()
    FakeSQLExecute.effects = [
        op_error(client_connection.HANDSHAKE_ERROR, 'first'),
        op_error(client_connection.HANDSHAKE_ERROR, 'second'),
    ]

    with pytest.raises(SystemExit) as excinfo:
        client.connect(host='db', port=3307, ssl={'mode': 'auto'})

    assert excinfo.value.code == 1
    assert len(FakeSQLExecute.calls) == 2


def test_connect_prompts_and_retries_after_access_denied_without_password(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    client = DummyClient()
    FakeSQLExecute.effects = [op_error(client_connection.ACCESS_DENIED_ERROR), None]
    prompts: list[str] = []
    monkeypatch.setattr(
        client_connection.click,
        'prompt',
        lambda text, **_kwargs: prompts.append(text) or 'new-secret',  # type: ignore[func-returns-value]
    )

    client.connect(user='alice', host='db', port=3307)

    assert prompts == ['Enter password for alice']
    assert [call['password'] for call in FakeSQLExecute.calls] == [None, 'new-secret']


def test_connect_does_not_prompt_after_access_denied_when_fallback_is_disabled(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    client = DummyClient(
        config={
            'main': {'password_sources': [source for source in KNOWN_PASSWORD_SOURCES if source != 'fallback']},
            'connection': {},
        }
    )
    FakeSQLExecute.effects = [op_error(client_connection.ACCESS_DENIED_ERROR)]
    prompts: list[str] = []
    monkeypatch.setattr(
        client_connection.click,
        'prompt',
        lambda text, **_kwargs: prompts.append(text) or 'new-secret',  # type: ignore[func-returns-value]
    )

    with pytest.raises(SystemExit) as excinfo:
        client.connect(user='alice', host='db', port=3307)

    assert excinfo.value.code == 1
    assert prompts == []
    assert [call['password'] for call in FakeSQLExecute.calls] == [None]


def test_connect_exits_when_password_retry_fails(monkeypatch: pytest.MonkeyPatch) -> None:
    client = DummyClient()
    FakeSQLExecute.effects = [
        op_error(client_connection.ACCESS_DENIED_ERROR, 'first'),
        op_error(client_connection.ACCESS_DENIED_ERROR, 'second'),
    ]
    monkeypatch.setattr(client_connection.click, 'prompt', lambda *_args, **_kwargs: 'new-secret')

    with pytest.raises(SystemExit) as excinfo:
        client.connect(user='alice', host='db', port=3307)

    assert excinfo.value.code == 1
    assert [call['password'] for call in FakeSQLExecute.calls] == [None, 'new-secret']


def test_connect_exits_when_password_retry_still_has_no_password(monkeypatch: pytest.MonkeyPatch) -> None:
    client = DummyClient()
    FakeSQLExecute.effects = [
        op_error(client_connection.ACCESS_DENIED_ERROR, 'first'),
        op_error(client_connection.ACCESS_DENIED_ERROR, 'second'),
    ]
    monkeypatch.setattr(client_connection.click, 'prompt', lambda *_args, **_kwargs: None)

    with pytest.raises(SystemExit) as excinfo:
        client.connect(user='alice', host='db', port=3307)

    assert excinfo.value.code == 1
    assert [call['password'] for call in FakeSQLExecute.calls] == [None, None]


def test_connect_reports_expired_password_login_error() -> None:
    client = DummyClient()
    FakeSQLExecute.effects = [op_error(ER_MUST_CHANGE_PASSWORD_LOGIN, 'expired')]

    with pytest.raises(SystemExit) as excinfo:
        client.connect(host='db', port=3307)

    assert excinfo.value.code == 1
    assert any('password has expired' in call[0][0] for call in client.echo_calls)


def test_connect_sets_sandbox_mode_when_sqlexecute_enters_sandbox() -> None:
    client = DummyClient()
    FakeSQLExecute.sandbox_mode_value = True

    client.connect(host='db', port=3307)

    assert client.sandbox_mode is True
    assert any('password has expired' in call[0][0] for call in client.echo_calls)


@pytest.mark.parametrize(
    ('code', 'message'),
    (
        (
            client_connection.CR_SERVER_LOST,
            'Connection to server lost. If this error persists',
        ),
        (9999, 'other failure'),
    ),
)
def test_connect_exits_for_server_lost_and_other_operational_errors(code: int, message: str) -> None:
    client = DummyClient()
    FakeSQLExecute.effects = [op_error(code, 'other failure')]

    with pytest.raises(SystemExit) as excinfo:
        client.connect(host='db', port=3307)

    assert excinfo.value.code == 1
    assert any(message in call[0][0] for call in client.echo_calls)


def test_connect_reports_socket_owner(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Any,
) -> None:
    socket_path = tmp_path / 'mysql.sock'
    socket_path.write_text('', encoding='utf-8')
    client = DummyClient()
    monkeypatch.setattr(client_connection, 'getpwuid', lambda uid: SimpleNamespace(pw_name='socket-owner'))

    client.connect(user='alice', socket=str(socket_path), port=None)

    assert any(f'Connecting to socket {socket_path}, owned by user socket-owner' in call[0][0] for call in client.echo_calls)
    assert len(FakeSQLExecute.calls) == 1


def test_connect_falls_back_to_tcp_after_socket_connection_error(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Any,
) -> None:
    socket_path = tmp_path / 'mysql.sock'
    socket_path.write_text('', encoding='utf-8')
    client = DummyClient()
    FakeSQLExecute.effects = [op_error(2002, 'no socket'), None]
    monkeypatch.setattr(client_connection, 'getpwuid', lambda uid: SimpleNamespace(pw_name='socket-owner'))

    client.connect(user='alice', socket=str(socket_path), port=None)

    assert any('Retrying over TCP/IP' in call[0][0] for call in client.echo_calls)
    assert len(FakeSQLExecute.calls) == 2


def test_connect_reports_unknown_socket_owner(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Any,
) -> None:
    socket_path = tmp_path / 'mysql.sock'
    socket_path.write_text('', encoding='utf-8')
    client = DummyClient()
    FakeSQLExecute.effects = [op_error(9999, 'bad socket')]

    def fail_getpwuid(uid: int) -> Any:
        raise KeyError(uid)

    monkeypatch.setattr(client_connection, 'getpwuid', fail_getpwuid)

    with pytest.raises(SystemExit):
        client.connect(socket=str(socket_path), port=None)

    assert any(f'Connecting to socket {socket_path}, owned by user <unknown>' in call[0][0] for call in client.echo_calls)


def test_connect_exits_for_unhandled_socket_connection_error(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Any,
) -> None:
    socket_path = tmp_path / 'mysql.sock'
    socket_path.write_text('', encoding='utf-8')
    client = DummyClient()
    FakeSQLExecute.effects = [op_error(9999, 'bad socket')]
    monkeypatch.setattr(client_connection, 'getpwuid', lambda uid: SimpleNamespace(pw_name='socket-owner'))

    with pytest.raises(SystemExit) as excinfo:
        client.connect(socket=str(socket_path), port=None)

    assert excinfo.value.code == 1


def test_connect_exits_for_invalid_port() -> None:
    client = DummyClient()

    class BadPort:
        def __init__(self) -> None:
            self.truth_values = iter([True, False, False, True])

        def __bool__(self) -> bool:
            return next(self.truth_values)

        def __int__(self) -> int:
            raise ValueError

        def __str__(self) -> str:
            return 'not-a-port'

    with pytest.raises(SystemExit) as excinfo:
        client.connect(host='db', port=cast(Any, BadPort()))

    assert excinfo.value.code == 1
    assert client.echo_calls == [(("Error: Invalid port number: 'not-a-port'.",), {'err': True, 'fg': 'red'})]


class FakeConn:
    def __init__(self, ping_effects: list[Any]) -> None:
        self.ping_effects = ping_effects
        self.select_db_calls: list[str] = []

    def ping(self, reconnect: bool = False) -> None:
        effect = self.ping_effects.pop(0)
        if isinstance(effect, BaseException):
            raise effect

    def select_db(self, dbname: str) -> None:
        self.select_db_calls.append(dbname)


class FakeReconnectSQLExecute:
    def __init__(self, conn: FakeConn, *, connection_id: int = 1, dbname: str = 'db') -> None:
        self.conn = conn
        self.connection_id = connection_id
        self.next_connection_id = connection_id + 1
        self.dbname = dbname
        self.connect_calls = 0

    def reset_connection_id(self) -> None:
        self.connection_id = self.next_connection_id

    def connect(self) -> None:
        self.connect_calls += 1


def test_reconnect_returns_true_when_ping_succeeds() -> None:
    client = DummyClient()
    client.sqlexecute = FakeReconnectSQLExecute(FakeConn([None]))

    assert client.reconnect() is True
    assert client.echo_calls == [(('Already connected.',), {'fg': 'yellow'})]


def test_reconnect_uses_ping_reconnect_and_selects_current_database() -> None:
    client = DummyClient()
    conn = FakeConn([pymysql.err.Error('stale'), None])
    client.sqlexecute = FakeReconnectSQLExecute(conn, connection_id=10, dbname='selected')
    client.sqlexecute.next_connection_id = 10

    assert client.reconnect(database='newdb') is True
    assert conn.select_db_calls == ['selected']


def test_reconnect_reports_session_reset_when_connection_id_changes() -> None:
    client = DummyClient()
    conn = FakeConn([pymysql.err.Error('stale'), None])
    client.sqlexecute = FakeReconnectSQLExecute(conn, connection_id=10, dbname='')

    assert client.reconnect(database='newdb') is True
    assert any(call[0] == ('Any session state was reset.',) for call in client.echo_calls)


def test_reconnect_creates_new_connection_after_ping_reconnect_fails() -> None:
    client = DummyClient()
    conn = FakeConn([pymysql.err.Error('stale'), pymysql.err.Error('still stale')])
    client.sqlexecute = FakeReconnectSQLExecute(conn)

    assert client.reconnect() is True
    assert client.sqlexecute.connect_calls == 1
    assert any(call[0] == ('New connection created successfully.',) for call in client.echo_calls)


def test_reconnect_returns_false_when_new_connection_fails() -> None:
    client = DummyClient()
    conn = FakeConn([pymysql.err.Error('stale'), pymysql.err.Error('still stale')])
    sqlexecute = FakeReconnectSQLExecute(conn)

    def fail_connect() -> None:
        raise pymysql.OperationalError(2003, 'no route')

    sqlexecute.connect = fail_connect  # type: ignore[method-assign]
    client.sqlexecute = sqlexecute

    assert client.reconnect() is False
    assert any('no route' in call[0][0] for call in client.echo_calls)
