import json from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.auth import AuthUser, require_admin from app.core.database import get_session from app.models.boss_battle import BossBattle from app.models.game import Game from app.models.pokemon import Pokemon from app.models.route import Route from app.models.route_encounter import RouteEncounter from app.schemas.game import ( GameCreate, GameDetailResponse, GameResponse, GameUpdate, RegionResponse, RouteCreate, RouteReorderRequest, RouteResponse, RouteUpdate, RouteWithChildrenResponse, ) from app.schemas.pokemon import BulkImportResult, BulkRouteItem from app.seeds.loader import upsert_route_encounters, upsert_routes router = APIRouter() async def _get_game_or_404(session: AsyncSession, game_id: int) -> Game: game = await session.get(Game, game_id) if game is None: raise HTTPException(status_code=404, detail="Game not found") return game async def _get_version_group_id(session: AsyncSession, game_id: int) -> int: game = await _get_game_or_404(session, game_id) if game.version_group_id is None: raise HTTPException( status_code=400, detail="Game has no version group assigned" ) return game.version_group_id @router.get("", response_model=list[GameResponse]) async def list_games(session: AsyncSession = Depends(get_session)): result = await session.execute(select(Game).order_by(Game.id)) return result.scalars().all() @router.get("/by-region", response_model=list[RegionResponse]) async def list_games_by_region(session: AsyncSession = Depends(get_session)): """Return games grouped by region with generation metadata and genlocke preset defaults.""" regions_path = Path(__file__).parent.parent / "seeds" / "data" / "regions.json" with open(regions_path) as f: regions_data = json.load(f) result = await session.execute(select(Game).order_by(Game.release_year, Game.name)) all_games = result.scalars().all() games_by_region: dict[str, list[Game]] = {} for game in all_games: games_by_region.setdefault(game.region, []).append(game) response = [] for region in regions_data: region_games = games_by_region.get(region["name"], []) defaults = region["genlocke_defaults"] response.append( { "name": region["name"], "generation": region["generation"], "order": region["order"], "genlocke_defaults": { "true_genlocke": defaults["true"], "normal_genlocke": defaults["normal"], }, "games": region_games, } ) return response @router.get("/{game_id}", response_model=GameDetailResponse) async def get_game(game_id: int, session: AsyncSession = Depends(get_session)): game = await _get_game_or_404(session, game_id) vg_id = game.version_group_id # Load routes via version_group_id result = await session.execute( select(Route).where(Route.version_group_id == vg_id).order_by(Route.order) ) routes = result.scalars().all() # Attach routes to game for serialization return { "id": game.id, "name": game.name, "slug": game.slug, "generation": game.generation, "region": game.region, "category": game.category, "box_art_url": game.box_art_url, "release_year": game.release_year, "color": game.color, "version_group_id": game.version_group_id, "routes": [ { "id": r.id, "name": r.name, "version_group_id": r.version_group_id, "order": r.order, "parent_route_id": r.parent_route_id, "pinwheel_zone": r.pinwheel_zone, "encounter_methods": [], } for r in routes ], } @router.get( "/{game_id}/routes", response_model=list[RouteWithChildrenResponse] | list[RouteResponse], ) async def list_game_routes( game_id: int, flat: bool = False, allowed_types: list[str] | None = Query(None), session: AsyncSession = Depends(get_session), ): """ List routes for a game. By default, returns a hierarchical structure with top-level routes containing nested children. Use `flat=True` to get a flat list of all routes. When `allowed_types` is provided, routes with no encounters matching any of those Pokemon types are excluded. """ vg_id = await _get_version_group_id(session, game_id) result = await session.execute( select(Route) .where(Route.version_group_id == vg_id) .options( selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon) ) .order_by(Route.order) ) all_routes = result.scalars().all() def route_to_dict(route: Route) -> dict: # Only show encounter methods for the requested game methods = sorted( { re.encounter_method for re in route.route_encounters if re.game_id == game_id } ) return { "id": route.id, "name": route.name, "version_group_id": route.version_group_id, "order": route.order, "parent_route_id": route.parent_route_id, "pinwheel_zone": route.pinwheel_zone, "encounter_methods": methods, } # Determine which routes have encounters for this game def has_encounters(route: Route) -> bool: encounters = [re for re in route.route_encounters if re.game_id == game_id] if not encounters: return False if allowed_types: return any( t in allowed_types for re in encounters for t in re.pokemon.types ) return True # Collect IDs of parent routes that have at least one child with encounters parents_with_children = set() for route in all_routes: if route.parent_route_id is not None and has_encounters(route): parents_with_children.add(route.parent_route_id) if flat: return [ route_to_dict(r) for r in all_routes if has_encounters(r) or r.id in parents_with_children ] # Build hierarchical structure # Group children by parent_route_id children_by_parent: dict[int, list[dict]] = {} top_level_routes: list[Route] = [] for route in all_routes: if route.parent_route_id is None: top_level_routes.append(route) elif has_encounters(route): children_by_parent.setdefault(route.parent_route_id, []).append( route_to_dict(route) ) # Build response with nested children # Only include top-level routes that have their own encounters or remaining children response = [] for route in top_level_routes: children = children_by_parent.get(route.id, []) if has_encounters(route) or children: route_dict = route_to_dict(route) route_dict["children"] = children response.append(route_dict) return response # --- Admin endpoints --- @router.post("", response_model=GameResponse, status_code=201) async def create_game( data: GameCreate, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): existing = await session.execute(select(Game).where(Game.slug == data.slug)) if existing.scalar_one_or_none() is not None: raise HTTPException( status_code=409, detail="Game with this slug already exists" ) game = Game(**data.model_dump()) session.add(game) await session.commit() await session.refresh(game) return game @router.put("/{game_id}", response_model=GameResponse) async def update_game( game_id: int, data: GameUpdate, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): game = await session.get(Game, game_id) if game is None: raise HTTPException(status_code=404, detail="Game not found") update_data = data.model_dump(exclude_unset=True) if "slug" in update_data: existing = await session.execute( select(Game).where(Game.slug == update_data["slug"], Game.id != game_id) ) if existing.scalar_one_or_none() is not None: raise HTTPException( status_code=409, detail="Game with this slug already exists" ) for field, value in update_data.items(): setattr(game, field, value) await session.commit() await session.refresh(game) return game @router.delete("/{game_id}", status_code=204) async def delete_game( game_id: int, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): result = await session.execute( select(Game).where(Game.id == game_id).options(selectinload(Game.runs)) ) game = result.scalar_one_or_none() if game is None: raise HTTPException(status_code=404, detail="Game not found") if game.runs: raise HTTPException( status_code=409, detail="Cannot delete game with existing runs. Delete the runs first.", ) vg_id = game.version_group_id # Delete game-specific route_encounters await session.execute( delete(RouteEncounter).where(RouteEncounter.game_id == game_id) ) # Check if this is the last game in the version group other_games = await session.execute( select(Game).where(Game.version_group_id == vg_id, Game.id != game_id) ) is_last_in_group = other_games.scalar_one_or_none() is None if is_last_in_group and vg_id is not None: # Delete boss battles await session.execute( delete(BossBattle).where(BossBattle.version_group_id == vg_id) ) # Delete routes (children first due to parent FK) child_routes = await session.execute( select(Route).where( Route.version_group_id == vg_id, Route.parent_route_id.isnot(None), ) ) for route in child_routes.scalars().all(): await session.delete(route) await session.flush() parent_routes = await session.execute( select(Route).where(Route.version_group_id == vg_id) ) for route in parent_routes.scalars().all(): await session.delete(route) await session.delete(game) await session.commit() @router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201) async def create_route( game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) route = Route(version_group_id=vg_id, **data.model_dump()) session.add(route) await session.commit() await session.refresh(route) return route @router.put("/{game_id}/routes/reorder", response_model=list[RouteResponse]) async def reorder_routes( game_id: int, data: RouteReorderRequest, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) for item in data.routes: route = await session.get(Route, item.id) if route is None or route.version_group_id != vg_id: raise HTTPException( status_code=400, detail=f"Route {item.id} not found in this game", ) route.order = item.order await session.commit() result = await session.execute( select(Route).where(Route.version_group_id == vg_id).order_by(Route.order) ) return result.scalars().all() @router.put("/{game_id}/routes/{route_id}", response_model=RouteResponse) async def update_route( game_id: int, route_id: int, data: RouteUpdate, session: AsyncSession = Depends(get_session), _user: AuthUser = Depends(require_admin), ): vg_id = await _get_version_group_id(session, game_id) route = await session.get(Route, route_id) if route is None or route.version_group_id != vg_id: raise HTTPException(status_code=404, detail="Route not found in this game") for field, value in data.model_dump(exclude_unset=True).items(): setattr(route, field, value) await session.commit() await session.refresh(route) return route @router.delete("/{game_id}/routes/{route_id}", status_code=204) async def delete_route( game_id: int, route_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(Route) .where(Route.id == route_id, Route.version_group_id == vg_id) .options(selectinload(Route.encounters)) ) route = result.scalar_one_or_none() if route is None: raise HTTPException(status_code=404, detail="Route not found in this game") if route.encounters: raise HTTPException( status_code=409, detail="Cannot delete route with existing encounters. Delete the encounters first.", ) # Null out any boss battle references to this route await session.execute( update(BossBattle) .where(BossBattle.after_route_id == route_id) .values(after_route_id=None) ) await session.delete(route) await session.commit() @router.post("/{game_id}/routes/bulk-import", response_model=BulkImportResult) async def bulk_import_routes( game_id: int, items: list[BulkRouteItem], 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 for encounter resolution result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id)) dex_to_id = {row.pokeapi_id: row.id for row in result} errors: list[str] = [] # Upsert routes using the seed loader (handles parent/child hierarchy) routes_data = [item.model_dump() for item in items] try: route_name_to_id = await upsert_routes(session, vg_id, routes_data) except Exception as e: raise HTTPException( status_code=400, detail=f"Failed to import routes: {e}" ) from e # Upsert encounters for each route encounter_count = 0 for item in items: route_id = route_name_to_id.get(item.name) if route_id is None: errors.append(f"Route '{item.name}' not found after upsert") continue if item.encounters: try: count = await upsert_route_encounters( session, route_id, [e.model_dump() for e in item.encounters], dex_to_id, game_id, ) encounter_count += count except Exception as e: errors.append(f"Encounters for '{item.name}': {e}") for child in item.children: child_id = route_name_to_id.get(child.name) if child_id is None: errors.append(f"Child route '{child.name}' not found after upsert") continue if child.encounters: try: count = await upsert_route_encounters( session, child_id, [e.model_dump() for e in child.encounters], dex_to_id, game_id, ) encounter_count += count except Exception as e: errors.append(f"Encounters for '{child.name}': {e}") await session.commit() route_count = len(route_name_to_id) return BulkImportResult( created=route_count, updated=encounter_count, errors=errors, )