""" Trade Builder Service Extends the TransactionBuilder to support multi-team trades and player exchanges. """ import logging from typing import Dict, List, Optional, Tuple from datetime import datetime, timezone import uuid from models.trade import Trade, TradeParticipant, TradeMove, TradeStatus from models.team import Team, RosterType from models.player import Player from services.transaction_builder import TransactionBuilder, RosterValidationResult, TransactionMove from services.team_service import team_service from services.roster_service import roster_service from services.league_service import league_service from constants import SBA_CURRENT_SEASON, FREE_AGENT_TEAM_ID logger = logging.getLogger(f'{__name__}.TradeBuilder') class TradeValidationResult: """Results of trade validation across all participating teams.""" def __init__(self): self.is_legal: bool = True self.participant_validations: Dict[int, RosterValidationResult] = {} self.trade_errors: List[str] = [] self.trade_warnings: List[str] = [] self.trade_suggestions: List[str] = [] @property def all_errors(self) -> List[str]: """Get all errors including trade-level and roster-level errors.""" errors = self.trade_errors.copy() for validation in self.participant_validations.values(): errors.extend(validation.errors) return errors @property def all_warnings(self) -> List[str]: """Get all warnings across trade and roster levels.""" warnings = self.trade_warnings.copy() for validation in self.participant_validations.values(): warnings.extend(validation.warnings) return warnings @property def all_suggestions(self) -> List[str]: """Get all suggestions across trade and roster levels.""" suggestions = self.trade_suggestions.copy() for validation in self.participant_validations.values(): suggestions.extend(validation.suggestions) return suggestions def get_participant_validation(self, team_id: int) -> Optional[RosterValidationResult]: """Get validation result for a specific team.""" return self.participant_validations.get(team_id) class TradeBuilder: """ Interactive trade builder for multi-team player exchanges. Extends the functionality of TransactionBuilder to support trades between teams. """ def __init__(self, initiated_by: int, initiating_team: Team, season: int = SBA_CURRENT_SEASON): """ Initialize trade builder. Args: initiated_by: Discord user ID who initiated the trade initiating_team: Team that initiated the trade season: Season number """ self.trade = Trade( trade_id=str(uuid.uuid4())[:8], # Short trade ID participants=[], status=TradeStatus.DRAFT, initiated_by=initiated_by, created_at=datetime.now(timezone.utc).isoformat(), season=season ) # Add the initiating team as first participant self.trade.add_participant(initiating_team) # Cache transaction builders for each participating team self._team_builders: Dict[int, TransactionBuilder] = {} logger.info(f"TradeBuilder initialized: {self.trade.trade_id} by user {initiated_by} for {initiating_team.abbrev}") @property def trade_id(self) -> str: """Get the trade ID.""" return self.trade.trade_id @property def participating_teams(self) -> List[Team]: """Get all participating teams.""" return self.trade.participating_teams @property def team_count(self) -> int: """Get number of participating teams.""" return self.trade.team_count @property def is_empty(self) -> bool: """Check if trade has no moves.""" return self.trade.total_moves == 0 @property def move_count(self) -> int: """Get total number of moves in trade.""" return self.trade.total_moves async def add_team(self, team: Team) -> tuple[bool, str]: """ Add a team to the trade. Args: team: Team to add Returns: Tuple of (success: bool, error_message: str) """ # Check if team is already participating if self.trade.get_participant_by_team_id(team.id): return False, f"{team.abbrev} is already participating in this trade" # Add team to trade participant = self.trade.add_participant(team) # Create transaction builder for this team self._team_builders[team.id] = TransactionBuilder(team, self.trade.initiated_by, self.trade.season) logger.info(f"Added team {team.abbrev} to trade {self.trade_id}") return True, "" async def remove_team(self, team_id: int) -> tuple[bool, str]: """ Remove a team from the trade. Args: team_id: ID of team to remove Returns: Tuple of (success: bool, error_message: str) """ participant = self.trade.get_participant_by_team_id(team_id) if not participant: return False, "Team is not participating in this trade" # Check if team has moves - prevent removal if they do if participant.all_moves: return False, f"{participant.team.abbrev} has moves in this trade and cannot be removed" # Remove team removed = self.trade.remove_participant(team_id) if removed and team_id in self._team_builders: del self._team_builders[team_id] if removed: logger.info(f"Removed team {team_id} from trade {self.trade_id}") return removed, "" if removed else "Failed to remove team" async def add_player_move( self, player: Player, from_team: Team, to_team: Team, from_roster: RosterType, to_roster: RosterType ) -> tuple[bool, str]: """ Add a player move to the trade. Args: player: Player being moved from_team: Team giving up the player to_team: Team receiving the player from_roster: Source roster type to_roster: Destination roster type Returns: Tuple of (success: bool, error_message: str) """ # Validate player is not from Free Agency if player.team_id == FREE_AGENT_TEAM_ID: return False, f"Cannot add {player.name} from Free Agency. Players must be traded from teams within the organizations involved in the trade." # Validate player has a valid team assignment if not player.team_id: return False, f"{player.name} does not have a valid team assignment" # Validate that from_team matches the player's actual team organization player_team = await team_service.get_team(player.team_id) if not player_team: return False, f"Could not find team for {player.name}" # Check if player's team is in the same organization as from_team if not player_team.is_same_organization(from_team): return False, f"{player.name} is on {player_team.abbrev}, they are not eligible to be added to the trade." # Ensure both teams are participating (check by organization for ML authority) from_participant = self.trade.get_participant_by_organization(from_team) to_participant = self.trade.get_participant_by_organization(to_team) if not from_participant: return False, f"{from_team.abbrev} is not participating in this trade" if not to_participant: return False, f"{to_team.abbrev} is not participating in this trade" # Check if player is already involved in a move for participant in self.trade.participants: for existing_move in participant.all_moves: if existing_move.player.id == player.id: return False, f"{player.name} is already involved in a move in this trade" # Create trade move trade_move = TradeMove( player=player, from_roster=from_roster, to_roster=to_roster, from_team=from_team, to_team=to_team, source_team=from_team, destination_team=to_team ) # Add to giving team's moves from_participant.moves_giving.append(trade_move) # Add to receiving team's moves to_participant.moves_receiving.append(trade_move) # Create corresponding transaction moves for each team's builder from_builder = self._get_or_create_builder(from_team) to_builder = self._get_or_create_builder(to_team) # Move for giving team (player leaving) from_move = TransactionMove( player=player, from_roster=from_roster, to_roster=RosterType.FREE_AGENCY, # Conceptually leaving the org from_team=from_team, to_team=None ) # Move for receiving team (player joining) to_move = TransactionMove( player=player, from_roster=RosterType.FREE_AGENCY, # Conceptually joining from outside to_roster=to_roster, from_team=None, to_team=to_team ) # Add moves to respective builders from_success, from_error = from_builder.add_move(from_move) if not from_success: # Remove from trade if builder failed from_participant.moves_giving.remove(trade_move) to_participant.moves_receiving.remove(trade_move) return False, f"Error adding move to {from_team.abbrev}: {from_error}" to_success, to_error = to_builder.add_move(to_move) if not to_success: # Rollback both if second failed from_builder.remove_move(player.id) from_participant.moves_giving.remove(trade_move) to_participant.moves_receiving.remove(trade_move) return False, f"Error adding move to {to_team.abbrev}: {to_error}" logger.info(f"Added player move to trade {self.trade_id}: {trade_move.description}") return True, "" async def add_supplementary_move( self, team: Team, player: Player, from_roster: RosterType, to_roster: RosterType ) -> tuple[bool, str]: """ Add a supplementary move (internal organizational move) for roster legality. Args: team: Team making the internal move player: Player being moved from_roster: Source roster type to_roster: Destination roster type Returns: Tuple of (success: bool, error_message: str) """ participant = self.trade.get_participant_by_organization(team) if not participant: return False, f"{team.abbrev} is not participating in this trade" # Create supplementary move (internal to organization) supp_move = TradeMove( player=player, from_roster=from_roster, to_roster=to_roster, from_team=team, to_team=team, source_team=team, destination_team=team ) # Add to participant's supplementary moves participant.supplementary_moves.append(supp_move) # Add to team's transaction builder builder = self._get_or_create_builder(team) trans_move = TransactionMove( player=player, from_roster=from_roster, to_roster=to_roster, from_team=team, to_team=team ) success, error = builder.add_move(trans_move) if not success: participant.supplementary_moves.remove(supp_move) return False, error logger.info(f"Added supplementary move for {team.abbrev}: {supp_move.description}") return True, "" async def remove_move(self, player_id: int) -> tuple[bool, str]: """ Remove a move from the trade. Args: player_id: ID of player whose move to remove Returns: Tuple of (success: bool, error_message: str) """ # Find and remove the move from all participants removed_move = None for participant in self.trade.participants: # Check moves_giving for move in participant.moves_giving[:]: if move.player.id == player_id: participant.moves_giving.remove(move) removed_move = move break # Check moves_receiving for move in participant.moves_receiving[:]: if move.player.id == player_id: participant.moves_receiving.remove(move) # Don't set removed_move again, we already got it from giving break # Check supplementary_moves for move in participant.supplementary_moves[:]: if move.player.id == player_id: participant.supplementary_moves.remove(move) removed_move = move break if not removed_move: return False, "No move found for that player" # Remove from transaction builders for builder in self._team_builders.values(): builder.remove_move(player_id) logger.info(f"Removed move from trade {self.trade_id}: {removed_move.description}") return True, "" async def validate_trade(self, next_week: Optional[int] = None) -> TradeValidationResult: """ Validate the entire trade including all teams' roster legality. Args: next_week: Week to validate for (optional) Returns: TradeValidationResult with comprehensive validation """ result = TradeValidationResult() # Validate trade structure is_balanced, balance_errors = self.trade.validate_trade_balance() if not is_balanced: result.is_legal = False result.trade_errors.extend(balance_errors) # Validate each team's roster after the trade for participant in self.trade.participants: team_id = participant.team.id if team_id in self._team_builders: builder = self._team_builders[team_id] roster_validation = await builder.validate_transaction(next_week) result.participant_validations[team_id] = roster_validation if not roster_validation.is_legal: result.is_legal = False # Add trade-level suggestions if self.is_empty: result.trade_suggestions.append("Add player moves to build your trade") if self.team_count < 2: result.trade_suggestions.append("Add another team to create a trade") logger.debug(f"Trade validation for {self.trade_id}: Legal={result.is_legal}, Errors={len(result.all_errors)}") return result def _get_or_create_builder(self, team: Team) -> TransactionBuilder: """Get or create a transaction builder for a team.""" if team.id not in self._team_builders: self._team_builders[team.id] = TransactionBuilder(team, self.trade.initiated_by, self.trade.season) return self._team_builders[team.id] def clear_trade(self) -> None: """Clear all moves from the trade.""" for participant in self.trade.participants: participant.moves_giving.clear() participant.moves_receiving.clear() participant.supplementary_moves.clear() for builder in self._team_builders.values(): builder.clear_moves() logger.info(f"Cleared all moves from trade {self.trade_id}") def get_trade_summary(self) -> str: """Get human-readable trade summary.""" return self.trade.get_trade_summary() # Global cache for active trade builders _active_trade_builders: Dict[str, TradeBuilder] = {} def get_trade_builder(user_id: int, initiating_team: Team) -> TradeBuilder: """ Get or create a trade builder for a user. Args: user_id: Discord user ID initiating_team: Team initiating the trade Returns: TradeBuilder instance """ # For now, use user_id as the key. In the future, could support multiple concurrent trades trade_key = f"{user_id}:trade" if trade_key not in _active_trade_builders: _active_trade_builders[trade_key] = TradeBuilder(user_id, initiating_team) return _active_trade_builders[trade_key] def clear_trade_builder(user_id: int) -> None: """Clear trade builder for a user.""" trade_key = f"{user_id}:trade" if trade_key in _active_trade_builders: del _active_trade_builders[trade_key] logger.info(f"Cleared trade builder for user {user_id}") def get_active_trades() -> Dict[str, TradeBuilder]: """Get all active trade builders (for debugging/admin purposes).""" return _active_trade_builders.copy()