import os 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 from ..services.refractor_init import initialize_card_refractor, _determine_card_type 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, player_name=None) -> 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 computed fields: - 'next_threshold': threshold for the tier immediately above (None when fully evolved). - 'progress_pct': current_value / next_threshold * 100, rounded to 1 decimal (None when fully evolved or next_threshold is zero). - 'player_name': included when passed (e.g. from a list join); omitted otherwise. 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 progress_pct = None if next_threshold is not None and next_threshold > 0: progress_pct = round((state.current_value / next_threshold) * 100, 1) result = { "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 hasattr(state.last_evaluated_at, "isoformat") else state.last_evaluated_at or None ), "track": track_dict, "next_threshold": next_threshold, "progress_pct": progress_pct, } if player_name is not None: result["player_name"] = player_name return result @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") async def list_card_states( team_id: int = Query(...), card_type: Optional[str] = Query(default=None), tier: Optional[int] = Query(default=None, ge=0, le=4), season: Optional[int] = Query(default=None), progress: Optional[str] = Query(default=None), evaluated_only: bool = Query(default=True), limit: int = Query(default=10, ge=1, le=100), offset: int = Query(default=0, ge=0), token: str = Depends(oauth2_scheme), ): """List RefractorCardState rows for a team, with optional filters and pagination. Required: team_id -- filter to this team's cards; returns empty list if team has no states Optional filters: card_type -- one of 'batter', 'sp', 'rp'; filters by RefractorTrack.card_type tier -- filter by current_tier (0-4) season -- filter to players who have batting or pitching season stats in that season (EXISTS subquery against batting/pitching_season_stats) progress -- 'close' = only cards within 80% of their next tier threshold; fully evolved cards are always excluded from this filter evaluated_only -- default True; when True, excludes cards where last_evaluated_at is NULL (cards created but never run through the evaluator). Set to False to include all rows, including zero-value placeholders. Pagination: limit -- page size (1-100, default 10) offset -- items to skip (default 0) Response: {"count": N, "items": [...]} count is the total matching rows before limit/offset. Each item includes player_name and progress_pct in addition to the standard single-card response fields. Sort order: current_tier DESC, current_value DESC. """ if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") from ..db_engine import ( RefractorCardState, RefractorTrack, Player, BattingSeasonStats, PitchingSeasonStats, fn, Case, JOIN, ) query = ( RefractorCardState.select(RefractorCardState, RefractorTrack, Player) .join(RefractorTrack) .switch(RefractorCardState) .join( Player, JOIN.LEFT_OUTER, on=(RefractorCardState.player == Player.player_id) ) .where(RefractorCardState.team == team_id) .order_by( RefractorCardState.current_tier.desc(), RefractorCardState.current_value.desc(), ) ) if card_type is not None: query = query.where(RefractorTrack.card_type == card_type) if tier is not None: query = query.where(RefractorCardState.current_tier == tier) if season is not None: batter_exists = BattingSeasonStats.select().where( (BattingSeasonStats.player == RefractorCardState.player) & (BattingSeasonStats.team == RefractorCardState.team) & (BattingSeasonStats.season == season) ) pitcher_exists = PitchingSeasonStats.select().where( (PitchingSeasonStats.player == RefractorCardState.player) & (PitchingSeasonStats.team == RefractorCardState.team) & (PitchingSeasonStats.season == season) ) query = query.where(fn.EXISTS(batter_exists) | fn.EXISTS(pitcher_exists)) if progress == "close": next_threshold_expr = Case( RefractorCardState.current_tier, ( (0, RefractorTrack.t1_threshold), (1, RefractorTrack.t2_threshold), (2, RefractorTrack.t3_threshold), (3, RefractorTrack.t4_threshold), ), None, ) query = query.where( (RefractorCardState.fully_evolved == False) # noqa: E712 & (RefractorCardState.current_value >= next_threshold_expr * 0.8) ) if evaluated_only: query = query.where(RefractorCardState.last_evaluated_at.is_null(False)) total = query.count() items = [] for state in query.offset(offset).limit(limit): player_name = None try: player_name = state.player.p_name except Exception: pass items.append(_build_card_state_response(state, player_name=player_name)) return {"count": total, "items": items} @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 auto-initialized on-the-fly via initialize_card_refractor (idempotent). 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, Player, StratPlay from ..services.refractor_boost import apply_tier_boost 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 = [] boost_enabled = os.environ.get("REFRACTOR_BOOST_ENABLED", "true").lower() != "false" 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: try: player = Player.get_by_id(player_id) card_type = _determine_card_type(player) state = initialize_card_refractor(player_id, team_id, card_type) except Exception: logger.warning( f"Refractor auto-init failed for player={player_id} " f"team={team_id} — skipping" ) if state is None: continue old_tier = state.current_tier # Use dry_run=True so that current_tier is NOT written here. # apply_tier_boost() writes current_tier + variant atomically on # tier-up. If no tier-up occurs, apply_tier_boost is not called # and the tier stays at old_tier (correct behaviour). result = evaluate_card(player_id, team_id, dry_run=True) evaluated += 1 # Use computed_tier (what the formula says) to detect tier-ups. computed_tier = result.get("computed_tier", old_tier) if computed_tier > old_tier: player_name = "Unknown" try: p = Player.get_by_id(player_id) player_name = p.p_name except Exception: pass # Phase 2: Apply rating boosts for each tier gained. # apply_tier_boost() writes current_tier + variant atomically. # If it fails, current_tier stays at old_tier — automatic retry next game. boost_result = None if not boost_enabled: # Boost disabled via REFRACTOR_BOOST_ENABLED=false. # Skip notification — current_tier was not written (dry_run), # so reporting a tier-up would be a false notification. continue card_type = state.track.card_type if state.track else None if card_type: last_successful_tier = old_tier failing_tier = old_tier + 1 try: for tier in range(old_tier + 1, computed_tier + 1): failing_tier = tier boost_result = apply_tier_boost( player_id, team_id, tier, card_type ) last_successful_tier = tier except Exception as boost_exc: logger.warning( f"Refractor boost failed for player={player_id} " f"team={team_id} tier={failing_tier}: {boost_exc}" ) # Report only the tiers that actually succeeded. # If none succeeded, skip the tier_up notification entirely. if last_successful_tier == old_tier: continue # At least one intermediate tier was committed; report that. computed_tier = last_successful_tier else: # No card_type means no track — skip boost and skip notification. # A false tier-up notification must not be sent when the boost # was never applied (current_tier was never written to DB). logger.warning( f"Refractor boost skipped for player={player_id} " f"team={team_id}: no card_type on track" ) continue tier_up_entry = { "player_id": player_id, "team_id": team_id, "player_name": player_name, "old_tier": old_tier, "new_tier": computed_tier, "current_value": result.get("current_value", 0), "track_name": state.track.name if state.track else "Unknown", } # Non-breaking addition: include boost info when available. if boost_result: tier_up_entry["variant_created"] = boost_result.get( "variant_created" ) tier_ups.append(tier_up_entry) 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}