""" Card import router endpoints. Provides endpoints for importing card collections, viewing import status, and using imported cards for deckbuilding. This feature allows users to upload their owned cards as a list, which then informs deckbuilding. """ import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, update from sqlalchemy.orm import selectinload from typing import Optional, List from app.core.database import get_db from app.core.security import get_current_user from app.models.models import User, MtgonlineCard from app.models.user_card_import import UserCardImport from app.schemas.card_import_schemas import ( CardImportRequest, CardImportResponse, CardImportStatusResponse, CardMatchResult, CardImportSummary, MessageResponse, ) router = APIRouter() @router.get("/status", response_model=CardImportStatusResponse) async def get_card_import_status( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """ Get current card import status for the current user. Returns whether a card import exists, the card count, and the names of all imported cards. """ user_id = int(current_user["user_id"]) stmt = select(UserCardImport).where(UserCardImport.user_id == user_id) result = await db.execute(stmt) card_import = result.scalar_one_or_none() if not card_import: return CardImportStatusResponse(has_import=False) # Parse card names from JSON try: card_names = json.loads(card_import.card_names_json) except (json.JSONDecodeError, TypeError): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid card import data format" ) return CardImportStatusResponse( has_import=True, card_count=len(card_names), card_names=card_names, last_imported=card_import.updated_at, ) @router.post("/", response_model=CardImportResponse) async def import_cards( request: CardImportRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """ Import a card collection for the current user. Accepts a list of card names and stores them as the user's owned card collection. This replaces any existing import. Uses fuzzy matching to find card IDs in the mtgonline_cards table. """ user_id = int(current_user["user_id"]) # Normalize card names: strip whitespace, title case normalized_names = [name.strip().title() for name in request.card_names] # Fetch all cards from the mtgonline_cards mirror stmt = select(MtgonlineCard).options(selectinload(MtgonlineCard.deck_cards)) result = await db.execute(stmt) all_cards = result.scalars().all() # Build lookup dictionaries card_by_name = {} # exact title case name -> MtgonlineCard card_by_lower = {} # lowercase name -> MtgonlineCard (for fuzzy matching) for card in all_cards: if card.name: # Exact match (title case) card_by_name[card.name] = card # Lowercase for case-insensitive matching card_by_lower[card.name.lower()] = card # Match each imported card name to a database card matched_cards = [] unmatched_cards = [] matched_card_names = [] for card_name in normalized_names: # Try exact match first if card_name in card_by_name: matched_cards.append(CardMatchResult( card_id=card_by_name[card_name].id, card_name=card_name, matched_name=card_by_name[card_name].name, match_type="exact", confidence=1.0, )) matched_card_names.append(card_by_name[card_name].name) continue # Try case-insensitive match if card_name.lower() in card_by_lower: matched_cards.append(CardMatchResult( card_id=card_by_lower[card_name.lower()].id, card_name=card_name, matched_name=card_by_lower[card_name.lower()].name, match_type="exact", confidence=0.95, )) matched_card_names.append(card_by_lower[card_name.lower()].name) continue # Try partial match (fuzzy) best_match = None best_confidence = 0.0 for db_name, db_card in card_by_lower.items(): # Simple partial match: check if one contains the other if card_name.lower() in db_name or db_name.lower() in card_name.lower(): # Calculate confidence based on length similarity min_len = min(len(card_name), len(db_name)) max_len = max(len(card_name), len(db_name)) if max_len > 0: confidence = min_len / max_len if confidence > best_confidence: best_confidence = confidence best_match = db_card if best_match and best_confidence >= 0.6: # 60% similarity threshold matched_cards.append(CardMatchResult( card_id=best_match.id, card_name=card_name, matched_name=best_match.name, match_type="partial", confidence=best_confidence, )) matched_card_names.append(best_match.name) else: unmatched_cards.append(card_name) # Create or update the card import card_names_json = json.dumps(matched_card_names) existing = select(UserCardImport).where(UserCardImport.user_id == user_id) existing_result = await db.execute(existing) existing_import = existing_result.scalar_one_or_none() if existing_import: # Update existing import stmt = ( update(UserCardImport) .where(UserCardImport.id == existing_import.id) .values( card_names_json=card_names_json, updated_at=func.now(), ) ) await db.execute(stmt) await db.flush() # Fetch updated import stmt = select(UserCardImport).where(UserCardImport.id == existing_import.id) result = await db.execute(stmt) updated_import = result.scalar_one_or_none() else: # Create new import new_import = UserCardImport( user_id=user_id, card_names_json=card_names_json, ) db.add(new_import) await db.flush() updated_import = new_import return CardImportResponse( message=f"Imported {len(matched_card_names)} cards successfully", card_count=len(matched_card_names), card_names=matched_card_names, imported_at=updated_import.updated_at, ) @router.delete("/", response_model=MessageResponse) async def delete_card_import( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Delete the current user's card import.""" user_id = int(current_user["user_id"]) stmt = select(UserCardImport).where(UserCardImport.user_id == user_id) result = await db.execute(stmt) card_import = result.scalar_one_or_none() if not card_import: return MessageResponse(message="No card import found to delete") # Delete the import (cascade will handle related data if any) await db.delete(card_import) await db.flush() return MessageResponse(message="Card import deleted successfully") @router.get("/summary", response_model=CardImportSummary) async def get_card_import_summary( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """ Get a summary of the card import including match results. Returns the full match results with confidence scores and lists of unmatched cards for review. """ user_id = int(current_user["user_id"]) stmt = select(UserCardImport).where(UserCardImport.user_id == user_id) result = await db.execute(stmt) card_import = result.scalar_one_or_none() if not card_import: return CardImportSummary( total_cards=0, matched_cards=[], unmatched_cards=[], ) try: card_names = json.loads(card_import.card_names_json) except (json.JSONDecodeError, TypeError): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid card import data format" ) # Fetch all cards for matching stmt = select(MtgonlineCard) result = await db.execute(stmt) all_cards = result.scalars().all() # Build lookup card_by_lower = {card.name.lower(): card for card in all_cards if card.name} # Match cards matched_cards = [] unmatched_cards = [] for card_name in card_names: normalized = card_name.strip().title() if normalized.lower() in card_by_lower: db_card = card_by_lower[normalized.lower()] matched_cards.append(CardMatchResult( card_id=db_card.id, card_name=normalized, matched_name=db_card.name, match_type="exact", confidence=1.0, )) else: unmatched_cards.append(card_name) return CardImportSummary( total_cards=len(card_names), matched_cards=matched_cards, unmatched_cards=unmatched_cards, import_id=card_import.id, )