"""
Card browser web UI routes (HTML views with HTMX).

Provides paginated card browsing with filters, search, and cursor-based pagination.
Complements the existing API routes in cards.py for tag-based card queries.
"""

from __future__ import annotations

import logging
from difflib import SequenceMatcher
from typing import TYPE_CHECKING

import pandas as pd
from fastapi import APIRouter, Request, Query
from fastapi.responses import HTMLResponse
from ..app import templates

# Import existing services
try:
    from code.services.all_cards_loader import AllCardsLoader
    from code.deck_builder.builder_utils import parse_theme_tags
    from code.settings import ENABLE_CARD_DETAILS
except ImportError:
    from services.all_cards_loader import AllCardsLoader
    from deck_builder.builder_utils import parse_theme_tags
    from settings import ENABLE_CARD_DETAILS

if TYPE_CHECKING:
    from code.web.services.card_similarity import CardSimilarity

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/cards", tags=["card-browser"])

# Cached loader instance and theme index
_loader: AllCardsLoader | None = None
_theme_index: dict[str, set[int]] | None = None  # theme_lower -> set of card indices
_theme_catalog: list[str] | None = None  # cached list of all theme names from catalog
_similarity: "CardSimilarity | None" = None  # cached CardSimilarity instance


def get_loader() -> AllCardsLoader:
    """Get cached AllCardsLoader instance."""
    global _loader
    if _loader is None:
        _loader = AllCardsLoader()
    return _loader


def get_similarity() -> "CardSimilarity":
    """
    Get cached CardSimilarity instance.
    
    CardSimilarity initialization is expensive (pre-computes tags for 29k cards,
    loads cache with 277k entries). Cache it globally to avoid re-initialization
    on every card detail page load.
    
    Returns:
        Cached CardSimilarity instance
    """
    global _similarity
    if _similarity is None:
        from code.web.services.card_similarity import CardSimilarity
        loader = get_loader()
        df = loader.load()
        logger.info("Initializing CardSimilarity singleton (one-time cost)...")
        _similarity = CardSimilarity(df)
        logger.info("CardSimilarity singleton ready")
    return _similarity


def get_theme_catalog() -> list[str]:
    """
    Get cached list of all theme names from theme_catalog.csv.
    
    Reads from the catalog CSV which includes all themes from all_cards.parquet
    (not just commander themes). Much faster than parsing themes from 26k+ cards.
    Used for autocomplete suggestions.
    
    Returns ~900+ themes (as of latest generation).
    """
    global _theme_catalog
    if _theme_catalog is None:
        import csv
        from pathlib import Path
        import os
        
        print("Loading theme catalog...", flush=True)
        
        # Try multiple possible paths (local dev vs Docker)
        possible_paths = [
            Path(__file__).parent.parent.parent / "config" / "themes" / "theme_catalog.csv",  # Local dev
            Path("/app/config/themes/theme_catalog.csv"),  # Docker
            Path(os.environ.get("CONFIG_DIR", "/app/config")) / "themes" / "theme_catalog.csv",  # Env var
        ]
        
        themes = []
        loaded = False
        
        for catalog_path in possible_paths:
            print(f"Checking path: {catalog_path} (exists: {catalog_path.exists()})", flush=True)
            if catalog_path.exists():
                try:
                    with open(catalog_path, 'r', encoding='utf-8') as f:
                        # Skip comment lines starting with #
                        lines = [line for line in f if not line.strip().startswith('#')]
                    
                    # Parse CSV from non-comment lines
                    from io import StringIO
                    csv_content = StringIO(''.join(lines))
                    reader = csv.DictReader(csv_content)
                    
                    for row in reader:
                        if 'theme' in row and row['theme']:
                            themes.append(row['theme'])
                    
                    _theme_catalog = themes
                    print(f"Loaded {len(themes)} themes from catalog: {catalog_path}", flush=True)
                    logger.info(f"Loaded {len(themes)} themes from catalog: {catalog_path}")
                    loaded = True
                    break
                except Exception as e:
                    print(f"❌ Failed to load from {catalog_path}: {e}", flush=True)  # Debug log
                    logger.warning(f"Failed to load theme catalog from {catalog_path}: {e}")
        
        if not loaded:
            print("⚠️ No catalog found, falling back to parsing cards", flush=True)  # Debug log
            logger.warning("Failed to load theme catalog from all paths, falling back to parsing cards")
            # Fallback: extract from theme index
            theme_index = get_theme_index()
            _theme_catalog = [theme.title() for theme in theme_index.keys()]
    
    return _theme_catalog


def get_theme_index() -> dict[str, set[int]]:
    """
    Get cached theme-to-card-index mapping for fast lookups.
    
    Returns dict mapping lowercase theme names to sets of card indices.
    Built once on first access and reused for all subsequent theme queries.
    """
    global _theme_index
    if _theme_index is None:
        logger.info("Building theme index for fast lookups...")
        _theme_index = {}
        loader = get_loader()
        df = loader.load()
        
        for idx, row in enumerate(df.itertuples()):
            themes = parse_theme_tags(row.themeTags if hasattr(row, 'themeTags') else '')
            for theme in themes:
                theme_lower = theme.lower()
                if theme_lower not in _theme_index:
                    _theme_index[theme_lower] = set()
                _theme_index[theme_lower].add(idx)
        
        logger.info(f"Theme index built with {len(_theme_index)} unique themes")
    
    return _theme_index


@router.get("/", response_class=HTMLResponse)
async def card_browser_index(
    request: Request,
    search: str = Query("", description="Card name search query"),
    themes: list[str] = Query([], description="Theme tag filters (AND logic)"),
    color: str = Query("", description="Color identity filter"),
    card_type: str = Query("", description="Card type filter"),
    rarity: str = Query("", description="Rarity filter"),
    sort: str = Query("name_asc", description="Sort order"),
    cmc_min: int = Query(None, description="Minimum CMC filter", ge=0, le=16),
    cmc_max: int = Query(None, description="Maximum CMC filter", ge=0, le=16),
    power_min: int = Query(None, description="Minimum power filter", ge=0, le=99),
    power_max: int = Query(None, description="Maximum power filter", ge=0, le=99),
    tough_min: int = Query(None, description="Minimum toughness filter", ge=0, le=99),
    tough_max: int = Query(None, description="Maximum toughness filter", ge=0, le=99),
):
    """
    Main card browser page.
    
    Displays initial grid of cards with filters and search bar.
    Uses HTMX for dynamic updates (pagination, filtering, search).
    """
    try:
        loader = get_loader()
        df = loader.load()
        
        # Apply filters
        filtered_df = df.copy()
        
        if search:
            # Prioritize exact matches first, then word-count matches, then fuzzy
            query_lower = search.lower().strip()
            query_words = set(query_lower.split())
            
            # 1. Check for exact match (case-insensitive)
            # For double-faced cards, check both full name and name before " //"
            exact_matches = []
            word_count_matches = []
            fuzzy_candidates = []
            fuzzy_indices = []
            
            for idx, card_name in enumerate(filtered_df['name']):
                card_lower = card_name.lower()
                # For double-faced cards, get the front face name
                front_name = card_lower.split(' // ')[0].strip() if ' // ' in card_lower else card_lower
                
                # Exact match (full name or front face)
                if card_lower == query_lower or front_name == query_lower:
                    exact_matches.append(idx)
                # Word count match (same number of words + high similarity)
                elif len(query_lower.split()) == len(front_name.split()) and (
                    query_lower in card_lower or any(word in card_lower for word in query_words)
                ):
                    word_count_matches.append((idx, card_name))
                # Fuzzy candidate
                elif query_lower in card_lower or any(word in card_lower for word in query_words):
                    fuzzy_candidates.append(card_name)
                    fuzzy_indices.append(idx)
            
            # Build final match list
            final_matches = []
            
            # If we have exact matches, ONLY return those (don't add fuzzy results)
            if exact_matches:
                final_matches = exact_matches
            else:
                # 2. Add word-count matches with fuzzy scoring
                if word_count_matches:
                    scored_wc = [(idx, _fuzzy_card_name_score(search, name), name) 
                                 for idx, name in word_count_matches]
                    scored_wc.sort(key=lambda x: -x[1])  # Sort by score desc
                    final_matches.extend([idx for idx, score, name in scored_wc if score >= 0.3])
                
                # 3. Add fuzzy matches
                if fuzzy_candidates:
                    scored_fuzzy = [(fuzzy_indices[i], _fuzzy_card_name_score(search, name), name)
                                   for i, name in enumerate(fuzzy_candidates)]
                    scored_fuzzy.sort(key=lambda x: -x[1])  # Sort by score desc
                    final_matches.extend([idx for idx, score, name in scored_fuzzy if score >= 0.3])
            
            # Apply matches
            if final_matches:
                # Remove duplicates while preserving order
                seen = set()
                unique_matches = []
                for idx in final_matches:
                    if idx not in seen:
                        seen.add(idx)
                        unique_matches.append(idx)
                filtered_df = filtered_df.iloc[unique_matches]
            else:
                filtered_df = filtered_df.iloc[0:0]
        
        # Multi-select theme filtering (AND logic: card must have ALL selected themes)
        if themes:
            theme_index = get_theme_index()
            
            # For each theme, get matching card indices
            all_theme_matches = []
            for theme in themes:
                theme_lower = theme.lower().strip()
                
                # Try exact match first (instant lookup)
                if theme_lower in theme_index:
                    # Direct index lookup - O(1) instead of O(n)
                    matching_indices = theme_index[theme_lower]
                    all_theme_matches.append(matching_indices)
                else:
                    # Fuzzy match: check all themes in index for similarity
                    matching_indices = set()
                    for indexed_theme, card_indices in theme_index.items():
                        if _fuzzy_theme_match_score(theme, indexed_theme) >= 0.5:
                            matching_indices.update(card_indices)
                    all_theme_matches.append(matching_indices)
            
            # Apply AND logic: card must be in ALL theme match sets
            if all_theme_matches:
                # Start with first theme's matches
                intersection = all_theme_matches[0]
                # Intersect with all other theme matches
                for theme_matches in all_theme_matches[1:]:
                    intersection = intersection & theme_matches
                
                # Intersect with current filtered_df indices
                current_indices = set(filtered_df.index)
                valid_indices = intersection & current_indices
                if valid_indices:
                    filtered_df = filtered_df.loc[list(valid_indices)]
                else:
                    filtered_df = filtered_df.iloc[0:0]

        if color:
            filtered_df = filtered_df[
                filtered_df['colorIdentity'] == color
            ]
        
        if card_type:
            filtered_df = filtered_df[
                filtered_df['type'].str.contains(card_type, case=False, na=False)
            ]
        
        if rarity and 'rarity' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['rarity'].str.lower() == rarity.lower()
            ]
        
        # CMC range filter
        if cmc_min is not None and 'manaValue' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['manaValue'] >= cmc_min
            ]
        
        if cmc_max is not None and 'manaValue' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['manaValue'] <= cmc_max
            ]
        
        # Power range filter (only applies to cards with power values)
        if power_min is not None and 'power' in filtered_df.columns:
            # Filter: either no power (NaN) OR power >= min
            filtered_df = filtered_df[
                filtered_df['power'].isna() | (filtered_df['power'] >= str(power_min))
            ]
        
        if power_max is not None and 'power' in filtered_df.columns:
            # Filter: either no power (NaN) OR power <= max
            filtered_df = filtered_df[
                filtered_df['power'].isna() | (filtered_df['power'] <= str(power_max))
            ]
        
        # Toughness range filter (only applies to cards with toughness values)
        if tough_min is not None and 'toughness' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['toughness'].isna() | (filtered_df['toughness'] >= str(tough_min))
            ]
        
        if tough_max is not None and 'toughness' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['toughness'].isna() | (filtered_df['toughness'] <= str(tough_max))
            ]
        
        # Apply sorting
        if sort == "name_desc":
            # Name Z-A
            filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False)
            filtered_df['_sort_key'] = filtered_df['_sort_key'].apply(
                lambda x: x.replace('_', ' ') if x.startswith('_') else x
            )
            filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower(), ascending=False)
            filtered_df = filtered_df.drop('_sort_key', axis=1)
        elif sort == "cmc_asc":
            # CMC Low-High, then name
            filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[True, True])
        elif sort == "cmc_desc":
            # CMC High-Low, then name
            filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[False, True])
        elif sort == "power_desc":
            # Power High-Low (creatures first, then non-creatures)
            # Convert power to numeric, NaN becomes -1 for sorting
            filtered_df['_power_sort'] = pd.to_numeric(filtered_df['power'], errors='coerce').fillna(-1)
            filtered_df = filtered_df.sort_values(['_power_sort', 'name'], ascending=[False, True])
            filtered_df = filtered_df.drop('_power_sort', axis=1)
        elif sort == "edhrec_asc":
            # EDHREC rank (low number = popular)
            if 'edhrecRank' in filtered_df.columns:
                # NaN goes to end (high value)
                filtered_df['_edhrec_sort'] = filtered_df['edhrecRank'].fillna(999999)
                filtered_df = filtered_df.sort_values(['_edhrec_sort', 'name'], ascending=[True, True])
                filtered_df = filtered_df.drop('_edhrec_sort', axis=1)
            else:
                # Fallback to name sort
                filtered_df = filtered_df.sort_values('name')
        else:
            # Default: Name A-Z (name_asc)
            filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False)
            filtered_df['_sort_key'] = filtered_df['_sort_key'].apply(
                lambda x: x.replace('_', ' ') if x.startswith('_') else x
            )
            filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower())
            filtered_df = filtered_df.drop('_sort_key', axis=1)
        
        total_cards = len(filtered_df)
        
        # Get first page (20 cards)
        per_page = 20
        cards_page = filtered_df.head(per_page)
        
        # Convert to list of dicts
        cards_list = cards_page.to_dict('records')
        
        # Parse theme tags and color identity for each card
        for card in cards_list:
            card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', ''))
            # Parse colorIdentity which can be:
            # - "Colorless" -> [] (but mark as colorless)
            # - "W" -> ['W']
            # - "B, R, U" -> ['B', 'R', 'U']
            # - "['W', 'U']" -> ['W', 'U']
            # - empty/None -> []
            raw_color = card.get('colorIdentity', '')
            is_colorless = False
            if raw_color and isinstance(raw_color, str):
                if raw_color.lower() == 'colorless':
                    card['colorIdentity'] = []
                    is_colorless = True
                elif raw_color.startswith('['):
                    # Parse list-like strings e.g. "['W', 'U']"
                    card['colorIdentity'] = parse_theme_tags(raw_color)
                elif ', ' in raw_color:
                    # Parse comma-separated e.g. "B, R, U"
                    card['colorIdentity'] = [c.strip() for c in raw_color.split(',')]
                else:
                    # Single color e.g. "W"
                    card['colorIdentity'] = [raw_color.strip()]
            elif not raw_color:
                card['colorIdentity'] = []
            card['is_colorless'] = is_colorless
            # TODO: Add owned card checking when integrated
            card['is_owned'] = False
        
        # Get unique values for filters
        # Build structured color identity list with proper names
        unique_color_ids = df['colorIdentity'].dropna().unique().tolist()
        
        # Define color identity groups with proper names
        color_groups = {
            'Colorless': ['Colorless'],
            'Mono-Color': ['W', 'U', 'B', 'R', 'G'],
            'Two-Color': [
                ('W, U', 'Azorius'),
                ('U, B', 'Dimir'),
                ('B, R', 'Rakdos'),
                ('R, G', 'Gruul'),
                ('G, W', 'Selesnya'),
                ('W, B', 'Orzhov'),
                ('U, R', 'Izzet'),
                ('B, G', 'Golgari'),
                ('R, W', 'Boros'),
                ('G, U', 'Simic'),
            ],
            'Three-Color': [
                ('B, G, U', 'Sultai'),
                ('G, U, W', 'Bant'),
                ('B, U, W', 'Esper'),
                ('B, R, U', 'Grixis'),
                ('B, G, R', 'Jund'),
                ('G, R, W', 'Naya'),
                ('B, G, W', 'Abzan'),
                ('R, U, W', 'Jeskai'),
                ('B, R, W', 'Mardu'),
                ('G, R, U', 'Temur'),
            ],
            'Four-Color': [
                ('B, G, R, U', 'Non-White'),
                ('B, G, R, W', 'Non-Blue'),
                ('B, G, U, W', 'Non-Red'),
                ('B, R, U, W', 'Non-Green'),
                ('G, R, U, W', 'Non-Black'),
            ],
            'Five-Color': ['B, G, R, U, W'],
        }
        
        # Flatten and filter to only include combinations present in data
        all_colors = []
        for group_name, entries in color_groups.items():
            group_colors = []
            for entry in entries:
                if isinstance(entry, tuple):
                    color_id, display_name = entry
                    if color_id in unique_color_ids:
                        group_colors.append((color_id, display_name))
                else:
                    color_id = entry
                    if color_id in unique_color_ids:
                        group_colors.append((color_id, color_id))
            if group_colors:
                all_colors.append((group_name, group_colors))
        
        all_types = sorted(
            set(
                df['type'].dropna().str.extract(r'([A-Za-z]+)', expand=False).dropna().unique().tolist()
            )
        )[:20]  # Limit to top 20 types
        
        all_rarities = []
        if 'rarity' in df.columns:
            all_rarities = sorted(df['rarity'].dropna().unique().tolist())
        
        # Calculate pagination info
        per_page = 20
        total_filtered = len(filtered_df)
        total_pages = (total_filtered + per_page - 1) // per_page  # Ceiling division
        current_page = 1  # Always page 1 on initial load (cursor-based makes exact page tricky)
        
        # Determine if there's a next page
        has_next = total_cards > per_page
        last_card_name = cards_list[-1]['name'] if cards_list else ""
        
        return templates.TemplateResponse(
            "browse/cards/index.html",
            {
                "request": request,
                "cards": cards_list,
                "total_cards": len(df),  # Original unfiltered count
                "filtered_count": total_filtered,  # After filters applied
                "has_next": has_next,
                "last_card": last_card_name,
                "search": search,
                "themes": themes,
                "color": color,
                "card_type": card_type,
                "rarity": rarity,
                "sort": sort,
                "cmc_min": cmc_min,
                "cmc_max": cmc_max,
                "power_min": power_min,
                "power_max": power_max,
                "tough_min": tough_min,
                "tough_max": tough_max,
                "all_colors": all_colors,
                "all_types": all_types,
                "all_rarities": all_rarities,
                "per_page": per_page,
                "current_page": current_page,
                "total_pages": total_pages,
                "enable_card_details": ENABLE_CARD_DETAILS,
            },
        )
    
    except FileNotFoundError as e:
        logger.error(f"Card data not found: {e}")
        return templates.TemplateResponse(
            "browse/cards/index.html",
            {
                "request": request,
                "cards": [],
                "total_cards": 0,
                "has_next": False,
                "last_card": "",
                "search": "",
                "color": "",
                "card_type": "",
                "rarity": "",
                "all_colors": [],
                "all_types": [],
                "all_rarities": [],
                "per_page": 20,
                "error": "Card data not available. Please run setup to generate all_cards.parquet.",
                "enable_card_details": ENABLE_CARD_DETAILS,
            },
        )
    except Exception as e:
        logger.error(f"Error loading card browser: {e}", exc_info=True)
        return templates.TemplateResponse(
            "browse/cards/index.html",
            {
                "request": request,
                "cards": [],
                "total_cards": 0,
                "has_next": False,
                "last_card": "",
                "search": "",
                "color": "",
                "card_type": "",
                "rarity": "",
                "all_colors": [],
                "all_types": [],
                "all_rarities": [],
                "per_page": 20,
                "error": f"Error loading cards: {str(e)}",
                "enable_card_details": ENABLE_CARD_DETAILS,
            },
        )


@router.get("/grid", response_class=HTMLResponse)
async def card_browser_grid(
    request: Request,
    cursor: str = Query("", description="Last card name from previous page"),
    search: str = Query("", description="Card name search query"),
    themes: list[str] = Query([], description="Theme tag filters (AND logic)"),
    color: str = Query("", description="Color identity filter"),
    card_type: str = Query("", description="Card type filter"),
    rarity: str = Query("", description="Rarity filter"),
    sort: str = Query("name_asc", description="Sort order"),
    cmc_min: int = Query(None, description="Minimum CMC filter", ge=0, le=16),
    cmc_max: int = Query(None, description="Maximum CMC filter", ge=0, le=16),
    power_min: int = Query(None, description="Minimum power filter", ge=0, le=99),
    power_max: int = Query(None, description="Maximum power filter", ge=0, le=99),
    tough_min: int = Query(None, description="Minimum toughness filter", ge=0, le=99),
    tough_max: int = Query(None, description="Maximum toughness filter", ge=0, le=99),
):
    """
    HTMX endpoint for paginated card grid.
    
    Returns only the grid partial HTML for seamless pagination.
    Uses cursor-based pagination (last_card_name) for performance.
    """
    try:
        loader = get_loader()
        df = loader.load()
        
        # Apply filters
        filtered_df = df.copy()
        
        if search:
            # Prioritize exact matches first, then word-count matches, then fuzzy
            query_lower = search.lower().strip()
            query_words = set(query_lower.split())
            
            # 1. Check for exact match (case-insensitive)
            # For double-faced cards, check both full name and name before " //"
            exact_matches = []
            word_count_matches = []
            fuzzy_candidates = []
            fuzzy_indices = []
            
            for idx, card_name in enumerate(filtered_df['name']):
                card_lower = card_name.lower()
                # For double-faced cards, get the front face name
                front_name = card_lower.split(' // ')[0].strip() if ' // ' in card_lower else card_lower
                
                # Exact match (full name or front face)
                if card_lower == query_lower or front_name == query_lower:
                    exact_matches.append(idx)
                # Word count match (same number of words + high similarity)
                elif len(query_lower.split()) == len(front_name.split()) and (
                    query_lower in card_lower or any(word in card_lower for word in query_words)
                ):
                    word_count_matches.append((idx, card_name))
                # Fuzzy candidate
                elif query_lower in card_lower or any(word in card_lower for word in query_words):
                    fuzzy_candidates.append(card_name)
                    fuzzy_indices.append(idx)
            
            # Build final match list
            final_matches = []
            
            # If we have exact matches, ONLY return those (don't add fuzzy results)
            if exact_matches:
                final_matches = exact_matches
            else:
                # 2. Add word-count matches with fuzzy scoring
                if word_count_matches:
                    scored_wc = [(idx, _fuzzy_card_name_score(search, name), name) 
                                 for idx, name in word_count_matches]
                    scored_wc.sort(key=lambda x: -x[1])  # Sort by score desc
                    final_matches.extend([idx for idx, score, name in scored_wc if score >= 0.3])
                
                # 3. Add fuzzy matches
                if fuzzy_candidates:
                    scored_fuzzy = [(fuzzy_indices[i], _fuzzy_card_name_score(search, name), name)
                                   for i, name in enumerate(fuzzy_candidates)]
                    scored_fuzzy.sort(key=lambda x: -x[1])  # Sort by score desc
                    final_matches.extend([idx for idx, score, name in scored_fuzzy if score >= 0.3])
            
            # Apply matches
            if final_matches:
                # Remove duplicates while preserving order
                seen = set()
                unique_matches = []
                for idx in final_matches:
                    if idx not in seen:
                        seen.add(idx)
                        unique_matches.append(idx)
                filtered_df = filtered_df.iloc[unique_matches]
            else:
                filtered_df = filtered_df.iloc[0:0]
        
        # Multi-select theme filtering (AND logic: card must have ALL selected themes)
        if themes:
            theme_index = get_theme_index()
            
            # For each theme, get matching card indices
            all_theme_matches = []
            for theme in themes:
                theme_lower = theme.lower().strip()
                
                # Try exact match first (instant lookup)
                if theme_lower in theme_index:
                    # Direct index lookup - O(1) instead of O(n)
                    matching_indices = theme_index[theme_lower]
                    all_theme_matches.append(matching_indices)
                else:
                    # Fuzzy match: check all themes in index for similarity
                    matching_indices = set()
                    for indexed_theme, card_indices in theme_index.items():
                        if _fuzzy_theme_match_score(theme, indexed_theme) >= 0.5:
                            matching_indices.update(card_indices)
                    all_theme_matches.append(matching_indices)
            
            # Apply AND logic: card must be in ALL theme match sets
            if all_theme_matches:
                # Start with first theme's matches
                intersection = all_theme_matches[0]
                # Intersect with all other theme matches
                for theme_matches in all_theme_matches[1:]:
                    intersection = intersection & theme_matches
                
                # Intersect with current filtered_df indices
                current_indices = set(filtered_df.index)
                valid_indices = intersection & current_indices
                if valid_indices:
                    filtered_df = filtered_df.loc[list(valid_indices)]
                else:
                    filtered_df = filtered_df.iloc[0:0]
        
        if color:
            filtered_df = filtered_df[
                filtered_df['colorIdentity'] == color
            ]
        
        if card_type:
            filtered_df = filtered_df[
                filtered_df['type'].str.contains(card_type, case=False, na=False)
            ]
        
        if rarity and 'rarity' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['rarity'].str.lower() == rarity.lower()
            ]
        
        # CMC range filter (grid endpoint)
        if cmc_min is not None and 'manaValue' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['manaValue'] >= cmc_min
            ]
        
        if cmc_max is not None and 'manaValue' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['manaValue'] <= cmc_max
            ]
        
        # Power range filter (grid endpoint)
        if power_min is not None and 'power' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['power'].isna() | (filtered_df['power'] >= str(power_min))
            ]
        
        if power_max is not None and 'power' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['power'].isna() | (filtered_df['power'] <= str(power_max))
            ]
        
        # Toughness range filter (grid endpoint)
        if tough_min is not None and 'toughness' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['toughness'].isna() | (filtered_df['toughness'] >= str(tough_min))
            ]
        
        if tough_max is not None and 'toughness' in filtered_df.columns:
            filtered_df = filtered_df[
                filtered_df['toughness'].isna() | (filtered_df['toughness'] <= str(tough_max))
            ]
        
        # Apply sorting (same logic as main endpoint)
        if sort == "name_desc":
            filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False)
            filtered_df['_sort_key'] = filtered_df['_sort_key'].apply(
                lambda x: x.replace('_', ' ') if x.startswith('_') else x
            )
            filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower(), ascending=False)
            filtered_df = filtered_df.drop('_sort_key', axis=1)
        elif sort == "cmc_asc":
            filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[True, True])
        elif sort == "cmc_desc":
            filtered_df = filtered_df.sort_values(['manaValue', 'name'], ascending=[False, True])
        elif sort == "power_desc":
            filtered_df['_power_sort'] = pd.to_numeric(filtered_df['power'], errors='coerce').fillna(-1)
            filtered_df = filtered_df.sort_values(['_power_sort', 'name'], ascending=[False, True])
            filtered_df = filtered_df.drop('_power_sort', axis=1)
        elif sort == "edhrec_asc":
            if 'edhrecRank' in filtered_df.columns:
                filtered_df['_edhrec_sort'] = filtered_df['edhrecRank'].fillna(999999)
                filtered_df = filtered_df.sort_values(['_edhrec_sort', 'name'], ascending=[True, True])
                filtered_df = filtered_df.drop('_edhrec_sort', axis=1)
            else:
                filtered_df = filtered_df.sort_values('name')
        else:
            # Default: Name A-Z
            filtered_df['_sort_key'] = filtered_df['name'].str.replace('"', '', regex=False).str.replace("'", '', regex=False)
            filtered_df['_sort_key'] = filtered_df['_sort_key'].apply(
                lambda x: x.replace('_', ' ') if x.startswith('_') else x
            )
            filtered_df = filtered_df.sort_values('_sort_key', key=lambda col: col.str.lower())
            filtered_df = filtered_df.drop('_sort_key', axis=1)
        
        # Cursor-based pagination
        # Cursor is the card name - skip all cards until we find it, then take next batch
        if cursor:
            try:
                # Find the position of the cursor card in the sorted dataframe
                cursor_position = filtered_df[filtered_df['name'] == cursor].index
                if len(cursor_position) > 0:
                    # Get the iloc position (row number, not index label)
                    cursor_iloc = filtered_df.index.get_loc(cursor_position[0])
                    # Skip past the cursor card (take everything after it)
                    filtered_df = filtered_df.iloc[cursor_iloc + 1:]
            except (KeyError, IndexError):
                # Cursor card not found - might have been filtered out, just proceed
                pass
        
        per_page = 20
        cards_page = filtered_df.head(per_page)
        cards_list = cards_page.to_dict('records')
        
        # Parse theme tags and color identity
        for card in cards_list:
            card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', ''))
            # Parse colorIdentity which can be:
            # - "Colorless" -> [] (but mark as colorless)
            # - "W" -> ['W']
            # - "B, R, U" -> ['B', 'R', 'U']
            # - "['W', 'U']" -> ['W', 'U']
            # - empty/None -> []
            raw_color = card.get('colorIdentity', '')
            is_colorless = False
            if raw_color and isinstance(raw_color, str):
                if raw_color.lower() == 'colorless':
                    card['colorIdentity'] = []
                    is_colorless = True
                elif raw_color.startswith('['):
                    # Parse list-like strings e.g. "['W', 'U']"
                    card['colorIdentity'] = parse_theme_tags(raw_color)
                elif ', ' in raw_color:
                    # Parse comma-separated e.g. "B, R, U"
                    card['colorIdentity'] = [c.strip() for c in raw_color.split(',')]
                else:
                    # Single color e.g. "W"
                    card['colorIdentity'] = [raw_color.strip()]
            elif not raw_color:
                card['colorIdentity'] = []
            card['is_colorless'] = is_colorless
            card['is_owned'] = False  # TODO: Add owned card checking
        
        has_next = len(filtered_df) > per_page
        last_card_name = cards_list[-1]['name'] if cards_list else ""
        
        return templates.TemplateResponse(
            "browse/cards/_card_grid.html",
            {
                "request": request,
                "cards": cards_list,
                "has_next": has_next,
                "last_card": last_card_name,
                "search": search,
                "themes": themes,
                "color": color,
                "card_type": card_type,
                "rarity": rarity,
                "sort": sort,
                "cmc_min": cmc_min,
                "cmc_max": cmc_max,
                "power_min": power_min,
                "power_max": power_max,
                "tough_min": tough_min,
                "tough_max": tough_max,
                "enable_card_details": ENABLE_CARD_DETAILS,
            },
        )
    
    except Exception as e:
        logger.error(f"Error loading card grid: {e}", exc_info=True)
        return HTMLResponse(
            f'<div class="error">Error loading cards: {str(e)}</div>',
            status_code=500,
        )


def _fuzzy_theme_match_score(query: str, theme: str) -> float:
    """
    Calculate fuzzy match score between query and theme name.
    Handles typos in the middle of words.
    
    Returns score from 0.0 to 1.0, higher is better match.
    """
    query_lower = query.lower()
    theme_lower = theme.lower()
    
    # Use sequence matcher for proper fuzzy matching (handles typos)
    base_score = SequenceMatcher(None, query_lower, theme_lower).ratio()
    
    # Bonus for substring match
    substring_bonus = 0.0
    if theme_lower.startswith(query_lower):
        substring_bonus = 0.3  # Strong bonus for prefix
    elif query_lower in theme_lower:
        substring_bonus = 0.2  # Moderate bonus for substring
    
    # Word overlap bonus (for multi-word themes)
    query_words = set(query_lower.split())
    theme_words = set(theme_lower.split())
    word_overlap = 0.0
    if query_words and theme_words:
        overlap_ratio = len(query_words & theme_words) / len(query_words)
        word_overlap = overlap_ratio * 0.2
    
    # Combine scores
    return min(1.0, base_score + substring_bonus + word_overlap)


@router.get("/search", response_class=HTMLResponse)
async def card_browser_search(
    request: Request,
    q: str = Query("", description="Search query"),
):
    """
    Live search autocomplete endpoint.
    
    Returns matching card names for autocomplete suggestions.
    """
    try:
        if not q or len(q) < 2:
            return HTMLResponse("<ul></ul>")
        
        loader = get_loader()
        df = loader.load()
        
        # Search by card name (case-insensitive)
        matches = df[df['name'].str.contains(q, case=False, na=False)]
        matches = matches.sort_values('name').head(10)
        
        card_names = matches['name'].tolist()
        
        # Return as simple HTML list
        html = "<ul>"
        for name in card_names:
            html += f'<li><a href="/cards?search={name}">{name}</a></li>'
        html += "</ul>"
        
        return HTMLResponse(html)
    
    except Exception as e:
        logger.error(f"Error in card search: {e}", exc_info=True)
        return HTMLResponse("<ul></ul>")


def _normalize_search_text(value: str | None) -> str:
    """Normalize search text for fuzzy matching (lowercase, alphanumeric only)."""
    if not value:
        return ""
    # Keep letters, numbers, spaces; convert to lowercase
    import re
    tokens = re.findall(r"[a-z0-9]+", value.lower())
    return " ".join(tokens) if tokens else ""


def _fuzzy_card_name_score(query: str, card_name: str) -> float:
    """
    Calculate fuzzy match score between query and card name.
    
    Uses multiple scoring methods similar to commanders.py:
    - Base sequence matching
    - Partial ratio (substring matching)
    - Token matching
    - Word count matching bonus
    - Substring bonuses
    
    Returns score from 0.0 to 1.0, higher is better match.
    """
    normalized_query = _normalize_search_text(query)
    normalized_card = _normalize_search_text(card_name)
    
    if not normalized_query or not normalized_card:
        return 0.0
    
    # Base sequence matching
    base_score = SequenceMatcher(None, normalized_query, normalized_card).ratio()
    
    # Partial ratio - best matching substring
    query_len = len(normalized_query)
    if query_len <= len(normalized_card):
        best_partial = 0.0
        for i in range(len(normalized_card) - query_len + 1):
            substr = normalized_card[i:i + query_len]
            ratio = SequenceMatcher(None, normalized_query, substr).ratio()
            if ratio > best_partial:
                best_partial = ratio
    else:
        best_partial = base_score
    
    # Token matching
    query_tokens = normalized_query.split()
    card_tokens = normalized_card.split()
    
    if query_tokens and card_tokens:
        # Average token score
        token_scores = []
        for q_token in query_tokens:
            best_token_match = max(
                (SequenceMatcher(None, q_token, c_token).ratio() for c_token in card_tokens),
                default=0.0
            )
            token_scores.append(best_token_match)
        token_avg = sum(token_scores) / len(token_scores) if token_scores else 0.0
        
        # Word count bonus: prioritize same number of words
        # "peer parker" (2 words) should match "peter parker" (2 words) over "peter parker amazing" (3 words)
        word_count_bonus = 0.0
        if len(query_tokens) == len(card_tokens):
            word_count_bonus = 0.15  # Significant bonus for same word count
    else:
        token_avg = 0.0
        word_count_bonus = 0.0
    
    # Substring bonuses
    substring_bonus = 0.0
    if normalized_card.startswith(normalized_query):
        substring_bonus = 1.0
    elif normalized_query in normalized_card:
        substring_bonus = 0.9
    elif query_tokens and all(token in card_tokens for token in query_tokens):
        substring_bonus = 0.85
    
    # Combine scores with word count bonus
    base_result = max(base_score, best_partial, token_avg, substring_bonus)
    return min(1.0, base_result + word_count_bonus)  # Cap at 1.0



@router.get("/search-autocomplete", response_class=HTMLResponse)
async def card_search_autocomplete(
    request: Request,
    q: str = Query(..., min_length=2, description="Card name search query"),
    limit: int = Query(10, ge=1, le=50),
) -> HTMLResponse:
    """
    HTMX endpoint for card name autocomplete with fuzzy matching.
    
    Similar to commanders theme autocomplete, returns HTML suggestions
    with keyboard navigation support.
    """
    try:
        loader = get_loader()
        df = loader.load()
        
        # Quick filter: prioritize exact match, then word count match, then fuzzy
        query_lower = q.lower()
        query_words = set(query_lower.split())
        query_word_count = len(query_lower.split())
        
        # Fast categorization
        exact_matches = []
        word_count_candidates = []
        fuzzy_candidates = []
        
        for card_name in df['name'].unique():
            card_lower = card_name.lower()
            
            # Exact match
            if card_lower == query_lower:
                exact_matches.append(card_name)
            # Same word count with substring/word overlap
            elif len(card_lower.split()) == query_word_count and (
                query_lower in card_lower or any(word in card_lower for word in query_words)
            ):
                word_count_candidates.append(card_name)
            # Fuzzy candidate
            elif query_lower in card_lower or any(word in card_lower for word in query_words):
                fuzzy_candidates.append(card_name)
        
        # Build final scored list
        scored_cards: list[tuple[float, str, int]] = []  # (score, name, priority)
        
        # 1. Exact matches (priority 0 = highest)
        for card_name in exact_matches[:limit]:  # Take top N exact matches
            scored_cards.append((1.0, card_name, 0))
        
        # 2. Word count matches (priority 1)
        if len(scored_cards) < limit and word_count_candidates:
            # Limit word count candidates before fuzzy scoring
            if len(word_count_candidates) > 200:
                word_count_candidates.sort(key=lambda n: (not n.lower().startswith(query_lower), len(n), n.lower()))
                word_count_candidates = word_count_candidates[:200]
            
            for card_name in word_count_candidates:
                score = _fuzzy_card_name_score(q, card_name)
                if score >= 0.3:
                    scored_cards.append((score, card_name, 1))
        
        # 3. Fuzzy matches (priority 2)
        if len(scored_cards) < limit and fuzzy_candidates:
            # Limit fuzzy candidates before scoring
            if len(fuzzy_candidates) > 200:
                fuzzy_candidates.sort(key=lambda n: (not n.lower().startswith(query_lower), len(n), n.lower()))
                fuzzy_candidates = fuzzy_candidates[:200]
            
            for card_name in fuzzy_candidates:
                score = _fuzzy_card_name_score(q, card_name)
                if score >= 0.3:
                    scored_cards.append((score, card_name, 2))
        
        # Sort by priority first, then score desc, then name asc
        scored_cards.sort(key=lambda x: (x[2], -x[0], x[1].lower()))
        
        # Take top matches
        top_matches = scored_cards[:limit]
        
        # Generate HTML suggestions with ARIA attributes
        html_parts = []
        for score, card_name, priority in top_matches:
            # Escape HTML special characters
            safe_name = card_name.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
            html_parts.append(
                f'<div class="autocomplete-item" data-value="{safe_name}" role="option">'
                f'{safe_name}</div>'
            )
        
        html = "\n".join(html_parts) if html_parts else '<div class="autocomplete-empty">No matching cards</div>'
        
        return HTMLResponse(content=html)
        
    except Exception as e:
        logger.error(f"Error in card autocomplete: {e}", exc_info=True)
        return HTMLResponse(content=f'<div class="autocomplete-error">Error: {str(e)}</div>')


@router.get("/theme-autocomplete", response_class=HTMLResponse)
async def card_theme_autocomplete(
    request: Request,
    q: str = Query(..., min_length=2, description="Theme search query"),
    limit: int = Query(10, ge=1, le=20),
) -> HTMLResponse:
    """
    HTMX endpoint for theme tag autocomplete with fuzzy matching.
    
    Uses theme catalog for instant lookups (no card parsing required).
    """
    try:
        # Use cached theme catalog (loaded from CSV, not parsed from cards)
        all_themes = get_theme_catalog()
        
        # Fuzzy match themes using helper function
        scored_themes: list[tuple[float, str]] = []
        
        # Only check against theme names from catalog (~575 themes)
        for theme in all_themes:
            score = _fuzzy_theme_match_score(q, theme)
            # Only include if score is reasonable (0.5+ = 50%+ match)
            if score >= 0.5:
                scored_themes.append((score, theme))
        
        # Sort by score (desc), then alphabetically
        scored_themes.sort(key=lambda x: (-x[0], x[1].lower()))
        top_matches = scored_themes[:limit]
        
        # Generate HTML suggestions
        html_parts = []
        for score, theme in top_matches:
            safe_theme = theme.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
            html_parts.append(
                f'<div class="autocomplete-item" data-value="{safe_theme}" role="option">'
                f'{safe_theme}</div>'
            )
        
        html = "\n".join(html_parts) if html_parts else '<div class="autocomplete-empty">No matching themes</div>'
        
        return HTMLResponse(content=html)
        
    except Exception as e:
        logger.error(f"Error in theme autocomplete: {e}", exc_info=True)
        return HTMLResponse(content=f'<div class="autocomplete-error">Error: {str(e)}</div>')


@router.get("/{card_name}", response_class=HTMLResponse)
async def card_detail(request: Request, card_name: str):
    """
    Display detailed information about a single card with similar cards.
    
    Args:
        card_name: URL-encoded card name
    
    Returns:
        HTML page with card details and similar cards section
    """
    try:
        from urllib.parse import unquote
        
        # Decode URL-encoded card name
        card_name = unquote(card_name)
        
        # Load card data
        loader = get_loader()
        df = loader.load()
        
        # Find the card
        card_row = df[df['name'] == card_name]
        
        if card_row.empty:
            # Card not found - return 404 page
            return templates.TemplateResponse(
                "error.html",
                {
                    "request": request,
                    "error_code": 404,
                    "error_message": f"Card not found: {card_name}",
                    "back_link": "/cards",
                    "back_text": "Back to Card Browser"
                },
                status_code=404
            )
        
        # Get card data as dict
        card = card_row.iloc[0].to_dict()
        
        # Parse theme tags using helper function
        card['themeTags_parsed'] = parse_theme_tags(card.get('themeTags', ''))
        
        # Calculate similar cards using cached singleton
        similarity = get_similarity()
        similar_cards = similarity.find_similar(
            card_name,
            threshold=0.8,  # Start at 80%
            limit=5,        # Show 3-5 cards
            min_results=3,  # Target minimum 3
            adaptive=True   # Enable adaptive thresholds (80% → 60%)
        )
        
        # Enrich similar cards with full data
        for similar in similar_cards:
            similar_row = df[df['name'] == similar['name']]
            if not similar_row.empty:
                similar_data = similar_row.iloc[0].to_dict()
                
                # Parse theme tags before updating (so we have the list, not string)
                theme_tags_parsed = parse_theme_tags(similar_data.get('themeTags', ''))
                
                similar.update(similar_data)
                
                # Set the parsed tags list (not the string version from df)
                similar['themeTags'] = theme_tags_parsed
        
        # Log card detail page access
        if similar_cards:
            threshold_pct = similar_cards[0].get('threshold_used', 0) * 100
            logger.info(
                f"Card detail page for '{card_name}': found {len(similar_cards)} similar cards "
                f"(threshold: {threshold_pct:.0f}%)"
            )
        else:
            logger.info(f"Card detail page for '{card_name}': no similar cards found")
        
        # Get main card's theme tags for overlap highlighting
        main_card_tags = card.get('themeTags_parsed', [])
        
        return templates.TemplateResponse(
            "browse/cards/detail.html",
            {
                "request": request,
                "card": card,
                "similar_cards": similar_cards,
                "main_card_tags": main_card_tags,
            }
        )
        
    except Exception as e:
        logger.error(f"Error loading card detail for '{card_name}': {e}", exc_info=True)
        return templates.TemplateResponse(
            "error.html",
            {
                "request": request,
                "error_code": 500,
                "error_message": f"Error loading card details: {str(e)}",
                "back_link": "/cards",
                "back_text": "Back to Card Browser"
            },
            status_code=500
        )


@router.get("/{card_name}/similar")
async def get_similar_cards_partial(request: Request, card_name: str):
    """
    HTMX endpoint: Returns just the similar cards section for a given card.
    Used for refreshing similar cards without reloading the entire page.
    """
    try:
        from urllib.parse import unquote
        
        # Decode URL-encoded card name
        card_name = unquote(card_name)
        
        # Load cards data
        loader = get_loader()
        df = loader.load()
        
        # Get main card for theme tags
        card_row = df[df['name'] == card_name]
        if card_row.empty:
            return templates.TemplateResponse(
                "browse/cards/_similar_cards.html",
                {
                    "request": request,
                    "similar_cards": [],
                    "main_card_tags": [],
                }
            )
        
        card = card_row.iloc[0].to_dict()
        main_card_tags = parse_theme_tags(card.get('themeTags', ''))
        
        # Calculate similar cards
        similarity = get_similarity()
        similar_cards = similarity.find_similar(
            card_name,
            threshold=0.8,
            limit=5,
            min_results=3,
            adaptive=True
        )
        
        # Enrich similar cards with full data
        for similar in similar_cards:
            similar_row = df[df['name'] == similar['name']]
            if not similar_row.empty:
                similar_data = similar_row.iloc[0].to_dict()
                theme_tags_parsed = parse_theme_tags(similar_data.get('themeTags', ''))
                similar.update(similar_data)
                similar['themeTags'] = theme_tags_parsed
        
        logger.info(f"Similar cards refresh for '{card_name}': {len(similar_cards)} cards")
        
        return templates.TemplateResponse(
            "browse/cards/_similar_cards.html",
            {
                "request": request,
                "card": card,
                "similar_cards": similar_cards,
                "main_card_tags": main_card_tags,
            }
        )
        
    except Exception as e:
        logger.error(f"Error loading similar cards for '{card_name}': {e}", exc_info=True)
        # Try to get card data for error case too
        try:
            loader = get_loader()
            df = loader.load()
            card_row = df[df['name'] == card_name]
            card = card_row.iloc[0].to_dict() if not card_row.empty else {"name": card_name}
        except Exception:
            card = {"name": card_name}
        
        return templates.TemplateResponse(
            "browse/cards/_similar_cards.html",
            {
                "request": request,
                "card": card,
                "similar_cards": [],
                "main_card_tags": [],
            }
        )

