# Data Layer - External API Integration & Caching

## Overview

The data layer provides external data integration for the Paper Dynasty game engine. It handles communication with league REST APIs to fetch team rosters, player data, and submit completed game results.

**Status**: 🚧 **NOT YET IMPLEMENTED** - This directory is currently empty and awaits implementation.

**Purpose**:
- Fetch team/roster data from league-specific REST APIs (SBA and PD)
- Retrieve detailed player/card information
- Submit completed game results to league systems
- Cache frequently accessed data to reduce API calls
- Abstract API differences between leagues

## Planned Architecture

```
app/data/
├── __init__.py              # Public API exports
├── api_client.py            # Base API client with HTTP operations
├── sba_client.py            # SBA League API wrapper
├── pd_client.py             # PD League API wrapper
└── cache.py                 # Optional caching layer (Redis or in-memory)
```

## Integration Points

### With Game Engine
```python
# Game engine needs player data at game start
from app.data import get_api_client

# Get league-specific client
api_client = get_api_client(league_id="sba")

# Fetch roster for game
roster = await api_client.get_team_roster(team_id=123)

# Create lineup from roster data
lineup = create_lineup_from_roster(roster)
```

### With Database Layer
```python
# Store fetched data in database
from app.database.operations import DatabaseOperations

db_ops = DatabaseOperations()

# Fetch and persist player data
player_data = await api_client.get_player(player_id=456)
await db_ops.store_player_metadata(player_data)
```

### With Player Models
```python
# Parse API responses into typed player models
from app.models import SbaPlayer, PdPlayer

# SBA league
sba_data = await sba_client.get_player(player_id=123)
player = SbaPlayer.from_api_response(sba_data)

# PD league (with scouting data)
pd_data = await pd_client.get_player(player_id=456)
batting_data = await pd_client.get_batting_card(player_id=456)
pitching_data = await pd_client.get_pitching_card(player_id=456)
player = PdPlayer.from_api_response(pd_data, batting_data, pitching_data)
```

## API Clients Design

### Base API Client Pattern

**Location**: `app/data/api_client.py` (not yet created)

**Purpose**: Abstract HTTP client with common patterns for all league APIs.

**Key Features**:
- Async HTTP requests using `httpx` or `aiohttp`
- Automatic retry logic with exponential backoff
- Request/response logging
- Error handling and custom exceptions
- Authentication header management
- Rate limiting protection

**Example Implementation**:
```python
import httpx
import logging
from typing import Dict, Any, Optional
from abc import ABC, abstractmethod

logger = logging.getLogger(f'{__name__}.BaseApiClient')

class ApiClientError(Exception):
    """Base exception for API client errors"""
    pass

class ApiConnectionError(ApiClientError):
    """Raised when API connection fails"""
    pass

class ApiAuthenticationError(ApiClientError):
    """Raised when authentication fails"""
    pass

class ApiNotFoundError(ApiClientError):
    """Raised when resource not found (404)"""
    pass

class BaseApiClient(ABC):
    """Abstract base class for league API clients

    Provides common HTTP operations and error handling.
    Subclasses implement league-specific endpoints.
    """

    def __init__(self, base_url: str, api_key: Optional[str] = None):
        self.base_url = base_url.rstrip('/')
        self.api_key = api_key
        self._client: Optional[httpx.AsyncClient] = None

    async def __aenter__(self):
        """Async context manager entry"""
        self._client = httpx.AsyncClient(
            base_url=self.base_url,
            headers=self._get_headers(),
            timeout=30.0
        )
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Async context manager exit"""
        if self._client:
            await self._client.aclose()

    def _get_headers(self) -> Dict[str, str]:
        """Get request headers including auth"""
        headers = {
            "Accept": "application/json",
            "Content-Type": "application/json"
        }
        if self.api_key:
            headers["Authorization"] = f"Bearer {self.api_key}"
        return headers

    async def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]:
        """Execute GET request with error handling

        Args:
            endpoint: API endpoint path (e.g., "/teams/123")
            params: Optional query parameters

        Returns:
            Parsed JSON response

        Raises:
            ApiConnectionError: Network/connection error
            ApiAuthenticationError: Auth failure (401, 403)
            ApiNotFoundError: Resource not found (404)
            ApiClientError: Other API errors
        """
        if not self._client:
            raise RuntimeError("Client not initialized. Use 'async with' context manager.")

        url = endpoint if endpoint.startswith('/') else f'/{endpoint}'

        try:
            logger.debug(f"GET {url} with params {params}")
            response = await self._client.get(url, params=params)
            response.raise_for_status()

            data = response.json()
            logger.debug(f"Response: {data}")
            return data

        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ApiNotFoundError(f"Resource not found: {url}") from e
            elif e.response.status_code in (401, 403):
                raise ApiAuthenticationError(f"Authentication failed: {e.response.text}") from e
            else:
                raise ApiClientError(f"HTTP {e.response.status_code}: {e.response.text}") from e

        except httpx.RequestError as e:
            logger.error(f"Connection error for {url}: {e}")
            raise ApiConnectionError(f"Failed to connect to API: {e}") from e

    async def _post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
        """Execute POST request with error handling"""
        if not self._client:
            raise RuntimeError("Client not initialized. Use 'async with' context manager.")

        url = endpoint if endpoint.startswith('/') else f'/{endpoint}'

        try:
            logger.debug(f"POST {url} with data {data}")
            response = await self._client.post(url, json=data)
            response.raise_for_status()

            result = response.json()
            logger.debug(f"Response: {result}")
            return result

        except httpx.HTTPStatusError as e:
            if e.response.status_code in (401, 403):
                raise ApiAuthenticationError(f"Authentication failed: {e.response.text}") from e
            else:
                raise ApiClientError(f"HTTP {e.response.status_code}: {e.response.text}") from e

        except httpx.RequestError as e:
            logger.error(f"Connection error for {url}: {e}")
            raise ApiConnectionError(f"Failed to connect to API: {e}") from e

    # Abstract methods for subclasses to implement
    @abstractmethod
    async def get_team(self, team_id: int) -> Dict[str, Any]:
        """Fetch team details"""
        pass

    @abstractmethod
    async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
        """Fetch team roster with all cards/players"""
        pass

    @abstractmethod
    async def get_player(self, player_id: int) -> Dict[str, Any]:
        """Fetch player details"""
        pass

    @abstractmethod
    async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
        """Submit completed game results to league system"""
        pass
```

### SBA League API Client

**Location**: `app/data/sba_client.py` (not yet created)

**API Base URL**: `https://api.sba.manticorum.com` (from config)

**Key Endpoints**:
- `GET /teams/:id` - Team details
- `GET /teams/:id/roster` - Team roster
- `GET /players/:id` - Player details (simple model)
- `POST /games/submit` - Submit completed game

**Example Implementation**:
```python
from typing import Dict, Any
from .api_client import BaseApiClient

class SbaApiClient(BaseApiClient):
    """SBA League API client

    Handles communication with SBA REST API for team/player data.
    Uses simple player model (id, name, image, positions).
    """

    async def get_team(self, team_id: int) -> Dict[str, Any]:
        """Fetch SBA team details

        Args:
            team_id: SBA team ID

        Returns:
            Team data including name, manager, season
        """
        return await self._get(f"/teams/{team_id}")

    async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
        """Fetch SBA team roster

        Args:
            team_id: SBA team ID

        Returns:
            Roster data with list of players
        """
        return await self._get(f"/teams/{team_id}/roster")

    async def get_player(self, player_id: int) -> Dict[str, Any]:
        """Fetch SBA player details

        Args:
            player_id: SBA player ID

        Returns:
            Player data (id, name, image, positions, wara, team)
        """
        return await self._get(f"/players/{player_id}")

    async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
        """Submit completed SBA game to league system

        Args:
            game_data: Complete game data including plays, stats, final score

        Returns:
            Confirmation response from API
        """
        return await self._post("/games/submit", game_data)
```

### PD League API Client

**Location**: `app/data/pd_client.py` (not yet created)

**API Base URL**: `https://api.pd.manticorum.com` (from config)

**Key Endpoints**:
- `GET /api/v2/teams/:id` - Team details
- `GET /api/v2/teams/:id/roster` - Team roster
- `GET /api/v2/players/:id` - Player/card details
- `GET /api/v2/battingcardratings/player/:id` - Batting scouting data
- `GET /api/v2/pitchingcardratings/player/:id` - Pitching scouting data
- `GET /api/v2/cardsets/:id` - Cardset details
- `POST /api/v2/games/submit` - Submit completed game

**Example Implementation**:
```python
from typing import Dict, Any, Optional
from .api_client import BaseApiClient

class PdApiClient(BaseApiClient):
    """PD League API client

    Handles communication with PD REST API for team/player/card data.
    Supports detailed scouting data with batting/pitching ratings.
    """

    async def get_team(self, team_id: int) -> Dict[str, Any]:
        """Fetch PD team details"""
        return await self._get(f"/api/v2/teams/{team_id}")

    async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
        """Fetch PD team roster"""
        return await self._get(f"/api/v2/teams/{team_id}/roster")

    async def get_player(self, player_id: int) -> Dict[str, Any]:
        """Fetch PD player/card details

        Returns basic card info without scouting data.
        Use get_batting_card() and get_pitching_card() for detailed ratings.
        """
        return await self._get(f"/api/v2/players/{player_id}")

    async def get_batting_card(self, player_id: int) -> Optional[Dict[str, Any]]:
        """Fetch PD batting card scouting data

        Args:
            player_id: PD card ID

        Returns:
            Batting ratings (steal, bunting, hit ratings vs LHP/RHP) or None
        """
        try:
            return await self._get(f"/api/v2/battingcardratings/player/{player_id}")
        except ApiNotFoundError:
            # Not all cards have batting ratings (pitcher-only cards)
            return None

    async def get_pitching_card(self, player_id: int) -> Optional[Dict[str, Any]]:
        """Fetch PD pitching card scouting data

        Args:
            player_id: PD card ID

        Returns:
            Pitching ratings (balk, hold, ratings vs LHB/RHB) or None
        """
        try:
            return await self._get(f"/api/v2/pitchingcardratings/player/{player_id}")
        except ApiNotFoundError:
            # Not all cards have pitching ratings (position players)
            return None

    async def get_cardset(self, cardset_id: int) -> Dict[str, Any]:
        """Fetch PD cardset details

        Args:
            cardset_id: Cardset ID

        Returns:
            Cardset info (name, description, ranked_legal)
        """
        return await self._get(f"/api/v2/cardsets/{cardset_id}")

    async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
        """Submit completed PD game to league system"""
        return await self._post("/api/v2/games/submit", game_data)
```

## Caching Strategy

**Location**: `app/data/cache.py` (not yet created)

**Purpose**: Reduce API calls by caching frequently accessed data.

**Caching Targets**:
- Player/card data (rarely changes)
- Team rosters (changes during roster management, not during games)
- Cardset information (static)
- **NOT** game state (always use database)

**Cache Backend Options**:

### Option 1: Redis (Recommended for Production)
```python
import redis.asyncio as redis
import json
from typing import Optional, Any

class RedisCache:
    """Redis-based cache for API data

    Provides async get/set operations with TTL support.
    """

    def __init__(self, redis_url: str = "redis://localhost:6379"):
        self.redis = redis.from_url(redis_url, decode_responses=True)

    async def get(self, key: str) -> Optional[Any]:
        """Get cached value

        Args:
            key: Cache key

        Returns:
            Cached value or None if not found/expired
        """
        value = await self.redis.get(key)
        if value:
            return json.loads(value)
        return None

    async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
        """Set cache value with TTL

        Args:
            key: Cache key
            value: Value to cache (must be JSON-serializable)
            ttl: Time to live in seconds (default 1 hour)
        """
        await self.redis.set(key, json.dumps(value), ex=ttl)

    async def delete(self, key: str) -> None:
        """Delete cached value"""
        await self.redis.delete(key)

    async def clear_pattern(self, pattern: str) -> None:
        """Delete all keys matching pattern

        Args:
            pattern: Redis key pattern (e.g., "player:*")
        """
        keys = await self.redis.keys(pattern)
        if keys:
            await self.redis.delete(*keys)
```

### Option 2: In-Memory Cache (Development/Testing)
```python
import asyncio
import pendulum
from typing import Dict, Any, Optional, Tuple
from dataclasses import dataclass

@dataclass
class CacheEntry:
    """Cache entry with expiration"""
    value: Any
    expires_at: pendulum.DateTime

class MemoryCache:
    """In-memory cache for API data

    Simple dict-based cache with TTL support.
    Suitable for development/testing, not production.
    """

    def __init__(self):
        self._cache: Dict[str, CacheEntry] = {}
        self._lock = asyncio.Lock()

    async def get(self, key: str) -> Optional[Any]:
        """Get cached value if not expired"""
        async with self._lock:
            if key in self._cache:
                entry = self._cache[key]
                if pendulum.now('UTC') < entry.expires_at:
                    return entry.value
                else:
                    # Expired, remove it
                    del self._cache[key]
            return None

    async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
        """Set cache value with TTL"""
        expires_at = pendulum.now('UTC').add(seconds=ttl)
        async with self._lock:
            self._cache[key] = CacheEntry(value=value, expires_at=expires_at)

    async def delete(self, key: str) -> None:
        """Delete cached value"""
        async with self._lock:
            self._cache.pop(key, None)

    async def clear(self) -> None:
        """Clear entire cache"""
        async with self._lock:
            self._cache.clear()
```

### Cached API Client Pattern
```python
from typing import Dict, Any, Optional
from .api_client import BaseApiClient
from .cache import RedisCache

class CachedApiClient:
    """Wrapper that adds caching to API client

    Caches player/team data to reduce API calls.
    """

    def __init__(self, api_client: BaseApiClient, cache: RedisCache):
        self.api_client = api_client
        self.cache = cache

    async def get_player(self, player_id: int, use_cache: bool = True) -> Dict[str, Any]:
        """Get player data with optional caching

        Args:
            player_id: Player ID
            use_cache: Whether to use cached data (default True)

        Returns:
            Player data from cache or API
        """
        cache_key = f"player:{player_id}"

        # Try cache first
        if use_cache:
            cached = await self.cache.get(cache_key)
            if cached:
                return cached

        # Cache miss, fetch from API
        data = await self.api_client.get_player(player_id)

        # Store in cache (1 hour TTL for player data)
        await self.cache.set(cache_key, data, ttl=3600)

        return data

    async def invalidate_player(self, player_id: int) -> None:
        """Invalidate cached player data

        Call this when player data changes (roster updates).
        """
        cache_key = f"player:{player_id}"
        await self.cache.delete(cache_key)
```

## Configuration Integration

API clients should read from league configs:

**Location**: `app/config/league_configs.py` (already exists)

```python
from app.config import get_league_config

# Get league-specific API base URL
config = get_league_config("sba")
api_url = config.get_api_base_url()  # "https://api.sba.manticorum.com"

# Or for PD
config = get_league_config("pd")
api_url = config.get_api_base_url()  # "https://api.pd.manticorum.com"
```

**Environment Variables** (`.env`):
```bash
# SBA League API
SBA_API_URL=https://api.sba.manticorum.com
SBA_API_KEY=your-api-key-here

# PD League API
PD_API_URL=https://api.pd.manticorum.com
PD_API_KEY=your-api-key-here

# Cache (optional)
REDIS_URL=redis://localhost:6379
CACHE_ENABLED=true
CACHE_DEFAULT_TTL=3600
```

## Usage Examples

### Fetching Team Roster
```python
from app.data import get_api_client
from app.models import SbaPlayer, PdPlayer

async def load_team_roster(league_id: str, team_id: int):
    """Load team roster from API"""

    # Get league-specific client
    api_client = get_api_client(league_id)

    async with api_client:
        # Fetch roster data
        roster_data = await api_client.get_team_roster(team_id)

        # Parse into player models
        players = []
        for player_data in roster_data['players']:
            if league_id == "sba":
                player = SbaPlayer.from_api_response(player_data)
            else:  # PD
                # Fetch detailed scouting data
                batting = await api_client.get_batting_card(player_data['id'])
                pitching = await api_client.get_pitching_card(player_data['id'])
                player = PdPlayer.from_api_response(player_data, batting, pitching)
            players.append(player)

    return players
```

### Submitting Game Results
```python
from app.data import get_api_client

async def submit_completed_game(game_id: str, league_id: str):
    """Submit completed game to league system"""

    # Fetch game data from database
    game_data = await db_ops.export_game_data(game_id)

    # Get league API client
    api_client = get_api_client(league_id)

    async with api_client:
        # Submit to league system
        result = await api_client.submit_game_result(game_data)

    return result
```

### Using Cache
```python
from app.data import get_cached_client

async def get_player_with_cache(league_id: str, player_id: int):
    """Get player data with caching"""

    # Get cached API client
    client = get_cached_client(league_id)

    async with client:
        # First call fetches from API and caches
        player_data = await client.get_player(player_id)

        # Second call returns from cache (fast)
        player_data = await client.get_player(player_id)

    return player_data
```

## Error Handling

All API client methods raise specific exceptions:

```python
from app.data import get_api_client, ApiClientError, ApiNotFoundError, ApiConnectionError

async def safe_api_call():
    """Example of proper error handling"""

    api_client = get_api_client("sba")

    try:
        async with api_client:
            player = await api_client.get_player(123)

    except ApiNotFoundError:
        # Player doesn't exist
        logger.warning(f"Player 123 not found")
        return None

    except ApiConnectionError:
        # Network error, retry later
        logger.error("API connection failed, will retry")
        raise

    except ApiClientError as e:
        # Other API error
        logger.error(f"API error: {e}")
        raise

    return player
```

## Testing Patterns

### Mocking API Clients
```python
from unittest.mock import AsyncMock, Mock
import pytest

@pytest.fixture
def mock_sba_client():
    """Mock SBA API client for testing"""
    client = Mock()

    # Mock get_player method
    client.get_player = AsyncMock(return_value={
        "id": 123,
        "name": "Mike Trout",
        "image": "https://example.com/trout.jpg",
        "pos_1": "CF",
        "wara": 8.5
    })

    # Mock context manager
    client.__aenter__ = AsyncMock(return_value=client)
    client.__aexit__ = AsyncMock(return_value=None)

    return client

async def test_game_with_mock_api(mock_sba_client):
    """Test game engine with mocked API"""

    # Use mock instead of real API
    async with mock_sba_client:
        player_data = await mock_sba_client.get_player(123)

    assert player_data['name'] == "Mike Trout"
```

### Testing Cache Behavior
```python
async def test_cache_hit():
    """Test cache returns cached value"""
    cache = MemoryCache()

    # Set value
    await cache.set("test:key", {"data": "value"}, ttl=60)

    # Get value (should be cached)
    result = await cache.get("test:key")
    assert result == {"data": "value"}

async def test_cache_expiration():
    """Test cache expires after TTL"""
    cache = MemoryCache()

    # Set value with 1 second TTL
    await cache.set("test:key", {"data": "value"}, ttl=1)

    # Wait for expiration
    await asyncio.sleep(2)

    # Should be expired
    result = await cache.get("test:key")
    assert result is None
```

## Common Tasks

### Adding a New API Endpoint

1. **Add method to appropriate client**:
```python
# In sba_client.py or pd_client.py
async def get_new_endpoint(self, param: int) -> Dict[str, Any]:
    """Fetch new endpoint data"""
    return await self._get(f"/new/endpoint/{param}")
```

2. **Update tests**:
```python
async def test_new_endpoint(sba_client):
    """Test new endpoint"""
    result = await sba_client.get_new_endpoint(123)
    assert result is not None
```

3. **Document in this file**: Add example to usage section

### Modifying Cache Behavior

1. **Adjust TTL for specific data type**:
```python
# Player data: 1 hour (changes rarely)
await cache.set(f"player:{player_id}", data, ttl=3600)

# Team roster: 10 minutes (may change during roster management)
await cache.set(f"roster:{team_id}", data, ttl=600)

# Cardset data: 24 hours (static)
await cache.set(f"cardset:{cardset_id}", data, ttl=86400)
```

2. **Add cache invalidation triggers**:
```python
async def on_roster_update(team_id: int):
    """Invalidate roster cache when roster changes"""
    await cache.delete(f"roster:{team_id}")
```

### Switching Cache Backends

Development (in-memory):
```python
from app.data.cache import MemoryCache

cache = MemoryCache()
```

Production (Redis):
```python
from app.data.cache import RedisCache
from app.config import settings

cache = RedisCache(settings.redis_url)
```

## Troubleshooting

### API Connection Issues

**Symptom**: `ApiConnectionError: Failed to connect to API`

**Checks**:
1. Verify API URL in config: `config.get_api_base_url()`
2. Check network connectivity: `curl https://api.sba.manticorum.com/health`
3. Verify API key is set: `echo $SBA_API_KEY`
4. Check firewall/network access

### Authentication Failures

**Symptom**: `ApiAuthenticationError: Authentication failed`

**Checks**:
1. Verify API key is correct
2. Check API key format (Bearer token vs other)
3. Confirm API key has required permissions
4. Check API key expiration

### Cache Not Working

**Symptom**: Every request hits API instead of cache

**Checks**:
1. Verify cache is initialized: `cache is not None`
2. Check Redis is running: `redis-cli ping` (should return "PONG")
3. Verify cache keys are consistent
4. Check TTL isn't too short

### Rate Limiting

**Symptom**: API returns 429 (Too Many Requests)

**Solutions**:
1. Implement exponential backoff retry logic
2. Reduce API call frequency
3. Increase cache TTL to reduce calls
4. Add rate limiting protection in client

## Performance Targets

- **API Response Time**: < 500ms for typical requests
- **Cache Hit Ratio**: > 80% for player/team data
- **Cache Lookup Time**: < 10ms (Redis), < 1ms (memory)
- **Concurrent Requests**: Support 10+ simultaneous API calls

## Security Considerations

- **API Keys**: Store in environment variables, never commit to git
- **HTTPS Only**: All API communication over encrypted connections
- **Input Validation**: Validate all API responses with Pydantic models
- **Error Messages**: Don't expose API keys or internal details in logs
- **Rate Limiting**: Respect API rate limits, implement backoff

## Dependencies

**Required**:
```txt
httpx>=0.25.0          # Async HTTP client
pydantic>=2.10.0       # Response validation (already installed)
```

**Optional**:
```txt
redis>=5.0.0           # Redis cache backend
aiohttp>=3.9.0         # Alternative HTTP client
```

## Implementation Priority

**Phase 1** (Week 7-8):
1. ✅ Create base API client with error handling
2. ✅ Implement SBA client for simple player data
3. ✅ Add basic integration tests
4. ⏳ Connect to game engine for roster loading

**Phase 2** (Week 9-10):
1. ⏳ Implement PD client with scouting data
2. ⏳ Add in-memory cache for development
3. ⏳ Add result submission endpoints

**Phase 3** (Post-MVP):
1. ⏳ Add Redis cache for production
2. ⏳ Implement advanced retry logic
3. ⏳ Add request/response logging
4. ⏳ Performance optimization

## References

- **PRD API Section**: `../../prd-web-scorecard-1.1.md` (lines 87-90, 1119-1126)
- **Player Models**: `../models/player_models.py` - SbaPlayer, PdPlayer classes
- **League Configs**: `../config/league_configs.py` - API URLs and settings
- **Backend Architecture**: `../CLAUDE.md` - Overall backend structure

---

**Status**: 🚧 **AWAITING IMPLEMENTATION**
**Current Phase**: Phase 2 - Week 7 (Runner Advancement & Play Resolution)
**Next Steps**: Implement base API client and SBA client for roster loading

**Note**: This directory will be populated during Phase 2 integration work when game engine needs to fetch real player data from league APIs.
