""" Card search router for MTG card database. Provides endpoints for searching and retrieving MTG card data with filters for type, set, and color. """ from typing import List, Optional, Dict, Any from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.database import mtg_get_db from app.core.redis_client import cache_get, cache_set from app.services.card_search_service import CardSearchService from app.services.deck_suggestion_service import DeckSuggestionService from app.services.fuzzy_card_matcher import FuzzyCardMatcher from app.models.user_deck import UserDeck from app.models.mtg_models import MtgCard from app.schemas.card_search_schemas import CardSearchResponse, CardResponse, SetResponse, CardTypeResponse router = APIRouter(prefix="/api/cards", tags=["Card Search"]) @router.get("/search", response_model=CardSearchResponse) async def search_cards_endpoint( q: str = Query(..., min_length=1, description="Search query"), card_type: Optional[str] = Query(None, description="Filter by card type"), set_code: Optional[str] = Query(None, description="Filter by set code"), color: Optional[str] = Query(None, description="Filter by color (e.g., WU, BR)"), limit: int = Query(100, ge=1, le=500, description="Maximum results"), offset: int = Query(0, ge=0, description="Number of results to skip"), db: AsyncSession = Depends(mtg_get_db), ): """ Search cards with filters. Supports filtering by type, set, and color in addition to name search. Uses fuzzy matching as a fallback when exact/partial matches are not found. """ cache_key = f"card_search:{q}:{card_type}:{set_code}:{color}:{limit}:{offset}" # Check cache first cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} # Normalize the search query for consistency normalized_query = FuzzyCardMatcher.normalize_card_name(q) # Search cards using the existing service results = await CardSearchService.search_cards( db=db, query=q, card_type=card_type, set_code=set_code, color=color, limit=limit, offset=offset, ) # If no results found, try fuzzy matching as a fallback if results["total"] == 0: fuzzy_results = await _fuzzy_search_fallback( db=db, query=q, normalized_query=normalized_query, card_type=card_type, set_code=set_code, color=color, limit=limit, offset=offset, ) results = fuzzy_results # Add fuzzy matching metadata to results results["query_normalized"] = normalized_query results["fuzzy_match"] = True # Cache results for 5 minutes await cache_set(cache_key, str(results), ttl=300) return {"cached": False, "results": results} async def _fuzzy_search_fallback( db: AsyncSession, query: str, normalized_query: str, card_type: Optional[str] = None, set_code: Optional[str] = None, color: Optional[str] = None, limit: int = 100, offset: int = 0, ) -> Dict[str, Any]: """ Fallback fuzzy search when exact/partial matches yield no results. Fetches all cards matching the type/set/color filters, then uses fuzzy matching to find the best card name matches. """ from sqlalchemy import select, or_ # Fetch candidate cards based on non-name filters conditions = [] if card_type: conditions.append(MtgCard.type_line.ilike(f"%{card_type}%")) if set_code: conditions.append(MtgCard.set_code == set_code) if color: colors = [c.strip() for c in color.upper().split(",")] for c in colors: if c in ["W", "U", "B", "R", "G"]: conditions.append(MtgCard.colors.ilike(f"%{c}%")) # If no filters, fetch a broader set for fuzzy matching if not conditions: stmt = select(MtgCard).limit(limit * 5) else: stmt = select(MtgCard).where(*conditions).limit(limit * 5) result = await db.execute(stmt) candidate_cards = result.scalars().all() if not candidate_cards: return { "cards": [], "total": 0, "page": offset // limit + 1, "page_size": limit, "total_pages": 0, "fuzzy_fallback": True, "message": "No cards found matching your query.", } # Build candidate name list and lookup candidate_names = [card.name for card in candidate_cards if card.name] card_lookup = {card.name.lower(): card for card in candidate_cards if card.name} # Use fuzzy matching to find best matches matched_name, confidence, match_type = FuzzyCardMatcher.find_best_match( normalized_query, candidate_names, threshold=FuzzyCardMatcher.MIN_MATCH_THRESHOLD ) # Build results from fuzzy matches card_list = [] if matched_name and matched_name.lower() in card_lookup: card = card_lookup[matched_name.lower()] card_data = { "id": card.id, "name": card.name, "mana_cost": card.mana_cost, "type_line": card.type_line, "oracle_text": card.oracle_text, "power": card.power, "toughness": card.toughness, "rarity": card.rarity, "layout": card.layout, "colors": card.colors, "set_code": card.set_code, "set_name": card.set_name, "fuzzy_match": True, "match_confidence": confidence, "match_type": match_type, "original_query": query, } card_list.append(card_data) return { "cards": card_list, "total": len(card_list), "page": offset // limit + 1, "page_size": limit, "total_pages": (len(card_list) + limit - 1) // limit if card_list else 0, "fuzzy_fallback": True, "match_type": match_type, "confidence": confidence, } @router.get("/{card_id}", response_model=CardResponse) async def get_card_endpoint( card_id: int, db: AsyncSession = Depends(mtg_get_db), ): """ Get a specific card by ID. """ cache_key = f"card_by_id:{card_id}" # Check cache first cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} # Get card card = await CardSearchService.get_card_by_id(db, card_id) if not card: raise HTTPException(status_code=404, detail="Card not found") # Cache for 10 minutes await cache_set(cache_key, str(card), ttl=600) return {"cached": False, "results": card} @router.get("/sets", response_model=List[SetResponse]) async def get_sets_endpoint( db: AsyncSession = Depends(mtg_get_db), ): """ Get all available sets. """ cache_key = "all_sets:all" cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} sets = await CardSearchService.get_sets(db) # Cache for 1 hour await cache_set(cache_key, str(sets), ttl=3600) return {"cached": False, "results": sets} @router.get("/types", response_model=List[CardTypeResponse]) async def get_card_types_endpoint( db: AsyncSession = Depends(mtg_get_db), ): """ Get all unique card types. """ cache_key = "card_types:all" cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} types = await CardSearchService.get_card_types(db) # Cache for 30 minutes await cache_set(cache_key, str(types), ttl=1800) return {"cached": False, "results": types} @router.get("/rarities", response_model=List[str]) async def get_card_rarities_endpoint( db: AsyncSession = Depends(mtg_get_db), ): """ Get all unique card rarities. """ cache_key = "card_rarities:all" cached = await cache_get(cache_key) if cached: return {"cached": True, "results": cached} rarities = await CardSearchService.get_card_rarities(db) # Cache for 30 minutes await cache_set(cache_key, str(rarities), ttl=1800) return {"cached": False, "results": rarities} @router.get("/suggest", response_model=List[Dict[str, Any]]) async def suggest_cards_endpoint( deck_id: int = Query(..., description="Deck ID to suggest cards for"), limit: int = Query(20, ge=1, le=100, description="Maximum suggestions"), db: AsyncSession = Depends(mtg_get_db), ): """ Suggest similar cards for a deck. Matches by: same type, same color, same set, same mana cost, and cards often paired in existing user decks. """ # Verify deck exists stmt = select(UserDeck).where(UserDeck.id == deck_id) result = await db.execute(stmt) deck = result.scalar_one_or_none() if not deck: raise HTTPException(status_code=404, detail="Deck not found") suggestions = await DeckSuggestionService.suggest_cards(db, deck_id, limit) return suggestions