from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.auth import AuthUser, require_admin, require_auth, require_run_owner from app.core.database import get_session from app.models.boss_battle import BossBattle from app.models.boss_pokemon import BossPokemon from app.models.boss_result import BossResult from app.models.boss_result_team import BossResultTeam from app.models.encounter import Encounter from app.models.game import Game from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon from app.models.route import Route from app.schemas.boss import ( BossBattleCreate, BossBattleResponse, BossBattleUpdate, BossPokemonInput, BossReorderRequest, BossResultCreate, BossResultResponse, ) from app.schemas.pokemon import BulkBossItem, BulkImportResult from app.seeds.loader import upsert_bosses router = APIRouter() def _boss_pokemon_load_options(): """Standard eager-loading options for BossPokemon relationships.""" return ( selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon), selectinload(BossBattle.pokemon).selectinload(BossPokemon.ability), selectinload(BossBattle.pokemon).selectinload(BossPokemon.move1), selectinload(BossBattle.pokemon).selectinload(BossPokemon.move2), selectinload(BossBattle.pokemon).selectinload(BossPokemon.move3), selectinload(BossBattle.pokemon).selectinload(BossPokemon.move4), ) async def _get_version_group_id(session: AsyncSession, game_id: int) -> int: game = await session.get(Game, game_id) if game is None: raise HTTPException(status_code=404, detail="Game not found") if game.version_group_id is None: raise HTTPException( status_code=400, detail="Game has no version group assigned" ) return game.version_group_id # --- Game-scoped (admin) endpoints --- @router.get("/games/{game_id}/bosses", response_model=list[BossBattleResponse]) async def list_bosses( game_id: int, all: bool = False, session: AsyncSession = Depends(get_session), ): vg_id = await _get_version_group_id(session, game_id) query = ( select(BossBattle) .where(BossBattle.version_group_id == vg_id) .options(*_boss_pokemon_load_options()) .order_by(BossBattle.order) ) if not all: query = query.where( or_(BossBattle.game_id.is_(None), BossBattle.game_id == game_id) ) result = await session.execute(query) return result.scalars().all() @router.put("/games/{game_id}/bosses/reorder", response_model=list[BossBattleResponse]) async def reorder_bosses( game_id: int, data: BossReorderRequest, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) boss_ids = [item.id for item in data.bosses] result = await session.execute( select(BossBattle).where( BossBattle.id.in_(boss_ids), BossBattle.version_group_id == vg_id ) ) bosses = {b.id: b for b in result.scalars().all()} if len(bosses) != len(boss_ids): raise HTTPException( status_code=400, detail="Some boss IDs not found in this game" ) # Phase 1: set temporary negative orders to avoid unique constraint violations for i, item in enumerate(data.bosses): bosses[item.id].order = -(i + 1) await session.flush() # Phase 2: set real orders for item in data.bosses: bosses[item.id].order = item.order await session.commit() # Re-fetch with eager loading result = await session.execute( select(BossBattle) .where(BossBattle.version_group_id == vg_id) .options(*_boss_pokemon_load_options()) .order_by(BossBattle.order) ) return result.scalars().all() @router.post( "/games/{game_id}/bosses", response_model=BossBattleResponse, status_code=201 ) async def create_boss( game_id: int, data: BossBattleCreate, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) if data.game_id is not None: game = await session.get(Game, data.game_id) if game is None or game.version_group_id != vg_id: raise HTTPException( status_code=400, detail="game_id does not belong to this version group", ) boss = BossBattle(version_group_id=vg_id, **data.model_dump()) session.add(boss) await session.commit() # Re-fetch with eager loading result = await session.execute( select(BossBattle) .where(BossBattle.id == boss.id) .options(*_boss_pokemon_load_options()) ) return result.scalar_one() @router.put("/games/{game_id}/bosses/{boss_id}", response_model=BossBattleResponse) async def update_boss( game_id: int, boss_id: int, data: BossBattleUpdate, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) if data.game_id is not None: game = await session.get(Game, data.game_id) if game is None or game.version_group_id != vg_id: raise HTTPException( status_code=400, detail="game_id does not belong to this version group", ) result = await session.execute( select(BossBattle) .where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id) .options(*_boss_pokemon_load_options()) ) boss = result.scalar_one_or_none() if boss is None: raise HTTPException(status_code=404, detail="Boss battle not found") for field, value in data.model_dump(exclude_unset=True).items(): setattr(boss, field, value) await session.commit() await session.refresh(boss) # Re-fetch with eager loading result = await session.execute( select(BossBattle) .where(BossBattle.id == boss.id) .options(*_boss_pokemon_load_options()) ) return result.scalar_one() @router.delete("/games/{game_id}/bosses/{boss_id}", status_code=204) async def delete_boss( game_id: int, boss_id: int, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) result = await session.execute( select(BossBattle).where( BossBattle.id == boss_id, BossBattle.version_group_id == vg_id ) ) boss = result.scalar_one_or_none() if boss is None: raise HTTPException(status_code=404, detail="Boss battle not found") await session.delete(boss) await session.commit() return Response(status_code=204) @router.post("/games/{game_id}/bosses/bulk-import", response_model=BulkImportResult) async def bulk_import_bosses( game_id: int, items: list[BulkBossItem], session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) # Build pokeapi_id -> id mapping result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id)) dex_to_id = {row.pokeapi_id: row.id for row in result} # Build route name -> id mapping for after_route_name resolution result = await session.execute( select(Route.name, Route.id).where(Route.version_group_id == vg_id) ) route_name_to_id = {row.name: row.id for row in result} # Build game slug -> id mapping for game_slug resolution result = await session.execute( select(Game.slug, Game.id).where(Game.version_group_id == vg_id) ) slug_to_game_id = {row.slug: row.id for row in result} bosses_data = [item.model_dump() for item in items] try: count = await upsert_bosses( session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_game_id ) except Exception as e: raise HTTPException( status_code=400, detail=f"Failed to import bosses: {e}" ) from e await session.commit() return BulkImportResult(created=count, updated=0, errors=[]) @router.put( "/games/{game_id}/bosses/{boss_id}/pokemon", response_model=BossBattleResponse, ) async def set_boss_team( game_id: int, boss_id: int, team: list[BossPokemonInput], session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) result = await session.execute( select(BossBattle) .where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id) .options(selectinload(BossBattle.pokemon)) ) boss = result.scalar_one_or_none() if boss is None: raise HTTPException(status_code=404, detail="Boss battle not found") # Remove existing team for p in boss.pokemon: await session.delete(p) # Add new team for item in team: bp = BossPokemon( boss_battle_id=boss_id, pokemon_id=item.pokemon_id, level=item.level, order=item.order, condition_label=item.condition_label, ability_id=item.ability_id, held_item=item.held_item, nature=item.nature, move1_id=item.move1_id, move2_id=item.move2_id, move3_id=item.move3_id, move4_id=item.move4_id, ) session.add(bp) await session.commit() # Clear identity map so selectinload fetches everything fresh # (expired Pokemon from deleted BossPokemon would otherwise cause # MissingGreenlet errors during response serialization) session.expunge_all() # Re-fetch with eager loading result = await session.execute( select(BossBattle) .where(BossBattle.id == boss.id) .options(*_boss_pokemon_load_options()) ) return result.scalar_one() # --- Run-scoped endpoints --- @router.get("/runs/{run_id}/boss-results", response_model=list[BossResultResponse]) async def list_boss_results(run_id: int, session: AsyncSession = Depends(get_session)): run = await session.get(NuzlockeRun, run_id) if run is None: raise HTTPException(status_code=404, detail="Run not found") result = await session.execute( select(BossResult) .where(BossResult.run_id == run_id) .options(selectinload(BossResult.team)) .order_by(BossResult.id) ) return result.scalars().all() @router.post( "/runs/{run_id}/boss-results", response_model=BossResultResponse, status_code=201 ) async def create_boss_result( run_id: int, data: BossResultCreate, session: AsyncSession = Depends(get_session), user: AuthUser = Depends(require_auth), ): run = await session.get(NuzlockeRun, run_id) if run is None: raise HTTPException(status_code=404, detail="Run not found") require_run_owner(run, user) boss = await session.get(BossBattle, data.boss_battle_id) if boss is None: raise HTTPException(status_code=404, detail="Boss battle not found") # Validate team encounter IDs belong to this run if data.team: encounter_ids = [t.encounter_id for t in data.team] enc_result = await session.execute( select(Encounter).where( Encounter.id.in_(encounter_ids), Encounter.run_id == run_id ) ) found_encounters = {e.id for e in enc_result.scalars().all()} missing = [eid for eid in encounter_ids if eid not in found_encounters] if missing: raise HTTPException( status_code=400, detail=f"Encounters not found in this run: {missing}", ) # Check for existing result (upsert) existing = await session.execute( select(BossResult) .where( BossResult.run_id == run_id, BossResult.boss_battle_id == data.boss_battle_id, ) .options(selectinload(BossResult.team)) ) result = existing.scalar_one_or_none() if result: result.result = data.result result.attempts = data.attempts result.completed_at = datetime.now(UTC) if data.result == "won" else None # Clear existing team and add new for tm in result.team: await session.delete(tm) await session.flush() else: result = BossResult( run_id=run_id, boss_battle_id=data.boss_battle_id, result=data.result, attempts=data.attempts, completed_at=datetime.now(UTC) if data.result == "won" else None, ) session.add(result) await session.flush() # Add team members for tm in data.team: team_member = BossResultTeam( boss_result_id=result.id, encounter_id=tm.encounter_id, level=tm.level, ) session.add(team_member) await session.commit() # Re-fetch with team loaded fresh = await session.execute( select(BossResult) .where(BossResult.id == result.id) .options(selectinload(BossResult.team)) ) return fresh.scalar_one() @router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204) async def delete_boss_result( run_id: int, result_id: int, session: AsyncSession = Depends(get_session), user: AuthUser = Depends(require_auth), ): run = await session.get(NuzlockeRun, run_id) if run is None: raise HTTPException(status_code=404, detail="Run not found") require_run_owner(run, user) result = await session.execute( select(BossResult).where( BossResult.id == result_id, BossResult.run_id == run_id ) ) boss_result = result.scalar_one_or_none() if boss_result is None: raise HTTPException(status_code=404, detail="Boss result not found") await session.delete(boss_result) await session.commit() return Response(status_code=204)