from fastapi import APIRouter, Depends, HTTPException, Query import logging from typing import Optional from ..db_engine import model_to_dict from ..dependencies import oauth2_scheme, valid_token logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v2/refractor", tags=["refractor"]) # Tier -> threshold attribute name. Index = current_tier; value is the # attribute on RefractorTrack whose value is the *next* threshold to reach. # Tier 4 is fully evolved so there is no next threshold (None sentinel). _NEXT_THRESHOLD_ATTR = { 0: "t1_threshold", 1: "t2_threshold", 2: "t3_threshold", 3: "t4_threshold", 4: None, } def _build_card_state_response(state) -> dict: """Serialise a RefractorCardState into the standard API response shape. Produces a flat dict with player_id and team_id as plain integers, a nested 'track' dict with all threshold fields, and a computed 'next_threshold' field: - For tiers 0-3: the threshold value for the tier immediately above. - For tier 4 (fully evolved): None. Uses model_to_dict(recurse=False) internally so FK fields are returned as IDs rather than nested objects, then promotes the needed IDs up to the top level. """ track = state.track track_dict = model_to_dict(track, recurse=False) next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier) next_threshold = getattr(track, next_attr) if next_attr else None return { "player_id": state.player_id, "team_id": state.team_id, "current_tier": state.current_tier, "current_value": state.current_value, "fully_evolved": state.fully_evolved, "last_evaluated_at": ( state.last_evaluated_at.isoformat() if state.last_evaluated_at else None ), "track": track_dict, "next_threshold": next_threshold, } @router.get("/tracks") async def list_tracks( card_type: Optional[str] = Query(default=None), token: str = Depends(oauth2_scheme), ): if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") from ..db_engine import RefractorTrack query = RefractorTrack.select() if card_type is not None: query = query.where(RefractorTrack.card_type == card_type) items = [model_to_dict(t, recurse=False) for t in query] return {"count": len(items), "items": items} @router.get("/tracks/{track_id}") async def get_track(track_id: int, token: str = Depends(oauth2_scheme)): if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") from ..db_engine import RefractorTrack try: track = RefractorTrack.get_by_id(track_id) except Exception: raise HTTPException(status_code=404, detail=f"Track {track_id} not found") return model_to_dict(track, recurse=False) @router.get("/cards/{card_id}") async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)): """Return the RefractorCardState for a card identified by its Card.id. Resolves card_id -> (player_id, team_id) via the Card table, then looks up the matching RefractorCardState row. Because duplicate cards for the same player+team share one state row (unique-(player,team) constraint), any card_id belonging to that player on that team returns the same state. Returns 404 when: - The card_id does not exist in the Card table. - The card exists but has no corresponding RefractorCardState yet. """ if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") from ..db_engine import Card, RefractorCardState, RefractorTrack, DoesNotExist # Resolve card_id to player+team try: card = Card.get_by_id(card_id) except DoesNotExist: raise HTTPException(status_code=404, detail=f"Card {card_id} not found") # Look up the refractor state for this (player, team) pair, joining the # track so a single query resolves both rows. try: state = ( RefractorCardState.select(RefractorCardState, RefractorTrack) .join(RefractorTrack) .where( (RefractorCardState.player == card.player_id) & (RefractorCardState.team == card.team_id) ) .get() ) except DoesNotExist: raise HTTPException( status_code=404, detail=f"No refractor state for card {card_id}", ) return _build_card_state_response(state) @router.post("/cards/{card_id}/evaluate") async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)): """Force-recalculate refractor state for a card from career stats. Resolves card_id to (player_id, team_id), then recomputes the refractor tier from all player_season_stats rows for that pair. Idempotent. """ if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") from ..db_engine import Card from ..services.refractor_evaluator import evaluate_card as _evaluate try: card = Card.get_by_id(card_id) except Exception: raise HTTPException(status_code=404, detail=f"Card {card_id} not found") try: result = _evaluate(card.player_id, card.team_id) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) return result @router.post("/evaluate-game/{game_id}") async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): """Evaluate refractor state for all players who appeared in a game. Finds all unique (player_id, team_id) pairs from the game's StratPlay rows, then for each pair that has a RefractorCardState, re-computes the refractor tier. Pairs without a state row are silently skipped. Per-player errors are logged but do not abort the batch. """ if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") from ..db_engine import RefractorCardState, RefractorTrack, Player, StratPlay from ..services.refractor_evaluator import evaluate_card plays = list(StratPlay.select().where(StratPlay.game == game_id)) pairs: set[tuple[int, int]] = set() for play in plays: if play.batter_id is not None: pairs.add((play.batter_id, play.batter_team_id)) if play.pitcher_id is not None: pairs.add((play.pitcher_id, play.pitcher_team_id)) evaluated = 0 tier_ups = [] for player_id, team_id in pairs: try: state = RefractorCardState.get_or_none( (RefractorCardState.player_id == player_id) & (RefractorCardState.team_id == team_id) ) if state is None: continue old_tier = state.current_tier result = evaluate_card(player_id, team_id) evaluated += 1 new_tier = result.get("current_tier", old_tier) if new_tier > old_tier: player_name = "Unknown" try: p = Player.get_by_id(player_id) player_name = p.p_name except Exception: pass tier_ups.append( { "player_id": player_id, "team_id": team_id, "player_name": player_name, "old_tier": old_tier, "new_tier": new_tier, "current_value": result.get("current_value", 0), "track_name": state.track.name if state.track else "Unknown", } ) except Exception as exc: logger.warning( f"Refractor eval failed for player={player_id} team={team_id}: {exc}" ) return {"evaluated": evaluated, "tier_ups": tier_ups}