# Mappers & Factories Specification

**Purpose**: Transform API models to game models and create players by league

**File**: `backend/app/models/player_models.py` (mapper section)

---

## Overview

Two key classes:
1. **PlayerMapper** - Transforms API models → Game models
2. **PlayerFactory** - Creates players by league_id (uses API client + mapper)

---

## PlayerMapper Class

### Purpose

Convert API response models to game-optimized models:
- Extract only essential fields
- Flatten nested structures
- Normalize position data (pos_1-8 → List[str])
- Combine multiple API responses (PD: player + ratings)

### Implementation

```python
class PlayerMapper:
    """
    Maps API response models to game models

    Handles league-specific transformation logic.
    """

    @staticmethod
    def extract_positions(
        pos_1: Optional[str],
        pos_2: Optional[str],
        pos_3: Optional[str],
        pos_4: Optional[str],
        pos_5: Optional[str],
        pos_6: Optional[str],
        pos_7: Optional[str],
        pos_8: Optional[str]
    ) -> List[str]:
        """
        Extract position list from pos_1 through pos_8 fields

        Args:
            pos_1 through pos_8: Position fields from API

        Returns:
            List of non-null positions, e.g., ["RF", "CF", "DH"]
        """
        positions = [pos_1, pos_2, pos_3, pos_4, pos_5, pos_6, pos_7, pos_8]
        return [p for p in positions if p is not None]

    @staticmethod
    def map_sba_player(api_player: SbaPlayerApi) -> SbaPlayer:
        """
        Map SBA API player to game model

        Args:
            api_player: Full API response with nested team data

        Returns:
            Simplified SbaPlayer for gameplay
        """
        return SbaPlayer(
            player_id=api_player.id,
            name=api_player.name,
            positions=PlayerMapper.extract_positions(
                api_player.pos_1, api_player.pos_2, api_player.pos_3,
                api_player.pos_4, api_player.pos_5, api_player.pos_6,
                api_player.pos_7, api_player.pos_8
            ),
            image_url=api_player.image,
            team_name=api_player.team.lname,
            team_abbrev=api_player.team.abbrev,
            strat_code=api_player.strat_code
        )

    @staticmethod
    def _flatten_batting_rating(api_rating: PdBattingRatingApi) -> PdBattingRating:
        """
        Flatten PD batting rating API response to game model

        Combines outcome fields (e.g., double_three + double_two + double_pull → double)
        """
        return PdBattingRating(
            vs_hand=api_rating.vs_hand,
            homerun=api_rating.homerun,
            triple=api_rating.triple,
            double=(
                api_rating.double_three +
                api_rating.double_two +
                api_rating.double_pull
            ),
            single=(
                api_rating.single_two +
                api_rating.single_one +
                api_rating.single_center
            ),
            walk=api_rating.walk,
            hbp=api_rating.hbp,
            strikeout=api_rating.strikeout,
            flyout=(
                api_rating.flyout_a +
                api_rating.flyout_bq +
                api_rating.flyout_lf_b +
                api_rating.flyout_rf_b
            ),
            lineout=api_rating.lineout,
            popout=api_rating.popout,
            groundout=(
                api_rating.groundout_a +
                api_rating.groundout_b +
                api_rating.groundout_c
            ),
            bp_homerun=api_rating.bp_homerun,
            bp_single=api_rating.bp_single,
            avg=api_rating.avg,
            obp=api_rating.obp,
            slg=api_rating.slg
        )

    @staticmethod
    def _flatten_pitching_rating(api_rating: PdPitchingRatingApi) -> PdPitchingRating:
        """
        Flatten PD pitching rating API response to game model

        Combines outcome fields and extracts xcheck dictionary
        """
        return PdPitchingRating(
            vs_hand=api_rating.vs_hand,
            homerun=api_rating.homerun,
            triple=api_rating.triple,
            double=(
                api_rating.double_three +
                api_rating.double_two +
                api_rating.double_cf
            ),
            single=(
                api_rating.single_two +
                api_rating.single_one +
                api_rating.single_center
            ),
            walk=api_rating.walk,
            hbp=api_rating.hbp,
            strikeout=api_rating.strikeout,
            flyout=(
                api_rating.flyout_lf_b +
                api_rating.flyout_cf_b +
                api_rating.flyout_rf_b
            ),
            groundout=(
                api_rating.groundout_a +
                api_rating.groundout_b
            ),
            bp_homerun=api_rating.bp_homerun,
            bp_single=api_rating.bp_single,
            xcheck={
                "P": api_rating.xcheck_p,
                "C": api_rating.xcheck_c,
                "1B": api_rating.xcheck_1b,
                "2B": api_rating.xcheck_2b,
                "3B": api_rating.xcheck_3b,
                "SS": api_rating.xcheck_ss,
                "LF": api_rating.xcheck_lf,
                "CF": api_rating.xcheck_cf,
                "RF": api_rating.xcheck_rf
            },
            avg=api_rating.avg,
            obp=api_rating.obp,
            slg=api_rating.slg
        )

    @staticmethod
    def map_pd_player(
        api_player: PdPlayerApi,
        batting_ratings: Optional[PdBattingRatingsResponseApi] = None,
        pitching_ratings: Optional[PdPitchingRatingsResponseApi] = None
    ) -> PdPlayer:
        """
        Map PD API player + ratings to game model

        Args:
            api_player: Basic player data
            batting_ratings: Batting ratings response (2 ratings: vs L and vs R)
            pitching_ratings: Pitching ratings response (2 ratings: vs L and vs R)

        Returns:
            Complete PdPlayer for gameplay with ratings attached
        """
        # Determine if pitcher (has pitching card)
        is_pitcher = pitching_ratings is not None

        # Extract batting ratings (all players have batting, even pitchers)
        batting_vs_lhp = None
        batting_vs_rhp = None
        if batting_ratings:
            for rating in batting_ratings.ratings:
                flattened = PlayerMapper._flatten_batting_rating(rating)
                if rating.vs_hand == "L":
                    batting_vs_lhp = flattened
                elif rating.vs_hand == "R":
                    batting_vs_rhp = flattened

            # Extract baserunning from batting card
            batting_card = batting_ratings.ratings[0].battingcard
            steal_rating = f"{batting_card.steal_low}-{batting_card.steal_high}"
            running_speed = batting_card.running
            hand = batting_card.hand
        else:
            steal_rating = None
            running_speed = None
            hand = "R"  # Default

        # Extract pitching ratings (only for pitchers)
        pitching_vs_lhb = None
        pitching_vs_rhb = None
        wild_pitch_range = None
        if pitching_ratings:
            for rating in pitching_ratings.ratings:
                flattened = PlayerMapper._flatten_pitching_rating(rating)
                if rating.vs_hand == "L":
                    pitching_vs_lhb = flattened
                elif rating.vs_hand == "R":
                    pitching_vs_rhb = flattened

            # Extract pitcher-specific data
            pitching_card = pitching_ratings.ratings[0].pitchingcard
            wild_pitch_range = pitching_card.wild_pitch
            hand = pitching_card.hand  # Pitcher handedness

        return PdPlayer(
            player_id=api_player.player_id,
            name=api_player.p_name,
            positions=PlayerMapper.extract_positions(
                api_player.pos_1, api_player.pos_2, api_player.pos_3,
                api_player.pos_4, api_player.pos_5, api_player.pos_6,
                api_player.pos_7, api_player.pos_8
            ),
            image_url=api_player.image,
            team_name=api_player.mlbclub,
            cardset_id=api_player.cardset.id,
            cardset_name=api_player.cardset.name,
            is_pitcher=is_pitcher,
            hand=hand,
            batting_vs_lhp=batting_vs_lhp,
            batting_vs_rhp=batting_vs_rhp,
            pitching_vs_lhb=pitching_vs_lhb,
            pitching_vs_rhb=pitching_vs_rhb,
            steal_rating=steal_rating,
            running_speed=running_speed,
            wild_pitch_range=wild_pitch_range
        )
```

---

## PlayerFactory Class

### Purpose

High-level interface to fetch and create players:
- Abstracts away API client details
- Handles league-specific logic (1 call for SBA, 3 for PD)
- Returns game models ready for use

### Implementation

```python
from app.data.api_client import LeagueApiClient


class PlayerFactory:
    """
    Factory for creating player instances by league

    Uses API client to fetch data and mapper to transform.
    """

    @staticmethod
    async def create_player(league_id: str, player_id: int) -> BasePlayer:
        """
        Fetch player from API and create game model

        Args:
            league_id: 'sba' or 'pd'
            player_id: Player ID in that league

        Returns:
            SbaPlayer or PdPlayer (via BasePlayer type)

        Raises:
            ValueError: If league_id is unknown
            HTTPError: If API call fails
        """
        if league_id == "sba":
            return await PlayerFactory._create_sba_player(player_id)
        elif league_id == "pd":
            return await PlayerFactory._create_pd_player(player_id)
        else:
            raise ValueError(f"Unknown league: {league_id}")

    @staticmethod
    async def _create_sba_player(player_id: int) -> SbaPlayer:
        """
        Fetch and create SBA player (1 API call)

        Args:
            player_id: SBA player ID

        Returns:
            SbaPlayer game model
        """
        # Create API client
        client = LeagueApiClient(league_id="sba")

        try:
            # Fetch player data
            api_player = await client.get_player(player_id)

            # Map to game model
            game_player = PlayerMapper.map_sba_player(api_player)

            return game_player

        finally:
            await client.close()

    @staticmethod
    async def _create_pd_player(player_id: int) -> PdPlayer:
        """
        Fetch and create PD player (3 API calls)

        Args:
            player_id: PD player ID

        Returns:
            PdPlayer game model with ratings
        """
        # Create API client
        client = LeagueApiClient(league_id="pd")

        try:
            # Fetch player data (call 1)
            api_player = await client.get_player(player_id)

            # Fetch batting ratings (call 2)
            batting_ratings = await client.get_batting_ratings(player_id)

            # Fetch pitching ratings (call 3) - may fail if not a pitcher
            try:
                pitching_ratings = await client.get_pitching_ratings(player_id)
            except Exception:
                # Not a pitcher - that's okay
                pitching_ratings = None

            # Map to game model
            game_player = PlayerMapper.map_pd_player(
                api_player,
                batting_ratings,
                pitching_ratings
            )

            return game_player

        finally:
            await client.close()

    @staticmethod
    async def create_lineup(
        league_id: str,
        player_ids: List[int]
    ) -> List[BasePlayer]:
        """
        Create multiple players for a lineup

        Args:
            league_id: 'sba' or 'pd'
            player_ids: List of player IDs

        Returns:
            List of player game models
        """
        players = []
        for player_id in player_ids:
            player = await PlayerFactory.create_player(league_id, player_id)
            players.append(player)
        return players
```

---

## Usage Examples

### Basic Usage

```python
from app.models.player_models import PlayerFactory

# Fetch SBA player
sba_player = await PlayerFactory.create_player("sba", 12288)
print(f"{sba_player.name} plays for {sba_player.team_name}")
print(f"Positions: {sba_player.positions}")

# Fetch PD player with ratings
pd_player = await PlayerFactory.create_player("pd", 10633)
print(f"{pd_player.name} - {pd_player.cardset_name}")

# Get batting rating vs RHP
rating = pd_player.get_batting_rating("R")
print(f"vs RHP - AVG: {rating.avg}, HR%: {rating.homerun}")
```

### In GameEngine

```python
# When creating a game, fetch lineups
away_player_ids = [101, 102, 103, 104, 105, 106, 107, 108, 109]
home_player_ids = [201, 202, 203, 204, 205, 206, 207, 208, 209]

away_lineup = await PlayerFactory.create_lineup("sba", away_player_ids)
home_lineup = await PlayerFactory.create_lineup("sba", home_player_ids)

# Store in game state
state.away_lineup = away_lineup
state.home_lineup = home_lineup
```

---

## Position Extraction Logic

### Input
```python
pos_1 = "RF"
pos_2 = "CF"
pos_3 = "DH"
pos_4 = None
pos_5 = None
pos_6 = None
pos_7 = None
pos_8 = None
```

### Output
```python
positions = ["RF", "CF", "DH"]
```

### Edge Cases

| Input | Output | Notes |
|-------|--------|-------|
| All None | `[]` | Invalid - should fail validation |
| `["P"]` | `["P"]` | Pitcher only |
| `["SP", "RP"]` | `["SP", "RP"]` | Starter/reliever |
| `["RF", None, "DH"]` | `["RF", "DH"]` | Skips None values |

---

## Testing Strategy

### Unit Tests

```python
def test_extract_positions_multiple():
    """Test extracting multiple positions"""
    positions = PlayerMapper.extract_positions(
        "RF", "CF", "DH", None, None, None, None, None
    )
    assert positions == ["RF", "CF", "DH"]

def test_map_sba_player():
    """Test SBA player mapping"""
    api_player = SbaPlayerApi(...)  # Full API response
    game_player = PlayerMapper.map_sba_player(api_player)

    assert game_player.player_id == api_player.id
    assert game_player.name == api_player.name
    assert game_player.team_name == api_player.team.lname

def test_map_pd_player_with_ratings():
    """Test PD player mapping with ratings"""
    api_player = PdPlayerApi(...)
    batting_ratings = PdBattingRatingsResponseApi(...)
    pitching_ratings = None  # Position player

    game_player = PlayerMapper.map_pd_player(
        api_player,
        batting_ratings,
        pitching_ratings
    )

    assert game_player.player_id == api_player.player_id
    assert game_player.batting_vs_lhp is not None
    assert game_player.batting_vs_rhp is not None
    assert game_player.pitching_vs_lhb is None  # Not a pitcher

def test_flattened_batting_rating_combines_outcomes():
    """Test that double/single/etc are properly combined"""
    api_rating = PdBattingRatingApi(
        double_three=1.0,
        double_two=2.0,
        double_pull=3.0,
        ...
    )

    game_rating = PlayerMapper._flatten_batting_rating(api_rating)

    assert game_rating.double == 6.0  # 1.0 + 2.0 + 3.0
```

---

## Performance Considerations

### API Call Counts

| League | API Calls | Total Time (est) |
|--------|-----------|------------------|
| SBA | 1 | ~200ms |
| PD Position Player | 2 (player + batting) | ~400ms |
| PD Pitcher | 3 (player + batting + pitching) | ~600ms |

### Caching Strategy

For future optimization:
```python
# Add simple in-memory cache
_player_cache: Dict[Tuple[str, int], BasePlayer] = {}

async def create_player_cached(league_id: str, player_id: int) -> BasePlayer:
    """Create player with caching"""
    cache_key = (league_id, player_id)

    if cache_key in _player_cache:
        return _player_cache[cache_key]

    player = await PlayerFactory.create_player(league_id, player_id)
    _player_cache[cache_key] = player
    return player
```

---

**Next**: See [api-client.md](./api-client.md) for HTTP client implementation
