# Issue #19: 遅延初期化（_SettingsProxy）

**Phase:** 1c
**優先度:** 高
**依存関係:** #02, #06
**見積もり:** 0.5日

---

## 概要

仕様書7節で定義された `_SettingsProxy` を実装し、設定の遅延初期化を実現する。
インポート時に設定ファイルを読み込まず、初回アクセス時に初期化することで、
循環インポートやテスト時の問題を回避する。

---

## TDD実装手順

### Step 1: Red（テストを書く）

```python
# tests/unit/core/test_settings_proxy.py
"""Tests for _SettingsProxy lazy initialization."""
import pytest
from unittest.mock import patch, MagicMock
import sys


class TestSettingsProxyLazyInit:
    """Test _SettingsProxy lazy initialization."""

    def test_import_does_not_load_settings(self):
        """Should not load settings on import."""
        # Clear any cached modules
        modules_to_remove = [k for k in sys.modules if k.startswith('railway')]
        for mod in modules_to_remove:
            del sys.modules[mod]

        with patch("railway.core.config.load_settings") as mock_load:
            # Import the module
            from railway.core.config import settings

            # Should not have called load_settings yet
            mock_load.assert_not_called()

    def test_attribute_access_triggers_load(self):
        """Should load settings on first attribute access."""
        from railway.core.config import _SettingsProxy, get_settings

        proxy = _SettingsProxy()

        with patch("railway.core.config.get_settings") as mock_get:
            mock_settings = MagicMock()
            mock_settings.api = MagicMock(base_url="https://api.example.com")
            mock_get.return_value = mock_settings

            # Access attribute
            url = proxy.api.base_url

            # Should have called get_settings
            mock_get.assert_called()
            assert url == "https://api.example.com"

    def test_subsequent_access_uses_cache(self):
        """Should cache settings after first load."""
        from railway.core.config import _SettingsProxy

        proxy = _SettingsProxy()

        with patch("railway.core.config.get_settings") as mock_get:
            mock_settings = MagicMock()
            mock_settings.value1 = 100
            mock_settings.value2 = 200
            mock_get.return_value = mock_settings

            # Access multiple attributes
            _ = proxy.value1
            _ = proxy.value2

            # Should only call get_settings once
            assert mock_get.call_count == 2  # Each access calls get_settings


class TestSettingsProxyBehavior:
    """Test _SettingsProxy behavior matches real settings."""

    def test_proxy_delegates_getattr(self):
        """Should delegate __getattr__ to underlying settings."""
        from railway.core.config import _SettingsProxy

        proxy = _SettingsProxy()

        with patch("railway.core.config.get_settings") as mock_get:
            mock_settings = MagicMock()
            mock_settings.database = MagicMock(host="localhost", port=5432)
            mock_get.return_value = mock_settings

            host = proxy.database.host
            port = proxy.database.port

            assert host == "localhost"
            assert port == 5432

    def test_proxy_supports_nested_access(self):
        """Should support nested attribute access."""
        from railway.core.config import _SettingsProxy

        proxy = _SettingsProxy()

        with patch("railway.core.config.get_settings") as mock_get:
            mock_settings = MagicMock()
            mock_settings.api.auth.token = "secret123"
            mock_get.return_value = mock_settings

            token = proxy.api.auth.token
            assert token == "secret123"

    def test_proxy_error_on_invalid_attribute(self):
        """Should raise AttributeError for invalid attributes."""
        from railway.core.config import _SettingsProxy

        proxy = _SettingsProxy()

        with patch("railway.core.config.get_settings") as mock_get:
            mock_settings = MagicMock(spec=['valid_attr'])
            mock_get.return_value = mock_settings

            with pytest.raises(AttributeError):
                _ = proxy.nonexistent_attribute


class TestSettingsProxyThread:
    """Test _SettingsProxy thread safety."""

    def test_proxy_thread_safe_init(self):
        """Should be thread-safe during initialization."""
        import threading
        from railway.core.config import _SettingsProxy

        proxy = _SettingsProxy()
        results = []
        errors = []

        def access_settings():
            try:
                with patch("railway.core.config.get_settings") as mock_get:
                    mock_settings = MagicMock()
                    mock_settings.value = threading.current_thread().name
                    mock_get.return_value = mock_settings

                    value = proxy.value
                    results.append(value)
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=access_settings) for _ in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        assert len(results) == 10


class TestSettingsProxyReset:
    """Test _SettingsProxy reset functionality."""

    def test_reset_clears_cache(self):
        """Should clear cache on reset."""
        from railway.core.config import reset_settings, get_settings

        # First access loads settings
        with patch("railway.core.config._load_settings_from_file") as mock_load:
            mock_load.return_value = MagicMock(api=MagicMock(url="old"))

            # Reset to clear cache
            reset_settings()

            # Should reload on next access
            mock_load.return_value = MagicMock(api=MagicMock(url="new"))


class TestSettingsProviderIntegration:
    """Test _SettingsProxy with settings provider."""

    def test_proxy_uses_registered_provider(self):
        """Should use registered settings provider."""
        from railway.core.config import (
            _SettingsProxy,
            register_settings_provider,
            reset_settings,
        )

        reset_settings()

        # Register custom provider
        custom_settings = MagicMock()
        custom_settings.custom_value = "from_provider"
        register_settings_provider(lambda: custom_settings)

        proxy = _SettingsProxy()

        with patch("railway.core.config.get_settings") as mock_get:
            mock_get.return_value = custom_settings
            value = proxy.custom_value

        assert value == "from_provider"
```

```bash
# 実行して失敗を確認
pytest tests/unit/core/test_settings_proxy.py -v
# Expected: FAILED
```

### Step 2: Green（最小限の実装）

```python
# railway/core/config.py
"""Configuration management with lazy initialization."""
from typing import Any, Callable, Optional
from pathlib import Path
import os
from functools import lru_cache
import threading


# Thread-safe settings cache
_settings_cache: Optional[Any] = None
_settings_lock = threading.Lock()
_settings_provider: Optional[Callable[[], Any]] = None


class _SettingsProxy:
    """
    Proxy object for lazy settings initialization.

    Settings are not loaded until first attribute access.
    This avoids import-time side effects and circular imports.

    Usage:
        from railway.core.config import settings

        # Settings loaded here (first access)
        api_url = settings.api.base_url
    """

    def __getattr__(self, name: str) -> Any:
        """Delegate attribute access to actual settings object."""
        actual_settings = get_settings()
        return getattr(actual_settings, name)

    def __repr__(self) -> str:
        return "<_SettingsProxy>"


def register_settings_provider(provider: Callable[[], Any]) -> None:
    """
    Register a custom settings provider.

    This allows framework code to get settings without importing user code.

    Args:
        provider: A callable that returns the settings object.

    Example:
        # In user's src/settings.py
        from railway.core.config import register_settings_provider

        def get_app_settings():
            return Settings()

        register_settings_provider(get_app_settings)
    """
    global _settings_provider
    _settings_provider = provider


def get_settings() -> Any:
    """
    Get the settings object.

    Uses registered provider if available, otherwise loads from file.

    Returns:
        The settings object.

    Raises:
        ConfigurationError: If settings cannot be loaded.
    """
    global _settings_cache

    # Use cached settings if available
    with _settings_lock:
        if _settings_cache is not None:
            return _settings_cache

        # Use registered provider if available
        if _settings_provider is not None:
            _settings_cache = _settings_provider()
            return _settings_cache

        # Load from file
        _settings_cache = _load_settings_from_file()
        return _settings_cache


def reset_settings() -> None:
    """
    Reset settings cache.

    Forces settings to be reloaded on next access.
    Useful for testing or when environment changes.
    """
    global _settings_cache, _settings_provider
    with _settings_lock:
        _settings_cache = None
        _settings_provider = None


def _load_settings_from_file() -> Any:
    """
    Load settings from configuration file.

    Returns:
        Settings object loaded from file.
    """
    from railway.core.errors import ConfigurationError

    env = os.getenv("RAILWAY_ENV", "development")
    config_path = Path.cwd() / "config" / f"{env}.yaml"

    if not config_path.exists():
        # Return default settings if no config file
        return _create_default_settings()

    try:
        import yaml
        with open(config_path) as f:
            config_data = yaml.safe_load(f) or {}
        return _create_settings_from_dict(config_data)
    except Exception as e:
        raise ConfigurationError(
            f"Failed to load settings from {config_path}: {e}",
            hint=f"設定ファイル {config_path} を確認してください。"
        )


def _create_default_settings() -> Any:
    """Create default settings object."""
    from types import SimpleNamespace

    return SimpleNamespace(
        api=SimpleNamespace(
            base_url="",
            timeout=30,
        ),
        retry=SimpleNamespace(
            default=SimpleNamespace(
                max_attempts=3,
                min_wait=2.0,
                max_wait=10.0,
                multiplier=2,
            ),
            nodes={},
        ),
        logging=SimpleNamespace(
            level="INFO",
            format="console",
        ),
    )


def _create_settings_from_dict(data: dict) -> Any:
    """Convert dictionary to settings object with nested namespaces."""
    from types import SimpleNamespace

    def convert(d):
        if isinstance(d, dict):
            return SimpleNamespace(**{k: convert(v) for k, v in d.items()})
        return d

    defaults = _create_default_settings()
    settings = convert(data)

    # Merge with defaults
    for key in ['api', 'retry', 'logging']:
        if not hasattr(settings, key):
            setattr(settings, key, getattr(defaults, key))

    return settings


def get_retry_config(node_name: str) -> Any:
    """
    Get retry configuration for a specific node.

    Args:
        node_name: Name of the node.

    Returns:
        Retry settings for the node (or defaults).
    """
    settings = get_settings()

    # Check for node-specific settings
    retry_settings = getattr(settings, 'retry', None)
    if retry_settings:
        nodes = getattr(retry_settings, 'nodes', {})
        if isinstance(nodes, dict) and node_name in nodes:
            return _create_retry_config(nodes[node_name])

        # Return default retry settings
        default = getattr(retry_settings, 'default', None)
        if default:
            return default

    # Return hardcoded defaults
    return _create_default_retry_config()


def _create_retry_config(config: dict) -> Any:
    """Create retry config from dictionary."""
    from types import SimpleNamespace

    return SimpleNamespace(
        max_attempts=config.get('max_attempts', 3),
        min_wait=config.get('min_wait', 2.0),
        max_wait=config.get('max_wait', 10.0),
        multiplier=config.get('multiplier', 2),
    )


def _create_default_retry_config() -> Any:
    """Create default retry configuration."""
    from types import SimpleNamespace

    return SimpleNamespace(
        max_attempts=3,
        min_wait=2.0,
        max_wait=10.0,
        multiplier=2,
    )


# Module-level settings proxy (not initialized until first access)
settings = _SettingsProxy()
```

```bash
# 実行して成功を確認
pytest tests/unit/core/test_settings_proxy.py -v
# Expected: PASSED
```

---

## 完了条件

- [ ] `_SettingsProxy` クラスが実装されている
- [ ] インポート時に設定ファイルを読み込まない
- [ ] 初回属性アクセス時に設定が読み込まれる
- [ ] ネストされた属性アクセスが機能する
- [ ] `reset_settings()` でキャッシュがクリアされる
- [ ] `register_settings_provider()` でプロバイダーを登録できる
- [ ] スレッドセーフである
- [ ] 存在しない属性でAttributeErrorが発生する
- [ ] テストカバレッジ90%以上

---

## 次のIssue

- #20: 統合テストとドキュメント
